| import { constraintsFor, getArtifact, getConstraint } from "./catalog.js"; |
| import { historyPreorder, MAX_VOTES_PER_OUTCOME } from "./domain.js"; |
|
|
| const button = (label, className, action, dataset = {}) => { |
| const element = document.createElement("button"); |
| element.type = "button"; |
| element.className = className; |
| element.textContent = label; |
| Object.assign(element.dataset, dataset); |
| element.addEventListener("click", action); |
| return element; |
| }; |
|
|
| const setFocusKey = (element, key) => { |
| element.dataset.focusKey = key; |
| return element; |
| }; |
|
|
| export function voteCountLabel(count) { |
| return `${count} ${count === 1 ? "vote" : "votes"}`; |
| } |
|
|
| export function historyNodeMetadata(node, artifactTitle, parentTitle, isLineage, selected) { |
| const status = node.status === "not-selected" |
| ? "Non-selected sibling" |
| : node.status[0].toUpperCase() + node.status.slice(1); |
| const labels = [ |
| node.depth === 0 ? "Seed origin" : `Round ${node.depth}`, |
| node.depth === 0 ? null : status, |
| isLineage ? "Active lineage" : null, |
| selected ? "Currently inspected" : null |
| ].filter(Boolean); |
| const relationship = node.depth === 0 ? "no parent" : `parent ${parentTitle}`; |
| return { |
| accessibleName: `${artifactTitle}, ${labels[0]}, ${relationship}${labels.slice(1).map((label) => `, ${label.toLowerCase()}`).join("")}`, |
| visibleText: labels.join(" · ") |
| }; |
| } |
|
|
| export function renderRound(container, state, source, actions) { |
| container.replaceChildren(); |
| const phase = document.createElement("p"); |
| phase.className = "phase-status"; |
| phase.textContent = state.phase === "choose" |
| ? "Current phase: choose a constraint. Next: reveal two fixed outcomes." |
| : state.phase === "compare" |
| ? "Current phase: compare both outcomes. Next: record at least one vote." |
| : state.phase === "vote" |
| ? "Current phase: operator records participant preferences. Next: resolve the vote." |
| : state.phase === "resolved" |
| ? "Current phase: decision recorded. Next: continue from the winner." |
| : "Current phase: branch complete. Next: explore any runnable history node."; |
| container.append(phase); |
| const kicker = document.createElement("p"); |
| kicker.className = "section-kicker"; |
| kicker.textContent = state.phase === "choose" ? "Shape the next fork" : `Round ${Object.keys(state.roundsById).length}`; |
| container.append(kicker); |
|
|
| if (state.phase === "choose") { |
| const heading = document.createElement("h2"); |
| heading.id = "round-heading"; |
| heading.tabIndex = -1; |
| heading.textContent = state.activeNodeId === state.seedNodeId ? "Choose a constraint" : "Continue the winning branch"; |
| container.append(heading); |
| const intro = document.createElement("p"); |
| intro.textContent = "Select one curated rule, then reveal its two fixed interpretations."; |
| container.append(intro); |
| const group = document.createElement("fieldset"); |
| const legend = document.createElement("legend"); |
| legend.textContent = "Available constraints"; |
| group.append(legend); |
| for (const constraint of constraintsFor(source, state.nodesById[state.activeNodeId].artifactId)) { |
| const label = document.createElement("label"); |
| label.className = "constraint-card"; |
| const radio = document.createElement("input"); |
| radio.type = "radio"; |
| radio.name = "constraint"; |
| radio.value = constraint.id; |
| setFocusKey(radio, `constraint-${constraint.id}`); |
| radio.checked = state.selectedConstraintId === constraint.id; |
| radio.addEventListener("change", () => actions.selectConstraint(constraint.id)); |
| const copy = document.createElement("span"); |
| const name = document.createElement("strong"); |
| name.textContent = constraint.title; |
| const detail = document.createElement("small"); |
| detail.textContent = constraint.description; |
| copy.append(name, detail); |
| label.append(radio, copy); |
| group.append(label); |
| } |
| container.append(group); |
| const reveal = button("Reveal outcomes", "primary-button", actions.reveal); |
| setFocusKey(reveal, "reveal-outcomes"); |
| reveal.disabled = !state.selectedConstraintId; |
| container.append(reveal); |
| return; |
| } |
|
|
| if (state.phase === "complete") { |
| const heading = document.createElement("h2"); |
| heading.id = "round-heading"; |
| heading.tabIndex = -1; |
| heading.textContent = "Branch complete"; |
| container.append(heading); |
| const end = document.createElement("p"); |
| end.className = "end-card"; |
| end.textContent = "This pre-authored branch ends here in the local MVP."; |
| container.append(end); |
| return; |
| } |
|
|
| const round = state.roundsById[state.currentRoundId]; |
| const recipe = source.recipes.find((item) => item.id === round.recipeId); |
| const constraint = getConstraint(source, recipe.constraintId); |
| const heading = document.createElement("h2"); |
| heading.id = state.phase === "resolved" ? "result-heading" : "comparison-heading"; |
| heading.tabIndex = -1; |
| heading.textContent = state.phase === "resolved" ? "The branch is decided" : `Compare: ${constraint.title}`; |
| container.append(heading); |
| const local = document.createElement("p"); |
| local.className = "context-disclosure"; |
| local.textContent = "These two outcomes were selected from a pre-authored local catalog for this artifact and constraint. The same choices always produce the same results."; |
| container.append(local); |
| const cards = document.createElement("div"); |
| cards.className = "outcome-grid"; |
| recipe.outcomes.forEach((outcome) => { |
| const artifact = getArtifact(source, outcome.artifactId); |
| const card = document.createElement("article"); |
| const selected = state.compareSelection === outcome.slot; |
| card.className = `outcome-card branch-${outcome.slot}${selected ? " is-selected" : ""}`; |
| const title = document.createElement("h3"); |
| title.textContent = `${outcome.slot.toUpperCase()} — ${artifact.title}`; |
| const summary = document.createElement("p"); |
| summary.textContent = artifact.summary; |
| const divergence = document.createElement("p"); |
| divergence.className = "divergence-note"; |
| const divergenceLabel = document.createElement("strong"); |
| divergenceLabel.textContent = "Meaningful split: "; |
| divergence.append(divergenceLabel); |
| divergence.append(outcome.divergenceNote); |
| const play = button(selected ? "Playing this outcome" : "Play this outcome", "compare-button", () => actions.compare(outcome.slot)); |
| setFocusKey(play, `compare-${outcome.slot}`); |
| play.setAttribute("aria-pressed", String(selected)); |
| if (selected) { |
| play.disabled = true; |
| } |
| card.append(title, summary, divergence, play); |
| cards.append(card); |
| }); |
| container.append(cards); |
|
|
| if (state.phase === "resolved") { |
| const winnerArtifact = getArtifact(source, state.nodesById[state.activeNodeId].artifactId); |
| const result = document.createElement("p"); |
| result.className = "result-card"; |
| result.textContent = `${round.winnerSlot.toUpperCase()} wins: ${winnerArtifact.title}. ${round.resolution === "authored-tie-rule" ? "The displayed authored tie rule decided this round." : "The higher recorded vote total decided this round."}`; |
| const continueButton = button("Continue from winner", "primary-button", actions.continue); |
| setFocusKey(continueButton, "continue-winner"); |
| container.append(result, continueButton); |
| return; |
| } |
|
|
| const voting = document.createElement("section"); |
| voting.className = "voting"; |
| voting.setAttribute("aria-labelledby", "voting-heading"); |
| const voteHeading = document.createElement("h3"); |
| voteHeading.id = "voting-heading"; |
| voteHeading.textContent = "Operator-recorded voting"; |
| const disclosure = document.createElement("p"); |
| disclosure.className = "vote-disclosure"; |
| disclosure.textContent = "Votes are entered by the local operator in this browser on this device. Participant devices are not connected."; |
| const tie = document.createElement("p"); |
| tie.textContent = `Tie rule, shown before resolution: outcome ${recipe.tieWinnerSlot.toUpperCase()} wins an equal total.`; |
| voting.append(voteHeading, disclosure, tie); |
| for (const slot of ["a", "b"]) { |
| const row = document.createElement("div"); |
| row.className = "vote-row"; |
| const name = document.createElement("strong"); |
| name.textContent = `Outcome ${slot.toUpperCase()}: ${voteCountLabel(round.votes[slot])}`; |
| const subtract = button("−", "vote-button", () => { |
| if (round.votes[slot] > 0) actions.vote(slot, -1); |
| }); |
| setFocusKey(subtract, `vote-${slot}-subtract`); |
| subtract.setAttribute("aria-label", `Remove one vote from outcome ${slot.toUpperCase()}`); |
| subtract.disabled = round.votes[slot] === 0; |
| const add = button("+", "vote-button", () => actions.vote(slot, 1)); |
| setFocusKey(add, `vote-${slot}-add`); |
| add.setAttribute("aria-label", `Add one vote to outcome ${slot.toUpperCase()}`); |
| add.disabled = round.votes[slot] >= MAX_VOTES_PER_OUTCOME; |
| row.append(name, subtract, add); |
| voting.append(row); |
| } |
| const resolve = button("Resolve vote", "primary-button", actions.resolve); |
| setFocusKey(resolve, "resolve-vote"); |
| resolve.disabled = state.phase !== "vote"; |
| voting.append(resolve); |
| container.append(voting); |
| } |
|
|
| export function renderHistory(container, state, source, inspect) { |
| const nodes = historyPreorder(state); |
| const activeLineage = new Set(); |
| for (let id = state.activeNodeId; id; id = state.nodesById[id].parentNodeId) activeLineage.add(id); |
| const children = new Map(); |
| for (const node of nodes) { |
| const key = node.parentNodeId ?? "root"; |
| children.set(key, [...(children.get(key) ?? []), node]); |
| } |
| const build = (parent) => { |
| const list = document.createElement("ul"); |
| for (const node of children.get(parent) ?? []) { |
| const item = document.createElement("li"); |
| const selected = state.inspectedNodeId === node.nodeId; |
| item.className = `history-node status-${node.status}${activeLineage.has(node.nodeId) ? " is-lineage" : ""}${selected ? " is-inspected" : ""}`; |
| const artifact = getArtifact(source, node.artifactId); |
| const parentTitle = node.parentNodeId ? getArtifact(source, state.nodesById[node.parentNodeId].artifactId).title : "none"; |
| const control = button(artifact.title, "history-button", () => inspect(node.nodeId)); |
| setFocusKey(control, `history-${node.nodeId}`); |
| const metadata = historyNodeMetadata(node, artifact.title, parentTitle, activeLineage.has(node.nodeId), selected); |
| control.setAttribute("aria-label", metadata.accessibleName); |
| if (selected) control.setAttribute("aria-current", "true"); |
| control.setAttribute("aria-pressed", String(selected)); |
| const row = document.createElement("div"); |
| row.className = "history-node-row"; |
| const meta = document.createElement("span"); |
| meta.className = "node-meta"; |
| meta.textContent = metadata.visibleText; |
| row.append(control, meta); |
| item.append(row); |
| if (children.has(node.nodeId)) item.append(build(node.nodeId)); |
| list.append(item); |
| } |
| return list; |
| }; |
| container.replaceChildren(build("root")); |
| } |
|
|