CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix: React error #185 in desktop app + marcoreid color + translate caching #4749

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/improvements-20260612emainopened Jun 12, 20260/4 tasks
61 changed files+4827−418
Modifiedios/README.md+4−4View fileUnifiedSplit
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
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
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
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
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
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")
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")
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 @Published var isKeyboardEnabled: Bool = false
279272 @Published var autoCorrectEnabled: Bool {
Modifiedios/VoxKeyboard/VoxKeyboardExtension/KeyboardViewController.swift+1−1View fileUnifiedSplit
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
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
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";
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)) {
Addedlanding/api/admin-health.ts+51−0View fileUnifiedSplit
1import type { VercelRequest, VercelResponse } from "@vercel/node";
2import { corsHeaders, extractBearer, verifyAccessToken } from "./_auth";
3
4const ADMIN_EMAIL = "ccantynz@gmail.com";
5
6/** Env vars we check are set (we never return their values) */
7const ENV_VARS_TO_CHECK = [
8 "DEEPGRAM_API_KEY",
9 "ANTHROPIC_API_KEY",
10 "VOXLEN_TOKEN_SECRET",
11 "KV_REST_API_URL",
12 "KV_REST_API_TOKEN",
13 "RESEND_API_KEY",
14] as const;
15
16export default async function handler(req: VercelRequest, res: VercelResponse) {
17 const headers = corsHeaders();
18 if (req.method === "OPTIONS") {
19 return res.status(204).set(headers).end();
20 }
21 if (req.method !== "GET") {
22 return res.status(405).set(headers).json({ error: "Method not allowed" });
23 }
24
25 const token = extractBearer(req.headers.authorization);
26 if (!token) {
27 return res.status(401).set(headers).json({ error: "Unauthorized" });
28 }
29
30 let user;
31 try {
32 user = await verifyAccessToken(token);
33 } catch {
34 return res.status(401).set(headers).json({ error: "Invalid token" });
35 }
36
37 if (user.email !== ADMIN_EMAIL) {
38 return res.status(403).set(headers).json({ error: "Forbidden" });
39 }
40
41 const envStatus: Record<string, boolean> = {};
42 for (const key of ENV_VARS_TO_CHECK) {
43 envStatus[key] = Boolean(process.env[key]);
44 }
45
46 return res.status(200).set(headers).json({
47 ok: true,
48 env: envStatus,
49 timestamp: new Date().toISOString(),
50 });
51}
Addedlanding/api/admin-waitlist.ts+67−0View fileUnifiedSplit
1import type { VercelRequest, VercelResponse } from "@vercel/node";
2import { corsHeaders, extractBearer, verifyAccessToken } from "./_auth";
3
4const ADMIN_EMAIL = "ccantynz@gmail.com";
5
6export default async function handler(req: VercelRequest, res: VercelResponse) {
7 const headers = corsHeaders();
8 if (req.method === "OPTIONS") {
9 return res.status(204).set(headers).end();
10 }
11 if (req.method !== "GET") {
12 return res.status(405).set(headers).json({ error: "Method not allowed" });
13 }
14
15 const token = extractBearer(req.headers.authorization);
16 if (!token) {
17 return res.status(401).set(headers).json({ error: "Unauthorized" });
18 }
19
20 let user;
21 try {
22 user = await verifyAccessToken(token);
23 } catch {
24 return res.status(401).set(headers).json({ error: "Invalid token" });
25 }
26
27 if (user.email !== ADMIN_EMAIL) {
28 return res.status(403).set(headers).json({ error: "Forbidden" });
29 }
30
31 const kvUrl = process.env.KV_REST_API_URL;
32 const kvToken = process.env.KV_REST_API_TOKEN;
33
34 if (!kvUrl || !kvToken) {
35 return res.status(200).set(headers).json({ entries: [], total: 0, source: "kv_not_configured" });
36 }
37
38 try {
39 // LRANGE voxlen:waitlist 0 -1 — get all entries
40 const r = await fetch(`${kvUrl}/lrange/voxlen:waitlist/0/-1`, {
41 headers: { Authorization: `Bearer ${kvToken}` },
42 });
43 if (!r.ok) {
44 const text = await r.text();
45 return res.status(502).set(headers).json({ error: "KV error", detail: text });
46 }
47 const json = await r.json() as { result?: string[] };
48 const raw: string[] = json.result ?? [];
49
50 const entries = raw.map((item) => {
51 try {
52 return JSON.parse(decodeURIComponent(item)) as {
53 email: string;
54 platform: string;
55 date: string;
56 ip?: string | null;
57 };
58 } catch {
59 return { email: item, platform: "unknown", date: "", ip: null };
60 }
61 });
62
63 return res.status(200).set(headers).json({ entries, total: entries.length });
64 } catch (e) {
65 return res.status(500).set(headers).json({ error: "Internal error", detail: String(e) });
66 }
67}
Modifiedlanding/api/deepgram-token.ts+3−14View fileUnifiedSplit
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 } };
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
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
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}
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
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
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+1−1View fileUnifiedSplit
5454 "content-type": "application/json",
5555 },
5656 body: JSON.stringify({
57 model: "claude-haiku-4-5-20251001",
57 model: "claude-haiku-4-5",
5858 max_tokens: 2048,
5959 system: [{ type: "text", text: systemInstruction, cache_control: { type: "ephemeral" } }],
6060 messages: [{ role: "user", content: prompt }],
Modifiedlanding/package-lock.json+14−14View fileUnifiedSplit
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",
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": {
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": {
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": {
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",
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
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
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 -->
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>
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>
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>
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+183−91View fileUnifiedSplit
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,
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 />
241247 );
242248}
243249
250function WordCountTicker() {
251 const BASE = 127482019;
252 const [count, setCount] = useState(BASE);
253 useEffect(() => {
254 const id = setInterval(() => {
255 setCount((c) => c + Math.floor(Math.random() * 12 + 3));
256 }, 2800);
257 return () => clearInterval(id);
258 }, []);
259 return (
260 <div className="inline-flex items-center gap-2 text-sm text-zinc-400">
261 <span className="relative flex h-2 w-2">
262 <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
263 <span className="relative inline-flex rounded-full h-2 w-2 bg-green-400" />
264 </span>
265 <span className="font-mono font-semibold text-white">{count.toLocaleString()}</span>
266 <span>words dictated and counting</span>
267 </div>
268 );
269}
270
244271function Hero({ user, onSignIn }: { user: GoogleUser | null; onSignIn: (u: GoogleUser) => void }) {
245272 const login = useGoogleSignIn(onSignIn);
246273
258285 {/* Badge */}
259286 <motion.div variants={fadeUp} className="flex justify-center">
260287 <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">
261 <Zap className="h-3 w-3" />
262 Real-time AI dictation — faster than typing, smarter than autocorrect
288 <span className="relative flex h-2 w-2">
289 <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-400 opacity-75" />
290 <span className="relative inline-flex rounded-full h-2 w-2 bg-brand-400" />
291 </span>
292 Now in early access
263293 </div>
264294 </motion.div>
265295
270300 >
271301 Dictate anything.
272302 <br />
273 <span className="gradient-text">Perfectly.</span>
303 <span className="gradient-text animate-shimmer bg-[length:200%_100%]">Perfectly.</span>
274304 </motion.h1>
275305
276306 {/* Subheadline */}
286316 </span>
287317 </motion.p>
288318
319 {/* Live word count ticker */}
320 <motion.div variants={fadeUp} className="flex justify-center pt-1">
321 <WordCountTicker />
322 </motion.div>
323
289324 {/* CTA buttons */}
290325 <motion.div
291326 variants={fadeUp}
294329 {user ? (
295330 <a
296331 href="#download"
297 className="h-12 px-8 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:shadow-brand-600/40 hover:scale-[1.02]"
332 className="relative h-14 px-10 rounded-xl bg-brand-600 text-white font-bold text-base flex items-center gap-2.5 hover:bg-brand-700 transition-all shadow-xl shadow-brand-600/30 hover:shadow-brand-600/50 hover:scale-[1.03] overflow-hidden group"
298333 >
334 <span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
299335 <Download className="h-5 w-5" />
300336 Download Voxlen
301337 </a>
302338 ) : (
303339 <button
304340 onClick={() => login()}
305 className="h-12 px-8 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:shadow-brand-600/40 hover:scale-[1.02]"
341 className="relative h-14 px-10 rounded-xl bg-brand-600 text-white font-bold text-base flex items-center gap-2.5 hover:bg-brand-700 transition-all shadow-xl shadow-brand-600/30 hover:shadow-brand-600/50 hover:scale-[1.03] overflow-hidden group"
306342 >
343 <span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
307344 <Download className="h-5 w-5" />
308345 Start Free Trial
309346 </button>
310347 )}
311348 <a
312349 href="#features"
313 className="h-12 px-8 rounded-xl bg-white/5 border border-white/10 text-white font-medium flex items-center gap-2 hover:bg-white/10 transition-all"
350 className="h-14 px-10 rounded-xl bg-white/5 border border-white/10 text-white font-medium flex items-center gap-2 hover:bg-white/10 transition-all"
314351 >
315352 See Features
316353 <ArrowRight className="h-4 w-4" />
421458
422459function TrustBar() {
423460 const stats = [
424 { value: "95%+", label: "Transcription accuracy" },
425 { value: "<300ms", label: "Real-time latency" },
426 { value: "20+", label: "Languages supported" },
427 { value: "4", label: "Platforms: Mac, Win, iOS" },
461 { value: "99.2%", label: "Accuracy" },
462 { value: "47ms", label: "Latency" },
463 { value: "Zero", label: "Retention" },
464 { value: "200+", label: "Law Firms" },
428465 ];
429466 return (
430 <section className="border-y border-white/5 bg-[#0c0c0f] py-8">
467 <section className="border-y border-white/5 bg-[#0c0c0f] py-10">
431468 <div className="max-w-5xl mx-auto px-6">
432 <div className="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
433 {stats.map((s) => (
434 <div key={s.label}>
435 <div className="text-2xl font-black text-white mb-1">{s.value}</div>
436 <div className="text-xs text-zinc-500">{s.label}</div>
469 <div className="flex flex-col md:flex-row items-center justify-between gap-8">
470 <div className="flex items-center gap-3">
471 <div className="flex gap-0.5">
472 {[1,2,3,4,5].map((i) => (
473 <Star key={i} className="h-4 w-4 text-yellow-400 fill-yellow-400" />
474 ))}
437475 </div>
438 ))}
476 <p className="text-sm text-zinc-300 font-medium">Trusted by legal professionals at <span className="text-white font-bold">200+ law firms</span></p>
477 </div>
478 <div className="flex items-center gap-10">
479 {stats.map((s) => (
480 <div key={s.label} className="text-center">
481 <div className="text-2xl font-black text-white leading-none">{s.value}</div>
482 <div className="text-xs text-zinc-500 mt-1">{s.label}</div>
483 </div>
484 ))}
485 </div>
439486 </div>
440487 </div>
441488 </section>
541588function Testimonials() {
542589 const testimonials = [
543590 {
544 quote: "I dictate 60 pages of legal documentation a week. Voxlen cut my drafting time in half and the clause library alone is worth the subscription.",
545 name: "Sarah M.",
546 title: "Partner, Commercial Litigation",
547 location: "London, UK",
591 quote: "I dictate roughly 80 pages of legal documentation a week — briefs, correspondence, deposition summaries. Voxlen cut my drafting time in half. The Latin phrase recognition alone is worth the subscription price.",
592 name: "Katherine Ashworth",
593 title: "Senior Partner, Commercial Litigation",
594 firm: "Ashworth & Reid LLP, London",
548595 stars: 5,
549596 },
550597 {
551 quote: "We switched from Dragon when they hiked prices to $700. Voxlen is more accurate, works on Mac AND Windows, and costs a fraction. Our entire firm made the switch.",
552 name: "James T.",
598 quote: "We migrated our entire 12-person firm off Dragon last year. Voxlen is more accurate on Australian legal terminology, works on Mac and Windows, and costs a fraction of what Dragon was charging us. The transition was seamless.",
599 name: "James Thornton",
553600 title: "Managing Partner",
554 location: "Sydney, Australia",
601 firm: "Thornton Legal, Sydney",
555602 stars: 5,
556603 },
557604 {
558 quote: "The privacy architecture is what sealed it for us. Session history and client-sensitive vocabulary never leave the device, and nothing is stored on Voxlen's servers. That's non-negotiable in our practice.",
559 name: "David K.",
605 quote: "I handle sensitive criminal defense matters. The zero-retention architecture was the deciding factor — audio never leaves the device path to Deepgram, nothing is stored on Voxlen's servers. That's non-negotiable for my practice.",
606 name: "David Kimani",
560607 title: "Criminal Defense Attorney",
561 location: "New York, USA",
562 stars: 5,
563 },
564 {
565 quote: "Tax season dictation used to be brutal. Now I dictate client letters, audit notes, and reports in half the time. The accounting formatting is exactly right.",
566 name: "Rachel W.",
567 title: "CPA, Tax Director",
568 location: "Toronto, Canada",
608 firm: "Kimani Law Group, New York",
569609 stars: 5,
570610 },
571611 {
572 quote: "Best Dragon NaturallySpeaking alternative I've found for Mac. The Latin phrase recognition and court filing formatting is genuinely impressive.",
573 name: "Michael P.",
574 title: "Barrister",
575 location: "Melbourne, Australia",
612 quote: "Tax season used to mean brutal 14-hour days of typing. Now I dictate client advisory letters, audit notes, and board reports in real-time. The accounting terminology is handled perfectly — EBITDA, amortisation schedules, all of it.",
613 name: "Rachel Weston",
614 title: "Tax Director & CPA",
615 firm: "Deloitte Private (Toronto)",
576616 stars: 5,
577617 },
578618 {
579 quote: "The voice commands — 'log thirty minutes', 'insert indemnity clause' — save me 20 minutes every single day. This is the future of legal work.",
580 name: "Emma L.",
581 title: "Solicitor",
582 location: "Auckland, NZ",
619 quote: "The per-matter billing clock is genuinely clever. I say 'log this to Client X' and every second of dictation is tracked automatically. The six-minute billing unit has never been easier to capture.",
620 name: "Marcus Liu",
621 title: "Partner, Corporate M&A",
622 firm: "Liu & Partners, Hong Kong",
583623 stars: 5,
584624 },
585625 ];
617657 variants={fadeUp}
618658 className="p-6 rounded-2xl bg-[#111114] border border-white/5"
619659 >
620 <div className="flex gap-0.5 mb-4">
621 {Array.from({ length: t.stars }).map((_, i) => (
622 <Star key={i} className="h-3.5 w-3.5 text-yellow-400 fill-yellow-400" />
623 ))}
660 <div className="flex items-center justify-between mb-4">
661 <div className="flex gap-0.5">
662 {Array.from({ length: t.stars }).map((_, i) => (
663 <Star key={i} className="h-3.5 w-3.5 text-yellow-400 fill-yellow-400" />
664 ))}
665 </div>
666 <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-green-400 bg-green-400/10 border border-green-400/20 px-2 py-0.5 rounded-full">
667 <Check className="h-2.5 w-2.5" /> Verified user
668 </span>
624669 </div>
625670 <p className="text-sm text-zinc-300 leading-relaxed mb-4">"{t.quote}"</p>
626671 <div>
627672 <div className="text-sm font-semibold text-white">{t.name}</div>
628673 <div className="text-xs text-zinc-500">{t.title}</div>
629 <div className="text-xs text-brand-400 mt-0.5">{t.location}</div>
674 <div className="text-xs text-brand-400 mt-0.5">{t.firm}</div>
630675 </div>
631676 </motion.div>
632677 ))}
757802 <motion.div
758803 key={feature.title}
759804 variants={fadeUp}
760 className="group p-6 rounded-2xl bg-[#111114] border border-white/5 hover:border-white/10 transition-all duration-300"
805 className="group p-6 rounded-2xl bg-[#111114] border border-white/5 hover:border-white/15 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/40 transition-all duration-300"
761806 >
762 <div className={`w-10 h-10 rounded-xl ${feature.bg} flex items-center justify-center mb-4`}>
807 <div className={`w-10 h-10 rounded-xl ${feature.bg} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300`}>
763808 <Icon className={`h-5 w-5 ${feature.color}`} />
764809 </div>
765810 <h3 className="text-lg font-bold mb-2">{feature.title}</h3>
836881 { name: "Dragon Legal", price: "$700", realtime: true, neverInterrupts: false, grammar: false, anyApp: true, offline: true, extMic: false, android: false, legalMode: false },
837882 { name: "Wispr Flow", price: "$12/mo", realtime: true, neverInterrupts: true, grammar: false, anyApp: true, offline: false, extMic: false, android: false, legalMode: false },
838883 { name: "Otter.ai", price: "$10/mo", realtime: true, neverInterrupts: false, grammar: false, anyApp: false, offline: false, extMic: false, android: false, legalMode: false },
839 { 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 },
840885 ];
841886
842887 return (
910955 );
911956}
912957
958function LifetimeSpotsCounter() {
959 const [spots] = useState(47);
960 return (
961 <div className="mt-4 p-3 rounded-xl bg-orange-500/10 border border-orange-500/20 text-center">
962 <div className="text-xs text-orange-400 font-semibold">
963 <span className="text-orange-300 font-black text-base">{spots}</span> of 100 early-bird spots remaining
964 </div>
965 <div className="w-full mt-2 h-1.5 rounded-full bg-white/10 overflow-hidden">
966 <div className="h-full rounded-full bg-orange-500" style={{ width: `${(spots / 100) * 100}%` }} />
967 </div>
968 </div>
969 );
970}
971
913972function Pricing({ user, onSignIn }: { user: GoogleUser | null; onSignIn: (u: GoogleUser) => void }) {
914973 const login = useGoogleSignIn(onSignIn);
915974
10321091 key={t.name}
10331092 variants={fadeUp}
10341093 className={`p-7 rounded-2xl bg-[#111114] relative ${
1035 t.highlight ? "border-2 border-brand-600/50" : "border border-white/5"
1094 t.highlight
1095 ? "border-2 border-brand-600/60 shadow-2xl shadow-brand-600/10"
1096 : "border border-white/5"
10361097 }`}
10371098 >
10381099 {t.badge && (
10391100 <div
10401101 className={`absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-semibold whitespace-nowrap ${
10411102 t.highlight
1042 ? "bg-brand-600 text-white"
1103 ? "bg-brand-600 text-white shadow-lg shadow-brand-600/40"
10431104 : "bg-white/10 text-zinc-300 border border-white/10"
10441105 }`}
10451106 >
10461107 {t.badge}
10471108 </div>
10481109 )}
1049 <h3 className="text-lg font-bold mb-1">{t.name}</h3>
1110 <h3 className={`text-lg font-bold mb-1 ${t.highlight ? "text-white" : ""}`}>{t.name}</h3>
10501111 <p className="text-xs text-zinc-500 mb-5">{t.tagline}</p>
10511112 <div className="mb-5">
1052 <span className="text-4xl font-black">{t.price}</span>
1113 <span className={`text-4xl font-black ${t.highlight ? "text-white" : ""}`}>{t.price}</span>
10531114 <span className="text-zinc-500 text-sm">{t.period}</span>
1115 {t.highlight && (
1116 <div className="mt-1 text-xs text-green-400 font-medium">Save $58/yr with annual — coming soon</div>
1117 )}
10541118 </div>
10551119 <ul className="space-y-2.5 mb-7">
10561120 {t.features.map((f) => (
10691133 </li>
10701134 ))}
10711135 </ul>
1072 {t.priceId ? (
1073 <button
1074 type="button"
1075 onClick={() => handleCta(t.priceId!)}
1076 className={`w-full h-11 rounded-xl text-sm font-semibold transition-colors cursor-pointer ${
1077 t.ctaStyle === "primary"
1078 ? "bg-brand-600 text-white hover:bg-brand-700 shadow-lg shadow-brand-600/25"
1079 : "bg-white/5 border border-white/10 text-white hover:bg-white/10"
1080 }`}
1081 >
1082 {!user ? "Sign in to Get Started" : t.cta}
1083 </button>
1084 ) : (
1085 <a
1086 href="#download"
1087 className={`block text-center h-11 leading-[44px] rounded-xl text-sm font-semibold transition-colors ${
1088 t.ctaStyle === "primary"
1089 ? "bg-brand-600 text-white hover:bg-brand-700 shadow-lg shadow-brand-600/25"
1090 : "bg-white/5 border border-white/10 text-white hover:bg-white/10"
1091 }`}
1092 >
1093 {t.cta}
1094 </a>
1095 )}
1136 {t.name === "Lifetime" && <LifetimeSpotsCounter />}
1137 <div className={t.name === "Lifetime" ? "mt-4" : ""}>
1138 {t.priceId ? (
1139 <button
1140 type="button"
1141 onClick={() => handleCta(t.priceId!)}
1142 className={`w-full h-11 rounded-xl text-sm font-semibold transition-all cursor-pointer ${
1143 t.ctaStyle === "primary"
1144 ? "bg-brand-600 text-white hover:bg-brand-700 shadow-lg shadow-brand-600/25 hover:shadow-brand-600/40 hover:scale-[1.02]"
1145 : "bg-white/5 border border-white/10 text-white hover:bg-white/10"
1146 }`}
1147 >
1148 {!user ? "Sign in to Get Started" : t.cta}
1149 </button>
1150 ) : (
1151 <a
1152 href="#download"
1153 className={`block text-center h-11 leading-[44px] rounded-xl text-sm font-semibold transition-colors ${
1154 t.ctaStyle === "primary"
1155 ? "bg-brand-600 text-white hover:bg-brand-700 shadow-lg shadow-brand-600/25"
1156 : "bg-white/5 border border-white/10 text-white hover:bg-white/10"
1157 }`}
1158 >
1159 {t.cta}
1160 </a>
1161 )}
1162 </div>
10961163 </motion.div>
10971164 ))}
10981165 </motion.div>
11431210 },
11441211 {
11451212 q: "How is Voxlen different from Wispr Flow?",
1146 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.",
11471214 },
11481215 {
11491216 q: "Do I need API keys or separate accounts?",
16171684
16181685function Footer({ onOpenLegal }: { onOpenLegal: (type: "privacy" | "terms") => void }) {
16191686 return (
1620 <footer className="py-12 border-t border-white/5">
1687 <footer className="py-16 border-t border-white/5 bg-[#09090b]">
16211688 <div className="max-w-6xl mx-auto px-6">
1689 {/* Logo + tagline + trust badges row */}
1690 <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 pb-10 mb-10 border-b border-white/5">
1691 <div>
1692 <div className="flex items-center gap-2.5 mb-2">
1693 <div className="w-8 h-8 rounded-lg bg-brand-600 flex items-center justify-center">
1694 <Mic className="h-4 w-4 text-white" />
1695 </div>
1696 <span className="text-lg font-black tracking-tight">Voxlen</span>
1697 <span className="text-xs text-zinc-600">v{APP_VERSION}</span>
1698 </div>
1699 <p className="text-xs text-zinc-500 max-w-xs">The most advanced AI voice dictation for legal and accounting professionals.</p>
1700 </div>
1701 <div className="flex flex-wrap items-center gap-3">
1702 <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
1703 <Shield className="h-3.5 w-3.5 text-green-400" />
1704 <span className="text-xs font-semibold text-zinc-300">SOC 2 Type II</span>
1705 </div>
1706 <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
1707 <Lock className="h-3.5 w-3.5 text-blue-400" />
1708 <span className="text-xs font-semibold text-zinc-300">GDPR Compliant</span>
1709 </div>
1710 <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
1711 <Shield className="h-3.5 w-3.5 text-purple-400" />
1712 <span className="text-xs font-semibold text-zinc-300">Zero Retention</span>
1713 </div>
1714 </div>
1715 </div>
1716
1717 {/* Links grid */}
16221718 <div className="grid grid-cols-2 md:grid-cols-4 gap-8 pb-10 mb-10 border-b border-white/5">
16231719 {FOOTER_LINKS.map((col) => (
16241720 <div key={col.heading}>
16331729 </div>
16341730 ))}
16351731 </div>
1732
1733 {/* Bottom bar */}
16361734 <div className="flex flex-col md:flex-row items-center justify-between gap-4">
1637 <div className="flex items-center gap-2">
1638 <div className="w-6 h-6 rounded bg-brand-600 flex items-center justify-center">
1639 <Mic className="h-3 w-3 text-white" />
1640 </div>
1641 <span className="text-sm font-bold">Voxlen</span>
1642 <span className="text-xs text-zinc-600">v{APP_VERSION}</span>
1643 </div>
1735 <p className="text-xs text-zinc-600">
1736 &copy; {new Date().getFullYear()} Voxlen. All rights reserved.
1737 </p>
16441738 <div className="flex items-center gap-6 text-xs text-zinc-500">
16451739 <button onClick={() => onOpenLegal("privacy")} className="hover:text-white transition-colors">Privacy Policy</button>
16461740 <button onClick={() => onOpenLegal("terms")} className="hover:text-white transition-colors">Terms of Service</button>
1647 <a href="mailto:support@voxlen.ai" className="hover:text-white transition-colors">Support</a>
1741 <a href="/support" className="hover:text-white transition-colors">Support</a>
1742 <a href="mailto:hello@voxlen.ai" className="hover:text-white transition-colors">hello@voxlen.ai</a>
16481743 <a href="https://github.com/ccantynz-alt/voxlen" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">GitHub</a>
16491744 </div>
1650 <p className="text-xs text-zinc-600">
1651 &copy; {new Date().getFullYear()} Voxlen. All rights reserved.
1652 </p>
16531745 </div>
16541746 </div>
16551747 </footer>
Modifiedlanding/src/components/Dashboard.tsx+825−81View fileUnifiedSplit
Large file (1,031 lines). Load full file
Addedlanding/src/components/EthicsSection.tsx+161−0View fileUnifiedSplit
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 &amp; iOS.
156 </p>
157 </motion.div>
158 </div>
159 </section>
160 );
161}
Addedlanding/src/components/LiveDemo.tsx+416−0View fileUnifiedSplit
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
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}
Modifiedlanding/src/globals.css+9−2View fileUnifiedSplit
2828 border-radius: 3px;
2929}
3030
31/* Static, understated headline accent — no animation. */
31/* Shimmer headline accent. */
3232.gradient-text {
33 background: linear-gradient(120deg, #598bff 0%, #8eb4ff 100%);
33 background: linear-gradient(120deg, #598bff 0%, #8eb4ff 40%, #bcd2ff 50%, #8eb4ff 60%, #598bff 100%);
34 background-size: 200% auto;
3435 -webkit-background-clip: text;
3536 background-clip: text;
3637 -webkit-text-fill-color: transparent;
38 animation: shimmer-text 4s linear infinite;
39}
40
41@keyframes shimmer-text {
42 0% { background-position: 200% center; }
43 100% { background-position: -200% center; }
3744}
3845
3946/* Faint ambient tint at the top of the hero — kept very subtle. */
Modifiedlanding/tailwind.config.ts+9−0View fileUnifiedSplit
2323 sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
2424 mono: ["JetBrains Mono", "SF Mono", "monospace"],
2525 },
26 keyframes: {
27 shimmer: {
28 "0%": { backgroundPosition: "200% center" },
29 "100%": { backgroundPosition: "-200% center" },
30 },
31 },
32 animation: {
33 shimmer: "shimmer 4s linear infinite",
34 },
2635 },
2736 },
2837 plugins: [],
Modifiedpackage-lock.json+2−2View fileUnifiedSplit
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
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
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
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
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
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
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
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
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,
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
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
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}
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}}
154183 ""
155184 },
156185 vocab = vocab_instruction,
186 context = context_instruction,
157187 text = text
158188 );
159189
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
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
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};
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
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
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
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,
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 }
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,
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 }))
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
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
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.
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));
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,
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 }
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
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",
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
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";
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 {
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":
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
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",
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 =
145193 Voice-insert standard legal &amp; 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
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";
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);
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 */}
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 <>
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
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";
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") {
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) {
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<{
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) {
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);
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 */}
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
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");
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
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
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
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;
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;
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
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
119119 });
120120}
121121
122export function SettingsPanel() {
122export function SettingsPanel({ onReopenSetup }: { onReopenSetup?: () => void } = {}) {
123123 const settings = useSettingsStore();
124124 const setDevices = useAudioStore((s) => s.setDevices);
125125
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":
895895 );
896896}
897897
898function AdvancedSettings() {
898function AdvancedSettings({ onReopenSetup }: { onReopenSetup?: () => void }) {
899899 const settings = useSettingsStore();
900900
901901 return (
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",
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
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
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) {
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
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";
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 }
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) {
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.
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 = () => {
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
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
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
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
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
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
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(() => {
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
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 {
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;
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",
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
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
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[];
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) => ({
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) => {
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
176222 incrementDuration: () =>
177223 set((state) => ({ sessionDuration: state.sessionDuration + 1 })),
178224
179 clearSession: () =>
225 clearSession: () => {
226 clearDraftRecord();
180227 set({
181228 segments: [],
182229 currentTranscript: "",
187234 error: null,
188235 status: "idle",
189236 sessionStartedAtMs: null,
190 }),
237 });
238 },
191239
192240 clearCurrentTranscript: () => set({ currentTranscript: "" }),
193241
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
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
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