Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 65 additions & 0 deletions scripts/scope-utilities.mjs
Original file line number Diff line number Diff line change
@@ -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")));
}
33 changes: 33 additions & 0 deletions test/scope-utilities.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading