Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Text Readability & Tone Analyzer</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| background: #0f0f0f; | |
| color: #e0e0e0; | |
| min-height: 100vh; | |
| padding: 32px 16px; | |
| } | |
| .container { max-width: 820px; margin: 0 auto; } | |
| h1 { | |
| font-size: 1.8rem; | |
| font-weight: 700; | |
| color: #fff; | |
| margin-bottom: 6px; | |
| } | |
| .subtitle { color: #888; font-size: 0.95rem; margin-bottom: 32px; } | |
| .card { | |
| background: #1a1a1a; | |
| border: 1px solid #2a2a2a; | |
| border-radius: 12px; | |
| padding: 24px; | |
| margin-bottom: 20px; | |
| } | |
| label { | |
| display: block; | |
| font-size: 0.85rem; | |
| color: #aaa; | |
| margin-bottom: 8px; | |
| font-weight: 500; | |
| } | |
| input, textarea { | |
| width: 100%; | |
| background: #111; | |
| border: 1px solid #333; | |
| border-radius: 8px; | |
| color: #e0e0e0; | |
| font-size: 0.95rem; | |
| padding: 12px 14px; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| font-family: inherit; | |
| } | |
| input:focus, textarea:focus { border-color: #2eb87a; } | |
| textarea { resize: vertical; min-height: 180px; line-height: 1.6; } | |
| .token-hint { font-size: 0.78rem; color: #666; margin-top: 6px; } | |
| .token-hint a { color: #2eb87a; text-decoration: none; } | |
| .token-hint a:hover { text-decoration: underline; } | |
| button { | |
| background: #2eb87a; | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 12px 28px; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| width: 100%; | |
| margin-top: 8px; | |
| } | |
| button:hover { background: #26a06a; } | |
| button:disabled { background: #444; cursor: not-allowed; } | |
| .status { | |
| padding: 12px 16px; | |
| border-radius: 8px; | |
| font-size: 0.9rem; | |
| margin-top: 12px; | |
| display: none; | |
| } | |
| .status.info { background: #0e2018; color: #6ee4a8; border: 1px solid #1a4a30; display: block; } | |
| .status.error { background: #2a1a1a; color: #f57a7a; border: 1px solid #6a2a2a; display: block; } | |
| .status.success { background: #0e2018; color: #6ee4a8; border: 1px solid #2a6a48; display: block; } | |
| .results { display: none; } | |
| .results.visible { display: block; } | |
| /* section title */ | |
| .section-title { | |
| font-size: 0.78rem; | |
| color: #888; | |
| text-transform: uppercase; | |
| letter-spacing: 0.07em; | |
| font-weight: 600; | |
| margin-bottom: 14px; | |
| } | |
| /* ββ Summary card ββ */ | |
| #summaryCard { margin-bottom: 20px; } | |
| #summaryOut { font-size: 0.97rem; line-height: 1.7; color: #ddd; } | |
| /* ββ Tone card ββ */ | |
| #toneCard { margin-bottom: 20px; } | |
| .tone-chips { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; } | |
| .tone-chip { | |
| background: #222; | |
| border: 1px solid #333; | |
| border-radius: 20px; | |
| padding: 5px 14px; | |
| font-size: 0.85rem; | |
| color: #bbb; | |
| position: relative; | |
| overflow: hidden; | |
| min-width: 100px; | |
| text-align: center; | |
| } | |
| .tone-chip .chip-fill { | |
| position: absolute; | |
| left: 0; top: 0; bottom: 0; | |
| background: #2eb87a22; | |
| border-radius: 20px; | |
| transition: width 0.6s ease; | |
| } | |
| .tone-chip.top-tone { | |
| border-color: #2eb87a; | |
| color: #6ee4a8; | |
| font-weight: 600; | |
| } | |
| .tone-chip .chip-label { position: relative; z-index: 1; } | |
| .tone-chip .chip-pct { font-size: 0.72rem; color: #888; margin-left: 4px; position: relative; z-index: 1; } | |
| .tone-chip.top-tone .chip-pct { color: #4cad88; } | |
| /* ββ Readability card ββ */ | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); | |
| gap: 12px; | |
| margin-bottom: 20px; | |
| } | |
| .stat-box { | |
| background: #111; | |
| border: 1px solid #2a2a2a; | |
| border-radius: 8px; | |
| padding: 14px 16px; | |
| } | |
| .stat-box .stat-val { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| color: #fff; | |
| line-height: 1; | |
| margin-bottom: 4px; | |
| } | |
| .stat-box .stat-lbl { font-size: 0.78rem; color: #777; } | |
| /* Flesch meter */ | |
| .meter-wrap { margin-top: 4px; } | |
| .meter-label-row { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.82rem; | |
| color: #888; | |
| margin-bottom: 6px; | |
| } | |
| .meter-label-row b { color: #e0e0e0; } | |
| .meter-track { | |
| height: 10px; | |
| border-radius: 5px; | |
| background: #222; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .meter-fill { | |
| height: 100%; | |
| border-radius: 5px; | |
| transition: width 0.7s ease, background 0.7s ease; | |
| } | |
| .meter-level { | |
| margin-top: 8px; | |
| font-size: 0.88rem; | |
| font-weight: 600; | |
| } | |
| /* ββ Suggestions card ββ */ | |
| .suggestion-list { list-style: none; } | |
| .suggestion-list li { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 10px; | |
| padding: 10px 0; | |
| border-bottom: 1px solid #222; | |
| font-size: 0.92rem; | |
| color: #ccc; | |
| line-height: 1.5; | |
| } | |
| .suggestion-list li:last-child { border-bottom: none; } | |
| .suggestion-list .badge { | |
| flex-shrink: 0; | |
| font-size: 0.72rem; | |
| font-weight: 700; | |
| padding: 2px 8px; | |
| border-radius: 10px; | |
| margin-top: 2px; | |
| } | |
| .badge.warn { background: #3a2800; color: #f0a832; border: 1px solid #6a4800; } | |
| .badge.good { background: #0e2018; color: #6ee4a8; border: 1px solid #2a5a38; } | |
| .badge.count { background: #1e1e2e; color: #9a9aff; border: 1px solid #3a3a6a; } | |
| .spinner { | |
| display: inline-block; | |
| width: 14px; height: 14px; | |
| border: 2px solid #555; | |
| border-top-color: #6ee4a8; | |
| border-radius: 50%; | |
| animation: spin 0.7s linear infinite; | |
| margin-right: 8px; | |
| vertical-align: middle; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| @media (max-width: 600px) { | |
| h1 { font-size: 1.4rem; } | |
| .stats-grid { grid-template-columns: 1fr 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>π Text Readability & Tone Analyzer</h1> | |
| <p class="subtitle">Paste any text β get a summary, tone classification, readability score, and writing suggestions.</p> | |
| <div class="card"> | |
| <label>HF API Token</label> | |
| <input type="password" id="hfToken" placeholder="hf_..." autocomplete="off" /> | |
| <p class="token-hint"> | |
| Free token at <a href="https://huggingface.co/settings/tokens" target="_blank">huggingface.co/settings/tokens</a> | |
| β Read access is enough. Used for summarization and tone classification. | |
| </p> | |
| </div> | |
| <div class="card"> | |
| <label>Your Text</label> | |
| <textarea id="textInput" placeholder="Paste a blog post, essay, email, report β anything. Minimum 20 words. Tip: longer texts produce more accurate results."></textarea> | |
| <button id="analyzeBtn" onclick="analyze()">Analyze Text</button> | |
| <div class="status" id="status"></div> | |
| </div> | |
| <!-- RESULTS --> | |
| <div class="results" id="results"> | |
| <!-- Summary --> | |
| <div class="card" id="summaryCard"> | |
| <div class="section-title">Summary</div> | |
| <p id="summaryOut">β</p> | |
| </div> | |
| <!-- Tone --> | |
| <div class="card" id="toneCard"> | |
| <div class="section-title">Tone Classification</div> | |
| <div class="tone-chips" id="toneChips"></div> | |
| </div> | |
| <!-- Readability stats --> | |
| <div class="card"> | |
| <div class="section-title">Readability Statistics</div> | |
| <div class="stats-grid" id="statsGrid"> | |
| <div class="stat-box"><div class="stat-val" id="statWords">β</div><div class="stat-lbl">Words</div></div> | |
| <div class="stat-box"><div class="stat-val" id="statSentences">β</div><div class="stat-lbl">Sentences</div></div> | |
| <div class="stat-box"><div class="stat-val" id="statAvgWPS">β</div><div class="stat-lbl">Avg Words / Sentence</div></div> | |
| <div class="stat-box"><div class="stat-val" id="statSyllables">β</div><div class="stat-lbl">Syllables</div></div> | |
| </div> | |
| <!-- Flesch meter --> | |
| <div class="meter-wrap"> | |
| <div class="meter-label-row"> | |
| <span>Flesch Reading Ease</span> | |
| <span><b id="fleschScore">β</b> / 100</span> | |
| </div> | |
| <div class="meter-track"> | |
| <div class="meter-fill" id="meterFill" style="width:0%"></div> | |
| </div> | |
| <div class="meter-level" id="meterLevel"></div> | |
| </div> | |
| </div> | |
| <!-- Writing suggestions --> | |
| <div class="card"> | |
| <div class="section-title">Writing Suggestions</div> | |
| <ul class="suggestion-list" id="suggestionList"></ul> | |
| </div> | |
| </div><!-- /results --> | |
| </div> | |
| <script> | |
| /* βββββββββββββββββββββββββββββ helpers βββββββββββββββββββββββββββββ */ | |
| function setStatus(msg, type) { | |
| const el = document.getElementById('status'); | |
| el.innerHTML = msg; | |
| el.className = 'status ' + type; | |
| } | |
| async function hfInference(token, model, inputs, params = {}) { | |
| const res = await fetch(`https://api-inference.huggingface.co/models/${model}`, { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ inputs, parameters: params }) | |
| }); | |
| if (!res.ok) { | |
| const err = await res.text(); | |
| throw new Error(`HF API error ${res.status}: ${err.slice(0, 200)}`); | |
| } | |
| return res.json(); | |
| } | |
| function chunkText(text, wordLimit = 800) { | |
| const words = text.split(/\s+/); | |
| const chunks = []; | |
| for (let i = 0; i < words.length; i += wordLimit) { | |
| chunks.push(words.slice(i, i + wordLimit).join(' ')); | |
| } | |
| return chunks; | |
| } | |
| /* βββββββββββββββββββββββββββββ readability (pure JS) βββββββββββββββββββββββββββββ */ | |
| function countSyllables(word) { | |
| word = word.toLowerCase().replace(/[^a-z]/g, ''); | |
| if (!word) return 0; | |
| if (word.length <= 3) return 1; | |
| // remove trailing silent e | |
| word = word.replace(/e$/, ''); | |
| const matches = word.match(/[aeiouy]{1,2}/g); | |
| return matches ? Math.max(1, matches.length) : 1; | |
| } | |
| function computeReadability(text) { | |
| // sentences: split on . ! ? | |
| const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length > 0); | |
| const sentenceCount = Math.max(sentences.length, 1); | |
| // words | |
| const words = text.match(/\b[a-zA-Z']+\b/g) || []; | |
| const wordCount = Math.max(words.length, 1); | |
| // syllables | |
| let syllableCount = 0; | |
| for (const w of words) syllableCount += countSyllables(w); | |
| const avgWPS = wordCount / sentenceCount; | |
| const avgSPW = syllableCount / wordCount; | |
| // Flesch Reading Ease = 206.835 - 1.015*(words/sentences) - 84.6*(syllables/words) | |
| let flesch = 206.835 - 1.015 * avgWPS - 84.6 * avgSPW; | |
| flesch = Math.min(100, Math.max(0, Math.round(flesch * 10) / 10)); | |
| return { wordCount, sentenceCount, syllableCount, avgWPS: Math.round(avgWPS * 10) / 10, flesch }; | |
| } | |
| function fleschLabel(score) { | |
| if (score >= 90) return { label: 'Very Easy', color: '#4caf50' }; | |
| if (score >= 70) return { label: 'Easy', color: '#8bc34a' }; | |
| if (score >= 50) return { label: 'Medium', color: '#ffc107' }; | |
| if (score >= 30) return { label: 'Difficult', color: '#ff9800' }; | |
| return { label: 'Very Difficult', color: '#f44336' }; | |
| } | |
| /* βββββββββββββββββββββββββββββ writing suggestions (pure JS) βββββββββββββββββββββββββββββ */ | |
| function buildSuggestions(text, stats) { | |
| const suggestions = []; | |
| const words = text.match(/\b[a-zA-Z']+\b/g) || []; | |
| const wordCount = words.length; | |
| // 1. Passive voice: was/were/been + past participle (simple heuristic) | |
| const passivePattern = /\b(was|were|been|is|are|am|being)\s+\w+ed\b/gi; | |
| const passiveMatches = text.match(passivePattern) || []; | |
| const passiveCount = passiveMatches.length; | |
| if (passiveCount === 0) { | |
| suggestions.push({ badge: 'good', text: 'No passive voice patterns detected β active voice throughout.' }); | |
| } else { | |
| suggestions.push({ | |
| badge: 'warn', | |
| text: `Passive voice: ${passiveCount} instance${passiveCount > 1 ? 's' : ''} detected (e.g. "was done", "were written"). Consider rewriting in active voice for clarity.` | |
| }); | |
| } | |
| // 2. Long sentences (> 30 words) | |
| const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length > 0); | |
| const longSentences = sentences.filter(s => s.split(/\s+/).length > 30); | |
| const longCount = longSentences.length; | |
| if (longCount === 0) { | |
| suggestions.push({ badge: 'good', text: 'No overly long sentences (>30 words) found β great sentence variety.' }); | |
| } else { | |
| suggestions.push({ | |
| badge: 'warn', | |
| text: `Long sentences: ${longCount} sentence${longCount > 1 ? 's' : ''} exceed 30 words. Breaking them up can improve readability.` | |
| }); | |
| } | |
| // 3. Filler words | |
| const fillers = ['very', 'really', 'just', 'quite', 'basically', 'literally', 'actually']; | |
| let fillerCount = 0; | |
| const foundFillers = []; | |
| for (const filler of fillers) { | |
| const re = new RegExp(`\\b${filler}\\b`, 'gi'); | |
| const hits = (text.match(re) || []).length; | |
| if (hits > 0) { fillerCount += hits; foundFillers.push(`"${filler}" Γ${hits}`); } | |
| } | |
| if (fillerCount === 0) { | |
| suggestions.push({ badge: 'good', text: 'No filler words detected β concise and direct writing.' }); | |
| } else { | |
| suggestions.push({ | |
| badge: 'count', | |
| text: `Filler words: ${fillerCount} found β ${foundFillers.join(', ')}. Removing them tightens your prose.` | |
| }); | |
| } | |
| // 4. Reading level note | |
| const { flesch } = stats; | |
| const lvl = fleschLabel(flesch); | |
| if (flesch >= 70) { | |
| suggestions.push({ badge: 'good', text: `Reading level: ${lvl.label} (Flesch ${flesch}). Your text is accessible to a broad audience.` }); | |
| } else if (flesch >= 50) { | |
| suggestions.push({ badge: 'count', text: `Reading level: ${lvl.label} (Flesch ${flesch}). Suitable for high-school/college readers; simplify where possible if targeting a general audience.` }); | |
| } else { | |
| suggestions.push({ badge: 'warn', text: `Reading level: ${lvl.label} (Flesch ${flesch}). Text is complex β consider shorter sentences and simpler vocabulary.` }); | |
| } | |
| return suggestions; | |
| } | |
| /* βββββββββββββββββββββββββββββ API calls βββββββββββββββββββββββββββββ */ | |
| async function summarize(token, text) { | |
| const chunks = chunkText(text, 800).slice(0, 3); | |
| const summaries = []; | |
| for (const chunk of chunks) { | |
| const res = await hfInference(token, 'facebook/bart-large-cnn', chunk, { | |
| max_length: 160, min_length: 40, do_sample: false | |
| }); | |
| if (res[0]?.summary_text) summaries.push(res[0].summary_text); | |
| } | |
| return summaries.join(' ') || 'Could not generate summary.'; | |
| } | |
| async function classifyTone(token, text) { | |
| const labels = ['formal', 'casual', 'persuasive', 'informative', 'emotional', 'humorous']; | |
| // use first 1000 chars for zero-shot (model limit) | |
| const snippet = text.slice(0, 1000); | |
| const res = await hfInference(token, 'facebook/bart-large-mnli', snippet, { | |
| candidate_labels: labels, | |
| multi_label: false | |
| }); | |
| // res has .labels and .scores arrays | |
| if (!res.labels) throw new Error('Unexpected tone API response.'); | |
| return res.labels.map((l, i) => ({ label: l, score: res.scores[i] })); | |
| } | |
| /* βββββββββββββββββββββββββββββ render helpers βββββββββββββββββββββββββββββ */ | |
| function renderStats(stats) { | |
| document.getElementById('statWords').textContent = stats.wordCount.toLocaleString(); | |
| document.getElementById('statSentences').textContent = stats.sentenceCount.toLocaleString(); | |
| document.getElementById('statAvgWPS').textContent = stats.avgWPS; | |
| document.getElementById('statSyllables').textContent = stats.syllableCount.toLocaleString(); | |
| const lvl = fleschLabel(stats.flesch); | |
| document.getElementById('fleschScore').textContent = stats.flesch; | |
| const fill = document.getElementById('meterFill'); | |
| fill.style.width = stats.flesch + '%'; | |
| fill.style.background = lvl.color; | |
| const levelEl = document.getElementById('meterLevel'); | |
| levelEl.textContent = lvl.label; | |
| levelEl.style.color = lvl.color; | |
| } | |
| function renderTone(toneData) { | |
| const container = document.getElementById('toneChips'); | |
| container.innerHTML = ''; | |
| const sorted = [...toneData].sort((a, b) => b.score - a.score); | |
| const topLabel = sorted[0].label; | |
| for (const item of sorted) { | |
| const pct = Math.round(item.score * 100); | |
| const div = document.createElement('div'); | |
| div.className = 'tone-chip' + (item.label === topLabel ? ' top-tone' : ''); | |
| div.innerHTML = ` | |
| <div class="chip-fill" style="width:${pct}%"></div> | |
| <span class="chip-label">${item.label}</span> | |
| <span class="chip-pct">${pct}%</span> | |
| `; | |
| container.appendChild(div); | |
| } | |
| } | |
| function renderSuggestions(suggestions) { | |
| const ul = document.getElementById('suggestionList'); | |
| ul.innerHTML = ''; | |
| for (const s of suggestions) { | |
| const li = document.createElement('li'); | |
| li.innerHTML = `<span class="badge ${s.badge}">${s.badge.toUpperCase()}</span><span>${s.text}</span>`; | |
| ul.appendChild(li); | |
| } | |
| } | |
| /* βββββββββββββββββββββββββββββ main entry βββββββββββββββββββββββββββββ */ | |
| async function analyze() { | |
| const token = document.getElementById('hfToken').value.trim(); | |
| const text = document.getElementById('textInput').value.trim(); | |
| if (!token) { setStatus('Please enter your HF API token.', 'error'); return; } | |
| if (!text) { setStatus('Please paste some text to analyze.', 'error'); return; } | |
| const wordCount = (text.match(/\b\w+\b/g) || []).length; | |
| if (wordCount < 20) { setStatus('Text is too short β please provide at least 20 words.', 'error'); return; } | |
| const btn = document.getElementById('analyzeBtn'); | |
| btn.disabled = true; | |
| document.getElementById('results').classList.remove('visible'); | |
| try { | |
| // 1. Readability (instant, no API) | |
| setStatus('<span class="spinner"></span>Computing readability stats...', 'info'); | |
| const stats = computeReadability(text); | |
| renderStats(stats); | |
| // 2. Summary (only if > 100 words) | |
| if (wordCount > 100) { | |
| setStatus('<span class="spinner"></span>Generating summary (BART)...', 'info'); | |
| const summary = await summarize(token, text); | |
| document.getElementById('summaryOut').textContent = summary; | |
| document.getElementById('summaryCard').style.display = ''; | |
| } else { | |
| document.getElementById('summaryOut').textContent = 'Text is under 100 words β summary skipped.'; | |
| document.getElementById('summaryCard').style.display = ''; | |
| } | |
| // 3. Tone classification | |
| setStatus('<span class="spinner"></span>Classifying tone (zero-shot)...', 'info'); | |
| const toneData = await classifyTone(token, text); | |
| renderTone(toneData); | |
| // 4. Suggestions | |
| const suggestions = buildSuggestions(text, stats); | |
| renderSuggestions(suggestions); | |
| document.getElementById('results').classList.add('visible'); | |
| setStatus('Analysis complete.', 'success'); | |
| } catch (e) { | |
| setStatus('Error: ' + e.message, 'error'); | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| } | |
| // Allow Ctrl+Enter to submit | |
| document.addEventListener('DOMContentLoaded', () => { | |
| document.getElementById('textInput').addEventListener('keydown', e => { | |
| if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) analyze(); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |