Skip to content

Commit 77ce45f

Browse files
authored
Merge pull request #164 from interscript/playground
feat: /playground — the two-layer stack in the browser
2 parents 405a442 + 9bafb0f commit 77ce45f

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

e2e/playground.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Playground — the two-layer stack client-side: golden path runs a real
2+
// authority system through the worker (no model download in CI).
3+
import { expect, test } from "@playwright/test"
4+
5+
test("playground runs a deterministic system end-to-end", async ({ page }) => {
6+
await page.goto("/playground")
7+
await expect(page.getByRole("heading", { name: "Playground" })).toBeVisible()
8+
await page.fill("#input", "Антон Павлович Чехов")
9+
await page.fill("#filter", "bgnpcgn-rus")
10+
await page.selectOption("#system", { index: 0 })
11+
await page.click("#run")
12+
const out = page.locator("#output")
13+
await expect(out).not.toBeEmpty({ timeout: 30_000 })
14+
await expect(out).toContainText("Anton")
15+
// shareable URL carries the state
16+
await expect(page).toHaveURL(/sys=bgnpcgn-rus/)
17+
// the snippet mirrors the selected system
18+
await expect(page.locator("#snippet")).toContainText("transliterateAsync")
19+
})
20+
21+
test("filter narrows the system list", async ({ page }) => {
22+
await page.goto("/playground")
23+
await page.fill("#filter", "Thai")
24+
const first = await page.locator("#system option").first().textContent()
25+
expect(first).toBeTruthy()
26+
const count = await page.locator("#system option").count()
27+
expect(count).toBeLessThan(50)
28+
})

src/layouts/Base.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ const slug = (label: string) => label.toLowerCase().replace(/\s+/g, "-")
274274
<li><a href="/demo">Live transliteration</a></li>
275275
<li><a href="/maps">Map catalogue</a></li>
276276
<li><a href="/ml">Model catalogue</a></li>
277+
<li><a href="/playground">Playground</a></li>
277278
<li><a href="/neural">Neural demo</a></li>
278279
<li><a href="/authorities">Authorities</a></li>
279280
<li><a href="/docs">Documentation</a></li>

