| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Sentiment Analysis — In-Browser Demo</title> |
| <style> |
| :root { |
| --ink: #1b1b1f; |
| --paper: #faf9f6; |
| --line: #dcd8ce; |
| --pos: #2f6d4f; |
| --pos-bg: #e6f2ea; |
| --neg: #9a3b3b; |
| --neg-bg: #f6e9e7; |
| --accent: #3d5a80; |
| --mono: "SF Mono", "Roboto Mono", ui-monospace, Menlo, Consolas, monospace; |
| } |
| |
| * { box-sizing: border-box; } |
| |
| body { |
| margin: 0; |
| background: var(--paper); |
| color: var(--ink); |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; |
| line-height: 1.5; |
| } |
| |
| .wrap { |
| max-width: 720px; |
| margin: 0 auto; |
| padding: 48px 24px 80px; |
| } |
| |
| header { |
| border-bottom: 1px solid var(--line); |
| padding-bottom: 24px; |
| margin-bottom: 32px; |
| } |
| |
| .eyebrow { |
| font-family: var(--mono); |
| font-size: 12px; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| color: var(--accent); |
| margin-bottom: 8px; |
| } |
| |
| h1 { |
| font-size: 28px; |
| font-weight: 650; |
| margin: 0 0 8px; |
| letter-spacing: -0.01em; |
| } |
| |
| .sub { |
| color: #55555c; |
| font-size: 15px; |
| max-width: 56ch; |
| } |
| |
| .sub code { |
| font-family: var(--mono); |
| background: #f0eee6; |
| padding: 1px 5px; |
| border-radius: 3px; |
| font-size: 13px; |
| } |
| |
| .status-row { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| font-family: var(--mono); |
| font-size: 13px; |
| color: #77777d; |
| margin-top: 16px; |
| } |
| |
| .dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: #c9c4b6; |
| flex-shrink: 0; |
| transition: background 0.3s; |
| } |
| .dot.loading { background: #d9a441; animation: pulse 1.2s infinite ease-in-out; } |
| .dot.ready { background: var(--pos); } |
| .dot.error { background: var(--neg); } |
| |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.35; } |
| } |
| |
| textarea { |
| width: 100%; |
| min-height: 110px; |
| padding: 14px 16px; |
| border: 1px solid var(--line); |
| border-radius: 8px; |
| background: #fff; |
| font-family: inherit; |
| font-size: 15px; |
| color: var(--ink); |
| resize: vertical; |
| outline: none; |
| transition: border-color 0.15s; |
| } |
| textarea:focus { border-color: var(--accent); } |
| textarea::placeholder { color: #a8a49a; } |
| |
| .btn-row { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-top: 12px; |
| } |
| |
| button { |
| font-family: var(--mono); |
| font-size: 13px; |
| letter-spacing: 0.02em; |
| text-transform: uppercase; |
| background: var(--ink); |
| color: var(--paper); |
| border: none; |
| padding: 11px 20px; |
| border-radius: 6px; |
| cursor: pointer; |
| transition: opacity 0.15s, transform 0.1s; |
| } |
| button:hover:not(:disabled) { opacity: 0.85; } |
| button:active:not(:disabled) { transform: scale(0.98); } |
| button:disabled { opacity: 0.35; cursor: not-allowed; } |
| |
| .hint { |
| font-size: 12px; |
| color: #93908a; |
| } |
| |
| #result { |
| margin-top: 28px; |
| } |
| |
| .result-card { |
| border: 1px solid var(--line); |
| border-radius: 10px; |
| padding: 20px 22px; |
| background: #fff; |
| display: none; |
| } |
| .result-card.show { display: block; } |
| |
| .result-top { |
| display: flex; |
| justify-content: space-between; |
| align-items: baseline; |
| margin-bottom: 14px; |
| } |
| |
| .label-pill { |
| font-family: var(--mono); |
| font-size: 13px; |
| font-weight: 600; |
| letter-spacing: 0.02em; |
| padding: 5px 12px; |
| border-radius: 100px; |
| } |
| .label-pill.POSITIVE { color: var(--pos); background: var(--pos-bg); } |
| .label-pill.NEGATIVE { color: var(--neg); background: var(--neg-bg); } |
| |
| .score { |
| font-family: var(--mono); |
| font-size: 13px; |
| color: #77777d; |
| } |
| |
| .bar-track { |
| height: 6px; |
| background: #eeece5; |
| border-radius: 100px; |
| overflow: hidden; |
| } |
| |
| .bar-fill { |
| height: 100%; |
| border-radius: 100px; |
| transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1); |
| width: 0%; |
| } |
| .bar-fill.POSITIVE { background: var(--pos); } |
| .bar-fill.NEGATIVE { background: var(--neg); } |
| |
| .history { |
| margin-top: 36px; |
| } |
| |
| .history h2 { |
| font-size: 13px; |
| font-family: var(--mono); |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| color: #93908a; |
| font-weight: 600; |
| margin-bottom: 12px; |
| } |
| |
| .history-item { |
| display: flex; |
| justify-content: space-between; |
| gap: 16px; |
| padding: 12px 0; |
| border-bottom: 1px solid var(--line); |
| font-size: 14px; |
| } |
| .history-item:last-child { border-bottom: none; } |
| |
| .history-text { |
| color: #3a3a3f; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| flex: 1; |
| } |
| |
| .history-label { |
| font-family: var(--mono); |
| font-size: 11px; |
| font-weight: 600; |
| letter-spacing: 0.02em; |
| flex-shrink: 0; |
| } |
| .history-label.POSITIVE { color: var(--pos); } |
| .history-label.NEGATIVE { color: var(--neg); } |
| |
| footer { |
| margin-top: 56px; |
| padding-top: 20px; |
| border-top: 1px solid var(--line); |
| font-size: 12px; |
| color: #a8a49a; |
| } |
| footer a { color: var(--accent); text-decoration: none; } |
| footer a:hover { text-decoration: underline; } |
| </style> |
| </head> |
| <body> |
| <div class="wrap"> |
| <header> |
| <div class="eyebrow">In-browser inference · no server</div> |
| <h1>Sentiment Analysis</h1> |
| <p class="sub"> |
| Runs <code>distilbert-base-uncased-finetuned-sst-2-english</code> entirely in your browser |
| via WebAssembly. Nothing you type is sent anywhere. |
| </p> |
| <div class="status-row"> |
| <span class="dot loading" id="statusDot"></span> |
| <span id="statusText">Loading model…</span> |
| </div> |
| </header> |
|
|
| <textarea id="input" placeholder="I really loved this movie, the acting was fantastic!" disabled></textarea> |
| <div class="btn-row"> |
| <button id="analyzeBtn" disabled>Analyze</button> |
| <span class="hint">⌘/Ctrl + Enter to run</span> |
| </div> |
|
|
| <div id="result"> |
| <div class="result-card" id="resultCard"> |
| <div class="result-top"> |
| <span class="label-pill" id="labelPill"></span> |
| <span class="score" id="scoreText"></span> |
| </div> |
| <div class="bar-track"> |
| <div class="bar-fill" id="barFill"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="history" id="historySection" style="display:none;"> |
| <h2>Recent</h2> |
| <div id="historyList"></div> |
| </div> |
|
|
| <footer> |
| Model card: <a href="https://huggingface.co/distilbert-base-uncased-finetuned-sst-2-english" target="_blank" rel="noopener">distilbert-base-uncased-finetuned-sst-2-english</a> |
| · Powered by <a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noopener">🤗 Transformers.js</a> |
| </footer> |
| </div> |
|
|
| <script type="module"> |
| import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0"; |
| |
| env.allowLocalModels = false; |
| |
| const statusDot = document.getElementById("statusDot"); |
| const statusText = document.getElementById("statusText"); |
| const input = document.getElementById("input"); |
| const analyzeBtn = document.getElementById("analyzeBtn"); |
| const resultCard = document.getElementById("resultCard"); |
| const labelPill = document.getElementById("labelPill"); |
| const scoreText = document.getElementById("scoreText"); |
| const barFill = document.getElementById("barFill"); |
| const historySection = document.getElementById("historySection"); |
| const historyList = document.getElementById("historyList"); |
| |
| let classifier = null; |
| const history = []; |
| |
| async function loadModel() { |
| try { |
| classifier = await pipeline( |
| "sentiment-analysis", |
| "Xenova/distilbert-base-uncased-finetuned-sst-2-english" |
| ); |
| statusDot.className = "dot ready"; |
| statusText.textContent = "Model ready"; |
| input.disabled = false; |
| analyzeBtn.disabled = false; |
| input.focus(); |
| } catch (err) { |
| statusDot.className = "dot error"; |
| statusText.textContent = "Failed to load model — check console"; |
| console.error(err); |
| } |
| } |
| |
| function renderResult(label, score) { |
| resultCard.classList.add("show"); |
| labelPill.textContent = label; |
| labelPill.className = "label-pill " + label; |
| scoreText.textContent = (score * 100).toFixed(1) + "% confidence"; |
| barFill.className = "bar-fill " + label; |
| requestAnimationFrame(() => { |
| barFill.style.width = (score * 100).toFixed(1) + "%"; |
| }); |
| } |
| |
| function addToHistory(text, label, score) { |
| history.unshift({ text, label, score }); |
| if (history.length > 6) history.pop(); |
| historySection.style.display = "block"; |
| historyList.innerHTML = history |
| .map( |
| (h) => ` |
| <div class="history-item"> |
| <span class="history-text">${escapeHtml(h.text)}</span> |
| <span class="history-label ${h.label}">${h.label} ${(h.score * 100).toFixed(0)}%</span> |
| </div>` |
| ) |
| .join(""); |
| } |
| |
| function escapeHtml(str) { |
| const div = document.createElement("div"); |
| div.textContent = str; |
| return div.innerHTML; |
| } |
| |
| async function analyze() { |
| const text = input.value.trim(); |
| if (!text || !classifier) return; |
| |
| analyzeBtn.disabled = true; |
| analyzeBtn.textContent = "Analyzing…"; |
| |
| const output = await classifier(text); |
| const { label, score } = output[0]; |
| |
| renderResult(label, score); |
| addToHistory(text, label, score); |
| |
| analyzeBtn.disabled = false; |
| analyzeBtn.textContent = "Analyze"; |
| } |
| |
| analyzeBtn.addEventListener("click", analyze); |
| input.addEventListener("keydown", (e) => { |
| if ((e.metaKey || e.ctrlKey) && e.key === "Enter") analyze(); |
| }); |
| |
| loadModel(); |
| </script> |
| </body> |
| </html> |
|
|