Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Sesotho Sentiment Analysis · Wale Lab</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --green-deep:#1E6B45;--green:#2F9463;--green-tint:#E5F5EC; | |
| --orange:#E8871E;--orange-deep:#C1690F;--orange-tint:#FCEEDC; | |
| --rust:#B3401A;--rust-tint:#FBE7DD;--tan:#B08331;--tan-tint:#F7EEDC; | |
| --white:#FFFFFF;--paper:#FBFAF7;--ink:#16231C;--ink-soft:#5B6B62; | |
| --line:#DEE5DF;--radius:10px; | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| html,body{background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;-webkit-font-smoothing:antialiased} | |
| body{min-height:100vh} | |
| .hero{background:var(--green-deep);color:var(--white);position:relative;overflow:hidden;padding:2.75rem 1.5rem 2.25rem;} | |
| .hero::before{content:"";position:absolute;inset:0;background:radial-gradient(circle at 85% -10%,rgba(232,135,30,0.18),transparent 55%),radial-gradient(circle at -5% 110%,rgba(31,92,63,0.55),transparent 50%);pointer-events:none;} | |
| .hero-inner{max-width:640px;margin:0 auto;position:relative} | |
| .eyebrow{display:inline-flex;align-items:center;gap:8px;font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--orange);margin-bottom:14px;} | |
| .eyebrow::before{content:"";width:6px;height:6px;background:var(--orange);border-radius:1px;transform:rotate(45deg);flex-shrink:0} | |
| .hero h1{font-family:'Fraunces',serif;font-weight:600;font-size:clamp(28px,5vw,36px);line-height:1.15;letter-spacing:-0.01em;margin-bottom:10px;} | |
| .hero p{font-size:14px;line-height:1.65;color:rgba(255,255,255,0.72);max-width:46ch;} | |
| .blanket-strip{height:14px;width:100%;background:repeating-linear-gradient(135deg,var(--orange) 0px,var(--orange) 10px,transparent 10px,transparent 20px),repeating-linear-gradient(45deg,var(--green) 0px,var(--green) 10px,var(--green-deep) 10px,var(--green-deep) 20px);} | |
| .app{padding:2rem 1.5rem 3rem;max-width:640px;margin:0 auto} | |
| .card{background:var(--white);border:1px solid var(--line);border-radius:var(--radius);padding:1.5rem;margin-bottom:1rem;box-shadow:0 1px 2px rgba(21,61,43,0.04);} | |
| .label{font-family:'JetBrains Mono',monospace;font-size:10.5px;font-weight:500;text-transform:uppercase;letter-spacing:0.09em;color:var(--ink-soft);margin-bottom:9px;} | |
| textarea{width:100%;min-height:96px;resize:vertical;font-size:15px;font-family:'Inter',sans-serif;padding:12px 14px;border-radius:8px;border:1.5px solid var(--line);background:var(--paper);color:var(--ink);line-height:1.6;} | |
| textarea:focus{outline:none;border-color:var(--green)} | |
| textarea::placeholder{color:#9AA69E} | |
| .toggle-row{display:flex;align-items:center;gap:12px;margin-top:14px} | |
| .toggle-label{font-size:13px;color:var(--ink-soft)} | |
| .toggle{position:relative;width:40px;height:23px;cursor:pointer;flex-shrink:0} | |
| .toggle input{opacity:0;width:0;height:0;position:absolute} | |
| .slider-t{position:absolute;inset:0;background:#C7D2CB;border-radius:99px;transition:0.2s} | |
| .slider-t::before{content:'';position:absolute;width:17px;height:17px;left:3px;top:3px;background:var(--white);border-radius:50%;transition:0.2s;box-shadow:0 1px 2px rgba(0,0,0,0.2)} | |
| input:checked+.slider-t{background:var(--orange)} | |
| input:checked+.slider-t::before{transform:translateX(17px)} | |
| .examples-row{display:flex;flex-wrap:wrap;gap:7px;margin-top:9px} | |
| .ex-btn{font-family:'JetBrains Mono',monospace;font-size:11.5px;padding:6px 12px;border-radius:99px;border:1px solid var(--line);background:var(--paper);color:var(--ink-soft);cursor:pointer;transition:0.15s;} | |
| .ex-btn:hover{border-color:var(--green);color:var(--green);background:var(--green-tint)} | |
| .analyze-btn{width:100%;padding:12px;font-size:14.5px;font-weight:600;border-radius:8px;border:none;background:var(--orange);color:var(--white);cursor:pointer;transition:0.15s;margin-top:16px;letter-spacing:0.01em;} | |
| .analyze-btn:hover{background:var(--orange-deep)} | |
| .analyze-btn:disabled{opacity:0.55;cursor:not-allowed} | |
| .loading-msg{font-size:12.5px;color:var(--green-deep);margin-top:12px;padding:10px 12px;background:var(--green-tint);border-radius:8px;border:1px solid #CFE3D8;display:none;line-height:1.5;} | |
| .converter-note{font-size:12.5px;color:var(--green-deep);margin-top:12px;padding:10px 12px;background:var(--green-tint);border-radius:8px;border:1px solid #CFE3D8;display:none;line-height:1.5;} | |
| .error-msg{font-size:13px;color:var(--rust);padding:10px 12px;background:var(--rust-tint);border-radius:8px;border:1px solid #F0C5AE;margin-top:12px;display:none;line-height:1.5;} | |
| .result-card{display:none} | |
| .sentiment-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.1rem} | |
| .sentiment-badge{display:inline-flex;align-items:center;gap:8px;padding:7px 16px;border-radius:99px;font-size:14.5px;font-weight:600;} | |
| .sentiment-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} | |
| .sentiment-meta{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-soft)} | |
| .bar-row{display:flex;align-items:center;gap:12px;margin-bottom:10px} | |
| .bar-label{font-size:12.5px;color:var(--ink-soft);width:66px;flex-shrink:0} | |
| .bar-track{flex:1;height:7px;background:#EEF1EE;border-radius:99px;overflow:hidden} | |
| .bar-fill{height:100%;border-radius:99px;transition:width 0.6s cubic-bezier(.4,0,.2,1);width:0} | |
| .bar-pct{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--ink-soft);width:40px;text-align:right;flex-shrink:0} | |
| .about{font-size:12px;color:var(--ink-soft);line-height:1.7;border-top:1px solid var(--line);padding-top:1.1rem;margin-top:0.75rem;} | |
| .about strong{color:var(--ink)} | |
| .about a{color:var(--green);text-decoration:none;border-bottom:1px solid #C7D9CD} | |
| .about a:hover{color:var(--orange-deep);border-color:var(--orange)} | |
| .spinner{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,0.4);border-top-color:var(--white);border-radius:50%;animation:spin 0.7s linear infinite;vertical-align:-2px;margin-right:7px;} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| </style> | |
| </head> | |
| <body> | |
| <header class="hero"> | |
| <div class="hero-inner"> | |
| <div class="eyebrow">Wale Lab · R2I Fellowship</div> | |
| <h1>Sesotho Sentiment Analysis</h1> | |
| <p>Classify Sesotho news headlines as positive, negative, or neutral. Model runs entirely in your browser using Transformers.js.</p> | |
| </div> | |
| </header> | |
| <div class="blanket-strip" aria-hidden="true"></div> | |
| <main class="app"> | |
| <div class="card"> | |
| <div class="label">Headline</div> | |
| <textarea id="inputText" placeholder="Ngola sehlooho sa Sesotho mona... (type a Sesotho headline)"></textarea> | |
| <div class="toggle-row"> | |
| <label class="toggle" title="Apply South African orthography converter"> | |
| <input type="checkbox" id="orthoToggle"> | |
| <span class="slider-t"></span> | |
| </label> | |
| <span class="toggle-label">Apply South African orthography converter</span> | |
| </div> | |
| <div class="label" style="margin-top:18px;margin-bottom:7px">Examples</div> | |
| <div class="examples-row"> | |
| <div class="examples-row"> | |
| <button class="ex-btn" data-text="MOTHO O KHAOTSOE BOTONA LE MENOANA A NTSE A PHELA">Negative</button> | |
| <button class="ex-btn" data-text="TONAKHOLO O LOKELA HO BUSA LILEMO TSE HLANO">Positive</button> | |
| <button class="ex-btn" data-text="TSEBISO E POTLAKILENG">Neutral</button> | |
| </div> | |
| </div> | |
| <button class="analyze-btn" id="analyzeBtn" disabled>Loading model...</button> | |
| <div class="loading-msg" id="loadingMsg">Loading model into browser for the first time. This may take 30 to 60 seconds...</div> | |
| <div class="converter-note" id="converterNote"></div> | |
| <div class="error-msg" id="errorMsg"></div> | |
| </div> | |
| <div class="card result-card" id="resultCard"> | |
| <div class="sentiment-row"> | |
| <div> | |
| <div class="label" style="margin-bottom:6px">Prediction</div> | |
| <div id="sentimentBadge" class="sentiment-badge"></div> | |
| </div> | |
| <div class="sentiment-meta" id="sentimentMeta"></div> | |
| </div> | |
| <div id="bars"></div> | |
| </div> | |
| <div class="about"> | |
| <strong>Research.</strong> Sentiment analysis on South African Sesotho, evaluating cross-orthography transfer from Lesotho-standard Sesotho.<br> | |
| <strong>Model.</strong> <a href="https://huggingface.co/Xhenifer/sesotho-sentiment-afriberta" target="_blank" rel="noopener">Xhenifer/sesotho-sentiment-afriberta</a><br> | |
| <strong>Dataset.</strong> Mokhosi et al. (2024), Zenodo DOI 10.5281/zenodo.10531959<br> | |
| <strong>Cohort.</strong> Wale Lab R2I Fellowship, Cohort 3 | |
| </div> | |
| </main> | |
| <script type="module"> | |
| import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.2/dist/transformers.min.js'; | |
| env.allowRemoteModels = true; | |
| env.allowLocalModels = false; | |
| const LABELS = { 0: "Negative", 1: "Neutral", 2: "Positive" }; | |
| const LABEL_STYLES = { | |
| "Positive": { badgeBg:"#E5F5EC", badgeFg:"#1E6B45", dot:"#2F9463", bar:"#2F9463" }, | |
| "Negative": { badgeBg:"#FBE7DD", badgeFg:"#B3401A", dot:"#B3401A", bar:"#B3401A" }, | |
| "Neutral": { badgeBg:"#F7EEDC", badgeFg:"#B08331", dot:"#B08331", bar:"#B08331" } | |
| }; | |
| const CONVERTER_RULES = [ | |
| [/k'h/gi,"KH"],[/tš/gi,"TSH"],[/\bch/gi,"TJH"], | |
| [/fsh/gi,"FJ"],[/psh/gi,"PJH"],[/\bkh/gi,"KG"], | |
| [/\bli/gi,"DI"],[/\blu/gi,"DU"],[/'n/gi,"NN"],[/'m/gi,"MM"] | |
| ]; | |
| function applyConverter(text) { | |
| let out = text, changed = false; | |
| CONVERTER_RULES.forEach(([pat,rep]) => { | |
| const next = out.replace(pat, rep); | |
| if (next !== out) changed = true; | |
| out = next; | |
| }); | |
| return { text: out, changed }; | |
| } | |
| const loadingMsg = document.getElementById("loadingMsg"); | |
| const analyzeBtn = document.getElementById("analyzeBtn"); | |
| const errorEl = document.getElementById("errorMsg"); | |
| const noteEl = document.getElementById("converterNote"); | |
| const resultCard = document.getElementById("resultCard"); | |
| loadingMsg.style.display = "block"; | |
| let classifier; | |
| try { | |
| classifier = await pipeline( | |
| "text-classification", | |
| "Xhenifer/sesotho-sentiment-afriberta", | |
| { device: "wasm", dtype: "fp32" } | |
| ); | |
| loadingMsg.style.display = "none"; | |
| analyzeBtn.disabled = false; | |
| analyzeBtn.textContent = "Analyze sentiment"; | |
| } catch(e) { | |
| loadingMsg.style.display = "none"; | |
| errorEl.textContent = "Failed to load model: " + e.message; | |
| errorEl.style.display = "block"; | |
| console.error(e); | |
| } | |
| document.querySelectorAll(".ex-btn").forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| document.getElementById("inputText").value = btn.dataset.text; | |
| }); | |
| }); | |
| analyzeBtn.addEventListener("click", async () => { | |
| const raw = document.getElementById("inputText").value.trim(); | |
| if (!raw || !classifier) return; | |
| const useConverter = document.getElementById("orthoToggle").checked; | |
| errorEl.style.display = "none"; | |
| noteEl.style.display = "none"; | |
| resultCard.style.display = "none"; | |
| analyzeBtn.disabled = true; | |
| analyzeBtn.innerHTML = '<span class="spinner"></span>Analyzing...'; | |
| let inputText = raw; | |
| if (useConverter) { | |
| const res = applyConverter(raw); | |
| inputText = res.text; | |
| noteEl.textContent = res.changed | |
| ? `Converter applied: "${raw}" → "${inputText}"` | |
| : "Converter applied, no orthographic changes detected."; | |
| noteEl.style.display = "block"; | |
| } | |
| try { | |
| const results = await classifier(inputText, { topk: 3 }); | |
| const sorted = [...results].sort((a,b) => b.score - a.score); | |
| const top = sorted[0]; | |
| const resolveLabel = l => l.includes("LABEL_") | |
| ? LABELS[parseInt(l.replace("LABEL_",""))] | |
| : (LABELS[parseInt(l)] ?? l); | |
| const topLabel = resolveLabel(top.label); | |
| const s = LABEL_STYLES[topLabel] || LABEL_STYLES["Neutral"]; | |
| const badge = document.getElementById("sentimentBadge"); | |
| badge.innerHTML = `<span class="sentiment-dot" style="background:${s.dot}"></span>${topLabel}`; | |
| badge.style.background = s.badgeBg; | |
| badge.style.color = s.badgeFg; | |
| document.getElementById("sentimentMeta").textContent = `${(top.score*100).toFixed(1)}% confidence`; | |
| document.getElementById("bars").innerHTML = sorted.map(item => { | |
| const lbl = resolveLabel(item.label); | |
| const pct = (item.score * 100).toFixed(1); | |
| const c = LABEL_STYLES[lbl] || LABEL_STYLES["Neutral"]; | |
| return `<div class="bar-row"> | |
| <span class="bar-label">${lbl}</span> | |
| <div class="bar-track"><div class="bar-fill" style="background:${c.bar}"></div></div> | |
| <span class="bar-pct">${pct}%</span> | |
| </div>`; | |
| }).join(""); | |
| resultCard.style.display = "block"; | |
| requestAnimationFrame(() => { | |
| document.querySelectorAll(".bar-fill").forEach((el,i) => { | |
| el.style.width = (sorted[i].score * 100).toFixed(1) + "%"; | |
| }); | |
| }); | |
| } catch(e) { | |
| errorEl.textContent = "Error: " + e.message; | |
| errorEl.style.display = "block"; | |
| console.error(e); | |
| } | |
| analyzeBtn.disabled = false; | |
| analyzeBtn.textContent = "Analyze sentiment"; | |
| }); | |
| </script> | |
| </body> | |
| </html> |