Skip to content
Read • Question • ConnectAnthony’s notes & researchReturn with a new question
Anthony’s NotesA personal field guide
From the notebook

Rhetoric Command Center

From understanding to expression

My Learning Process gives the general reading-to-explanation process. Use its short whole-response exercise before extending into formal speaking reps or sparring.

// Rhetoric Command Center
// Companion dashboard to the main Command Center for 30 Rhetoric: positions, reviews, reps, and the bridge to writing.
// Reads only, except when you click a button (each button says what it writes).
 
const { Notice, getAllTags } = require("obsidian");
 
const RC = {
  rhetoric: "30 Rhetoric/",
  hub: "30 Rhetoric/30 Rhetoric",
  cardsBase: "30 Rhetoric/Position Cards.base",
  repsHub: "30 Rhetoric/Speaking Reps",
  repsFolder: "30 Rhetoric/Reps",
  repTemplate: "20 Obsidian/Templates/Speaking Rep.md",
  commandCenter: "Command Center",
  writingPipeline: "80 Blog & Writing/Writing Pipeline",
  writingIdeas: "80 Blog & Writing/Writing Ideas",
  distillation: "60 Knowledge/Knowledge Distillation Pipeline (MOC)",
  tasksFolder: "20 Obsidian/TaskNotes/Tasks/",
  reviewDefault: 30,
  todayPool: 5,
  queueLimit: 6
};
 
const selfPath = dv.current().file.path;
const today = window.moment().startOf("day");
const todayStr = today.format("YYYY-MM-DD");
 
// ---------- helpers ----------
function make(parent, tag, cls, text) {
  const node = document.createElement(tag);
  if (cls) node.className = cls;
  if (text !== undefined && text !== null) node.textContent = text;
  parent.appendChild(node);
  return node;
}
function openTarget(target) { app.workspace.openLinkText(target, selfPath, false); }
function link(parent, label, target, cls) {
  const a = make(parent, "a", cls || "rc-row-title", label);
  a.href = target;
  a.addEventListener("click", (e) => { e.preventDefault(); openTarget(target); });
  return a;
}
function button(parent, label, title, handler, cls) {
  const b = make(parent, "button", "rc-btn" + (cls ? " " + cls : ""), label);
  b.title = title;
  b.addEventListener("click", async (e) => {
    e.preventDefault();
    b.disabled = true;
    try { await handler(); }
    catch (err) { console.error(err); new Notice("Rhetoric dashboard: " + (err && err.message ? err.message : err)); }
    finally { b.disabled = false; }
  });
  return b;
}
function panel(parent, icon, title, badge, target, extraCls) {
  const p = make(parent, "section", "cc-panel rc-panel" + (extraCls ? " " + extraCls : ""));
  const header = make(p, "div", "cc-panel-header");
  const wrap = make(header, "div", "cc-panel-title-wrap");
  make(wrap, "span", "cc-panel-icon rc-icon", icon);
  make(wrap, "div", "cc-panel-title", title);
  if (badge) {
    if (target) link(header, badge, target, "cc-badge rc-badge");
    else make(header, "span", "cc-badge rc-badge", badge);
  }
  return p;
}
function pill(parent, text, tone) { return make(parent, "span", "rc-pill rc-pill-" + tone, text); }
 
