fix(css): scope utilities under the view root so tailwind plugins stop outranking each other
This commit is contained in:
@@ -4,6 +4,7 @@ node_modules/
|
|||||||
# build output (regenerated by `make install` / `make zip`)
|
# build output (regenerated by `make install` / `make zip`)
|
||||||
main.js
|
main.js
|
||||||
styles.css
|
styles.css
|
||||||
|
.tailwind.css
|
||||||
*.zip
|
*.zip
|
||||||
preview/bundle.js
|
preview/bundle.js
|
||||||
tests/.entry.ts
|
tests/.entry.ts
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Changing settings remounts open panels.
|
|||||||
```
|
```
|
||||||
bun install
|
bun install
|
||||||
bun run dev # css + esbuild watch
|
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 test # vitest (theme rules) + tests/smoke.mjs (jsdom, fake gateway)
|
||||||
bun run preview # the panel in a browser, no Obsidian: http://localhost:4174/#<scene>
|
bun run preview # the panel in a browser, no Obsidian: http://localhost:4174/#<scene>
|
||||||
make check # ultracite + svelte-check
|
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.
|
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
|
## Install
|
||||||
|
|
||||||
`make install VAULT=…` copies `manifest.json`, `main.js`, `styles.css`, `versions.json` into `<vault>/.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.
|
`make install VAULT=…` copies `manifest.json`, `main.js`, `styles.css`, `versions.json` into `<vault>/.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.
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"includes": ["tests/**", "preview/**"],
|
"includes": ["tests/**", "preview/**", "scripts/**"],
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"performance": {
|
"performance": {
|
||||||
|
|||||||
+3
-3
@@ -6,10 +6,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"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",
|
"check": "svelte-check --tsconfig ./tsconfig.json --threshold error",
|
||||||
"css": "tailwindcss -i src/tailwind.css -o styles.css",
|
"css": "node scripts/css.mjs",
|
||||||
"css:watch": "tailwindcss -i src/tailwind.css -o styles.css --watch",
|
"css:watch": "node scripts/css.mjs --watch",
|
||||||
"dev": "bun run css && node esbuild.config.mjs",
|
"dev": "bun run css && node esbuild.config.mjs",
|
||||||
"fix": "ultracite fix",
|
"fix": "ultracite fix",
|
||||||
"lint": "ultracite check",
|
"lint": "ultracite check",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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; }"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user