| import { catalog, getArtifact, validateCatalog } from "./catalog.js"; |
| import { |
| createSession, selectConstraint, revealOutcomes, selectComparison, |
| incrementVote, decrementVote, resolveVote, continueFromWinner, inspectNode |
| } from "./domain.js"; |
| import { createPersistence } from "./persistence.js"; |
| import { mountSignalGarden } from "./artifact.js"; |
| import { renderRound, renderHistory } from "./render.js"; |
| import { focusControl } from "./focus.js"; |
|
|
| const validation = validateCatalog(catalog); |
| if (!validation.valid) throw new Error(`Catalog validation failed: ${validation.errors.join(" ")}`); |
|
|
| const artifactView = document.querySelector("#artifact-view"); |
| const roundView = document.querySelector("#round-view"); |
| const historyView = document.querySelector("#history-view"); |
| const polite = document.querySelector("#polite-status"); |
| const assertive = document.querySelector("#assertive-status"); |
| const storageSummary = document.querySelector("#storage-summary"); |
| const resetSession = document.querySelector("#reset-session"); |
| const resetConfirmation = document.querySelector("#reset-confirmation"); |
| const cancelReset = document.querySelector("#cancel-reset"); |
| const confirmReset = document.querySelector("#confirm-reset"); |
| let browserStorage; |
| try { |
| browserStorage = window.localStorage; |
| } catch { |
| browserStorage = { |
| getItem() { throw new Error("Storage unavailable"); }, |
| setItem() { throw new Error("Storage unavailable"); }, |
| removeItem() { throw new Error("Storage unavailable"); } |
| }; |
| } |
| const persistence = createPersistence(browserStorage, catalog); |
| const loaded = persistence.load(); |
| let state = loaded.state ?? createSession(catalog); |
| let runtime = null; |
|
|
| if (loaded.notice) storageSummary.textContent = loaded.notice; |
| if (loaded.recovered) announce(loaded.notice, true); |
|
|
| function announce(message, urgent = false) { |
| const region = urgent ? assertive : polite; |
| region.textContent = ""; |
| requestAnimationFrame(() => { region.textContent = message; }); |
| } |
|
|
| function focusAfterRender(selector) { |
| requestAnimationFrame(() => document.querySelector(selector)?.focus()); |
| } |
|
|
| function focusedControlKey() { |
| const key = document.activeElement?.dataset.focusKey; |
| return typeof key === "string" ? key : null; |
| } |
|
|
| function restoreControlFocus(key, fallbackKey = null) { |
| if (!key) return; |
| requestAnimationFrame(() => focusControl(document, key, fallbackKey)); |
| } |
|
|
| function commit(next, message = "", preserveFocus = true, fallbackFocusKey = null) { |
| const focusKey = preserveFocus ? focusedControlKey() : null; |
| state = next; |
| const result = persistence.save(state); |
| storageSummary.textContent = result.notice; |
| render(); |
| restoreControlFocus(focusKey, fallbackFocusKey); |
| if (message) announce(message); |
| } |
|
|
| function render() { |
| runtime?.destroy(); |
| runtime = mountSignalGarden(artifactView, getArtifact(catalog, state.nodesById[state.inspectedNodeId].artifactId)); |
| renderRound(roundView, state, catalog, { |
| selectConstraint: (id) => commit(selectConstraint(state, catalog, id)), |
| reveal: () => { |
| commit(revealOutcomes(state, catalog), "Two pre-authored outcomes are ready to compare.", false); |
| focusAfterRender("#comparison-heading"); |
| }, |
| compare: (slot) => commit(selectComparison(state, slot), "", true, "artifact-heading"), |
| vote: (slot, change) => { |
| const next = change > 0 ? incrementVote(state, slot) : decrementVote(state, slot); |
| const total = next.roundsById[next.currentRoundId].votes[slot]; |
| const fallback = change < 0 && total === 0 ? `vote-${slot}-add` : null; |
| commit(next, `Outcome ${slot.toUpperCase()} now has ${total} votes.`, true, fallback); |
| }, |
| resolve: () => { |
| const next = resolveVote(state, catalog); |
| commit(next, `Outcome ${next.roundsById[next.currentRoundId].winnerSlot.toUpperCase()} won the round.`, false); |
| focusAfterRender("#result-heading"); |
| }, |
| continue: () => { |
| const next = continueFromWinner(state, catalog); |
| commit(next, next.phase === "complete" ? "This pre-authored branch is complete." : "", false); |
| focusAfterRender("#round-heading"); |
| } |
| }); |
| renderHistory(historyView, state, catalog, (nodeId) => commit(inspectNode(state, nodeId))); |
| } |
|
|
| function closeResetConfirmation({ restoreFocus = true } = {}) { |
| resetConfirmation.hidden = true; |
| resetSession.setAttribute("aria-expanded", "false"); |
| if (restoreFocus) resetSession.focus(); |
| } |
|
|
| resetSession.addEventListener("click", () => { |
| resetConfirmation.hidden = false; |
| resetSession.setAttribute("aria-expanded", "true"); |
| cancelReset.focus(); |
| }); |
| cancelReset.addEventListener("click", () => closeResetConfirmation()); |
| resetConfirmation.addEventListener("keydown", (event) => { |
| if (event.key !== "Escape") return; |
| event.preventDefault(); |
| closeResetConfirmation(); |
| }); |
| confirmReset.addEventListener("click", () => { |
| closeResetConfirmation({ restoreFocus: false }); |
| runtime?.destroy(); |
| const result = persistence.reset(); |
| state = result.state; |
| storageSummary.textContent = result.notice; |
| render(); |
| focusAfterRender("#play-heading"); |
| announce(result.notice); |
| }); |
|
|
| render(); |
|
|