feat: init

This commit is contained in:
hh
2026-08-08 16:30:22 +02:00
commit 8a609da778
59 changed files with 10053 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBoard } from "../src/model/board";
import { serializeTaskLine } from "../src/model/serialize";
import type { Task } from "../src/model/types";
import {
applyArchive,
applyReorder,
cutTask,
insertBlock,
locate,
MissingLaneError,
replaceLine,
StaleTaskError,
} from "../src/vault/edits";
const FIXTURES = join(import.meta.dirname, "fixtures");
function board(name: string) {
const content = readFileSync(join(FIXTURES, name), "utf8");
return { content, board: parseBoard(`Boards/${name}`, content) };
}
function landmarks(text: string) {
const lines = text.split("\n");
return {
settings: lines.filter((line) => line.startsWith("%% kanban:settings")).length,
fences: lines.filter((line) => line.trim() === "***").length,
headings: lines.filter((line) => line.startsWith("## ")),
frontmatter: text.startsWith("---\n")
? text.slice(0, text.indexOf("\n---\n", 4))
: "",
};
}
function edited(name: string, mutate: (lines: string[]) => void) {
const { content } = board(name);
const lines = content.split("\n");
mutate(lines);
return lines.join("\n");
}
describe("locate", () => {
it("finds a line that has shifted", () => {
const { board: parsed } = board("personal.md");
const task = parsed.tasks.find((item) => !item.inArchive) as Task;
const lines = readFileSync(join(FIXTURES, "personal.md"), "utf8").split("\n");
lines.unshift("", "", "");
expect(locate(lines, task)).toBe(task.line + 3);
});
it("refuses when the line is gone", () => {
const { board: parsed } = board("personal.md");
expect(() => locate(["nothing", "here"], parsed.tasks[0])).toThrow(
StaleTaskError,
);
});
it("picks the nearest of several identical lines", () => {
const { board: parsed } = board("work.md");
const counts = new Map<string, Task[]>();
for (const task of parsed.tasks) {
const list = counts.get(task.raw) ?? [];
list.push(task);
counts.set(task.raw, list);
}
const dupes = [...counts.values()].find((list) => list.length > 1);
expect(dupes, "work.md should carry duplicate archived lines").toBeDefined();
const lines = readFileSync(join(FIXTURES, "work.md"), "utf8").split("\n");
for (const task of dupes as Task[]) {
expect(locate(lines, task)).toBe(task.line);
}
});
});
describe("replaceLine", () => {
it("changes only the target line", () => {
const { content, board: parsed } = board("personal.md");
const task = parsed.tasks.find(
(item) => item.text === "renew the gym membership",
) as Task;
expect(task).toBeDefined();
const next = edited("personal.md", (lines) => {
replaceLine(lines, task, serializeTaskLine({ ...task, due: "2026-09-01" }));
});
const before = content.split("\n");
const after = next.split("\n");
expect(after).toHaveLength(before.length);
expect(after.filter((line, index) => line !== before[index])).toEqual([
"- [ ] renew the gym membership 📅 2026-09-01",
]);
});
it("expands a recurring toggle into two lines", () => {
const { board: parsed } = board("personal.md");
const task = parsed.tasks.find(
(item) => !item.inArchive && item.recurrence,
) as Task;
const next = edited("personal.md", (lines) => {
replaceLine(
lines,
task,
`- [ ] ${task.text} 🔁 every day 📅 2026-08-09\n- [x] ${task.text} 🔁 every day 📅 2026-08-08 ✅ 2026-08-08`,
);
});
expect(next).toContain("📅 2026-08-09");
expect(next).toContain("✅ 2026-08-08");
expect(landmarks(next).settings).toBe(1);
});
});
describe("cutTask and insertBlock", () => {
it("moves a card between lanes of the same board", () => {
const { content, board: parsed } = board("personal.md");
const task = parsed.tasks.find(
(item) => !item.inArchive && item.project === "errands",
) as Task;
const next = edited("personal.md", (lines) => {
const block = cutTask(lines, task);
insertBlock(lines, "personal.md", { project: "someday" }, block);
});
const reparsed = parseBoard("personal.md", next);
const moved = reparsed.tasks.find((item) => item.text === task.text) as Task;
expect(moved.project).toBe("someday");
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
const before = landmarks(content);
const after = landmarks(next);
expect(after.settings).toBe(before.settings);
expect(after.fences).toBe(before.fences);
expect(after.headings).toEqual(before.headings);
});
it("carries a multi-line card body along", () => {
const { content, board: parsed } = board("work.md");
const task = parsed.tasks.find((item) => item.body.length > 1) as Task;
expect(task).toBeDefined();
const lines = content.split("\n");
const before = lines.length;
const block = cutTask(lines, task);
expect(block).toHaveLength(1 + task.body.length);
expect(block.slice(1)).toEqual(task.body);
expect(lines.join("\n")).not.toContain(block.join("\n"));
expect(before - lines.length).toBeGreaterThanOrEqual(block.length);
});
it("reports a missing lane instead of writing", () => {
const lines = board("personal.md").content.split("\n");
expect(() =>
insertBlock(lines, "personal.md", { project: "nope" }, ["- [ ] x"]),
).toThrow(MissingLaneError);
});
it("appends after the last card, before the lane's blank tail", () => {
const next = edited("personal.md", (lines) => {
insertBlock(lines, "personal.md", { project: "errands" }, [
"- [ ] new one 📅 2026-08-08",
]);
});
const lane = parseBoard("personal.md", next).tasks.filter(
(item) => item.project === "errands" && !item.inArchive,
);
expect(lane[lane.length - 1].text).toBe("new one");
expect(landmarks(next).settings).toBe(1);
});
});
describe("applyReorder", () => {
it("reverses a lane without disturbing the rest of the file", () => {
const { content, board: parsed } = board("personal.md");
const inLane = parsed.tasks.filter(
(item) => item.project === "errands" && !item.inArchive,
);
expect(inLane.length).toBeGreaterThan(2);
const next = edited("personal.md", (lines) => {
applyReorder(lines, inLane, [...inLane].reverse());
});
const after = parseBoard("personal.md", next)
.tasks.filter((item) => item.project === "errands" && !item.inArchive)
.map((item) => item.text);
expect(after).toEqual([...inLane].reverse().map((item) => item.text));
expect(next.split("\n").sort()).toEqual(content.split("\n").sort());
expect(landmarks(next)).toEqual(landmarks(content));
});
});
describe("applyArchive", () => {
it("moves done cards under the archive heading", () => {
const { content, board: parsed } = board("personal.md");
const lines = content.split("\n");
const open = parsed.tasks.find((item) => !item.inArchive) as Task;
replaceLine(
lines,
open,
serializeTaskLine({ ...open, status: "x", done: "2026-08-08" }),
);
expect(applyArchive(lines, "personal.md", "Archive")).toBe(1);
const next = lines.join("\n");
const reparsed = parseBoard("personal.md", next);
const archived = reparsed.tasks.find((item) => item.text === open.text) as Task;
expect(archived.inArchive).toBe(true);
expect(archived.done).toBe("2026-08-08");
expect(reparsed.tasks.filter((item) => item.text === open.text)).toHaveLength(1);
expect(landmarks(next).settings).toBe(1);
expect(landmarks(next).fences).toBe(1);
});
it("is a no-op when nothing is done", () => {
const { content } = board("home.md");
const lines = content.split("\n");
expect(applyArchive(lines, "home.md", "Archive")).toBe(0);
expect(lines.join("\n")).toBe(content);
});
it("creates the fence and heading on a board without one", () => {
const { content } = board("home.md");
expect(content).not.toContain("## Archive");
const lines = content.split("\n");
const parsed = parseBoard("home.md", content);
for (const task of parsed.tasks) {
replaceLine(
lines,
task,
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
);
}
expect(applyArchive(lines, "home.md", "Archive")).toBe(parsed.tasks.length);
const next = lines.join("\n");
expect(next).toContain("***");
expect(next).toContain("## Archive");
expect(next).toContain("%% kanban:settings");
expect(parseBoard("home.md", next).tasks.every((t) => t.inArchive)).toBe(true);
});
});
describe("every board survives a full archive pass", () => {
for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) {
it(`${name}: landmarks intact, no task lost`, () => {
const { content, board: parsed } = board(name);
const lines = content.split("\n");
for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
replaceLine(
lines,
task,
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
);
}
applyArchive(lines, name, "Archive");
const next = lines.join("\n");
const reparsed = parseBoard(name, next);
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true);
const before = landmarks(content);
const after = landmarks(next);
expect(after.settings).toBe(before.settings);
expect(after.frontmatter).toBe(before.frontmatter);
for (const heading of before.headings) {
expect(after.headings, `${heading} must survive`).toContain(heading);
}
});
}
});
+17
View File
@@ -0,0 +1,17 @@
---
kanban-plugin: board
---
## repairs
- [ ] replace the kitchen tap 📅 2026-08-20
- [ ] fix the squeaky door
%% kanban:settings
```
{"kanban-plugin":"board","list-collapse":[false]}
```
%%
+34
View File
@@ -0,0 +1,34 @@
---
kanban-plugin: board
---
## errands
- [ ] renew the gym membership 📅 2026-08-08
- [ ] book a dentist appointment ⏳ 2026-08-09 📅 2026-08-12
- [ ] water the plants 🔁 every day 📅 2026-08-08
- [ ] call [[Alex Rivera|Alex]] about the move 📅 2026-08-09
## someday
- [ ] learn to bake sourdough
- [ ] read **Thinking in Systems**
***
## Archive
- [x] pick up the parcel 📅 2026-07-30 ✅ 2026-07-30
- [x] pay the electricity bill ⏳ 2026-07-28 📅 2026-07-29 ✅ 2026-07-28
- [x] water the plants 🔁 every day 📅 2026-07-27 ✅ 2026-07-27
- [x] water the plants 🔁 every day 📅 2026-07-26 ✅ 2026-07-27
%% kanban:settings
```
{"kanban-plugin":"board","list-collapse":[false,false]}
```
%%
+24
View File
@@ -0,0 +1,24 @@
---
kanban-plugin: board
---
## queue
- [ ] ![[cover.png]]
- [ ] compare the pricing tiers https://example.com/pricing#plans
- [ ] finish the chapter on caching 📅 2026-08-15
***
## Archive
- [x] skim the release notes ✅ 2026-07-15
%% kanban:settings
```
{"kanban-plugin":"board","list-collapse":[false,false]}
```
%%
+43
View File
@@ -0,0 +1,43 @@
---
planner:
log: []
kanban-plugin: board
TQ_explain:
TQ_short_mode:
TQ_show_due_date:
TQ_show_tags:
---
## website
- [ ] fix the mobile nav overlap ⏫ 📅 2026-08-10
- [ ] compress the hero images https://example.com/tools/images
## backlog
## notes
- [x] rewrite the onboarding copy ✅ 2026-08-01
- shorter intro
- drop the second screenshot
- link to the docs instead
- [x] audit the third-party scripts ✅ 2026-07-31
follow-up: check the analytics bundle size
***
## Archive
- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21
- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21
%% kanban:settings
```
{"kanban-plugin":"board","list-collapse":[false,false,false,false]}
```
%%
+57
View File
@@ -0,0 +1,57 @@
export class TFile {
constructor(
public path: string,
public extension = "md",
) {}
}
export class TFolder {
constructor(public path: string) {}
}
export const notices: string[] = [];
export class Notice {
constructor(message: string) {
notices.push(message);
}
hide(): void {}
}
export class Plugin {}
export class ItemView {}
export class PluginSettingTab {}
export class Setting {}
export class Component {}
export const moment = () => undefined;
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export const MarkdownRenderer = {
async render(
_app: unknown,
markdown: string,
el: HTMLElement,
_sourcePath: string,
_component: unknown,
): Promise<void> {
const html = escapeHtml(markdown)
.replace(
/!?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_full, target: string, alias?: string) =>
`<a class="internal-link" href="${target}">${alias ?? target}</a>`,
)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(
/(^|\s)(https?:\/\/[^\s]+)/g,
(_full, lead: string, url: string) =>
`${lead}<a class="external-link" href="${url}">${url}</a>`,
);
el.innerHTML = `<p>${html}</p>`;
},
};
+106
View File
@@ -0,0 +1,106 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBoard, renderBoard } from "../src/model/board";
import { serializeTaskLine } from "../src/model/serialize";
const FIXTURES = join(import.meta.dirname, "fixtures");
const BOARDS = readdirSync(FIXTURES).filter((name) => name.endsWith(".md"));
describe("board round-trip", () => {
it("has boards to work with", () => {
expect(BOARDS.length).toBeGreaterThan(0);
});
for (const name of BOARDS) {
const content = readFileSync(join(FIXTURES, name), "utf8");
const board = parseBoard(`Boards/${name}`, content);
it(`${name}: re-renders byte for byte`, () => {
expect(renderBoard(board)).toBe(content);
});
it(`${name}: every task line survives parse then serialize`, () => {
for (const task of board.tasks) {
expect(serializeTaskLine(task), `line ${task.line + 1}`).toBe(task.raw);
}
});
it(`${name}: finds tasks and lanes`, () => {
expect(board.lanes.length).toBeGreaterThan(0);
expect(board.tasks.length).toBeGreaterThan(0);
for (const task of board.tasks) {
expect(board.lines[task.line]).toBe(task.raw);
}
});
}
});
describe("field extraction", () => {
const parse = (line: string) => {
const board = parseBoard("x.md", `## lane\n\n${line}\n`);
return board.tasks[0];
};
it("reads recurrence followed by dates", () => {
const task = parse("- [ ] water the plants 🔁 every day 📅 2026-08-08");
expect(task.text).toBe("water the plants");
expect(task.recurrence).toBe("every day");
expect(task.due).toBe("2026-08-08");
expect(task.scheduled).toBeNull();
});
it("reads scheduled, due and done together", () => {
const task = parse(
"- [x] pay the electricity bill ⏳ 2026-07-28 📅 2026-07-29 ✅ 2026-07-28",
);
expect(task.status).toBe("x");
expect(task.text).toBe("pay the electricity bill");
expect(task.scheduled).toBe("2026-07-28");
expect(task.due).toBe("2026-07-29");
expect(task.done).toBe("2026-07-28");
});
it("keeps wikilinks and embeds inside the description", () => {
const task = parse("- [ ] call [[Alex Rivera|Alex]] about the move 📅 2026-08-09");
expect(task.text).toBe("call [[Alex Rivera|Alex]] about the move");
expect(task.due).toBe("2026-08-09");
});
it("does not mistake a url fragment for a tag", () => {
const task = parse("- [ ] compare tiers https://example.com/pricing#plans");
expect(task.tags).toEqual([]);
expect(task.text).toBe("compare tiers https://example.com/pricing#plans");
});
it("reads priority and writes it back in Tasks' order", () => {
const task = parse("- [ ] fix the mobile nav ⏫ 📅 2026-08-10");
expect(task.priority).toBe(4);
expect(serializeTaskLine(task)).toBe("- [ ] fix the mobile nav ⏫ 📅 2026-08-10");
});
it("captures a multi-line kanban card body", () => {
const board = parseBoard(
"x.md",
"## lane\n\n- [x] rewrite the copy ✅ 2026-08-01\n\t- shorter intro\n\t- link the docs\n",
);
expect(board.tasks[0].body).toEqual(["\t- shorter intro", "\t- link the docs"]);
});
it("marks archive lanes", () => {
const board = parseBoard(
"x.md",
"## live\n\n- [ ] a\n\n***\n\n## Archive\n\n- [x] b ✅ 2026-01-01\n",
);
expect(board.tasks[0].inArchive).toBe(false);
expect(board.tasks[1].inArchive).toBe(true);
});
it("ignores anything inside the kanban settings block", () => {
const board = parseBoard(
"x.md",
'## lane\n\n- [ ] real\n\n%% kanban:settings\n```\n{"kanban-plugin":"board"}\n```\n%%\n',
);
expect(board.tasks).toHaveLength(1);
});
});
+203
View File
@@ -0,0 +1,203 @@
import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import esbuild from "esbuild";
import sveltePlugin from "esbuild-svelte";
import { JSDOM } from "jsdom";
const root = new URL("..", import.meta.url).pathname;
const fixtures = join(root, "tests/fixtures");
const FOLDER = "Boards";
const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs");
writeFileSync(
join(root, "tests/.entry.ts"),
`export { default as App } from "../src/ui/app.svelte";
export { BoardStore } from "../src/vault/store.svelte";
export { mount, unmount, flushSync } from "svelte";
export { TFile, Component } from "obsidian";
`,
);
await esbuild.build({
entryPoints: [join(root, "tests/.entry.ts")],
bundle: true,
format: "esm",
target: "es2022",
outfile: out,
logLevel: "error",
conditions: ["svelte", "browser"],
mainFields: ["svelte", "browser", "module", "main"],
alias: { obsidian: join(root, "tests/obsidian-stub.ts") },
plugins: [sveltePlugin({ compilerOptions: { css: "injected", runes: true } })],
});
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (key.startsWith("_")) continue;
if (globalThis[key] !== undefined) continue;
try {
globalThis[key] = dom.window[key];
} catch {
/* getter-only window properties are fine to skip */
}
}
globalThis.window = dom.window;
globalThis.document = dom.window.document;
dom.window.Element.prototype.getBoundingClientRect = () => ({
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
toJSON: () => ({}),
});
dom.window.Element.prototype.setPointerCapture = () => {};
dom.window.Element.prototype.releasePointerCapture = () => {};
dom.window.Element.prototype.hasPointerCapture = () => false;
dom.window.Element.prototype.scrollIntoView = () => {};
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
const { App, BoardStore, mount, unmount, flushSync, TFile, Component } =
await import(pathToFileURL(out).href);
const names = readdirSync(fixtures).filter((name) => name.endsWith(".md"));
const files = new Map(
names.map((name) => [
`${FOLDER}/${name}`,
readFileSync(join(fixtures, name), "utf8"),
]),
);
const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)]));
const writes = [];
const app = {
vault: {
getMarkdownFiles: () => [...handles.values()],
getAbstractFileByPath: (path) => handles.get(path) ?? null,
cachedRead: async (file) => files.get(file.path),
read: async (file) => files.get(file.path),
process: async (file, fn) => {
const next = fn(files.get(file.path));
writes.push({ path: file.path, content: next });
files.set(file.path, next);
return next;
},
on: () => ({}),
},
workspace: { getLeaf: () => ({}), activeEditor: null },
plugins: { plugins: {} },
};
const settings = {
boardsFolder: FOLDER,
archiveHeading: "Archive",
view: "list",
groupBy: "sphere",
listSort: "due",
calendarSort: "priority",
showDone: false,
includeArchive: false,
railCollapsed: false,
undatedCollapsed: false,
};
const failures = [];
function check(name, condition, detail = "") {
if (condition) console.log(` ok ${name}`);
else {
failures.push(name);
console.log(` FAIL ${name}${detail ? ` -- ${detail}` : ""}`);
}
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
const store = new BoardStore(app, settings, () => {});
await store.reloadAll();
// Every task outside the archive, done ones included; the done filter
// applies to `visible`, not to the pool.
const outsideArchive = 15;
check("parsed every board", store.spheres.length === names.length, `${store.spheres.length}`);
check(`${outsideArchive} tasks outside the archive`, store.all.length === outsideArchive, `got ${store.all.length}`);
const target = dom.window.document.body;
const instance = mount(App, {
target,
props: { app, store, settings, component: new Component() },
});
flushSync();
await settle();
flushSync();
const text = () => target.textContent ?? "";
check("list rendered a task", text().includes("renew the gym membership"));
check("sphere rail rendered", text().includes("All spheres"));
check("group header rendered", text().includes("personal"));
check("project chip rendered", text().includes("errands"));
check("no crash markers", !text().includes("undefined"));
const links = target.querySelectorAll("a.internal-link");
check("wikilink rendered as a link", links.length > 0, `${links.length} links`);
check("alias is displayed, not the target", text().includes("Alex"));
check("no raw wikilink syntax leaked", !text().includes("[["));
store.includeArchive = true;
store.showDone = true;
flushSync();
await settle();
check("archive appears when both toggles are on", text().includes("pick up the parcel"));
check("archived task carries the Archive badge", text().includes("Archive"));
store.includeArchive = false;
flushSync();
check("archive hidden again", !text().includes("pick up the parcel"));
store.showDone = false;
flushSync();
store.view = "calendar";
flushSync();
await settle();
check("calendar rendered a month label", /\d{4}/.test(store.monthLabel));
check("calendar rendered the undated tray", text().includes("No date"));
store.view = "list";
store.groupBy = "due";
flushSync();
const BUCKETS = ["Overdue", "Today", "Tomorrow", "This week", "Later", "No date"];
check(
"due grouping produced known buckets",
store.groups.length > 0 && store.groups.every((g) => BUCKETS.includes(g.label)),
store.groups.map((g) => g.label).join(", "),
);
const button = target.querySelector('button[aria-label^="Status"]');
check("status button present", button !== null);
if (button) {
button.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
await settle();
check("clicking status wrote to the vault", writes.length > 0, `${writes.length}`);
if (writes.length > 0) {
const after = writes[writes.length - 1];
const before = readFileSync(
join(fixtures, after.path.slice(FOLDER.length + 1)),
"utf8",
).split("\n");
const now = after.content.split("\n");
const diff = now.filter((line, index) => line !== before[index]);
check("exactly one line changed", diff.length === 1, JSON.stringify(diff));
check("line is now done", diff[0]?.startsWith("- [x] "), diff[0]);
check("done date stamped", /✅ \d{4}-\d{2}-\d{2}$/u.test(diff[0] ?? ""), diff[0]);
check("settings block intact", after.content.includes("%% kanban:settings"));
}
}
await unmount(instance);
console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`);
process.exit(failures.length === 0 ? 0 : 1);
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const source = readFileSync(
join(import.meta.dirname, "../src/tailwind.css"),
"utf8",
);
function block(selector: string): string {
const at = source.indexOf(`\n${selector} {`);
if (at === -1) return "";
const open = source.indexOf("{", at);
const close = source.indexOf("\n}", open);
return source.slice(open, close);
}
describe("theme aliases", () => {
const OBSIDIAN_VARS = [
"--background-primary",
"--background-secondary",
"--background-modifier-border",
"--text-normal",
"--text-muted",
"--color-accent",
"--text-on-accent",
"--text-error",
];
it("are declared on .bcal-root, not :root", () => {
const root = block(".bcal-root");
expect(root).not.toBe("");
for (const name of OBSIDIAN_VARS) {
expect(root, `${name} must be aliased on .bcal-root`).toContain(name);
}
});
it("never reference Obsidian variables from :root", () => {
const rootBlocks = [...source.matchAll(/(^|\n):root\s*\{([^}]*)\}/g)];
for (const [, , body] of rootBlocks) {
for (const name of OBSIDIAN_VARS) {
expect(body, `:root must not reference ${name}`).not.toContain(name);
}
}
});
it("scopes the dark override under the view root", () => {
expect(source).toContain(".theme-dark .bcal-root");
expect(source).not.toMatch(/\n\.theme-dark\s*\{/);
});
});
describe("reset specificity", () => {
it("does not use :where() for element resets", () => {
expect(source).not.toMatch(/\.bcal-root\s+:where\(/);
});
it("uses a doubled class to outrank app and theme styles", () => {
expect(source).toContain(".bcal-root.bcal-root");
});
it("neutralises the native search affordance", () => {
expect(source).toContain("-webkit-search-cancel-button");
});
});
describe("cascade against Obsidian", () => {
it("marks utilities important so they outrank app.css", () => {
expect(source).toMatch(
/@import "tailwindcss\/utilities\.css"[^;]*\bimportant\b/,
);
});
it("keeps the element reset out of a cascade layer", () => {
const reset = source.indexOf(".bcal-root.bcal-root :is(button");
expect(reset).toBeGreaterThan(-1);
const before = source.slice(0, reset);
const opens = (before.match(/@layer[^;{]*\{/g) ?? []).length;
const closes = (before.match(/\n\}/g) ?? []).length;
expect(opens, "reset must be unlayered").toBeLessThanOrEqual(closes);
});
});
describe("preflight stays out", () => {
it("imports only the theme and utility layers", () => {
expect(source).toContain('@import "tailwindcss/theme.css"');
expect(source).toContain('@import "tailwindcss/utilities.css"');
expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/);
expect(source).not.toMatch(/@import\s+"tailwindcss\/preflight/);
});
});