fix(css,preview): scope utilities under the view root, preview with real boards and foreign css

This commit is contained in:
hh committed 2026-08-29 19:39:19 +02:00
1 parent 0a7742eb5e
commit 8c971e431a
10 files changed
+458 -14

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ node_modules/
# vault by `make install`.
main.js
styles.css
.tailwind.css
*.zip
# Obsidian writes plugin settings here when the repo is symlinked into a
+16
View File
@@ -18,3 +18,19 @@ make zip # distributable
```
After `make install` turn on plugin in settings.
`styles.css` is Tailwind output run through `scripts/scope-css.mjs`: every
selector is prefixed with `:is(.bcal-root, .bcal-root *)`. Other plugins built
with Tailwind put their utilities into the same `utilities` cascade layer, and
without the extra specificity whichever plugin loaded last would win (their
`.hidden` over our `@container` variants, for one).
Preview knobs, all optional:
```bash
VAULT=/path/to/vault npm run preview # the vault's theme
BOARDS=/path/to/vault/Boards npm run preview # real boards, not fixtures
EXTRA_CSS=/path/to/other-plugin/styles.css … # another plugin's css, loaded after ours
open 'http://localhost:4173/?groupBy=due&railCollapsed=1#edit-date' # prefs + scene
node preview/shot.mjs out.png 'http://localhost:4173/#date' # screenshot + console
```
+3 -3
View File
@@ -6,10 +6,10 @@
"private": true,
"type": "module",
"scripts": {
"css": "tailwindcss -i src/tailwind.css -o styles.css",
"css:watch": "tailwindcss -i src/tailwind.css -o styles.css --watch",
"css": "node scripts/css.mjs",
"css:watch": "node scripts/css.mjs --watch",
"dev": "npm run css && node esbuild.config.mjs",
"build": "svelte-check --tsconfig ./tsconfig.json --threshold error && npm run css -- --minify && node esbuild.config.mjs production",
"build": "svelte-check --tsconfig ./tsconfig.json --threshold error && node scripts/css.mjs --minify && node esbuild.config.mjs production",
"test": "vitest run && node tests/smoke.mjs",
"preview": "node preview/serve.mjs"
},
+54 -10
View File
@@ -2,17 +2,46 @@ import { Component, TFile } from "obsidian";
import { mount } from "svelte";
import App from "../src/ui/app.svelte";
import { DEFAULT_SETTINGS } from "../src/settings";
import type { BeaverCalendarSettings } from "../src/settings";
import { BoardStore } from "../src/vault/store.svelte";
const BOARDS = ["personal", "work", "reading", "home"];
const FOLDER = "Boards";
const FIXTURES = ["personal", "work", "reading", "home"];
/**
* Settings come off the query string, so a screenshot can start from the
* same preferences a real vault has: ?groupBy=due&includeArchive=1&railCollapsed=1
*/
function prefsFromQuery(): Partial<BeaverCalendarSettings> {
const params = new URLSearchParams(location.search);
const out: Record<string, unknown> = {};
const flag = (value: string): boolean => value === "1" || value === "true";
for (const [key, value] of params) {
if (!(key in DEFAULT_SETTINGS)) continue;
const current = DEFAULT_SETTINGS[key as keyof BeaverCalendarSettings];
out[key] = typeof current === "boolean" ? flag(value) : value;
}
return out as Partial<BeaverCalendarSettings>;
}
async function main(): Promise<void> {
// Real boards when the server was given a folder, the fixtures otherwise.
const index = (await (await fetch("/boards.json")).json()) as {
folder: string;
names: string[];
} | null;
const folder = index?.folder ?? "Boards";
const sources = index
? index.names.map((name) => ({ path: `${folder}/${name}`, url: `/boards/${name}` }))
: FIXTURES.map((name) => ({
path: `${folder}/${name}.md`,
url: `/tests/fixtures/${name}.md`,
}));
const files = new Map<string, string>();
await Promise.all(
BOARDS.map(async (name) => {
const response = await fetch(`/tests/fixtures/${name}.md`);
files.set(`${FOLDER}/${name}.md`, await response.text());
sources.map(async ({ path, url }) => {
const response = await fetch(url);
files.set(path, await response.text());
}),
);
const handles = new Map(
@@ -37,7 +66,7 @@ async function main(): Promise<void> {
plugins: { plugins: {} },
};
const settings = { ...DEFAULT_SETTINGS, boardsFolder: FOLDER };
const settings = { ...DEFAULT_SETTINGS, boardsFolder: folder, ...prefsFromQuery() };
const store = new BoardStore(app as never, settings, () => {});
await store.reloadAll();
@@ -58,6 +87,14 @@ async function main(): Promise<void> {
await wait(50);
store.keyboard = true;
const first = store.spheres[0];
const tab = async (times: number): Promise<void> => {
for (let i = 0; i < times; i += 1) {
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", code: "Tab", bubbles: true }),
);
await wait(20);
}
};
if (scene.startsWith("rail")) {
store.toggleRail();
store.railExpanded = new Set([first.path, store.spheres[1].path]);
@@ -85,10 +122,17 @@ async function main(): Promise<void> {
if (scene === "date") {
store.write(null, "[data-composer='toolbar']");
await wait(50);
for (let i = 0; i < 3; i += 1) {
document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await wait(20);
}
await tab(3);
}
// The editor over an existing task, tabbed through to its date field.
if (scene === "edit-date") {
const task = store.visible[0];
store.zone = "task";
store.cursorTask = task.id;
await wait(20);
store.edit(task.id);
await wait(50);
await tab(3);
}
}
+2
View File
@@ -6,6 +6,8 @@
<link rel="stylesheet" href="/app.css" />
<link rel="stylesheet" href="/theme.css" />
<link rel="stylesheet" href="/styles.css" />
<!-- Other plugins' stylesheets (EXTRA_CSS), after ours like a later plugin. -->
<link rel="stylesheet" href="/extra.css" />
<style>
body.theme-dark,
body.theme-light {
+63 -1
View File
@@ -3,12 +3,13 @@ import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http";
import { homedir, tmpdir } from "node:os";
import { extname, join, normalize } from "node:path";
import { basename, extname, join, normalize, resolve } from "node:path";
import { extractFile } from "@electron/asar";
import esbuild from "esbuild";
import sveltePlugin from "esbuild-svelte";
@@ -65,6 +66,26 @@ if (!theme) {
);
}
/**
* Other plugins' stylesheets, loaded after ours the way Obsidian would load a
* plugin enabled later: EXTRA_CSS=path[,path…]. This is how a cascade fight
* with another Tailwind build is reproduced outside Obsidian.
*/
const extraCss = (process.env.EXTRA_CSS ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => resolve(item));
for (const file of extraCss) {
if (!existsSync(file)) console.warn(`preview: EXTRA_CSS not found: ${file}`);
}
/** Real boards instead of the fixtures: BOARDS=<folder of .md boards>. */
const boards = process.env.BOARDS ? resolve(process.env.BOARDS) : null;
if (boards && !existsSync(boards)) {
console.warn(`preview: BOARDS folder not found: ${boards}`);
}
const ctx = await esbuild.context({
entryPoints: [join(root, "preview/entry.ts")],
bundle: true,
@@ -85,9 +106,17 @@ const TYPES = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".md": "text/plain; charset=utf-8",
};
function boardNames() {
if (!boards || !existsSync(boards)) return null;
return readdirSync(boards)
.filter((name) => name.endsWith(".md"))
.sort();
}
createServer((request, response) => {
const url = decodeURIComponent((request.url ?? "/").split("?")[0]);
const external = { "/app.css": appCss, "/theme.css": theme };
@@ -103,6 +132,37 @@ createServer((request, response) => {
return;
}
if (url === "/extra.css") {
const body = extraCss
.filter((file) => existsSync(file))
.map((file) => `/* ${file} */\n${readFileSync(file, "utf8")}`)
.join("\n");
response
.writeHead(200, { "content-type": TYPES[".css"], "cache-control": "no-store" })
.end(body);
return;
}
if (url === "/boards.json") {
const names = boardNames();
const body = names ? { folder: basename(boards), names } : null;
response
.writeHead(200, { "content-type": TYPES[".json"], "cache-control": "no-store" })
.end(JSON.stringify(body));
return;
}
if (url.startsWith("/boards/") && boards) {
const file = join(boards, normalize(url.slice("/boards/".length)));
if (!file.startsWith(boards) || !existsSync(file) || statSync(file).isDirectory()) {
response.writeHead(404).end("not found");
return;
}
response.writeHead(200, { "content-type": TYPES[".md"], "cache-control": "no-store" });
createReadStream(file).pipe(response);
return;
}
const file = join(root, normalize(url === "/" ? "/preview/index.html" : url));
if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) {
response.writeHead(404).end("not found");
@@ -115,4 +175,6 @@ createServer((request, response) => {
createReadStream(file).pipe(response);
}).listen(port, () => {
console.log(`preview: http://localhost:${port}/`);
if (boards) console.log(`preview: boards from ${boards}`);
if (extraCss.length > 0) console.log(`preview: extra css ${extraCss.join(", ")}`);
});
+137
View File
@@ -0,0 +1,137 @@
// node preview/shot.mjs <out.png> <url> [width] [height]
//
// Headless Chrome over the DevTools protocol: a screenshot plus everything the
// page said on the console, and a note if the page stopped answering — which
// a plain --screenshot run would only report as a silent timeout.
// DUMP=1 also print the page text
// PROBE=js evaluate an expression at the end and print its value
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
const [out, url, w = "1100", h = "720"] = process.argv.slice(2);
if (!out || !url) {
console.error("usage: node preview/shot.mjs <out.png> <url> [width] [height]");
process.exit(2);
}
const port = 9334;
const chrome =
process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const proc = spawn(
chrome,
[
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
`--remote-debugging-port=${port}`,
`--window-size=${w},${h}`,
"--user-data-dir=/tmp/bcal-chrome-profile",
"about:blank",
],
{ stdio: "ignore" },
);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let targets = [];
for (let i = 0; i < 40; i += 1) {
try {
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
if (targets.length > 0) break;
} catch {
/* not up yet */
}
await wait(250);
}
const page = targets.find((target) => target.type === "page");
if (!page) {
console.error("shot: chrome did not come up");
proc.kill();
process.exit(1);
}
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve) => {
ws.onopen = resolve;
});
let id = 0;
const pending = new Map();
const logs = [];
ws.onmessage = (message) => {
const data = JSON.parse(String(message.data));
if (data.id && pending.has(data.id)) {
pending.get(data.id)(data);
pending.delete(data.id);
}
if (data.method === "Runtime.consoleAPICalled") {
logs.push(
`${data.params.type}: ${data.params.args
.map((arg) => arg.value ?? arg.description)
.join(" ")}`,
);
}
if (data.method === "Runtime.exceptionThrown") {
const details = data.params.exceptionDetails;
logs.push(`EXC: ${details.exception?.description ?? details.text}`);
}
};
const send = (method, params = {}, timeout = 15_000) =>
new Promise((resolve, reject) => {
const next = ++id;
const timer = setTimeout(() => {
pending.delete(next);
reject(new Error(`${method} timed out after ${timeout} ms`));
}, timeout);
pending.set(next, (value) => {
clearTimeout(timer);
resolve(value);
});
ws.send(JSON.stringify({ id: next, method, params }));
});
await send("Emulation.setDeviceMetricsOverride", {
width: Number(w),
height: Number(h),
deviceScaleFactor: 2,
mobile: false,
});
await send("Page.enable");
await send("Runtime.enable");
await send("Page.navigate", { url });
await wait(Number(process.env.SETTLE ?? 2500));
let alive = true;
try {
const ping = await send("Runtime.evaluate", { expression: "1 + 1", returnByValue: true }, 5000);
alive = ping.result?.result?.value === 2;
} catch {
alive = false;
}
if (!alive) {
console.log("shot: PAGE UNRESPONSIVE (main thread busy — a hang)");
}
if (alive && process.env.PROBE) {
const probe = await send("Runtime.evaluate", {
expression: process.env.PROBE,
returnByValue: true,
});
console.log("PROBE:", JSON.stringify(probe.result?.result?.value ?? probe.result));
}
if (alive && process.env.DUMP) {
const text = await send("Runtime.evaluate", {
expression: "document.body.innerText.slice(0, 1500)",
returnByValue: true,
});
console.log("TEXT:", text.result?.result?.value);
}
try {
const shot = await send("Page.captureScreenshot", { format: "png" }, 10_000);
writeFileSync(out, Buffer.from(shot.result.data, "base64"));
console.log("wrote", out);
} catch (error) {
console.log("shot: no screenshot —", error.message);
}
if (logs.length > 0) console.log("LOGS:\n" + logs.join("\n"));
ws.close();
proc.kill();
process.exit(alive ? 0 : 3);
+48
View File
@@ -0,0 +1,48 @@
// node scripts/css.mjs [--minify] [--watch]
//
// Tailwind writes to a scratch file; the scoped, optionally minified result
// is what lands in styles.css. See scope-css.mjs for why the extra step.
import { spawn, spawnSync } from "node:child_process";
import { readFileSync, watch, writeFileSync } from "node:fs";
import { join } from "node:path";
import { scopeCss } from "./scope-css.mjs";
const root = new URL("..", import.meta.url).pathname;
const args = new Set(process.argv.slice(2));
const minify = args.has("--minify");
const watching = args.has("--watch");
const input = join(root, "src/tailwind.css");
const scratch = join(root, ".tailwind.css");
const output = join(root, "styles.css");
const cli = join(root, "node_modules/.bin/tailwindcss");
function finish() {
const built = readFileSync(scratch, "utf8");
writeFileSync(output, scopeCss(built, { minify }));
console.log(`css: wrote styles.css (${minify ? "minified, " : ""}scoped)`);
}
if (!watching) {
const run = spawnSync(cli, ["-i", input, "-o", scratch], { stdio: "inherit" });
if (run.status !== 0) process.exit(run.status ?? 1);
finish();
} else {
writeFileSync(scratch, "");
const child = spawn(cli, ["-i", input, "-o", scratch, "--watch"], {
stdio: "inherit",
});
let timer = null;
watch(scratch, () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
try {
finish();
} catch (error) {
console.error("css:", error);
}
}, 60);
});
child.on("exit", (code) => process.exit(code ?? 0));
}
+71
View File
@@ -0,0 +1,71 @@
import { transform } from "lightningcss";
/**
* Obsidian loads every plugin's stylesheet into one document, and Tailwind
* puts its utilities into a cascade layer called `utilities` in each of them.
* Layers with the same name merge, so another Tailwind plugin's
* `.hidden{display:none!important}` and this plugin's
* `@container … .\@min-\[30rem\]\:inline-block{…!important}` end up in the
* same layer with the same specificity, and whichever plugin loaded last
* wins. Prefixing every selector with `:is(.bcal-root, .bcal-root *)` adds a
* class of specificity that no unscoped utility can match, whatever the load
* order, while still reaching the view root itself and the portal layer,
* both of which carry `.bcal-root`.
*
* Theme variables move from `:root, :host` onto `.bcal-root` for the same
* reason: a later `:root{--spacing:…}` from someone else must not resize us.
*/
const ROOT = "bcal-root";
const SCOPE = {
type: "pseudo-class",
kind: "is",
selectors: [
[{ type: "class", name: ROOT }],
[
{ type: "class", name: ROOT },
{ type: "combinator", value: "descendant" },
{ type: "universal" },
],
],
};
function isPseudo(selector, kind) {
return (
selector.length === 1 &&
selector[0].type === "pseudo-class" &&
selector[0].kind === kind
);
}
function mentions(selector, name) {
for (const part of selector) {
if (part.type === "class" && part.name === name) return true;
if (part.selectors?.some((inner) => mentions(inner, name))) return true;
}
return false;
}
function scopeSelector(selector) {
if (isPseudo(selector, "root")) return [{ type: "class", name: ROOT }];
// `:root, :host` is one rule; the root alone carries it, so :host goes.
if (isPseudo(selector, "host")) return [];
if (mentions(selector, ROOT)) return selector;
// `*` has to open a compound, and :is() takes that place now.
const rest = selector[0]?.type === "universal" ? selector.slice(1) : selector;
return [SCOPE, ...rest];
}
/** Rewrites a built stylesheet so its rules only reach the plugin's own DOM. */
export function scopeCss(code, { minify = false } = {}) {
// The selector visitor, not the rule one: handing a whole rule back makes
// lightningcss re-read its declarations, and some of Tailwind's it cannot.
const result = transform({
filename: "styles.css",
code: Buffer.from(code),
minify,
visitor: { Selector: scopeSelector },
});
return result.code.toString();
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { scopeCss } from "../scripts/scope-css.mjs";
const one = (css: string) => scopeCss(css).replace(/\s+/gu, " ").trim();
describe("scoping the built stylesheet", () => {
it("prefixes a utility with the view root, keeping the root itself in reach", () => {
expect(one(".flex{display:flex!important}")).toBe(
":is(.bcal-root, .bcal-root *).flex { display: flex !important; }",
);
});
it("outranks the same utility from another plugin's Tailwind", () => {
// Class specificity: theirs is (0,1,0); ours must be (0,2,0).
const out = one(".hidden{display:none!important}");
expect(out.startsWith(":is(.bcal-root, .bcal-root *).hidden")).toBe(true);
});
it("keeps pseudo-elements at the end where they are valid", () => {
expect(one(".x::placeholder{color:red}")).toBe(
":is(.bcal-root, .bcal-root *).x::placeholder { color: red; }",
);
expect(one(".y::first-letter{t:1}")).toContain(".y:first-letter");
});
it("scopes rules nested in container queries and layers", () => {
const out = one(
"@layer utilities{@container (min-width:30rem){.a{display:inline-block!important}}}",
);
// lightningcss rewrites the query into range syntax; Obsidian's Chromium
// has understood that inside @container since container queries landed.
expect(out).toContain("@container (width >= 30rem)");
expect(out).toContain(":is(.bcal-root, .bcal-root *).a");
});
it("drops a leading universal selector instead of producing `:is()*`", () => {
const out = one("*,:before,::backdrop{--tw-a:initial}");
expect(out).toContain(":is(.bcal-root, .bcal-root *),");
expect(out).toContain(":is(.bcal-root, .bcal-root *):before");
expect(out).not.toContain("*)*");
});
it("moves theme variables from :root onto the view root", () => {
expect(one(":root,:host{--spacing:.25rem}")).toBe(
".bcal-root { --spacing: .25rem; }",
);
});
it("leaves rules that already name the root alone", () => {
const reset = ".bcal-root.bcal-root button{cursor:pointer}";
expect(one(reset)).toBe(".bcal-root.bcal-root button { cursor: pointer; }");
expect(one(".theme-dark .bcal-root{--note:red}")).toBe(
".theme-dark .bcal-root { --note: red; }",
);
});
it("scopes variant selectors without breaking their inner :where()", () => {
const out = one(".dark\\:bg-x:where(.theme-dark,.theme-dark *){b:1}");
expect(out).toBe(
":is(.bcal-root, .bcal-root *).dark\\:bg-x:where(.theme-dark, .theme-dark *) { b: 1; }",
);
});
});