fix(css,preview): scope utilities under the view root, preview with real boards and foreign css
This commit is contained in:
1 parent
0a7742eb5e
commit
8c971e431a
10 files changed
+457
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
+53
-9
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(", ")}`);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user