diff --git a/docs/superpowers/plans/2026-07-28-webinar-dashboard.md b/docs/superpowers/plans/2026-07-28-webinar-dashboard.md index 256d5d0..952702f 100644 --- a/docs/superpowers/plans/2026-07-28-webinar-dashboard.md +++ b/docs/superpowers/plans/2026-07-28-webinar-dashboard.md @@ -269,6 +269,31 @@ test("derivePipeline treats a page with no raw path as orphaned", () => { assert.equal(out.unprocessed.length, 1); }); +test("derivePipeline routes a duplicate raw-path claim to orphaned", () => { + const out = derivePipeline({ + rawFiles: [{ path: "raw/sources/a.md", name: "a.md", size: 10 }], + sourcePages: [ + { path: "wiki/sources/first.md", name: "first.md", rawPath: "raw/sources/a.md" }, + { path: "wiki/sources/second.md", name: "second.md", rawPath: "raw/sources/a.md" }, + ], + }); + assert.equal(out.processed.length, 1); + assert.equal(out.processed[0].page.name, "first.md"); + assert.equal(out.unprocessed.length, 0); + assert.deepEqual(out.orphaned.map((p) => p.name), ["second.md"]); +}); + +test("derivePipeline lists a page once when it is both a duplicate claim and missing its raw file", () => { + const out = derivePipeline({ + rawFiles: [{ path: "raw/sources/other.md", name: "other.md", size: 10 }], + sourcePages: [ + { path: "wiki/sources/first.md", name: "first.md", rawPath: "raw/sources/gone.md" }, + { path: "wiki/sources/second.md", name: "second.md", rawPath: "raw/sources/gone.md" }, + ], + }); + assert.deepEqual(out.orphaned.map((p) => p.name), ["first.md", "second.md"]); +}); + test("derivePipeline sorts unprocessed by name and processed newest first", () => { const out = derivePipeline({ rawFiles: [ @@ -307,9 +332,16 @@ function extractRawPath(text) { } 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) claimed.set(page.rawPath, page); + if (!page.rawPath) continue; + if (claimed.has(page.rawPath)) duplicates.push(page); + else claimed.set(page.rawPath, page); } const processed = []; @@ -320,8 +352,15 @@ function derivePipeline({ rawFiles, sourcePages }) { 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 orphaned = sourcePages.filter((p) => !p.rawPath || !rawPaths.has(p.rawPath)); + 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)); @@ -344,7 +383,7 @@ module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, STATION node --test test/pipeline.test.js ``` -Expected: PASS, 7 tests. +Expected: PASS, 9 tests. - [ ] **Step 6: Commit** @@ -416,12 +455,19 @@ Values are copied verbatim from `colors_and_type.css`. Scoped to `.webinar-dash` --wd-border: var(--wd-ink-700); --wd-border-strong: #b8b8b8; --wd-border-subtle: #161616; - --wd-accent: #ff4a3d; - --wd-accent-press: #ff6d62; - --wd-accent-on: #0a0a0a; + /* Accent tracks the installed theme at .obsidian/themes/tesanti/theme.css, + whose dark section reads "Black canvas, same signal red": --accent-h/-s/-l + are declared once at :root and never redeclared under .theme-dark, and only + the hover state lifts (#c31c14 light, #ff4d43 dark). Match that exactly. */ + --wd-accent: var(--wd-red-500); + --wd-accent-press: #ff4d43; + --wd-accent-on: #ffffff; + /* Status tokens are separate from the brand accent by design-system rule, and + here they carry small text in a dense table. #e1261c on near-black is about + 3.8:1, under AA for small text, so these lift where the accent does not. */ --wd-ok: #2fbf6a; --wd-warn: #e0a516; - --wd-danger: #ff4a3d; + --wd-danger: #ff5c50; } @media (max-width: 820px) { @@ -503,6 +549,10 @@ Values are copied verbatim from `colors_and_type.css`. Scoped to `.webinar-dash` Insert into `main.js` above the plugin class. Icons are inline stroke paths — the Lucide CDN is unavailable offline and the design system forbids emoji. ```js +// 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", @@ -608,8 +658,12 @@ class WebinarDashPlugin extends PluginBase { 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) && f.extension === "md") + .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"); @@ -637,10 +691,14 @@ Expected, against the vault's current state: ```bash cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash" -node --test test/ +node --test ``` -Expected: PASS, 6 tests. +Bare `node --test` auto-discovers the test files. Do not pass `test/` as an +argument — Node 24 resolves a bare directory path as a module and fails with +`MODULE_NOT_FOUND` before running anything. + +Expected: PASS, 9 tests. - [ ] **Step 6: Commit** @@ -820,7 +878,11 @@ function parseCoverageTable(markdown) { if (!inTable) return; const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1); - const cells = body.split("|").map((c) => c.trim()); + // 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 — + // the status cell then reads "The harness]]" and the row is rejected. + const cells = body.split(/(? c.trim()); if (cells.length < 3) { errors.push({ line: i + 1, text: t, reason: "expected at least 3 columns" }); @@ -1580,10 +1642,14 @@ This writes to the vault unsupervised. Everything is committed, so `git diff HEA ```bash cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash" -node --test test/ +node --test ``` -Expected: PASS, 24 tests across three files. +Bare `node --test` auto-discovers the test files. Do not pass `test/` as an +argument — Node 24 resolves a bare directory path as a module and fails with +`MODULE_NOT_FOUND` before running anything. + +Expected: PASS, 26 tests across three files. - [ ] **Step 11: Commit** @@ -1616,4 +1682,4 @@ The spec's "v1 does not write the coverage file from the plugin" is honored — **Type consistency.** `rawFiles` items are `{path, name, size}` in Tasks 2 and 3. `sourcePages` items are `{path, name, rawPath}` in both. Coverage rows are `{concept, status, stations, pinned, line}` in Tasks 4, 5, and 6. `onIngest(file, rowEl)` is declared in Task 3 and implemented with the same signature in Task 7. `addIcon(parent, name)` is defined in Task 3 and reused in Task 6. `STATIONS` is defined in Task 1 and consumed by `groupByStation` in Task 4. -**Test count.** Task 2 adds 7, Task 4 adds 11, Task 6 adds 2, Task 7 adds 4 — 24 total, matching Step 10 of Task 7. +**Test count.** Task 2 adds 9, Task 4 adds 11, Task 6 adds 2, Task 7 adds 4 — 26 total, matching Step 10 of Task 7.