File size: 5,134 Bytes
2fe29a7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | 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();
|