diff --git a/demo/playground/index.html b/demo/playground/index.html new file mode 100644 index 0000000..aca4a7c --- /dev/null +++ b/demo/playground/index.html @@ -0,0 +1,92 @@ + + + + + + Guided Navigation Fixture Playground + + + + + +
+
+ +
+

1. Input HTML

+

+    
+ +
+
+

2. Guided Navigation document

+ +
+
+
+

Expected

+

+        
+
+

Actual

+

+        
+
+
+ +
+
+

3. Utterances

+ +
+ +
+
+ Format + + +
+ +
+ Language + +
+ +
+ + +
+ +
+ Skip roles + +
+
+ +
+
+

Expected

+

+        
+
+

Actual

+

+        
+
+
+
+ + + + diff --git a/demo/playground/script.js b/demo/playground/script.js new file mode 100644 index 0000000..7495b0f --- /dev/null +++ b/demo/playground/script.js @@ -0,0 +1,301 @@ +const filterInput = document.getElementById("filter-input"); +const fixtureListEl = document.getElementById("fixture-list"); +const fixtureMetaEl = document.getElementById("fixture-meta"); +const inputHtmlEl = document.getElementById("input-html"); +const gndExpectedEl = document.getElementById("gnd-expected"); +const gndActualEl = document.getElementById("gnd-actual"); +const gndBadgeEl = document.getElementById("gnd-badge"); +const utterancesExpectedEl = document.getElementById("utterances-expected"); +const utterancesActualEl = document.getElementById("utterances-actual"); +const utterancesBadgeEl = document.getElementById("utterances-badge"); +const optionSkipEl = document.getElementById("option-skip"); +const optionLanguageEl = document.getElementById("option-language"); +const optionInterruptEl = document.getElementById("option-interrupt"); +const optionContextualizeEl = document.getElementById("option-contextualize"); +const formatRadios = [...document.querySelectorAll('input[name="format"]')]; + +// Feature-detect the GND converter and the utterance extractor, if/when +// @readium/speech exports them. +let converter = null; +let utteranceExtractor = null; +try { + const mod = await import("../../build/index.js"); + if (typeof mod.parseMarkup === "function") { + converter = mod; + } + if (typeof mod.extractUtterances === "function") { + utteranceExtractor = mod; + } + if (Array.isArray(mod.skippableRoles)) { + for (const role of mod.skippableRoles) { + const option = document.createElement("option"); + option.value = role; + option.textContent = role; + optionSkipEl.appendChild(option); + } + optionSkipEl.addEventListener("change", () => renderUtterances()); + } +} catch (err) { + // build/index.js may not exist yet (run `npm run build`) or may not + // export these yet; logged rather than silently swallowed so a real + // failure here doesn't just look like an empty options list. + console.error("Failed to load @readium/speech build/index.js:", err); +} + +// The extraction options currently selected in the toolbar, beyond the +// required `format` — mirrors exactly how fixtures/*/utterances.json's +// `variants[].options` are shaped, so it can be compared against them +// directly (see `matchingExpected`). +function currentExtraOptions() { + const extra = {}; + const skip = optionSkipEl + ? [...optionSkipEl.selectedOptions].map((option) => option.value) + : []; + if (skip.length > 0) extra.skip = skip; + if (optionLanguageEl?.value) extra.language = optionLanguageEl.value; + if (optionInterruptEl?.checked) extra.interruptSentence = true; + if (optionContextualizeEl && !optionContextualizeEl.checked) extra.contextualize = false; + return extra; +} + +function currentFormat() { + return formatRadios.find((r) => r.checked)?.value ?? "plain"; +} + +// Finds the fixture's expected output for the exact combination of options +// currently selected: the branch's `base` when no extra options are set, or +// the `variants` entry whose `options` deep-equals the selection. Returns +// `undefined` when this fixture doesn't illustrate that combination. +function matchingExpected(utterances, format, extraOptions) { + const branch = utterances[format]; + if (!branch) return undefined; + if (Object.keys(extraOptions).length === 0) return branch.base; + const variant = (branch.variants ?? []).find((v) => deepEqual(v.options, extraOptions)); + return variant?.utterances; +} + +const manifest = await fetch("../../fixtures/manifest.json").then((r) => r.json()); +manifest.sort((a, b) => a.role.localeCompare(b.role) || a.description.localeCompare(b.description)); + +let selectedId = null; + +// Matches against what's actually shown to the user (role, description) — +// not the roles.md-internal "tier"/"pathway" bookkeeping, which isn't (and +// shouldn't be) displayed as a navigable concept anywhere in this UI. +function matchesFilter(fixture, filter) { + return [fixture.role, fixture.description].join(" ").toLowerCase().includes(filter); +} + +function renderList() { + const filter = filterInput.value.trim().toLowerCase(); + const filtering = filter.length > 0; + const filtered = filtering ? manifest.filter((f) => matchesFilter(f, filter)) : manifest; + + fixtureListEl.innerHTML = ""; + + if (filtered.length === 0) { + const empty = document.createElement("div"); + empty.className = "no-results"; + empty.textContent = "No fixtures match."; + fixtureListEl.appendChild(empty); + return; + } + + const byRole = new Map(); + for (const fixture of filtered) { + if (!byRole.has(fixture.role)) byRole.set(fixture.role, []); + byRole.get(fixture.role).push(fixture); + } + + let firstGroup = true; + for (const [role, fixtures] of byRole) { + const details = document.createElement("details"); + details.className = "role-group"; + // While filtering, force every matching group open so results are never + // hidden inside a collapsed section. Otherwise, only the first group + // (or the one containing the currently selected fixture) starts open. + details.open = filtering || firstGroup || fixtures.some((f) => f.id === selectedId); + firstGroup = false; + + const summary = document.createElement("summary"); + summary.textContent = role; + details.appendChild(summary); + + const ul = document.createElement("ul"); + for (const fixture of fixtures) { + const li = document.createElement("li"); + li.dataset.id = fixture.id; + li.className = fixture.id === selectedId ? "active" : ""; + li.textContent = fixture.description; + li.addEventListener("click", () => selectFixture(fixture.id)); + ul.appendChild(li); + } + details.appendChild(ul); + fixtureListEl.appendChild(details); + } + + if (!selectedId && filtered.length > 0) selectFixture(filtered[0].id); +} + +function deepEqual(a, b) { + return JSON.stringify(sortKeysDeep(a)) === JSON.stringify(sortKeysDeep(b)); +} + +function sortKeysDeep(value) { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (value && typeof value === "object") { + return Object.keys(value) + .sort() + .reduce((acc, key) => { + acc[key] = sortKeysDeep(value[key]); + return acc; + }, {}); + } + return value; +} + +// Property order per object.schema.json, for display only — fixtures are +// hand-authored and don't consistently follow it, and JSON key order carries +// no semantic meaning, but showing both panes in the schema's own order keeps +// the visual diff free of that noise. +const SCHEMA_KEY_ORDER = [ + "id", + "audioref", + "imgref", + "textref", + "videoref", + "text", + "role", + "children", + "description", +]; + +function withSchemaKeyOrder(value) { + if (Array.isArray(value)) return value.map(withSchemaKeyOrder); + if (value && typeof value === "object") { + const ordered = {}; + for (const key of SCHEMA_KEY_ORDER) { + if (key in value) ordered[key] = withSchemaKeyOrder(value[key]); + } + for (const key of Object.keys(value)) { + if (!(key in ordered)) ordered[key] = withSchemaKeyOrder(value[key]); + } + return ordered; + } + return value; +} + +// gnd.json stores a single fixture's expected top-level item(s) directly — +// a bare object for one item, or a role-less/id-less `{children: [...]}` for +// several siblings — while `parseMarkup()` always returns an array. This maps +// the stored file format onto that array shape for comparison. +function expectedTopLevel(gnd) { + if (gnd && typeof gnd === "object" && !Array.isArray(gnd)) { + const keys = Object.keys(gnd); + if (keys.length === 1 && keys[0] === "children") return gnd.children; + } + return [gnd]; +} + +// Inverse of expectedTopLevel: reshapes parseMarkup()'s array output into +// the same bare-object/`{children}` convention gnd.json is stored in, so +// the "actual" pane displays in the fixture's own reference shape. +function toStoredShape(items) { + return items.length === 1 ? items[0] : { children: items }; +} + +function setBadge(el, state) { + el.textContent = + state === "pass" ? "pass" : state === "fail" ? "fail" : state === "none" ? "no fixture data for this combination" : "not implemented yet"; + el.className = `badge ${state}`; +} + +// Cached across options-toolbar changes, so toggling an option re-runs +// extraction without refetching the fixture's files. +let currentFixture = null; + +function renderUtterances() { + if (!currentFixture) return; + const { gndActual, utterances } = currentFixture; + const format = currentFormat(); + const extraOptions = currentExtraOptions(); + const expected = matchingExpected(utterances, format, extraOptions); + + utterancesExpectedEl.textContent = + expected !== undefined ? JSON.stringify(expected, null, 2) : "(none — this fixture doesn't illustrate this combination of options)"; + + if (!utteranceExtractor || gndActual === undefined) { + utterancesActualEl.textContent = ""; + setBadge(utterancesBadgeEl, "pending"); + return; + } + + try { + const actualUtterances = utteranceExtractor.extractUtterances(gndActual, { format, ...extraOptions }); + utterancesActualEl.textContent = JSON.stringify(actualUtterances, null, 2); + setBadge( + utterancesBadgeEl, + expected === undefined ? "none" : deepEqual(actualUtterances, expected) ? "pass" : "fail", + ); + } catch (err) { + utterancesActualEl.textContent = String(err); + setBadge(utterancesBadgeEl, "fail"); + } +} + +async function selectFixture(id) { + selectedId = id; + for (const li of fixtureListEl.querySelectorAll("li[data-id]")) { + li.classList.toggle("active", li.dataset.id === id); + } + + const entry = manifest.find((f) => f.id === id); + const base = `../../fixtures/${entry.dir}/`; + + const [meta, inputHtml, gnd, utterances] = await Promise.all([ + fetch(base + "meta.json").then((r) => r.json()), + fetch(base + entry.files.input).then((r) => r.text()), + fetch(base + entry.files.gnd).then((r) => r.json()), + fetch(base + entry.files.utterances).then((r) => r.json()), + ]); + + fixtureMetaEl.replaceChildren(); + + const heading = document.createElement("h2"); + heading.textContent = meta.description; + fixtureMetaEl.appendChild(heading); + + const idLine = document.createElement("p"); + idLine.className = "fixture-id"; + idLine.textContent = meta.id; + fixtureMetaEl.appendChild(idLine); + + inputHtmlEl.textContent = inputHtml; + gndExpectedEl.textContent = JSON.stringify(withSchemaKeyOrder(gnd), null, 2); + + let gndActual; + if (!converter) { + gndActualEl.textContent = ""; + setBadge(gndBadgeEl, "pending"); + } else { + try { + gndActual = converter.parseMarkup(inputHtml); + gndActualEl.textContent = JSON.stringify(withSchemaKeyOrder(toStoredShape(gndActual)), null, 2); + setBadge(gndBadgeEl, deepEqual(gndActual, expectedTopLevel(gnd)) ? "pass" : "fail"); + } catch (err) { + gndActualEl.textContent = String(err); + setBadge(gndBadgeEl, "fail"); + } + } + + currentFixture = { gndActual, utterances }; + renderUtterances(); +} + +filterInput.addEventListener("input", renderList); +for (const radio of formatRadios) radio.addEventListener("change", renderUtterances); +optionLanguageEl?.addEventListener("change", renderUtterances); +optionInterruptEl?.addEventListener("change", renderUtterances); +optionContextualizeEl?.addEventListener("change", renderUtterances); + +renderList(); diff --git a/demo/playground/styles.css b/demo/playground/styles.css new file mode 100644 index 0000000..905990d --- /dev/null +++ b/demo/playground/styles.css @@ -0,0 +1,276 @@ +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color: #333; +} + +body { + display: flex; + align-items: stretch; +} + +/* ---- Sidebar: every fixture, one flat scrollable list, no gating step ---- */ + +.sidebar { + width: 320px; + flex-shrink: 0; + height: 100vh; + overflow-y: auto; + border-right: 1px solid #ddd; + background: #f7f7f8; + display: flex; + flex-direction: column; +} + +.sidebar-header { + position: sticky; + top: 0; + background: #f7f7f8; + padding: 14px 14px 10px; + border-bottom: 1px solid #ddd; +} + +.sidebar-header h1 { + font-size: 1.1em; + margin: 0 0 8px 0; +} + +#filter-input { + width: 100%; + padding: 6px 8px; + border: 1px solid #767676; + border-radius: 5px; + font-size: 0.9em; + color: #333; + background: #fff; +} + +.fixture-list { + padding: 6px 0; + flex: 1; +} + +.fixture-list .role-group { + border-bottom: 1px solid #eaeaea; +} + +.fixture-list .role-group summary { + cursor: pointer; + padding: 8px 14px; + font-size: 0.9em; + font-weight: 700; + color: #333; + list-style: none; + user-select: none; +} + +.fixture-list .role-group summary::-webkit-details-marker { + display: none; +} + +.fixture-list .role-group summary::before { + content: "▸"; + display: inline-block; + width: 1em; + color: #555; +} + +.fixture-list .role-group[open] summary::before { + content: "▾"; +} + +.fixture-list .role-group summary:hover { + background: #ececee; +} + +.fixture-list .role-group ul { + list-style: none; + margin: 0; + padding: 0 0 4px; +} + +.fixture-list li { + padding: 6px 14px 6px 26px; + cursor: pointer; + font-size: 0.88em; + line-height: 1.3; + border-left: 3px solid transparent; +} + +.fixture-list li:hover { + background: #ececee; +} + +.fixture-list li.active { + background: #e4e9fd; + border-left-color: #3a5cf5; + font-weight: 600; +} + +.fixture-list .no-results { + padding: 10px 14px; + color: #5a5a5a; + font-size: 0.88em; +} + +/* ---- Main content ---- */ + +.content { + flex: 1; + height: 100vh; + overflow-y: auto; + padding: 20px 28px; +} + +.fixture-meta { + margin-bottom: 20px; + font-size: 0.95em; + color: #444; +} + +.fixture-meta h2 { + margin: 0 0 4px 0; +} + +.fixture-meta .fixture-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8em; + color: #666; + margin: 8px 0 0 0; +} + +.pane { + margin-bottom: 24px; +} + +.pane h2 { + font-size: 1.05em; +} + +.pane-header { + display: flex; + align-items: center; + gap: 10px; +} + +.pane-header h2 { + margin: 0; +} + +.options-bar { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin: 10px 0 14px; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + background: #fafafa; +} + +.options-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + border: none; + margin: 0; + padding: 0; +} + +.options-group legend { + font-size: 0.78em; + font-weight: 600; + color: #666; + padding: 0 4px 0 0; +} + +.options-group label { + font-size: 0.85em; + white-space: nowrap; +} + +#option-skip { + /*