CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

10 Mandatory Rules + streaming voice rewrite (WhisperTyping parity) #4013

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/marketing-mobile-buildmainopened Mar 25, 2026
2 changed files+374−617
ModifiedCLAUDE.md+113−64View fileUnifiedSplit
66- If something can't be done, say WHY and offer the next-best alternative immediately.
77- Never present a half-solution without flagging what's missing.
88
9## Hard Rules — Non-Negotiable
10
11### 1. Engineering Gap Detection (MANDATORY)
12Before writing ANY code or proposing ANY solution:
13- Research what competitors ship (WhisperTyping, Wispr Flow, SuperWhisper, Voicy)
14- Identify what technology they use and why
15- Flag EVERY gap between our product and theirs BEFORE building
16- If a browser security limitation blocks a feature, say so IMMEDIATELY — don't build a workaround and pretend it's equivalent
17- If an approach requires copy-paste, manual steps, or user friction — REJECT IT and find a better way
18
19### 2. Bug Prevention & Auto-Repair
20- Test every code path mentally before writing it
21- Handle ALL error states (mic permission denied, API failures, network loss, browser incompatibility)
22- Never ship code that fails silently — every failure must show a clear message to the user
23- If a bug is found during development, fix it BEFORE moving on
24- Run through the full user flow start-to-finish before marking anything as done
25
26### 3. Technology Research (MANDATORY)
27Before implementing any feature:
28- Search for the best current technology/library for the job (2024-2026 state of the art)
29- Compare at least 2-3 approaches and pick the best one
30- Document WHY a technology was chosen over alternatives
31- If a better approach exists than what we're currently using, FLAG IT and recommend migration
9---
3210
33### 4. Autonomous Operation
34- Don't wait for the user to find problems — find them yourself
35- Don't ask permission to fix obvious bugs — just fix them
36- Don't present options when one is clearly better — make the call and explain why
37- If a task requires 5 steps, do all 5 — don't stop at step 2 and wait for instructions
38- If something breaks during implementation, fix it before reporting back
11## THE 10 MANDATORY RULES — Non-Negotiable
12
13These rules override everything. Every session. Every task. No exceptions.
3914
40### 5. No Half-Measures
15### Rule 1: Scan Before You Build
16Every session starts by checking for security issues, broken code, and problems BEFORE doing anything new.
17- Run through the user flow: install → configure → use → edge cases
18- Check if existing features actually work (don't assume they do)
19- If something is broken, fix it FIRST before building new things
20- Test on the actual platforms users will use (Claude.ai, ChatGPT, Gmail, etc.)
21
22### Rule 2: Auto-Repair
23If a bug is found while working on ANYTHING, fix it immediately.
24- No "that's out of scope" excuses
25- No "I'll fix that later" — fix it NOW
26- No asking permission to fix obvious bugs — just fix them
27- If a bug was reported before and not fixed, that's a failure — escalate priority
28
29### Rule 3: Proactive Research
30Before building anything significant, check if there's a better way.
31- Search for the best current technology/library for the job (2025-2026 state of the art)
32- Compare at least 2-3 approaches and pick the best one
33- Research what competitors ship (WhisperTyping, Wispr Flow, SuperWhisper, Grammarly)
34- If a better approach exists than what we're using, FLAG IT and recommend migration
35- Document WHY a technology was chosen
36
37### Rule 4: Engineering Gap Detection
38Systematically check for features that promise something the code can't deliver.
39- Flag EVERY gap between our product and competitors BEFORE building
40- If a browser limitation blocks a feature, say so IMMEDIATELY
41- Check every feature actually works on the target platform (Claude.ai, ChatGPT, Gmail)
42- If an approach requires copy-paste or manual steps — REJECT IT
43- Missing error handling = gap. Silent failures = gap. Find them all.
44
45### Rule 5: Technology Currency
46Check if our tools are up to date. If there's newer, faster, safer — upgrade.
47- Review npm dependencies for security vulnerabilities
48- Check if browser APIs have changed (SpeechRecognition, Clipboard, etc.)
49- If a library we're using has a better alternative, migrate
50- Stay on latest stable versions of Electron, React, Tailwind
51
52### Rule 6: Explain Like You're Not A Developer
53All communication in plain English.
54- "Your users were seeing X, now they see Y" — not tech jargon
55- Explain what changed, why, and what the user will experience
56- If something is technically complex, explain with an analogy
57- Never say "refactored the state machine" — say "fixed the recording getting stuck"
58
59### Rule 7: Never Leave It Worse
60Every file touched gets cleaned up. No leaving messes behind.
61- If you open a file to fix one thing and see other issues, fix them too
62- No commented-out code left behind
63- No TODO comments without a plan to address them
64- Code should be cleaner after every session, not messier
65
66### Rule 8: Autonomous Testing
67After changes, verify everything still works. No "it should be fine."
68- Mentally trace every code path
69- Check that error states are handled
70- Verify the feature works on the specific sites users care about (Claude.ai, ChatGPT, Gmail)
71- If a change could break something else, check that too
72
73### Rule 9: Mandatory Documentation
74Every change gets documented so the next session knows what happened.
75- Commit messages explain WHAT and WHY
76- STRATEGY.md stays updated with current state
77- Known issues are documented, not hidden
78- Architecture decisions are recorded with reasoning
79
80### Rule 10: Mandatory Session Protocol
81Every session must follow this protocol:
821. **SCAN** — Check for broken features, bugs, security issues
832. **DETECT** — Find engineering gaps vs competitors
843. **FIX** — Repair everything found before building new
854. **RESEARCH** — Check for better tech before implementing
865. **BUILD** — Only then, build new features
876. **TEST** — Verify everything works end-to-end
887. **DOCUMENT** — Record what changed and why
89
90---
91
92## Hard Rules — Additional
93
94### No Half-Measures
4195- Every feature must work end-to-end with zero user friction
4296- "Copy to clipboard" is NOT a solution when auto-typing is possible
43- "Drag to bookmarks bar" is NOT zero-friction — find a better way or be honest about the tradeoff
44- If the web platform can't do something, the desktop app MUST, and vice versa
97- If the web platform can't do something, say so HONESTLY
4598- Every user-facing flow must be tested: install → configure → use → edge cases
4699
47### 6. Honest Communication
100### Honest Communication
48101- If something is technically impossible in a browser, say so clearly
49102- If a feature requires a download, don't dress it up as "web-based"
50103- Separate WHAT WORKS from WHAT'S ASPIRATIONAL
51- Give time/effort estimates for complex features
52104- Flag risks and blockers at the START, not when they become problems
53105
106### Autonomous Operation
107- Don't wait for the user to find problems — find them yourself
108- Don't present options when one is clearly better — make the call and explain
109- If a task requires 5 steps, do all 5 — don't stop at step 2
110- If something breaks during implementation, fix it before reporting back
111
112---
113
54114## Architecture Decisions (Locked In)
55115
56### Product Delivery — Two Modes:
571. **Desktop App (PRIMARY)** — Electron app for Windows + Mac
58 - System tray with global hotkey (Ctrl+Shift+Space / Cmd+Shift+Space)
59 - Uses OS-level keyboard simulation to type into ANY focused text field
60 - Works in ANY app: browsers, Slack, Discord, VS Code, anything
61 - Uses @nut-tree/nut-js for cross-platform keyboard simulation
62 - Whisper API for transcription (high accuracy) + Web Speech API fallback
63 - Auto-updates via electron-updater
64 - This is how WhisperTyping/Wispr Flow/SuperWhisper do it — proven approach
65
662. **Web Bookmarklet (SECONDARY)** — Zero-download option
67 - User clicks a button on 48co.nz → injects voice widget into current page
68 - Uses javascript: URI or hosted script injection
69 - Types DIRECTLY into the page's text fields (not clipboard)
70 - Limited to browser only, but zero friction for web-only users
71 - HONEST TRADEOFF: Only works in browser, only on the current page, must re-inject per page
72 - Chrome extension remains as the BEST browser-only experience
73
743. **Website (48co.nz)** — Marketing + Live Demo + Downloads
75 - Landing page with clear value prop
76 - Live demo that actually works (uses Web Speech API on the page itself)
77 - Download page with platform detection
78 - Bookmarklet activation page
116### Product = AI Grammar + Voice-to-Text Everywhere
117
118### Product Delivery — Five Platforms:
1191. **Desktop App** (PRIMARY) — Electron, system tray, global hotkey, types into any app
1202. **Chrome Extension** (VIRAL ENGINE) — Grammar check on any website, free tier drives upgrades
1213. **iPhone Keyboard** — Custom keyboard extension, corrects as you type, voice button
1224. **Android Keyboard** — Same as iPhone, InputMethodService
1235. **Website** (48co.nz) — Marketing, live demo, SEO, downloads
124
125### Voice-to-Text Standard (Match WhisperTyping):
126- Text appears in the chat box WORD BY WORD as user speaks — NOT after they stop
127- Use Web Speech API with interimResults=true for real-time streaming
128- ZERO popups, overlays, or visible UI elements — just text appearing
129- Mouse wheel click as default trigger (customizable hotkey)
130- Must work on: Claude.ai, ChatGPT, Gemini, DeepSeek, Gmail, Slack, any site
79131
80132### Tech Stack:
81133- **Website**: Next.js 14, React 18, Tailwind CSS 3.4 (LIGHT THEME — white bg, indigo accent)
86138- **Speech-to-Text**: OpenAI Whisper API (primary), Web Speech API (free fallback)
87139- **Build/Package**: electron-builder for Win + Mac, EAS Build for iOS + Android
88140
89### Product Delivery — Five Platforms:
901. **Desktop App** (PRIMARY) — Electron, system tray, global hotkey, types into any app
912. **Chrome Extension** (VIRAL ENGINE) — Grammar check on any website, free tier drives upgrades
923. **iPhone Keyboard** — Custom keyboard extension, corrects as you type, voice button
934. **Android Keyboard** — Same as iPhone, InputMethodService
945. **Website** (48co.nz) — Marketing, live demo, SEO, downloads
95
96141### Quality Checklist (Run Before Every Commit):
97142- [ ] Does every feature work end-to-end?
98143- [ ] Are all error states handled with user-friendly messages?
99144- [ ] Is there any copy-paste or manual friction that could be automated?
100145- [ ] Would a competitor's user be impressed or disappointed switching to us?
101146- [ ] Has every engineering gap been flagged and addressed?
147- [ ] Does voice-to-text stream into the chat box in real-time (not after stopping)?
148- [ ] Does text insertion work on Claude.ai, ChatGPT, and Gmail specifically?
102149
103150## Known Limitations (Be Honest About These):
104151- **Web bookmarklet** cannot type into other tabs — only the current page
107154- **Whisper API requires an API key** — costs ~$0.006/minute
108155- **Electron apps are large** (~150MB) — this is a known Electron tradeoff
109156- **macOS requires accessibility permissions** for keyboard simulation
157- **Chrome extension cannot type into other apps** — only browser text fields
110158
111159## File Structure Convention:
112160```
117165/desktop/main/ → Electron main process
118166/desktop/renderer/ → Electron renderer (UI)
119167/desktop/assets/ → Icons, images for desktop app
168/mobile/ → Mobile keyboard app (React Native + native extensions)
120169/public/ → Static web assets
121170```
Modifiedextension/content.js+261−553View fileUnifiedSplit
11/**
2 * 48co Content Script
3 * Injected into all pages. ZERO visible elements on the page.
4 * All controls: toolbar icon popup, middle-click, Ctrl+Shift+Space, push-to-talk.
5 * Status shown via extension badge only.
2 * 48co Content Script — STREAMING VOICE-TO-TEXT
63 *
7 * AUDIT FIXES (22 bugs addressed):
8 * - State machine guards on every transition
9 * - Separate timeout IDs for recording vs processing
10 * - Whisper errors are errors, not text
11 * - processTranscript checks state before acting
12 * - All failures show feedback via badge
13 * - insertText uses clipboard-paste fallback for ProseMirror
4 * Works like WhisperTyping: text appears in the chat box WORD BY WORD
5 * as you speak. No popups. No overlays. No visible UI.
6 *
7 * Trigger: Mouse wheel click (auxclick button 1) or Ctrl+Shift+Space
8 *
9 * How it works:
10 * 1. User clicks mouse wheel → recording starts
11 * 2. Web Speech API streams interim results
12 * 3. Each word is IMMEDIATELY inserted into the focused text field
13 * 4. User clicks mouse wheel again → recording stops
14 * 5. Final text is cleaned up (punctuation, capitalization)
1415 */
1516;(function () {
1617 'use strict'
1718
18 // ═══════════════════════════════════════════════════════════════════
19 // SITE ADAPTERS
20 // ═══════════════════════════════════════════════════════════════════
21
22 function queryFirst(selectors) {
23 for (const sel of selectors) {
24 try {
25 const el = document.querySelector(sel)
26 if (el) return el
27 } catch { /* skip invalid selectors */ }
28 }
29 return null
30 }
19 // ═══════════════════════════════════════════════════════════════
20 // STATE
21 // ═══════════════════════════════════════════════════════════════
3122
32 const ADAPTERS = {
33 claude: {
34 name: 'Claude',
35 input: [
36 'div.ProseMirror[contenteditable="true"]',
37 'div[contenteditable="true"][data-testid]',
38 'div[contenteditable="true"][class*="ProseMirror"]',
39 'fieldset div[contenteditable="true"]',
40 'div[contenteditable="true"][data-placeholder]',
41 'div[contenteditable="true"]',
42 ],
43 send: [
44 'button[aria-label="Send Message"]',
45 'button[aria-label="Send message"]',
46 'fieldset button[type="button"]:last-of-type',
47 'button[data-testid="send-button"]',
48 ],
49 },
50 chatgpt: {
51 name: 'ChatGPT',
52 input: [
53 '#prompt-textarea',
54 'div[id="prompt-textarea"]',
55 'div.ProseMirror[contenteditable="true"]',
56 'textarea[data-id="root"]',
57 'textarea',
58 ],
59 send: [
60 'button[data-testid="send-button"]',
61 'button[aria-label="Send prompt"]',
62 'button[aria-label="Send"]',
63 'form button[type="submit"]',
64 ],
65 },
66 gemini: {
67 name: 'Gemini',
68 input: [
69 'rich-textarea div[contenteditable="true"]',
70 'div.ql-editor[contenteditable="true"]',
71 'div[contenteditable="true"][aria-label*="prompt"]',
72 'div[contenteditable="true"][role="textbox"]',
73 'div[contenteditable="true"]',
74 ],
75 send: [
76 'button[aria-label="Send message"]',
77 'button.send-button',
78 'button[data-test-id="send-button"]',
79 ],
80 },
81 deepseek: {
82 name: 'DeepSeek',
83 input: [
84 'textarea#chat-input',
85 'textarea[placeholder*="message"]',
86 'textarea[placeholder*="Message"]',
87 'div[contenteditable="true"]',
88 'textarea',
89 ],
90 send: [
91 'button[aria-label="Send"]',
92 'div[role="button"][aria-label="Send"]',
93 'button.send-btn',
94 'button[class*="send"]',
95 ],
96 },
23 let state = {
24 status: 'idle', // idle | recording
25 engine: 'web-speech',
26 language: 'en',
27 codingMode: false,
28 autoCoding: true,
29 autoSubmit: false,
30 pushToTalk: false,
31 vocabulary: [],
32 replacements: [],
9733 }
9834
99 const ADAPTERS_GENERIC = {
100 name: 'Any Site',
101 input: [
102 'textarea:focus',
103 'input[type="text"]:focus',
104 'div[contenteditable="true"]:focus',
105 'textarea',
106 'div[contenteditable="true"]',
107 'input[type="text"]',
108 'input:not([type])',
109 ],
110 send: [
111 'button[type="submit"]',
112 'form button:last-of-type',
113 ],
114 }
35 let recognition = null
36 let lastInsertedText = '' // track what we've already typed so we only insert new chars
11537
116 function detectSite() {
117 const host = window.location.hostname
118 if (host.includes('claude.ai')) return ADAPTERS.claude
119 if (host.includes('chatgpt.com') || host.includes('chat.openai.com')) return ADAPTERS.chatgpt
120 if (host.includes('gemini.google.com')) return ADAPTERS.gemini
121 if (host.includes('chat.deepseek.com')) return ADAPTERS.deepseek
122 return ADAPTERS_GENERIC
123 }
38 // Load settings
39 chrome.storage.local.get([
40 'codingMode', 'autoCoding', 'autoSubmit', 'engine', 'language',
41 'pushToTalk', 'vocabulary', 'replacements',
42 ], (stored) => {
43 if (stored.codingMode !== undefined) state.codingMode = stored.codingMode
44 if (stored.autoCoding !== undefined) state.autoCoding = stored.autoCoding
45 if (stored.autoSubmit !== undefined) state.autoSubmit = stored.autoSubmit
46 if (stored.engine !== undefined) state.engine = stored.engine
47 if (stored.language !== undefined) state.language = stored.language
48 if (stored.pushToTalk !== undefined) state.pushToTalk = stored.pushToTalk
49 if (stored.vocabulary) state.vocabulary = stored.vocabulary
50 if (stored.replacements) state.replacements = stored.replacements
51 })
12452
125 function getInput(adapter) { return queryFirst(adapter.input) }
126 function getSend(adapter) { return queryFirst(adapter.send) }
53 // ═══════════════════════════════════════════════════════════════
54 // TEXT INSERTION — multiple methods for different editors
55 // ═══════════════════════════════════════════════════════════════
12756
128 // Track last focused text input
12957 let lastFocusedInput = null
58
59 // Remember the last text field the user clicked/focused
13060 document.addEventListener('focusin', (e) => {
13161 const t = e.target
13262 if (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.contentEditable === 'true') {
13464 }
13565 }, true)
13666
137 // ═══════════════════════════════════════════════════════════════════
138 // TEXT INSERTION — with ProseMirror clipboard-paste fallback
139 // ═══════════════════════════════════════════════════════════════════
67 function findTextInput() {
68 // Try site-specific selectors first
69 const host = window.location.hostname
70 const selectors = []
71
72 if (host.includes('claude.ai')) {
73 selectors.push(
74 'div.ProseMirror[contenteditable="true"]',
75 'div[contenteditable="true"][data-placeholder]',
76 'fieldset div[contenteditable="true"]',
77 )
78 } else if (host.includes('chatgpt.com') || host.includes('chat.openai.com')) {
79 selectors.push('#prompt-textarea', 'div[id="prompt-textarea"]', 'div.ProseMirror[contenteditable="true"]')
80 } else if (host.includes('gemini.google.com')) {
81 selectors.push('rich-textarea div[contenteditable="true"]', 'div.ql-editor[contenteditable="true"]')
82 } else if (host.includes('chat.deepseek.com')) {
83 selectors.push('textarea#chat-input', 'textarea[placeholder*="message"]')
84 }
85
86 // Site-specific
87 for (const sel of selectors) {
88 try { const el = document.querySelector(sel); if (el) return el } catch {}
89 }
90
91 // Last focused
92 if (lastFocusedInput && document.body.contains(lastFocusedInput)) return lastFocusedInput
14093
141 function insertText(adapter, text) {
142 let el = getInput(adapter)
143 if (!el && lastFocusedInput && document.body.contains(lastFocusedInput)) {
144 el = lastFocusedInput
94 // Generic fallback
95 const generic = ['textarea:focus', 'div[contenteditable="true"]:focus', 'textarea', 'div[contenteditable="true"]', 'input[type="text"]']
96 for (const sel of generic) {
97 try { const el = document.querySelector(sel); if (el) return el } catch {}
14598 }
99
100 return null
101 }
102
103 // Insert text at cursor position
104 function insertAtCursor(text) {
105 const el = findTextInput()
146106 if (!el) return false
147107 el.focus()
148108
149109 if (el.contentEditable === 'true') {
150 // Clear ProseMirror placeholder
151 const placeholder = el.querySelector('p.is-empty, p.is-editor-empty')
152 if (placeholder) placeholder.textContent = ''
153
154 // Method 1: execCommand (works on basic contentEditable)
110 // Method 1: execCommand (works on many editors)
155111 const ok = document.execCommand('insertText', false, text)
156 if (ok && el.textContent.includes(text.slice(0, 20))) {
112 if (ok) {
157113 el.dispatchEvent(new InputEvent('input', { bubbles: true, data: text }))
158114 return true
159115 }
160116
161 // Method 2: Synthetic paste event (works on ProseMirror — Claude, ChatGPT)
117 // Method 2: Synthetic paste (works on ProseMirror — Claude, ChatGPT)
162118 try {
163119 const dt = new DataTransfer()
164120 dt.setData('text/plain', text)
165 const ok2 = el.dispatchEvent(new ClipboardEvent('paste', {
166 bubbles: true,
167 cancelable: true,
168 clipboardData: dt,
169 }))
170 if (ok2 !== false) return true
171 } catch { /* fall through */ }
172
173 // Method 3: Direct DOM manipulation (last resort)
121 el.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt }))
122 return true
123 } catch {}
124
125 // Method 3: Direct DOM insert
174126 try {
175127 const textNode = document.createTextNode(text)
176128 const sel = window.getSelection()
187139 }
188140 el.dispatchEvent(new InputEvent('input', { bubbles: true }))
189141 return true
190 } catch { /* fall through */ }
142 } catch {}
191143
192144 return false
193145 }
194146
195 // Regular textarea/input
147 // Textarea / input
196148 if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
149 const start = el.selectionStart || el.value.length
150 const before = el.value.substring(0, start)
151 const after = el.value.substring(el.selectionEnd || start)
197152 const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
198153 const setter = Object.getOwnPropertyDescriptor(proto, 'value').set
199 setter.call(el, el.value + text)
154 setter.call(el, before + text + after)
155 el.selectionStart = el.selectionEnd = start + text.length
200156 el.dispatchEvent(new Event('input', { bubbles: true }))
201157 return true
202158 }
204160 return false
205161 }
206162
207 function triggerSend(adapter) {
208 setTimeout(() => {
209 const btn = getSend(adapter)
210 if (btn && !btn.disabled) {
211 btn.click()
212 return
213 }
214 const el = getInput(adapter)
215 if (el) {
216 el.dispatchEvent(new KeyboardEvent('keydown', {
217 key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true,
218 }))
219 }
220 }, 80)
221 }
222
223 // ═══════════════════════════════════════════════════════════════════
224 // VOICE COMMANDS
225 // ═══════════════════════════════════════════════════════════════════
226
227 const VOICE_COMMANDS = [
228 { triggers: ['claude refactor this', 'refactor this'], action: 'text', output: 'Refactor the following code. Identify inefficiencies, simplify logic, and rewrite it cleanly:\n\n' },
229 { triggers: ['claude explain this', 'explain this'], action: 'text', output: 'Explain what the following code does, step by step:\n\n' },
230 { triggers: ['claude debug this', 'debug this'], action: 'text', output: 'Debug the following code. Find errors, explain them, and provide a fix:\n\n' },
231 { triggers: ['claude fix this', 'fix this'], action: 'text', output: 'Fix the following code. Identify the bug and provide the corrected version:\n\n' },
232 { triggers: ['claude test this', 'test this'], action: 'text', output: 'Write comprehensive tests for the following code:\n\n' },
233 { triggers: ['claude optimize this', 'optimize this'], action: 'text', output: 'Optimize the following code for performance:\n\n' },
234 { triggers: ['send it', 'send message'], action: 'submit', output: null },
235 { triggers: ['cancel', 'cancel that', 'never mind'], action: 'cancel', output: null },
236 ]
237
238 function matchCommand(text) {
239 const lower = text.toLowerCase().trim()
240 for (const cmd of VOICE_COMMANDS) {
241 for (const trigger of cmd.triggers) {
242 if (lower.includes(trigger)) return cmd
243 }
244 }
245 return null
246 }
247
248 // ═══════════════════════════════════════════════════════════════════
249 // PUNCTUATION & EMOJI SUBSTITUTIONS
250 // ═══════════════════════════════════════════════════════════════════
251
252 const PUNCTUATION_MAP = [
253 { pattern: /\b(full stop|period)\b/gi, replacement: '.' },
254 { pattern: /\bcomma\b/gi, replacement: ',' },
255 { pattern: /\b(question mark)\b/gi, replacement: '?' },
256 { pattern: /\b(exclamation mark|exclamation point)\b/gi, replacement: '!' },
257 { pattern: /\bsemicolon\b/gi, replacement: ';' },
258 { pattern: /\bcolon\b/gi, replacement: ':' },
259 { pattern: /\bellipsis\b/gi, replacement: '...' },
260 { pattern: /\bdash\b/gi, replacement: ' — ' },
261 { pattern: /\bhyphen\b/gi, replacement: '-' },
262 { pattern: /\b(new line|newline)\b/gi, replacement: '\n' },
263 { pattern: /\b(new paragraph)\b/gi, replacement: '\n\n' },
264 { pattern: /\btab\b/gi, replacement: '\t' },
265 { pattern: /\b(open parenthesis|open paren|left paren)\b/gi, replacement: '(' },
266 { pattern: /\b(close parenthesis|close paren|right paren)\b/gi, replacement: ')' },
267 { pattern: /\b(open bracket|left bracket)\b/gi, replacement: '[' },
268 { pattern: /\b(close bracket|right bracket)\b/gi, replacement: ']' },
269 { pattern: /\b(open brace|left brace|open curly)\b/gi, replacement: '{' },
270 { pattern: /\b(close brace|right brace|close curly)\b/gi, replacement: '}' },
271 { pattern: /\b(open quote|begin quote)\b/gi, replacement: '"' },
272 { pattern: /\b(close quote|end quote|unquote)\b/gi, replacement: '"' },
273 { pattern: /\bsingle quote\b/gi, replacement: "'" },
274 { pattern: /\b(at sign|at symbol)\b/gi, replacement: '@' },
275 { pattern: /\b(hash sign|hashtag|pound sign)\b/gi, replacement: '#' },
276 { pattern: /\b(dollar sign)\b/gi, replacement: '$' },
277 { pattern: /\b(percent sign|percentage)\b/gi, replacement: '%' },
278 { pattern: /\b(ampersand)\b/gi, replacement: '&' },
279 { pattern: /\b(asterisk|star)\b/gi, replacement: '*' },
280 { pattern: /\bforward slash\b/gi, replacement: '/' },
281 { pattern: /\bbackslash\b/gi, replacement: '\\' },
282 { pattern: /\b(pipe|vertical bar)\b/gi, replacement: '|' },
283 { pattern: /\b(underscore)\b/gi, replacement: '_' },
284 { pattern: /\b(equals sign)\b/gi, replacement: '=' },
285 { pattern: /\b(plus sign)\b/gi, replacement: '+' },
286 { pattern: /\b(less than|left angle)\b/gi, replacement: '<' },
287 { pattern: /\b(greater than|right angle)\b/gi, replacement: '>' },
288 { pattern: /\btilde\b/gi, replacement: '~' },
289 { pattern: /\b(thumbs up emoji|thumbsup emoji)\b/gi, replacement: '👍' },
290 { pattern: /\b(thumbs down emoji)\b/gi, replacement: '👎' },
291 { pattern: /\b(smiley face|smiley emoji|smile emoji)\b/gi, replacement: '😊' },
292 { pattern: /\b(heart emoji)\b/gi, replacement: '❤️' },
293 { pattern: /\b(fire emoji)\b/gi, replacement: '🔥' },
294 { pattern: /\b(check mark emoji|checkmark emoji)\b/gi, replacement: '✅' },
295 { pattern: /\b(cross mark emoji|x emoji)\b/gi, replacement: '❌' },
296 { pattern: /\b(warning emoji)\b/gi, replacement: '⚠️' },
297 { pattern: /\b(rocket emoji)\b/gi, replacement: '🚀' },
298 { pattern: /\b(thinking emoji)\b/gi, replacement: '🤔' },
299 { pattern: /\b(clap emoji)\b/gi, replacement: '👏' },
300 { pattern: /\b(laughing emoji|lol emoji)\b/gi, replacement: '😂' },
301 { pattern: /\b(crying emoji|sad emoji)\b/gi, replacement: '😢' },
302 { pattern: /\b(wave emoji)\b/gi, replacement: '👋' },
303 { pattern: /\b(party emoji)\b/gi, replacement: '🎉' },
304 { pattern: /\b(eyes emoji)\b/gi, replacement: '👀' },
305 { pattern: /\b(100 emoji)\b/gi, replacement: '💯' },
306 { pattern: /\b(bug emoji)\b/gi, replacement: '🐛' },
307 ]
308
309 function applyPunctuation(text) {
310 let result = text
311 for (const { pattern, replacement } of PUNCTUATION_MAP) {
312 result = result.replace(pattern, replacement)
313 }
314 return result
315 }
316
317 // ═══════════════════════════════════════════════════════════════════
318 // TEXT POST-PROCESSING
319 // ═══════════════════════════════════════════════════════════════════
320
321 function postProcess(text) {
322 let result = text
323 result = applyPunctuation(result)
324
325 if (state.vocabulary && state.vocabulary.length > 0) {
326 for (const { from, to } of state.vocabulary) {
327 if (from && to) {
328 const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
329 result = result.replace(new RegExp('\\b' + escaped + '\\b', 'gi'), to)
330 }
331 }
332 }
163 // Replace ALL text in the field (used for final cleanup)
164 function replaceAllText(el, newText) {
165 if (!el) return
166 el.focus()
333167
334 if (state.replacements && state.replacements.length > 0) {
335 for (const { from, to } of state.replacements) {
336 if (from && to) result = result.replaceAll(from, to)
337 }
168 if (el.contentEditable === 'true') {
169 // Select all then replace
170 const sel = window.getSelection()
171 const range = document.createRange()
172 range.selectNodeContents(el)
173 sel.removeAllRanges()
174 sel.addRange(range)
175 document.execCommand('insertText', false, newText)
176 el.dispatchEvent(new InputEvent('input', { bubbles: true }))
177 } else if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
178 const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
179 const setter = Object.getOwnPropertyDescriptor(proto, 'value').set
180 setter.call(el, newText)
181 el.dispatchEvent(new Event('input', { bubbles: true }))
338182 }
339
340 result = result.replace(/\s+([.,;:!?)\]}])/g, '$1')
341 result = result.replace(/([.,;:!?])([A-Za-z])/g, '$1 $2')
342 result = result.replace(/^(\s*)([a-z])/, (_, ws, ch) => ws + ch.toUpperCase())
343 result = result.replace(/([.!?]\s+)([a-z])/g, (_, punct, ch) => punct + ch.toUpperCase())
344 result = result.replace(/(\n\s*)([a-z])/g, (_, nl, ch) => nl + ch.toUpperCase())
345 result = result.replace(/ {2,}/g, ' ')
346 return result.trim()
347 }
348
349 // ═══════════════════════════════════════════════════════════════════
350 // AUTO CODING MODE
351 // ═══════════════════════════════════════════════════════════════════
352
353 const CODING_KEYWORDS = [
354 'function', 'class', 'import', 'export', 'const', 'let', 'var',
355 'loop', 'array', 'object', 'return', 'async', 'await', 'def',
356 'interface', 'struct', 'enum', 'module', 'require', 'extends',
357 'constructor', 'prototype', 'promise', 'component', 'useState',
358 'useEffect', 'middleware', 'endpoint', 'callback',
359 ]
360
361 function isCodingContent(text) {
362 const lower = text.toLowerCase()
363 return CODING_KEYWORDS.some((kw) => lower.includes(kw))
364 }
365
366 function detectLang(text) {
367 const l = text.toLowerCase()
368 if (l.includes('python') || l.includes('def ')) return 'python'
369 if (l.includes('rust') || l.includes('fn ')) return 'rust'
370 if (l.includes('go ') || l.includes('goroutine')) return 'go'
371 if (l.includes('typescript')) return 'typescript'
372 if (l.includes('bash') || l.includes('terminal')) return 'bash'
373 if (l.includes('sql') || l.includes('query')) return 'sql'
374 if (l.includes('html') || l.includes('div ')) return 'html'
375 if (l.includes('css') || l.includes('flexbox')) return 'css'
376 return 'javascript'
377 }
378
379 function wrapCode(text) {
380 return '```' + detectLang(text) + '\n' + text + '\n```'
381 }
382
383 // ═══════════════════════════════════════════════════════════════════
384 // STATE — single source of truth
385 // ═══════════════════════════════════════════════════════════════════
386
387 let state = {
388 status: 'idle', // idle | recording | processing | done
389 transcript: '',
390 codingMode: false,
391 autoCoding: true,
392 autoSubmit: false,
393 engine: 'web-speech',
394 language: 'en',
395 pushToTalk: false,
396 vocabulary: [],
397 replacements: [],
398 }
399
400 let recognition = null
401 const adapter = detectSite()
402
403 // Load persisted settings
404 chrome.storage.local.get([
405 'codingMode', 'autoCoding', 'autoSubmit', 'engine', 'language',
406 'pushToTalk', 'vocabulary', 'replacements',
407 ], (stored) => {
408 if (stored.codingMode !== undefined) state.codingMode = stored.codingMode
409 if (stored.autoCoding !== undefined) state.autoCoding = stored.autoCoding
410 if (stored.autoSubmit !== undefined) state.autoSubmit = stored.autoSubmit
411 if (stored.engine !== undefined) state.engine = stored.engine
412 if (stored.language !== undefined) state.language = stored.language
413 if (stored.pushToTalk !== undefined) state.pushToTalk = stored.pushToTalk
414 if (stored.vocabulary) state.vocabulary = stored.vocabulary
415 if (stored.replacements) state.replacements = stored.replacements
416 })
417
418 // ═══════════════════════════════════════════════════════════════════
419 // ZERO DOM — badge only
420 // ═══════════════════════════════════════════════════════════════════
421
422 function updateUI() {
423 try {
424 chrome.runtime.sendMessage({ type: 'UPDATE_BADGE', status: state.status })
425 } catch { /* extension context invalid */ }
426183 }
427184
428 // ═══════════════════════════════════════════════════════════════════
429 // SAFE STATE TRANSITION — prevents impossible transitions
430 // ═══════════════════════════════════════════════════════════════════
431
432 function setState(newStatus) {
433 state.status = newStatus
434 updateUI()
435 }
436
437 function resetToIdle() {
438 clearTimeout(recTimeout)
439 clearTimeout(procTimeout)
440 if (recognition) { try { recognition.abort() } catch {} recognition = null }
441 state.transcript = ''
442 setState('idle')
443 }
185 // ═══════════════════════════════════════════════════════════════
186 // STREAMING SPEECH RECOGNITION
187 // Text appears word-by-word as user speaks — like WhisperTyping
188 // ═══════════════════════════════════════════════════════════════
444189
445 // ═══════════════════════════════════════════════════════════════════
446 // SPEECH RECOGNITION (Web Speech API)
447 // ═══════════════════════════════════════════════════════════════════
190 function startStreaming() {
191 if (state.status !== 'idle') return
448192
449 function startWebSpeech() {
450193 const SR = window.SpeechRecognition || window.webkitSpeechRecognition
451194 if (!SR) {
452 console.warn('[48co] Speech API not supported — use Chrome/Edge')
453 // Show error via badge
454 try { chrome.runtime.sendMessage({ type: 'UPDATE_BADGE', status: 'error' }) } catch {}
455 setTimeout(() => resetToIdle(), 2000)
195 console.warn('[48co] Speech API not available — use Chrome or Edge')
196 return
197 }
198
199 // Remember which text field we're typing into
200 const targetEl = findTextInput()
201 if (!targetEl) {
202 console.warn('[48co] No text field found — click on a text box first')
456203 return
457204 }
205 targetEl.focus()
206
207 // Track what's already in the field so we don't overwrite it
208 const existingText = targetEl.contentEditable === 'true'
209 ? (targetEl.innerText || '')
210 : (targetEl.value || '')
458211
459212 recognition = new SR()
460213 recognition.continuous = true
461 recognition.interimResults = true
214 recognition.interimResults = true // KEY: this enables real-time streaming
462215 recognition.lang = state.language || 'en'
463216
217 lastInsertedText = ''
218 let finalText = ''
219
464220 recognition.onstart = () => {
465 setState('recording')
221 state.status = 'recording'
222 updateBadge('recording')
466223 }
467224
468225 recognition.onresult = (e) => {
226 // Build the full transcript from all results
469227 let full = ''
470228 for (let i = 0; i < e.results.length; i++) {
471229 full += e.results[i][0].transcript
472230 }
473 state.transcript = full
231
232 // Calculate what's NEW since last update
233 if (full.length > lastInsertedText.length) {
234 const newChars = full.substring(lastInsertedText.length)
235 insertAtCursor(newChars)
236 lastInsertedText = full
237 } else if (full !== lastInsertedText) {
238 // Speech API revised earlier text — need to replace
239 // This happens when interim results change
240 // For now, just update our tracker (don't rewrite — too disruptive)
241 lastInsertedText = full
242 }
243
244 finalText = full
474245 }
475246
476247 recognition.onend = () => {
477 // Guard: only process if we're still in recording state
478 if (state.status !== 'recording') return
248 state.status = 'idle'
479249 recognition = null
480 processTranscript(state.transcript)
250
251 // Apply post-processing to the final text
252 if (finalText) {
253 const processed = postProcess(finalText)
254 if (processed !== finalText) {
255 // Replace the raw transcript with polished version
256 const el = findTextInput()
257 if (el) {
258 const currentContent = el.contentEditable === 'true'
259 ? (el.innerText || '')
260 : (el.value || '')
261 // Only replace the part we dictated (after existing text)
262 const ourPart = currentContent.substring(existingText.length)
263 if (ourPart) {
264 const newContent = existingText + processed
265 replaceAllText(el, newContent)
266 }
267 }
268 }
269 }
270
271 lastInsertedText = ''
272 updateBadge('idle')
481273 }
482274
483275 recognition.onerror = (e) => {
484276 if (e.error === 'aborted') return
277 console.warn('[48co]', e.error)
278 state.status = 'idle'
485279 recognition = null
486
487 const errorMessages = {
488 'not-allowed': 'Mic denied — check browser permissions',
489 'no-speech': 'No speech detected',
490 'network': 'Network error',
491 'audio-capture': 'No mic found',
492 'service-not-allowed': 'Speech service blocked — use Chrome',
493 }
494 console.warn('[48co]', errorMessages[e.error] || 'Error: ' + e.error)
495
496 // Show error badge briefly
497 try { chrome.runtime.sendMessage({ type: 'UPDATE_BADGE', status: 'error' }) } catch {}
498 setTimeout(() => resetToIdle(), 2000)
280 lastInsertedText = ''
281 updateBadge('idle')
499282 }
500283
501284 try {
502285 recognition.start()
503286 } catch (err) {
504 console.warn('[48co] Failed to start recognition:', err)
287 console.warn('[48co] Failed to start:', err)
288 state.status = 'idle'
505289 recognition = null
506 resetToIdle()
507290 }
508291 }
509292
510 function stopWebSpeech() {
511 if (recognition) {
293 function stopStreaming() {
294 if (recognition && state.status === 'recording') {
512295 try { recognition.stop() } catch {}
513 // Don't null recognition here — onend handler needs it
514296 }
515297 }
516298
517 // ═══════════════════════════════════════════════════════════════════
518 // RECORDING CONTROL — separate timeouts for recording vs processing
519 // ═══════════════════════════════════════════════════════════════════
299 // ═══════════════════════════════════════════════════════════════
300 // POST-PROCESSING (cleanup after recording stops)
301 // ═══════════════════════════════════════════════════════════════
520302
521 let recTimeout = null // safety timeout for recording phase
522 let procTimeout = null // safety timeout for processing/Whisper phase
523
524 function startRecording() {
525 if (state.status !== 'idle') return
526
527 // Recording safety timeout — 60s max
528 clearTimeout(recTimeout)
529 recTimeout = setTimeout(() => {
530 if (state.status === 'recording') {
531 console.warn('[48co] Recording timed out (60s) — resetting')
532 resetToIdle()
533 }
534 }, 60000)
535
536 if (state.engine === 'whisper') {
537 chrome.runtime.sendMessage({ type: 'START_RECORDING' }, (response) => {
538 if (chrome.runtime.lastError || (response && response.error)) {
539 console.warn('[48co] Failed to start Whisper recording')
540 resetToIdle()
541 return
542 }
543 })
544 setState('recording')
545 } else {
546 startWebSpeech()
547 }
548 }
303 const PUNCTUATION_MAP = [
304 [/\b(full stop|period)\b/gi, '.'],
305 [/\bcomma\b/gi, ','],
306 [/\b(question mark)\b/gi, '?'],
307 [/\b(exclamation mark|exclamation point)\b/gi, '!'],
308 [/\bsemicolon\b/gi, ';'],
309 [/\bcolon\b/gi, ':'],
310 [/\bellipsis\b/gi, '...'],
311 [/\bdash\b/gi, ' — '],
312 [/\bhyphen\b/gi, '-'],
313 [/\b(new line|newline)\b/gi, '\n'],
314 [/\b(new paragraph)\b/gi, '\n\n'],
315 ]
549316
550 function stopRecording() {
551 if (state.status !== 'recording') return
552 clearTimeout(recTimeout)
317 function postProcess(text) {
318 let result = text
553319
554 if (state.engine === 'whisper') {
555 chrome.runtime.sendMessage({ type: 'STOP_RECORDING' })
556 setState('processing')
320 for (const [p, r] of PUNCTUATION_MAP) result = result.replace(p, r)
557321
558 // Whisper processing timeout — 30s (API can take time)
559 clearTimeout(procTimeout)
560 procTimeout = setTimeout(() => {
561 if (state.status === 'processing') {
562 console.warn('[48co] Whisper processing timed out (30s) — resetting')
563 resetToIdle()
322 // Custom vocabulary
323 if (state.vocabulary && state.vocabulary.length > 0) {
324 for (const { from, to } of state.vocabulary) {
325 if (from && to) {
326 const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
327 result = result.replace(new RegExp('\\b' + escaped + '\\b', 'gi'), to)
564328 }
565 }, 30000)
566 } else {
567 stopWebSpeech()
568 // onend handler will call processTranscript
569 }
570 }
571
572 // ═══════════════════════════════════════════════════════════════════
573 // TRANSCRIPT PROCESSING
574 // ═══════════════════════════════════════════════════════════════════
575
576 function processTranscript(text) {
577 clearTimeout(procTimeout)
578
579 if (!text || !text.trim()) {
580 // No speech detected — brief error then reset
581 try { chrome.runtime.sendMessage({ type: 'UPDATE_BADGE', status: 'error' }) } catch {}
582 setTimeout(() => resetToIdle(), 1500)
583 return
584 }
585
586 setState('processing')
587
588 // Voice commands
589 const cmd = matchCommand(text)
590
591 if (cmd && cmd.action === 'cancel') {
592 resetToIdle()
593 return
594 }
595
596 if (cmd && cmd.action === 'submit') {
597 triggerSend(adapter)
598 setState('done')
599 setTimeout(() => resetToIdle(), 500)
600 return
329 }
601330 }
602331
603 let output = (cmd && cmd.action === 'text') ? cmd.output : postProcess(text)
604
605 // Auto coding mode
606 if (state.codingMode || (state.autoCoding && isCodingContent(output))) {
607 output = wrapCode(output)
332 // Custom replacements
333 if (state.replacements && state.replacements.length > 0) {
334 for (const { from, to } of state.replacements) {
335 if (from && to) result = result.replaceAll(from, to)
336 }
608337 }
609338
610 // Insert into text input
611 const inserted = insertText(adapter, output)
339 // Cleanup
340 result = result.replace(/\s+([.,;:!?)\]}])/g, '$1')
341 result = result.replace(/([.,;:!?])([A-Za-z])/g, '$1 $2')
342 result = result.replace(/^(\s*)([a-z])/, (_, ws, ch) => ws + ch.toUpperCase())
343 result = result.replace(/([.!?]\s+)([a-z])/g, (_, p, ch) => p + ch.toUpperCase())
344 result = result.replace(/ {2,}/g, ' ')
612345
613 if (inserted) {
614 setState('done')
346 return result.trim()
347 }
615348
616 if (state.autoSubmit) {
617 triggerSend(adapter)
618 }
349 // ═══════════════════════════════════════════════════════════════
350 // BADGE (the ONLY UI — just the extension icon)
351 // ═══════════════════════════════════════════════════════════════
619352
620 setTimeout(() => resetToIdle(), 500)
621 } else {
622 // Fallback: copy to clipboard
623 if (navigator.clipboard && navigator.clipboard.writeText) {
624 navigator.clipboard.writeText(output).then(() => {
625 console.log('[48co] Copied to clipboard — no text field found')
626 setState('done')
627 setTimeout(() => resetToIdle(), 1500)
628 }).catch(() => {
629 console.warn('[48co] Failed to copy — click a text field first')
630 resetToIdle()
631 })
632 } else {
633 console.warn('[48co] No text field found — click one first')
634 resetToIdle()
635 }
636 }
353 function updateBadge(status) {
354 try { chrome.runtime.sendMessage({ type: 'UPDATE_BADGE', status }) } catch {}
637355 }
638356
639 // ═══════════════════════════════════════════════════════════════════
640 // EVENT HANDLERS
641 // ═══════════════════════════════════════════════════════════════════
357 // ═══════════════════════════════════════════════════════════════
358 // EVENT HANDLERS — mouse wheel click + keyboard shortcut
359 // ═══════════════════════════════════════════════════════════════
642360
643 // Middle-click: auxclick only fires on full press+release, never from scrolling
361 // Mouse wheel CLICK (not scroll) — auxclick only fires on press+release
644362 window.addEventListener('auxclick', (e) => {
645363 if (e.button !== 1) return
646364 e.preventDefault()
647365 e.stopPropagation()
648 if (state.status === 'idle') startRecording()
649 else if (state.status === 'recording') stopRecording()
366 if (state.status === 'idle') startStreaming()
367 else if (state.status === 'recording') stopStreaming()
650368 }, true)
369
370 // Prevent middle-click auto-scroll
651371 window.addEventListener('mousedown', (e) => { if (e.button === 1) e.preventDefault() })
652372
653 // Messages from background
373 // Messages from popup/background
654374 chrome.runtime.onMessage.addListener((msg) => {
655375 if (msg.type === 'TOGGLE_RECORDING') {
656 if (state.status === 'idle') startRecording()
657 else if (state.status === 'recording') stopRecording()
376 if (state.status === 'idle') startStreaming()
377 else if (state.status === 'recording') stopStreaming()
658378 }
659
660 if (msg.type === 'TRANSCRIPTION_READY') {
661 clearTimeout(procTimeout)
662 clearTimeout(recTimeout)
663
664 // If we're not in a state where we expect transcription, ignore it
665 if (state.status !== 'recording' && state.status !== 'processing') {
666 return
667 }
668
669 if (msg.error) {
670 console.warn('[48co]', msg.error)
671 try { chrome.runtime.sendMessage({ type: 'UPDATE_BADGE', status: 'error' }) } catch {}
672 setTimeout(() => resetToIdle(), 2000)
673 return
674 }
675
676 state.transcript = msg.text || ''
677 processTranscript(msg.text || '')
379 if (msg.type === 'FORCE_RESET') {
380 if (recognition) { try { recognition.abort() } catch {} recognition = null }
381 state.status = 'idle'
382 lastInsertedText = ''
383 updateBadge('idle')
678384 }
679
680385 if (msg.type === 'STATE_UPDATED') {
681386 Object.assign(state, msg.updates)
682387 }
683
684 if (msg.type === 'FORCE_RESET') {
685 resetToIdle()
686 console.log('[48co] Force reset — ready')
388 // Whisper engine: transcription comes back as complete text
389 if (msg.type === 'TRANSCRIPTION_READY') {
390 if (msg.error) { console.warn('[48co]', msg.error); updateBadge('idle'); return }
391 if (msg.text) {
392 const processed = postProcess(msg.text)
393 insertAtCursor(processed)
394 }
395 state.status = 'idle'
396 updateBadge('idle')
687397 }
688398 })
689399
690 // Push-to-talk: Hold Ctrl+Shift to record, release to stop
400 // Push-to-talk
691401 let pttActive = false
692
693402 window.addEventListener('keydown', (e) => {
694403 if (!state.pushToTalk) return
695404 if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && e.key === 'Shift' && !pttActive) {
696405 pttActive = true
697 startRecording()
406 startStreaming()
698407 e.preventDefault()
699408 }
700409 })
701
702410 window.addEventListener('keyup', (e) => {
703411 if (!state.pushToTalk) return
704412 if (pttActive && (e.key === 'Shift' || e.key === 'Control')) {
705413 pttActive = false
706 stopRecording()
414 stopStreaming()
707415 e.preventDefault()
708416 }
709417 })
710418
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts