From f91db17b03595d07c6ef86b96af48ae59280453d Mon Sep 17 00:00:00 2001 From: Stephen Date: Wed, 1 Jul 2026 20:20:54 -0700 Subject: [PATCH 1/3] fix(styles): scope bk-utilities layer to .bk-root / .bk-portal-content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiled stylesheet imports the full Tailwind utility set into a global `bk-utilities` layer. Because consumers import styles.css into the host document, those ~1485 generic utilities (`hidden`, `table-cell`, …) apply document-wide and override host styles, breaking Studio reveals. Add a postcss post-build step that wraps the `bk-utilities` layer's rules in `@scope (.bk-root, .bk-portal-content) { … }`, so utilities apply only under the package's own roots (and their subtree) and nowhere else. Bare `@scope` selectors match the scope root itself, so `.bk-root`'s own utilities still resolve. `bk-theme` vars stay global (no collisions). Closes ENG-5125. Co-Authored-By: Claude Opus 4.8 --- package.json | 3 ++- scripts/scope-utilities.mjs | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 scripts/scope-utilities.mjs 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/scripts/scope-utilities.mjs b/scripts/scope-utilities.mjs new file mode 100644 index 0000000..ea39657 --- /dev/null +++ b/scripts/scope-utilities.mjs @@ -0,0 +1,52 @@ +/** + * 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. See ENG-5125. + * + * 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 postcss from "postcss"; + +const file = process.argv[2] ?? "./dist/styles.css"; +const root = postcss.parse(readFileSync(file, "utf8"), { from: file }); + +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}`, + ); +} + +writeFileSync(file, root.toString()); From 6874a158b80065c7e72b0ad49442a46232193b63 Mon Sep 17 00:00:00 2001 From: Stephen Date: Wed, 1 Jul 2026 20:22:48 -0700 Subject: [PATCH 2/3] build: update lockfile for postcss devDependency Add the postcss direct-dependency specifier so `pnpm install --frozen-lockfile` passes in CI. Resolves to 8.5.15, already present transitively. Co-Authored-By: Claude Opus 4.8 --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) 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 From 8e10dae9df5b7f2ae0164fbdd0cd82a8c889539a Mon Sep 17 00:00:00 2001 From: Stephen Date: Wed, 1 Jul 2026 20:27:04 -0700 Subject: [PATCH 3/3] test(styles): cover scope-utilities build step Extract the layer-wrapping logic into an exported pure `scopeUtilities` function and add a unit test: verifies utilities get wrapped in @scope, the ordering statement and bk-theme layer stay global, and the one-body-expected guard throws. Also drop the stray ticket ref from the file header comment. Co-Authored-By: Claude Opus 4.8 --- scripts/scope-utilities.mjs | 55 ++++++++++++++++++++++-------------- test/scope-utilities.test.ts | 33 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 test/scope-utilities.test.ts diff --git a/scripts/scope-utilities.mjs b/scripts/scope-utilities.mjs index ea39657..9058670 100644 --- a/scripts/scope-utilities.mjs +++ b/scripts/scope-utilities.mjs @@ -16,37 +16,50 @@ * `.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. See ENG-5125. + * 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"; -const file = process.argv[2] ?? "./dist/styles.css"; -const root = postcss.parse(readFileSync(file, "utf8"), { from: file }); +/** + * 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; + 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: [], + 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++; }); - 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}`, - ); + if (wrapped !== 1) { + throw new Error( + `scope-utilities: expected exactly 1 bk-utilities layer with a body, wrapped ${wrapped}`, + ); + } + + return root.toString(); } -writeFileSync(file, 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/); + }); +});