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")); }