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();