diff --git a/package.json b/package.json index 4dba801..161125b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "build": "pnpm run build:js && pnpm run build:css", "build:clean": "rm -rf dist && pnpm run build", "build:js": "tsup", - "build:css": "tailwindcss -i ./src/styles.src.css -o ./dist/styles.css --minify", + "build:css": "tailwindcss -i ./src/styles.src.css -o ./dist/styles.css --minify && node ./scripts/scope-utilities.mjs", "dev": "tsup --watch", "dev:css": "tailwindcss -i ./src/styles.src.css -o ./dist/styles.css --watch --minify", "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json", @@ -103,6 +103,7 @@ "lefthook": "^2.1.6", "lucide-react": "^1.16.0", "playwright": "^1.60.0", + "postcss": "^8.5.15", "react": "^19.2.6", "react-dom": "^19.2.6", "slack-web-api-client": "^1.1.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de16f0a..1dd92a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: playwright: specifier: ^1.60.0 version: 1.60.0 + postcss: + specifier: ^8.5.15 + version: 8.5.15 react: specifier: ^19.2.6 version: 19.2.7 diff --git a/scripts/scope-utilities.mjs b/scripts/scope-utilities.mjs new file mode 100644 index 0000000..9058670 --- /dev/null +++ b/scripts/scope-utilities.mjs @@ -0,0 +1,65 @@ +/** + * Post-build step: scope the `bk-utilities` cascade layer to this package's + * own roots so its ~1485 generic Tailwind utilities apply only under + * `.bk-root` / `.bk-portal-content` (and their descendants), never to the + * whole host document. + * + * Runs after `tailwindcss --minify` (see the `build:css` script). We parse the + * compiled stylesheet, find the single `@layer bk-utilities { … }` block, and + * wrap its rules in `@scope (.bk-root, .bk-portal-content) { … }`: + * + * @layer bk-utilities { + * @scope (.bk-root, .bk-portal-content) { …utilities… } + * } + * + * `@scope` bare selectors match the scope root *and* its subtree, so + * `.bk-root`'s own utilities (`flex h-full …`) still resolve — a plain + * descendant-combinator wrap would miss the root elements. `bk-theme` (CSS + * custom properties) and Tailwind's `properties` layer stay global; vars don't + * collide. + * + * We parse structure (not regex) so the already-minified input stays safe; + * postcss preserves each rule's minified raws, so no re-minify step is needed. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import postcss from "postcss"; + +/** + * Wrap the `bk-utilities` layer's rules in an `@scope` at-rule. Pure string -> + * string; throws unless exactly one utilities-layer body is found (guards + * against a future Tailwind change to how the layer is emitted). + */ +export function scopeUtilities(css) { + const root = postcss.parse(css); + + let wrapped = 0; + root.walkAtRules("layer", (layer) => { + // Skip the `@layer bk-theme, bk-utilities;` ordering statement (no body). + if (layer.params.trim() !== "bk-utilities" || !layer.nodes?.length) return; + + const scope = postcss.atRule({ + name: "scope", + params: "(.bk-root, .bk-portal-content)", + nodes: [], + }); + layer.each((child) => scope.append(child.clone())); + layer.removeAll(); + layer.append(scope); + wrapped++; + }); + + if (wrapped !== 1) { + throw new Error( + `scope-utilities: expected exactly 1 bk-utilities layer with a body, wrapped ${wrapped}`, + ); + } + + return root.toString(); +} + +// CLI entry: only run when invoked directly, not when imported by tests. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const target = process.argv[2] ?? "./dist/styles.css"; + writeFileSync(target, scopeUtilities(readFileSync(target, "utf8"))); +} diff --git a/test/scope-utilities.test.ts b/test/scope-utilities.test.ts new file mode 100644 index 0000000..a260cd6 --- /dev/null +++ b/test/scope-utilities.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +// @ts-expect-error — plain .mjs build script, no types +import { scopeUtilities } from '../scripts/scope-utilities.mjs'; + +// Mirrors the shape Tailwind emits: an ordering statement, a global theme +// layer, then the utilities layer we want scoped. +const INPUT = [ + '@layer bk-theme, bk-utilities;', + '@layer bk-theme{:root{--color-primary:blue}}', + '@layer bk-utilities{.flex{display:flex}.hidden{display:none}}' +].join(''); + +describe('scopeUtilities', () => { + const out = scopeUtilities(INPUT); + + it("wraps the utilities layer body in an @scope rooted on the package's roots", () => { + expect(out).toContain('@layer bk-utilities{@scope (.bk-root, .bk-portal-content){'); + // utilities are preserved verbatim inside the scope + expect(out).toContain('.flex{display:flex}'); + expect(out).toContain('.hidden{display:none}'); + }); + + it('leaves the ordering statement and the theme layer global', () => { + expect(out).toContain('@layer bk-theme, bk-utilities;'); + // theme layer is untouched — not wrapped in @scope + expect(out).toContain('@layer bk-theme{:root{--color-primary:blue}}'); + expect(out.match(/@scope/g)).toHaveLength(1); + }); + + it('throws if there is no bk-utilities layer body (guards Tailwind emission changes)', () => { + expect(() => scopeUtilities('@layer bk-theme{:root{}}')).toThrow(/expected exactly 1 bk-utilities layer/); + }); +});