src/pages/playground.astro

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
---
2+
// Playground: the full two-layer stack in the browser — pick any of the
3+
// 289 authority systems, optionally vocalize Arabic through the neural
4+
// layer first, transliterate off-main-thread, share the state by URL,
5+
// and copy the exact TS that does the same thing.
6+
import Base from "../layouts/Base.astro"
7+
import catalogue from "../data/maps-catalogue.json"
8+
9+
const systems = Object.entries(catalogue as Record<string, { data: { name: string; language: string } }>)
10+
.map(([code, m]) => ({ code, name: m.data.name, lang: m.data.language }))
11+
.sort((a, b) => a.name.localeCompare(b.name))
12+
---
13+
14+
<Base title="Playground">
15+
<section class="page-narrow">
16+
<h1>Playground</h1>
17+
<p>
18+
The whole stack runs in your browser: 289 authority systems, plus the
19+
neural layer for the conversions no committee ever published. Nothing
20+
leaves the page.
21+
</p>
22+
23+
<div class="grid">
24+
<div class="pane">
25+
<label for="input">Input</label>
26+
<textarea id="input" rows="5" dir="auto">Антон Павлович Чехов</textarea>
27+
28+
<label for="vocalize">Neural vocalization (Arabic input)</label>
29+
<select id="vocalize">
30+
<option value="">Off — deterministic maps only</option>
31+
<option value="ara-diac-layerdrop-1.0-int4">Arabic lite (int4, 95 MB, downloads once)</option>
32+
<option value="ara-diac-small-2.1-int8">Arabic 2.1 (int8, 264 MB, downloads once)</option>
33+
</select>
34+
35+
<label for="filter">System</label>
36+
<input id="filter" type="search" placeholder="Filter 289 systems — try “BGN”, “Thai”, “ISO 9”…" />
37+
<select id="system"></select>
38+
39+
<button id="run" class="btn">Transliterate</button>
40+
<p id="status" class="muted" hidden></p>
41+
</div>
42+
43+
<div class="pane">
44+
<label for="vocalized">Vocalized (neural layer)</label>
45+
<output id="vocalized" dir="auto" hidden></output>
46+
47+
<label for="output">Output</label>
48+
<output id="output" dir="auto"></output>
49+
50+
<label for="snippet">The same thing in TypeScript</label>
51+
<pre><code id="snippet"></code></pre>
52+
<button id="copy-snippet" class="btn">Copy snippet</button>
53+
</div>
54+
</div>
55+
</section>
56+
</Base>
57+
58+
<style>
59+
.grid {
60+
display: grid;
61+
grid-template-columns: 1fr 1fr;
62+
gap: 1.25rem;
63+
margin-top: 1rem;
64+
}
65+
@media (max-width: 800px) {
66+
.grid { grid-template-columns: 1fr; }
67+
}
68+
.pane {
69+
display: flex;
70+
flex-direction: column;
71+
gap: 0.25rem;
72+
}
73+
label {
74+
font-weight: 600;
75+
margin-top: 0.5rem;
76+
}
77+
textarea,
78+
input,
79+
select {
80+
width: 100%;
81+
padding: 0.5rem;
82+
border-radius: 6px;
83+
border: 1px solid rgba(128, 128, 128, 0.4);
84+
font-size: 1rem;
85+
}
86+
output,
87+
#vocalized {
88+
display: block;
89+
min-height: 2.5rem;
90+
padding: 0.6rem;
91+
border: 1px solid rgba(128, 128, 128, 0.4);
92+
border-radius: 6px;
93+
font-size: 1.1rem;
94+
white-space: pre-wrap;
95+
}
96+
#vocalized { border-style: dashed; }
97+
pre {
98+
margin: 0.25rem 0;
99+
padding: 0.75rem;
100+
background: rgba(128, 128, 128, 0.12);
101+
border-radius: 6px;
102+
overflow-x: auto;
103+
font-size: 0.85rem;
104+
}
105+
.muted { color: #5c6470; font-size: 0.85rem; }
106+
.btn {
107+
margin-top: 0.75rem;
108+
max-width: max-content;
109+
}
110+
</style>
111+
112+
<script>
113+
import { createWorkerClient } from "../scripts/worker-client"
114+
import { imf } from "interscript/ml"
115+
import catalogue from "../data/maps-catalogue.json"
116+
117+
const ASSET_INDEX = "https://api.interscript.org/v1/assets/index-v5/models-index.yaml"
118+
119+
type Sys = { code: string; name: string; lang: string }
120+
const systems: Sys[] = Object.entries(
121+
catalogue as Record<string, { data: { name: string; language: string } }>,
122+
)
123+
.map(([code, m]) => ({ code, name: m.data.name, lang: m.data.language }))
124+
.sort((a, b) => a.name.localeCompare(b.name))
125+
126+
const $ = (id: string) => document.getElementById(id)!
127+
const worker = createWorkerClient()
128+
let mlModel: { translate(t: string, m?: number): Promise<string> } | null = null
129+
let mlModelId = ""
130+
131+
const systemSelect = $("system") as HTMLSelectElement
132+
const filterInput = $("filter") as HTMLInputElement
133+
134+
function renderOptions() {
135+
const q = filterInput.value.trim().toLowerCase()
136+
const hits = systems.filter(
137+
(s) => !q || s.name.toLowerCase().includes(q) || s.code.toLowerCase().includes(q),
138+
)
139+
systemSelect.innerHTML = ""
140+
for (const s of hits) {
141+
const opt = document.createElement("option")
142+
opt.value = s.code
143+
opt.textContent = `${s.name} (${s.code})`
144+
systemSelect.append(opt)
145+
}
146+
if (hits.length === 0) {
147+
const opt = document.createElement("option")
148+
opt.textContent = "no system matches the filter"
149+
systemSelect.append(opt)
150+
}
151+
}
152+
renderOptions()
153+
filterInput.addEventListener("input", renderOptions)
154+
155+
const params = new URLSearchParams(location.search)
156+
if (params.get("q")) ($("input") as HTMLTextAreaElement).value = params.get("q")!
157+
if (params.get("voc")) ($("vocalize") as HTMLSelectElement).value = params.get("voc")!
158+
renderOptions()
159+
if (params.get("sys")) systemSelect.value = params.get("sys")!
160+
if (!params.get("sys") && systemSelect.options.length) {
161+
const rus = systems.find((s) => s.code.includes("bgnpcgn-rus"))
162+
if (rus) systemSelect.value = rus.code
163+
}
164+
165+
function snippetText(): string {
166+
const code = systemSelect.value
167+
const voc = ($("vocalize") as HTMLSelectElement).value
168+
if (!voc) {
169+
return `import { transliterateAsync } from "interscript"\n\nconst out = await transliterateAsync(\n "${code}",\n "…",\n)`
170+
}
171+
return `import { transliterateAsync } from "interscript"\nimport { imf } from "interscript/ml"\n\nconst resolved = await imf.resolve("${voc}")\nconst model = await imf.IMFModel.fromZipBytes(resolved.bytes)\nconst vocalized = await model.translate("…")\nconst out = await transliterateAsync(\n "${code}",\n vocalized,\n)`
172+
}
173+
$("snippet").textContent = snippetText()
174+
systemSelect.addEventListener("change", () => ($("snippet").textContent = snippetText()))
175+
;($("vocalize") as HTMLSelectElement).addEventListener("change", () => {
176+
$("snippet").textContent = snippetText()
177+
})
178+
179+
$("copy-snippet").addEventListener("click", async () => {
180+
await navigator.clipboard.writeText(snippetText())
181+
$("copy-snippet").textContent = "Copied"
182+
setTimeout(() => ($("copy-snippet").textContent = "Copy snippet"), 1500)
183+
})
184+
185+
async function ensureModel(id: string) {
186+
if (mlModel && mlModelId === id) return mlModel
187+
const status = $("status")
188+
status.hidden = false
189+
status.textContent = `Downloading ${id} (once per browser)…`
190+
const resolved = await imf.resolve(id, ASSET_INDEX, {
191+
onProgress: (f: number, b: number) => {
192+
status.textContent = `${id}: ${(b / 1e6).toFixed(0)} MB (${Math.round(f * 100)}%)`
193+
},
194+
})
195+
const model = await imf.IMFModel.fromZipBytes(resolved.bytes)
196+
mlModel = model as unknown as { translate(t: string, m?: number): Promise<string> }
197+
mlModelId = id
198+
status.hidden = true
199+
return mlModel
200+
}
201+
202+
$("run").addEventListener("click", async () => {
203+
const out = $("output")
204+
const voc = $("vocalized")
205+
const status = $("status")
206+
out.textContent = ""
207+
voc.hidden = true
208+
status.hidden = true
209+
try {
210+
let text = ($("input") as HTMLTextAreaElement).value
211+
const modelId = ($("vocalize") as HTMLSelectElement).value
212+
if (modelId) {
213+
const model = await ensureModel(modelId)
214+
text = imf.normalizeArabicInput(text)
215+
text = await model.translate(text, 2048)
216+
voc.hidden = false
217+
voc.textContent = text
218+
}
219+
const result = await worker.transliterate(systemSelect.value, text)
220+
out.textContent = result
221+
const share = new URLSearchParams({
222+
q: ($("input") as HTMLTextAreaElement).value,
223+
sys: systemSelect.value,
224+
})
225+
if (modelId) share.set("voc", modelId)
226+
history.replaceState(null, "", `?${share}`)
227+
} catch (e) {
228+
out.textContent = `Error: ${(e as Error).message}`
229+
}
230+
})
231+
</script>

0 commit comments

Comments
 (0)