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