spellchecker / static /index.html
iamleonie's picture
Initial public commit
c0ba126
Raw
History Blame Contribute Delete
15.1 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>English spellchecker</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=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrap">
<header class="site-intro">
<h1>English <em>spellchecker</em></h1>
<p class="sub">Correct English spelling and grammar with LFM2.5.</p>
</header>
<section class="model-strip is-loading" id="model-strip" aria-label="Model status">
<span class="model-logo-frame" aria-hidden="true">
<img id="model-logo" class="model-logo" width="56" height="59" alt="" src="lliquid.gif">
</span>
<div class="model-copy">
<div class="model-name-row">
<a href="https://huggingface.co/LiquidAI/LFM2.5-Encoder-350M-Spellchecker" target="_blank" rel="noopener">LFM2.5 Spellchecker</a>
<span class="model-variant">350M parameters</span>
</div>
</div>
<div class="load-block">
<div class="load-meta">
<span id="status" role="status" aria-live="polite">Loading model…</span>
<span id="load-percent">0%</span>
</div>
<div class="load-track" role="progressbar" aria-label="Model loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="load-progress"></span></div>
</div>
</section>
<main>
<section class="spell-workspace" id="spell-workspace" aria-label="Spellchecker">
<article class="workspace-card input-panel">
<div class="panel-header">
<h2>Input</h2>
</div>
<div class="editor-stage input-stage">
<label class="sr-only" for="input">Text to check</label>
<textarea id="input" spellcheck="false" placeholder="Write or paste text to check…"></textarea>
<div class="editor-scan" aria-hidden="true"></div>
</div>
<div class="input-meta"><span id="count">0 characters</span><span>Checks after 650 ms</span></div>
<details class="settings-menu">
<summary>Advanced settings</summary>
<div class="settings-reveal">
<div class="settings-grid" aria-label="Correction settings">
<label class="setting-card" for="mep">
<span class="setting-copy"><span>Confidence threshold</span><output id="mepv" for="mep">0.00</output></span>
<input type="range" id="mep" min="0" max="1" step="0.05" value="0">
<span class="setting-scale"><span>More corrections</span><span>More precise</span></span>
</label>
<label class="setting-card" for="mit">
<span class="setting-copy"><span>Correction passes</span><output id="mitv" for="mit">3</output></span>
<input type="range" id="mit" min="1" max="5" step="1" value="3">
<span class="setting-scale"><span>1 pass</span><span>5 passes</span></span>
</label>
</div>
</div>
</details>
</article>
<article class="workspace-card output-panel">
<div class="panel-header output-heading">
<h2>Corrected</h2>
<button type="button" class="copy-button" id="copy" disabled>Copy</button>
</div>
<div class="editor-stage output-stage" id="output-stage">
<div id="output" class="output-text empty" aria-live="polite" data-placeholder="Your corrected text will appear here."></div>
<div class="editor-scan" aria-hidden="true"></div>
</div>
<div class="legend" aria-label="Correction legend">
<span><i class="legend-swatch modified"></i>Modified</span>
<span><i class="legend-swatch removed"></i>Removed</span>
</div>
<div class="stats-grid" id="stats" aria-live="polite">
<div class="result-slot"><div class="activity-pill is-idle" id="activity"><span class="activity-dot" aria-hidden="true"></span><span id="activity-label">Ready</span></div></div>
<div class="stat-card"><span>Edits</span><strong id="stat-edits"></strong></div>
<div class="stat-card"><span>Latency</span><strong id="stat-latency"></strong></div>
<div class="stat-card"><span>Characters</span><strong id="stat-characters"></strong></div>
</div>
</article>
</section>
<section class="examples-panel" aria-label="Examples">
<span class="examples-label">Examples</span>
<div class="example-list" id="examples"></div>
</section>
</main>
</div>
<script>
{
const settingsMenu = document.querySelector(".settings-menu");
const settingsSummary = settingsMenu.querySelector("summary");
const settingsReveal = settingsMenu.querySelector(".settings-reveal");
settingsSummary.setAttribute("aria-expanded", "false");
settingsSummary.addEventListener("click", async (event) => {
event.preventDefault();
if (settingsMenu.dataset.animating === "true") return;
const opening = !settingsMenu.open;
const duration = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300;
if (!settingsReveal.animate || duration === 0) {
settingsMenu.open = opening;
settingsSummary.setAttribute("aria-expanded", String(opening));
return;
}
settingsMenu.dataset.animating = "true";
if (opening) settingsMenu.open = true;
settingsSummary.setAttribute("aria-expanded", String(opening));
const expandedHeight = settingsReveal.scrollHeight;
const animation = settingsReveal.animate(
opening
? [
{ height: "0px", opacity: 0, transform: "translateY(-6px)" },
{ height: `${expandedHeight}px`, opacity: 1, transform: "translateY(0)" },
]
: [
{ height: `${settingsReveal.getBoundingClientRect().height}px`, opacity: 1, transform: "translateY(0)" },
{ height: "0px", opacity: 0, transform: "translateY(-6px)" },
],
{ duration, easing: "cubic-bezier(.2,.8,.2,1)" },
);
try { await animation.finished; } catch (_) {}
if (!opening) settingsMenu.open = false;
delete settingsMenu.dataset.animating;
});
}
</script>
<script>
const EXAMPLES = [
{ label:"Agreement", text:"Their are many reason to study hard." },
{ label:"Verb forms", text:"I has went to the stor yesterday." },
{ label:"Pronouns", text:"Him and me was late for the meetting." },
{ label:"Repetition", text:"i want to go home home." },
{ label:"Comparison", text:"He is more taller than his brother." },
{ label:"Articles", text:"Can you give me a advice?" },
{ label:"Clean text", text:"That's a fair point, let's discuss it tomorrow." }
];
const $ = id => document.getElementById(id);
// Private Space auth: HF signs the embedded iframe URL with ?__sign=<token>.
// Incognito / Safari block the third-party .hf.space cookie, so carry that token
// on same-origin API requests, else /api/* returns 404.
const __sign = new URLSearchParams(location.search).get("__sign");
const signedUrl = path => {
const url = new URL(path, location.href);
if (__sign) url.searchParams.set("__sign", __sign);
return url;
};
const input = $("input"), output = $("output"), copy = $("copy"), workspace = $("spell-workspace");
const ATTACH_LEFT = /^[.,!?;:%)\]}»…]+$/;
const CONTRACTION = /^(?:n['’]t|['’](?:s|re|ve|ll|d|m))$/i;
const APOSTROPHE = /^['’]$/;
const CONTRACTION_TAIL = /^(?:s|t|d|m|re|ve|ll)$/i;
const OPEN = /^[(\[{«¿¡]+$/;
const DEBOUNCE = 650;
let timer = null, requestSequence = 0, lastCorrected = "", activeExample = null;
function setModelState(kind, status, percent) {
const strip = $("model-strip");
strip.className = `model-strip is-${kind}`;
const value = kind === "ready" ? 100 : kind === "error" ? 100 : Math.max(0, Math.min(100, Number(percent) || 0));
$("status").textContent = status;
$("load-percent").textContent = kind === "error" ? "Error" : `${Math.round(value)}%`;
$("load-progress").style.width = `${value}%`;
strip.querySelector("[role=progressbar]").setAttribute("aria-valuenow", String(Math.round(value)));
}
function setActivity(kind, label) {
$("activity").className = `activity-pill is-${kind}`;
$("activity-label").textContent = label;
}
function setRangeFill(range) {
const pct = (range.value - range.min) / (range.max - range.min) * 100;
range.style.setProperty("--pct", `${pct}%`);
}
function updateCount() {
const count = input.value.length;
$("count").textContent = `${count} ${count === 1 ? "character" : "characters"}`;
}
function clearActiveExample() {
if (activeExample) activeExample.classList.remove("active");
activeExample = null;
}
function schedule(delay = DEBOUNCE) {
clearTimeout(timer);
timer = setTimeout(correct, delay);
}
function attachLeft(token) {
return ATTACH_LEFT.test(token) || CONTRACTION.test(token);
}
function needsSpace(previous, current, next) {
if (previous === null) return false;
const startsSplitContraction = APOSTROPHE.test(current) && CONTRACTION_TAIL.test(next || "");
const finishesSplitContraction = APOSTROPHE.test(previous) && CONTRACTION_TAIL.test(current);
return !attachLeft(current) && !startsSplitContraction && !finishesSplitContraction && !OPEN.test(previous);
}
function renderOutput(segments) {
const tokens = [];
for (const segment of segments) {
for (const token of segment.text.split(/\s+/).filter(Boolean)) tokens.push({ text:token, kind:segment.kind });
}
output.innerHTML = "";
output.classList.toggle("empty", tokens.length === 0);
let previous = null, editIndex = 0;
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (needsSpace(previous, token.text, tokens[index + 1]?.text)) output.append(document.createTextNode(" "));
let node;
if (token.kind === "edit") {
node = document.createElement("mark");
node.style.setProperty("--edit-index", editIndex++);
node.textContent = token.text;
} else if (token.kind === "del") {
node = document.createElement("span");
node.className = "deleted";
node.style.setProperty("--edit-index", editIndex++);
node.textContent = token.text;
} else {
node = document.createTextNode(token.text);
}
output.append(node);
previous = token.text;
}
}
function countEdits(segments) {
let edits = 0, inEdit = false;
for (const segment of segments) {
if (segment.kind === "keep") inEdit = false;
else if (!inEdit) { edits++; inEdit = true; }
}
return edits;
}
function resetResult() {
requestSequence++;
lastCorrected = "";
renderOutput([]);
copy.disabled = true;
setActivity("idle", "Ready");
workspace.classList.remove("is-checking", "is-complete");
$("stats").classList.remove("has-result");
$("stat-edits").textContent = "—";
$("stat-latency").textContent = "—";
$("stat-characters").textContent = "—";
}
async function correct() {
const text = input.value.replace(/\s+/g, " ").trim();
if (!text) { resetResult(); return; }
const sequence = ++requestSequence;
const started = performance.now();
workspace.classList.remove("is-complete");
workspace.classList.add("is-checking");
setActivity("running", "Checking…");
try {
const response = await fetch(signedUrl("/api/correct"), {
method:"POST",
headers:{ "Content-Type":"application/json" },
body:JSON.stringify({ text, min_error_prob:+$("mep").value, max_iter:+$("mit").value })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (sequence !== requestSequence) return;
const latency = performance.now() - started;
const edits = countEdits(data.segments);
lastCorrected = data.corrected;
renderOutput(data.segments);
copy.disabled = !lastCorrected;
workspace.classList.remove("is-checking");
void workspace.offsetWidth;
workspace.classList.add("is-complete");
setActivity("done", data.changed ? "Corrected" : "Looks good");
const stats = $("stats");
stats.classList.remove("has-result");
void stats.offsetWidth;
stats.classList.add("has-result");
$("stat-edits").textContent = String(edits);
$("stat-latency").textContent = `${Math.round(latency)} ms`;
$("stat-characters").textContent = String(text.length);
setTimeout(() => workspace.classList.remove("is-complete"), 1000);
} catch (error) {
if (sequence !== requestSequence) return;
workspace.classList.remove("is-checking");
setActivity("error", "Check failed");
console.error(error);
}
}
input.addEventListener("input", () => {
updateCount();
clearActiveExample();
schedule();
});
for (const range of [$("mep"), $("mit")]) {
setRangeFill(range);
range.addEventListener("input", event => {
setRangeFill(event.target);
$(event.target.id === "mep" ? "mepv" : "mitv").textContent = event.target.id === "mep" ? (+event.target.value).toFixed(2) : event.target.value;
schedule(0);
});
}
for (const example of EXAMPLES) {
const button = document.createElement("button");
button.type = "button";
button.className = "ex";
button.textContent = example.label;
button.dataset.text = example.text;
button.addEventListener("click", () => {
clearActiveExample();
button.classList.add("active");
activeExample = button;
input.value = example.text;
updateCount();
input.focus();
schedule(0);
});
$("examples").append(button);
}
copy.addEventListener("click", async () => {
if (!lastCorrected) return;
await navigator.clipboard.writeText(lastCorrected);
copy.textContent = "Copied";
copy.classList.remove("copied");
void copy.offsetWidth;
copy.classList.add("copied");
clearTimeout(copy._timer);
copy._timer = setTimeout(() => { copy.textContent = "Copy"; copy.classList.remove("copied"); }, 1200);
});
fetch(signedUrl("/api/health"))
.then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); })
.then(data => {
setModelState("ready", "Model ready", 100);
$("status").title = data.mem_human ? `${data.mem_human} in memory` : "";
})
.catch(() => setModelState("error", "Model unavailable", 100));
(function init() {
const first = $("examples").querySelector(".ex");
first.classList.add("active");
activeExample = first;
input.value = first.dataset.text;
updateCount();
schedule(0);
})();
</script>
</body>
</html>