AI Grammar Checker — viral distribution engine #4015
4 changed files+362−11
Addedextension/grammar.js+328−0View fileUnifiedSplit
@@ -0,0 +1,328 @@
1/**
2 * 48co Grammar Checker
3 * Watches text fields on any page. When user pauses typing, checks grammar
4 * via Claude API and shows inline corrections in a tooltip.
5 *
6 * Injected by content.js when grammar mode is enabled.
7 * Zero visible UI until a correction is found.
8 */
9;(function () {
10 'use strict'
11
12 // ── State ──────────────────────────────────────────────
13 let enabled = false
14 let claudeApiKey = ''
15 let checkTimeout = null
16 let lastCheckedText = ''
17 let activeTooltip = null
18 let correctionsToday = 0
19 let maxFreeCorrections = 10 // free tier limit per day
20
21 // ── Init: load settings ────────────────────────────────
22 chrome.storage.local.get(['grammarEnabled', 'claudeApiKey', 'correctionsToday', 'correctionDate'], (data) => {
23 enabled = data.grammarEnabled || false
24 claudeApiKey = data.claudeApiKey || ''
25 // Reset daily counter
26 const today = new Date().toDateString()
27 if (data.correctionDate !== today) {
28 correctionsToday = 0
29 chrome.storage.local.set({ correctionsToday: 0, correctionDate: today })
30 } else {
31 correctionsToday = data.correctionsToday || 0
32 }
33 if (enabled) attachListeners()
34 })
35
36 // Listen for settings changes
37 chrome.storage.onChanged.addListener((changes) => {
38 if (changes.grammarEnabled) {
39 enabled = changes.grammarEnabled.newValue
40 if (enabled) attachListeners()
41 else detachListeners()
42 }
43 if (changes.claudeApiKey) {
44 claudeApiKey = changes.claudeApiKey.newValue
45 }
46 })
47
48 // ── Attach to all text fields ──────────────────────────
49 let observing = false
50
51 function attachListeners() {
52 if (observing) return
53 observing = true
54 document.addEventListener('input', onInput, true)
55 document.addEventListener('focusout', onBlur, true)
56 }
57
58 function detachListeners() {
59 observing = false
60 document.removeEventListener('input', onInput, true)
61 document.removeEventListener('focusout', onBlur, true)
62 hideTooltip()
63 }
64
65 function onInput(e) {
66 if (!enabled) return
67 const el = e.target
68 if (!isTextField(el)) return
69
70 // Debounce: check 1.5s after user stops typing
71 clearTimeout(checkTimeout)
72 checkTimeout = setTimeout(() => checkGrammar(el), 1500)
73 }
74
75 function onBlur() {
76 clearTimeout(checkTimeout)
77 // Don't hide tooltip immediately — user might be clicking it
78 setTimeout(() => {
79 if (activeTooltip && !activeTooltip.matches(':hover')) {
80 hideTooltip()
81 }
82 }, 300)
83 }
84
85 function isTextField(el) {
86 if (!el) return false
87 if (el.tagName === 'TEXTAREA') return true
88 if (el.tagName === 'INPUT' && (el.type === 'text' || el.type === 'email' || el.type === 'search' || !el.type)) return true
89 if (el.contentEditable === 'true') return true
90 return false
91 }
92
93 // ── Grammar check via Claude API ───────────────────────
94 async function checkGrammar(el) {
95 if (!claudeApiKey) return
96 if (!enabled) return
97
98 const text = getTextFromField(el)
99 if (!text || text.length < 10) return // too short to check
100 if (text === lastCheckedText) return // already checked
101 if (text.length > 2000) return // too long for free check
102
103 lastCheckedText = text
104
105 try {
106 const corrections = await callGrammarAPI(text)
107 if (corrections && corrections.length > 0) {
108 showCorrections(el, text, corrections)
109 } else {
110 hideTooltip()
111 }
112 } catch (err) {
113 console.warn('[48co grammar]', err.message)
114 }
115 }
116
117 function getTextFromField(el) {
118 if (el.contentEditable === 'true') return el.innerText || el.textContent || ''
119 return el.value || ''
120 }
121
122 async function callGrammarAPI(text) {
123 const controller = new AbortController()
124 const timeout = setTimeout(() => controller.abort(), 6000)
125
126 try {
127 const response = await fetch('https://api.anthropic.com/v1/messages', {
128 method: 'POST',
129 headers: {
130 'Content-Type': 'application/json',
131 'x-api-key': claudeApiKey,
132 'anthropic-version': '2023-06-01',
133 },
134 body: JSON.stringify({
135 model: 'claude-haiku-4-5-20251001',
136 max_tokens: 512,
137 system: `You are a grammar checker. Analyze the text for grammar, spelling, and punctuation errors. Return a JSON array of corrections. Each correction has: "original" (the wrong text), "corrected" (the fixed text), "reason" (brief explanation, max 8 words). If the text is correct, return an empty array []. Return ONLY valid JSON, nothing else.`,
138 messages: [{ role: 'user', content: text }],
139 }),
140 signal: controller.signal,
141 })
142
143 clearTimeout(timeout)
144
145 if (!response.ok) {
146 if (response.status === 401) throw new Error('Invalid API key')
147 throw new Error(`API error: ${response.status}`)
148 }
149
150 const data = await response.json()
151 const content = data.content?.[0]?.text?.trim() || '[]'
152
153 // Parse JSON — handle markdown code fences if present
154 const jsonStr = content.replace(/^```json?\n?/, '').replace(/\n?```$/, '').trim()
155 return JSON.parse(jsonStr)
156 } catch (err) {
157 clearTimeout(timeout)
158 if (err.name === 'AbortError') return []
159 if (err instanceof SyntaxError) return [] // JSON parse failed
160 throw err
161 }
162 }
163
164 // ── Show corrections UI ────────────────────────────────
165 // Minimal tooltip above the text field showing what to fix
166
167 function showCorrections(el, originalText, corrections) {
168 hideTooltip()
169
170 if (correctionsToday >= maxFreeCorrections && !claudeApiKey) {
171 // Free tier exhausted
172 return
173 }
174
175 const tooltip = document.createElement('div')
176 tooltip.id = 'foureightco-grammar'
177 tooltip.style.cssText = `
178 position: fixed;
179 z-index: 2147483647;
180 max-width: 380px;
181 font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
182 font-size: 13px;
183 background: white;
184 border: 1px solid rgba(0,0,0,0.1);
185 border-radius: 12px;
186 box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
187 padding: 0;
188 overflow: hidden;
189 `
190
191 // Header
192 const header = document.createElement('div')
193 header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid rgba(0,0,0,0.06);background:#f8f9fa;'
194 header.innerHTML = `
195 <span style="font-weight:600;font-size:12px;color:#4f46e5;">48co Grammar</span>
196 <span style="font-size:11px;color:#888;">${corrections.length} correction${corrections.length > 1 ? 's' : ''}</span>
197 `
198 tooltip.appendChild(header)
199
200 // Corrections list
201 const list = document.createElement('div')
202 list.style.cssText = 'max-height:200px;overflow-y:auto;'
203
204 corrections.slice(0, 5).forEach((c) => {
205 const row = document.createElement('div')
206 row.style.cssText = 'padding:10px 14px;border-bottom:1px solid rgba(0,0,0,0.04);cursor:pointer;transition:background 0.15s;'
207 row.onmouseenter = () => { row.style.background = '#f0f0ff' }
208 row.onmouseleave = () => { row.style.background = 'transparent' }
209
210 row.innerHTML = `
211 <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
212 <span style="color:#dc2626;text-decoration:line-through;font-size:12px;">${escapeHtml(c.original)}</span>
213 <span style="color:#888;font-size:10px;">→</span>
214 <span style="color:#16a34a;font-weight:500;font-size:12px;">${escapeHtml(c.corrected)}</span>
215 </div>
216 <div style="font-size:11px;color:#888;">${escapeHtml(c.reason)}</div>
217 `
218
219 // Click to apply correction
220 row.addEventListener('click', () => {
221 applyCorrection(el, c.original, c.corrected)
222 correctionsToday++
223 chrome.storage.local.set({ correctionsToday })
224 row.style.background = '#f0fff4'
225 row.innerHTML = '<div style="font-size:12px;color:#16a34a;text-align:center;padding:4px 0;">Applied!</div>'
226 setTimeout(() => {
227 row.remove()
228 if (list.children.length === 0) hideTooltip()
229 }, 600)
230 })
231
232 list.appendChild(row)
233 })
234
235 tooltip.appendChild(list)
236
237 // "Fix All" button
238 if (corrections.length > 1) {
239 const fixAll = document.createElement('div')
240 fixAll.style.cssText = 'padding:10px 14px;text-align:center;border-top:1px solid rgba(0,0,0,0.06);cursor:pointer;transition:background 0.15s;'
241 fixAll.innerHTML = '<span style="font-size:12px;font-weight:500;color:#4f46e5;">Fix all</span>'
242 fixAll.onmouseenter = () => { fixAll.style.background = '#f0f0ff' }
243 fixAll.onmouseleave = () => { fixAll.style.background = 'transparent' }
244 fixAll.addEventListener('click', () => {
245 corrections.forEach((c) => applyCorrection(el, c.original, c.corrected))
246 correctionsToday += corrections.length
247 chrome.storage.local.set({ correctionsToday })
248 hideTooltip()
249 })
250 tooltip.appendChild(fixAll)
251 }
252
253 document.body.appendChild(tooltip)
254 activeTooltip = tooltip
255
256 // Position above the text field
257 const rect = el.getBoundingClientRect()
258 const tooltipRect = tooltip.getBoundingClientRect()
259 let top = rect.top - tooltipRect.height - 8
260 let left = rect.left
261
262 // If above viewport, show below
263 if (top < 8) top = rect.bottom + 8
264 // Keep within viewport
265 if (left + tooltipRect.width > window.innerWidth - 8) left = window.innerWidth - tooltipRect.width - 8
266 if (left < 8) left = 8
267
268 tooltip.style.top = top + 'px'
269 tooltip.style.left = left + 'px'
270 }
271
272 function hideTooltip() {
273 if (activeTooltip) {
274 activeTooltip.remove()
275 activeTooltip = null
276 }
277 }
278
279 function applyCorrection(el, original, corrected) {
280 if (el.contentEditable === 'true') {
281 const html = el.innerHTML
282 // Replace first occurrence
283 const idx = el.innerText.indexOf(original)
284 if (idx !== -1) {
285 // Use execCommand for undo support
286 const sel = window.getSelection()
287 const range = document.createRange()
288
289 // Find text node containing the original
290 const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
291 let node, offset = 0
292 while ((node = walker.nextNode())) {
293 if (offset + node.length > idx) {
294 range.setStart(node, idx - offset)
295 range.setEnd(node, Math.min(idx - offset + original.length, node.length))
296 sel.removeAllRanges()
297 sel.addRange(range)
298 document.execCommand('insertText', false, corrected)
299 break
300 }
301 offset += node.length
302 }
303 }
304 } else if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
305 const val = el.value
306 const idx = val.indexOf(original)
307 if (idx !== -1) {
308 el.focus()
309 el.setSelectionRange(idx, idx + original.length)
310 document.execCommand('insertText', false, corrected)
311 el.dispatchEvent(new Event('input', { bubbles: true }))
312 }
313 }
314 }
315
316 function escapeHtml(str) {
317 const div = document.createElement('div')
318 div.textContent = str
319 return div.innerHTML
320 }
321
322 // ── Public API for popup ───────────────────────────────
323 window._48coGrammar = {
324 get enabled() { return enabled },
325 get correctionsToday() { return correctionsToday },
326 }
327
328})()
Modifiedextension/manifest.json+4−9View fileUnifiedSplit
@@ -1,8 +1,8 @@
11{
22 "manifest_version": 3,
3 "name": "48co — AI Voice Assistant",
4 "version": "1.0.0",
5 "description": "Voice-to-text for any website. 50+ languages, push-to-talk, voice punctuation, custom vocabulary, auto code fences. Free WhisperTyping alternative.",
3 "name": "48co — AI Grammar & Voice",
4 "version": "1.1.0",
5 "description": "AI grammar checker + voice-to-text for any website. Fixes grammar, spelling, and tone in real-time. Powered by Claude AI. Free tier available.",
66 "permissions": [
77 "offscreen",
88 "storage",
@@ -10,11 +10,6 @@
1010 "commands"
1111 ],
1212 "host_permissions": [
13 "https://claude.ai/*",
14 "https://chat.openai.com/*",
15 "https://chatgpt.com/*",
16 "https://gemini.google.com/*",
17 "https://chat.deepseek.com/*",
1813 "https://*/*",
1914 "http://*/*"
2015 ],
@@ -27,7 +22,7 @@
2722 "https://*/*",
2823 "http://*/*"
2924 ],
30 "js": ["content.js"],
25 "js": ["content.js", "grammar.js"],
3126 "run_at": "document_idle"
3227 }
3328 ],
Modifiedextension/popup.html+20−2View fileUnifiedSplit
@@ -229,7 +229,18 @@
229229
230230 <div class="header">
231231 <span class="logo">≡ 48CO</span>
232 <span class="version">v1.0.0</span>
232 <span class="version">v1.1.0</span>
233 </div>
234
235 <!-- Grammar Mode -->
236 <div class="section" style="background:rgba(79,70,229,0.06);border-bottom:1px solid rgba(255,255,255,0.06)">
237 <div class="row">
238 <span class="label" style="color:rgba(255,255,255,0.7)">AI Grammar Check</span>
239 <button class="toggle" id="grammar-toggle"><span class="knob"></span></button>
240 </div>
241 <div style="font-size:9px;color:rgba(255,255,255,0.2);margin-top:4px">
242 Checks grammar in real-time on any text field. Requires Claude API key.
243 </div>
233244 </div>
234245
235246 <!-- Mic toggle -->
@@ -264,9 +275,16 @@
264275 </select>
265276 </div>
266277 <div class="row" id="api-key-row" style="display:none">
267 <span class="label">API Key</span>
278 <span class="label">Whisper Key</span>
268279 <input type="password" id="api-key" placeholder="sk-..." />
269280 </div>
281 <div class="row">
282 <span class="label">Claude Key</span>
283 <input type="password" id="claude-key" placeholder="sk-ant-..." />
284 </div>
285 <div style="font-size:9px;color:rgba(255,255,255,0.15);padding:0 16px 8px;margin-top:-4px">
286 Powers grammar check + AI rewrite. Get key at console.anthropic.com
287 </div>
270288 <div class="row">
271289 <span class="label">Language</span>
272290 <select id="language">
Modifiedextension/popup.js+10−0View fileUnifiedSplit
@@ -70,6 +70,15 @@ apiKeyInput.addEventListener('change', () => {
7070 chrome.storage.local.set({ whisperApiKey: apiKeyInput.value })
7171})
7272
73// Claude API key (for grammar + AI rewrite)
74const claudeKeyInput = $('#claude-key')
75chrome.storage.local.get('claudeApiKey', (data) => {
76 if (data.claudeApiKey) claudeKeyInput.value = data.claudeApiKey
77})
78claudeKeyInput.addEventListener('change', () => {
79 chrome.storage.local.set({ claudeApiKey: claudeKeyInput.value })
80})
81
7382// ── Language selector ─────────────────────────────────────────────
7483const langSelect = $('#language')
7584chrome.storage.local.get('language', (data) => {
@@ -93,6 +102,7 @@ typeSpeedSlider.addEventListener('input', () => {
93102})
94103
95104// ── Setup toggles ──────────────────────────────────────────────────
105setupToggle($('#grammar-toggle'), 'grammarEnabled')
96106setupToggle($('#noise-toggle'), 'noiseSuppression')
97107setupToggle($('#auto-coding-toggle'), 'autoCoding')
98108setupToggle($('#coding-mode-toggle'), 'codingMode')
99109
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts