tag early.
var embedJson = JSON.stringify(embed).replace(/window.__ROUNDFLOW_OFFLINE__ = " + embedJson + ";<" + "/script>\n";
var out = html.slice(0, idx) + inject + html.slice(idx);
var blob = new Blob([out], { type: "text/html" });
var url = URL.createObjectURL(blob);
var rawName = state.title || (state.meta && state.meta.proTeam && state.meta.conTeam ? state.meta.proTeam + " vs " + state.meta.conTeam : "flow");
var fileName = rawName.trim().replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() + "-offline.html";
var a = document.createElement("a");
a.href = url;
a.download = fileName || "roundflow-offline.html";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
toast("Downloaded " + fileName + " — open it directly, even with no internet. When you're back online, use Copy flow here, then Import to bring your changes back.");
}).catch(function () {
toast("Couldn't build the offline file — check your connection and try again.");
});
}
function doImport() {
var root = document.getElementById("modal-root");
var veil = document.createElement("div");
veil.className = "modal-veil";
veil.innerHTML =
'
' +
"
Import
" +
"
Paste a full flow (replaces everything currently loaded) or a single shared sheet/case (adds it as a new sheet here).
" +
'' +
'' +
'
' +
'' +
'' +
"
" +
"
";
root.innerHTML = "";
root.appendChild(veil);
var ta = veil.querySelector("textarea");
var err = veil.querySelector(".modal-err");
veil.querySelector('[data-act="cancel"]').addEventListener("click", closeModal);
veil.querySelector('[data-act="load"]').addEventListener("click", function () {
try {
var parsed = JSON.parse(ta.value);
if (parsed && parsed.__flowlineSheet && parsed.page) {
if (!state || !state.setupDone) throw new Error("no-flow-yet");
var incoming = parsed.page;
incoming.id = uid("page");
if (incoming.type === "preflow") migratePreflowToCards(incoming, state.meta);
else { incoming.type = "flow"; migrateFlowPage(incoming); }
state.pages.push(incoming);
state.activePageId = incoming.id;
scheduleSave();
closeModal();
render();
toast(parsed.format && parsed.format !== state.meta.format
? "Sheet added — heads up, it was built for a different format, so some columns may not line up."
: "Sheet added: " + (incoming.title || "Untitled"));
return;
}
if (!parsed || !parsed.meta || !Array.isArray(parsed.pages)) throw new Error("shape");
parsed.id = uid("flow");
parsed.title = parsed.title || ((parsed.meta.proTeam && parsed.meta.conTeam) ? (parsed.meta.proTeam + " vs " + parsed.meta.conTeam) : "Imported flow");
parsed.tournamentId = null;
if (parsed.setupDone === undefined) parsed.setupDone = true;
migrateRoundOrFlow(parsed);
library.flows.push(parsed);
library.activeRef = { kind: "flow", flowId: parsed.id };
focused = null;
persistLibrary();
closeModal();
render();
toast("Flow imported as a new entry in your library.");
} catch (e) {
err.textContent = e && e.message === "no-flow-yet"
? "Set up a flow first, then import a sheet into it."
: "That doesn’t look like valid RoundFlow data — check you copied the whole block.";
}
});
setTimeout(function () { ta.focus(); }, 30);
}
/* ============ Card ops ============ */
function findCard(page, cardId) {
return page.cards.filter(function (c) { return c.id === cardId; })[0];
}
function addCard(colId, opts) {
var page = currentPage();
var card = newCard(colId, opts);
page.cards.push(card);
scheduleSave();
liveBroadcastCard(card);
return card;
}
function addCardFromMenu(colId, term) {
var page = currentPage();
if (!term) {
var card = addCard(colId, {});
render();
focusCardText(card.id);
return;
}
var parts = STRUCTURED_PARTS[term.tag];
var card = addCard(colId, { shellTag: term.tag });
if (parts) {
parts.forEach(function (part) {
var child = newCard(colId, { parentId: card.id, label: part });
page.cards.push(child);
liveBroadcastCard(child);
});
}
scheduleSave();
render();
if (parts) {
var firstChild = page.cards.filter(function (c) { return c.parentId === card.id; })[0];
if (firstChild) focusCardText(firstChild.id);
} else {
focusCardText(card.id);
}
}
function addSubpoint(cardId) {
var page = currentPage();
var parent = findCard(page, cardId);
if (!parent) return;
var child = newCard(parent.colId, { parentId: parent.id });
page.cards.push(child);
scheduleSave();
liveBroadcastCard(child);
render();
focusCardText(child.id);
}
function deleteCard(cardId) {
var page = currentPage();
var toRemove = { };
toRemove[cardId] = true;
page.cards.forEach(function (c) { if (c.parentId === cardId) toRemove[c.id] = true; });
page.cards = page.cards.filter(function (c) { return !toRemove[c.id]; });
page.links = (page.links || []).filter(function (l) { return !toRemove[l.fromId] && !toRemove[l.toId]; });
scheduleSave();
Object.keys(toRemove).forEach(function (id) { liveBroadcastDelete(id); });
render();
}
function updateCardText(cardId, value) {
var page = currentPage();
var card = findCard(page, cardId);
if (!card) return;
card.text = value;
scheduleSave();
liveBroadcastCardDebounced(cardId);
}
// Congress (singleQueue) speaker-name input — see renderCard(). No render()
// here, same reasoning as updateCardText(): re-rendering on every keystroke
// would steal focus out of the input while typing.
function updateCardLabel(cardId, value) {
var page = currentPage();
var card = findCard(page, cardId);
if (!card) return;
card.label = value;
scheduleSave();
liveBroadcastCardDebounced(cardId);
}
function setCardColor(cardId, color) {
var page = currentPage();
var card = findCard(page, cardId);
if (!card) return;
card.color = color;
scheduleSave();
liveBroadcastCard(card);
render();
}
// Added 2026-09-01 for the Evidence tab (§11 item 2 in the handoff brief,
// scoped with Bobby as "citation text + link, side by side"). Was a
// single-field promptText() for just the URL; now collects a short
// citation too (card.evidenceCite), since a card's own argument text
// isn't the same thing as a citation and openEvidenceModal() needs both.
function editCardEvidence(cardId) {
var page = currentPage();
var card = findCard(page, cardId);
if (!card) return;
var root = document.getElementById("modal-root");
var veil = document.createElement("div");
veil.className = "modal-veil";
var fieldStyle = "width:100%;padding:9px 11px;border-radius:7px;border:1px solid var(--rule-strong);background:var(--surface);color:var(--ink);font-size:14px;margin-bottom:10px;";
var labelStyle = "display:block;font-size:11px;color:var(--ink-dim);margin-bottom:4px;";
veil.innerHTML =
'
' +
"
Link evidence
" +
'
A short citation, plus a link to the cut card, doc, or source this argument comes from. Both show up together in Evidence (More ▾).
";
root.innerHTML = "";
root.appendChild(veil);
var citeInp = document.getElementById("evidence-cite-input");
var urlInp = document.getElementById("evidence-url-input");
veil.addEventListener("click", function (e) { if (e.target === veil) closeModal(); });
veil.querySelector('[data-act="cancel"]').addEventListener("click", closeModal);
var clearBtn = veil.querySelector('[data-act="clear"]');
if (clearBtn) clearBtn.addEventListener("click", function () {
card.evidenceCite = null;
card.evidenceUrl = null;
scheduleSave();
liveBroadcastCard(card);
closeModal();
render();
});
veil.querySelector('[data-act="ok"]').addEventListener("click", function () {
card.evidenceCite = citeInp.value.trim() || null;
card.evidenceUrl = urlInp.value.trim() || null;
scheduleSave();
liveBroadcastCard(card);
closeModal();
render();
});
setTimeout(function () { citeInp.focus(); citeInp.select(); }, 30);
}
// Added 2026-09-01 (§11 item 1 in the handoff brief): lets a card's
// shellTag (the term badge — Drop/Concede, Theory, etc.) be changed or
// removed after the fact, so picking the wrong one from a column's + menu
// no longer means deleting the card and starting over. Deliberately only
// touches shellTag — it never adds or removes the auto-generated
// sub-point children a *new* structured card gets (Theory/Topicality/
// Kritik/Counterplan/Disad), since a card being retagged may already have
// its own children (or none at all) and guessing which to add/remove
// would be more likely to surprise someone than help them.
function setCardShellTag(cardId, tag) {
var page = currentPage();
var card = findCard(page, cardId);
if (!card) return;
card.shellTag = tag || null;
openTypeMenu = null;
scheduleSave();
liveBroadcastCard(card);
render();
}
function renderTypeMenu(cardId) {
return '
";
}
// Congress-only (singleQueue formats): which side a speech card argues,
// changeable after the fact the same way a card's term badge is — this
// never touches the card's text/links/evidence/sub-points, only the label
// and the border/text color renderCard() derives from it.
function setCardQSide(cardId, side) {
var page = currentPage();
var card = findCard(page, cardId);
if (!card) return;
card.qSide = side || null;
openSideMenu = null;
scheduleSave();
liveBroadcastCard(card);
render();
}
function renderSideMenu(cardId) {
var sn = currentFormat().sideNames;
return '
";
root.innerHTML = "";
root.appendChild(veil);
veil.querySelector('[data-act="cancel"]').addEventListener("click", closeModal);
var removeBtn = veil.querySelector('[data-act="remove"]');
if (removeBtn) removeBtn.addEventListener("click", function () {
card.groupId = null;
scheduleSave(); closeModal(); render();
});
veil.querySelector('[data-act="save"]').addEventListener("click", function () {
var name = document.getElementById("bundle-name").value.trim();
var checked = Array.from(veil.querySelectorAll("[data-cand]:checked")).map(function (el) { return el.getAttribute("data-cand"); });
if (!name && !checked.length) { card.groupId = null; scheduleSave(); closeModal(); render(); return; }
var gid = card.groupId || uid("group");
page.groups = page.groups || [];
var g = page.groups.filter(function (g) { return g.id === gid; })[0];
if (!g) { g = { id: gid, label: name || "Bundle", color: card.color || null }; page.groups.push(g); }
else { g.label = name || g.label; }
card.groupId = gid;
others.forEach(function (c) { if (checked.indexOf(c.id) !== -1) c.groupId = gid; else if (c.groupId === gid) c.groupId = null; });
scheduleSave();
closeModal();
render();
});
}
/* ============ Page ops ============ */
function addPage(type) {
if (type === "preflow") {
var gating = currentGating();
if (gating.maxPreflowPages != null) {
var existingPreflows = state.pages.filter(function (p) { return (p.type || "flow") === "preflow"; }).length;
if (existingPreflows >= gating.maxPreflowPages) {
openUpgradeModal("Free accounts get " + gating.maxPreflowPages + " preflow sheet per flow — upgrade for unlimited.");
return;
}
}
}
var count = state.pages.filter(function (p) { return (p.type || "flow") === type; }).length + 1;
var title = type === "preflow" ? "Preflow " + count : "Sheet " + (state.pages.length + 1);
var p = newPage(title, type);
state.pages.push(p);
state.activePageId = p.id;
scheduleSave();
render();
}
function deletePage(id) {
if (state.pages.length <= 1) { toast("Keep at least one sheet."); return; }
confirmModal({
title: "Delete this sheet?", body: "This removes everything on this sheet. This can’t be undone.",
okLabel: "Delete sheet", danger: true,
onConfirm: function () {
state.pages = state.pages.filter(function (p) { return p.id !== id; });
if (state.activePageId === id) state.activePageId = state.pages[0].id;
scheduleSave();
render();
}
});
}
function renamePage(id, title) {
if (id === SHARED_PREFLOW_ID) {
var tour = currentTournament();
if (tour) { tour.preflow.title = title; scheduleSave(); }
return;
}
var p = state.pages.filter(function (pg) { return pg.id === id; })[0];
if (p) { p.title = title; scheduleSave(); }
}
function autosize(el) {
el.style.height = "auto";
el.style.height = (el.scrollHeight + 2) + "px";
scheduleRedrawLinks();
}
/* ============ Web-link geometry ============ */
function scheduleRedrawLinks() {
if (redrawScheduled) return;
redrawScheduled = true;
requestAnimationFrame(function () { redrawScheduled = false; redrawLinks(); });
}
function redrawLinks() {
var svg = document.querySelector(".web-svg");
var labelsWrap = document.querySelector(".web-labels");
var canvas = document.querySelector(".web-canvas");
if (!svg || !canvas || !labelsWrap) return;
var page = currentPage();
if (!page || !page.links || !page.links.length) {
svg.innerHTML = ""; labelsWrap.innerHTML = "";
return;
}
var canvasRect = canvas.getBoundingClientRect();
var defs = '' +
'';
var paths = "", labels = "";
page.links.forEach(function (link) {
var fromEl = document.querySelector('[data-card="' + link.fromId + '"]');
var toEl = document.querySelector('[data-card="' + link.toId + '"]');
if (!fromEl || !toEl) return;
var r1 = fromEl.getBoundingClientRect(), r2 = toEl.getBoundingClientRect();
var x1 = r1.left + r1.width / 2 - canvasRect.left, y1 = r1.top + r1.height / 2 - canvasRect.top;
var x2 = r2.left + r2.width / 2 - canvasRect.left, y2 = r2.top + r2.height / 2 - canvasRect.top;
var mx = (x1 + x2) / 2, my = (y1 + y2) / 2 - Math.min(40, Math.abs(x2 - x1) / 4 + 16);
paths += '';
labels += '
' +
'" +
'' +
"
";
});
svg.innerHTML = defs + paths;
labelsWrap.innerHTML = labels;
wireLinkLabels();
}
function wireLinkLabels() {
document.querySelectorAll("[data-jump-link]").forEach(function (btn) {
btn.addEventListener("click", function () {
var link = (currentPage().links || []).filter(function (l) { return l.id === btn.getAttribute("data-jump-link"); })[0];
if (!link) return;
var el = document.querySelector('[data-card="' + link.toId + '"]');
if (el) {
el.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" });
el.style.transition = "none"; el.style.boxShadow = "0 0 0 3px var(--accent)";
requestAnimationFrame(function () { el.style.transition = "box-shadow 900ms ease"; el.style.boxShadow = ""; });
}
});
});
document.querySelectorAll("[data-unlink]").forEach(function (btn) {
btn.addEventListener("click", function (e) { e.stopPropagation(); removeLink(btn.getAttribute("data-unlink")); });
});
}
/* ============ Setup screen ============ */
function openSetupNew(kind) {
setupDraft = { kind: kind, tournamentName: "", tabroomLink: "", meta: freshMeta() };
render();
}
function openAddRoundModal(tournamentId) {
var t = library.tournaments.filter(function (x) { return x.id === tournamentId; })[0];
if (!t) return;
var sn = formatFor(t.defaultMeta).sideNames;
var chosenSide = t.defaultMeta.mySide || "";
var root = document.getElementById("modal-root");
var veil = document.createElement("div");
veil.className = "modal-veil";
veil.innerHTML =
'
' +
"
Add a round
" +
"
" + escapeHtml(t.name || "This tournament") + " — this round inherits your team/topic setup, and shares the same Preflow as every other round. You can fine-tune it after.
" +
'' +
'' +
'' +
'' +
'
' +
'" +
'" +
'' +
"
" +
'' +
"
";
root.innerHTML = "";
root.appendChild(veil);
veil.querySelectorAll("[data-rside]").forEach(function (btn) {
btn.addEventListener("click", function () {
chosenSide = btn.getAttribute("data-rside");
veil.querySelectorAll("[data-rside]").forEach(function (b) { b.setAttribute("aria-pressed", b === btn ? "true" : "false"); });
});
});
veil.addEventListener("click", function (e) { if (e.target === veil) closeModal(); });
veil.querySelector('[data-act="cancel"]').addEventListener("click", closeModal);
veil.querySelector('[data-act="ok"]').addEventListener("click", function () {
var title = document.getElementById("round-title").value.trim() || ("Round " + (t.rounds.length + 1));
var judge = document.getElementById("round-judge").value.trim();
var meta = JSON.parse(JSON.stringify(t.defaultMeta));
meta.mySide = chosenSide;
meta.round = title;
meta.judge = judge;
meta.tabroomLink = document.getElementById("round-tabroom").value.trim();
meta.judgeLink = document.getElementById("round-judgelink").value.trim();
var round = { id: uid("round"), title: title, setupDone: true, tournamentId: t.id, meta: meta, pages: [], activePageId: null };
var mainPage = newPage("Main Flow", "flow");
round.pages.push(mainPage);
round.activePageId = mainPage.id;
t.rounds.push(round);
closeModal();
library.activeRef = { kind: "round", tournamentId: t.id, roundId: round.id };
persistLibrary();
render();
});
setTimeout(function () { document.getElementById("round-title").focus(); }, 30);
}
// Heuristic "smart paste" for pairing info copied from Tabroom (or any tab
// site, or an email) — there's no public Tabroom API to integrate against,
// so this looks for the same keywords most pairing postings share (Round,
// Judge, side labels, a URL, a resolution line) rather than assuming one
// exact template. It only fills fields it's confident about, and always
// leaves the result on-screen for the debater to review before saving.
function parsePastedPairing(text, existingMeta) {
text = String(text || "");
var values = {};
var filledLabels = [];
if (!text.trim()) return { any: false, values: values, filledLabels: filledLabels };
var roundMatch = text.match(/round\s*[:#]?\s*(\d+|octafinals?|octas?|quarterfinals?|quarters?|semifinals?|semis?|finals?|doubles?)/i);
if (roundMatch) {
var rNum = roundMatch[1];
values.round = /^\d+$/.test(rNum) ? ("Round " + rNum) : (rNum.charAt(0).toUpperCase() + rNum.slice(1).toLowerCase());
filledLabels.push("round");
}
var judgeMatch = text.match(/judges?\s*[:\-]\s*([^\n,;]{2,60})/i);
if (judgeMatch) {
values.judge = judgeMatch[1].trim();
filledLabels.push("judge");
}
var urlMatch = text.match(/https?:\/\/\S+/i);
if (urlMatch && !(existingMeta && existingMeta.tabroomLink)) {
values.tabroomLink = urlMatch[0].replace(/[),.;]+$/, "");
filledLabels.push("link");
}
var topicMatch = text.match(/(?:resolved|resolution|topic)\s*[:\-]\s*([^\n]{4,240})/i);
if (topicMatch) {
values.topic = (/^resolved/i.test(topicMatch[0]) ? "Resolved: " : "") + topicMatch[1].trim();
filledLabels.push("topic");
}
// Side labels vary by format/tab site (Aff/Neg, Pro/Con, Gov/Opp,
// Prop/Opp) — map whichever appears to this app's internal pro/con.
var SIDE_WORDS = {
aff: "pro", pro: "pro", gov: "pro", government: "pro", prop: "pro", proposition: "pro",
neg: "con", con: "con", opp: "con", opposition: "con"
};
Object.keys(SIDE_WORDS).forEach(function (word) {
// Stop the capture before "vs"/"v." or another side label butting up
// against this one on the same line (e.g. "Aff: Team A vs Neg: Team B"),
// not just at a comma/newline — pairings often have no comma there.
var re = new RegExp(
"\\b" + word + "\\b\\s*[:\\-]\\s*([^\\n,;]+?)(?=\\s+v(?:s\\.?|\\.)\\s+|\\s+(?:aff|neg|pro|con|gov|opp|prop)\\s*[:\\-]|[\\n,;]|$)",
"i"
);
var mm = text.match(re);
if (mm) {
var side = SIDE_WORDS[word];
var key = side === "pro" ? "proTeam" : "conTeam";
if (!values[key]) {
values[key] = mm[1].trim();
filledLabels.push(side === "pro" ? "your side's team" : "opponent team");
}
}
});
if (!values.proTeam && !values.conTeam) {
var vsMatch = text.match(/([A-Za-z][A-Za-z0-9 &'.\-]{1,40})\s+v(?:s\.?|\.)\s+([A-Za-z][A-Za-z0-9 &'.\-]{1,40})/i);
if (vsMatch) {
values.proTeam = vsMatch[1].trim();
values.conTeam = vsMatch[2].trim();
filledLabels.push("teams");
}
}
var uniqueLabels = filledLabels.filter(function (v, i) { return filledLabels.indexOf(v) === i; });
return { any: uniqueLabels.length > 0, values: values, filledLabels: uniqueLabels };
}
function renderSetup() {
var app = document.getElementById("app");
var draft = setupDraft;
var m = draft ? draft.meta : state.meta;
var formatKeys = Object.keys(FORMATS);
var sideNames = (FORMATS[m.format] || FORMATS.pf).sideNames;
var sideKeys = Object.keys(sideNames);
var colorsLocked = !currentGating().customColors;
var eyebrow = draft ? (draft.kind === "tournament" ? "New tournament" : "New flow") : "Edit setup";
var heading = draft && draft.kind === "tournament" ? "Set up your tournament" : "Set up your flow";
var subtext = draft && draft.kind === "tournament"
? "Pick a format and fill in who’s debating. Every round you add shares one Preflow you can prep once and reuse all day."
: "Pick a format and fill in who’s debating. You can edit any of this later — your arguments stay put.";
var html =
'
' +
sharedTab +
state.pages.map(function (p) {
var active = state.activePageId === p.id;
var isPreflow = p.type === "preflow";
return '
' +
(isPreflow ? 'Prep' : "") +
'' +
'' +
"
";
}).join("") +
'
' +
'' +
'
' +
'' +
(tour ? "" : '') +
"
" +
"
" +
"
";
var pickerBar = pendingLink
? '
Click the argument this ' + escapeHtml(pendingLink.tag) + ' connects to…
'
: "";
var bodyHtml = renderFlowBody(page);
var footer = '";
app.innerHTML = topbar + tabs + pickerBar + bodyHtml + footer;
wireThemeToggle("theme-toggle-app");
wireApp();
app.querySelectorAll("textarea").forEach(function (el) {
el.style.height = "auto";
el.style.height = (el.scrollHeight + 2) + "px";
});
scheduleRedrawLinks();
}
var openPopover = null; /* "colors" | null */
function renderColorsPopover() {
var m = state.meta;
var sn = currentFormat().sideNames;
var locked = !currentGating().customColors;
return '
' +
(locked ? '
Custom colors are a paid feature.
' : "") +
Object.keys(sn).map(function (k) {
var color = teamField(m, k, "color") || DEFAULT_SIDE_COLOR[k];
return '";
}).join("") +
"
";
}
function renderFlowBody(page) {
var fmt = currentFormat();
var cols = fmt.columns.map(function (c) {
// singleQueue formats (Congress): speeches aren't tied to a fixed side,
// so instead of the generic "+ Add" + term-picker menu, offer two
// direct-action buttons that tag the new card's qSide immediately —
// this is the "tagged Aff/Neg as you add them" behavior Bobby asked for.
var addUi = fmt.singleQueue
? '
"
: "") +
// Congress (singleQueue) cards are titled with the speaker's name
// instead of the structured-term label every other format uses card.label
// for — that field is otherwise unused here (Congress has no shellTag/
// structured parts), so it's reused rather than adding a new one.
(fmt.singleQueue
? ''
: (card.label ? '' + escapeHtml(card.label) + "" : "")) +
(card.evidenceUrl ? '🔗' : "") +
'" +
'