iamleonie mlabonne claude opus 4.8 (1m context) commited on
Commit
c0ba126
·
0 Parent(s):

Initial public commit

Browse files

Co-authored-by: mlabonne <mlabonne@users.noreply.huggingface.co>
Co-authored-by: claude opus 4.8 (1m context) <claude opus 4.8 (1m context)@users.noreply.huggingface.co>

Files changed (9) hide show
  1. .dockerignore +6 -0
  2. .gitattributes +35 -0
  3. Dockerfile +25 -0
  4. README.md +13 -0
  5. requirements.txt +9 -0
  6. server.py +152 -0
  7. static/index.html +382 -0
  8. static/lliquid.gif +0 -0
  9. static/style.css +218 -0
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .git
2
+ .gitattributes
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
6
+ *.md
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Space for the Liquidspell demo: FastAPI backend + static frontend (no Gradio).
2
+ # Pinned to the stack the model was validated against (the encoder's bidirectional-mask remote code is
3
+ # transformers-version sensitive — see server.py).
4
+ FROM python:3.12-slim
5
+
6
+ # non-root user (HF Spaces convention; gives a writable HOME for the HF cache)
7
+ RUN useradd -m -u 1000 user
8
+ USER user
9
+ ENV HOME=/home/user \
10
+ PATH=/home/user/.local/bin:$PATH \
11
+ HF_HOME=/home/user/.cache/huggingface \
12
+ PYTHONUNBUFFERED=1
13
+
14
+ WORKDIR /home/user/app
15
+
16
+ # CPU-only torch first (default PyPI wheel is CUDA and huge) — matches the validated 2.12.0.
17
+ RUN pip install --no-cache-dir --user torch==2.12.0 --index-url https://download.pytorch.org/whl/cpu
18
+
19
+ COPY --chown=user requirements.txt .
20
+ RUN pip install --no-cache-dir --user -r requirements.txt
21
+
22
+ COPY --chown=user . .
23
+
24
+ EXPOSE 7860
25
+ CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: English spellchecker
3
+ emoji: ✍️
4
+ colorFrom: purple
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ header: mini
9
+ pinned: false
10
+ private: true
11
+ short_description: Fix typos with a tiny local model on CPU
12
+ ---
13
+
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ transformers==5.1.0
2
+ safetensors==0.7.0
3
+ fastapi==0.115.6
4
+ uvicorn[standard]==0.34.0
5
+ huggingface_hub>=0.26
6
+ # errant/spaCy intentionally NOT installed: the model's .correct() reranks by default and its edit
7
+ # extractor uses ERRANT when importable, else a self-contained difflib aligner — verified to suppress
8
+ # the same over-corrections as ERRANT on the demo set. Skipping the heavy errant+spaCy+model install
9
+ # keeps the Space build fast and reliable (that install was the likely rebuild failure).
server.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI backend for the LFM2.5 Spellchecker demo (Docker Space).
2
+
3
+ uvicorn server:app --host 0.0.0.0 --port 7860
4
+ """
5
+ import difflib
6
+ import os
7
+ import re
8
+
9
+ import torch
10
+ from fastapi import FastAPI
11
+ from fastapi.responses import FileResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from pydantic import BaseModel
14
+ from transformers import AutoModel
15
+
16
+
17
+ def _effective_cpus():
18
+ """Cores actually granted to this container (cgroup quota), not the host core count.
19
+ os.cpu_count() returns the host's cores, so torch would oversubscribe the 2-vCPU Space."""
20
+ try: # cgroup v2
21
+ raw = open("/sys/fs/cgroup/cpu.max").read().split()
22
+ if raw and raw[0] != "max":
23
+ return max(1, round(int(raw[0]) / int(raw[1])))
24
+ except (OSError, ValueError):
25
+ pass
26
+ try: # cgroup v1
27
+ q = int(open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").read())
28
+ p = int(open("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read())
29
+ if q > 0:
30
+ return max(1, q // p)
31
+ except (OSError, ValueError):
32
+ pass
33
+ try:
34
+ return max(1, len(os.sched_getaffinity(0)))
35
+ except AttributeError:
36
+ return os.cpu_count() or 1
37
+
38
+
39
+ _CPUS = _effective_cpus()
40
+ torch.set_num_threads(_CPUS)
41
+ try:
42
+ torch.set_num_interop_threads(1)
43
+ except RuntimeError:
44
+ pass
45
+
46
+ MODEL_ID = os.environ.get("SPELLCHECKER_MODEL", "LiquidAI/LFM2.5-Encoder-350M-Spellchecker")
47
+ MODEL_REV = os.environ.get("SPELLCHECKER_REVISION", "main")
48
+ STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
49
+
50
+ print(f"[server] loading {MODEL_ID}@{MODEL_REV} on {_CPUS} CPU thread(s) ...", flush=True)
51
+ _model = AutoModel.from_pretrained(MODEL_ID, revision=MODEL_REV, trust_remote_code=True,
52
+ token=os.environ.get("HF_TOKEN")).float().eval()
53
+ _mem_bytes = sum(t.numel() * t.element_size() for t in (*_model.parameters(), *_model.buffers()))
54
+ _mem_human = (f"{_mem_bytes / 1024**3:.2f} GB" if _mem_bytes >= 1024**3
55
+ else f"{_mem_bytes / 1024**2:.0f} MB")
56
+ print(f"[server] model ready ({_mem_human} in memory)", flush=True)
57
+
58
+
59
+ # The model is trained on spaCy-tokenized text (punctuation separated by spaces AND contractions split:
60
+ # "let's"->"let 's", "don't"->"do n't"). We tokenize natural input into that form before the model and
61
+ # detokenize for display, so users type/read natural text while the model sees its training form. Feeding
62
+ # JOINED contractions is out-of-distribution and made the model edit them erratically (the "let 's" /
63
+ # "That's 's" artifacts) — splitting+rejoining contractions is the fix.
64
+ _PUNCT = re.compile(r'([.,!?;:()\[\]{}"«»…])')
65
+ _ATTACH_LEFT = re.compile(r'\s+([.,!?;:%)\]}»…])')
66
+ _ATTACH_RIGHT = re.compile(r'([(\[{«])\s+')
67
+ _NT = re.compile(r"(\w+?)(n['’]t)\b", re.I) # don't->do n't, can't->ca n't
68
+ _CONTR = re.compile(r"(\w)(['’](?:s|re|ve|ll|d|m))\b", re.I) # let's->let 's, I'm->I 'm
69
+ _REJOIN_NT = re.compile(r"\s+(n['’]t)\b", re.I) # do n't->don't
70
+ _REJOIN_CONTR = re.compile(r"\s+(['’](?:s|re|ve|ll|d|m))\b", re.I) # let 's->let's
71
+ _REJOIN_SPLIT_APOSTROPHE = re.compile(r"(?<=\w)\s+(['’])\s+(?=(?:s|t|d|m|re|ve|ll)\b)", re.I)
72
+
73
+
74
+ def tokenize(text: str) -> str:
75
+ text = _PUNCT.sub(r" \1 ", text)
76
+ text = _NT.sub(r"\1 \2", text) # split n't (own apostrophe) first
77
+ text = _CONTR.sub(r"\1 \2", text) # then 's/'re/'ve/'ll/'d/'m
78
+ return re.sub(r"\s+", " ", text).strip()
79
+
80
+
81
+ def detok(text: str) -> str:
82
+ text = _ATTACH_LEFT.sub(r"\1", text)
83
+ text = _ATTACH_RIGHT.sub(r"\1", text)
84
+ text = _REJOIN_NT.sub(r"\1", text) # do n't->don't
85
+ text = _REJOIN_CONTR.sub(r"\1", text) # let 's->let's
86
+ text = _REJOIN_SPLIT_APOSTROPHE.sub(r"\1", text) # don ' t->don't
87
+ return re.sub(r"\s+", " ", text).strip()
88
+
89
+
90
+ _PROBE_IN = "That's a fair point, let's discuss it tomorrow."
91
+ try:
92
+ _PROBE_OUT = detok(_model.correct([tokenize(_PROBE_IN)], max_iter=3)[0])
93
+ _RERANK_ACTIVE = (_PROBE_OUT == _PROBE_IN)
94
+ except Exception as e: # pragma: no cover
95
+ _PROBE_OUT, _RERANK_ACTIVE = f"ERROR: {e}", None
96
+ print(f"[server] self-test ok={_RERANK_ACTIVE}: {_PROBE_IN!r} -> {_PROBE_OUT!r}", flush=True)
97
+
98
+
99
+ def diff_segments(source: str, corrected: str):
100
+ """Word-level diff -> [{text, kind}] segments, kind in {keep, edit, del}, for inline rendering of
101
+ the corrected text: `keep` unchanged, `edit` inserted/replaced (highlight green), `del` removed
102
+ (shown struck-through red — these words are NOT part of the corrected text)."""
103
+ s, c = source.split(), corrected.split()
104
+ seg = []
105
+ for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, s, c, autojunk=False).get_opcodes():
106
+ if op == "equal":
107
+ seg.append({"text": " ".join(c[j1:j2]), "kind": "keep"})
108
+ elif op == "insert":
109
+ seg.append({"text": " ".join(c[j1:j2]), "kind": "edit"})
110
+ elif op == "delete":
111
+ seg.append({"text": " ".join(s[i1:i2]), "kind": "del"})
112
+ elif op == "replace": # new words highlighted; removed words shown struck
113
+ seg.append({"text": " ".join(s[i1:i2]), "kind": "del"})
114
+ seg.append({"text": " ".join(c[j1:j2]), "kind": "edit"})
115
+ return seg or [{"text": corrected, "kind": "keep"}]
116
+
117
+
118
+ class CorrectRequest(BaseModel):
119
+ text: str
120
+ min_error_prob: float = 0.0
121
+ max_iter: int = 3
122
+
123
+
124
+ app = FastAPI(title="LFM2.5 Spellchecker")
125
+
126
+
127
+ @app.get("/api/health")
128
+ def health():
129
+ return {"status": "ok", "model": MODEL_ID, "revision": MODEL_REV,
130
+ "mem_bytes": _mem_bytes, "mem_human": _mem_human,
131
+ "rerank_active": _RERANK_ACTIVE, "self_test": {"in": _PROBE_IN, "out": _PROBE_OUT}}
132
+
133
+
134
+ @app.post("/api/correct")
135
+ @torch.no_grad()
136
+ def correct(req: CorrectRequest):
137
+ text = (req.text or "").strip()
138
+ if not text:
139
+ return {"corrected": "", "segments": [], "changed": False}
140
+ src = tokenize(text) # natural -> spaced (the model's form)
141
+ out = _model.correct([src], min_error_prob=float(req.min_error_prob),
142
+ max_iter=int(req.max_iter))[0]
143
+ # diff on spaced tokens (accurate, word-level); the frontend detokenizes spacing for display.
144
+ return {"corrected": detok(out), "segments": diff_segments(src, out), "changed": out != src}
145
+
146
+
147
+ @app.get("/")
148
+ def index():
149
+ return FileResponse(os.path.join(STATIC, "index.html"))
150
+
151
+
152
+ app.mount("/", StaticFiles(directory=STATIC), name="static")
static/index.html ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>English spellchecker</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <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">
10
+ <link rel="stylesheet" href="style.css">
11
+ </head>
12
+ <body>
13
+ <div class="wrap">
14
+ <header class="site-intro">
15
+ <h1>English <em>spellchecker</em></h1>
16
+ <p class="sub">Correct English spelling and grammar with LFM2.5.</p>
17
+ </header>
18
+
19
+ <section class="model-strip is-loading" id="model-strip" aria-label="Model status">
20
+ <span class="model-logo-frame" aria-hidden="true">
21
+ <img id="model-logo" class="model-logo" width="56" height="59" alt="" src="lliquid.gif">
22
+ </span>
23
+ <div class="model-copy">
24
+ <div class="model-name-row">
25
+ <a href="https://huggingface.co/LiquidAI/LFM2.5-Encoder-350M-Spellchecker" target="_blank" rel="noopener">LFM2.5 Spellchecker</a>
26
+ <span class="model-variant">350M parameters</span>
27
+ </div>
28
+ </div>
29
+ <div class="load-block">
30
+ <div class="load-meta">
31
+ <span id="status" role="status" aria-live="polite">Loading model…</span>
32
+ <span id="load-percent">0%</span>
33
+ </div>
34
+ <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>
35
+ </div>
36
+ </section>
37
+
38
+ <main>
39
+ <section class="spell-workspace" id="spell-workspace" aria-label="Spellchecker">
40
+ <article class="workspace-card input-panel">
41
+ <div class="panel-header">
42
+ <h2>Input</h2>
43
+ </div>
44
+
45
+ <div class="editor-stage input-stage">
46
+ <label class="sr-only" for="input">Text to check</label>
47
+ <textarea id="input" spellcheck="false" placeholder="Write or paste text to check…"></textarea>
48
+ <div class="editor-scan" aria-hidden="true"></div>
49
+ </div>
50
+
51
+ <div class="input-meta"><span id="count">0 characters</span><span>Checks after 650 ms</span></div>
52
+
53
+ <details class="settings-menu">
54
+ <summary>Advanced settings</summary>
55
+ <div class="settings-reveal">
56
+ <div class="settings-grid" aria-label="Correction settings">
57
+ <label class="setting-card" for="mep">
58
+ <span class="setting-copy"><span>Confidence threshold</span><output id="mepv" for="mep">0.00</output></span>
59
+ <input type="range" id="mep" min="0" max="1" step="0.05" value="0">
60
+ <span class="setting-scale"><span>More corrections</span><span>More precise</span></span>
61
+ </label>
62
+ <label class="setting-card" for="mit">
63
+ <span class="setting-copy"><span>Correction passes</span><output id="mitv" for="mit">3</output></span>
64
+ <input type="range" id="mit" min="1" max="5" step="1" value="3">
65
+ <span class="setting-scale"><span>1 pass</span><span>5 passes</span></span>
66
+ </label>
67
+ </div>
68
+ </div>
69
+ </details>
70
+ </article>
71
+
72
+ <article class="workspace-card output-panel">
73
+ <div class="panel-header output-heading">
74
+ <h2>Corrected</h2>
75
+ <button type="button" class="copy-button" id="copy" disabled>Copy</button>
76
+ </div>
77
+
78
+ <div class="editor-stage output-stage" id="output-stage">
79
+ <div id="output" class="output-text empty" aria-live="polite" data-placeholder="Your corrected text will appear here."></div>
80
+ <div class="editor-scan" aria-hidden="true"></div>
81
+ </div>
82
+
83
+ <div class="legend" aria-label="Correction legend">
84
+ <span><i class="legend-swatch modified"></i>Modified</span>
85
+ <span><i class="legend-swatch removed"></i>Removed</span>
86
+ </div>
87
+
88
+ <div class="stats-grid" id="stats" aria-live="polite">
89
+ <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>
90
+ <div class="stat-card"><span>Edits</span><strong id="stat-edits">—</strong></div>
91
+ <div class="stat-card"><span>Latency</span><strong id="stat-latency">—</strong></div>
92
+ <div class="stat-card"><span>Characters</span><strong id="stat-characters">—</strong></div>
93
+ </div>
94
+ </article>
95
+ </section>
96
+
97
+ <section class="examples-panel" aria-label="Examples">
98
+ <span class="examples-label">Examples</span>
99
+ <div class="example-list" id="examples"></div>
100
+ </section>
101
+ </main>
102
+ </div>
103
+
104
+ <script>
105
+ {
106
+ const settingsMenu = document.querySelector(".settings-menu");
107
+ const settingsSummary = settingsMenu.querySelector("summary");
108
+ const settingsReveal = settingsMenu.querySelector(".settings-reveal");
109
+ settingsSummary.setAttribute("aria-expanded", "false");
110
+
111
+ settingsSummary.addEventListener("click", async (event) => {
112
+ event.preventDefault();
113
+ if (settingsMenu.dataset.animating === "true") return;
114
+
115
+ const opening = !settingsMenu.open;
116
+ const duration = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300;
117
+ if (!settingsReveal.animate || duration === 0) {
118
+ settingsMenu.open = opening;
119
+ settingsSummary.setAttribute("aria-expanded", String(opening));
120
+ return;
121
+ }
122
+
123
+ settingsMenu.dataset.animating = "true";
124
+ if (opening) settingsMenu.open = true;
125
+ settingsSummary.setAttribute("aria-expanded", String(opening));
126
+
127
+ const expandedHeight = settingsReveal.scrollHeight;
128
+ const animation = settingsReveal.animate(
129
+ opening
130
+ ? [
131
+ { height: "0px", opacity: 0, transform: "translateY(-6px)" },
132
+ { height: `${expandedHeight}px`, opacity: 1, transform: "translateY(0)" },
133
+ ]
134
+ : [
135
+ { height: `${settingsReveal.getBoundingClientRect().height}px`, opacity: 1, transform: "translateY(0)" },
136
+ { height: "0px", opacity: 0, transform: "translateY(-6px)" },
137
+ ],
138
+ { duration, easing: "cubic-bezier(.2,.8,.2,1)" },
139
+ );
140
+
141
+ try { await animation.finished; } catch (_) {}
142
+ if (!opening) settingsMenu.open = false;
143
+ delete settingsMenu.dataset.animating;
144
+ });
145
+ }
146
+ </script>
147
+
148
+ <script>
149
+ const EXAMPLES = [
150
+ { label:"Agreement", text:"Their are many reason to study hard." },
151
+ { label:"Verb forms", text:"I has went to the stor yesterday." },
152
+ { label:"Pronouns", text:"Him and me was late for the meetting." },
153
+ { label:"Repetition", text:"i want to go home home." },
154
+ { label:"Comparison", text:"He is more taller than his brother." },
155
+ { label:"Articles", text:"Can you give me a advice?" },
156
+ { label:"Clean text", text:"That's a fair point, let's discuss it tomorrow." }
157
+ ];
158
+
159
+ const $ = id => document.getElementById(id);
160
+ // Private Space auth: HF signs the embedded iframe URL with ?__sign=<token>.
161
+ // Incognito / Safari block the third-party .hf.space cookie, so carry that token
162
+ // on same-origin API requests, else /api/* returns 404.
163
+ const __sign = new URLSearchParams(location.search).get("__sign");
164
+ const signedUrl = path => {
165
+ const url = new URL(path, location.href);
166
+ if (__sign) url.searchParams.set("__sign", __sign);
167
+ return url;
168
+ };
169
+ const input = $("input"), output = $("output"), copy = $("copy"), workspace = $("spell-workspace");
170
+ const ATTACH_LEFT = /^[.,!?;:%)\]}»…]+$/;
171
+ const CONTRACTION = /^(?:n['’]t|['’](?:s|re|ve|ll|d|m))$/i;
172
+ const APOSTROPHE = /^['’]$/;
173
+ const CONTRACTION_TAIL = /^(?:s|t|d|m|re|ve|ll)$/i;
174
+ const OPEN = /^[(\[{«¿¡]+$/;
175
+ const DEBOUNCE = 650;
176
+ let timer = null, requestSequence = 0, lastCorrected = "", activeExample = null;
177
+
178
+ function setModelState(kind, status, percent) {
179
+ const strip = $("model-strip");
180
+ strip.className = `model-strip is-${kind}`;
181
+ const value = kind === "ready" ? 100 : kind === "error" ? 100 : Math.max(0, Math.min(100, Number(percent) || 0));
182
+ $("status").textContent = status;
183
+ $("load-percent").textContent = kind === "error" ? "Error" : `${Math.round(value)}%`;
184
+ $("load-progress").style.width = `${value}%`;
185
+ strip.querySelector("[role=progressbar]").setAttribute("aria-valuenow", String(Math.round(value)));
186
+ }
187
+
188
+ function setActivity(kind, label) {
189
+ $("activity").className = `activity-pill is-${kind}`;
190
+ $("activity-label").textContent = label;
191
+ }
192
+
193
+ function setRangeFill(range) {
194
+ const pct = (range.value - range.min) / (range.max - range.min) * 100;
195
+ range.style.setProperty("--pct", `${pct}%`);
196
+ }
197
+
198
+ function updateCount() {
199
+ const count = input.value.length;
200
+ $("count").textContent = `${count} ${count === 1 ? "character" : "characters"}`;
201
+ }
202
+
203
+ function clearActiveExample() {
204
+ if (activeExample) activeExample.classList.remove("active");
205
+ activeExample = null;
206
+ }
207
+
208
+ function schedule(delay = DEBOUNCE) {
209
+ clearTimeout(timer);
210
+ timer = setTimeout(correct, delay);
211
+ }
212
+
213
+ function attachLeft(token) {
214
+ return ATTACH_LEFT.test(token) || CONTRACTION.test(token);
215
+ }
216
+
217
+ function needsSpace(previous, current, next) {
218
+ if (previous === null) return false;
219
+ const startsSplitContraction = APOSTROPHE.test(current) && CONTRACTION_TAIL.test(next || "");
220
+ const finishesSplitContraction = APOSTROPHE.test(previous) && CONTRACTION_TAIL.test(current);
221
+ return !attachLeft(current) && !startsSplitContraction && !finishesSplitContraction && !OPEN.test(previous);
222
+ }
223
+
224
+ function renderOutput(segments) {
225
+ const tokens = [];
226
+ for (const segment of segments) {
227
+ for (const token of segment.text.split(/\s+/).filter(Boolean)) tokens.push({ text:token, kind:segment.kind });
228
+ }
229
+ output.innerHTML = "";
230
+ output.classList.toggle("empty", tokens.length === 0);
231
+ let previous = null, editIndex = 0;
232
+ for (let index = 0; index < tokens.length; index++) {
233
+ const token = tokens[index];
234
+ if (needsSpace(previous, token.text, tokens[index + 1]?.text)) output.append(document.createTextNode(" "));
235
+ let node;
236
+ if (token.kind === "edit") {
237
+ node = document.createElement("mark");
238
+ node.style.setProperty("--edit-index", editIndex++);
239
+ node.textContent = token.text;
240
+ } else if (token.kind === "del") {
241
+ node = document.createElement("span");
242
+ node.className = "deleted";
243
+ node.style.setProperty("--edit-index", editIndex++);
244
+ node.textContent = token.text;
245
+ } else {
246
+ node = document.createTextNode(token.text);
247
+ }
248
+ output.append(node);
249
+ previous = token.text;
250
+ }
251
+ }
252
+
253
+ function countEdits(segments) {
254
+ let edits = 0, inEdit = false;
255
+ for (const segment of segments) {
256
+ if (segment.kind === "keep") inEdit = false;
257
+ else if (!inEdit) { edits++; inEdit = true; }
258
+ }
259
+ return edits;
260
+ }
261
+
262
+ function resetResult() {
263
+ requestSequence++;
264
+ lastCorrected = "";
265
+ renderOutput([]);
266
+ copy.disabled = true;
267
+ setActivity("idle", "Ready");
268
+ workspace.classList.remove("is-checking", "is-complete");
269
+ $("stats").classList.remove("has-result");
270
+ $("stat-edits").textContent = "—";
271
+ $("stat-latency").textContent = "—";
272
+ $("stat-characters").textContent = "—";
273
+ }
274
+
275
+ async function correct() {
276
+ const text = input.value.replace(/\s+/g, " ").trim();
277
+ if (!text) { resetResult(); return; }
278
+ const sequence = ++requestSequence;
279
+ const started = performance.now();
280
+ workspace.classList.remove("is-complete");
281
+ workspace.classList.add("is-checking");
282
+ setActivity("running", "Checking…");
283
+
284
+ try {
285
+ const response = await fetch(signedUrl("/api/correct"), {
286
+ method:"POST",
287
+ headers:{ "Content-Type":"application/json" },
288
+ body:JSON.stringify({ text, min_error_prob:+$("mep").value, max_iter:+$("mit").value })
289
+ });
290
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
291
+ const data = await response.json();
292
+ if (sequence !== requestSequence) return;
293
+
294
+ const latency = performance.now() - started;
295
+ const edits = countEdits(data.segments);
296
+ lastCorrected = data.corrected;
297
+ renderOutput(data.segments);
298
+ copy.disabled = !lastCorrected;
299
+ workspace.classList.remove("is-checking");
300
+ void workspace.offsetWidth;
301
+ workspace.classList.add("is-complete");
302
+ setActivity("done", data.changed ? "Corrected" : "Looks good");
303
+
304
+ const stats = $("stats");
305
+ stats.classList.remove("has-result");
306
+ void stats.offsetWidth;
307
+ stats.classList.add("has-result");
308
+ $("stat-edits").textContent = String(edits);
309
+ $("stat-latency").textContent = `${Math.round(latency)} ms`;
310
+ $("stat-characters").textContent = String(text.length);
311
+ setTimeout(() => workspace.classList.remove("is-complete"), 1000);
312
+ } catch (error) {
313
+ if (sequence !== requestSequence) return;
314
+ workspace.classList.remove("is-checking");
315
+ setActivity("error", "Check failed");
316
+ console.error(error);
317
+ }
318
+ }
319
+
320
+ input.addEventListener("input", () => {
321
+ updateCount();
322
+ clearActiveExample();
323
+ schedule();
324
+ });
325
+
326
+ for (const range of [$("mep"), $("mit")]) {
327
+ setRangeFill(range);
328
+ range.addEventListener("input", event => {
329
+ setRangeFill(event.target);
330
+ $(event.target.id === "mep" ? "mepv" : "mitv").textContent = event.target.id === "mep" ? (+event.target.value).toFixed(2) : event.target.value;
331
+ schedule(0);
332
+ });
333
+ }
334
+
335
+ for (const example of EXAMPLES) {
336
+ const button = document.createElement("button");
337
+ button.type = "button";
338
+ button.className = "ex";
339
+ button.textContent = example.label;
340
+ button.dataset.text = example.text;
341
+ button.addEventListener("click", () => {
342
+ clearActiveExample();
343
+ button.classList.add("active");
344
+ activeExample = button;
345
+ input.value = example.text;
346
+ updateCount();
347
+ input.focus();
348
+ schedule(0);
349
+ });
350
+ $("examples").append(button);
351
+ }
352
+
353
+ copy.addEventListener("click", async () => {
354
+ if (!lastCorrected) return;
355
+ await navigator.clipboard.writeText(lastCorrected);
356
+ copy.textContent = "Copied";
357
+ copy.classList.remove("copied");
358
+ void copy.offsetWidth;
359
+ copy.classList.add("copied");
360
+ clearTimeout(copy._timer);
361
+ copy._timer = setTimeout(() => { copy.textContent = "Copy"; copy.classList.remove("copied"); }, 1200);
362
+ });
363
+
364
+ fetch(signedUrl("/api/health"))
365
+ .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); })
366
+ .then(data => {
367
+ setModelState("ready", "Model ready", 100);
368
+ $("status").title = data.mem_human ? `${data.mem_human} in memory` : "";
369
+ })
370
+ .catch(() => setModelState("error", "Model unavailable", 100));
371
+
372
+ (function init() {
373
+ const first = $("examples").querySelector(".ex");
374
+ first.classList.add("active");
375
+ activeExample = first;
376
+ input.value = first.dataset.text;
377
+ updateCount();
378
+ schedule(0);
379
+ })();
380
+ </script>
381
+ </body>
382
+ </html>
static/lliquid.gif ADDED
static/style.css ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --paper:#fff;
3
+ --ink:#050505;
4
+ --purple:#6c4fe0;
5
+ --lilac:#cd82f0;
6
+ --green:#2dbd70;
7
+ --red:#e45454;
8
+ --neutral-50:#fafafa;
9
+ --neutral-100:#f4f4f3;
10
+ --neutral-200:#e7e7e5;
11
+ --neutral-300:#b9b9b5;
12
+ --neutral-500:#737373;
13
+ --neutral-700:#404040;
14
+ --border:rgba(0,0,0,.09);
15
+ --sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
16
+ --serif:"Instrument Serif",Georgia,"Times New Roman",serif;
17
+ --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
18
+ }
19
+
20
+ * { box-sizing:border-box; }
21
+ html { background:var(--neutral-50); }
22
+ body {
23
+ min-width:320px;
24
+ min-height:100vh;
25
+ margin:0;
26
+ background:var(--neutral-50);
27
+ color:var(--ink);
28
+ font:16px/1.45 var(--sans);
29
+ -webkit-font-smoothing:antialiased;
30
+ }
31
+ button,textarea,input { font:inherit; }
32
+ a { color:inherit; }
33
+
34
+ .wrap {
35
+ display:flex;
36
+ flex-direction:column;
37
+ width:min(calc(100% - 32px),1240px);
38
+ min-height:100vh;
39
+ min-height:100dvh;
40
+ margin:0 auto;
41
+ border-right:1px solid var(--border);
42
+ border-left:1px solid var(--border);
43
+ background:var(--paper);
44
+ }
45
+
46
+ .site-intro { margin-top:auto; padding:20px 30px 16px; }
47
+ h1 {
48
+ margin:0 0 7px;
49
+ font:400 clamp(40px,4.3vw,51px)/.98 var(--serif);
50
+ letter-spacing:.002em;
51
+ }
52
+ h1 em { color:var(--purple); font-weight:400; font-style:italic; }
53
+ .sub { max-width:900px; margin:0; color:var(--neutral-500); font-size:15px; line-height:1.45; }
54
+ .sub strong { color:var(--neutral-700); font-weight:600; }
55
+
56
+ .model-strip {
57
+ display:grid;
58
+ grid-template-columns:48px 280px minmax(240px,1fr);
59
+ align-items:center;
60
+ column-gap:16px;
61
+ min-height:76px;
62
+ padding:12px 30px;
63
+ border-top:1px solid var(--border);
64
+ border-bottom:1px solid var(--border);
65
+ background:linear-gradient(90deg,rgba(108,79,224,.018),rgba(205,130,240,.035),rgba(108,79,224,.018));
66
+ transition:background .3s ease;
67
+ }
68
+ .model-logo-frame { position:relative; display:block; width:48px; height:48px; overflow:hidden; }
69
+ .model-logo {
70
+ position:absolute;
71
+ top:50%;
72
+ left:50%;
73
+ display:block;
74
+ width:56px;
75
+ height:auto;
76
+ max-width:none;
77
+ transform:translate(-50%,-50%);
78
+ image-rendering:pixelated;
79
+ }
80
+ .model-copy { display:flex; min-width:0; align-items:center; justify-content:center; }
81
+ .model-name-row { display:flex; min-height:25px; align-items:center; gap:10px; white-space:nowrap; }
82
+ .model-copy a { font:400 22px/1.15 var(--serif); text-decoration:none; }
83
+ .model-copy a:hover { color:var(--purple); }
84
+ .model-variant { display:inline-flex; height:18px; align-items:center; box-sizing:border-box; padding:0 7px; border:1px solid rgba(108,79,224,.14); border-radius:999px; background:rgba(108,79,224,.05); color:var(--purple); font:700 9px/1 var(--mono); letter-spacing:.035em; text-transform:uppercase; }
85
+ .load-block { min-width:0; }
86
+ .load-meta { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:8px; color:var(--neutral-500); font-size:12px; font-weight:600; }
87
+ #load-percent { font:600 11px/1 var(--mono); font-variant-numeric:tabular-nums; }
88
+ .load-track { height:7px; overflow:hidden; border-radius:999px; background:rgba(108,79,224,.09); }
89
+ .load-track span { display:block; width:0; height:100%; border-radius:inherit; background:linear-gradient(90deg,var(--purple),var(--lilac)); transition:width .18s ease; }
90
+ .is-ready .load-track { background:rgba(45,189,112,.09); }
91
+ .is-ready .load-track span { background:var(--green); }
92
+ .is-ready .load-meta { color:#188e50; }
93
+ .is-error .load-track span { background:var(--red); }
94
+ .is-error .load-meta { color:var(--red); }
95
+
96
+ main { display:flex; min-height:0; flex:0 0 auto; flex-direction:column; margin-bottom:auto; padding:18px 30px 22px; }
97
+ .spell-workspace { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); align-items:stretch; gap:18px; }
98
+ .workspace-card { position:relative; min-width:0; padding:20px; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:var(--paper); }
99
+ .input-panel,.output-panel { display:flex; flex-direction:column; }
100
+ .panel-header { display:flex; min-height:31px; align-items:center; justify-content:space-between; gap:16px; margin-bottom:12px; }
101
+ .panel-header h2 { margin:0; color:var(--neutral-500); font:600 12px/1.25 var(--mono); letter-spacing:.08em; text-transform:uppercase; }
102
+
103
+ .activity-pill { display:flex; align-items:center; gap:8px; flex:none; padding:7px 10px; border:1px solid var(--border); border-radius:999px; background:var(--neutral-50); color:var(--neutral-500); font-size:12px; font-weight:600; white-space:nowrap; transition:all .2s ease; }
104
+ .activity-dot { width:8px; height:8px; flex:none; border-radius:50%; background:var(--neutral-300); }
105
+ .activity-pill.is-running { border-color:rgba(108,79,224,.18); background:rgba(108,79,224,.055); color:var(--purple); }
106
+ .activity-pill.is-running .activity-dot { background:var(--purple); box-shadow:0 0 0 5px rgba(108,79,224,.11); animation:status-pulse .8s ease-in-out infinite; }
107
+ .activity-pill.is-done { border-color:rgba(45,189,112,.18); background:rgba(45,189,112,.055); color:#188e50; animation:done-pill .5s ease both; }
108
+ .activity-pill.is-done .activity-dot { background:var(--green); box-shadow:0 0 0 4px rgba(45,189,112,.1); }
109
+ .activity-pill.is-error { border-color:rgba(228,84,84,.2); background:rgba(228,84,84,.05); color:var(--red); }
110
+ .activity-pill.is-error .activity-dot { background:var(--red); }
111
+
112
+ .editor-stage { position:relative; min-height:clamp(220px,25dvh,292px); overflow:hidden; border:1px solid var(--border); border-radius:10px; background:var(--paper); transition:border-color .2s,box-shadow .2s,background .2s; }
113
+ .editor-stage:focus-within { border-color:var(--purple); box-shadow:0 0 0 3px rgba(108,79,224,.11); }
114
+ #input,.output-text { position:relative; z-index:1; width:100%; min-height:clamp(220px,25dvh,292px); margin:0; padding:16px 17px; border:0; outline:0; background:transparent; color:var(--ink); font:400 17px/1.62 var(--sans); white-space:pre-wrap; word-break:break-word; }
115
+ #input { display:block; resize:none; }
116
+ #input::placeholder { color:var(--neutral-300); }
117
+ .output-stage { background:var(--neutral-50); }
118
+ .output-text.empty::before { content:attr(data-placeholder); color:var(--neutral-300); }
119
+ .editor-scan { position:absolute; inset:-1px; z-index:3; opacity:0; pointer-events:none; background:linear-gradient(90deg,transparent 20%,rgba(108,79,224,.045) 40%,rgba(205,130,240,.24) 49%,rgba(255,255,255,.88) 54%,transparent 75%); transform:translateX(-110%); }
120
+ .is-checking .editor-stage { border-color:rgba(108,79,224,.28); box-shadow:0 0 0 3px rgba(108,79,224,.055); }
121
+ .is-checking .editor-scan { opacity:1; animation:editor-sweep 1.05s cubic-bezier(.45,0,.25,1) infinite; }
122
+ .is-checking .output-stage { background:linear-gradient(90deg,var(--neutral-50),rgba(108,79,224,.025),var(--neutral-50)); background-size:200% 100%; animation:output-wait 1.2s ease-in-out infinite; }
123
+ .is-complete .output-stage { animation:editor-complete .62s ease both; }
124
+
125
+ .input-meta { display:flex; justify-content:space-between; gap:14px; margin:8px 2px 0; color:var(--neutral-300); font:500 10px/1.3 var(--mono); }
126
+ .settings-menu { margin-top:auto; padding-top:12px; }
127
+ .settings-menu > summary { display:flex; align-items:center; justify-content:space-between; box-sizing:border-box; gap:14px; height:54px; min-height:54px; padding:0 13px; border:1px solid var(--border); border-radius:10px; background:var(--neutral-50); color:var(--neutral-500); font:600 10px/1.25 var(--mono); letter-spacing:.055em; text-transform:uppercase; cursor:pointer; list-style:none; transition:border-color .16s,background .16s,color .16s; }
128
+ .settings-menu > summary::-webkit-details-marker { display:none; }
129
+ .settings-menu > summary::after { content:"+"; flex:none; color:var(--purple); font:500 20px/1 var(--sans); transition:transform .2s ease; }
130
+ .settings-menu > summary:hover { border-color:rgba(108,79,224,.28); background:rgba(108,79,224,.025); color:var(--neutral-700); }
131
+ .settings-menu[open] > summary { border-radius:10px 10px 0 0; color:var(--neutral-700); }
132
+ .settings-menu[open] > summary::after { transform:rotate(45deg); }
133
+ .settings-reveal { overflow:hidden; }
134
+ .settings-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:8px; border:1px solid var(--border); border-top:0; border-radius:0 0 10px 10px; background:var(--neutral-50); }
135
+ .setting-card { display:block; min-width:0; padding:12px 13px 9px; border:1px solid var(--border); border-radius:10px; background:var(--neutral-50); }
136
+ .setting-copy { display:flex; align-items:baseline; justify-content:space-between; gap:10px; color:var(--neutral-500); font:600 10px/1.25 var(--mono); letter-spacing:.055em; text-transform:uppercase; }
137
+ .setting-copy output { color:var(--purple); font:700 12px/1 var(--mono); letter-spacing:0; font-variant-numeric:tabular-nums; }
138
+ input[type=range] { width:100%; height:24px; appearance:none; background:transparent; accent-color:var(--purple); --pct:0%; cursor:pointer; }
139
+ input[type=range]::-webkit-slider-runnable-track { height:7px; border-radius:999px; background:linear-gradient(90deg,var(--purple) 0 var(--pct),var(--neutral-200) var(--pct) 100%); }
140
+ input[type=range]::-webkit-slider-thumb { width:17px; height:17px; margin-top:-5px; appearance:none; border:4px solid var(--paper); border-radius:50%; background:var(--purple); box-shadow:0 1px 5px rgba(38,26,90,.32); }
141
+ input[type=range]::-moz-range-track { height:7px; border:0; border-radius:999px; background:var(--neutral-200); }
142
+ input[type=range]::-moz-range-progress { height:7px; border-radius:999px; background:var(--purple); }
143
+ input[type=range]::-moz-range-thumb { width:11px; height:11px; border:3px solid var(--paper); border-radius:50%; background:var(--purple); box-shadow:0 1px 5px rgba(38,26,90,.32); }
144
+ .setting-scale { display:flex; justify-content:space-between; color:var(--neutral-300); font-size:9px; line-height:1.2; }
145
+
146
+ .copy-button { display:inline-flex; align-items:center; justify-content:center; min-width:64px; padding:7px 11px; border:1px solid var(--border); border-radius:999px; background:var(--neutral-50); color:var(--neutral-500); font-size:12px; font-weight:600; cursor:pointer; transition:border-color .15s,color .15s,background .15s,transform .15s,box-shadow .15s; }
147
+ .copy-button:hover:not(:disabled) { border-color:rgba(108,79,224,.28); background:rgba(108,79,224,.045); color:var(--purple); transform:translateY(-1px); box-shadow:0 5px 12px rgba(54,42,105,.07); }
148
+ .copy-button:disabled { opacity:.45; cursor:default; }
149
+ .copy-button.copied { border-color:rgba(45,189,112,.2); background:rgba(45,189,112,.055); color:#188e50; animation:copy-pop .36s ease; }
150
+
151
+ mark { position:relative; z-index:0; display:inline-block; padding:0 .045em; background:transparent; color:inherit; isolation:isolate; }
152
+ mark::before { content:""; position:absolute; z-index:-1; top:.12em; right:-.08em; bottom:.08em; left:-.08em; border-radius:2px 5px 3px 2px; background:radial-gradient(ellipse at 18% 42%,rgba(255,255,255,.32) 0 8%,transparent 9% 100%),repeating-linear-gradient(176deg,rgba(205,130,240,.46) 0 2px,rgba(177,111,229,.38) 2px 5px,rgba(213,151,241,.48) 5px 8px); box-shadow:inset 0 1px rgba(255,255,255,.32); clip-path:polygon(0 8%,3% 2%,28% 6%,52% 1%,78% 5%,100% 0,98% 94%,72% 98%,42% 95%,17% 100%,1% 94%); mix-blend-mode:multiply; transform:scaleX(0) rotate(-.35deg); transform-origin:left center; animation:marker-stroke .62s cubic-bezier(.18,.72,.18,1) both; animation-delay:calc(var(--edit-index)*55ms); }
153
+ mark::after { content:""; position:absolute; z-index:-1; top:18%; bottom:12%; left:-12%; width:24%; opacity:0; background:linear-gradient(90deg,transparent,rgba(255,255,255,.72),transparent); filter:blur(2px); transform:skewX(-12deg); animation:marker-glint .62s ease-out both; animation-delay:calc(var(--edit-index)*55ms); }
154
+ .deleted { display:inline-block; padding:0 .08em; border-radius:4px; background:rgba(228,84,84,.075); color:#b83a3a; text-decoration:line-through; text-decoration-color:var(--red); text-decoration-thickness:2px; animation:deleted-in .42s cubic-bezier(.2,.8,.2,1) both; animation-delay:calc(var(--edit-index)*55ms); }
155
+
156
+ .legend { display:flex; align-items:center; justify-content:flex-end; gap:16px; min-height:31px; color:var(--neutral-500); font:500 10px/1 var(--mono); }
157
+ .legend span { display:inline-flex; align-items:center; gap:6px; }
158
+ .legend-swatch { width:12px; height:8px; border-radius:2px; }
159
+ .legend-swatch.modified { background:rgba(205,130,240,.42); }
160
+ .legend-swatch.removed { background:rgba(228,84,84,.11); border-bottom:1px solid var(--red); }
161
+ .stats-grid { display:grid; grid-template-columns:1.25fr repeat(3,1fr); gap:8px; margin-top:auto; }
162
+ .stat-card { min-width:0; height:54px; min-height:54px; padding:10px 12px; border:1px solid var(--border); border-radius:9px; background:var(--paper); }
163
+ .stat-card span { display:block; margin-bottom:5px; color:var(--neutral-500); font:600 9px/1 var(--mono); letter-spacing:.08em; text-transform:uppercase; }
164
+ .stat-card strong { display:block; overflow:hidden; color:var(--neutral-700); font:650 14px/1.25 var(--sans); text-overflow:ellipsis; white-space:nowrap; font-variant-numeric:tabular-nums; }
165
+ .result-slot { display:flex; min-width:0; height:54px; min-height:54px; align-items:stretch; }
166
+ .result-slot .activity-pill { width:100%; justify-content:center; border-radius:9px; }
167
+ .stats-grid.has-result .stat-card { animation:stat-rise .42s cubic-bezier(.2,.8,.2,1) both; }
168
+ .stats-grid.has-result .stat-card:nth-child(2) { animation-delay:35ms; }
169
+ .stats-grid.has-result .stat-card:nth-child(3) { animation-delay:70ms; }
170
+ .stats-grid.has-result .stat-card:nth-child(4) { animation-delay:105ms; }
171
+
172
+ .examples-panel { display:flex; align-items:center; gap:15px; margin-top:16px; padding:15px 17px; overflow:hidden; border:1px solid var(--border); border-radius:11px; background:var(--neutral-50); }
173
+ .examples-label { flex:none; color:var(--neutral-500); font:600 11px/1.3 var(--mono); letter-spacing:.07em; text-transform:uppercase; }
174
+ .example-list { display:flex; flex-wrap:wrap; gap:7px; min-width:0; padding-top:2px; }
175
+ .ex { display:inline-flex; align-items:center; padding:8px 12px; border:1px solid var(--border); border-radius:999px; background:var(--paper); color:var(--neutral-700); font-size:12px; line-height:1.15; cursor:pointer; transition:background .15s,border-color .15s,color .15s,transform .15s,box-shadow .15s; }
176
+ .ex:hover { border-color:rgba(108,79,224,.28); color:var(--purple); transform:translateY(-1px); box-shadow:0 5px 12px rgba(54,42,105,.08); }
177
+ .ex.active { border-color:var(--ink); background:var(--ink); color:var(--paper); box-shadow:none; }
178
+ .sr-only { position:absolute!important; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
179
+
180
+ @keyframes editor-sweep { 0% { transform:translateX(-110%); } 100% { transform:translateX(110%); } }
181
+ @keyframes output-wait { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } }
182
+ @keyframes status-pulse { 50% { transform:scale(.72); opacity:.55; } }
183
+ @keyframes done-pill { 0% { transform:scale(.95); } 55% { transform:scale(1.04); } 100% { transform:scale(1); } }
184
+ @keyframes editor-complete { 0% { box-shadow:0 0 0 0 rgba(45,189,112,.2); } 45% { border-color:rgba(45,189,112,.42); box-shadow:0 0 0 5px rgba(45,189,112,.09); } 100% { box-shadow:none; } }
185
+ @keyframes marker-stroke { 0% { opacity:0; transform:scaleX(0) rotate(-.35deg); } 14% { opacity:.85; } 76% { opacity:1; transform:scaleX(1.035) rotate(-.35deg); } 100% { opacity:.88; transform:scaleX(1) rotate(-.35deg); } }
186
+ @keyframes marker-glint { 0%,18% { left:-12%; opacity:0; } 34% { opacity:.68; } 100% { left:90%; opacity:0; } }
187
+ @keyframes deleted-in { from { opacity:0; transform:translateY(2px); } to { opacity:1; transform:translateY(0); } }
188
+ @keyframes stat-rise { from { opacity:.35; transform:translateY(5px); } to { opacity:1; transform:translateY(0); } }
189
+ @keyframes copy-pop { 0% { transform:scale(.94); } 55% { transform:scale(1.05); } 100% { transform:scale(1); } }
190
+
191
+ @media (max-width:980px) {
192
+ .spell-workspace { grid-template-columns:1fr; }
193
+ .editor-stage,#input,.output-text { min-height:210px; }
194
+ }
195
+ @media (max-width:720px) {
196
+ .wrap { width:100%; border:0; }
197
+ .site-intro { padding:20px; }
198
+ h1 { font-size:40px; }
199
+ .sub { font-size:14px; }
200
+ .model-strip { grid-template-columns:48px minmax(0,1fr); column-gap:16px; row-gap:10px; padding:12px 20px; }
201
+ .model-copy { min-width:0; }
202
+ .load-block { grid-column:1/-1; }
203
+ main { padding:15px 20px 20px; }
204
+ .examples-panel { align-items:flex-start; flex-direction:column; }
205
+ }
206
+ @media (max-width:520px) {
207
+ .workspace-card { padding:16px; }
208
+ .settings-grid,.stats-grid { grid-template-columns:1fr 1fr; }
209
+ .panel-header { align-items:flex-start; flex-direction:column; }
210
+ .output-heading { flex-direction:row; }
211
+ .input-meta { align-items:flex-start; flex-direction:column; gap:3px; }
212
+ }
213
+ @media (max-width:470px) {
214
+ .model-name-row { align-items:flex-start; flex-direction:column; gap:2px; }
215
+ }
216
+ @media (prefers-reduced-motion:reduce) {
217
+ *,*::before,*::after { scroll-behavior:auto!important; animation-duration:.01ms!important; animation-iteration-count:1!important; transition-duration:.01ms!important; }
218
+ }