diff --git a/.gitignore b/.gitignore index 8a4f401..074c3f5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # build output (regenerated by `make install` / `make zip`) main.js styles.css +.tailwind.css *.zip preview/bundle.js tests/.entry.ts diff --git a/README.md b/README.md index 9933dc9..425f521 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Changing settings remounts open panels. ``` bun install bun run dev # css + esbuild watch -bun run build # svelte-check + css (minified) + bundle +bun run build # svelte-check + css (minified, scoped) + bundle bun run test # vitest (theme rules) + tests/smoke.mjs (jsdom, fake gateway) bun run preview # the panel in a browser, no Obsidian: http://localhost:4174/# make check # ultracite + svelte-check @@ -48,6 +48,8 @@ make zip Preview scenes (`#thread` default, `#switcher`, `#closed`, `#menu`, `#actions`, `#activity`, `#branch`, `#question`, `#deep`, `#empty`, `#offline`, `#stream`; suffix `-light` for the light theme). Width is the window: run headless Chrome with `--window-size=340,720` for the sidedock, `1100,720` for a tab, `390,800` for a phone. Set `VAULT=` or `THEME=` to preview against a community theme; `app.css` is read from the Obsidian install. +`styles.css` is Tailwind output run through `scripts/scope-css.mjs`: every selector gets `:is(.beaver-root, .beaver-root *)` in front. Other Tailwind plugins (beaver-calendar for one) put their utilities into the same `utilities` cascade layer, and without the extra specificity whichever plugin loaded last would win - its `.hidden` over ours and the other way round. + ## Install `make install VAULT=…` copies `manifest.json`, `main.js`, `styles.css`, `versions.json` into `/.obsidian/plugins/beaver`; then enable **Beaver** in Settings → Community plugins. On a phone, sync the four files with Obsidian Sync ("Installed community plugins") or any file sync, and point the settings at a URL the phone can reach. diff --git a/biome.jsonc b/biome.jsonc index 54b8d05..35c80aa 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -48,7 +48,7 @@ } }, { - "includes": ["tests/**", "preview/**"], + "includes": ["tests/**", "preview/**", "scripts/**"], "linter": { "rules": { "performance": { diff --git a/package.json b/package.json index 5e72841..36b67df 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,10 @@ "type": "module", "main": "main.js", "scripts": { - "build": "bun run check && bun run css -- --minify && node esbuild.config.mjs production", + "build": "bun run check && node scripts/css.mjs --minify && node esbuild.config.mjs production", "check": "svelte-check --tsconfig ./tsconfig.json --threshold error", - "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": "bun run css && node esbuild.config.mjs", "fix": "ultracite fix", "lint": "ultracite check", diff --git a/scripts/css.mjs b/scripts/css.mjs new file mode 100644 index 0000000..0ded834 --- /dev/null +++ b/scripts/css.mjs @@ -0,0 +1,54 @@ +// 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) { + 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)); +} else { + const run = spawnSync(cli, ["-i", input, "-o", scratch], { + stdio: "inherit", + }); + if (run.status !== 0) { + process.exit(run.status ?? 1); + } + finish(); +} diff --git a/scripts/scope-css.mjs b/scripts/scope-css.mjs new file mode 100644 index 0000000..fb2fcf7 --- /dev/null +++ b/scripts/scope-css.mjs @@ -0,0 +1,81 @@ +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(.beaver-root, .beaver-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 `.beaver-root`. + * + * Theme variables move from `:root, :host` onto `.beaver-root` for the same + * reason: a later `:root{--spacing:…}` from someone else must not resize us. + */ + +const ROOT = "beaver-root"; + +const SCOPE = { + kind: "is", + selectors: [ + [{ name: ROOT, type: "class" }], + [ + { name: ROOT, type: "class" }, + { type: "combinator", value: "descendant" }, + { type: "universal" }, + ], + ], + type: "pseudo-class", +}; + +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 [{ name: ROOT, type: "class" }]; + } + // `: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({ + code: Buffer.from(code), + filename: "styles.css", + minify, + visitor: { Selector: scopeSelector }, + }); + return result.code.toString(); +} diff --git a/tests/scope-css.test.ts b/tests/scope-css.test.ts new file mode 100644 index 0000000..ced7083 --- /dev/null +++ b/tests/scope-css.test.ts @@ -0,0 +1,67 @@ +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(.beaver-root, .beaver-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(.beaver-root, .beaver-root *).hidden")).toBe( + true + ); + }); + + it("keeps pseudo-elements at the end where they are valid", () => { + expect(one(".x::placeholder{color:red}")).toBe( + ":is(.beaver-root, .beaver-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(.beaver-root, .beaver-root *).a"); + }); + + it("drops a leading universal selector instead of producing `:is()*`", () => { + const out = one("*,:before,::backdrop{--tw-a:initial}"); + expect(out).toContain(":is(.beaver-root, .beaver-root *),"); + expect(out).toContain(":is(.beaver-root, .beaver-root *):before"); + expect(out).not.toContain("*)*"); + }); + + it("moves theme variables from :root onto the view root", () => { + expect(one(":root,:host{--spacing:.25rem}")).toBe( + ".beaver-root { --spacing: .25rem; }" + ); + }); + + it("leaves rules that already name the root alone", () => { + const reset = ".beaver-root.beaver-root button{cursor:pointer}"; + expect(one(reset)).toBe( + ".beaver-root.beaver-root button { cursor: pointer; }" + ); + expect(one(".theme-dark .beaver-root{--note:red}")).toBe( + ".theme-dark .beaver-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(.beaver-root, .beaver-root *).dark\\:bg-x:where(.theme-dark, .theme-dark *) { b: 1; }" + ); + }); +});