function fmOf(file) { return (app.metadataCache.getFileCache(file) || {}).frontmatter || {}; }
function tagsOf(file) {
  const cache = app.metadataCache.getFileCache(file);
  return cache ? (getAllTags(cache) || []).map((t) => t.replace(/^#/, "").toLowerCase()) : [];
}
function headingsOf(file) {
  return ((app.metadataCache.getFileCache(file) || {}).headings || []).map((h) => h.heading.trim().toLowerCase());
}
function parseDate(value) {
  if (!value) return null;
  const raw = String(Array.isArray(value) ? value[0] : value).trim();
  const iso = raw.match(/^(\d{4}-\d{2}-\d{2})/);
  if (iso) return window.moment(iso[1], "YYYY-MM-DD");
  const long = window.moment(raw, ["dddd, MMMM Do YYYY, h:mm:ss a", "dddd, MMMM Do YYYY"]);
  return long.isValid() ? long : null;
}
function daysSince(m) { return m ? today.diff(m.clone().startOf("day"), "days") : null; }
function ago(days) {
  if (days === null || days === undefined) return "never";
  if (days <= 0) return "today";
  if (days === 1) return "yesterday";
  return days + " days ago";
}
function linkTarget(value) {
  if (!value) return "";
  const raw = String(Array.isArray(value) ? value[0] : value);
  return raw.replace(/^\s*\[\[/, "").replace(/\]\]\s*$/, "").split("|")[0].split("#")[0].trim();
}
function resolve(value, from) {
  const target = linkTarget(value);
  return target ? app.metadataCache.getFirstLinkpathDest(target, from || selfPath) : null;
}
function wikilink(file, alias) { return "[[" + file.path.replace(/\.md$/, "") + "|" + (alias || file.basename) + "]]"; }
function sanitize(name) { return String(name).replace(/[\\/:*?"<>|#^\[\]]/g, "").replace(/\s+/g, " ").trim(); }
function uniquePath(folder, base) {
  let path = folder + base + ".md";
  let n = 2;
  while (app.vault.getAbstractFileByPath(path)) { path = folder + base + " " + n + ".md"; n++; }
  return path;
}
function norm(s) {
  return String(s || "").toLowerCase()
    .replace(/my assessment of/g, "").replace(/position card/g, "")
    .replace(/\bthe\b/g, " ").replace(/[^a-z0-9 ]/g, " ").replace(/\s+/g, " ").trim();
}
function cardTopic(file) {
  return file.basename
    .replace(/ - Position Card$/i, "")
    .replace(/^My Assessment of (the )?/i, "")
    .replace(/ - .*$/, "")
    .trim();
}
function taskTopic(file) {
  const fm = fmOf(file);
  const title = String(fm.title || file.basename.replace(/^Writing - /, ""));
  return title.split(/\s+[—–-]\s+/)[0].trim();
}
function topicsMatch(a, b) {
  const x = norm(a), y = norm(b);
  return !!x && !!y && (x === y || x.includes(y) || y.includes(x));
}
 
// ---------- data ----------
const allMd = app.vault.getMarkdownFiles();
const rhetoricFiles = allMd.filter((f) => f.path.startsWith(RC.rhetoric) && f.path !== selfPath);
const cardFiles = rhetoricFiles.filter((f) => tagsOf(f).includes("position-card") || / - Position Card$/i.test(f.basename));
 
const repFiles = allMd.filter((f) => {
  if (f.path.startsWith("20 Obsidian/Templates/")) return false;
  const fm = fmOf(f);
  const tags = tagsOf(f);
  if (String(fm.type || "").toLowerCase() === "speaking-rep") return true;
  return tags.includes("speaking-rep") && !tags.includes("moc");
});
const reps = repFiles.map((f) => {
  const fm = fmOf(f);
  const cardFile = resolve(fm.card, f.path);
  return { file: f, date: parseDate(fm.date) || window.moment(f.stat.ctime), cardPath: cardFile ? cardFile.path : "" };
});
 
const writingTasks = allMd.filter((f) =>
  f.path.startsWith(RC.tasksFolder) &&
  tagsOf(f).includes("command-center-writing") &&
  String(fmOf(f).status || "").toLowerCase() !== "done"
);
 
const cards = cardFiles.map((file) => {
  const fm = fmOf(file);
  const voiceMine = String(fm.voice || "").toLowerCase() === "mine";
  const started = headingsOf(file).includes("in my own words");
  const reviewed = parseDate(fm.last_reviewed) || parseDate(fm["date modified"]) || window.moment(file.stat.mtime);
  const every = Number(fm.review_every) || RC.reviewDefault;
  const age = daysSince(reviewed);
  const myReps = reps.filter((r) => r.cardPath === file.path).sort((a, b) => b.date - a.date);
  const lastRep = myReps.length ? myReps[0].date : parseDate(fm.last_rep);
  const explicitWriting = resolve(fm.writing, file.path);
  const writingTask = explicitWriting || writingTasks.find((t) => topicsMatch(cardTopic(file), taskTopic(t))) || null;
  const stage = String(fm.stage || (voiceMine ? "in-my-words" : "drafted")).toLowerCase();
  return {
    file, fm, voiceMine, started, reviewed, every, age,
    due: age !== null && age >= every,
    reviewedExplicit: !!fm.last_reviewed,
    lastRep, repCount: myReps.length, writingTask, stage,
    topic: cardTopic(file)
  };
});
 
const mineCount = cards.filter((c) => c.voiceMine).length;
const startedCount = cards.filter((c) => c.started && !c.voiceMine).length;
const dueCount = cards.filter((c) => c.due).length;
const reps30 = reps.filter((r) => daysSince(r.date) <= 30).length;
const lastRepAll = reps.length ? reps.slice().sort((a, b) => b.date - a.date)[0] : null;
const lastEdit = rhetoricFiles.slice().sort((a, b) => b.stat.mtime - a.stat.mtime)[0];
 
const resolvedLinks = app.metadataCache.resolvedLinks;
const rhetoricPaths = new Set(rhetoricFiles.map((f) => f.path));
const linkedFromRhetoric = new Set();
rhetoricFiles.forEach((f) => Object.keys(resolvedLinks[f.path] || {}).forEach((p) => linkedFromRhetoric.add(p)));
const distilled = allMd.filter((f) => !f.path.startsWith(RC.rhetoric) && headingsOf(f).includes("distilled knowledge"));
const unclaimed = distilled
  .filter((f) => !linkedFromRhetoric.has(f.path) && !Object.keys(resolvedLinks[f.path] || {}).some((p) => rhetoricPaths.has(p)))
  .sort((a, b) => b.stat.mtime - a.stat.mtime);
 
// ---------- actions (each writes only when clicked) ----------
const OWN_WORDS = [
  "## In My Own Words",
  "",
  "*Written by me, from memory, no copy-paste. Three to five sentences.*",
  "",
  "- **What I believe:** ",
  "- **My strongest reason:** ",
  "- **What would change my mind:** ",
  ""
];
 
async function writeInMyWords(card) {
  if (!card.started) {
    await app.vault.process(card.file, (text) => {
      if (/^##\s+In My Own Words\s*$/m.test(text)) return text;
      const lines = text.split("\n");
      let i = 0;
      if (lines[0] === "---") { i = 1; while (i < lines.length && lines[i] !== "---") i++; i++; }
      while (i < lines.length && lines[i].trim() === "") i++;
      if (i < lines.length && /^>\s*\[!nav\]/i.test(lines[i])) { while (i < lines.length && lines[i].startsWith(">")) i++; }
      lines.splice(i, 0, "", ...OWN_WORDS);
      return lines.join("\n");
    });
  }
  openTarget(card.file.path + "#In My Own Words");
}
 
async function markMine(card) {
  if (!card.started) { new Notice("Write your In My Own Words section first, then mark it as yours."); return; }
  await app.fileManager.processFrontMatter(card.file, (fm) => {
    fm.voice = "mine";
    if (!fm.stage || ["seed", "drafted"].includes(String(fm.stage).toLowerCase())) fm.stage = "in-my-words";
    fm.last_reviewed = todayStr;
    if (!fm.review_every) fm.review_every = RC.reviewDefault;
  });
  new Notice(card.file.basename + " marked as yours.");
}
 
async function markReviewed(card) {
  await app.fileManager.processFrontMatter(card.file, (fm) => {
    fm.last_reviewed = todayStr;
    if (!fm.review_every) fm.review_every = RC.reviewDefault;
  });
  new Notice(card.file.basename + " reviewed today.");
}
 
async function ensureFolder(path) {
  if (!app.vault.getAbstractFileByPath(path)) await app.vault.createFolder(path);
}
 
async function logRep(card) {
  const templater = app.plugins.plugins["templater-obsidian"];
  const template = app.vault.getAbstractFileByPath(RC.repTemplate);
  if (!templater || !template) { new Notice("Templater or the Speaking Rep template is unavailable."); return; }
  await ensureFolder(RC.repsFolder);
  const folder = app.vault.getAbstractFileByPath(RC.repsFolder);
  const name = sanitize("Rep " + todayStr + (card ? " - " + card.topic : ""));
  const created = await templater.templater.create_new_note_from_template(template, folder, name, true);
  if (created && card) {
    await app.fileManager.processFrontMatter(created, (fm) => {
      fm.card = wikilink(card.file);
      fm.topic = card.topic;
    });
  }
}
 
function positionSkeleton(opts) {
  const related = (opts.related || []).map((r) => '  - "' + r + '"').join("\n");
  return [
    "---",
    "up:",
    '  - "[[30 Rhetoric/30 Rhetoric|30 Rhetoric]]"',
    "related:" + (related ? "\n" + related : ""),
    "Genre:",
    "  - Rhetoric",
    "tags:",
    "  - position-card",
    "Category: Rhetoric",
    "type: position",
    "stage: seed",
    "voice: draft",
    "confidence:",
    "last_reviewed: " + todayStr,
    "review_every: " + RC.reviewDefault,
    "writing:" + (opts.writing ? ' "' + opts.writing + '"' : ""),
    "date created: " + todayStr,
    "date modified: " + todayStr,
    "---",
    "",
    "> [!nav] Hub: [[30 Rhetoric/30 Rhetoric|30 Rhetoric]] · Dashboard: [[" + selfPath.replace(/\.md$/, "") + "|Rhetoric Command Center]]" + (opts.navExtra ? " · " + opts.navExtra : ""),
    "",
    ...(opts.ownWords || OWN_WORDS),
    "## Thesis",
    "",
    "## Three Supports",
    "",
    "## Steelman",
    "",
    "## Reply Matrix",
    ""
  ].join("\n");
}
 
async function createAndOpen(path, content) {
  const file = await app.vault.create(path, content);
  openTarget(file.path);
  return file;
}
 
async function newPosition() {
  await createAndOpen(uniquePath(RC.rhetoric, "Untitled - Position Card"), positionSkeleton({}));
}
 
async function startPositionFromTask(task) {
  const topic = sanitize(taskTopic(task)) || "Untitled";
  const path = uniquePath(RC.rhetoric, topic + " - Position Card");
  await createAndOpen(path, positionSkeleton({
    related: [wikilink(task, "Writing idea")],
    writing: wikilink(task),
    navExtra: "Feeds: " + wikilink(task, "writing idea")
  }));
}
 
async function writeAssessment(source) {
  const base = sanitize("My Assessment of " + source.basename);
  const src = source.path.replace(/\.md$/, "");
  await createAndOpen(uniquePath(RC.rhetoric, base), positionSkeleton({
    related: ["[[" + src + "|" + source.basename + "]]"],
    navExtra: "Source: [[" + src + "#Distilled Knowledge|Distilled Knowledge]]",
    ownWords: [
      "## In My Own Words",
      "",
      "*Written by me, from memory, no copy-paste.*",
      "",
      "- **What the author gets right:** ",
      "- **Where I disagree:** ",
      "- **What I now believe:** ",
      "- **What would change my mind:** ",
      ""
    ]
  }));
}
 
async function promote(card) {
  const topic = sanitize(card.topic);
  const path = uniquePath(RC.tasksFolder, "Writing - " + topic);
  const content = [
    "---",
    "up:",
    '  - "[[Command Center|Command Center]]"',
    "related:",
    '  - "[[80 Blog & Writing/Writing Ideas|Writing Ideas]]"',
    '  - "' + wikilink(card.file, "Position") + '"',
    "tags:",
    '  - "task"',
    '  - "command-center-writing"',
    '  - "writing"',
    'title: "' + topic.replace(/"/g, "'") + '"',
    "status: open",
    "priority: normal",
    "contexts:",
    "  - writing",
    "projects:",
    '  - "[[80 Blog & Writing/Writing Ideas|Writing Ideas]]"',
    "writing_stage: Incubator",
    'dateCreated: "' + new Date().toISOString() + '"',
    'dateModified: "' + new Date().toISOString() + '"',
    "---",
    "",
    "## Outcome",
    "",
    "Turn the position in " + wikilink(card.file) + " into a public argument.",
    ""
  ].join("\n");
  const task = await app.vault.create(path, content);
  await app.fileManager.processFrontMatter(card.file, (fm) => { fm.writing = wikilink(task); });
  openTarget(task.path);
}
 
async function thesisOf(file) {
  const text = await app.vault.cachedRead(file);
  const t = text.match(/>\s*\[!\w+\][^\n]*THESIS[^\n]*\n>\s*(.+)/i);
  const own = text.match(/\*\*What I believe:\*\*\s*(.+)/);
  const notebook = text.match(/\[\[([^\]|]+\.pdf#page=\d+)\|([^\]]+)\]\]/);
  return {
    thesis: t ? t[1].replace(/\*\*/g, "").trim() : "",
    mine: own && own[1].trim() ? own[1].trim() : "",
    notebook: notebook ? { target: notebook[1], label: notebook[2] } : null
  };
}
 
// ---------- styles ----------
const STYLE_ID = "rc-dashboard-style";
const oldStyle = document.getElementById(STYLE_ID);
if (oldStyle) oldStyle.remove();
{
  const style = document.createElement("style");
  style.id = STYLE_ID;
  style.textContent = `
.command-center .rc-shell { gap: 16px; }
.command-center .rc-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
.command-center .rc-btn { appearance: none; background: var(--cc-card); border: 1px solid var(--cc-border); border-radius: 8px; box-shadow: none; color: var(--cc-text); cursor: pointer; font: inherit; font-size: 0.72rem; font-weight: 650; height: auto; line-height: 1.2; padding: 6px 10px; }
.command-center .rc-btn:hover { background: var(--cc-control-hover); border-color: var(--cc-border-strong); }
.command-center .rc-btn:disabled { cursor: wait; opacity: 0.6; }
.command-center .rc-btn.rc-primary { background: var(--cc-purple-soft); border-color: var(--cc-purple-border); color: var(--cc-purple-strong); }
.command-center .rc-btn.rc-small { font-size: 0.66rem; padding: 4px 8px; }
.command-center .rc-strip { background: var(--cc-amber-soft); border: 1px solid var(--cc-amber-border); border-radius: 10px; color: var(--cc-text); font-size: 0.76rem; line-height: 1.45; padding: 9px 13px; }
.command-center .rc-strip strong { color: var(--cc-amber-strong); }
.command-center .rc-stats { display: grid; gap: 12px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.command-center .rc-stat { background: var(--cc-card); border: 1px solid var(--cc-border); border-radius: 12px; box-shadow: var(--cc-shadow); padding: 12px 14px; }
.command-center .rc-stat-value { color: var(--cc-text); font-family: Georgia, "Times New Roman", serif; font-size: 1.65rem; font-weight: 650; line-height: 1; }
.command-center .rc-stat-value small { color: var(--cc-muted); font-size: 0.9rem; font-weight: 500; }
.command-center .rc-stat-label { color: var(--cc-muted); font-size: 0.7rem; margin-top: 6px; }
.command-center .rc-meter { background: var(--cc-slate-soft); border-radius: 999px; height: 6px; margin-top: 8px; overflow: hidden; }
.command-center .rc-meter > span { background: var(--cc-purple); display: block; height: 100%; }
.command-center .rc-icon { color: var(--cc-purple); }
.command-center .rc-badge { background: var(--cc-purple-soft); border-color: var(--cc-purple-border); color: var(--cc-purple-strong) !important; }
.command-center .rc-today-title { color: var(--cc-text); font-family: Georgia, "Times New Roman", serif; font-size: 1.28rem; font-weight: 650; }
.command-center .rc-quote { background: var(--cc-card-soft); border-left: 3px solid var(--cc-purple-border); border-radius: 0 8px 8px 0; color: var(--cc-text); font-size: 0.84rem; line-height: 1.45; margin: 10px 0 8px; padding: 8px 11px; }
.command-center .rc-quote-label { color: var(--cc-muted); display: block; font-size: 0.64rem; font-weight: 700; letter-spacing: 0.06em; margin-bottom: 3px; text-transform: uppercase; }
.command-center .rc-prompt { color: var(--cc-text); font-size: 0.82rem; line-height: 1.45; }
.command-center .rc-meta { color: var(--cc-muted); font-size: 0.72rem; margin-top: 4px; }
.command-center .rc-btnrow { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.command-center .rc-row { align-items: center; border-top: 1px solid var(--cc-row-border); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto; padding: 8px 0; }
.command-center .rc-row:first-of-type { border-top: 0; }
.command-center .rc-row-title { color: var(--cc-link); cursor: pointer; font-size: 0.83rem; font-weight: 600; text-decoration: none; }
.command-center .rc-row-title:hover { color: var(--cc-link-hover); }
.command-center .rc-row-detail { color: var(--cc-muted); display: block; font-size: 0.69rem; margin-top: 2px; }
.command-center .rc-table { display: grid; font-size: 0.76rem; grid-template-columns: minmax(0, 2.2fr) 0.9fr 1fr 1fr 0.9fr minmax(0, 1.3fr) auto; }
.command-center .rc-table > div { align-items: center; border-top: 1px solid var(--cc-row-border); display: flex; gap: 6px; min-width: 0; padding: 7px 8px 7px 0; }
.command-center .rc-table > .rc-th { border-top: 0; color: var(--cc-muted); font-size: 0.64rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.command-center .rc-table .rc-row-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.command-center .rc-pill { border: 1px solid transparent; border-radius: 999px; font-size: 0.64rem; font-weight: 650; padding: 2px 7px; white-space: nowrap; }
.command-center .rc-pill-green { background: var(--cc-green-soft); border-color: var(--cc-green-border); color: var(--cc-green-strong); }
.command-center .rc-pill-amber { background: var(--cc-amber-soft); border-color: var(--cc-amber-border); color: var(--cc-amber-strong); }
.command-center .rc-pill-purple { background: var(--cc-purple-soft); border-color: var(--cc-purple-border); color: var(--cc-purple-strong); }
.command-center .rc-pill-slate { background: var(--cc-slate-soft); border-color: var(--cc-slate-border); color: var(--cc-slate-strong); }
.command-center .rc-pill-blue { background: var(--cc-blue-soft); border-color: var(--cc-blue-border); color: var(--cc-blue-strong); }
.command-center .rc-muted { color: var(--cc-muted); }
.command-center .rc-footer { color: var(--cc-muted); display: flex; flex-wrap: wrap; font-size: 0.72rem; gap: 14px; justify-content: center; padding-top: 4px; }
.command-center .rc-footer a { color: var(--cc-link); text-decoration: none; }
@container (max-width: 820px) {
  .command-center .rc-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .command-center .rc-table { grid-template-columns: minmax(0, 1fr) auto auto; }
  .command-center .rc-table .rc-hide-sm { display: none; }
}
@container (max-width: 480px) {
  .command-center .rc-stats { grid-template-columns: minmax(0, 1fr); }
}
`;
  document.head.appendChild(style);
}
 
// ---------- render ----------
const shell = make(dv.container, "div", "cc-shell rc-shell");
 
// Top line
const top = make(shell, "div", "cc-topline");
const titleWrap = make(top, "div");
make(titleWrap, "div", "cc-date", today.format("dddd, MMMM D"));
make(titleWrap, "div", "cc-subtitle", "What you believe, in your own words, and can defend out loud.");
const actions = make(top, "div", "rc-actions");
button(actions, "+ New position", "Creates a new position card in 30 Rhetoric and opens it", newPosition, "rc-primary");
button(actions, "Log a rep", "Creates a Speaking Rep note in 30 Rhetoric/Reps from your template", () => logRep(null));
button(actions, "Position Cards", "Open Position Cards.base", async () => openTarget(RC.cardsBase));
 
const strip = make(shell, "div", "rc-strip");
strip.innerHTML = "<strong>How this page works.</strong> It only reads your vault. Buttons write only when clicked: " +
  "<em>Write in my words</em> adds an <em>In My Own Words</em> section to a card; <em>Mark as mine</em> and <em>Reviewed</em> set properties " +
  "(<code>voice</code>, <code>stage</code>, <code>last_reviewed</code>); <em>Promote</em>, <em>Start position</em>, and <em>Write my assessment</em> create new notes. " +
  "Cards without properties are treated as drafts and dated by their <code>date modified</code>.";
 
// Stats
const stats = make(shell, "div", "rc-stats");
function stat(value, suffix, label, meterPct) {
  const s = make(stats, "div", "rc-stat");
  const v = make(s, "div", "rc-stat-value", String(value));
  if (suffix) make(v, "small", null, suffix);
  make(s, "div", "rc-stat-label", label);
  if (meterPct !== undefined) {
    const m = make(s, "div", "rc-meter");
    make(m, "span").style.width = Math.round(meterPct) + "%";
  }
}
stat(mineCount, " / " + cards.length, "positions in your own voice" + (startedCount ? " · " + startedCount + " started" : ""), cards.length ? (mineCount / cards.length) * 100 : 0);
stat(dueCount, " due", "reviews due (every " + RC.reviewDefault + " days by default)");
stat(reps30, " reps", "speaking reps, last 30 days · last: " + (lastRepAll ? ago(daysSince(lastRepAll.date)) : "never"));
stat(lastEdit ? ago(daysSince(window.moment(lastEdit.stat.mtime))) : "none", "", "last edit in 30 Rhetoric" + (lastEdit ? " · " + lastEdit.basename : ""));
 
// Row 1: Today's position | What do you think?
const row1 = make(shell, "div", "cc-grid-two");
 
const todayPanel = panel(row1, "◎", "Today's Position", "Rotates daily", null);
const pool = cards.filter((c) => !c.voiceMine).sort((a, b) => b.age - a.age).slice(0, RC.todayPool);
const fallbackPool = cards.slice().sort((a, b) => (b.age - b.every) - (a.age - a.every)).slice(0, RC.todayPool);
const pick = (pool.length ? pool : fallbackPool)[today.dayOfYear() % Math.max(1, (pool.length ? pool : fallbackPool).length)];
if (pick) {
  const info = await thesisOf(pick.file);
  const head = make(todayPanel, "div");
  link(head, pick.file.basename, pick.file.path, "rc-today-title rc-row-title");
  make(todayPanel, "div", "rc-meta",
    (pick.voiceMine ? "In your voice" : pick.started ? "Draft · your section started" : "Draft · not yet in your words") +
    " · last reviewed " + ago(pick.age) + " · reps: " + pick.repCount);
  if (info.mine) {
    const q = make(todayPanel, "div", "rc-quote");
    make(q, "span", "rc-quote-label", "What you wrote");
    make(q, "span", null, info.mine);
  } else if (info.thesis) {
    const q = make(todayPanel, "div", "rc-quote");
    make(q, "span", "rc-quote-label", "Card thesis (draft)");
    make(q, "span", null, info.thesis);
  }
  make(todayPanel, "div", "rc-prompt", pick.voiceMine
    ? "Without opening the card: say your position out loud in two minutes, then log the rep."
    : "Without rereading the card: in three sentences, what do you believe, why, and what would change your mind?");
  if (info.notebook) {
    const nb = make(todayPanel, "div", "rc-meta");
    nb.appendText("Your notebook touches this: ");
    link(nb, info.notebook.label, info.notebook.target, "rc-row-title");
  }
  const btns = make(todayPanel, "div", "rc-btnrow");
  button(btns, pick.started ? "Continue in my words" : "Write in my words", "Adds an In My Own Words section if missing, then opens it", () => writeInMyWords(pick), "rc-primary");
  if (!pick.voiceMine) button(btns, "Mark as mine", "Sets voice: mine, stage: in-my-words, last_reviewed: today", () => markMine(pick));
  button(btns, "Reviewed today", "Sets last_reviewed: today", () => markReviewed(pick));
  button(btns, "Rep this card", "Creates a Speaking Rep linked to this card", () => logRep(pick));
} else {
  make(todayPanel, "div", "cc-empty", "No position cards found in 30 Rhetoric.");
}
 
const queuePanel = panel(row1, "?", "What Do You Think?", unclaimed.length + " of " + distilled.length + " distilled sources", RC.distillation);
make(queuePanel, "div", "rc-meta", "Distilled sources that no 30 Rhetoric note links to yet. Each one is a finished read waiting for your verdict.");
const queueList = make(queuePanel, "div");
if (unclaimed.length) {
  unclaimed.slice(0, RC.queueLimit).forEach((src) => {
    const row = make(queueList, "div", "rc-row");
    const left = make(row, "div");
    link(left, src.basename, src.path + "#Distilled Knowledge");
    const parts = src.path.split("/"); parts.pop();
    make(left, "span", "rc-row-detail", parts.slice(-2).join(" / ") + " · updated " + ago(daysSince(window.moment(src.stat.mtime))));
    button(row, "Write my assessment", "Creates 'My Assessment of " + src.basename + "' in 30 Rhetoric", () => writeAssessment(src), "rc-small");
  });
} else {
  make(queueList, "div", "cc-empty", "Every distilled source is linked to a position. Nice.");
}
 
// Row 2: all positions
const tablePanel = panel(shell, "▤", "Positions", cards.length + " cards", RC.cardsBase);
const table = make(tablePanel, "div", "rc-table");
["Position", "Voice", "Stage", "Reviewed", "Last rep", "Writing", ""].forEach((h, i) =>
  make(table, "div", "rc-th" + ([2, 3, 4, 5].includes(i) ? " rc-hide-sm" : ""), h));
cards
  .slice()
  .sort((a, b) => (a.voiceMine - b.voiceMine) || (Number(b.due) - Number(a.due)) || (b.age - a.age))
  .forEach((c) => {
    link(make(table, "div"), c.file.basename, c.file.path);
    const voice = make(table, "div");
    if (c.voiceMine) pill(voice, "mine", "green");
    else if (c.started) pill(voice, "started", "purple");
    else pill(voice, "draft", "amber");
    pill(make(table, "div", "rc-hide-sm"), c.stage, "slate");
    const rev = make(table, "div", "rc-hide-sm");
    if (c.due) pill(rev, ago(c.age), "amber"); else make(rev, "span", "rc-muted", ago(c.age));
    make(make(table, "div", "rc-hide-sm"), "span", "rc-muted", c.lastRep ? ago(daysSince(c.lastRep)) : "never");
    const w = make(table, "div", "rc-hide-sm");
    if (c.writingTask) link(w, c.writingTask.basename.replace(/^Writing - /, ""), c.writingTask.path);
    else make(w, "span", "rc-muted", "none");
    const act = make(table, "div");
    if (!c.started) button(act, "Write", "Add an In My Own Words section", () => writeInMyWords(c), "rc-small");
    else if (!c.voiceMine) button(act, "Mark mine", "Set voice: mine", () => markMine(c), "rc-small");
    else button(act, "Reviewed", "Set last_reviewed: today", () => markReviewed(c), "rc-small");
  });
 
// Row 3: Rhetoric -> Writing | Writing -> Rhetoric
const row3 = make(shell, "div", "cc-grid-two");
 
const promotePanel = panel(row3, "→", "Rhetoric to Writing", "Writing Pipeline", RC.writingPipeline);
make(promotePanel, "div", "rc-meta", "Positions in your own voice that don't feed a writing project yet.");
const promoteList = make(promotePanel, "div");
const ready = cards.filter((c) => c.voiceMine && !c.writingTask);
if (ready.length) {
  ready.forEach((c) => {
    const row = make(promoteList, "div", "rc-row");
    const left = make(row, "div");
    link(left, c.file.basename, c.file.path);
    make(left, "span", "rc-row-detail", "stage: " + c.stage + (["tested", "settled"].includes(c.stage) ? " · ready" : " · consider a rep before promoting"));
    button(row, "Promote to Writing Idea", "Creates a TaskNote tagged command-center-writing and links it from the card", () => promote(c), "rc-small");
  });
} else {
  make(promoteList, "div", "cc-empty",
    "Nothing to promote yet. A position lands here once it's marked as yours. Right now " + mineCount + " of " + cards.length + " are.");
}
 
const backPanel = panel(row3, "←", "Writing to Rhetoric", writingTasks.length + " writing ideas", RC.writingIdeas);
make(backPanel, "div", "rc-meta", "Your Command Center writing ideas, checked for a position behind them.");
const backList = make(backPanel, "div");
if (writingTasks.length) {
  writingTasks.forEach((t) => {
    const backing = cards.find((c) => c.writingTask && c.writingTask.path === t.path);
    const row = make(backList, "div", "rc-row");
    const left = make(row, "div");
    link(left, taskTopic(t), t.path);
    if (!backing) {
      make(left, "span", "rc-row-detail", "No position yet. Decide what you believe before drafting.");
      button(row, "Start position", "Creates a position card linked to this writing idea", () => startPositionFromTask(t), "rc-small");
    } else {
      const d = make(left, "span", "rc-row-detail", "Backed by " + backing.file.basename + " · ");
      d.appendText(backing.voiceMine ? "in your voice" : "not yet in your words");
      const right = make(row, "div");
      if (backing.voiceMine) pill(right, "ready", "green");
      else button(right, "Write in my words", "Open the backing card's In My Own Words section", () => writeInMyWords(backing), "rc-small");
    }
  });
} else {
  make(backList, "div", "cc-empty", "No open writing ideas tagged command-center-writing.");
}
 
// Footer
const footer = make(shell, "div", "rc-footer");
[
  ["Command Center", RC.commandCenter],
  ["30 Rhetoric", RC.hub],
  ["Position Cards", RC.cardsBase],
  ["Speaking Reps", RC.repsHub],
  ["Writing Pipeline", RC.writingPipeline],
  ["Knowledge Distillation", RC.distillation]
].forEach(([label, target]) => link(footer, label, target, ""));