diff --git a/.obsidian/plugins/webinar-dash/main.js b/.obsidian/plugins/webinar-dash/main.js index ecb01b1..5438ee1 100644 --- a/.obsidian/plugins/webinar-dash/main.js +++ b/.obsidian/plugins/webinar-dash/main.js @@ -1,629 +1,657 @@ -"use strict"; - -// Obsidian injects its own module resolver. Under plain `node --test` it is -// absent, so guard the require and fall back to an empty base class. This is -// what keeps the pure helpers below unit-testable without an Obsidian runtime. -let OB = null; -try { - OB = require("obsidian"); -} catch (_) { - OB = null; -} -const PluginBase = OB ? OB.Plugin : class {}; - -const STATIONS = ["Chat box", "ReAct", "Tools", "Memory", "Skills", "Process", "OS"]; - -const DEFAULTS = { - script: "raw/sources/Webinar script.md", - coverage: "wiki/script-coverage.md", - rawDir: "raw/sources", - wikiSourceDir: "wiki/sources", - conceptDir: "wiki/concepts", -}; - -const RAW_PATH_RE = /^\s*-\s*\*\*Raw path:\*\*\s*`([^`]+)`/m; - -function extractRawPath(text) { - const m = String(text).match(RAW_PATH_RE); - return m ? m[1].trim() : null; -} - -function derivePipeline({ rawFiles, sourcePages }) { - // First claim on a raw path wins. A later page claiming the same file is a - // duplicate claim — real catalog drift — and joins `orphaned` rather than - // being silently dropped. `orphaned` therefore means "source page not paired - // with a raw file", whatever the reason. - const claimed = new Map(); - const duplicates = []; - for (const page of sourcePages) { - if (!page.rawPath) continue; - if (claimed.has(page.rawPath)) duplicates.push(page); - else claimed.set(page.rawPath, page); - } - - const processed = []; - const unprocessed = []; - for (const file of rawFiles) { - const page = claimed.get(file.path); - if (page) processed.push(Object.assign({}, file, { page })); - else unprocessed.push(file); - } - - // One pass over sourcePages, so a page appears in `orphaned` at most once no - // matter how many of the three reasons apply to it. Concatenating a separate - // duplicates array here would double-count a losing claimant whose shared raw - // path is also missing from disk. - const rawPaths = new Set(rawFiles.map((f) => f.path)); - const duplicateSet = new Set(duplicates); - const orphaned = sourcePages.filter( - (p) => duplicateSet.has(p) || !p.rawPath || !rawPaths.has(p.rawPath) - ); - - unprocessed.sort((a, b) => a.name.localeCompare(b.name)); - processed.sort((a, b) => b.page.name.localeCompare(a.page.name)); - - return { processed, unprocessed, orphaned }; -} - -const VALID_STATUS = new Set(["covered", "partial", "absent"]); -const SEPARATOR_RE = /^\|?\s*:?-{2,}/; - -function parseCoverageTable(markdown) { - const text = String(markdown); - const meta = { script: null, lastSynced: null }; - - const scriptM = text.match(/^\s*-\s*\*\*Script:\*\*\s*`([^`]+)`/m); - if (scriptM) meta.script = scriptM[1].trim(); - const syncM = text.match(/^\s*-\s*\*\*Last synced:\*\*\s*(\S+)/m); - if (syncM) meta.lastSynced = syncM[1].trim(); - - const rows = []; - const errors = []; - let inTable = false; - - text.split(/\r?\n/).forEach((line, i) => { - const t = line.trim(); - if (!t.startsWith("|")) { - inTable = false; - return; - } - if (SEPARATOR_RE.test(t)) { - inTable = true; - return; - } - if (!inTable) return; - - const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1); - // Split on unescaped pipes only. Obsidian escapes the pipe of a piped - // wikilink inside a table cell as `\|`, and a plain split("|") tears - // `[[harness\|alias]]` into two cells, shifting every later column left. - const cells = body.split(/(? c.trim()); - - if (cells.length < 3) { - errors.push({ line: i + 1, text: t, reason: "expected at least 3 columns" }); - return; - } - - const status = cells[1].toLowerCase(); - if (!VALID_STATUS.has(status)) { - errors.push({ line: i + 1, text: t, reason: `invalid status "${cells[1]}"` }); - return; - } - - // Capture up to the first ] | or backslash. Obsidian escapes the pipe in a - // piped wikilink inside a table cell, so the raw cell reads [[name\|alias]] — - // excluding the backslash is what keeps the trailing "\" out of the name. - const linkM = cells[0].match(/\[\[([^\]|\\]+)/); - const stationCell = cells[2]; - - rows.push({ - concept: linkM ? linkM[1].trim() : cells[0], - status, - stations: - stationCell === "—" || stationCell === "-" || stationCell === "" - ? [] - : stationCell.split(",").map((s) => s.trim()).filter(Boolean), - pinned: (cells[3] || "").toLowerCase() === "yes", - line: i + 1, - }); - }); - - return { meta, rows, errors }; -} - -function groupByStation(rows) { - const order = ["All stations", ...STATIONS, "No station"]; - const buckets = new Map(order.map((k) => [k, []])); - - for (const row of rows) { - let key; - if (row.stations.includes("all")) key = "All stations"; - else if (row.stations.length === 0) key = "No station"; - else key = row.stations[0]; - if (!buckets.has(key)) buckets.set(key, []); - buckets.get(key).push(row); - } - - const known = order.filter((k) => buckets.get(k).length > 0); - const unknown = [...buckets.keys()].filter((k) => !order.includes(k) && buckets.get(k).length > 0); - return [...known, ...unknown].map((station) => ({ station, rows: buckets.get(station) })); -} - -function reconcileConcepts(rows, conceptNames) { - const named = new Set(conceptNames); - const rowed = new Set(rows.map((r) => r.concept)); - return { - rows, - unsynced: conceptNames.filter((n) => !rowed.has(n)).sort(), - stale: rows.filter((r) => !named.has(r.concept)), - }; -} - -// Defence in depth. The spawn path no longer uses a shell, so nothing here is -// load-bearing against injection today — but the guard costs nothing and would -// still hold if the single-string `shell: true` form is ever needed for a .cmd -// shim. `^` is cmd.exe's escape character; control characters are rejected -// because a NUL byte makes spawn() throw synchronously. -// -// Apostrophes, spaces, cyrillic, em dashes and `!` stay allowed — they appear in -// real filenames in this vault. -const UNSAFE_CHARS = /["`$&|;<>%^\u0000-\u001f\u007f]/; - -function isSafeFilename(name) { - if (typeof name !== "string" || name.length === 0) return false; - if (UNSAFE_CHARS.test(name)) return false; - if (name.includes("..")) return false; - return true; -} - -function parseConfig(source) { - const cfg = Object.assign({}, DEFAULTS); - for (const line of String(source).split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.+?)\s*$/); - if (m && Object.prototype.hasOwnProperty.call(DEFAULTS, m[1])) { - cfg[m[1]] = m[2]; - } - } - return cfg; -} - -// Source types CLAUDE.md documents for raw/sources. Anything else in that -// folder is not a source and stays out of the queue. -const RAW_EXTENSIONS = new Set(["md", "txt", "pdf"]); - -const ICONS = { - terminal: "M4 17l6-6-6-6M12 19h8", - check: "M20 6 9 17l-5-5", - minus: "M5 12h14", - x: "M18 6 6 18M6 6l12 12", - refresh: "M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.7 3L21 8M21 3v5h-5 M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.7-3L3 16M3 21v-5h5", -}; - -// Built with createElementNS rather than Obsidian's createSvg helper, whose -// availability varies by version. This works on any Obsidian build. -const SVG_NS = "http://www.w3.org/2000/svg"; - -function addIcon(parent, name) { - const svg = document.createElementNS(SVG_NS, "svg"); - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("fill", "none"); - svg.setAttribute("aria-hidden", "true"); - const path = document.createElementNS(SVG_NS, "path"); - path.setAttribute("d", ICONS[name]); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-width", "1.75"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - svg.appendChild(path); - parent.appendChild(svg); - return svg; -} - -function formatBytes(n) { - return `${n.toLocaleString("en-US")} B`; -} - -function renderLeftPane(container, pipeline, onIngest, gate) { - const pane = container.createDiv({ cls: "wd-pane" }); - - const queue = pane.createDiv({ cls: "wd-block" }); - queue.createDiv({ - cls: "wd-eyebrow", - text: `Queue — ${pipeline.unprocessed.length} unprocessed`, - }); - if (pipeline.unprocessed.length === 0) { - queue.createDiv({ cls: "wd-note", text: "Every raw source has a summary page." }); - } - for (const file of pipeline.unprocessed) { - const row = queue.createDiv({ cls: "wd-row" }); - const main = row.createDiv({ cls: "wd-row-main" }); - main.createDiv({ cls: "wd-row-name", text: file.name }); - main.createDiv({ cls: "wd-mono", text: formatBytes(file.size) }); - const btn = row.createEl("button", { cls: "wd-btn" }); - addIcon(btn, "terminal"); - btn.createSpan({ text: "Ingest" }); - if (gate && !gate.ok) { - btn.disabled = true; - btn.setAttr("title", gate.reason); - } else { - btn.addEventListener("click", () => onIngest(file, row)); - } - } - - const done = pane.createDiv({ cls: "wd-block" }); - done.createDiv({ cls: "wd-eyebrow", text: `Ingested — ${pipeline.processed.length}` }); - const list = done.createDiv({ cls: "wd-done" }); - for (const file of pipeline.processed) { - const row = list.createDiv({ cls: "wd-done-row" }); - const date = file.page.name.slice(0, 10); - row.createSpan({ cls: "wd-mono", text: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : "—" }); - row.createSpan({ cls: "wd-done-name", text: file.name.replace(/\.md$/, "") }); - } - - if (pipeline.orphaned.length > 0) { - const orphan = pane.createDiv({ cls: "wd-block" }); - orphan.createDiv({ - cls: "wd-eyebrow", - text: `Orphaned — ${pipeline.orphaned.length}`, - }); - for (const page of pipeline.orphaned) { - const row = orphan.createDiv({ cls: "wd-done-row" }); - row.createSpan({ cls: "wd-done-name", text: page.name }); - row.createSpan({ cls: "wd-mono", text: page.rawPath || "no raw path" }); - } - } - - return pane; -} - -const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" }; - -function renderRightPane(container, parsed, reconciled, cfg) { - const pane = container.createDiv({ cls: "wd-pane" }); - pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" }); - - const meta = parsed.meta || {}; - pane.createDiv({ - cls: "wd-mono", - text: `${meta.script || "no script recorded"} — last synced ${meta.lastSynced || "never"}`, - }); - if (cfg && cfg.script && meta.script && meta.script !== cfg.script) { - pane.createDiv({ - cls: "wd-error", - text: `This dashboard is configured for ${cfg.script}, but the coverage file tracks ${meta.script}.`, - }); - } - - const counts = { covered: 0, partial: 0, absent: 0 }; - for (const row of parsed.rows) counts[row.status] += 1; - const total = parsed.rows.length; - - if (total > 0) { - const meter = pane.createDiv({ cls: "wd-meter" }); - meter.setAttr("role", "img"); - meter.setAttr( - "aria-label", - `Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}` - ); - for (const key of ["covered", "partial", "absent"]) { - if (counts[key] === 0) continue; - const seg = meter.createEl("i", { cls: `wd-seg-${key}` }); - seg.style.flex = String(counts[key]); - } - const key = pane.createDiv({ cls: "wd-key" }); - for (const name of ["covered", "partial", "absent"]) { - const span = key.createSpan(); - span.createEl("i", { cls: `wd-seg-${name}` }); - span.createSpan({ text: `${counts[name]} ${name}` }); - } - } - - if (parsed.errors.length > 0) { - const box = pane.createDiv({ cls: "wd-error" }); - box.createDiv({ text: `${parsed.errors.length} unparseable row(s):` }); - for (const e of parsed.errors) { - box.createDiv({ cls: "wd-mono", text: `line ${e.line} — ${e.reason}` }); - } - } - - const table = pane.createEl("table", { cls: "wd-tbl" }); - const head = table.createEl("thead").createEl("tr"); - for (const h of ["Concept", "Status", "Station", ""]) head.createEl("th", { text: h }); - const body = table.createEl("tbody"); - - for (const group of groupByStation(parsed.rows)) { - const gr = body.createEl("tr", { cls: "wd-grp" }); - gr.createEl("td", { attr: { colspan: "4" }, text: `${group.station} — ${group.rows.length}` }); - for (const row of group.rows) { - const tr = body.createEl("tr"); - // Obsidian's click handler resolves internal links via data-href, so both - // attributes are required for the link to open the concept page. - tr.createEl("td").createEl("a", { - cls: "internal-link", - text: row.concept, - attr: { href: row.concept, "data-href": row.concept }, - }); - const stat = tr.createEl("td").createSpan({ cls: `wd-stat wd-${row.status}` }); - addIcon(stat, STATUS_ICON[row.status]); - stat.createSpan({ text: row.status }); - tr.createEl("td", { cls: "wd-mono", text: row.stations.join(", ") || "—" }); - tr.createEl("td", { cls: "wd-pin", text: row.pinned ? "pinned" : "" }); - } - } - - if (reconciled.unsynced.length > 0) { - const box = pane.createDiv({ cls: "wd-block" }); - box.createDiv({ cls: "wd-eyebrow", text: `Unsynced — ${reconciled.unsynced.length}` }); - box.createDiv({ - cls: "wd-note", - text: `Concept pages with no row. Run "sync script coverage": ${reconciled.unsynced.join(", ")}`, - }); - } - - if (reconciled.stale.length > 0) { - const box = pane.createDiv({ cls: "wd-block" }); - box.createDiv({ cls: "wd-eyebrow", text: `Stale — ${reconciled.stale.length}` }); - box.createDiv({ - cls: "wd-note", - text: `Rows whose concept page is gone: ${reconciled.stale.map((r) => r.concept).join(", ")}`, - }); - } - - return pane; -} - -class WebinarDashPlugin extends PluginBase { - async onload() { - this.running = new Set(); - this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => { - const cfg = parseConfig(source); - const root = el.createDiv({ cls: "webinar-dash" }); - await this.renderAll(root, cfg); - }); - } - - // Obsidian caps note content at --file-line-width when readable line length - // is on, which squeezes a two-pane dashboard into a column. Lift the cap on - // whichever ancestor carries it. - // - // Done here rather than in CSS on purpose: an inline style beats the app - // stylesheet without an !important arms race, and the computed-style walk - // means this keeps working even if Obsidian renames the sizer classes. The - // walk stops at the first capped ancestor and is bounded, so it cannot climb - // out into the workspace chrome and widen something it shouldn't. - widenHost(root) { - const known = root.closest(".markdown-preview-sizer, .cm-sizer"); - if (known) { - known.style.maxWidth = "none"; - return known; - } - let el = root.parentElement; - for (let hops = 0; el && hops < 6; hops += 1, el = el.parentElement) { - if (el.classList.contains("workspace-leaf")) break; - const max = window.getComputedStyle(el).maxWidth; - if (max && max !== "none") { - el.style.maxWidth = "none"; - return el; - } - } - return null; - } - - async renderAll(root, cfg) { - root.empty(); - this.widenHost(root); - const gate = this.spawnGate(); - const refresh = () => { this.renderAll(root, cfg); }; - - const bar = root.createDiv({ cls: "wd-bar" }); - const sync = bar.createEl("button", { cls: "wd-btn wd-btn-ghost" }); - addIcon(sync, "refresh"); - sync.createSpan({ text: "Sync coverage" }); - if (!gate.ok) { - sync.disabled = true; - sync.setAttr("title", gate.reason); - } else { - sync.addEventListener("click", () => - this.runClaude("__sync__", "sync script coverage", bar, "sync coverage", refresh) - ); - } - - const grid = root.createDiv({ cls: "wd-grid" }); - - try { - const pipeline = await this.readPipeline(cfg); - renderLeftPane(grid, pipeline, (file, rowEl) => this.runIngest(file, rowEl, refresh), gate); - } catch (err) { - grid.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` }); - } - - try { - const { parsed, reconciled } = await this.readCoverage(cfg); - renderRightPane(grid, parsed, reconciled, cfg); - } catch (err) { - grid.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` }); - } - } - - async readCoverage(cfg) { - const file = this.app.vault.getAbstractFileByPath(cfg.coverage); - if (!file) throw new Error(`no coverage file at ${cfg.coverage}`); - const parsed = parseCoverageTable(await this.app.vault.cachedRead(file)); - - const conceptDir = cfg.conceptDir.replace(/\/+$/, "") + "/"; - const conceptNames = this.app.vault - .getFiles() - .filter((f) => f.path.startsWith(conceptDir) && f.extension === "md") - .map((f) => f.basename); - - return { parsed, reconciled: reconcileConcepts(parsed.rows, conceptNames) }; - } - - async readPipeline(cfg) { - const all = this.app.vault.getFiles(); - const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/"; - const wikiDir = cfg.wikiSourceDir.replace(/\/+$/, "") + "/"; - - // CLAUDE.md documents raw/sources as holding "markdown/text/pdf exports". - // Allow-listing only "md" would hide the others from the queue with no - // warning — the same silent-invisibility failure this dashboard exists to - // remove. Wiki source pages below stay markdown-only; those really are .md. - const rawFiles = all - .filter((f) => f.path.startsWith(rawDir) && RAW_EXTENSIONS.has(f.extension)) - .map((f) => ({ path: f.path, name: f.name, size: f.stat.size })); - - const pageFiles = all.filter((f) => f.path.startsWith(wikiDir) && f.extension === "md"); - const sourcePages = []; - for (const f of pageFiles) { - const text = await this.app.vault.cachedRead(f); - sourcePages.push({ path: f.path, name: f.name, rawPath: extractRawPath(text) }); - } - - return derivePipeline({ rawFiles, sourcePages }); - } - - vaultPath() { - const adapter = this.app.vault.adapter; - if (typeof adapter.getBasePath === "function") return adapter.getBasePath(); - return null; - } - - spawnGate() { - if (!this.vaultPath()) { - return { ok: false, reason: "Needs desktop Obsidian." }; - } - try { - require("child_process"); - } catch (_) { - return { ok: false, reason: "child_process unavailable — needs desktop Obsidian." }; - } - return { ok: true, reason: "" }; - } - - // One subprocess runner for every button. `key` is what makes a run unique in - // `this.running` — a file path for ingest, a constant for sync — so the guard - // survives the row re-renders that a DOM-scoped guard could not. - runClaude(key, prompt, hostEl, label, onSuccess) { - const Notice = OB ? OB.Notice : null; - const notify = (msg) => { if (Notice) new Notice(msg); }; - - if (this.running.has(key)) { - notify(`Already running: ${label}.`); - return; - } - - const gate = this.spawnGate(); - if (!gate.ok) { - notify(gate.reason); - return; - } - const { spawn } = require("child_process"); - const base = this.vaultPath(); - - // A retry reuses the same host element. Clear the previous run's status and - // output so they are replaced rather than stacked. - hostEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); - - const button = hostEl.querySelector("button"); - if (button) button.disabled = true; - this.running.add(key); - - const status = hostEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" }); - const output = hostEl.createDiv({ cls: "wd-output", text: "" }); - const started = Date.now(); - const timer = window.setInterval(() => { - status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`); - }, 1000); - this.registerInterval(timer); - - // Bounded as it accumulates, not only when displayed. - let buffered = ""; - const append = (text) => { - buffered = (buffered + text).slice(-8000); - output.setText(buffered); - output.scrollTop = output.scrollHeight; - }; - - // Node can emit both `error` and `close` for one failure. `settled` keeps the - // first, more specific message instead of letting `exit null` overwrite it. - let settled = false; - const finish = (cls, text) => { - if (settled) return; - settled = true; - window.clearInterval(timer); - this.running.delete(key); - status.className = `wd-status ${cls}`; - status.setText(text); - if (button) button.disabled = false; - }; - - let child; - try { - // See the C1 comment in runIngest: no shell, deliberately. - child = spawn("claude", ["-p", prompt], { cwd: base }); - } catch (err) { - append(`\nCould not start claude: ${err.message}`); - finish("wd-status-failed", "failed"); - return; - } - - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", append); - child.stderr.on("data", append); - - child.on("error", (err) => { - append(`\nCould not start claude: ${err.message}\nIs it on PATH?`); - finish("wd-status-failed", "failed"); - }); - - child.on("close", (code) => { - const secs = Math.round((Date.now() - started) / 1000); - if (code === 0) { - finish("wd-status-done", `done in ${secs}s`); - if (onSuccess) onSuccess(); - } else { - finish("wd-status-failed", `failed - exit ${code}`); - } - }); - } - - runIngest(file, rowEl, onSuccess) { - const Notice = OB ? OB.Notice : null; - - if (!isSafeFilename(file.name)) { - rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); - rowEl.createDiv({ - cls: "wd-output", - text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`, - }); - return; - } - - // `claude` resolves to a real .exe here, so libuv finds it via PATH and - // PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell, - // Node concatenates argv without quoting and cmd.exe re-tokenizes, so the - // prompt arrives as two arguments and the filename is silently discarded. - // Verified: shell:true yields ["-p","ingest","Webinar script.md"], where the - // program sees only "ingest". If a .cmd shim ever needs supporting, use the - // single-string form spawn(`claude -p "ingest ${name}"`, { shell: true }). - this.runClaude( - file.path, - `ingest "${file.name}"`, - rowEl, - file.name, - () => { - if (Notice) new Notice(`Ingested ${file.name}.`); - if (onSuccess) onSuccess(); - } - ); - } -} - -module.exports = WebinarDashPlugin; -module.exports.default = WebinarDashPlugin; -module.exports.__test__ = { - parseConfig, extractRawPath, derivePipeline, - parseCoverageTable, groupByStation, reconcileConcepts, - STATIONS, DEFAULTS, isSafeFilename, -}; +"use strict"; + +// Obsidian injects its own module resolver. Under plain `node --test` it is +// absent, so guard the require and fall back to an empty base class. This is +// what keeps the pure helpers below unit-testable without an Obsidian runtime. +let OB = null; +try { + OB = require("obsidian"); +} catch (_) { + OB = null; +} +const PluginBase = OB ? OB.Plugin : class {}; + +const STATIONS = ["Chat box", "ReAct", "Tools", "Memory", "Skills", "Process", "OS"]; + +const DEFAULTS = { + script: "raw/sources/Webinar script.md", + coverage: "wiki/script-coverage.md", + rawDir: "raw/sources", + wikiSourceDir: "wiki/sources", + conceptDir: "wiki/concepts", +}; + +const RAW_PATH_RE = /^\s*-\s*\*\*Raw path:\*\*\s*`([^`]+)`/m; + +function extractRawPath(text) { + const m = String(text).match(RAW_PATH_RE); + return m ? m[1].trim() : null; +} + +function derivePipeline({ rawFiles, sourcePages }) { + // First claim on a raw path wins. A later page claiming the same file is a + // duplicate claim — real catalog drift — and joins `orphaned` rather than + // being silently dropped. `orphaned` therefore means "source page not paired + // with a raw file", whatever the reason. + const claimed = new Map(); + const duplicates = []; + for (const page of sourcePages) { + if (!page.rawPath) continue; + if (claimed.has(page.rawPath)) duplicates.push(page); + else claimed.set(page.rawPath, page); + } + + const processed = []; + const unprocessed = []; + for (const file of rawFiles) { + const page = claimed.get(file.path); + if (page) processed.push(Object.assign({}, file, { page })); + else unprocessed.push(file); + } + + // One pass over sourcePages, so a page appears in `orphaned` at most once no + // matter how many of the three reasons apply to it. Concatenating a separate + // duplicates array here would double-count a losing claimant whose shared raw + // path is also missing from disk. + const rawPaths = new Set(rawFiles.map((f) => f.path)); + const duplicateSet = new Set(duplicates); + const orphaned = sourcePages.filter( + (p) => duplicateSet.has(p) || !p.rawPath || !rawPaths.has(p.rawPath) + ); + + unprocessed.sort((a, b) => a.name.localeCompare(b.name)); + processed.sort((a, b) => b.page.name.localeCompare(a.page.name)); + + return { processed, unprocessed, orphaned }; +} + +const VALID_STATUS = new Set(["covered", "partial", "absent"]); +const SEPARATOR_RE = /^\|?\s*:?-{2,}/; + +function parseCoverageTable(markdown) { + const text = String(markdown); + const meta = { script: null, lastSynced: null }; + + const scriptM = text.match(/^\s*-\s*\*\*Script:\*\*\s*`([^`]+)`/m); + if (scriptM) meta.script = scriptM[1].trim(); + const syncM = text.match(/^\s*-\s*\*\*Last synced:\*\*\s*(\S+)/m); + if (syncM) meta.lastSynced = syncM[1].trim(); + + const rows = []; + const errors = []; + let inTable = false; + + text.split(/\r?\n/).forEach((line, i) => { + const t = line.trim(); + if (!t.startsWith("|")) { + inTable = false; + return; + } + if (SEPARATOR_RE.test(t)) { + inTable = true; + return; + } + if (!inTable) return; + + const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1); + // Split on unescaped pipes only. Obsidian escapes the pipe of a piped + // wikilink inside a table cell as `\|`, and a plain split("|") tears + // `[[harness\|alias]]` into two cells, shifting every later column left. + const cells = body.split(/(? c.trim()); + + if (cells.length < 3) { + errors.push({ line: i + 1, text: t, reason: "expected at least 3 columns" }); + return; + } + + const status = cells[1].toLowerCase(); + if (!VALID_STATUS.has(status)) { + errors.push({ line: i + 1, text: t, reason: `invalid status "${cells[1]}"` }); + return; + } + + // Capture up to the first ] | or backslash. Obsidian escapes the pipe in a + // piped wikilink inside a table cell, so the raw cell reads [[name\|alias]] — + // excluding the backslash is what keeps the trailing "\" out of the name. + const linkM = cells[0].match(/\[\[([^\]|\\]+)/); + const stationCell = cells[2]; + + rows.push({ + concept: linkM ? linkM[1].trim() : cells[0], + status, + stations: + stationCell === "—" || stationCell === "-" || stationCell === "" + ? [] + : stationCell.split(",").map((s) => s.trim()).filter(Boolean), + pinned: (cells[3] || "").toLowerCase() === "yes", + line: i + 1, + }); + }); + + return { meta, rows, errors }; +} + +function groupByStation(rows) { + const order = ["All stations", ...STATIONS, "No station"]; + const buckets = new Map(order.map((k) => [k, []])); + + for (const row of rows) { + let key; + if (row.stations.includes("all")) key = "All stations"; + else if (row.stations.length === 0) key = "No station"; + else key = row.stations[0]; + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key).push(row); + } + + const known = order.filter((k) => buckets.get(k).length > 0); + const unknown = [...buckets.keys()].filter((k) => !order.includes(k) && buckets.get(k).length > 0); + return [...known, ...unknown].map((station) => ({ station, rows: buckets.get(station) })); +} + +function reconcileConcepts(rows, conceptNames) { + const named = new Set(conceptNames); + const rowed = new Set(rows.map((r) => r.concept)); + return { + rows, + unsynced: conceptNames.filter((n) => !rowed.has(n)).sort(), + stale: rows.filter((r) => !named.has(r.concept)), + }; +} + +// Defence in depth. The spawn path no longer uses a shell, so nothing here is +// load-bearing against injection today — but the guard costs nothing and would +// still hold if the single-string `shell: true` form is ever needed for a .cmd +// shim. `^` is cmd.exe's escape character; control characters are rejected +// because a NUL byte makes spawn() throw synchronously. +// +// Apostrophes, spaces, cyrillic, em dashes and `!` stay allowed — they appear in +// real filenames in this vault. +const UNSAFE_CHARS = /["`$&|;<>%^\u0000-\u001f\u007f]/; + +function isSafeFilename(name) { + if (typeof name !== "string" || name.length === 0) return false; + if (UNSAFE_CHARS.test(name)) return false; + if (name.includes("..")) return false; + return true; +} + +function parseConfig(source) { + const cfg = Object.assign({}, DEFAULTS); + for (const line of String(source).split(/\r?\n/)) { + const m = line.match(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.+?)\s*$/); + if (m && Object.prototype.hasOwnProperty.call(DEFAULTS, m[1])) { + cfg[m[1]] = m[2]; + } + } + return cfg; +} + +// Source types CLAUDE.md documents for raw/sources. Anything else in that +// folder is not a source and stays out of the queue. +const RAW_EXTENSIONS = new Set(["md", "txt", "pdf"]); + +// How far up from the dashboard to look for the element carrying Obsidian's +// readable-line-width cap. Measured chains reach it in 3 hops; 8 is slack. +const MAX_WIDEN_HOPS = 8; + +// Given each ancestor's computed max-width, walking outward from the +// dashboard's parent, return the index of the first that actually carries a +// cap — or -1 if none does before the workspace chrome begins. +// +// Split out from the DOM walk so the decision can be tested against real +// measured chains. Class-name matching got this wrong twice: in live preview +// the cap sits on `.cm-content`, while `.cm-sizer` is already uncapped. +function firstCappedIndex(ancestors) { + for (let i = 0; i < ancestors.length && i < MAX_WIDEN_HOPS; i += 1) { + if (ancestors[i].isWorkspaceLeaf) return -1; + const max = ancestors[i].maxWidth; + if (max && max !== "none") return i; + } + return -1; +} + +const ICONS = { + terminal: "M4 17l6-6-6-6M12 19h8", + check: "M20 6 9 17l-5-5", + minus: "M5 12h14", + x: "M18 6 6 18M6 6l12 12", + refresh: "M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.7 3L21 8M21 3v5h-5 M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.7-3L3 16M3 21v-5h5", +}; + +// Built with createElementNS rather than Obsidian's createSvg helper, whose +// availability varies by version. This works on any Obsidian build. +const SVG_NS = "http://www.w3.org/2000/svg"; + +function addIcon(parent, name) { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("aria-hidden", "true"); + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", ICONS[name]); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", "1.75"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + parent.appendChild(svg); + return svg; +} + +function formatBytes(n) { + return `${n.toLocaleString("en-US")} B`; +} + +function renderLeftPane(container, pipeline, onIngest, gate) { + const pane = container.createDiv({ cls: "wd-pane" }); + + const queue = pane.createDiv({ cls: "wd-block" }); + queue.createDiv({ + cls: "wd-eyebrow", + text: `Queue — ${pipeline.unprocessed.length} unprocessed`, + }); + if (pipeline.unprocessed.length === 0) { + queue.createDiv({ cls: "wd-note", text: "Every raw source has a summary page." }); + } + for (const file of pipeline.unprocessed) { + const row = queue.createDiv({ cls: "wd-row" }); + const main = row.createDiv({ cls: "wd-row-main" }); + main.createDiv({ cls: "wd-row-name", text: file.name }); + main.createDiv({ cls: "wd-mono", text: formatBytes(file.size) }); + const btn = row.createEl("button", { cls: "wd-btn" }); + addIcon(btn, "terminal"); + btn.createSpan({ text: "Ingest" }); + if (gate && !gate.ok) { + btn.disabled = true; + btn.setAttr("title", gate.reason); + } else { + btn.addEventListener("click", () => onIngest(file, row)); + } + } + + const done = pane.createDiv({ cls: "wd-block" }); + done.createDiv({ cls: "wd-eyebrow", text: `Ingested — ${pipeline.processed.length}` }); + const list = done.createDiv({ cls: "wd-done" }); + for (const file of pipeline.processed) { + const row = list.createDiv({ cls: "wd-done-row" }); + const date = file.page.name.slice(0, 10); + row.createSpan({ cls: "wd-mono", text: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : "—" }); + row.createSpan({ cls: "wd-done-name", text: file.name.replace(/\.md$/, "") }); + } + + if (pipeline.orphaned.length > 0) { + const orphan = pane.createDiv({ cls: "wd-block" }); + orphan.createDiv({ + cls: "wd-eyebrow", + text: `Orphaned — ${pipeline.orphaned.length}`, + }); + for (const page of pipeline.orphaned) { + const row = orphan.createDiv({ cls: "wd-done-row" }); + row.createSpan({ cls: "wd-done-name", text: page.name }); + row.createSpan({ cls: "wd-mono", text: page.rawPath || "no raw path" }); + } + } + + return pane; +} + +const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" }; + +function renderRightPane(container, parsed, reconciled, cfg) { + const pane = container.createDiv({ cls: "wd-pane" }); + pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" }); + + const meta = parsed.meta || {}; + pane.createDiv({ + cls: "wd-mono", + text: `${meta.script || "no script recorded"} — last synced ${meta.lastSynced || "never"}`, + }); + if (cfg && cfg.script && meta.script && meta.script !== cfg.script) { + pane.createDiv({ + cls: "wd-error", + text: `This dashboard is configured for ${cfg.script}, but the coverage file tracks ${meta.script}.`, + }); + } + + const counts = { covered: 0, partial: 0, absent: 0 }; + for (const row of parsed.rows) counts[row.status] += 1; + const total = parsed.rows.length; + + if (total > 0) { + const meter = pane.createDiv({ cls: "wd-meter" }); + meter.setAttr("role", "img"); + meter.setAttr( + "aria-label", + `Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}` + ); + for (const key of ["covered", "partial", "absent"]) { + if (counts[key] === 0) continue; + const seg = meter.createEl("i", { cls: `wd-seg-${key}` }); + seg.style.flex = String(counts[key]); + } + const key = pane.createDiv({ cls: "wd-key" }); + for (const name of ["covered", "partial", "absent"]) { + const span = key.createSpan(); + span.createEl("i", { cls: `wd-seg-${name}` }); + span.createSpan({ text: `${counts[name]} ${name}` }); + } + } + + if (parsed.errors.length > 0) { + const box = pane.createDiv({ cls: "wd-error" }); + box.createDiv({ text: `${parsed.errors.length} unparseable row(s):` }); + for (const e of parsed.errors) { + box.createDiv({ cls: "wd-mono", text: `line ${e.line} — ${e.reason}` }); + } + } + + const table = pane.createEl("table", { cls: "wd-tbl" }); + const head = table.createEl("thead").createEl("tr"); + for (const h of ["Concept", "Status", "Station", ""]) head.createEl("th", { text: h }); + const body = table.createEl("tbody"); + + for (const group of groupByStation(parsed.rows)) { + const gr = body.createEl("tr", { cls: "wd-grp" }); + gr.createEl("td", { attr: { colspan: "4" }, text: `${group.station} — ${group.rows.length}` }); + for (const row of group.rows) { + const tr = body.createEl("tr"); + // Obsidian's click handler resolves internal links via data-href, so both + // attributes are required for the link to open the concept page. + tr.createEl("td").createEl("a", { + cls: "internal-link", + text: row.concept, + attr: { href: row.concept, "data-href": row.concept }, + }); + const stat = tr.createEl("td").createSpan({ cls: `wd-stat wd-${row.status}` }); + addIcon(stat, STATUS_ICON[row.status]); + stat.createSpan({ text: row.status }); + tr.createEl("td", { cls: "wd-mono", text: row.stations.join(", ") || "—" }); + tr.createEl("td", { cls: "wd-pin", text: row.pinned ? "pinned" : "" }); + } + } + + if (reconciled.unsynced.length > 0) { + const box = pane.createDiv({ cls: "wd-block" }); + box.createDiv({ cls: "wd-eyebrow", text: `Unsynced — ${reconciled.unsynced.length}` }); + box.createDiv({ + cls: "wd-note", + text: `Concept pages with no row. Run "sync script coverage": ${reconciled.unsynced.join(", ")}`, + }); + } + + if (reconciled.stale.length > 0) { + const box = pane.createDiv({ cls: "wd-block" }); + box.createDiv({ cls: "wd-eyebrow", text: `Stale — ${reconciled.stale.length}` }); + box.createDiv({ + cls: "wd-note", + text: `Rows whose concept page is gone: ${reconciled.stale.map((r) => r.concept).join(", ")}`, + }); + } + + return pane; +} + +class WebinarDashPlugin extends PluginBase { + async onload() { + this.running = new Set(); + this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => { + const cfg = parseConfig(source); + const root = el.createDiv({ cls: "webinar-dash" }); + await this.renderAll(root, cfg); + }); + } + + // Obsidian caps note content at --file-line-width when readable line length + // is on, which squeezes a two-pane dashboard into a column. Lift the cap on + // whichever ancestor carries it. + // + // Done here rather than in CSS on purpose: an inline style beats the app + // stylesheet without an !important arms race, and the computed-style walk + // means this keeps working even if Obsidian renames the sizer classes. The + // walk stops at the first capped ancestor and is bounded, so it cannot climb + // out into the workspace chrome and widen something it shouldn't. + widenHost(root) { + // Deliberately measured, not matched by class name. In live preview the cap + // sits on `.cm-content` (700px) while `.cm-sizer` — the obvious candidate, + // and the one a class-based lookup finds first — is already uncapped at full + // width. Shortcutting via closest() therefore locks onto the wrong element + // and stops before reaching the real one. Reading computed max-width finds + // whichever element actually carries the cap, in either view. + // + // Starts at root.parentElement so the dashboard's own 1600px cap survives, + // stops at the first capped ancestor so nothing above the note widens, and + // is bounded so it cannot climb into the workspace chrome. + const chain = []; + let el = root.parentElement; + for (let hops = 0; el && hops < MAX_WIDEN_HOPS; hops += 1, el = el.parentElement) { + chain.push({ + el, + maxWidth: window.getComputedStyle(el).maxWidth, + isWorkspaceLeaf: el.classList.contains("workspace-leaf"), + }); + } + const idx = firstCappedIndex(chain); + if (idx < 0) return null; + chain[idx].el.style.maxWidth = "none"; + return chain[idx].el; + } + + async renderAll(root, cfg) { + root.empty(); + this.widenHost(root); + const gate = this.spawnGate(); + const refresh = () => { this.renderAll(root, cfg); }; + + const bar = root.createDiv({ cls: "wd-bar" }); + const sync = bar.createEl("button", { cls: "wd-btn wd-btn-ghost" }); + addIcon(sync, "refresh"); + sync.createSpan({ text: "Sync coverage" }); + if (!gate.ok) { + sync.disabled = true; + sync.setAttr("title", gate.reason); + } else { + sync.addEventListener("click", () => + this.runClaude("__sync__", "sync script coverage", bar, "sync coverage", refresh) + ); + } + + const grid = root.createDiv({ cls: "wd-grid" }); + + try { + const pipeline = await this.readPipeline(cfg); + renderLeftPane(grid, pipeline, (file, rowEl) => this.runIngest(file, rowEl, refresh), gate); + } catch (err) { + grid.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` }); + } + + try { + const { parsed, reconciled } = await this.readCoverage(cfg); + renderRightPane(grid, parsed, reconciled, cfg); + } catch (err) { + grid.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` }); + } + } + + async readCoverage(cfg) { + const file = this.app.vault.getAbstractFileByPath(cfg.coverage); + if (!file) throw new Error(`no coverage file at ${cfg.coverage}`); + const parsed = parseCoverageTable(await this.app.vault.cachedRead(file)); + + const conceptDir = cfg.conceptDir.replace(/\/+$/, "") + "/"; + const conceptNames = this.app.vault + .getFiles() + .filter((f) => f.path.startsWith(conceptDir) && f.extension === "md") + .map((f) => f.basename); + + return { parsed, reconciled: reconcileConcepts(parsed.rows, conceptNames) }; + } + + async readPipeline(cfg) { + const all = this.app.vault.getFiles(); + const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/"; + const wikiDir = cfg.wikiSourceDir.replace(/\/+$/, "") + "/"; + + // CLAUDE.md documents raw/sources as holding "markdown/text/pdf exports". + // Allow-listing only "md" would hide the others from the queue with no + // warning — the same silent-invisibility failure this dashboard exists to + // remove. Wiki source pages below stay markdown-only; those really are .md. + const rawFiles = all + .filter((f) => f.path.startsWith(rawDir) && RAW_EXTENSIONS.has(f.extension)) + .map((f) => ({ path: f.path, name: f.name, size: f.stat.size })); + + const pageFiles = all.filter((f) => f.path.startsWith(wikiDir) && f.extension === "md"); + const sourcePages = []; + for (const f of pageFiles) { + const text = await this.app.vault.cachedRead(f); + sourcePages.push({ path: f.path, name: f.name, rawPath: extractRawPath(text) }); + } + + return derivePipeline({ rawFiles, sourcePages }); + } + + vaultPath() { + const adapter = this.app.vault.adapter; + if (typeof adapter.getBasePath === "function") return adapter.getBasePath(); + return null; + } + + spawnGate() { + if (!this.vaultPath()) { + return { ok: false, reason: "Needs desktop Obsidian." }; + } + try { + require("child_process"); + } catch (_) { + return { ok: false, reason: "child_process unavailable — needs desktop Obsidian." }; + } + return { ok: true, reason: "" }; + } + + // One subprocess runner for every button. `key` is what makes a run unique in + // `this.running` — a file path for ingest, a constant for sync — so the guard + // survives the row re-renders that a DOM-scoped guard could not. + runClaude(key, prompt, hostEl, label, onSuccess) { + const Notice = OB ? OB.Notice : null; + const notify = (msg) => { if (Notice) new Notice(msg); }; + + if (this.running.has(key)) { + notify(`Already running: ${label}.`); + return; + } + + const gate = this.spawnGate(); + if (!gate.ok) { + notify(gate.reason); + return; + } + const { spawn } = require("child_process"); + const base = this.vaultPath(); + + // A retry reuses the same host element. Clear the previous run's status and + // output so they are replaced rather than stacked. + hostEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); + + const button = hostEl.querySelector("button"); + if (button) button.disabled = true; + this.running.add(key); + + const status = hostEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" }); + const output = hostEl.createDiv({ cls: "wd-output", text: "" }); + const started = Date.now(); + const timer = window.setInterval(() => { + status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`); + }, 1000); + this.registerInterval(timer); + + // Bounded as it accumulates, not only when displayed. + let buffered = ""; + const append = (text) => { + buffered = (buffered + text).slice(-8000); + output.setText(buffered); + output.scrollTop = output.scrollHeight; + }; + + // Node can emit both `error` and `close` for one failure. `settled` keeps the + // first, more specific message instead of letting `exit null` overwrite it. + let settled = false; + const finish = (cls, text) => { + if (settled) return; + settled = true; + window.clearInterval(timer); + this.running.delete(key); + status.className = `wd-status ${cls}`; + status.setText(text); + if (button) button.disabled = false; + }; + + let child; + try { + // See the C1 comment in runIngest: no shell, deliberately. + child = spawn("claude", ["-p", prompt], { cwd: base }); + } catch (err) { + append(`\nCould not start claude: ${err.message}`); + finish("wd-status-failed", "failed"); + return; + } + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", append); + child.stderr.on("data", append); + + child.on("error", (err) => { + append(`\nCould not start claude: ${err.message}\nIs it on PATH?`); + finish("wd-status-failed", "failed"); + }); + + child.on("close", (code) => { + const secs = Math.round((Date.now() - started) / 1000); + if (code === 0) { + finish("wd-status-done", `done in ${secs}s`); + if (onSuccess) onSuccess(); + } else { + finish("wd-status-failed", `failed - exit ${code}`); + } + }); + } + + runIngest(file, rowEl, onSuccess) { + const Notice = OB ? OB.Notice : null; + + if (!isSafeFilename(file.name)) { + rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); + rowEl.createDiv({ + cls: "wd-output", + text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`, + }); + return; + } + + // `claude` resolves to a real .exe here, so libuv finds it via PATH and + // PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell, + // Node concatenates argv without quoting and cmd.exe re-tokenizes, so the + // prompt arrives as two arguments and the filename is silently discarded. + // Verified: shell:true yields ["-p","ingest","Webinar script.md"], where the + // program sees only "ingest". If a .cmd shim ever needs supporting, use the + // single-string form spawn(`claude -p "ingest ${name}"`, { shell: true }). + this.runClaude( + file.path, + `ingest "${file.name}"`, + rowEl, + file.name, + () => { + if (Notice) new Notice(`Ingested ${file.name}.`); + if (onSuccess) onSuccess(); + } + ); + } +} + +module.exports = WebinarDashPlugin; +module.exports.default = WebinarDashPlugin; +module.exports.__test__ = { + parseConfig, extractRawPath, derivePipeline, + parseCoverageTable, groupByStation, reconcileConcepts, + STATIONS, DEFAULTS, firstCappedIndex, isSafeFilename, +}; diff --git a/.obsidian/plugins/webinar-dash/styles.css b/.obsidian/plugins/webinar-dash/styles.css index f930de2..3887f21 100644 --- a/.obsidian/plugins/webinar-dash/styles.css +++ b/.obsidian/plugins/webinar-dash/styles.css @@ -87,8 +87,8 @@ ------------------------------------------------------------------ */ .markdown-preview-sizer:has(.webinar-dash), .markdown-preview-view.is-readable-line-width .markdown-preview-sizer:has(.webinar-dash), -.markdown-source-view.mod-cm6 .cm-sizer:has(.webinar-dash), -.markdown-source-view.mod-cm6.is-readable-line-width .cm-sizer:has(.webinar-dash) { +.markdown-source-view.mod-cm6 .cm-content:has(.webinar-dash), +.markdown-source-view.mod-cm6 .cm-sizer:has(.webinar-dash) { max-width: none !important; } diff --git a/.obsidian/plugins/webinar-dash/test/layout.test.js b/.obsidian/plugins/webinar-dash/test/layout.test.js new file mode 100644 index 0000000..ba7d034 --- /dev/null +++ b/.obsidian/plugins/webinar-dash/test/layout.test.js @@ -0,0 +1,56 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { firstCappedIndex } = require("../main.js").__test__; + +test("firstCappedIndex finds the cap on the real measured live-preview chain", () => { + // Measured in Obsidian live preview via getComputedStyle, walking outward + // from .webinar-dash. The cap is on .cm-content at 700px; .cm-sizer - the + // element a class-name lookup finds first - is already uncapped at 1680px. + // Matching by class name selected .cm-sizer and stopped, leaving the real + // cap in place. This test pins the measurement so that cannot recur. + const chain = [ + { maxWidth: "none", isWorkspaceLeaf: false }, // block-language-webinar-dash + { maxWidth: "none", isWorkspaceLeaf: false }, // cm-preview-code-block + { maxWidth: "700px", isWorkspaceLeaf: false }, // cm-content <- the cap + { maxWidth: "none", isWorkspaceLeaf: false }, // cm-contentContainer + { maxWidth: "none", isWorkspaceLeaf: false }, // cm-sizer + { maxWidth: "none", isWorkspaceLeaf: false }, // cm-scroller + ]; + assert.equal(firstCappedIndex(chain), 2); +}); + +test("firstCappedIndex finds the cap on a reading-view chain", () => { + const chain = [ + { maxWidth: "none", isWorkspaceLeaf: false }, // block-language-webinar-dash + { maxWidth: "700px", isWorkspaceLeaf: false }, // markdown-preview-sizer + { maxWidth: "none", isWorkspaceLeaf: false }, // markdown-preview-view + ]; + assert.equal(firstCappedIndex(chain), 1); +}); + +test("firstCappedIndex returns -1 when nothing above the dashboard is capped", () => { + const chain = [ + { maxWidth: "none", isWorkspaceLeaf: false }, + { maxWidth: "none", isWorkspaceLeaf: false }, + ]; + assert.equal(firstCappedIndex(chain), -1); +}); + +test("firstCappedIndex stops at the workspace leaf rather than widening chrome", () => { + const chain = [ + { maxWidth: "none", isWorkspaceLeaf: false }, + { maxWidth: "none", isWorkspaceLeaf: true }, // workspace-leaf + { maxWidth: "900px", isWorkspaceLeaf: false }, // must never be reached + ]; + assert.equal(firstCappedIndex(chain), -1); +}); + +test("firstCappedIndex takes the innermost cap when several ancestors are capped", () => { + const chain = [ + { maxWidth: "none", isWorkspaceLeaf: false }, + { maxWidth: "700px", isWorkspaceLeaf: false }, + { maxWidth: "1200px", isWorkspaceLeaf: false }, + ]; + assert.equal(firstCappedIndex(chain), 1); +});