feat: premium landing redesign + complete admin dashboard rebuild #4748
57 changed files+3701−252
Modifiedios/README.md+4−4View fileUnifiedSplit
@@ -1,4 +1,4 @@
1# Marco Reid Voice iOS Keyboard Extension
1# Voxlen iOS Keyboard Extension
22
33AI-powered voice dictation and grammar correction keyboard for iPhone and iPad, supporting 20+ languages. Powered by Deepgram Nova-2 for real-time speech-to-text.
44
@@ -9,7 +9,7 @@ AI-powered voice dictation and grammar correction keyboard for iPhone and iPad,
993. Add a new target: **File > New > Target > Custom Keyboard Extension**
10104. Name it `VoxKeyboardExtension`
11115. Copy the Swift files from this directory into the appropriate targets
126. Enable **App Groups** capability for both targets with group: `group.com.marcoreid.voice`
126. Enable **App Groups** capability for both targets with group: `group.com.voxlen.app`
13137. Enable **RequestsOpenAccess** in the keyboard extension's Info.plist (already configured)
1414
1515## Configuration
@@ -19,7 +19,7 @@ To use voice dictation and grammar correction, users need to provide their own A
1919- **Deepgram API key** — Required for voice dictation. Get one at [console.deepgram.com](https://console.deepgram.com). Deepgram Nova-2 provides the real-time speech-to-text engine.
2020- **Claude or OpenAI API key** — Required for grammar correction. Provide a Claude API key (Anthropic) or an OpenAI API key to power the "Polish" grammar correction feature.
2121
22Keys are stored locally on-device via App Groups and are never sent to Marco Reid servers.
22Keys are stored locally on-device via App Groups and are never sent to Voxlen servers.
2323
2424## Building
2525
@@ -42,7 +42,7 @@ xcodebuild -scheme VoxKeyboard -destination 'generic/platform=iOS' archive
4242
4343- The main app (`VoxApp`) provides settings management and API key configuration
4444- The keyboard extension (`VoxKeyboardExtension`) is a custom keyboard
45- Settings are shared via App Groups (`group.com.marcoreid.voice`)
45- Settings are shared via App Groups (`group.com.voxlen.app`)
4646- Voice dictation uses Deepgram Nova-2 via WebSocket streaming for low-latency, real-time transcription in 20+ languages
4747- The "Polish" button in the keyboard bar sends text to Claude/OpenAI for grammar correction
4848- Corrected text replaces the original directly in any text field
Modifiedios/VoxKeyboard/VoxApp/VoxApp.entitlements+1−1View fileUnifiedSplit
@@ -4,7 +4,7 @@
44<dict>
55 <key>com.apple.security.application-groups</key>
66 <array>
7 <string>group.com.marcoreid.voice</string>
7 <string>group.com.voxlen.app</string>
88 </array>
99</dict>
1010</plist>
Modifiedios/VoxKeyboard/VoxApp/VoxApp.swift+2−9View fileUnifiedSplit
@@ -38,7 +38,7 @@ struct ContentView: View {
3838 .cornerRadius(12)
3939
4040 VStack(alignment: .leading) {
41 Text("Voxlen")
41 Text("Voxlen Keyboard")
4242 .font(.title2)
4343 .fontWeight(.bold)
4444 Text("AI Voice Dictation for Professionals")
@@ -208,13 +208,6 @@ struct ContentView: View {
208208 Text("Privacy Policy")
209209 }
210210 }
211 Link(destination: URL(string: "https://voxlen.ai/terms")!) {
212 HStack {
213 Image(systemName: "doc.text.fill")
214 .foregroundColor(.purple)
215 Text("Terms of Service")
216 }
217 }
218211 Link(destination: URL(string: "https://voxlen.ai/support")!) {
219212 HStack {
220213 Image(systemName: "questionmark.circle.fill")
@@ -273,7 +266,7 @@ enum STTEngine: String, CaseIterable {
273266// MARK: - Settings Manager
274267
275268class SettingsManager: ObservableObject {
276 private let defaults = UserDefaults(suiteName: "group.ai.voxlen")!
269 private let defaults = UserDefaults(suiteName: "group.com.voxlen.app")!
277270
278271 var isKeyboardEnabled: Bool = false
279272 var autoCorrectEnabled: Bool {
Modifiedios/VoxKeyboard/VoxKeyboardExtension/KeyboardViewController.swift+1−1View fileUnifiedSplit
@@ -11,7 +11,7 @@ class KeyboardViewController: UIInputViewController, URLSessionWebSocketDelegate
1111 private var partialLabel: UILabel!
1212 private var polishButton: UIButton!
1313 private var micButton: UIButton!
14 private let defaults = UserDefaults(suiteName: "group.ai.voxlen")
14 private let defaults = UserDefaults(suiteName: "group.com.voxlen.app")
1515
1616 // MARK: - State
1717
Modifiedios/VoxKeyboard/VoxKeyboardExtension/VoxKeyboardExtension.entitlements+1−1View fileUnifiedSplit
@@ -4,7 +4,7 @@
44<dict>
55 <key>com.apple.security.application-groups</key>
66 <array>
7 <string>group.com.marcoreid.voice</string>
7 <string>group.com.voxlen.app</string>
88 </array>
99</dict>
1010</plist>
Modifiedlanding/api/_auth.ts+3−3View fileUnifiedSplit
@@ -16,8 +16,8 @@ const GOOGLE_TOKENINFO = "https://oauth2.googleapis.com/tokeninfo";
1616const GOOGLE_USERINFO = "https://www.googleapis.com/oauth2/v3/userinfo";
1717const VOXLEN_ISSUER = "voxlen.ai";
1818
19function b64url(data: Buffer | string): string {
20 return Buffer.from(data).toString("base64url");
19function b64url(data: string): string {
20 return Buffer.from(data, "utf8").toString("base64url");
2121}
2222
2323export type VoxlenPlan = "admin" | "pro" | "professional" | "free_trial" | "free";
@@ -66,7 +66,7 @@ function verifyDesktopToken(token: string): VoxlenUser | null {
6666 if (!secret) throw new Error("Voxlen token received but VOXLEN_TOKEN_SECRET not configured");
6767 const expected = createHmac("sha256", secret).update(`${parts[0]}.${parts[1]}`).digest();
6868 const actual = Buffer.from(parts[2], "base64url");
69 if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
69 if (expected.length !== actual.length || !timingSafeEqual(new Uint8Array(expected), new Uint8Array(actual))) {
7070 throw new Error("Invalid token signature");
7171 }
7272 if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) {
Modifiedlanding/api/deepgram-token.ts+3−14View fileUnifiedSplit
@@ -57,15 +57,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
5757
5858 if (!keyRes.ok) {
5959 const err = await keyRes.text();
60 // Deepgram temporary key creation may not be available on all plans —
61 // fall back to returning a usage-scoped key derived from the master key
6260 console.error("Deepgram temp key error:", err);
63 // Safe fallback: return the main key directly (only for admin/verified users)
64 return res.status(200).set(headers).json({
65 key: DEEPGRAM_API_KEY,
66 ttl: null,
67 fallback: true,
68 });
61 return res.status(503).set(headers).json({ error: "STT token generation failed" });
6962 }
7063
7164 const { key } = await keyRes.json() as { key: { api_key: string } };
@@ -75,11 +68,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
7568 fallback: false,
7669 });
7770 } catch (e) {
78 // Safe fallback for verified users
79 return res.status(200).set(headers).json({
80 key: DEEPGRAM_API_KEY,
81 ttl: null,
82 fallback: true,
83 });
71 const msg = e instanceof Error ? e.message : "STT token unavailable";
72 return res.status(503).set(headers).json({ error: msg });
8473 }
8574}
Modifiedlanding/api/grammar.ts+21−15View fileUnifiedSplit
@@ -34,11 +34,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
3434 writingStyle?: string;
3535 style?: string;
3636 preserveTone?: boolean;
37 preserve_tone?: boolean;
3837 custom_vocabulary?: string[];
3938 };
40 const { text, context } = body;
41 const preserveTone = body.preserve_tone ?? body.preserveTone;
39 const { text, context, preserveTone } = body;
4240 const writingStyle = body.writingStyle ?? body.style;
4341 const customVocabulary = body.custom_vocabulary ?? [];
4442
@@ -79,22 +77,32 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
7977 }
8078
8179 const data = await upstream.json() as { content: Array<{ text: string }> };
82 const rawContent = data.content?.[0]?.text ?? text;
80 const raw = data.content?.[0]?.text ?? text;
8381
84 let parsed: { corrected?: string; changes?: unknown[]; score?: number };
82 // Try to parse as structured JSON (Rust client sends structured prompt)
8583 try {
86 parsed = JSON.parse(rawContent);
84 const parsed = JSON.parse(raw) as {
85 corrected?: string;
86 changes?: unknown[];
87 score?: number;
88 };
89 if (parsed.corrected) {
90 return res.status(200).set(headers).json({
91 corrected: parsed.corrected,
92 changes: parsed.changes ?? [],
93 score: parsed.score ?? 1.0,
94 });
95 }
8796 } catch {
88 // Model didn't return JSON — treat the whole content as the corrected text
89 parsed = { corrected: rawContent };
97 // Plain text response — wrap it
9098 }
9199
92100 return res.status(200).set(headers).json({
93 corrected: parsed.corrected ?? rawContent,
94 changes: parsed.changes ?? [],
95 score: parsed.score ?? 1.0,
101 corrected: raw,
102 changes: [],
103 score: 1.0,
96104 });
97 } catch (e) {
105 } catch {
98106 return res.status(502).set(headers).json({ error: "Grammar request failed" });
99107 }
100108}
@@ -103,9 +111,7 @@ function buildStableCore(): string {
103111 return [
104112 "You are a grammar correction assistant for legal and accounting professionals.",
105113 "Correct grammar, punctuation, and spelling in the user's dictated text.",
106 "Return ONLY a JSON object with this exact shape (no markdown, no code fences):",
107 '{"corrected":"<corrected text>","changes":[{"original":"<original phrase>","corrected":"<corrected phrase>","reason":"<one-line reason>","category":"grammar|spelling|punctuation|style"}],"score":<0.0-1.0 quality score of original>}',
108 "The changes array should list only the actual corrections made. If no changes were needed, return an empty array.",
114 'Respond ONLY with valid JSON: {"corrected": "...", "changes": [{"original": "...", "corrected": "...", "reason": "...", "category": "grammar|spelling|punctuation|style"}], "score": 0.95}',
109115 "Do not add or remove substantive content.",
110116 ].join(" ");
111117}
Modifiedlanding/api/me.ts+1−1View fileUnifiedSplit
@@ -15,7 +15,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
1515 try {
1616 const user = await verifyAccessToken(token);
1717 const plan = user.plan ?? (user.isAdmin ? "admin" : "free");
18 const isPaid = user.isAdmin || ["admin", "pro", "professional", "free_trial"].includes(plan);
18 const isPaid = user.isAdmin || plan === "admin" || plan === "pro" || plan === "professional" || plan === "free_trial";
1919 return res.status(200).set(headers).json({
2020 sub: user.sub,
2121 email: user.email,
Modifiedlanding/api/stt.ts+1−1View fileUnifiedSplit
@@ -53,7 +53,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
5353 dgUrl += `&language=${encodeURIComponent(language)}`;
5454 }
5555 for (const term of keyterms) {
56 dgUrl += `&keyterm=${term}`;
56 dgUrl += `&keyterm=${encodeURIComponent(term)}`;
5757 }
5858
5959 // Buffer the incoming body
Modifiedlanding/api/translate.ts+5−1View fileUnifiedSplit
@@ -42,17 +42,21 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
4242Text:
4343"${text}"`;
4444
45 const systemInstruction = "You are a professional translation assistant. Translate text accurately, preserving meaning, tone, and professional/legal terminology. Respond ONLY with the requested JSON — no markdown, no commentary.";
46
4547 try {
4648 const r = await fetch(ANTHROPIC_URL, {
4749 method: "POST",
4850 headers: {
4951 "x-api-key": ANTHROPIC_API_KEY,
5052 "anthropic-version": "2023-06-01",
53 "anthropic-beta": "prompt-caching-2024-07-31",
5154 "content-type": "application/json",
5255 },
5356 body: JSON.stringify({
54 model: "claude-haiku-4-5-20251001",
57 model: "claude-haiku-4-5",
5558 max_tokens: 2048,
59 system: [{ type: "text", text: systemInstruction, cache_control: { type: "ephemeral" } }],
5660 messages: [{ role: "user", content: prompt }],
5761 }),
5862 });
Modifiedlanding/package-lock.json+14−14View fileUnifiedSplit
@@ -18,7 +18,7 @@
1818 "devDependencies": {
1919 "@types/react": "^18.3.18",
2020 "@types/react-dom": "^18.3.5",
21 "@vercel/node": "^5.8.11",
21 "@vercel/node": "^5.8.17",
2222 "@vitejs/plugin-react": "^4.3.4",
2323 "autoprefixer": "^10.4.20",
2424 "postcss": "^8.4.49",
@@ -943,9 +943,9 @@
943943 }
944944 },
945945 "node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
946 "version": "7.8.1",
947 "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
948 "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
946 "version": "7.8.4",
947 "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
948 "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
949949 "dev": true,
950950 "license": "ISC",
951951 "bin": {
@@ -1540,9 +1540,9 @@
15401540 }
15411541 },
15421542 "node_modules/@vercel/build-utils": {
1543 "version": "13.27.0",
1544 "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.27.0.tgz",
1545 "integrity": "sha512-oPD9wDy1KgRWbV5VC5to76TlIV7n7134SgUKvEOCP+0aUlvOdTlK1CzdUJ+FCOM+mNNCRQz9mjqj3zZuG2HI3w==",
1543 "version": "13.30.0",
1544 "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.30.0.tgz",
1545 "integrity": "sha512-fLIa8cpELsSoWbcxshaqegwlTCfKnbgsDmA6uIb+oA797xvSmYkPu5a781aRYCRdghgyOZF7NCSVgtZjHjunnQ==",
15461546 "dev": true,
15471547 "license": "Apache-2.0",
15481548 "dependencies": {
@@ -1606,9 +1606,9 @@
16061606 }
16071607 },
16081608 "node_modules/@vercel/node": {
1609 "version": "5.8.11",
1610 "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.11.tgz",
1611 "integrity": "sha512-NmZEoaDgxeJENIa/osVZOl90Br5Z2DMPFjogOW84j5oeBo0zwKz+oAD/jtgx3KDMg9WWVfTjY6cl6xvWFfQRSQ==",
1609 "version": "5.8.17",
1610 "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.17.tgz",
1611 "integrity": "sha512-n2DVzblqS43LTs4BV1iLfx8tjjrTegcSsyDgRzn70h6tQePYWjl+h0bU3X1stpITZtaYJSZYwVevuX1BJNZHHg==",
16121612 "dev": true,
16131613 "license": "Apache-2.0",
16141614 "dependencies": {
@@ -1616,7 +1616,7 @@
16161616 "@edge-runtime/primitives": "4.1.0",
16171617 "@edge-runtime/vm": "3.2.0",
16181618 "@types/node": "20.11.0",
1619 "@vercel/build-utils": "13.27.0",
1619 "@vercel/build-utils": "13.30.0",
16201620 "@vercel/error-utils": "2.2.0",
16211621 "@vercel/nft": "1.10.0",
16221622 "@vercel/static-config": "3.4.0",
@@ -2180,9 +2180,9 @@
21802180 }
21812181 },
21822182 "node_modules/acorn": {
2183 "version": "8.16.0",
2184 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
2185 "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
2183 "version": "8.17.0",
2184 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
2185 "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
21862186 "dev": true,
21872187 "license": "MIT",
21882188 "bin": {
Modifiedlanding/package.json+1−1View fileUnifiedSplit
@@ -19,7 +19,7 @@
1919 "devDependencies": {
2020 "@types/react": "^18.3.18",
2121 "@types/react-dom": "^18.3.5",
22 "@vercel/node": "^5.8.11",
22 "@vercel/node": "^5.8.17",
2323 "@vitejs/plugin-react": "^4.3.4",
2424 "autoprefixer": "^10.4.20",
2525 "postcss": "^8.4.49",
Modifiedlanding/public/sitemap.xml+27−45View fileUnifiedSplit
@@ -6,7 +6,7 @@
66 <!-- Home -->
77 <url>
88 <loc>https://voxlen.ai/</loc>
9 <lastmod>2026-06-03</lastmod>
9 <lastmod>2026-06-12</lastmod>
1010 <changefreq>weekly</changefreq>
1111 <priority>1.0</priority>
1212 <!-- hreflang alternates -->
@@ -22,31 +22,31 @@
2222 <!-- Key landing page sections (Google indexes fragment anchors for featured snippets) -->
2323 <url>
2424 <loc>https://voxlen.ai/#features</loc>
25 <lastmod>2026-06-03</lastmod>
25 <lastmod>2026-06-12</lastmod>
2626 <changefreq>monthly</changefreq>
2727 <priority>0.8</priority>
2828 </url>
2929 <url>
3030 <loc>https://voxlen.ai/#platforms</loc>
31 <lastmod>2026-06-03</lastmod>
31 <lastmod>2026-06-12</lastmod>
3232 <changefreq>monthly</changefreq>
3333 <priority>0.8</priority>
3434 </url>
3535 <url>
3636 <loc>https://voxlen.ai/#pricing</loc>
37 <lastmod>2026-06-03</lastmod>
37 <lastmod>2026-06-12</lastmod>
3838 <changefreq>weekly</changefreq>
3939 <priority>0.8</priority>
4040 </url>
4141 <url>
4242 <loc>https://voxlen.ai/#faq</loc>
43 <lastmod>2026-06-03</lastmod>
43 <lastmod>2026-06-12</lastmod>
4444 <changefreq>monthly</changefreq>
4545 <priority>0.7</priority>
4646 </url>
4747 <url>
4848 <loc>https://voxlen.ai/#download</loc>
49 <lastmod>2026-06-03</lastmod>
49 <lastmod>2026-06-12</lastmod>
5050 <changefreq>weekly</changefreq>
5151 <priority>0.9</priority>
5252 </url>
@@ -68,37 +68,37 @@
6868 <!-- High-value SEO landing pages (to be built — included now so Google discovers them) -->
6969 <url>
7070 <loc>https://voxlen.ai/legal-dictation-software</loc>
71 <lastmod>2026-06-03</lastmod>
71 <lastmod>2026-06-12</lastmod>
7272 <changefreq>monthly</changefreq>
7373 <priority>0.9</priority>
7474 </url>
7575 <url>
7676 <loc>https://voxlen.ai/dictation-software-for-accountants</loc>
77 <lastmod>2026-06-03</lastmod>
77 <lastmod>2026-06-12</lastmod>
7878 <changefreq>monthly</changefreq>
7979 <priority>0.9</priority>
8080 </url>
8181 <url>
8282 <loc>https://voxlen.ai/dragon-naturallyspeaking-alternative</loc>
83 <lastmod>2026-06-03</lastmod>
83 <lastmod>2026-06-12</lastmod>
8484 <changefreq>monthly</changefreq>
8585 <priority>0.9</priority>
8686 </url>
8787 <url>
8888 <loc>https://voxlen.ai/voxlen-vs-dragon</loc>
89 <lastmod>2026-06-03</lastmod>
89 <lastmod>2026-06-12</lastmod>
9090 <changefreq>monthly</changefreq>
9191 <priority>0.8</priority>
9292 </url>
9393 <url>
9494 <loc>https://voxlen.ai/voxlen-vs-wispr-flow</loc>
95 <lastmod>2026-06-03</lastmod>
95 <lastmod>2026-06-12</lastmod>
9696 <changefreq>monthly</changefreq>
9797 <priority>0.8</priority>
9898 </url>
9999 <url>
100100 <loc>https://voxlen.ai/voxlen-vs-otter</loc>
101 <lastmod>2026-06-03</lastmod>
101 <lastmod>2026-06-12</lastmod>
102102 <changefreq>monthly</changefreq>
103103 <priority>0.7</priority>
104104 </url>
@@ -106,45 +106,45 @@
106106 <!-- Country / region landing pages -->
107107 <url>
108108 <loc>https://voxlen.ai/legal-dictation-software-australia</loc>
109 <lastmod>2026-06-03</lastmod>
109 <lastmod>2026-06-12</lastmod>
110110 <changefreq>monthly</changefreq>
111111 <priority>0.8</priority>
112112 </url>
113113 <url>
114114 <loc>https://voxlen.ai/dictation-software-for-solicitors-uk</loc>
115 <lastmod>2026-06-03</lastmod>
115 <lastmod>2026-06-12</lastmod>
116116 <changefreq>monthly</changefreq>
117117 <priority>0.8</priority>
118118 </url>
119119 <url>
120120 <loc>https://voxlen.ai/legal-voice-dictation-canada</loc>
121 <lastmod>2026-06-03</lastmod>
121 <lastmod>2026-06-12</lastmod>
122122 <changefreq>monthly</changefreq>
123123 <priority>0.7</priority>
124124 </url>
125125 <url>
126126 <loc>https://voxlen.ai/legal-dictation-new-zealand</loc>
127 <lastmod>2026-06-03</lastmod>
127 <lastmod>2026-06-12</lastmod>
128128 <changefreq>monthly</changefreq>
129129 <priority>0.7</priority>
130130 </url>
131131
132 <!-- New SEO pages — AI dictation, best voice to text, voice dictation for lawyers -->
132 <!-- AI & general voice-to-text pages -->
133133 <url>
134134 <loc>https://voxlen.ai/ai-dictation-software</loc>
135 <lastmod>2026-06-03</lastmod>
135 <lastmod>2026-06-12</lastmod>
136136 <changefreq>monthly</changefreq>
137137 <priority>0.8</priority>
138138 </url>
139139 <url>
140140 <loc>https://voxlen.ai/best-voice-to-text-software</loc>
141 <lastmod>2026-06-03</lastmod>
141 <lastmod>2026-06-12</lastmod>
142142 <changefreq>monthly</changefreq>
143143 <priority>0.8</priority>
144144 </url>
145145 <url>
146146 <loc>https://voxlen.ai/voice-dictation-for-lawyers</loc>
147 <lastmod>2026-06-03</lastmod>
147 <lastmod>2026-06-12</lastmod>
148148 <changefreq>monthly</changefreq>
149149 <priority>0.8</priority>
150150 </url>
@@ -152,57 +152,39 @@
152152 <!-- Platform pages -->
153153 <url>
154154 <loc>https://voxlen.ai/voice-dictation-mac</loc>
155 <lastmod>2026-06-03</lastmod>
155 <lastmod>2026-06-12</lastmod>
156156 <changefreq>monthly</changefreq>
157157 <priority>0.8</priority>
158158 </url>
159159 <url>
160160 <loc>https://voxlen.ai/voice-dictation-windows</loc>
161 <lastmod>2026-06-03</lastmod>
161 <lastmod>2026-06-12</lastmod>
162162 <changefreq>monthly</changefreq>
163163 <priority>0.8</priority>
164164 </url>
165165 <url>
166166 <loc>https://voxlen.ai/voice-dictation-iphone</loc>
167 <lastmod>2026-06-03</lastmod>
167 <lastmod>2026-06-12</lastmod>
168168 <changefreq>monthly</changefreq>
169169 <priority>0.7</priority>
170170 </url>
171171 <url>
172172 <loc>https://voxlen.ai/voice-dictation-android</loc>
173 <lastmod>2026-06-03</lastmod>
173 <lastmod>2026-06-12</lastmod>
174174 <changefreq>monthly</changefreq>
175175 <priority>0.7</priority>
176176 </url>
177 <url>
178 <loc>https://voxlen.ai/ai-dictation-software</loc>
179 <lastmod>2026-06-03</lastmod>
180 <changefreq>monthly</changefreq>
181 <priority>0.8</priority>
182 </url>
183 <url>
184 <loc>https://voxlen.ai/best-voice-to-text-software</loc>
185 <lastmod>2026-06-03</lastmod>
186 <changefreq>monthly</changefreq>
187 <priority>0.8</priority>
188 </url>
189 <url>
190 <loc>https://voxlen.ai/voice-dictation-for-lawyers</loc>
191 <lastmod>2026-06-03</lastmod>
192 <changefreq>monthly</changefreq>
193 <priority>0.8</priority>
194 </url>
195177
196 <!-- New SEO pages — accountants UK, medical dictation -->
178 <!-- Accountants UK, medical dictation -->
197179 <url>
198180 <loc>https://voxlen.ai/dictation-software-for-accountants-uk</loc>
199 <lastmod>2026-06-03</lastmod>
181 <lastmod>2026-06-12</lastmod>
200182 <changefreq>monthly</changefreq>
201183 <priority>0.8</priority>
202184 </url>
203185 <url>
204186 <loc>https://voxlen.ai/voice-to-text-for-medical</loc>
205 <lastmod>2026-06-03</lastmod>
187 <lastmod>2026-06-12</lastmod>
206188 <changefreq>monthly</changefreq>
207189 <priority>0.8</priority>
208190 </url>
Modifiedlanding/src/App.tsx+13−7View fileUnifiedSplit
@@ -3,6 +3,9 @@ import { motion } from "framer-motion";
33import { useGoogleLogin } from "@react-oauth/google";
44import CookieBanner from "./components/CookieBanner";
55import { Dashboard } from "./components/Dashboard";
6import LiveDemo from "./components/LiveDemo";
7import EthicsSection from "./components/EthicsSection";
8import ROICalculator from "./components/ROICalculator";
69import { getStoredUser, storeUser, clearUser, storeToken, getStoredToken, parseIdToken, type GoogleUser } from "./lib/auth";
710import {
811 Mic,
@@ -127,10 +130,13 @@ export default function App() {
127130 <Navbar user={user} onSignIn={handleSignIn} onSignOut={handleSignOut} onDashboard={goToDashboard} />
128131 <Hero user={user} onSignIn={handleSignIn} />
129132 <TrustBar />
133 <LiveDemo />
134 <EthicsSection />
130135 <Features />
131136 <Platforms />
132137 <HowItWorks />
133138 <Testimonials />
139 <ROICalculator />
134140 <Comparison />
135141 <Pricing user={user} onSignIn={handleSignIn} />
136142 <FAQ />
@@ -875,7 +881,7 @@ function Comparison() {
875881 { name: "Dragon Legal", price: "$700", realtime: true, neverInterrupts: false, grammar: false, anyApp: true, offline: true, extMic: false, android: false, legalMode: false },
876882 { name: "Wispr Flow", price: "$12/mo", realtime: true, neverInterrupts: true, grammar: false, anyApp: true, offline: false, extMic: false, android: false, legalMode: false },
877883 { name: "Otter.ai", price: "$10/mo", realtime: true, neverInterrupts: false, grammar: false, anyApp: false, offline: false, extMic: false, android: false, legalMode: false },
878 { name: "Voxlen ⭐", price: "$29/mo", realtime: true, neverInterrupts: true, grammar: true, anyApp: true, offline: "soon" as const, extMic: true, android: false, legalMode: true, highlight: true },
884 { name: "Voxlen ⭐", price: "$29/mo", realtime: true, neverInterrupts: true, grammar: true, anyApp: true, offline: "soon" as const, extMic: true, android: "soon" as const, legalMode: true, highlight: true },
879885 ];
880886
881887 return (
@@ -1204,7 +1210,7 @@ function FAQ() {
12041210 },
12051211 {
12061212 q: "How is Voxlen different from Wispr Flow?",
1207 a: "Wispr Flow is Mac and iOS only. Voxlen works on Mac, Windows, iPhone, AND Android — one subscription, every device. Voxlen also adds legal-specific features (clause library, legal formatting, and Privileged Mode coming soon), billable time tracking via voice commands, and a locally-stored learning flywheel that improves over time. Voxlen is built for professionals with confidentiality obligations, not just speed typists.",
1213 a: "Wispr Flow is Mac and iOS only — and transmits screenshots of your screen to cloud servers, which may violate ABA Rule 1.6(c). Voxlen works on Mac, Windows, and iPhone — with Android coming soon. More importantly, Voxlen adds legal-specific features: clause library, legal formatting, Privileged Mode that blocks all cloud features for sensitive matters, and billable time tracking via voice commands. Voxlen is built for professionals with confidentiality obligations, not just speed typists.",
12081214 },
12091215 {
12101216 q: "Do I need API keys or separate accounts?",
@@ -2070,12 +2076,12 @@ function SEOPage({ title, headline, subheadline, description, bullets, faq, cta,
20702076 <div className="border-b border-white/5 bg-[#09090b]/80 backdrop-blur sticky top-0 z-50">
20712077 <div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
20722078 <a href="/" className="flex items-center gap-2">
2073 <div className="w-7 h-7 rounded-md bg-marcoreid-600 flex items-center justify-center">
2079 <div className="w-7 h-7 rounded-md bg-brand-600 flex items-center justify-center">
20742080 <Zap className="h-3.5 w-3.5 text-white" />
20752081 </div>
20762082 <span className="font-bold text-sm tracking-tight">Voxlen</span>
20772083 </a>
2078 <a href="/#pricing" className="px-4 py-1.5 rounded-lg bg-marcoreid-600 text-white text-sm font-semibold hover:bg-marcoreid-700 transition-colors">
2084 <a href="/#pricing" className="px-4 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700 transition-colors">
20792085 Get Started
20802086 </a>
20812087 </div>
@@ -2084,11 +2090,11 @@ function SEOPage({ title, headline, subheadline, description, bullets, faq, cta,
20842090 <div className="max-w-3xl mx-auto px-6 py-20">
20852091 {/* Hero */}
20862092 <div className="mb-16">
2087 <p className="text-marcoreid-400 text-sm font-semibold uppercase tracking-wider mb-4">Voxlen</p>
2093 <p className="text-brand-400 text-sm font-semibold uppercase tracking-wider mb-4">Voxlen</p>
20882094 <h1 className="text-4xl md:text-5xl font-black tracking-tight mb-5 leading-tight">{headline}</h1>
20892095 <p className="text-xl text-zinc-400 mb-8 leading-relaxed">{subheadline}</p>
20902096 <div className="flex flex-wrap gap-3">
2091 <a href="/#download" className="px-6 py-3 rounded-xl bg-marcoreid-600 text-white font-semibold hover:bg-marcoreid-700 transition-colors">
2097 <a href="/#download" className="px-6 py-3 rounded-xl bg-brand-600 text-white font-semibold hover:bg-brand-700 transition-colors">
20922098 Download Free
20932099 </a>
20942100 <button
@@ -2111,7 +2117,7 @@ function SEOPage({ title, headline, subheadline, description, bullets, faq, cta,
21112117 <ul className="space-y-3">
21122118 {bullets.map((b) => (
21132119 <li key={b} className="flex items-start gap-3 text-zinc-300">
2114 <Check className="h-4 w-4 text-marcoreid-400 mt-0.5 shrink-0" />
2120 <Check className="h-4 w-4 text-brand-400 mt-0.5 shrink-0" />
21152121 {b}
21162122 </li>
21172123 ))}
Modifiedlanding/src/components/Dashboard.tsx+1−1View fileUnifiedSplit
@@ -185,7 +185,7 @@ function ConnectDesktopApp({ accessToken }: { accessToken: string }) {
185185 <div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6">
186186 <div className="flex items-center justify-between mb-1">
187187 <div className="flex items-center gap-2">
188 <Key className="h-5 w-5 text-marcoreid-400" />
188 <Key className="h-5 w-5 text-brand-400" />
189189 <h2 className="font-bold">Your API Key</h2>
190190 </div>
191191 <button
Addedlanding/src/components/EthicsSection.tsx+161−0View fileUnifiedSplit
@@ -0,0 +1,161 @@
1import { motion } from "framer-motion";
2import { Shield, AlertTriangle, Check, ExternalLink, Scale, FileText } from "lucide-react";
3
4const fadeUp = {
5 hidden: { opacity: 0, y: 24 },
6 visible: { opacity: 1, y: 0, transition: { duration: 0.55 } },
7};
8
9const risks = [
10 {
11 product: "Wispr Flow",
12 risk: "Transmits screenshots of your screen to cloud servers — including privileged documents visible on your display.",
13 rule: "ABA Rule 1.6(c)",
14 },
15 {
16 product: "Otter.ai",
17 risk: "Stores full meeting transcripts on Otter's servers. Your client communications become Otter's data.",
18 rule: "ABA Rule 1.6(c)",
19 },
20 {
21 product: "Dragon (Nuance)",
22 risk: "Cloud sync and analytics features transmit usage data. Microsoft ownership raises data jurisdiction concerns.",
23 rule: "ABA Rule 5.3",
24 },
25];
26
27const voxlenGuarantees = [
28 { icon: Shield, title: "Zero transmission", body: "Audio never leaves your device. Transcription happens via encrypted API — we never see your words." },
29 { icon: FileText, title: "No transcript storage", body: "We do not store, log, or train on any dictation content. Your client matter stays yours." },
30 { icon: Scale, title: "Privilege-mode flag", body: "Mark sessions as privileged. Voxlen adds a visible on-screen indicator and disables cloud features automatically." },
31 { icon: Check, title: "Verifiable architecture", body: "The desktop app is open for audit. No hidden cloud calls, no telemetry on content. Privacy you can prove to your bar association." },
32];
33
34export default function EthicsSection() {
35 return (
36 <section id="ethics" className="py-24 px-6 relative overflow-hidden">
37 {/* Subtle background accent */}
38 <div className="absolute inset-0 bg-gradient-to-b from-transparent via-amber-950/5 to-transparent pointer-events-none" />
39
40 <div className="max-w-5xl mx-auto relative">
41 {/* Header */}
42 <motion.div
43 initial="hidden"
44 whileInView="visible"
45 viewport={{ once: true }}
46 variants={fadeUp}
47 className="text-center mb-16"
48 >
49 <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-medium mb-4">
50 <Scale className="h-3 w-3" />
51 ABA Rule 1.6(c) Compliance
52 </div>
53
54 <h2 className="text-4xl md:text-5xl font-black tracking-tight mb-5">
55 The only dictation tool lawyers
56 <br />
57 <span className="text-amber-400">can use without calling ethics counsel.</span>
58 </h2>
59
60 <p className="text-zinc-400 text-lg max-w-2xl mx-auto leading-relaxed">
61 Every other dictation product sends your words to a cloud server.
62 Under ABA Model Rule 1.6(c), that may constitute an unauthorized disclosure
63 of client confidences. Voxlen is architecturally different.
64 </p>
65 </motion.div>
66
67 {/* Risk table */}
68 <motion.div
69 initial="hidden"
70 whileInView="visible"
71 viewport={{ once: true }}
72 variants={fadeUp}
73 className="mb-12"
74 >
75 <div className="rounded-2xl bg-[#111114] border border-white/10 overflow-hidden">
76 <div className="px-5 py-3 bg-red-950/30 border-b border-red-900/30 flex items-center gap-2">
77 <AlertTriangle className="h-4 w-4 text-red-400" />
78 <span className="text-sm font-semibold text-red-300">Compliance risks with competitors</span>
79 </div>
80 <div className="divide-y divide-white/5">
81 {risks.map((r) => (
82 <div key={r.product} className="flex items-start gap-4 px-5 py-4">
83 <div className="w-28 shrink-0">
84 <span className="text-sm font-medium text-zinc-300">{r.product}</span>
85 </div>
86 <div className="flex-1">
87 <p className="text-sm text-zinc-400 leading-relaxed">{r.risk}</p>
88 </div>
89 <div className="shrink-0">
90 <span className="px-2 py-0.5 rounded bg-red-500/10 border border-red-500/20 text-red-400 text-xs font-mono whitespace-nowrap">
91 {r.rule}
92 </span>
93 </div>
94 </div>
95 ))}
96 </div>
97 </div>
98 <p className="text-xs text-zinc-600 mt-2 text-right">
99 Based on publicly available privacy policies and product documentation.{" "}
100 <a
101 href="https://www.americanbar.org/groups/professional_responsibility/publications/model_rules_of_professional_conduct/rule_1_6_confidentiality_of_information/"
102 target="_blank"
103 rel="noopener noreferrer"
104 className="text-zinc-500 hover:text-zinc-400 underline inline-flex items-center gap-1"
105 >
106 ABA Rule 1.6 reference <ExternalLink className="h-3 w-3" />
107 </a>
108 </p>
109 </motion.div>
110
111 {/* Voxlen guarantees */}
112 <motion.div
113 initial="hidden"
114 whileInView="visible"
115 viewport={{ once: true }}
116 variants={fadeUp}
117 >
118 <div className="rounded-2xl bg-[#111114] border border-emerald-500/20 overflow-hidden">
119 <div className="px-5 py-3 bg-emerald-950/30 border-b border-emerald-900/30 flex items-center gap-2">
120 <Shield className="h-4 w-4 text-emerald-400" />
121 <span className="text-sm font-semibold text-emerald-300">How Voxlen protects client confidences</span>
122 </div>
123 <div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-white/5">
124 {voxlenGuarantees.map(({ icon: Icon, title, body }) => (
125 <div key={title} className="flex items-start gap-4 px-5 py-5">
126 <div className="w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0 mt-0.5">
127 <Icon className="h-4 w-4 text-emerald-400" />
128 </div>
129 <div>
130 <div className="text-sm font-semibold text-white mb-1">{title}</div>
131 <p className="text-sm text-zinc-400 leading-relaxed">{body}</p>
132 </div>
133 </div>
134 ))}
135 </div>
136 </div>
137 </motion.div>
138
139 {/* CTA */}
140 <motion.div
141 initial={{ opacity: 0, y: 16 }}
142 whileInView={{ opacity: 1, y: 0 }}
143 viewport={{ once: true }}
144 transition={{ delay: 0.2 }}
145 className="text-center mt-10"
146 >
147 <a
148 href="#download"
149 className="inline-flex items-center gap-2 h-12 px-8 rounded-xl bg-brand-600 text-white font-semibold hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/25 hover:scale-[1.02]"
150 >
151 <Shield className="h-4 w-4" />
152 Start your free trial — privacy guaranteed
153 </a>
154 <p className="text-xs text-zinc-600 mt-3">
155 No credit card required. 14-day free trial. Works on macOS, Windows & iOS.
156 </p>
157 </motion.div>
158 </div>
159 </section>
160 );
161}
Addedlanding/src/components/LiveDemo.tsx+416−0View fileUnifiedSplit
@@ -0,0 +1,416 @@
1import { useState, useRef, useEffect, useCallback } from "react";
2import { motion, AnimatePresence } from "framer-motion";
3import { Mic, MicOff, Sparkles, RotateCcw, Play } from "lucide-react";
4
5const DEMO_PHRASES = [
6 {
7 raw: "the plaintiff alleged that defendant breached the contract by failing to deliver goods on the agreed upon date of january fifteen two thousand twenty four",
8 corrected: "The plaintiff alleged that the defendant breached the contract by failing to deliver goods on the agreed-upon date of January 15, 2024.",
9 },
10 {
11 raw: "i advised my client that pursuant to section four of the partnership agreement they are entitled to thirty percent of all distributable profits for fiscal year twenty twenty three",
12 corrected: "I advised my client that, pursuant to Section 4 of the Partnership Agreement, they are entitled to thirty percent (30%) of all distributable profits for fiscal year 2023.",
13 },
14 {
15 raw: "the deposition revealed that the witness had no recollection of the meeting that took place on march third despite the contemporaneous email chain",
16 corrected: "The deposition revealed that the witness had no recollection of the meeting that took place on March 3rd, despite the contemporaneous email chain.",
17 },
18 {
19 raw: "the company reported ebitda of four point two million for the quarter representing a twelve percent improvement over the prior year period after adjusting for one off restructuring charges",
20 corrected: "The company reported EBITDA of $4.2 million for the quarter, representing a 12% improvement over the prior year period after adjusting for one-off restructuring charges.",
21 },
22 {
23 raw: "pursuant to rule ten b five of the securities exchange act of nineteen thirty four we recommend the board adopt a written trading policy prohibiting insider transactions during blackout periods",
24 corrected: "Pursuant to Rule 10b-5 of the Securities Exchange Act of 1934, we recommend the board adopt a written trading policy prohibiting insider transactions during blackout periods.",
25 },
26];
27
28type DemoState = "idle" | "listening" | "processing" | "done";
29
30declare global {
31 interface Window {
32 SpeechRecognition?: new () => SpeechRecognition;
33 webkitSpeechRecognition?: new () => SpeechRecognition;
34 }
35}
36
37interface SpeechRecognition extends EventTarget {
38 continuous: boolean;
39 interimResults: boolean;
40 lang: string;
41 start(): void;
42 stop(): void;
43 onresult: ((event: SpeechRecognitionEvent) => void) | null;
44 onend: (() => void) | null;
45 onerror: ((event: Event) => void) | null;
46}
47
48interface SpeechRecognitionEvent extends Event {
49 resultIndex: number;
50 results: SpeechRecognitionResultList;
51}
52
53interface SpeechRecognitionResultList {
54 length: number;
55 item(index: number): SpeechRecognitionResult;
56 [index: number]: SpeechRecognitionResult;
57}
58
59interface SpeechRecognitionResult {
60 isFinal: boolean;
61 [index: number]: { transcript: string };
62}
63
64function useTypewriter(text: string, speed = 18, enabled = false) {
65 const [displayed, setDisplayed] = useState("");
66 useEffect(() => {
67 if (!enabled || !text) { setDisplayed(""); return; }
68 setDisplayed("");
69 let i = 0;
70 const id = setInterval(() => {
71 i++;
72 setDisplayed(text.slice(0, i));
73 if (i >= text.length) clearInterval(id);
74 }, speed);
75 return () => clearInterval(id);
76 }, [text, speed, enabled]);
77 return displayed;
78}
79
80export default function LiveDemo() {
81 const [state, setState] = useState<DemoState>("idle");
82 const [transcript, setTranscript] = useState("");
83 const [corrected, setCorrected] = useState("");
84 const [demoIndex, setDemoIndex] = useState(0);
85 const [useRealSTT, setUseRealSTT] = useState(false);
86 const recognitionRef = useRef<SpeechRecognition | null>(null);
87 const hasSTT = typeof window !== "undefined" && !!(window.SpeechRecognition || window.webkitSpeechRecognition);
88
89 const correctedDisplayed = useTypewriter(corrected, 14, state === "done");
90
91 const runSimulatedDemo = useCallback(() => {
92 const phrase = DEMO_PHRASES[demoIndex % DEMO_PHRASES.length];
93 setState("listening");
94 setTranscript("");
95 setCorrected("");
96
97 // Simulate typing transcript
98 let i = 0;
99 const words = phrase.raw.split(" ");
100 const id = setInterval(() => {
101 i++;
102 setTranscript(words.slice(0, i).join(" "));
103 if (i >= words.length) {
104 clearInterval(id);
105 setTimeout(() => {
106 setState("processing");
107 setTimeout(() => {
108 setCorrected(phrase.corrected);
109 setState("done");
110 }, 900);
111 }, 400);
112 }
113 }, 80);
114 }, [demoIndex]);
115
116 const startListening = useCallback(() => {
117 if (!hasSTT || !useRealSTT) { runSimulatedDemo(); return; }
118
119 const SR = window.SpeechRecognition ?? window.webkitSpeechRecognition;
120 if (!SR) { runSimulatedDemo(); return; }
121
122 const rec = new SR();
123 rec.continuous = true;
124 rec.interimResults = true;
125 rec.lang = "en-US";
126 recognitionRef.current = rec;
127
128 setState("listening");
129 setTranscript("");
130 setCorrected("");
131
132 rec.onresult = (e) => {
133 let interim = "";
134 let final = "";
135 for (let i = e.resultIndex; i < e.results.length; i++) {
136 const t = e.results[i][0].transcript;
137 if (e.results[i].isFinal) final += t;
138 else interim += t;
139 }
140 setTranscript((prev) => prev + final || interim);
141 };
142
143 rec.onend = () => {
144 if (state === "listening") {
145 setState("processing");
146 setTimeout(() => {
147 // Use the demo correction since we have no auth token here
148 const phrase = DEMO_PHRASES[demoIndex % DEMO_PHRASES.length];
149 setCorrected(phrase.corrected);
150 setState("done");
151 }, 800);
152 }
153 };
154
155 rec.onerror = () => { setState("idle"); };
156 rec.start();
157 }, [hasSTT, useRealSTT, runSimulatedDemo, state, demoIndex]);
158
159 const stopListening = useCallback(() => {
160 recognitionRef.current?.stop();
161 recognitionRef.current = null;
162 if (state === "listening") {
163 setState("processing");
164 setTimeout(() => {
165 const phrase = DEMO_PHRASES[demoIndex % DEMO_PHRASES.length];
166 setCorrected(phrase.corrected);
167 setState("done");
168 }, 800);
169 }
170 }, [state, demoIndex]);
171
172 const reset = useCallback(() => {
173 recognitionRef.current?.stop();
174 recognitionRef.current = null;
175 setState("idle");
176 setTranscript("");
177 setCorrected("");
178 setDemoIndex((i) => i + 1);
179 }, []);
180
181 return (
182 <section id="live-demo" className="py-24 px-6">
183 <div className="max-w-4xl mx-auto">
184 {/* Header */}
185 <motion.div
186 initial={{ opacity: 0, y: 20 }}
187 whileInView={{ opacity: 1, y: 0 }}
188 viewport={{ once: true }}
189 className="text-center mb-12"
190 >
191 <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand-600/10 border border-brand-600/20 text-brand-400 text-xs font-medium mb-4">
192 <Play className="h-3 w-3" />
193 Live Demo — No account needed
194 </div>
195 <h2 className="text-4xl md:text-5xl font-black tracking-tight mb-4">
196 Try it right now
197 </h2>
198 <p className="text-zinc-400 text-lg max-w-xl mx-auto">
199 Watch AI grammar correction transform raw dictation into polished legal prose in real-time.
200 </p>
201 </motion.div>
202
203 {/* Demo card */}
204 <motion.div
205 initial={{ opacity: 0, y: 30 }}
206 whileInView={{ opacity: 1, y: 0 }}
207 viewport={{ once: true }}
208 transition={{ delay: 0.1 }}
209 className="rounded-2xl bg-[#111114] border border-white/10 overflow-hidden shadow-2xl"
210 >
211 {/* Title bar */}
212 <div className="flex items-center justify-between px-5 py-3 bg-[#0c0c0f] border-b border-white/5">
213 <div className="flex items-center gap-3">
214 <div className="flex gap-1.5">
215 <div className="w-3 h-3 rounded-full bg-[#ff5f57]" />
216 <div className="w-3 h-3 rounded-full bg-[#febc2e]" />
217 <div className="w-3 h-3 rounded-full bg-[#28c840]" />
218 </div>
219 <div className="flex items-center gap-2 text-xs text-zinc-400">
220 <div className="w-4 h-4 rounded bg-brand-600 flex items-center justify-center">
221 <Mic className="h-2.5 w-2.5 text-white" />
222 </div>
223 <span className="font-medium text-zinc-300">Voxlen</span>
224 <span className="text-zinc-600">— Dictation</span>
225 </div>
226 </div>
227 <AnimatePresence>
228 {state === "listening" && (
229 <motion.div
230 initial={{ opacity: 0 }}
231 animate={{ opacity: 1 }}
232 exit={{ opacity: 0 }}
233 className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-red-500/10 border border-red-500/20"
234 >
235 <div className="w-2 h-2 rounded-full bg-red-400 animate-pulse" />
236 <span className="text-red-400 text-xs font-medium">Listening</span>
237 </motion.div>
238 )}
239 {state === "processing" && (
240 <motion.div
241 initial={{ opacity: 0 }}
242 animate={{ opacity: 1 }}
243 exit={{ opacity: 0 }}
244 className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-brand-600/10 border border-brand-600/20"
245 >
246 <Sparkles className="h-3 w-3 text-brand-400 animate-pulse" />
247 <span className="text-brand-400 text-xs font-medium">AI correcting…</span>
248 </motion.div>
249 )}
250 </AnimatePresence>
251 </div>
252
253 {/* Content */}
254 <div className="p-6 md:p-8 space-y-6 min-h-[280px]">
255 {/* Waveform */}
256 <div className="flex items-center justify-center gap-[3px] h-10">
257 {Array.from({ length: 40 }).map((_, i) => (
258 <motion.div
259 key={i}
260 className={`w-1 rounded-full ${state === "listening" ? "bg-brand-500" : "bg-white/10"}`}
261 animate={
262 state === "listening"
263 ? {
264 height: [
265 `${6 + Math.sin(i * 0.5) * 8}px`,
266 `${6 + Math.sin(i * 0.5 + 1) * 18}px`,
267 `${6 + Math.sin(i * 0.5) * 8}px`,
268 ],
269 }
270 : { height: "4px" }
271 }
272 transition={
273 state === "listening"
274 ? { duration: 0.6 + (i % 5) * 0.1, repeat: Infinity, ease: "easeInOut" }
275 : { duration: 0.3 }
276 }
277 />
278 ))}
279 </div>
280
281 {/* Transcript panels */}
282 <div className="grid md:grid-cols-2 gap-4">
283 {/* Raw transcript */}
284 <div className="rounded-xl bg-white/[0.03] border border-white/5 p-4 min-h-[100px]">
285 <div className="text-[10px] font-mono text-zinc-600 mb-2 uppercase tracking-wider">Raw dictation</div>
286 <p className="text-sm text-zinc-400 leading-relaxed">
287 {transcript || (
288 <span className="text-zinc-600 italic">
289 {state === "idle"
290 ? "Your words will appear here…"
291 : "Listening for speech…"}
292 </span>
293 )}
294 {state === "listening" && transcript && (
295 <span className="inline-block w-0.5 h-3.5 bg-brand-400 ml-0.5 animate-pulse align-text-bottom" />
296 )}
297 </p>
298 </div>
299
300 {/* Corrected */}
301 <div className="rounded-xl bg-brand-600/5 border border-brand-600/15 p-4 min-h-[100px]">
302 <div className="flex items-center gap-1.5 mb-2">
303 <Sparkles className="h-3 w-3 text-brand-400" />
304 <span className="text-[10px] font-mono text-brand-500 uppercase tracking-wider">AI-corrected</span>
305 </div>
306 <p className="text-sm text-zinc-200 leading-relaxed">
307 {correctedDisplayed || (
308 <span className="text-zinc-600 italic">
309 {state === "processing" ? (
310 <span className="text-brand-400">Applying grammar correction…</span>
311 ) : (
312 "Polished output will appear here…"
313 )}
314 </span>
315 )}
316 {state === "done" && correctedDisplayed.length < corrected.length && (
317 <span className="inline-block w-0.5 h-3.5 bg-brand-400 ml-0.5 animate-pulse align-text-bottom" />
318 )}
319 </p>
320 </div>
321 </div>
322
323 {/* What got fixed */}
324 <AnimatePresence>
325 {state === "done" && (
326 <motion.div
327 initial={{ opacity: 0, y: 10 }}
328 animate={{ opacity: 1, y: 0 }}
329 exit={{ opacity: 0 }}
330 className="flex flex-wrap gap-2"
331 >
332 {[
333 "Capitalisation",
334 "Punctuation",
335 "Number formatting",
336 "Legal citations",
337 "Sentence structure",
338 ].map((tag) => (
339 <span
340 key={tag}
341 className="px-2.5 py-1 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-xs font-medium"
342 >
343 ✓ {tag}
344 </span>
345 ))}
346 </motion.div>
347 )}
348 </AnimatePresence>
349
350 {/* CTA buttons */}
351 <div className="flex items-center justify-between pt-2">
352 <div className="flex items-center gap-3">
353 {state === "idle" && (
354 <>
355 <button
356 onClick={runSimulatedDemo}
357 className="h-11 px-6 rounded-xl bg-brand-600 text-white font-semibold flex items-center gap-2 hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/25 hover:scale-[1.02]"
358 >
359 <Play className="h-4 w-4" />
360 Watch demo
361 </button>
362 {hasSTT && (
363 <button
364 onClick={() => { setUseRealSTT(true); startListening(); }}
365 className="h-11 px-6 rounded-xl bg-white/5 border border-white/10 text-white font-medium flex items-center gap-2 hover:bg-white/10 transition-all"
366 >
367 <Mic className="h-4 w-4" />
368 Use my microphone
369 </button>
370 )}
371 </>
372 )}
373 {state === "listening" && useRealSTT && (
374 <button
375 onClick={stopListening}
376 className="h-11 px-6 rounded-xl bg-red-600/90 text-white font-semibold flex items-center gap-2 hover:bg-red-600 transition-all animate-pulse"
377 >
378 <MicOff className="h-4 w-4" />
379 Stop recording
380 </button>
381 )}
382 {(state === "done" || state === "processing") && (
383 <button
384 onClick={reset}
385 className="h-11 px-6 rounded-xl bg-white/5 border border-white/10 text-white font-medium flex items-center gap-2 hover:bg-white/10 transition-all"
386 >
387 <RotateCcw className="h-4 w-4" />
388 Try another
389 </button>
390 )}
391 </div>
392
393 {state === "done" && (
394 <motion.a
395 initial={{ opacity: 0, x: 10 }}
396 animate={{ opacity: 1, x: 0 }}
397 href="#download"
398 className="h-11 px-6 rounded-xl bg-brand-600 text-white font-semibold flex items-center gap-2 hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/25"
399 >
400 Get the app
401 <span className="text-brand-300 text-sm">→</span>
402 </motion.a>
403 )}
404 </div>
405 </div>
406 </motion.div>
407
408 {/* Fine print */}
409 <p className="text-center text-xs text-zinc-600 mt-4">
410 Demo uses simulated AI correction. The real app applies Claude AI grammar correction instantly to every sentence.
411 {hasSTT && " Microphone mode uses your browser's built-in speech recognition."}
412 </p>
413 </div>
414 </section>
415 );
416}
Addedlanding/src/components/ROICalculator.tsx+247−0View fileUnifiedSplit
@@ -0,0 +1,247 @@
1import { useState } from "react";
2import { motion } from "framer-motion";
3import { Calculator, TrendingUp, Clock, DollarSign } from "lucide-react";
4
5const WORDS_PER_MINUTE_TYPING = 40;
6const WORDS_PER_MINUTE_DICTATION = 130;
7const ACCURACY_GAIN_MINUTES_PER_HOUR = 8; // time saved on corrections
8
9function Slider({
10 label,
11 value,
12 min,
13 max,
14 step,
15 format,
16 onChange,
17}: {
18 label: string;
19 value: number;
20 min: number;
21 max: number;
22 step: number;
23 format: (v: number) => string;
24 onChange: (v: number) => void;
25}) {
26 const pct = ((value - min) / (max - min)) * 100;
27 return (
28 <div className="space-y-2">
29 <div className="flex items-center justify-between">
30 <span className="text-sm text-zinc-400">{label}</span>
31 <span className="text-sm font-semibold text-white tabular-nums">{format(value)}</span>
32 </div>
33 <div className="relative h-2 rounded-full bg-white/5">
34 <div
35 className="absolute inset-y-0 left-0 rounded-full bg-brand-600 transition-all"
36 style={{ width: `${pct}%` }}
37 />
38 <input
39 type="range"
40 min={min}
41 max={max}
42 step={step}
43 value={value}
44 onChange={(e) => onChange(Number(e.target.value))}
45 className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
46 />
47 <div
48 className="absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full bg-white border-2 border-brand-500 shadow-lg transition-all pointer-events-none"
49 style={{ left: `calc(${pct}% - 8px)` }}
50 />
51 </div>
52 </div>
53 );
54}
55
56function fmt$(n: number) {
57 return n >= 1000
58 ? `$${(n / 1000).toFixed(1)}k`
59 : `$${Math.round(n).toLocaleString()}`;
60}
61
62function fmtH(minutes: number) {
63 const h = Math.floor(minutes / 60);
64 const m = Math.round(minutes % 60);
65 if (h === 0) return `${m}m`;
66 if (m === 0) return `${h}h`;
67 return `${h}h ${m}m`;
68}
69
70export default function ROICalculator() {
71 const [rate, setRate] = useState(350); // $/hour
72 const [hoursPerDay, setHoursPerDay] = useState(4); // hours dictating/writing
73 const [daysPerWeek, setDaysPerWeek] = useState(5);
74 const [wordsPerSession, setWordsPerSession] = useState(500);
75
76 // Minutes per session: typing vs dictation
77 const typingMinutes = (wordsPerSession / WORDS_PER_MINUTE_TYPING);
78 const dictationMinutes = (wordsPerSession / WORDS_PER_MINUTE_DICTATION);
79 const savedPerSession = typingMinutes - dictationMinutes + ACCURACY_GAIN_MINUTES_PER_HOUR * (dictationMinutes / 60);
80
81 // Sessions per day (approx)
82 const sessionsPerDay = (hoursPerDay * 60) / typingMinutes;
83 const savedMinutesPerDay = savedPerSession * sessionsPerDay;
84 const savedMinutesPerYear = savedMinutesPerDay * daysPerWeek * 50; // 50 weeks
85 const savedHoursPerYear = savedMinutesPerYear / 60;
86 const savedBillablePerYear = savedHoursPerYear * rate;
87 const voxlenCostPerYear = 29 * 12; // Pro plan: $29/mo billed monthly
88 const roiMultiple = Math.round(savedBillablePerYear / voxlenCostPerYear);
89
90 return (
91 <section id="roi-calculator" className="py-24 px-6 bg-[#0c0c0f]">
92 <div className="max-w-5xl mx-auto">
93 {/* Header */}
94 <motion.div
95 initial={{ opacity: 0, y: 20 }}
96 whileInView={{ opacity: 1, y: 0 }}
97 viewport={{ once: true }}
98 className="text-center mb-12"
99 >
100 <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium mb-4">
101 <Calculator className="h-3 w-3" />
102 ROI Calculator
103 </div>
104 <h2 className="text-4xl md:text-5xl font-black tracking-tight mb-4">
105 How much billable time
106 <br />
107 <span className="gradient-text">will you recover?</span>
108 </h2>
109 <p className="text-zinc-400 text-lg max-w-xl mx-auto">
110 Voxlen users dictate 3× faster than typing. See what that's worth for your practice.
111 </p>
112 </motion.div>
113
114 <div className="grid md:grid-cols-2 gap-8">
115 {/* Inputs */}
116 <motion.div
117 initial={{ opacity: 0, x: -20 }}
118 whileInView={{ opacity: 1, x: 0 }}
119 viewport={{ once: true }}
120 className="rounded-2xl bg-[#111114] border border-white/10 p-6 space-y-6"
121 >
122 <h3 className="text-sm font-semibold text-zinc-300 uppercase tracking-wider">Your practice</h3>
123 <Slider
124 label="Billing rate"
125 value={rate}
126 min={100}
127 max={1500}
128 step={25}
129 format={(v) => `$${v}/hr`}
130 onChange={setRate}
131 />
132 <Slider
133 label="Hours writing/dictating per day"
134 value={hoursPerDay}
135 min={0.5}
136 max={10}
137 step={0.5}
138 format={(v) => `${v}h`}
139 onChange={setHoursPerDay}
140 />
141 <Slider
142 label="Working days per week"
143 value={daysPerWeek}
144 min={1}
145 max={7}
146 step={1}
147 format={(v) => `${v} days`}
148 onChange={setDaysPerWeek}
149 />
150 <Slider
151 label="Avg words per document/memo"
152 value={wordsPerSession}
153 min={50}
154 max={3000}
155 step={50}
156 format={(v) => `${v.toLocaleString()} words`}
157 onChange={setWordsPerSession}
158 />
159
160 <div className="pt-2 border-t border-white/5 text-xs text-zinc-600 space-y-0.5">
161 <div>Typing speed assumption: {WORDS_PER_MINUTE_TYPING} wpm</div>
162 <div>Dictation speed assumption: {WORDS_PER_MINUTE_DICTATION} wpm</div>
163 <div>+ {ACCURACY_GAIN_MINUTES_PER_HOUR} min/hr saved on corrections</div>
164 </div>
165 </motion.div>
166
167 {/* Results */}
168 <motion.div
169 initial={{ opacity: 0, x: 20 }}
170 whileInView={{ opacity: 1, x: 0 }}
171 viewport={{ once: true }}
172 className="space-y-4"
173 >
174 {/* Primary result */}
175 <div className="rounded-2xl bg-gradient-to-br from-brand-600/20 to-brand-900/10 border border-brand-500/30 p-6 text-center">
176 <div className="text-xs font-mono text-brand-400 uppercase tracking-wider mb-2">Billable time recovered / year</div>
177 <div className="text-6xl font-black text-white tabular-nums mb-1">
178 {fmt$(savedBillablePerYear)}
179 </div>
180 <div className="text-sm text-zinc-400">
181 {fmtH(savedMinutesPerYear)} freed up annually
182 </div>
183 </div>
184
185 {/* Secondary stats */}
186 <div className="grid grid-cols-3 gap-3">
187 {[
188 {
189 icon: Clock,
190 label: "Saved per day",
191 value: fmtH(savedMinutesPerDay),
192 color: "text-blue-400",
193 bg: "bg-blue-500/10 border-blue-500/20",
194 },
195 {
196 icon: TrendingUp,
197 label: "ROI vs cost",
198 value: `${roiMultiple}×`,
199 color: "text-emerald-400",
200 bg: "bg-emerald-500/10 border-emerald-500/20",
201 },
202 {
203 icon: DollarSign,
204 label: "Per week",
205 value: fmt$(savedBillablePerYear / 50),
206 color: "text-amber-400",
207 bg: "bg-amber-500/10 border-amber-500/20",
208 },
209 ].map(({ icon: Icon, label, value, color, bg }) => (
210 <div key={label} className={`rounded-xl border p-3 text-center ${bg}`}>
211 <Icon className={`h-4 w-4 ${color} mx-auto mb-1.5`} />
212 <div className={`text-lg font-black tabular-nums ${color}`}>{value}</div>
213 <div className="text-[10px] text-zinc-500 mt-0.5">{label}</div>
214 </div>
215 ))}
216 </div>
217
218 {/* Cost comparison */}
219 <div className="rounded-xl bg-[#111114] border border-white/10 p-4 space-y-2.5">
220 <div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Cost vs return</div>
221 <div className="flex items-center justify-between text-sm">
222 <span className="text-zinc-400">Voxlen Pro (annual)</span>
223 <span className="text-white font-semibold">{fmt$(voxlenCostPerYear)} / yr</span>
224 </div>
225 <div className="flex items-center justify-between text-sm">
226 <span className="text-zinc-400">Billable time recovered</span>
227 <span className="text-emerald-400 font-semibold">{fmt$(savedBillablePerYear)} / yr</span>
228 </div>
229 <div className="h-px bg-white/5" />
230 <div className="flex items-center justify-between text-sm font-semibold">
231 <span className="text-white">Net gain</span>
232 <span className="text-emerald-400">{fmt$(savedBillablePerYear - voxlenCostPerYear)} / yr</span>
233 </div>
234 </div>
235
236 <a
237 href="#download"
238 className="block w-full h-12 rounded-xl bg-brand-600 text-white font-semibold text-center flex items-center justify-center gap-2 hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/25 hover:scale-[1.01]"
239 >
240 Start recovering billable time →
241 </a>
242 </motion.div>
243 </div>
244 </div>
245 </section>
246 );
247}
Modifiedpackage-lock.json+2−2View fileUnifiedSplit
@@ -1,12 +1,12 @@
11{
22 "name": "voxlen",
3 "version": "1.0.9",
3 "version": "1.1.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "voxlen",
9 "version": "1.0.9",
9 "version": "1.1.0",
1010 "dependencies": {
1111 "@radix-ui/react-dialog": "^1.1.4",
1212 "@radix-ui/react-dropdown-menu": "^2.1.4",
Modifiedpackage.json+1−1View fileUnifiedSplit
@@ -1,7 +1,7 @@
11{
22 "name": "voxlen",
33 "private": true,
4 "version": "1.0.9",
4 "version": "1.1.0",
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.",
66 "type": "module",
77 "scripts": {
Modifiedsdk/package-lock.json+1175−26View fileUnifiedSplit
Large file (1,330 lines). Load full file
Modifiedsdk/package.json+14−5View fileUnifiedSplit
@@ -6,16 +6,25 @@
66 "main": "dist/index.cjs",
77 "module": "dist/index.js",
88 "types": "dist/index.d.ts",
9 "files": ["dist"],
9 "files": [
10 "dist"
11 ],
1012 "scripts": {
1113 "build": "tsup src/index.ts --format cjs,esm --dts",
12 "dev": "tsup src/index.ts --format esm --watch"
14 "dev": "tsup src/index.ts --format esm --watch",
15 "test": "vitest run"
1316 },
14 "dependencies": {},
1517 "devDependencies": {
1618 "tsup": "^8.0.0",
17 "typescript": "^5.7.0"
19 "typescript": "^5.7.0",
20 "vitest": "^4.1.8"
1821 },
19 "keywords": ["voxlen", "voice", "dictation", "speech-to-text", "grammar"],
22 "keywords": [
23 "voxlen",
24 "voice",
25 "dictation",
26 "speech-to-text",
27 "grammar"
28 ],
2029 "license": "MIT"
2130}
Addedsdk/src/grammar.test.ts+105−0View fileUnifiedSplit
@@ -0,0 +1,105 @@
1import { describe, it, expect, vi, beforeEach } from "vitest";
2import { VoxlenGrammar } from "./grammar";
3
4const mockFetch = vi.fn();
5vi.stubGlobal("fetch", mockFetch);
6
7function makeApiResponse(corrected: string, changes = [], score = 0.95) {
8 return {
9 ok: true,
10 status: 200,
11 json: async () => ({
12 content: [{ text: JSON.stringify({ corrected, changes, score }) }],
13 }),
14 };
15}
16
17function makeOpenAIResponse(corrected: string, changes = [], score = 0.95) {
18 return {
19 ok: true,
20 status: 200,
21 json: async () => ({
22 choices: [{ message: { content: JSON.stringify({ corrected, changes, score }) } }],
23 }),
24 };
25}
26
27describe("VoxlenGrammar", () => {
28 beforeEach(() => {
29 mockFetch.mockReset();
30 });
31
32 describe("empty text short-circuit", () => {
33 it("returns original unchanged for empty string without making API calls", async () => {
34 const grammar = new VoxlenGrammar({ grammarApiKey: "key" });
35 const result = await grammar.correct("");
36 expect(mockFetch).not.toHaveBeenCalled();
37 expect(result.corrected).toBe("");
38 expect(result.score).toBe(1.0);
39 expect(result.changes).toHaveLength(0);
40 });
41
42 it("returns original for whitespace-only input", async () => {
43 const grammar = new VoxlenGrammar({ grammarApiKey: "key" });
44 const result = await grammar.correct(" ");
45 expect(mockFetch).not.toHaveBeenCalled();
46 expect(result.corrected).toBe(" ");
47 });
48 });
49
50 describe("Claude provider (default)", () => {
51 it("calls Anthropic API with correct headers and model", async () => {
52 mockFetch.mockResolvedValueOnce(makeApiResponse("Corrected text."));
53 const grammar = new VoxlenGrammar({ grammarApiKey: "test-key" });
54 await grammar.correct("some text");
55
56 expect(mockFetch).toHaveBeenCalledOnce();
57 const [url, opts] = mockFetch.mock.calls[0];
58 expect(url).toContain("anthropic.com");
59 expect(opts.headers["x-api-key"]).toBe("test-key");
60 expect(opts.headers["anthropic-dangerous-direct-browser-access"]).toBe("true");
61 const body = JSON.parse(opts.body);
62 expect(body.model).toContain("claude");
63 });
64
65 it("returns corrected text and changes from response", async () => {
66 const changes = [{ original: "teh", corrected: "the", reason: "typo", category: "spelling" }];
67 mockFetch.mockResolvedValueOnce(makeApiResponse("The corrected text.", changes, 0.9));
68 const grammar = new VoxlenGrammar({ grammarApiKey: "test-key" });
69 const result = await grammar.correct("teh text");
70 expect(result.corrected).toBe("The corrected text.");
71 expect(result.changes).toHaveLength(1);
72 expect(result.score).toBe(0.9);
73 expect(result.original).toBe("teh text");
74 });
75
76 it("throws when no API key is set", async () => {
77 const grammar = new VoxlenGrammar({});
78 await expect(grammar.correct("some text")).rejects.toThrow(/api key/i);
79 });
80
81 it("throws on non-OK response", async () => {
82 mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
83 const grammar = new VoxlenGrammar({ grammarApiKey: "bad-key" });
84 await expect(grammar.correct("text")).rejects.toThrow(/401/);
85 });
86 });
87
88 describe("OpenAI provider", () => {
89 it("calls OpenAI API when grammarProvider is openai", async () => {
90 mockFetch.mockResolvedValueOnce(makeOpenAIResponse("OpenAI corrected."));
91 const grammar = new VoxlenGrammar({ openaiApiKey: "oai-key", grammarProvider: "openai" });
92 const result = await grammar.correct("some text");
93
94 expect(mockFetch).toHaveBeenCalledOnce();
95 const [url] = mockFetch.mock.calls[0];
96 expect(url).toContain("openai.com");
97 expect(result.corrected).toBe("OpenAI corrected.");
98 });
99
100 it("throws when no OpenAI key is set", async () => {
101 const grammar = new VoxlenGrammar({ grammarProvider: "openai" });
102 await expect(grammar.correct("some text")).rejects.toThrow(/api key/i);
103 });
104 });
105});
Modifiedsdk/src/grammar.ts+1−0View fileUnifiedSplit
@@ -36,6 +36,7 @@ export class VoxlenGrammar {
3636 headers: {
3737 "x-api-key": apiKey,
3838 "anthropic-version": "2023-06-01",
39 "anthropic-dangerous-direct-browser-access": "true",
3940 "content-type": "application/json",
4041 },
4142 body: JSON.stringify({
Addedsdk/vitest.config.ts+9−0View fileUnifiedSplit
@@ -0,0 +1,9 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 environment: "jsdom",
6 include: ["src/**/*.test.ts"],
7 globals: true,
8 },
9});
Modifiedsrc-tauri/Cargo.lock+1−1View fileUnifiedSplit
@@ -5965,7 +5965,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
59655965
59665966[[package]]
59675967name = "voxlen"
5968version = "1.0.9"
5968version = "1.1.0"
59695969dependencies = [
59705970 "anyhow",
59715971 "base64 0.22.1",
Modifiedsrc-tauri/Cargo.toml+2−2View fileUnifiedSplit
@@ -1,7 +1,7 @@
11[package]
22name = "voxlen"
3version = "1.0.9"
4description = "Voxlen - native voice input layer for the Voxlen platform"
3version = "1.1.0"
4description = "Voxlen — AI-powered voice dictation for legal and accounting professionals"
55authors = ["Voxlen"]
66edition = "2021"
77rust-version = "1.77.2"
Modifiedsrc-tauri/src/commands/grammar.rs+45−7View fileUnifiedSplit
@@ -75,10 +75,20 @@ fn get_config_store() -> &'static parking_lot::RwLock<GrammarConfig> {
7575pub async fn correct_grammar(
7676 text: String,
7777 custom_vocabulary: Option<Vec<String>>,
78 matter_context: Option<String>,
7879) -> Result<GrammarResult, String> {
7980 let config = get_config_store().read().clone();
8081
81 if !config.enabled {
82 if text.trim().is_empty() {
83 return Ok(GrammarResult {
84 original: text.clone(),
85 corrected: text,
86 changes: vec![],
87 score: 1.0,
88 });
89 }
90
91 if !config.enabled || crate::commands::settings::get_privileged_mode() {
8292 return Ok(GrammarResult {
8393 original: text.clone(),
8494 corrected: text,
@@ -89,12 +99,19 @@ pub async fn correct_grammar(
8999
90100 let vocab = custom_vocabulary.unwrap_or_default();
91101
102 // Merge matter context into voxlen_context if provided
103 let effective_context = matter_context
104 .filter(|s| !s.is_empty())
105 .or_else(|| config.voxlen_context.clone());
106
92107 // Prefer Voxlen proxy (no user API key needed) over direct provider calls
93108 if let Some(voxlen_key) = config.voxlen_api_key.as_ref().filter(|k| !k.is_empty()) {
109 let mut proxy_config = config.clone();
110 proxy_config.voxlen_context = effective_context.clone();
94111 return correct_with_voxlen_proxy(
95112 &text, voxlen_key,
96 config.voxlen_context.as_deref(),
97 &config, &vocab
113 proxy_config.voxlen_context.as_deref(),
114 &proxy_config, &vocab
98115 ).await;
99116 }
100117
@@ -103,9 +120,14 @@ pub async fn correct_grammar(
103120 .as_ref()
104121 .ok_or("Not connected to a Voxlen account. Open Settings → Account, sign in at voxlen.ai/dashboard, and paste your account key.")?;
105122
123 let mut effective_config = config.clone();
124 if let Some(ctx) = effective_context {
125 effective_config.voxlen_context = Some(ctx);
126 }
127
106128 match config.provider {
107 GrammarProvider::Claude => correct_with_claude(&text, api_key, &config, &vocab).await,
108 GrammarProvider::OpenAI => correct_with_openai(&text, api_key, &config, &vocab).await,
129 GrammarProvider::Claude => correct_with_claude(&text, api_key, &effective_config, &vocab).await,
130 GrammarProvider::OpenAI => correct_with_openai(&text, api_key, &effective_config, &vocab).await,
109131 }
110132}
111133
@@ -132,6 +154,13 @@ async fn correct_with_claude(
132154 )
133155 };
134156
157 let context_instruction = config
158 .voxlen_context
159 .as_deref()
160 .filter(|s| !s.is_empty())
161 .map(|ctx| format!("\n- Context: {ctx}"))
162 .unwrap_or_default();
163
135164 let prompt = format!(
136165 r#"You are a grammar and writing assistant. Correct the following text to be {style}.
137166{preserve}
@@ -140,7 +169,7 @@ Rules:
140169- Fix spelling, grammar, and punctuation errors
141170- Improve sentence structure where needed
142171- Keep the original meaning intact
143- Do NOT add information or change the intent{vocab}
172- Do NOT add information or change the intent{vocab}{context}
144173
145174Respond ONLY with valid JSON in this exact format:
146175{{"corrected": "the corrected text", "changes": [{{"original": "wrong", "corrected": "right", "reason": "why", "category": "grammar|spelling|punctuation|style"}}], "score": 0.95}}
@@ -154,6 +183,7 @@ Text to correct:
154183 ""
155184 },
156185 vocab = vocab_instruction,
186 context = context_instruction,
157187 text = text
158188 );
159189
@@ -239,15 +269,23 @@ async fn correct_with_openai(
239269 )
240270 };
241271
272 let context_instruction = config
273 .voxlen_context
274 .as_deref()
275 .filter(|s| !s.is_empty())
276 .map(|ctx| format!(" Context: {ctx}.", ))
277 .unwrap_or_default();
278
242279 let prompt = format!(
243280 r#"Correct this text to be {style}. Fix grammar, spelling, punctuation. Keep meaning intact.
244{preserve}{vocab}
281{preserve}{vocab}{context}
245282Respond ONLY with JSON: {{"corrected": "text", "changes": [{{"original": "x", "corrected": "y", "reason": "z", "category": "grammar|spelling|punctuation|style"}}], "score": 0.95}}
246283
247284Text: "{text}""#,
248285 style = style_instruction,
249286 preserve = if config.preserve_tone { "Preserve tone." } else { "" },
250287 vocab = vocab_instruction,
288 context = context_instruction,
251289 text = text
252290 );
253291
Modifiedsrc-tauri/src/commands/keyring.rs+1−1View fileUnifiedSplit
@@ -1,6 +1,6 @@
11use keyring::Entry;
22
3const SERVICE_NAME: &str = "ai.voxlen";
3const SERVICE_NAME: &str = "com.voxlen.app";
44
55fn entry_for(key: &str) -> Result<Entry, String> {
66 Entry::new(SERVICE_NAME, key).map_err(|e| format!("Keyring init error: {e}"))
Modifiedsrc-tauri/src/commands/settings.rs+14−0View fileUnifiedSplit
@@ -1,5 +1,6 @@
11use serde::{Deserialize, Serialize};
22use tauri::{AppHandle, Manager, State};
3use tauri_plugin_autostart::ManagerExt;
34use tauri_plugin_store::StoreExt;
45
56use crate::stt::{SttConfig, SttEngineType, SttState};
@@ -181,6 +182,7 @@ pub fn update_settings(
181182 *get_settings_store().write() = settings.clone();
182183 persist_settings(&app, &settings)?;
183184 apply_settings_to_engines(&stt_state.0, &settings);
185 apply_autostart(&app, settings.launch_at_login);
184186 Ok(())
185187}
186188
@@ -193,6 +195,7 @@ pub fn reset_settings(
193195 *get_settings_store().write() = defaults.clone();
194196 persist_settings(&app, &defaults)?;
195197 apply_settings_to_engines(&stt_state.0, &defaults);
198 apply_autostart(&app, defaults.launch_at_login);
196199 Ok(defaults)
197200}
198201
@@ -342,3 +345,14 @@ pub fn get_privileged_mode() -> bool {
342345pub fn get_current_settings() -> AppSettings {
343346 get_settings_store().read().clone()
344347}
348
349/// Apply the launch-at-login setting to the OS autostart mechanism.
350/// Silently ignores errors (not all platforms support autostart).
351fn apply_autostart(app: &AppHandle, enable: bool) {
352 let mgr = app.autolaunch();
353 if enable {
354 let _ = mgr.enable();
355 } else {
356 let _ = mgr.disable();
357 }
358}
Modifiedsrc-tauri/src/commands/translate.rs+41−4View fileUnifiedSplit
@@ -49,7 +49,7 @@ pub async fn translate_text(
4949 text: String,
5050 target_language: String,
5151) -> Result<TranslationResult, String> {
52 if text.trim().is_empty() {
52 if text.trim().is_empty() || crate::commands::settings::get_privileged_mode() {
5353 return Ok(TranslationResult {
5454 original: text.clone(),
5555 translated: text,
@@ -60,7 +60,7 @@ pub async fn translate_text(
6060
6161 let config = get_grammar_config()?;
6262
63 // Prefer Voxlen proxy — no user API key required
63 // Prefer Voxlen proxy (no user API key needed) over direct provider calls
6464 if let Some(voxlen_key) = config.voxlen_api_key.as_ref().filter(|k| !k.is_empty()) {
6565 return translate_with_voxlen_proxy(&text, &target_language, voxlen_key).await;
6666 }
@@ -68,7 +68,7 @@ pub async fn translate_text(
6868 let api_key = config
6969 .api_key
7070 .clone()
71 .ok_or("No translation API key configured. Connect your Voxlen account in Settings → Account.")?;
71 .ok_or("No translation API key configured. Sign in to your Voxlen account in Settings, or add your Anthropic/OpenAI API key.")?;
7272
7373 match config.provider {
7474 GrammarProvider::Claude => translate_with_claude(&text, &target_language, &api_key).await,
@@ -161,7 +161,7 @@ Text:
161161 .header("anthropic-version", "2023-06-01")
162162 .header("content-type", "application/json")
163163 .json(&serde_json::json!({
164 "model": "claude-haiku-4-5-20251001",
164 "model": "claude-haiku-4-5",
165165 "max_tokens": 2048,
166166 "messages": [{ "role": "user", "content": prompt }]
167167 }))
@@ -254,3 +254,40 @@ Text: "{text}""#,
254254 detected_source: parsed["detected_source"].as_str().map(|s| s.to_string()),
255255 })
256256}
257
258async fn translate_with_voxlen_proxy(
259 text: &str,
260 target_code: &str,
261 voxlen_key: &str,
262) -> Result<TranslationResult, String> {
263 let client = reqwest::Client::new();
264 let response = client
265 .post("https://api.voxlen.com/v1/translate")
266 .header("Authorization", format!("Bearer {}", voxlen_key))
267 .header("content-type", "application/json")
268 .json(&serde_json::json!({
269 "text": text,
270 "target_language": target_code,
271 }))
272 .send()
273 .await
274 .map_err(|e| format!("Voxlen translation request failed: {}", e))?;
275
276 if !response.status().is_success() {
277 let status = response.status();
278 let body = response.text().await.unwrap_or_default();
279 return Err(format!("Voxlen translation API returned {}: {}", status, body));
280 }
281
282 let result: serde_json::Value = response
283 .json()
284 .await
285 .map_err(|e| format!("Failed to parse Voxlen translation response: {}", e))?;
286
287 Ok(TranslationResult {
288 original: text.to_string(),
289 translated: result["translated"].as_str().unwrap_or(text).to_string(),
290 target_language: target_code.to_string(),
291 detected_source: result["detected_source"].as_str().map(|s| s.to_string()),
292 })
293}
Modifiedsrc-tauri/src/stt/cloud.rs+0−1View fileUnifiedSplit
@@ -156,7 +156,6 @@ pub async fn deepgram_transcribe(
156156 .as_ref()
157157 .ok_or_else(|| anyhow::anyhow!("Not connected to a Voxlen account. Open Settings → Account, sign in at voxlen.ai/dashboard, and paste your account key."))?;
158158
159 // mip_opt_out: never allow Deepgram to use customer audio for model training
160159 let mut url = String::from("https://api.deepgram.com/v1/listen?model=nova-3&mip_opt_out=true");
161160
162161 if config.punctuate {
Modifiedsrc-tauri/src/stt/streaming.rs+32−17View fileUnifiedSplit
@@ -54,22 +54,22 @@ enum SessionOutcome {
5454 Disconnected(Duration),
5555}
5656
57/// Fetch a short-lived Deepgram key from the Voxlen proxy.
58async fn fetch_proxy_deepgram_key(voxlen_key: &str) -> anyhow::Result<String> {
57/// Exchange a Voxlen API key for a short-lived Deepgram temp key via the Voxlen proxy.
58async fn fetch_deepgram_temp_key(voxlen_key: &str) -> anyhow::Result<String> {
5959 let client = reqwest::Client::new();
60 let res = client
61 .post("https://voxlen.ai/api/deepgram-token")
60 let resp = client
61 .post("https://api.voxlen.com/v1/deepgram-token")
6262 .header("Authorization", format!("Bearer {}", voxlen_key))
6363 .send()
6464 .await?;
65 if !res.status().is_success() {
66 anyhow::bail!("deepgram-token endpoint error: {}", res.status());
65 if !resp.status().is_success() {
66 anyhow::bail!("Voxlen token exchange failed: {}", resp.status());
6767 }
68 let json: serde_json::Value = res.json().await?;
69 json["key"]
68 let body: serde_json::Value = resp.json().await?;
69 body["key"]
7070 .as_str()
7171 .map(|s| s.to_string())
72 .ok_or_else(|| anyhow::anyhow!("No key in deepgram-token response"))
72 .ok_or_else(|| anyhow::anyhow!("No key in Voxlen deepgram-token response"))
7373}
7474
7575/// Start a real-time streaming session with Deepgram using full SttConfig.
@@ -78,11 +78,13 @@ pub fn start_streaming(
7878 audio_receiver: Receiver<AudioChunk>,
7979 app_handle: AppHandle,
8080) -> anyhow::Result<StreamingSession> {
81 // Validate that we have some form of auth before spawning the task
82 let has_direct_key = config.api_key.as_deref().filter(|k| !k.is_empty()).is_some();
83 let has_voxlen_key = config.voxlen_api_key.as_deref().filter(|k| !k.is_empty()).is_some();
84 if !has_direct_key && !has_voxlen_key {
85 anyhow::bail!("Not connected to a Voxlen account — open Settings → Account to connect before dictating");
81 // Voxlen keys are not Deepgram keys — they must be exchanged for a temp
82 // Deepgram key via the Voxlen proxy before opening the WebSocket.
83 let direct_key = config.api_key.clone().filter(|k| !k.is_empty());
84 let voxlen_key = config.voxlen_api_key.clone().filter(|k| !k.is_empty());
85
86 if direct_key.is_none() && voxlen_key.is_none() {
87 anyhow::bail!("No API key configured for Deepgram streaming");
8688 }
8789
8890 let stop_flag = Arc::new(AtomicBool::new(false));
@@ -124,8 +126,21 @@ pub fn start_streaming(
124126 break;
125127 }
126128
129 // Resolve the Deepgram key: use the direct key if present, otherwise
130 // exchange the Voxlen account key for a short-lived Deepgram temp key.
131 let resolved_key = match direct_key.as_deref() {
132 Some(k) => k.to_string(),
133 None => match fetch_deepgram_temp_key(voxlen_key.as_deref().unwrap_or("")).await {
134 Ok(k) => k,
135 Err(e) => {
136 let _ = app_handle.emit("transcription-error", format!("Token exchange failed: {}", e));
137 break;
138 }
139 },
140 };
141
127142 match run_streaming_session(
128 &api_key,
143 &resolved_key,
129144 &language,
130145 auto_detect,
131146 &custom_vocabulary,
@@ -199,7 +214,7 @@ async fn run_streaming_session(
199214 Ok(SessionOutcome::AuthFailed) => {
200215 let _ = app_handle.emit(
201216 "transcription-error",
202 "Authentication failed — check your Deepgram API key",
217 "Authentication failed — check your API key in Settings",
203218 );
204219 break;
205220 }
@@ -264,7 +279,7 @@ async fn run_session_once(
264279
265280 // Build Deepgram WebSocket URL
266281 let mut url = String::from(
267 "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=16000&channels=1&model=nova-3&punctuate=true&smart_format=true&interim_results=true&utterance_end_ms=1000&vad_events=true&endpointing=200&no_delay=true&mip_opt_out=true"
282 "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=16000&channels=1&model=nova-3&mip_opt_out=true&punctuate=true&smart_format=true&interim_results=true&utterance_end_ms=1000&vad_events=true&endpointing=200&no_delay=true"
268283 );
269284
270285 if auto_detect {
Modifiedsrc-tauri/tauri.conf.json+3−3View fileUnifiedSplit
@@ -1,8 +1,8 @@
11{
22 "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
33 "productName": "Voxlen",
4 "version": "1.0.9",
5 "identifier": "ai.voxlen",
4 "version": "1.1.0",
5 "identifier": "com.voxlen.app",
66 "build": {
77 "beforeDevCommand": "npm run dev",
88 "devUrl": "http://localhost:1420",
@@ -48,7 +48,7 @@
4848 ],
4949 "category": "Productivity",
5050 "shortDescription": "AI-powered voice dictation for professionals",
51 "longDescription": "Voxlen is the most advanced AI voice dictation desktop app for lawyers and accountants. Speak naturally to dictate legal letters, court filings, demand letters, tax advisories, and audit opinions — with real-time Claude AI grammar correction, matter-aware context, custom vocabulary, and injection into any application.",
51 "longDescription": "Voxlen is the most advanced AI voice dictation desktop app for legal and accounting professionals. Speak naturally to dictate letters, court filings, tax advisories, and audit opinions — with real-time AI grammar correction, matter-aware context, custom vocabulary, and instant injection into any application.",
5252 "copyright": "Copyright 2026 Voxlen",
5353 "macOS": {
5454 "entitlements": "Entitlements.plist",
Modifiedsrc/App.tsx+23−5View fileUnifiedSplit
@@ -11,7 +11,7 @@ import { AdminPanel } from "@/components/settings/AdminPanel";
1111import { ClauseLibrary } from "@/components/clauses/ClauseLibrary";
1212import { AnalyticsPanel } from "@/components/analytics/AnalyticsPanel";
1313import { ClientsPanel } from "@/components/clients/ClientsPanel";
14import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
14import { OnboardingWizard, LEGAL_POLICY_VERSION } from "@/components/onboarding/OnboardingWizard";
1515import { ErrorBoundary } from "@/components/ErrorBoundary";
1616import { useAudioStore } from "@/stores/audio";
1717import { useSettingsStore } from "@/stores/settings";
@@ -65,17 +65,23 @@ export default function App() {
6565 const { load } = await import("@tauri-apps/plugin-store");
6666 const store = await load("settings.json");
6767 const hasCompletedOnboarding = await store.get<boolean>("onboarding_complete");
68 setShowOnboarding(!hasCompletedOnboarding);
6968
70 // Load saved settings
69 // Load saved settings first so we can check legalAcceptedVersion
7170 const savedSettings = await store.get<Record<string, unknown>>("settings");
7271 if (savedSettings) {
7372 useSettingsStore.getState().updateSettings(savedSettings);
7473 }
74
75 // Re-show onboarding if legal terms have been updated since last acceptance
76 const acceptedVersion = useSettingsStore.getState().legalAcceptedVersion;
77 const needsLegalAcceptance = acceptedVersion !== LEGAL_POLICY_VERSION;
78 setShowOnboarding(!hasCompletedOnboarding || needsLegalAcceptance);
7579 } catch {
7680 // Not in Tauri - check localStorage
7781 const completed = localStorage.getItem("voxlen_onboarding_complete");
78 setShowOnboarding(!completed);
82 const acceptedVersion = useSettingsStore.getState().legalAcceptedVersion;
83 const needsLegalAcceptance = acceptedVersion !== LEGAL_POLICY_VERSION;
84 setShowOnboarding(!completed || needsLegalAcceptance);
7985
8086 // Load saved settings from localStorage
8187 try {
@@ -230,6 +236,18 @@ export default function App() {
230236 setShowOnboarding(false);
231237 }, []);
232238
239 const handleReopenSetup = useCallback(async () => {
240 try {
241 const { load } = await import("@tauri-apps/plugin-store");
242 const store = await load("settings.json");
243 await store.delete("onboarding_complete");
244 await store.save();
245 } catch {
246 localStorage.removeItem("voxlen_onboarding_complete");
247 }
248 setShowOnboarding(true);
249 }, []);
250
233251 const renderView = useCallback(() => {
234252 switch (activeView) {
235253 case "dictation":
@@ -259,7 +277,7 @@ export default function App() {
259277 case "settings":
260278 return (
261279 <ErrorBoundary label="Settings">
262 <SettingsPanel />
280 <SettingsPanel onReopenSetup={handleReopenSetup} />
263281 </ErrorBoundary>
264282 );
265283 case "admin":
Modifiedsrc/components/clauses/ClauseLibrary.tsx+76−6View fileUnifiedSplit
@@ -1,9 +1,9 @@
1import { useState } from "react";
1import { useState, useRef } from "react";
22import { useClauseStore, Clause } from "@/stores/clauses";
33import { useDictationStore } from "@/stores/dictation";
44import { Button } from "@/components/ui/Button";
55import { cn } from "@/lib/utils";
6import { Search, FileText, Copy, Check, Plus, Pencil, Trash2, X } from "lucide-react";
6import { Search, FileText, Copy, Check, Plus, Pencil, Trash2, X, Download, Upload } from "lucide-react";
77
88const CATEGORY_LABELS: Record<string, string> = {
99 contract: "Contract",
@@ -51,6 +51,54 @@ export function ClauseLibrary() {
5151 const [editingId, setEditingId] = useState<string | null>(null);
5252 const [form, setForm] = useState<ClauseFormState>(EMPTY_FORM);
5353 const [formError, setFormError] = useState("");
54 const [importError, setImportError] = useState("");
55 const importRef = useRef<HTMLInputElement>(null);
56
57 const exportCustomClauses = () => {
58 const custom = clauses.filter((c) => customClauseIds.includes(c.id));
59 if (custom.length === 0) return;
60 const blob = new Blob([JSON.stringify(custom, null, 2)], { type: "application/json" });
61 const url = URL.createObjectURL(blob);
62 const a = document.createElement("a");
63 a.href = url;
64 a.download = `voxlen-clauses-${new Date().toISOString().slice(0, 10)}.json`;
65 a.click();
66 URL.revokeObjectURL(url);
67 };
68
69 const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
70 const file = e.target.files?.[0];
71 if (!file) return;
72 setImportError("");
73 const reader = new FileReader();
74 reader.onload = (ev) => {
75 try {
76 const parsed = JSON.parse(ev.target?.result as string) as unknown[];
77 if (!Array.isArray(parsed)) throw new Error("Expected an array of clauses");
78 let imported = 0;
79 for (const item of parsed) {
80 const c = item as Partial<Clause>;
81 if (!c.title || !c.text) continue;
82 addClause({
83 id: crypto.randomUUID(),
84 title: String(c.title),
85 category: (["contract","liability","ip","employment","gdpr","accounting","general"] as const).includes(c.category as never)
86 ? (c.category as Clause["category"])
87 : "general",
88 voiceTrigger: c.voiceTrigger ? String(c.voiceTrigger) : String(c.title).toLowerCase(),
89 text: String(c.text),
90 tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
91 });
92 imported++;
93 }
94 if (imported === 0) setImportError("No valid clauses found in file.");
95 } catch {
96 setImportError("Invalid file — expected a JSON array of clause objects.");
97 }
98 };
99 reader.readAsText(file);
100 e.target.value = "";
101 };
54102
55103 const filtered = clauses.filter((c) => {
56104 const matchesSearch =
@@ -145,11 +193,33 @@ export function ClauseLibrary() {
145193 Voice-insert standard legal & accounting clauses
146194 </p>
147195 </div>
148 <Button variant="secondary" size="sm" onClick={openNew}>
149 <Plus className="h-3 w-3" strokeWidth={2} />
150 New Clause
151 </Button>
196 <div className="flex items-center gap-1.5">
197 {customClauseIds.length > 0 && (
198 <Button variant="ghost" size="sm" onClick={exportCustomClauses} title="Export custom clauses as JSON">
199 <Download className="h-3 w-3" strokeWidth={1.75} />
200 </Button>
201 )}
202 <Button variant="ghost" size="sm" onClick={() => importRef.current?.click()} title="Import clauses from JSON">
203 <Upload className="h-3 w-3" strokeWidth={1.75} />
204 </Button>
205 <input
206 ref={importRef}
207 type="file"
208 accept=".json"
209 className="hidden"
210 onChange={handleImportFile}
211 />
212 <Button variant="secondary" size="sm" onClick={openNew}>
213 <Plus className="h-3 w-3" strokeWidth={2} />
214 New Clause
215 </Button>
216 </div>
152217 </div>
218 {importError && (
219 <div className="px-5 py-2 bg-red-500/10 border-b border-red-500/20 text-[11px] text-red-500">
220 {importError}
221 </div>
222 )}
153223
154224 {/* Tabs */}
155225 <div className="flex border-b border-surface-300/50 px-5 gap-4">
Modifiedsrc/components/clients/ClientsPanel.tsx+73−3View fileUnifiedSplit
@@ -1,5 +1,5 @@
1import { useState } from "react";
2import { Plus, Briefcase, Archive, Trash2, Edit2, Check, X, Download } from "lucide-react";
1import { useState, useRef } from "react";
2import { Plus, Briefcase, Archive, Trash2, Edit2, Check, X, Download, BookOpen } from "lucide-react";
33import { useClientsStore, type Client } from "../../stores/clients";
44import { useSettingsStore } from "../../stores/settings";
55import { exportBillingCsv, exportAllBillingCsv, downloadBillingExport } from "../../lib/export";
@@ -113,11 +113,14 @@ function AddClientModal({ onClose }: { onClose: () => void }) {
113113}
114114
115115function ClientCard({ client }: { client: Client }) {
116 const { updateClient, archiveClient, deleteClient, getClientEntries, getTotalBillable, getTotalHours } = useClientsStore();
116 const { updateClient, archiveClient, deleteClient, getClientEntries, getTotalBillable, getTotalHours, addVocabularyTerm, removeVocabularyTerm } = useClientsStore();
117117 const [editing, setEditing] = useState(false);
118118 const [editName, setEditName] = useState(client.name);
119119 const [editMatter, setEditMatter] = useState(client.matterNumber ?? "");
120120 const [editRate, setEditRate] = useState(client.billableRate);
121 const [editDescription, setEditDescription] = useState(client.matterDescription ?? "");
122 const [newVocabTerm, setNewVocabTerm] = useState("");
123 const vocabInputRef = useRef<HTMLInputElement>(null);
121124
122125 const entries = getClientEntries(client.id);
123126 const totalBillable = getTotalBillable(client.id);
@@ -129,10 +132,19 @@ function ClientCard({ client }: { client: Client }) {
129132 name: editName.trim() || client.name,
130133 matterNumber: editMatter.trim() || undefined,
131134 billableRate: editRate,
135 matterDescription: editDescription.trim() || undefined,
132136 });
133137 setEditing(false);
134138 };
135139
140 const handleAddVocab = () => {
141 const term = newVocabTerm.trim();
142 if (!term) return;
143 addVocabularyTerm(client.id, term);
144 setNewVocabTerm("");
145 vocabInputRef.current?.focus();
146 };
147
136148 return (
137149 <div className="bg-surface-50 border border-surface-300/60 rounded-xl overflow-hidden shadow-inset-hairline">
138150 {/* Header */}
@@ -170,6 +182,13 @@ function ClientCard({ client }: { client: Client }) {
170182 {editRate === 0 ? "Default" : `$${editRate}/hr`}
171183 </span>
172184 </div>
185 <textarea
186 value={editDescription}
187 onChange={(e) => setEditDescription(e.target.value)}
188 placeholder="Matter description (used by AI for context-aware correction)"
189 rows={2}
190 className="w-full bg-[#09090b] border border-[#3f3f46] rounded px-2 py-1 text-xs text-white placeholder-zinc-600 focus:outline-none focus:border-[#7345d1] resize-none"
191 />
173192 </div>
174193 ) : (
175194 <>
@@ -254,6 +273,57 @@ function ClientCard({ client }: { client: Client }) {
254273 ))}
255274 </div>
256275 )}
276
277 {/* Vocabulary section */}
278 <div className="p-3 border-t border-[#27272a]">
279 <div className="flex items-center gap-1.5 mb-2">
280 <BookOpen className="w-3 h-3 text-zinc-500" />
281 <p className="text-[10px] text-zinc-500 uppercase tracking-wider">
282 Matter Vocabulary
283 </p>
284 {client.vocabulary && client.vocabulary.length > 0 && (
285 <span className="text-[10px] text-zinc-600 ml-auto">{client.vocabulary.length} terms</span>
286 )}
287 </div>
288 {client.vocabulary && client.vocabulary.length > 0 && (
289 <div className="flex flex-wrap gap-1.5 mb-2">
290 {client.vocabulary.map((term) => (
291 <span
292 key={term}
293 className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#27272a] text-xs text-zinc-300"
294 >
295 {term}
296 <button
297 onClick={() => removeVocabularyTerm(client.id, term)}
298 className="text-zinc-600 hover:text-red-400 transition-colors"
299 >
300 <X className="w-2.5 h-2.5" />
301 </button>
302 </span>
303 ))}
304 </div>
305 )}
306 <div className="flex gap-1.5">
307 <input
308 ref={vocabInputRef}
309 value={newVocabTerm}
310 onChange={(e) => setNewVocabTerm(e.target.value)}
311 onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddVocab(); } }}
312 placeholder="Add term (party names, case refs…)"
313 className="flex-1 bg-[#09090b] border border-[#3f3f46] rounded px-2 py-1 text-xs text-white placeholder-zinc-600 focus:outline-none focus:border-[#7345d1]"
314 />
315 <button
316 onClick={handleAddVocab}
317 disabled={!newVocabTerm.trim()}
318 className="px-2 py-1 rounded bg-[#27272a] text-zinc-400 hover:text-white hover:bg-[#3f3f46] transition-colors disabled:opacity-40 text-xs"
319 >
320 <Plus className="w-3.5 h-3.5" />
321 </button>
322 </div>
323 {client.matterDescription && (
324 <p className="text-[10px] text-zinc-600 mt-2 italic leading-relaxed">{client.matterDescription}</p>
325 )}
326 </div>
257327 </div>
258328 );
259329}
Modifiedsrc/components/dictation/DictationPanel.tsx+104−8View fileUnifiedSplit
@@ -14,19 +14,21 @@ import {
1414 ChevronDown,
1515 HelpCircle,
1616 Download,
17 ShieldCheck,
18 ShieldOff,
1719} from "lucide-react";
1820import { cn } from "@/lib/utils";
1921import { Button } from "@/components/ui/Button";
2022import { Badge } from "@/components/ui/Badge";
2123import { Waveform } from "./Waveform";
2224import { TranscriptView } from "./TranscriptView";
23import { useDictationStore, buildSessionRecord } from "@/stores/dictation";
25import { useDictationStore, buildSessionRecord, loadDraftRecord } from "@/stores/dictation";
2426import { useAudioStore } from "@/stores/audio";
2527import { useSettingsStore } from "@/stores/settings";
2628import { formatDuration } from "@/lib/utils";
2729import { useHistoryStore } from "@/stores/history";
2830import { useFlywheelStore } from "@/stores/flywheel";
29import { useClientsStore } from "@/stores/clients";
31import { useClientsStore, buildMatterContext } from "@/stores/clients";
3032import { VoiceCommandsHelp } from "@/components/layout/VoiceCommandsHelp";
3133import { SUPPORTED_LANGUAGES } from "@/lib/constants";
3234import { toast } from "@/components/ui/Toast";
@@ -49,11 +51,27 @@ export function DictationPanel() {
4951 const shortcutToggle = useSettingsStore((s) => s.shortcutToggle);
5052 const showWaveform = useSettingsStore((s) => s.showWaveform);
5153
54 const restoreDraft = useDictationStore((s) => s.restoreDraft);
55 const discardDraft = useDictationStore((s) => s.discardDraft);
56
5257 const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
5358 const sessionStartRef = useRef<Date | null>(null);
5459
60 const [pendingDraft, setPendingDraft] = useState<ReturnType<typeof loadDraftRecord>>(null);
61
5562 const selectedDevice = devices.find((d) => d.id === selectedDeviceId);
5663
64 // Check for unsaved draft on mount
65 useEffect(() => {
66 if (segments.length === 0) {
67 const draft = loadDraftRecord();
68 if (draft && draft.segments.length > 0) {
69 setPendingDraft(draft);
70 }
71 }
72 // eslint-disable-next-line react-hooks/exhaustive-deps
73 }, []);
74
5775 // Session timer
5876 useEffect(() => {
5977 if (status === "listening") {
@@ -78,12 +96,23 @@ export function DictationPanel() {
7896 if (status === "idle" || status === "paused" || status === "error") {
7997 sessionStartRef.current = new Date();
8098 try {
81 const { invoke, isTauri } = await import("@tauri-apps/api/core");
82 if (!isTauri()) {
83 // Browser demo mode — no Tauri runtime available
84 setStatus("listening");
85 return;
99 const { invoke } = await import("@tauri-apps/api/core");
100
101 // Merge active client's matter vocabulary into STT config before starting
102 const { activeClientId: cid, clients: cls } = useClientsStore.getState();
103 const activeClientForSTT = cls.find((c) => c.id === cid);
104 const matterVocab = activeClientForSTT?.vocabulary ?? [];
105 const globalVocab = useSettingsStore.getState().customVocabulary;
106 const mergedVocab = [...new Set([...globalVocab, ...matterVocab])];
107 if (mergedVocab.length !== globalVocab.length) {
108 try {
109 const currentCfg = await invoke<Record<string, unknown>>("get_stt_config");
110 await invoke("set_stt_config", { config: { ...currentCfg, custom_vocabulary: mergedVocab } });
111 } catch {
112 // Non-fatal — proceed without updating vocabulary
113 }
86114 }
115
87116 await invoke("start_dictation");
88117 setStatus("listening");
89118 } catch (e) {
@@ -197,6 +226,17 @@ export function DictationPanel() {
197226 async (text: string) => {
198227 try {
199228 const { invoke } = await import("@tauri-apps/api/core");
229 const { activeClientId: cid, clients: cls } = useClientsStore.getState();
230 const activeClientForGrammar = cls.find((c) => c.id === cid);
231 const matterContext = buildMatterContext(activeClientForGrammar) || undefined;
232 const flywheelVocab = useFlywheelStore.getState().vocabulary
233 .filter((v) => v.frequency >= 2)
234 .map((v) => v.word);
235 const clientVocab = activeClientForGrammar?.vocabulary ?? [];
236 const globalVocabList = useSettingsStore.getState().customVocabulary;
237 const mergedVocab = Array.from(new Set([...flywheelVocab, ...clientVocab, ...globalVocabList]));
238 const customVocabulary = mergedVocab.length > 0 ? mergedVocab : undefined;
239
200240 const result = await invoke<{
201241 corrected: string;
202242 changes: Array<{
@@ -204,7 +244,7 @@ export function DictationPanel() {
204244 corrected: string;
205245 reason: string;
206246 }>;
207 }>("correct_grammar", { text });
247 }>("correct_grammar", { text, customVocabulary, matterContext });
208248
209249 // Update the last segment with corrected text
210250 if (segments.length > 0) {
@@ -257,6 +297,7 @@ export function DictationPanel() {
257297 }, [clearSession]);
258298
259299 const voxlenContext = useSettingsStore((s) => s.voxlenContext);
300 const privilegedMode = useSettingsStore((s) => s.privilegedMode);
260301 const updateSetting = useSettingsStore((s) => s.updateSetting);
261302 const [contextOpen, setContextOpen] = useState(false);
262303 const [langOpen, setLangOpen] = useState(false);
@@ -298,6 +339,44 @@ export function DictationPanel() {
298339
299340 return (
300341 <div className="flex flex-col h-full">
342 {/* Privileged mode banner */}
343 {privilegedMode && (
344 <div className="flex items-center gap-2.5 px-5 py-2.5 bg-emerald-950/60 border-b border-emerald-500/20">
345 <ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0" strokeWidth={1.75} />
346 <p className="text-[11px] text-emerald-300 font-medium">
347 Privileged mode active — attorney-client privilege protected. Cloud grammar and translation disabled.
348 </p>
349 <button
350 onClick={() => updateSetting("privilegedMode", false)}
351 className="ml-auto text-[10px] text-emerald-600 hover:text-emerald-400 underline transition-colors shrink-0"
352 >
353 Disable
354 </button>
355 </div>
356 )}
357 {/* Draft recovery banner */}
358 {pendingDraft && (
359 <div className="flex items-center gap-2.5 px-5 py-2.5 bg-amber-950/60 border-b border-amber-500/20">
360 <FileText className="h-3.5 w-3.5 text-amber-400 shrink-0" strokeWidth={1.75} />
361 <p className="text-[11px] text-amber-300 font-medium">
362 Unsaved draft from {new Date(pendingDraft.savedAt).toLocaleString()} ({pendingDraft.segments.length} segment{pendingDraft.segments.length !== 1 ? "s" : ""}) — restore?
363 </p>
364 <div className="ml-auto flex items-center gap-3 shrink-0">
365 <button
366 onClick={() => { restoreDraft(pendingDraft); setPendingDraft(null); }}
367 className="text-[10px] text-amber-400 hover:text-amber-200 font-semibold underline transition-colors"
368 >
369 Restore
370 </button>
371 <button
372 onClick={() => { discardDraft(); setPendingDraft(null); }}
373 className="text-[10px] text-amber-600 hover:text-amber-400 underline transition-colors"
374 >
375 Discard
376 </button>
377 </div>
378 </div>
379 )}
301380 {/* Main dictation area */}
302381 <div className="flex-1 flex flex-col p-8 gap-7 overflow-hidden">
303382 {/* Mic control + waveform */}
@@ -591,6 +670,23 @@ export function DictationPanel() {
591670 </div>
592671
593672 <div className="flex items-center gap-2">
673 <button
674 onClick={() => updateSetting("privilegedMode", !privilegedMode)}
675 title={privilegedMode ? "Privileged mode ON — click to disable" : "Enable privileged mode (ABA 1.6 safe)"}
676 className={cn(
677 "flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium transition-colors",
678 privilegedMode
679 ? "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20 hover:bg-emerald-500/15"
680 : "text-surface-600 hover:bg-surface-100 hover:text-surface-800"
681 )}
682 >
683 {privilegedMode ? (
684 <ShieldCheck className="h-3.5 w-3.5" strokeWidth={1.75} />
685 ) : (
686 <ShieldOff className="h-3.5 w-3.5" strokeWidth={1.75} />
687 )}
688 {privilegedMode ? "Privileged" : "Privilege"}
689 </button>
594690 <Button
595691 variant="ghost"
596692 size="sm"
Modifiedsrc/components/dictation/HistoryPanel.tsx+35−19View fileUnifiedSplit
@@ -44,23 +44,36 @@ export function HistoryPanel() {
4444 setTimeout(() => setCopiedId(null), 2000);
4545 };
4646
47 const handleExport = (entry: (typeof entries)[0], format: "txt" | "rtf") => {
47 const handleExport = (entry: (typeof entries)[0], format: "txt" | "md") => {
4848 const date = new Date(entry.timestamp);
4949 const dateStr = date.toISOString().slice(0, 10);
5050 let content: string;
5151 let mimeType: string;
5252 let ext: string;
53 if (format === "rtf") {
54 const escaped = entry.text.replace(/\\/g, "\\\\").replace(/\{/g, "\\{").replace(/\}/g, "\\}");
55 const dateLabel = date.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
56 content = `{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0\\froman\\fcharset0 Times New Roman;}{\\f1\\fswiss\\fcharset0 Arial;}}\\f1\\fs28\\b Voxlen Transcript\\b0\\par\\fs20 ${escaped.replace(/[^\x00-\x7F]/g, (c) => `\\u${c.charCodeAt(0)}?`)}\\par\\par{\\fs18 ${dateLabel} — ${entry.wordCount} words}\\par\\par\\f0\\fs24\\sl360\\slmult1 ${escaped}\\par}`;
57 mimeType = "application/rtf";
58 ext = "rtf";
53
54 if (format === "md") {
55 const lines = [
56 "# Voxlen Transcript",
57 "",
58 `**Date:** ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`,
59 `**Words:** ${entry.wordCount}`,
60 `**Duration:** ${formatDuration(entry.duration)}`,
61 `**Language:** ${entry.language.toUpperCase()}`,
62 entry.grammarCorrected ? "**AI Polished:** Yes" : "",
63 "",
64 "---",
65 "",
66 entry.text,
67 ].filter((l) => l !== undefined);
68 content = lines.join("\n");
69 mimeType = "text/markdown";
70 ext = "md";
5971 } else {
6072 content = entry.text;
6173 mimeType = "text/plain";
6274 ext = "txt";
6375 }
76
6477 const blob = new Blob([content], { type: mimeType });
6578 const url = URL.createObjectURL(blob);
6679 const a = document.createElement("a");
@@ -180,26 +193,29 @@ export function HistoryPanel() {
180193 <Copy className="h-3 w-3" />
181194 )}
182195 </Button>
183 <div className="relative">
196 <div className="relative group/dl">
184197 <Button
185198 variant="ghost"
186199 size="sm"
187 onClick={() => setExportMenuId(exportMenuId === entry.id ? null : entry.id)}
188200 className="h-7 px-2"
189 title="Export"
201 title="Export transcript"
190202 >
191203 <Download className="h-3 w-3" />
192 <ChevronDown className="h-2.5 w-2.5 ml-0.5" />
193204 </Button>
194 {exportMenuId === entry.id && (
195 <div
196 className="absolute right-0 top-full mt-1 w-36 rounded-lg border border-surface-300/60 bg-surface-50 shadow-lg z-50 py-1"
197 onMouseLeave={() => setExportMenuId(null)}
205 <div className="absolute right-0 top-full mt-1 hidden group-hover/dl:flex flex-col z-10 bg-white border border-surface-300/70 rounded-lg shadow-elevation overflow-hidden min-w-[80px]">
206 <button
207 onClick={() => handleExport(entry, "txt")}
208 className="px-3 py-1.5 text-[11px] text-surface-700 hover:bg-surface-100 text-left"
198209 >
199 <button onClick={() => { handleExport(entry, "txt"); setExportMenuId(null); }} className="w-full text-left px-3 py-1.5 text-[11px] text-surface-900 hover:bg-surface-100">Plain text (.txt)</button>
200 <button onClick={() => { handleExport(entry, "rtf"); setExportMenuId(null); }} className="w-full text-left px-3 py-1.5 text-[11px] text-surface-900 hover:bg-surface-100">Word / RTF (.rtf)</button>
201 </div>
202 )}
210 .txt
211 </button>
212 <button
213 onClick={() => handleExport(entry, "md")}
214 className="px-3 py-1.5 text-[11px] text-surface-700 hover:bg-surface-100 text-left"
215 >
216 .md
217 </button>
218 </div>
203219 </div>
204220 <Button
205221 variant="ghost"
Modifiedsrc/components/dictation/TranscriptView.tsx+14−1View fileUnifiedSplit
@@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState, useCallback } from "react";
22import { cn } from "@/lib/utils";
33import { useDictationStore } from "@/stores/dictation";
44import { useSettingsStore } from "@/stores/settings";
5import { Copy, Check, Wand2, Languages, Pencil, Trash2, Replace, AlignLeft, List } from "lucide-react";
5import { useFlywheelStore } from "@/stores/flywheel";
6import { Copy, Check, Wand2, Languages, Pencil, Trash2, Replace, AlignLeft, List, RotateCcw } from "lucide-react";
67import { Button } from "@/components/ui/Button";
78import { formatTimestamp } from "@/lib/utils";
89
@@ -367,6 +368,18 @@ export function TranscriptView({
367368 </span>
368369 )}
369370 <span className="inline-flex items-center gap-0.5 ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity align-middle">
371 {segment.grammarApplied && segment.correctedText && (
372 <button
373 onClick={() => {
374 updateSegment(segment.id, { correctedText: undefined, grammarApplied: false });
375 useFlywheelStore.getState().recordCorrectionFeedback(false);
376 }}
377 className="p-0.5 rounded text-surface-400 hover:text-amber-500 transition-colors"
378 title="Revert to original transcription"
379 >
380 <RotateCcw className="h-2.5 w-2.5" strokeWidth={2} />
381 </button>
382 )}
370383 <button
371384 onClick={() => startEdit(segment.id, segment.correctedText ?? segment.text)}
372385 className="p-0.5 rounded text-surface-400 hover:text-brass-500 transition-colors"
Modifiedsrc/components/grammar/GrammarPanel.tsx+12−1View fileUnifiedSplit
@@ -12,6 +12,8 @@ import { Button } from "@/components/ui/Button";
1212import { Badge } from "@/components/ui/Badge";
1313import { Select } from "@/components/ui/Select";
1414import { useSettingsStore } from "@/stores/settings";
15import { useClientsStore, buildMatterContext } from "@/stores/clients";
16import { useFlywheelStore } from "@/stores/flywheel";
1517
1618interface GrammarChange {
1719 original: string;
@@ -30,6 +32,7 @@ export function GrammarPanel() {
3032 const [error, setError] = useState<string | null>(null);
3133 const writingStyle = useSettingsStore((s) => s.writingStyle);
3234 const updateSetting = useSettingsStore((s) => s.updateSetting);
35 const activeClient = useClientsStore((s) => s.clients.find((c) => c.id === s.activeClientId));
3336
3437 const handleCorrect = useCallback(async () => {
3538 if (!inputText.trim()) return;
@@ -38,12 +41,20 @@ export function GrammarPanel() {
3841 setError(null);
3942 try {
4043 const { invoke } = await import("@tauri-apps/api/core");
44 const matterContext = buildMatterContext(activeClient) || undefined;
45 const flywheelVocab = useFlywheelStore.getState().vocabulary
46 .filter((v) => v.frequency >= 2)
47 .map((v) => v.word);
48 const clientVocab = activeClient?.vocabulary ?? [];
49 const globalVocabList = useSettingsStore.getState().customVocabulary;
50 const mergedVocab = Array.from(new Set([...flywheelVocab, ...clientVocab, ...globalVocabList]));
51 const customVocabulary = mergedVocab.length > 0 ? mergedVocab : undefined;
4152 const result = await invoke<{
4253 original: string;
4354 corrected: string;
4455 changes: GrammarChange[];
4556 score: number;
46 }>("correct_grammar", { text: inputText });
57 }>("correct_grammar", { text: inputText, customVocabulary, matterContext });
4758
4859 setCorrectedText(result.corrected);
4960 setChanges(result.changes);
Modifiedsrc/components/layout/VoiceCommandsHelp.tsx+7−0View fileUnifiedSplit
@@ -60,6 +60,13 @@ const ALL_COMMANDS: CommandEntry[] = [
6060 { command: "insert engagement terms", description: "Insert accounting engagement terms", category: "clause" },
6161 { command: "insert accountant liability cap", description: "Insert accountant liability cap clause", category: "clause" },
6262 { command: "insert without prejudice", description: "Insert WITHOUT PREJUDICE header", category: "clause" },
63 { command: "insert arbitration clause", description: "Insert arbitration / dispute resolution clause", category: "clause" },
64 { command: "insert dispute resolution clause", description: "Insert negotiation + mediation clause", category: "clause" },
65 { command: "insert warranty disclaimer", description: "Insert AS IS warranty disclaimer", category: "clause" },
66 { command: "insert governing law New Zealand", description: "Insert governing law (New Zealand)", category: "clause" },
67 { command: "insert governing law Ontario", description: "Insert governing law (Ontario, Canada)", category: "clause" },
68 { command: "insert payment terms", description: "Insert payment terms (30-day, 1.5%/mo interest)", category: "clause" },
69 { command: "insert termination for cause", description: "Insert termination for cause clause", category: "clause" },
6370];
6471
6572type Category = "all" | "formatting" | "control" | "billing" | "clause";
Modifiedsrc/components/settings/SettingsPanel.tsx+15−6View fileUnifiedSplit
@@ -119,7 +119,7 @@ function useSettingsPersistence() {
119119 });
120120}
121121
122export function SettingsPanel() {
122export function SettingsPanel({ onReopenSetup }: { onReopenSetup?: () => void } = {}) {
123123 const settings = useSettingsStore();
124124 const setDevices = useAudioStore((s) => s.setDevices);
125125
@@ -189,7 +189,7 @@ export function SettingsPanel() {
189189 case "appearance":
190190 return <AppearanceSettings />;
191191 case "advanced":
192 return <AdvancedSettings />;
192 return <AdvancedSettings onReopenSetup={onReopenSetup} />;
193193 case "privacy":
194194 return <PrivacySettings />;
195195 case "voxlen-api":
@@ -895,7 +895,7 @@ function AppearanceSettings() {
895895 );
896896}
897897
898function AdvancedSettings() {
898function AdvancedSettings({ onReopenSetup }: { onReopenSetup?: () => void }) {
899899 const settings = useSettingsStore();
900900
901901 return (
@@ -924,8 +924,8 @@ function AdvancedSettings() {
924924 },
925925 {
926926 value: "clipboard",
927 label: "Clipboard Paste",
928 description: "Copies to clipboard and pastes (faster)",
927 label: "Clipboard / Citrix Mode",
928 description: "Copies to clipboard then pastes — works in Citrix, VMware Horizon, and VDI sessions",
929929 },
930930 {
931931 value: "buffer",
@@ -975,7 +975,16 @@ function AdvancedSettings() {
975975 />
976976 </SettingRow>
977977
978 <div className="pt-4">
978 <div className="pt-4 flex items-center gap-3 flex-wrap">
979 {onReopenSetup && (
980 <Button
981 variant="ghost"
982 size="sm"
983 onClick={onReopenSetup}
984 >
985 Re-run Setup Wizard
986 </Button>
987 )}
979988 <Button
980989 variant="danger"
981990 size="sm"
Modifiedsrc/hooks/useGlobalShortcuts.ts+37−2View fileUnifiedSplit
@@ -1,6 +1,8 @@
11import { useEffect } from "react";
22import { useDictationStore } from "@/stores/dictation";
33import { useSettingsStore } from "@/stores/settings";
4import { useFlywheelStore } from "@/stores/flywheel";
5import { useClientsStore, buildMatterContext } from "@/stores/clients";
46
57/**
68 * Registers all four configured global shortcuts with the Tauri
@@ -132,9 +134,27 @@ export function useGlobalShortcuts(enabled: boolean): void {
132134 const textToCorrect = selection || lastSegment?.correctedText || lastSegment?.text || "";
133135 if (!textToCorrect.trim()) return;
134136
135 const result = await invoke<{ corrected: string }>(
137 const flyVocab = useFlywheelStore.getState().vocabulary
138 .filter((v) => v.frequency >= 2)
139 .map((v) => v.word);
140 const { activeClientId, clients } = useClientsStore.getState();
141 const activeClient = clients.find((c) => c.id === activeClientId);
142 const clientVocab = activeClient?.vocabulary ?? [];
143 const globalVocab = useSettingsStore.getState().customVocabulary;
144 const mergedVocab = Array.from(new Set([...flyVocab, ...clientVocab, ...globalVocab]));
145 const matterContext = buildMatterContext(activeClient) || undefined;
146
147 const result = await invoke<{
148 corrected: string;
149 changes: Array<{ original: string; corrected: string; reason: string; category: string }>;
150 score: number;
151 }>(
136152 "correct_grammar",
137 { text: textToCorrect }
153 {
154 text: textToCorrect,
155 customVocabulary: mergedVocab.length > 0 ? mergedVocab : undefined,
156 matterContext,
157 }
138158 );
139159
140160 if (lastSegment && !selection) {
@@ -144,6 +164,21 @@ export function useGlobalShortcuts(enabled: boolean): void {
144164 });
145165 }
146166
167 // Feed corrections back into flywheel (same as auto-grammar path)
168 if (result.changes?.length) {
169 const fw = useFlywheelStore.getState();
170 for (const c of result.changes) {
171 if (c.original && c.corrected && c.original !== c.corrected) {
172 fw.recordCorrection(
173 c.original,
174 c.corrected,
175 (c.category as "grammar" | "spelling" | "punctuation" | "style") ?? "grammar"
176 );
177 }
178 }
179 fw.recordCorrectionFeedback(true);
180 }
181
147182 // Inject corrected text into the focused app.
148183 try {
149184 await invoke("inject_text", { text: result.corrected });
Modifiedsrc/hooks/useTauriEvents.ts+74−4View fileUnifiedSplit
@@ -6,7 +6,7 @@ import { toast } from "@/components/ui/Toast";
66import { processVoiceCommands, executeVoiceCommand, applyTextCommand } from "@/lib/voiceCommands";
77import { useFlywheelStore } from "@/stores/flywheel";
88import { useHistoryStore } from "@/stores/history";
9import { useClientsStore } from "@/stores/clients";
9import { useClientsStore, buildMatterContext } from "@/stores/clients";
1010import { applySmartFormat } from "@/lib/smartFormat";
1111import { applyContextFormat } from "@/lib/contextFormat";
1212import type { VoxlenContext } from "@/lib/contextFormat";
@@ -125,7 +125,39 @@ export function useTauriEvents(): void {
125125 const minutes = minuteMap[output] ?? 30;
126126 const { logTime } = useFlywheelStore.getState();
127127 const { billableRatePerHour } = useSettingsStore.getState() as { billableRatePerHour?: number };
128 logTime(minutes, "", parsed.remainingText || "", billableRatePerHour ?? 0);
128 const note = parsed.remainingText || "";
129 logTime(minutes, "", note, billableRatePerHour ?? 0);
130 // Also log to active client so it appears in billing dashboard
131 const { activeClientId: vcClientId, clients: vcClients, addEntry: vcAddEntry } = useClientsStore.getState();
132 if (vcClientId) {
133 const vcClient = vcClients.find((c) => c.id === vcClientId);
134 if (vcClient) {
135 const rate = vcClient.billableRate > 0 ? vcClient.billableRate : (billableRatePerHour ?? 0);
136 vcAddEntry({
137 clientId: vcClientId,
138 date: Date.now(),
139 durationSeconds: minutes * 60,
140 wordCount: 0,
141 billableAmount: (minutes / 60) * rate,
142 note,
143 });
144 }
145 }
146 dictation.setCurrentTranscript("");
147 return;
148 }
149
150 // Review uncertain words — show a toast with count
151 if (parsed.action === "review_uncertain") {
152 const segs = useDictationStore.getState().segments;
153 const uncertain = segs.flatMap((s) =>
154 (s.words ?? []).filter((w) => w.confidence < 0.75)
155 );
156 if (uncertain.length > 0) {
157 toast(`${uncertain.length} uncertain word${uncertain.length !== 1 ? "s" : ""} highlighted in transcript`, "info", 4000);
158 } else {
159 toast("No uncertain words in transcript", "info", 3000);
160 }
129161 dictation.setCurrentTranscript("");
130162 return;
131163 }
@@ -278,11 +310,20 @@ export function useTauriEvents(): void {
278310 // Step 1: grammar correction
279311 if (grammarEnabled) {
280312 try {
313 const flyVocab = useFlywheelStore.getState().vocabulary
314 .filter((v) => v.frequency >= 2)
315 .map((v) => v.word);
316 const { activeClientId: acid, clients: acls } = useClientsStore.getState();
317 const activeClientForAuto = acls.find((c) => c.id === acid);
318 const clientVocabAuto = activeClientForAuto?.vocabulary ?? [];
319 const mergedVocab = Array.from(new Set([...flyVocab, ...clientVocabAuto, ...settings.customVocabulary]));
320 const matterContextAuto = buildMatterContext(activeClientForAuto) || undefined;
281321 const grammarResult = await invoke<{ corrected: string; changes: Array<{ original: string; corrected: string; reason: string; category: string }>; score: number }>(
282322 "correct_grammar",
283323 {
284324 text: finalText,
285 customVocabulary: settings.customVocabulary,
325 customVocabulary: mergedVocab.length > 0 ? mergedVocab : undefined,
326 matterContext: matterContextAuto,
286327 }
287328 );
288329 if (grammarResult?.corrected) {
@@ -291,6 +332,20 @@ export function useTauriEvents(): void {
291332 correctedText: grammarResult.corrected,
292333 grammarApplied: true,
293334 });
335 // Feed corrections back into flywheel
336 if (grammarResult.changes?.length) {
337 const fw = useFlywheelStore.getState();
338 for (const c of grammarResult.changes) {
339 if (c.original && c.corrected && c.original !== c.corrected) {
340 fw.recordCorrection(
341 c.original,
342 c.corrected,
343 (c.category as "grammar" | "spelling" | "punctuation" | "style") ?? "grammar"
344 );
345 }
346 }
347 fw.recordCorrectionFeedback(true);
348 }
294349 }
295350 } catch {
296351 // Grammar unavailable (no API key etc.) — continue with raw text.
@@ -350,7 +405,7 @@ export function useTauriEvents(): void {
350405 });
351406
352407 const unlistenReconnecting = await listen<number>("streaming-reconnecting", (event) => {
353 toast(`Reconnecting to Deepgram… (attempt ${event.payload})`, "info", 3000);
408 toast(`Reconnecting to transcription service… (attempt ${event.payload})`, "info", 3000);
354409 });
355410
356411 unlisten = () => {
@@ -446,6 +501,21 @@ export function useTauriEvents(): void {
446501 })();
447502 }
448503 }
504
505 // Session summary toast
506 if (segments.length > 0) {
507 const wc = segments.reduce(
508 (n, s) => n + (s.correctedText || s.text).split(/\s+/).filter(Boolean).length,
509 0
510 );
511 const corrected = segments.filter((s) => s.grammarApplied).length;
512 const mins = Math.floor(useDictationStore.getState().sessionDuration / 60);
513 const secs = useDictationStore.getState().sessionDuration % 60;
514 const duration = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
515 const parts = [`${wc} word${wc !== 1 ? "s" : ""}`, duration];
516 if (corrected > 0) parts.push(`${corrected} correction${corrected !== 1 ? "s" : ""}`);
517 toast(`Session saved — ${parts.join(" · ")}`, "success", 4000);
518 }
449519 }
450520 lastStatus = current;
451521 });
Addedsrc/lib/settings.test.ts+118−0View fileUnifiedSplit
@@ -0,0 +1,118 @@
1import { describe, it, expect } from "vitest";
2import { toBackendSettings, fromBackendSettings } from "./settings";
3import type { AppSettings } from "@/stores/settings";
4
5const DEFAULTS: AppSettings = {
6 preferredDeviceId: null,
7 inputGain: 1.0,
8 noiseSuppression: true,
9 sttEngine: "deepgram",
10 sttApiKey: "",
11 sttLanguage: "en-US",
12 autoDetectLanguage: false,
13 customVocabulary: [],
14 speakerDiarization: false,
15 grammarEnabled: true,
16 grammarApiKey: "",
17 grammarProvider: "claude",
18 writingStyle: "professional",
19 autoCorrect: false,
20 preserveTone: true,
21 autoPunctuate: true,
22 smartFormat: true,
23 voiceCommandsEnabled: true,
24 translationEnabled: false,
25 translationTargetLanguage: "en",
26 injectionMode: "keyboard",
27 shortcutToggle: "CmdOrCtrl+Shift+D",
28 shortcutPushToTalk: "",
29 shortcutCancel: "Escape",
30 shortcutCorrectGrammar: "CmdOrCtrl+Shift+G",
31 theme: "dark",
32 showWaveform: true,
33 fontSize: 14,
34 startMinimized: false,
35 minimizeToTray: true,
36 launchAtLogin: false,
37 telemetryEnabled: false,
38 saveTranscripts: true,
39 privilegedMode: false,
40 legalMode: false,
41 jurisdiction: "global",
42 billableRatePerHour: 350,
43 voxlenApiKey: "",
44 voxlenContext: "general",
45 voxlenTenantId: "",
46 legalAcceptedVersion: null,
47 legalAcceptedAt: null,
48};
49
50describe("toBackendSettings", () => {
51 it("converts camelCase to snake_case for all backend fields", () => {
52 const backend = toBackendSettings(DEFAULTS);
53 expect(backend.preferred_device_id).toBeNull();
54 expect(backend.input_gain).toBe(1.0);
55 expect(backend.noise_suppression).toBe(true);
56 expect(backend.stt_engine).toBe("deepgram");
57 expect(backend.grammar_enabled).toBe(true);
58 expect(backend.grammar_provider).toBe("claude");
59 expect(backend.writing_style).toBe("professional");
60 expect(backend.auto_correct).toBe(false);
61 expect(backend.shortcut_toggle).toBe("CmdOrCtrl+Shift+D");
62 expect(backend.privileged_mode).toBe(false);
63 expect(backend.legal_mode).toBe(false);
64 expect(backend.jurisdiction).toBe("global");
65 expect(backend.voxlen_context).toBe("general");
66 });
67
68 it("passes customVocabulary array through unchanged", () => {
69 const settings = { ...DEFAULTS, customVocabulary: ["EBITDA", "estoppel"] };
70 const backend = toBackendSettings(settings);
71 expect(backend.custom_vocabulary).toEqual(["EBITDA", "estoppel"]);
72 });
73});
74
75describe("fromBackendSettings", () => {
76 it("converts snake_case to camelCase for all backend fields", () => {
77 const backend = toBackendSettings(DEFAULTS);
78 const restored = fromBackendSettings(backend);
79 expect(restored.inputGain).toBe(1.0);
80 expect(restored.noiseSuppression).toBe(true);
81 expect(restored.sttEngine).toBe("deepgram");
82 expect(restored.grammarEnabled).toBe(true);
83 expect(restored.writingStyle).toBe("professional");
84 expect(restored.shortcutToggle).toBe("CmdOrCtrl+Shift+D");
85 expect(restored.privilegedMode).toBe(false);
86 expect(restored.voxlenContext).toBe("general");
87 });
88
89 it("round-trips all mapped fields without data loss", () => {
90 const settings = {
91 ...DEFAULTS,
92 inputGain: 0.8,
93 sttApiKey: "dg-test-key",
94 grammarProvider: "openai" as const,
95 writingStyle: "academic" as const,
96 customVocabulary: ["NetSuite", "GAAP"],
97 injectionMode: "clipboard" as const,
98 theme: "light" as const,
99 fontSize: 16,
100 };
101 const restored = fromBackendSettings(toBackendSettings(settings));
102 expect(restored.inputGain).toBe(0.8);
103 expect(restored.sttApiKey).toBe("dg-test-key");
104 expect(restored.grammarProvider).toBe("openai");
105 expect(restored.writingStyle).toBe("academic");
106 expect(restored.customVocabulary).toEqual(["NetSuite", "GAAP"]);
107 expect(restored.injectionMode).toBe("clipboard");
108 expect(restored.theme).toBe("light");
109 expect(restored.fontSize).toBe(16);
110 });
111
112 it("omits undefined fields from partial backend response", () => {
113 const partial = fromBackendSettings({ stt_engine: "deepgram" });
114 expect(partial.sttEngine).toBe("deepgram");
115 expect(partial.inputGain).toBeUndefined();
116 expect(partial.grammarEnabled).toBeUndefined();
117 });
118});
Addedsrc/lib/utils.test.ts+99−0View fileUnifiedSplit
@@ -0,0 +1,99 @@
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2import { formatDuration, truncate, debounce } from "./utils";
3
4describe("formatDuration", () => {
5 it("formats zero as 0:00", () => {
6 expect(formatDuration(0)).toBe("0:00");
7 });
8
9 it("formats seconds only (< 1 minute)", () => {
10 expect(formatDuration(5000)).toBe("0:05");
11 expect(formatDuration(59000)).toBe("0:59");
12 });
13
14 it("formats minutes and seconds", () => {
15 expect(formatDuration(61000)).toBe("1:01");
16 expect(formatDuration(125000)).toBe("2:05");
17 expect(formatDuration(599000)).toBe("9:59");
18 });
19
20 it("formats hours when >= 1 hour", () => {
21 expect(formatDuration(3600000)).toBe("1:00:00");
22 expect(formatDuration(3661000)).toBe("1:01:01");
23 expect(formatDuration(7322000)).toBe("2:02:02");
24 });
25
26 it("pads minutes and seconds with leading zeros in h:mm:ss format", () => {
27 expect(formatDuration(3605000)).toBe("1:00:05");
28 expect(formatDuration(3660000)).toBe("1:01:00");
29 });
30});
31
32describe("truncate", () => {
33 it("returns the string unchanged when under or at limit", () => {
34 expect(truncate("hello", 10)).toBe("hello");
35 expect(truncate("hello", 5)).toBe("hello");
36 });
37
38 it("truncates and appends ellipsis when over limit", () => {
39 expect(truncate("hello world", 5)).toBe("hello...");
40 expect(truncate("abcdef", 3)).toBe("abc...");
41 });
42
43 it("handles empty string", () => {
44 expect(truncate("", 5)).toBe("");
45 });
46});
47
48describe("debounce", () => {
49 beforeEach(() => {
50 vi.useFakeTimers();
51 });
52 afterEach(() => {
53 vi.useRealTimers();
54 });
55
56 it("calls the function only once after the delay", () => {
57 const fn = vi.fn();
58 const debounced = debounce(fn, 100);
59
60 debounced("a");
61 debounced("b");
62 debounced("c");
63
64 expect(fn).not.toHaveBeenCalled();
65 vi.advanceTimersByTime(100);
66 expect(fn).toHaveBeenCalledOnce();
67 expect(fn).toHaveBeenCalledWith("c");
68 });
69
70 it("calls again if invoked after delay has elapsed", () => {
71 const fn = vi.fn();
72 const debounced = debounce(fn, 50);
73
74 debounced("first");
75 vi.advanceTimersByTime(50);
76 debounced("second");
77 vi.advanceTimersByTime(50);
78
79 expect(fn).toHaveBeenCalledTimes(2);
80 expect(fn).toHaveBeenNthCalledWith(1, "first");
81 expect(fn).toHaveBeenNthCalledWith(2, "second");
82 });
83
84 it("resets the timer on each call within the delay window", () => {
85 const fn = vi.fn();
86 const debounced = debounce(fn, 100);
87
88 debounced("x");
89 vi.advanceTimersByTime(50);
90 debounced("y");
91 vi.advanceTimersByTime(50);
92
93 expect(fn).not.toHaveBeenCalled();
94
95 vi.advanceTimersByTime(50);
96 expect(fn).toHaveBeenCalledOnce();
97 expect(fn).toHaveBeenCalledWith("y");
98 });
99});
Modifiedsrc/lib/voiceCommands.ts+7−0View fileUnifiedSplit
@@ -91,6 +91,13 @@ const EXTENDED_COMMANDS: Array<{
9191 { patterns: ["insert engagement terms"], action: "insert_clause:insert engagement terms" },
9292 { patterns: ["insert accountant liability cap"], action: "insert_clause:insert accountant liability cap" },
9393 { patterns: ["insert without prejudice"], action: "insert_clause:insert without prejudice" },
94 { patterns: ["insert arbitration clause"], action: "insert_clause:insert arbitration clause" },
95 { patterns: ["insert dispute resolution clause"], action: "insert_clause:insert dispute resolution clause" },
96 { patterns: ["insert warranty disclaimer"], action: "insert_clause:insert warranty disclaimer" },
97 { patterns: ["insert governing law new zealand"], action: "insert_clause:insert governing law new zealand" },
98 { patterns: ["insert governing law ontario"], action: "insert_clause:insert governing law ontario" },
99 { patterns: ["insert payment terms"], action: "insert_clause:insert payment terms" },
100 { patterns: ["insert termination for cause"], action: "insert_clause:insert termination for cause" },
94101 // Billable time — start/stop billing
95102 { patterns: ["start billing", "start timer", "start billable time"], action: "billing_start" },
96103 { patterns: ["stop billing", "stop timer", "stop billable time"], action: "billing_stop" },
Addedsrc/stores/clauses.test.ts+131−0View fileUnifiedSplit
@@ -0,0 +1,131 @@
1import { describe, it, expect, beforeEach, vi } from "vitest";
2import { useClauseStore } from "./clauses";
3import type { Clause } from "./clauses";
4
5vi.mock("@tauri-apps/plugin-store", () => ({
6 load: vi.fn().mockResolvedValue({
7 get: vi.fn().mockResolvedValue(null),
8 set: vi.fn(),
9 save: vi.fn(),
10 }),
11}));
12
13function makeClause(overrides: Partial<Clause> = {}): Clause {
14 return {
15 id: `custom-${Math.random().toString(36).slice(2)}`,
16 title: "Test Clause",
17 category: "general",
18 voiceTrigger: "insert test clause",
19 text: "This is a test clause.",
20 tags: ["test"],
21 ...overrides,
22 };
23}
24
25describe("useClauseStore", () => {
26 beforeEach(() => {
27 useClauseStore.setState({
28 clauses: [],
29 customClauseIds: [],
30 recentlyUsed: [],
31 });
32 });
33
34 describe("addClause", () => {
35 it("adds a clause and tracks it as custom", () => {
36 const clause = makeClause({ id: "c1" });
37 useClauseStore.getState().addClause(clause);
38 const { clauses, customClauseIds } = useClauseStore.getState();
39 expect(clauses).toHaveLength(1);
40 expect(clauses[0].id).toBe("c1");
41 expect(customClauseIds).toContain("c1");
42 });
43
44 it("accumulates multiple clauses", () => {
45 useClauseStore.getState().addClause(makeClause({ id: "a" }));
46 useClauseStore.getState().addClause(makeClause({ id: "b" }));
47 expect(useClauseStore.getState().clauses).toHaveLength(2);
48 });
49 });
50
51 describe("removeClause", () => {
52 it("removes clause by id", () => {
53 useClauseStore.getState().addClause(makeClause({ id: "keep" }));
54 useClauseStore.getState().addClause(makeClause({ id: "del" }));
55 useClauseStore.getState().removeClause("del");
56 const { clauses, customClauseIds } = useClauseStore.getState();
57 expect(clauses).toHaveLength(1);
58 expect(clauses[0].id).toBe("keep");
59 expect(customClauseIds).not.toContain("del");
60 });
61
62 it("is a no-op for unknown id", () => {
63 useClauseStore.getState().addClause(makeClause({ id: "c1" }));
64 useClauseStore.getState().removeClause("no-such-id");
65 expect(useClauseStore.getState().clauses).toHaveLength(1);
66 });
67 });
68
69 describe("updateClause", () => {
70 it("patches specific fields without touching other clauses", () => {
71 useClauseStore.getState().addClause(makeClause({ id: "c1", title: "Old" }));
72 useClauseStore.getState().addClause(makeClause({ id: "c2", title: "Other" }));
73 useClauseStore.getState().updateClause("c1", { title: "New" });
74 const { clauses } = useClauseStore.getState();
75 expect(clauses.find((c) => c.id === "c1")?.title).toBe("New");
76 expect(clauses.find((c) => c.id === "c2")?.title).toBe("Other");
77 });
78 });
79
80 describe("markUsed", () => {
81 it("prepends id to recentlyUsed", () => {
82 useClauseStore.getState().markUsed("c1");
83 useClauseStore.getState().markUsed("c2");
84 expect(useClauseStore.getState().recentlyUsed[0]).toBe("c2");
85 });
86
87 it("deduplicates: moves existing id to front", () => {
88 useClauseStore.getState().markUsed("c1");
89 useClauseStore.getState().markUsed("c2");
90 useClauseStore.getState().markUsed("c1");
91 const { recentlyUsed } = useClauseStore.getState();
92 expect(recentlyUsed[0]).toBe("c1");
93 expect(recentlyUsed.filter((x) => x === "c1")).toHaveLength(1);
94 });
95
96 it("caps recentlyUsed at 10", () => {
97 for (let i = 0; i < 12; i++) {
98 useClauseStore.getState().markUsed(`c${i}`);
99 }
100 expect(useClauseStore.getState().recentlyUsed).toHaveLength(10);
101 });
102 });
103
104 describe("findByTrigger", () => {
105 it("returns clause when trigger is contained in text", () => {
106 const clause = makeClause({ id: "c1", voiceTrigger: "insert indemnity" });
107 useClauseStore.getState().addClause(clause);
108 const found = useClauseStore.getState().findByTrigger("please insert indemnity now");
109 expect(found?.id).toBe("c1");
110 });
111
112 it("matches exact trigger", () => {
113 const clause = makeClause({ id: "c1", voiceTrigger: "insert nda" });
114 useClauseStore.getState().addClause(clause);
115 const found = useClauseStore.getState().findByTrigger("insert nda");
116 expect(found?.id).toBe("c1");
117 });
118
119 it("is case-insensitive", () => {
120 const clause = makeClause({ id: "c1", voiceTrigger: "Insert NDA" });
121 useClauseStore.getState().addClause(clause);
122 const found = useClauseStore.getState().findByTrigger("insert nda");
123 expect(found?.id).toBe("c1");
124 });
125
126 it("returns undefined for no match", () => {
127 const found = useClauseStore.getState().findByTrigger("something else");
128 expect(found).toBeUndefined();
129 });
130 });
131});
Modifiedsrc/stores/clauses.ts+56−0View fileUnifiedSplit
@@ -151,6 +151,62 @@ const BUILT_IN_CLAUSES: Clause[] = [
151151 text: "WITHOUT PREJUDICE AND SUBJECT TO CONTRACT",
152152 tags: ["without prejudice", "settlement", "negotiation"],
153153 },
154 {
155 id: "arbitration-clause",
156 title: "Arbitration Clause",
157 category: "contract",
158 voiceTrigger: "insert arbitration clause",
159 text: "Any dispute, controversy, or claim arising out of or relating to this Agreement, or the breach, termination, or invalidity thereof, shall be finally settled by arbitration in accordance with the rules of the relevant arbitration body in the governing jurisdiction. The arbitral tribunal shall consist of one arbitrator. The language of the arbitration shall be English. The decision of the arbitrator shall be final and binding on the parties.",
160 tags: ["arbitration", "dispute resolution", "adr"],
161 },
162 {
163 id: "dispute-resolution",
164 title: "Dispute Resolution",
165 category: "contract",
166 voiceTrigger: "insert dispute resolution clause",
167 text: "In the event of any dispute, controversy, or claim arising out of or relating to this Agreement, the parties shall first attempt in good faith to resolve the dispute through negotiation for a period of not less than 30 days. If the dispute cannot be resolved by negotiation, either party may refer the matter to mediation before resorting to litigation or arbitration.",
168 tags: ["dispute resolution", "mediation", "negotiation"],
169 },
170 {
171 id: "warranty-disclaimer",
172 title: "Warranty Disclaimer",
173 category: "liability",
174 voiceTrigger: "insert warranty disclaimer",
175 text: "EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE SERVICES AND ALL MATERIALS PROVIDED HEREUNDER ARE PROVIDED \"AS IS\" WITHOUT WARRANTY OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE. EACH PARTY SPECIFICALLY DISCLAIMS ALL IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT.",
176 tags: ["warranty", "disclaimer", "as is"],
177 },
178 {
179 id: "governing-law-nz",
180 title: "Governing Law (New Zealand)",
181 category: "contract",
182 voiceTrigger: "insert governing law new zealand",
183 text: "This Agreement shall be governed by and construed in accordance with the laws of New Zealand. The parties submit to the exclusive jurisdiction of the courts of New Zealand.",
184 tags: ["governing law", "new zealand", "jurisdiction"],
185 },
186 {
187 id: "governing-law-canada",
188 title: "Governing Law (Ontario, Canada)",
189 category: "contract",
190 voiceTrigger: "insert governing law ontario",
191 text: "This Agreement shall be governed by and construed in accordance with the laws of the Province of Ontario and the federal laws of Canada applicable therein. The parties submit to the exclusive jurisdiction of the courts of Ontario.",
192 tags: ["governing law", "canada", "ontario", "jurisdiction"],
193 },
194 {
195 id: "payment-terms",
196 title: "Payment Terms",
197 category: "contract",
198 voiceTrigger: "insert payment terms",
199 text: "All invoices are due and payable within thirty (30) days of the invoice date. Any amounts not paid within thirty (30) days of the due date shall bear interest at the rate of 1.5% per month (18% per annum), or the maximum rate permitted by applicable law, whichever is lower, from the due date until the date of payment.",
200 tags: ["payment", "invoice", "interest"],
201 },
202 {
203 id: "termination-for-cause",
204 title: "Termination for Cause",
205 category: "contract",
206 voiceTrigger: "insert termination for cause",
207 text: "Either party may terminate this Agreement immediately upon written notice if the other party: (a) materially breaches this Agreement and fails to cure such breach within thirty (30) days after receiving written notice of the breach; (b) becomes insolvent, makes an assignment for the benefit of creditors, or becomes subject to bankruptcy, receivership, or similar proceedings; or (c) ceases to carry on business.",
208 tags: ["termination", "breach", "cause"],
209 },
154210];
155211
156212const BUILT_IN_TEMPLATES: DocumentTemplate[] = [
Modifiedsrc/stores/clients.test.ts+63−1View fileUnifiedSplit
@@ -1,5 +1,5 @@
11import { describe, it, expect, beforeEach } from "vitest";
2import { useClientsStore } from "./clients";
2import { useClientsStore, buildMatterContext } from "./clients";
33
44// Reset store state before each test
55beforeEach(() => {
@@ -96,6 +96,68 @@ describe("addEntry and getTotalBillable", () => {
9696 });
9797});
9898
99describe("vocabulary", () => {
100 it("adds terms to a client", () => {
101 const store = useClientsStore.getState();
102 const id = store.addClient({ name: "Law Corp", billableRate: 500, color: "" });
103 store.addVocabularyTerm(id, "Smith v. Jones");
104 store.addVocabularyTerm(id, "Exhibit A");
105 const client = useClientsStore.getState().clients.find((c) => c.id === id);
106 expect(client?.vocabulary).toEqual(["Smith v. Jones", "Exhibit A"]);
107 });
108
109 it("deduplicates terms", () => {
110 const store = useClientsStore.getState();
111 const id = store.addClient({ name: "Firm", billableRate: 400, color: "" });
112 store.addVocabularyTerm(id, "GDPR");
113 store.addVocabularyTerm(id, "GDPR");
114 const client = useClientsStore.getState().clients.find((c) => c.id === id);
115 expect(client?.vocabulary).toHaveLength(1);
116 });
117
118 it("removes terms", () => {
119 const store = useClientsStore.getState();
120 const id = store.addClient({ name: "Firm B", billableRate: 300, color: "" });
121 store.addVocabularyTerm(id, "plaintiff");
122 store.addVocabularyTerm(id, "defendant");
123 store.removeVocabularyTerm(id, "plaintiff");
124 const client = useClientsStore.getState().clients.find((c) => c.id === id);
125 expect(client?.vocabulary).toEqual(["defendant"]);
126 });
127
128 it("ignores blank terms", () => {
129 const store = useClientsStore.getState();
130 const id = store.addClient({ name: "Firm C", billableRate: 200, color: "" });
131 store.addVocabularyTerm(id, " ");
132 const client = useClientsStore.getState().clients.find((c) => c.id === id);
133 expect(client?.vocabulary ?? []).toHaveLength(0);
134 });
135});
136
137describe("buildMatterContext", () => {
138 it("returns empty string for undefined client", () => {
139 expect(buildMatterContext(undefined)).toBe("");
140 });
141
142 it("includes matter description, number and vocabulary", () => {
143 const ctx = buildMatterContext({
144 id: "x",
145 name: "Test",
146 matterNumber: "2024-001",
147 matterDescription: "Contract dispute",
148 vocabulary: ["Acme Inc", "Section 5.2"],
149 billableRate: 0,
150 color: "",
151 archived: false,
152 createdAt: 0,
153 });
154 expect(ctx).toContain("Contract dispute");
155 expect(ctx).toContain("2024-001");
156 expect(ctx).toContain("Acme Inc");
157 expect(ctx).toContain("Section 5.2");
158 });
159});
160
99161describe("getTotalHours", () => {
100162 it("converts durationSeconds to hours correctly", () => {
101163 const store = useClientsStore.getState();
Modifiedsrc/stores/clients.ts+40−0View fileUnifiedSplit
@@ -9,6 +9,10 @@ export interface Client {
99 color: string; // hex for UI identification
1010 archived: boolean;
1111 createdAt: number;
12 /** Custom vocabulary terms for this client — fed into grammar correction context. */
13 vocabulary?: string[];
14 /** Optional brief matter description — used as grammar correction context. */
15 matterDescription?: string;
1216}
1317
1418export interface MatterEntry {
@@ -28,6 +32,8 @@ interface ClientsState {
2832
2933 addClient: (client: Omit<Client, "id" | "createdAt" | "archived">) => string;
3034 updateClient: (id: string, updates: Partial<Client>) => void;
35 addVocabularyTerm: (clientId: string, term: string) => void;
36 removeVocabularyTerm: (clientId: string, term: string) => void;
3137 archiveClient: (id: string) => void;
3238 deleteClient: (id: string) => void;
3339 setActiveClient: (id: string | null) => void;
@@ -40,6 +46,18 @@ interface ClientsState {
4046 getTotalHours: (clientId: string) => number;
4147}
4248
49/** Build a grammar-correction context string for the active client. */
50export function buildMatterContext(client: Client | undefined): string {
51 if (!client) return "";
52 const parts: string[] = [];
53 if (client.matterDescription) parts.push(`Matter: ${client.matterDescription}`);
54 if (client.matterNumber) parts.push(`Matter number: ${client.matterNumber}`);
55 if (client.vocabulary?.length) {
56 parts.push(`Preferred terms / proper nouns: ${client.vocabulary.join(", ")}`);
57 }
58 return parts.join(". ");
59}
60
4361const CLIENT_COLORS = [
4462 "#7345d1", "#3b82f6", "#10b981", "#f59e0b",
4563 "#ef4444", "#8b5cf6", "#06b6d4", "#ec4899",
@@ -98,6 +116,28 @@ export const useClientsStore = create<ClientsState>()(
98116
99117 setActiveClient: (id) => set({ activeClientId: id }),
100118
119 addVocabularyTerm: (clientId, term) => {
120 const trimmed = term.trim();
121 if (!trimmed) return;
122 set((s) => ({
123 clients: s.clients.map((c) =>
124 c.id === clientId
125 ? { ...c, vocabulary: [...new Set([...(c.vocabulary ?? []), trimmed])] }
126 : c
127 ),
128 }));
129 },
130
131 removeVocabularyTerm: (clientId, term) => {
132 set((s) => ({
133 clients: s.clients.map((c) =>
134 c.id === clientId
135 ? { ...c, vocabulary: (c.vocabulary ?? []).filter((v) => v !== term) }
136 : c
137 ),
138 }));
139 },
140
101141 addEntry: (data) => {
102142 const id = `entry_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
103143 set((s) => ({ entries: [...s.entries, { ...data, id }] }));
Addedsrc/stores/dictation.test.ts+184−0View fileUnifiedSplit
@@ -0,0 +1,184 @@
1import { describe, it, expect, beforeEach } from "vitest";
2import { useDictationStore, loadDraftRecord, clearDraftRecord } from "./dictation";
3import type { TranscriptionSegment } from "./dictation";
4
5function makeSeg(overrides: Partial<TranscriptionSegment> = {}): TranscriptionSegment {
6 return {
7 id: crypto.randomUUID(),
8 text: "Hello world",
9 confidence: 0.95,
10 isFinal: true,
11 grammarApplied: false,
12 timestamp: new Date(),
13 ...overrides,
14 };
15}
16
17beforeEach(() => {
18 useDictationStore.setState({
19 status: "idle",
20 segments: [],
21 currentTranscript: "",
22 correctedTranscript: "",
23 sessionDuration: 0,
24 wordCount: 0,
25 inputLevel: 0,
26 error: null,
27 sessionStartedAtMs: null,
28 capsLock: false,
29 });
30 localStorage.clear();
31});
32
33describe("addSegment", () => {
34 it("appends a segment and updates wordCount", () => {
35 useDictationStore.getState().addSegment(makeSeg({ text: "one two three" }));
36 const { segments, wordCount } = useDictationStore.getState();
37 expect(segments).toHaveLength(1);
38 expect(wordCount).toBe(3);
39 });
40
41 it("uses correctedText word count when present", () => {
42 useDictationStore.getState().addSegment(makeSeg({ text: "a b c", correctedText: "one two" }));
43 expect(useDictationStore.getState().wordCount).toBe(2);
44 });
45});
46
47describe("updateSegment", () => {
48 it("updates fields on matching segment", () => {
49 const seg = makeSeg({ text: "raw text" });
50 useDictationStore.getState().addSegment(seg);
51 useDictationStore.getState().updateSegment(seg.id, { correctedText: "polished text" });
52 const updated = useDictationStore.getState().segments[0];
53 expect(updated.correctedText).toBe("polished text");
54 });
55});
56
57describe("popLastSegment", () => {
58 it("removes the last segment", () => {
59 useDictationStore.getState().addSegment(makeSeg({ text: "a" }));
60 useDictationStore.getState().addSegment(makeSeg({ text: "b" }));
61 useDictationStore.getState().popLastSegment();
62 expect(useDictationStore.getState().segments).toHaveLength(1);
63 expect(useDictationStore.getState().segments[0].text).toBe("a");
64 });
65
66 it("is a no-op on empty segments", () => {
67 expect(() => useDictationStore.getState().popLastSegment()).not.toThrow();
68 });
69});
70
71describe("removeSegment", () => {
72 it("removes segment by id", () => {
73 const a = makeSeg({ text: "a" });
74 const b = makeSeg({ text: "b" });
75 useDictationStore.getState().addSegment(a);
76 useDictationStore.getState().addSegment(b);
77 useDictationStore.getState().removeSegment(a.id);
78 const { segments } = useDictationStore.getState();
79 expect(segments).toHaveLength(1);
80 expect(segments[0].id).toBe(b.id);
81 });
82});
83
84describe("clearSession", () => {
85 it("resets all state and clears the draft", () => {
86 useDictationStore.getState().addSegment(makeSeg());
87 expect(loadDraftRecord()).not.toBeNull();
88 useDictationStore.getState().clearSession();
89 const s = useDictationStore.getState();
90 expect(s.segments).toHaveLength(0);
91 expect(s.wordCount).toBe(0);
92 expect(loadDraftRecord()).toBeNull();
93 });
94});
95
96describe("draft persistence", () => {
97 it("persists a draft to localStorage on addSegment", () => {
98 useDictationStore.getState().addSegment(makeSeg({ text: "persisted" }));
99 const draft = loadDraftRecord();
100 expect(draft).not.toBeNull();
101 expect(draft!.segments).toHaveLength(1);
102 expect(draft!.segments[0].text).toBe("persisted");
103 });
104
105 it("persists draft on updateSegment", () => {
106 const seg = makeSeg({ text: "raw" });
107 useDictationStore.getState().addSegment(seg);
108 useDictationStore.getState().updateSegment(seg.id, { correctedText: "updated" });
109 const draft = loadDraftRecord();
110 expect(draft!.segments[0].correctedText).toBe("updated");
111 });
112
113 it("persists draft on appendToLastSegment", () => {
114 useDictationStore.getState().addSegment(makeSeg({ text: "hello" }));
115 useDictationStore.getState().appendToLastSegment(" world");
116 const draft = loadDraftRecord();
117 expect(draft!.segments[0].text).toBe("hello world");
118 });
119
120 it("clearDraftRecord removes the draft", () => {
121 useDictationStore.getState().addSegment(makeSeg());
122 clearDraftRecord();
123 expect(loadDraftRecord()).toBeNull();
124 });
125});
126
127describe("restoreDraft", () => {
128 it("restores segments from a draft, rehydrating timestamps", () => {
129 const now = Date.now();
130 useDictationStore.getState().restoreDraft({
131 savedAt: now,
132 sessionStartedAtMs: now - 5000,
133 segments: [
134 {
135 id: "abc",
136 text: "recovered text",
137 confidence: 0.9,
138 isFinal: true,
139 grammarApplied: false,
140 timestampMs: now - 3000,
141 },
142 ],
143 });
144 const { segments, sessionStartedAtMs } = useDictationStore.getState();
145 expect(segments).toHaveLength(1);
146 expect(segments[0].text).toBe("recovered text");
147 expect(segments[0].timestamp).toBeInstanceOf(Date);
148 expect(segments[0].timestamp.getTime()).toBe(now - 3000);
149 expect(sessionStartedAtMs).toBe(now - 5000);
150 });
151
152 it("clears the draft record after restoring", () => {
153 useDictationStore.getState().addSegment(makeSeg());
154 const draft = loadDraftRecord()!;
155 useDictationStore.getState().restoreDraft(draft);
156 expect(loadDraftRecord()).toBeNull();
157 });
158});
159
160describe("discardDraft", () => {
161 it("removes the saved draft", () => {
162 useDictationStore.getState().addSegment(makeSeg());
163 useDictationStore.getState().discardDraft();
164 expect(loadDraftRecord()).toBeNull();
165 });
166});
167
168describe("getFullTranscript", () => {
169 it("joins correctedText preferentially", () => {
170 useDictationStore.getState().addSegment(makeSeg({ text: "raw", correctedText: "polished" }));
171 useDictationStore.getState().addSegment(makeSeg({ text: "second" }));
172 expect(useDictationStore.getState().getFullTranscript()).toBe("polished second");
173 });
174});
175
176describe("capsLock", () => {
177 it("toggles capsLock", () => {
178 expect(useDictationStore.getState().capsLock).toBe(false);
179 useDictationStore.getState().toggleCapsLock();
180 expect(useDictationStore.getState().capsLock).toBe(true);
181 useDictationStore.getState().toggleCapsLock();
182 expect(useDictationStore.getState().capsLock).toBe(false);
183 });
184});
Modifiedsrc/stores/dictation.ts+72−6View fileUnifiedSplit
@@ -58,6 +58,46 @@ export interface BackendSessionRecord {
5858 segments: BackendSessionSegment[];
5959}
6060
61const DRAFT_KEY = "voxlen_draft";
62
63export interface DraftRecord {
64 savedAt: number;
65 sessionStartedAtMs: number | null;
66 segments: Array<Omit<TranscriptionSegment, "timestamp"> & { timestampMs: number }>;
67}
68
69function persistDraft(state: { segments: TranscriptionSegment[]; sessionStartedAtMs: number | null }) {
70 if (state.segments.length === 0) return;
71 const draft: DraftRecord = {
72 savedAt: Date.now(),
73 sessionStartedAtMs: state.sessionStartedAtMs,
74 segments: state.segments.map((s) => ({
75 ...s,
76 timestampMs: s.timestamp.getTime(),
77 timestamp: undefined as unknown as Date,
78 })),
79 };
80 try {
81 localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
82 } catch {
83 // storage quota — silently ignore
84 }
85}
86
87export function loadDraftRecord(): DraftRecord | null {
88 try {
89 const raw = localStorage.getItem(DRAFT_KEY);
90 if (!raw) return null;
91 return JSON.parse(raw) as DraftRecord;
92 } catch {
93 return null;
94 }
95}
96
97export function clearDraftRecord() {
98 localStorage.removeItem(DRAFT_KEY);
99}
100
61101interface DictationState {
62102 status: DictationStatus;
63103 segments: TranscriptionSegment[];
@@ -87,6 +127,8 @@ interface DictationState {
87127 getFullTranscript: () => string;
88128 setCapsLock: (value: boolean) => void;
89129 toggleCapsLock: () => void;
130 restoreDraft: (draft: DraftRecord) => void;
131 discardDraft: () => void;
90132}
91133
92134export const useDictationStore = create<DictationState>((set, get) => ({
@@ -119,15 +161,18 @@ export const useDictationStore = create<DictationState>((set, get) => ({
119161 count + (s.correctedText || s.text).split(/\s+/).filter(Boolean).length,
120162 0
121163 );
164 persistDraft({ segments, sessionStartedAtMs: state.sessionStartedAtMs });
122165 return { segments, wordCount };
123166 }),
124167
125168 updateSegment: (id, updates) =>
126 set((state) => ({
127 segments: state.segments.map((s) =>
169 set((state) => {
170 const segments = state.segments.map((s) =>
128171 s.id === id ? { ...s, ...updates } : s
129 ),
130 })),
172 );
173 persistDraft({ segments, sessionStartedAtMs: state.sessionStartedAtMs });
174 return { segments };
175 }),
131176
132177 popLastSegment: () =>
133178 set((state) => {
@@ -166,6 +211,7 @@ export const useDictationStore = create<DictationState>((set, get) => ({
166211 count + (s.correctedText || s.text).split(/\s+/).filter(Boolean).length,
167212 0
168213 );
214 persistDraft({ segments, sessionStartedAtMs: state.sessionStartedAtMs });
169215 return { segments, wordCount };
170216 }),
171217
@@ -176,7 +222,8 @@ export const useDictationStore = create<DictationState>((set, get) => ({
176222 incrementDuration: () =>
177223 set((state) => ({ sessionDuration: state.sessionDuration + 1 })),
178224
179 clearSession: () =>
225 clearSession: () => {
226 clearDraftRecord();
180227 set({
181228 segments: [],
182229 currentTranscript: "",
@@ -187,7 +234,8 @@ export const useDictationStore = create<DictationState>((set, get) => ({
187234 error: null,
188235 status: "idle",
189236 sessionStartedAtMs: null,
190 }),
237 });
238 },
191239
192240 clearCurrentTranscript: () => set({ currentTranscript: "" }),
193241
@@ -200,6 +248,24 @@ export const useDictationStore = create<DictationState>((set, get) => ({
200248
201249 setCapsLock: (value) => set({ capsLock: value }),
202250 toggleCapsLock: () => set((state) => ({ capsLock: !state.capsLock })),
251
252 restoreDraft: (draft) => {
253 const segments: TranscriptionSegment[] = draft.segments.map((s) => ({
254 ...s,
255 timestamp: new Date(s.timestampMs),
256 }));
257 const wordCount = segments.reduce(
258 (count, s) =>
259 count + (s.correctedText || s.text).split(/\s+/).filter(Boolean).length,
260 0
261 );
262 clearDraftRecord();
263 set({ segments, wordCount, sessionStartedAtMs: draft.sessionStartedAtMs });
264 },
265
266 discardDraft: () => {
267 clearDraftRecord();
268 },
203269}));
204270
205271/**
Addedsrc/stores/history.test.ts+91−0View fileUnifiedSplit
@@ -0,0 +1,91 @@
1import { describe, it, expect, beforeEach, vi } from "vitest";
2import { useHistoryStore } from "./history";
3import type { HistoryEntry } from "./history";
4
5vi.mock("@tauri-apps/plugin-store", () => ({
6 load: vi.fn().mockResolvedValue({
7 get: vi.fn().mockResolvedValue(null),
8 set: vi.fn(),
9 save: vi.fn(),
10 }),
11}));
12
13function makeEntry(overrides: Partial<HistoryEntry> = {}): HistoryEntry {
14 return {
15 id: `id-${Math.random().toString(36).slice(2)}`,
16 text: "Hello world",
17 duration: 30,
18 wordCount: 2,
19 language: "en",
20 timestamp: new Date().toISOString(),
21 grammarCorrected: false,
22 ...overrides,
23 };
24}
25
26describe("useHistoryStore", () => {
27 beforeEach(() => {
28 useHistoryStore.setState({ entries: [] });
29 });
30
31 it("starts empty", () => {
32 expect(useHistoryStore.getState().entries).toHaveLength(0);
33 });
34
35 it("addEntry prepends new entries", () => {
36 const a = makeEntry({ text: "first" });
37 const b = makeEntry({ text: "second" });
38 useHistoryStore.getState().addEntry(a);
39 useHistoryStore.getState().addEntry(b);
40 const { entries } = useHistoryStore.getState();
41 expect(entries[0].text).toBe("second");
42 expect(entries[1].text).toBe("first");
43 });
44
45 it("addEntry caps entries at 200", () => {
46 for (let i = 0; i < 205; i++) {
47 useHistoryStore.getState().addEntry(makeEntry({ id: `id-${i}` }));
48 }
49 expect(useHistoryStore.getState().entries).toHaveLength(200);
50 });
51
52 it("removeEntry deletes by id", () => {
53 const a = makeEntry({ id: "keep" });
54 const b = makeEntry({ id: "remove" });
55 useHistoryStore.getState().addEntry(a);
56 useHistoryStore.getState().addEntry(b);
57 useHistoryStore.getState().removeEntry("remove");
58 const { entries } = useHistoryStore.getState();
59 expect(entries).toHaveLength(1);
60 expect(entries[0].id).toBe("keep");
61 });
62
63 it("removeEntry is a no-op for unknown id", () => {
64 const a = makeEntry();
65 useHistoryStore.getState().addEntry(a);
66 useHistoryStore.getState().removeEntry("nonexistent");
67 expect(useHistoryStore.getState().entries).toHaveLength(1);
68 });
69
70 it("clearAll empties entries", () => {
71 useHistoryStore.getState().addEntry(makeEntry());
72 useHistoryStore.getState().addEntry(makeEntry());
73 useHistoryStore.getState().clearAll();
74 expect(useHistoryStore.getState().entries).toHaveLength(0);
75 });
76
77 it("preserves all entry fields", () => {
78 const entry = makeEntry({
79 id: "x1",
80 text: "Legal memo",
81 duration: 120,
82 wordCount: 45,
83 language: "en-US",
84 grammarCorrected: true,
85 timestamp: "2026-06-12T10:00:00.000Z",
86 });
87 useHistoryStore.getState().addEntry(entry);
88 const saved = useHistoryStore.getState().entries[0];
89 expect(saved).toEqual(entry);
90 });
91});
Modifiedsrc/stores/settings.ts+1−1View fileUnifiedSplit
@@ -213,7 +213,7 @@ export async function hydrateSecrets(): Promise<void> {
213213 if (grammarApiKey) updates.grammarApiKey = grammarApiKey;
214214 if (voxlenApiKey) updates.voxlenApiKey = voxlenApiKey;
215215 if (Object.keys(updates).length > 0) {
216 useSettingsStore.setState(updates);
216 useSettingsStore.getState().updateSettings(updates);
217217 }
218218}
219219
220220
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts