diff --git a/.changeset/4929-plugin-published-stylesheets.md b/.changeset/4929-plugin-published-stylesheets.md new file mode 100644 index 0000000000..b2cb6e0b03 --- /dev/null +++ b/.changeset/4929-plugin-published-stylesheets.md @@ -0,0 +1,52 @@ +--- +'@object-ui/plugin-grid': patch +'@object-ui/plugin-kanban': patch +--- + +`@object-ui/plugin-grid` and `@object-ui/plugin-kanban` now publish a stylesheet — +`"./style.css"`, mapped to `dist/index.css` and compiled at build time from the package's +own sources (objectui#4929, maintainer ruling 2026-08-17, Direction 1). + +**What was broken.** Only `@object-ui/components` and `@object-ui/fields` shipped CSS, and +each scans its own `src` only, so a class used exclusively by a plugin could not appear in +either sheet BY CONSTRUCTION. A published-state Vite app that installed one of these two +plugins and followed the quick-start rendered the grid or the board with **25 themed +utilities that had no source anywhere in the world** — `bg-muted/10`, `bg-card/60`, +`text-muted-foreground/60`, `ring-primary/40` and friends, ordinary appearance classes — +plus ~103 plain ones. Re-measured on the merged tree: the 21 the card listed all still hold, +and four more (`[&>h3]:text-foreground/80`, `border-l-primary/40`, `border-primary/30`, +`hover:text-primary`) that its literal-grep method could not see. + +The plain utilities a consumer could in principle regenerate by pointing `@source` at the +package's `dist`. The themed ones they cannot, at all: they resolve `@theme` tokens declared +in `packages/components/src/index.css`, which that package does not publish. A build inside +this monorepo is their only possible producer — which is why the fix is a stylesheet we +ship, not documentation teaching consumers to hand-declare the theme and scan +`node_modules` (the advice objectui#4858 had just retired from the guides). + +**The shape**, inherited from `@object-ui/fields` (objectui#4059): each package gains +`src/index.css` that `@reference`s the components entry — theme tokens, the class-based +`dark` variant and the animate plugin become available for resolution while emitting +nothing — plus `scripts/build-css.mjs`, which subtracts every rule components' published +sheet already ships. So these are **supplements, imported after** the components sheet, and +they are 16.30 kB and 11.41 kB rather than another ~170 kB each: + +```css +@import 'tailwindcss'; +@import '@object-ui/components/style.css'; +@import '@object-ui/fields/style.css'; +@import '@object-ui/plugin-grid/style.css'; +@import '@object-ui/plugin-kanban/style.css'; +``` + +Add a line only for the plugins you install; no other `@object-ui/plugin-*` package +publishes a stylesheet yet. The build step is shared +(`scripts/build-plugin-stylesheet.mjs`) so it is the pattern the next one inherits rather +than a file to copy, and it refuses to write a sheet that fails any of four assertions — no +rule may vanish, the subtraction must have removed something, the class count may not pass +a leak ceiling, and named themed utilities only this build can produce must still be +present. + +Nothing is removed and no existing import changes: a consumer who does not import the new +sheets is exactly where they were, and the guides' "do not scan `node_modules`" advice +stays correct — it is now correct for plugins too. diff --git a/content/docs/guide/plugins.md b/content/docs/guide/plugins.md index 716d1ed79b..3ab1f2cc10 100644 --- a/content/docs/guide/plugins.md +++ b/content/docs/guide/plugins.md @@ -199,6 +199,23 @@ Kanban board component with drag-and-drop powered by @dnd-kit. ## How Plugins Work +### Stylesheets + +A plugin's JavaScript is only half of what it renders with. `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` publish a `style.css` of their own, and an app that installs one must import it after the base sheets: + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-grid/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + +Each plugin sheet is compiled against the components theme and then has every rule that sheet already ships subtracted from it, so it carries only what the plugin adds. That includes the themed utilities (`bg-muted/10`, `bg-card/60`, `ring-primary/40`) which **no consumer-side configuration can produce** — the `@theme` block declaring their tokens lives in package source that is not published, so scanning `node_modules` cannot reach it ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). Skip the import and the view renders unstyled. + +Add a line only for the plugins you install. The other `@object-ui/plugin-*` packages do not publish a stylesheet yet; the build step above is the pattern each of them will adopt when it needs one. + ### Lazy Loading Architecture Plugins use React's `lazy()` and `Suspense` to load heavy dependencies on-demand: diff --git a/content/docs/guide/quick-start.md b/content/docs/guide/quick-start.md index 3d4cf4714a..52e000b5f9 100644 --- a/content/docs/guide/quick-start.md +++ b/content/docs/guide/quick-start.md @@ -63,6 +63,15 @@ Each `style.css` is a stylesheet the package compiles from its own sources at bu **Import them in that order.** `@object-ui/components/style.css` is the complete sheet: Tailwind's base layer, the `@theme` tokens and the utilities its components use. `@object-ui/fields/style.css` is a small supplement on top of it — only the ~155 utilities the field widgets add and the components sheet does not already carry, which is why it is a few kB rather than another 170. It is not a standalone stylesheet, and on its own it will not style anything. +**Plugin packages that publish a stylesheet need one line each.** `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` ship the same kind of supplement, built the same way, so add whichever of them you install: + +```css +@import "@object-ui/plugin-grid/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + +Without that line the plugin renders with no themed styling at all — its `bg-muted/10`, `bg-card/60` and `text-muted-foreground/60` have no other source in a published app, because the `@theme` block they resolve lives in package source that is never published ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). The remaining `@object-ui/plugin-*` packages ship no stylesheet yet; importing one that does not exist breaks the build, so add only the lines above. + That is the whole styling setup: you do not add `@source` lines for the ObjectUI packages, and pointing Tailwind at them inside `node_modules` only regenerates utilities these imports already gave you. ## Step 4: Render Your First Schema diff --git a/content/docs/guide/theming.md b/content/docs/guide/theming.md index a3e026bca0..43cb1ee05c 100644 --- a/content/docs/guide/theming.md +++ b/content/docs/guide/theming.md @@ -80,6 +80,15 @@ Each `style.css` is a real export, mapped to that package's `dist/index.css` and `@object-ui/fields/style.css` is a supplement, and the order matters: it is compiled against the components theme and then has every rule that sheet already ships subtracted from it, so it contains only the utilities the field widgets add — the tag colour map, the signature canvas cursor, the rating hover states, and 17 themed utilities such as `hover:bg-accent/30` and `ring-destructive/50` that no consumer-side configuration can generate, because the tokens they resolve live in unpublished package source. Import it before the components sheet, or alone, and those rules resolve against tokens that are not there yet. +`@object-ui/plugin-grid/style.css` and `@object-ui/plugin-kanban/style.css` are the same shape again, one per plugin you install: + +```css +@import "@object-ui/plugin-grid/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + +Each is compiled against the components theme and then has that sheet's rules subtracted, which is why they are ~16 kB and ~11 kB rather than another 170 each. Between them they carry the 25 themed utilities the two plugins use and neither base sheet contains — `bg-muted/10`, `bg-card/60`, `ring-primary/40` and friends — which nothing on your side can generate ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). No other `@object-ui/plugin-*` package publishes a stylesheet yet. + Do **not** point Tailwind at the packages inside `node_modules` — neither with a v4 `@source` line nor a v3 `content` entry. Scanning the published files regenerates the shape-only utilities (`inline-flex`, `rounded-md`, `h-9`) the two sheets already contain, and it cannot produce the themed ones at all: the `@theme` block they come from lives in package source, which is not published. Your Tailwind entry goes on generating the classes *your* source uses, exactly as before. To recolour ObjectUI, override the token values rather than the utilities — either the `:root` custom properties shown above, or a `Theme` object handed to `ThemeProvider` (see below). Both re-theme every component without any scanning. diff --git a/content/docs/guide/troubleshooting.md b/content/docs/guide/troubleshooting.md index 159afa3518..ff84bc2790 100644 --- a/content/docs/guide/troubleshooting.md +++ b/content/docs/guide/troubleshooting.md @@ -57,7 +57,14 @@ npx objectui doctor @import '@object-ui/fields/style.css'; ``` -Two packages publish a `style.css`: `@object-ui/components` (the base sheet — theme tokens, base layer, its own utilities) and `@object-ui/fields` (a supplement carrying only what the field widgets add). The fields sheet is built by subtracting everything the components sheet already ships, so it must come **after** it; on its own it styles almost nothing. +`@object-ui/components` publishes the base sheet — theme tokens, base layer, its own utilities — and every other sheet is a supplement built by subtracting everything the base already ships, so each must come **after** it and none styles much on its own. `@object-ui/fields` carries what the field widgets add. `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` carry what those two plugins add; add a line for each plugin package you install: + +```css +@import '@object-ui/plugin-grid/style.css'; +@import '@object-ui/plugin-kanban/style.css'; +``` + +If a grid or a kanban board specifically looks wrong — column headers and card surfaces flat, drag feedback and selection rings missing — that plugin's sheet is the missing import. It did not exist before either: those packages emitted no CSS at all until [#4929](https://github.com/objectstack-ai/objectui/issues/4929), so on earlier versions the subpath does not resolve and upgrading is the fix, not a scanning path. The remaining `@object-ui/plugin-*` packages still publish no stylesheet. If field widgets specifically look wrong — tag and badge colours flat, the rating stars not reacting to hover, the signature pad showing the wrong cursor — the fields import is the one that is missing. Note that it genuinely did not exist before: every release up to and including 17.3.0 declared the `@object-ui/fields/style.css` subpath while shipping no stylesheet at all ([#4059](https://github.com/objectstack-ai/objectui/issues/4059)), so on those versions the import fails to resolve and breaks the build. Upgrade rather than adding scanning paths. diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 9c2c4adfb9..6aa2b7ec77 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -13,6 +13,16 @@ Advanced data grid with sorting, filtering, pagination, and row selection capabi npm install @object-ui/plugin-grid ``` +This package publishes a stylesheet. Import it after the base sheets, or the grid renders unstyled — the themed utilities it uses have no other source in a published app ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)): + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-grid/style.css"; +``` + ## Interactive Examples diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index 5c95ea536a..8be65f9f2e 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -13,6 +13,16 @@ Kanban board component with drag-and-drop powered by @dnd-kit. npm install @object-ui/plugin-kanban ``` +This package publishes a stylesheet. Import it after the base sheets, or the board renders unstyled — the themed utilities it uses have no other source in a published app ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)): + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + ## Interactive Examples diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 3498960275..df515ca6d9 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -18,6 +18,19 @@ Grid plugin for Object UI - Advanced data grid with sorting, filtering, and pagi pnpm add @object-ui/plugin-grid ``` +Then import the stylesheet this package publishes, after the base sheets. It is a +supplement — compiled against the `@object-ui/components` theme with that sheet's +rules subtracted — so the order matters, and without it the grid renders with no +themed styling at all ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)): + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-grid/style.css"; +``` + ## Usage ### Registration is a side effect of the import diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json index 7883e0cd20..3fc6d8ff2a 100644 --- a/packages/plugin-grid/package.json +++ b/packages/plugin-grid/package.json @@ -12,10 +12,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.umd.cjs" - } + }, + "./style.css": "./dist/index.css" }, "scripts": { - "build": "vite build", + "build": "vite build && node scripts/build-css.mjs", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." @@ -42,8 +43,11 @@ "@object-ui/data-objectstack": "workspace:*", "@object-ui/sdui-parser": "workspace:*", "@objectstack/spec": "^17.0.0", + "@tailwindcss/postcss": "^4.3.3", "@vitejs/plugin-react": "^6.0.5", "msw": "^2.15.0", + "postcss": "^8.5.26", + "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "vite": "^8.2.1", "vite-plugin-dts": "^5.0.3" diff --git a/packages/plugin-grid/scripts/build-css.mjs b/packages/plugin-grid/scripts/build-css.mjs new file mode 100644 index 0000000000..9aa0d0b558 --- /dev/null +++ b/packages/plugin-grid/scripts/build-css.mjs @@ -0,0 +1,77 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Builds `dist/index.css` for `@object-ui/plugin-grid` — the CSS half of this + * package's `build` script, run after `vite build`. + * + * All of the reasoning lives in two places and neither is repeated here: + * `src/index.css`'s header explains the narrow `@reference` shape, and + * `scripts/build-plugin-stylesheet.mjs` at the repository root explains the + * subtraction and the assertions that guard it (objectui#4929). This file holds + * only what is specific to THIS package. + */ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import postcss from 'postcss'; +import tailwind from '@tailwindcss/postcss'; + +import { isEntrypoint } from '../../../scripts/invoked-as.mjs'; +import { createPluginStylesheetBuilder } from '../../../scripts/build-plugin-stylesheet.mjs'; + +export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const PACKAGE_NAME = '@object-ui/plugin-grid'; + +/** + * Utilities that MUST survive the subtraction, spanning both reasons a rule can + * be plugin-only. + * + * The first three resolve `@theme` tokens `@object-ui/components` declares but + * does not publish, so this build is the only producer they can ever have — if + * the subtraction over-reaches, these are what silently disappear, and no test + * that renders a grid in this repo would notice (every in-repo host compiles + * this package's source directly and never loads this sheet). The last three are + * plain utilities that simply are not in components' sheet. + * + * Deliberately a handful of named specimens, not a count: a threshold would have + * to be re-tuned every time a column renderer gains a class, and the edit that + * silences a real regression would look exactly like the edit that keeps it + * current. + */ +export const MUST_SURVIVE = [ + 'divide-border/50', + 'focus:ring-destructive/30', + 'text-muted-foreground/80', + 'border-l-[3px]', + 'bg-emerald-500/5', + 'dark:bg-slate-950/40', +]; + +/** + * Leak ceiling, not a budget. Measured on this card: 126 classes survive the + * subtraction; a lost `source(none)` would put it in the thousands. + */ +export const CLASS_CEILING = 600; + +export const builder = createPluginStylesheetBuilder({ postcss, tailwind }); + +export const buildOptions = { + packageRoot: PACKAGE_ROOT, + packageName: PACKAGE_NAME, + mustSurvive: MUST_SURVIVE, + classCeiling: CLASS_CEILING, +}; + +if (isEntrypoint(import.meta.url)) { + const { css, survivors, survivingClasses, droppedRules, droppedAtRules } = + await builder.build(buildOptions); + console.log( + `✓ built dist/index.css (${(css.length / 1024).toFixed(2)} kB) — ` + + `${survivors.size} rules kept (${survivingClasses.size} classes), ` + + `${droppedRules} rules + ${droppedAtRules} at-rules already in @object-ui/components' sheet`, + ); +} diff --git a/packages/plugin-grid/src/index.css b/packages/plugin-grid/src/index.css new file mode 100644 index 0000000000..5952ca0310 --- /dev/null +++ b/packages/plugin-grid/src/index.css @@ -0,0 +1,92 @@ +/* + * Tailwind entry for `@object-ui/plugin-grid` — BUILD-TIME ONLY. + * + * Consumers never compile this file. They import the compiled artifact, and the + * order is part of the contract: + * + * @import '@object-ui/components/style.css'; + * @import '@object-ui/plugin-grid/style.css'; <- ./dist/index.css, built from this + * + * ## Why this file exists (objectui#4929) + * + * Until it did, a published-state consumer had NO source for this plugin's + * themed utilities. `@object-ui/components` and `@object-ui/fields` were the only + * packages shipping a stylesheet, and each scans its own `src` only, so a class + * used exclusively here could not appear in either by construction. `divide-border/50`, `text-muted-foreground/80` and `focus:ring-destructive/30` + * are ordinary appearance classes, not edge cases, and they rendered unstyled. + * + * Measured on this package at the commit that added this file: 76 plain and + * 7 themed utilities compile here and appear in neither published sheet. The + * plain ones a consumer could in principle regenerate by pointing `@source` at + * this package's `dist`. The themed ones they cannot: those resolve `@theme` + * tokens declared in `packages/components/src/index.css`, which that package does + * not publish (it ships `dist` only). A build inside this monorepo is their only + * possible producer — which is why the fix is a stylesheet we ship rather than a + * documentation note telling consumers to scan `node_modules` and hand-declare + * the theme, a direction objectui#4858 had just retired from the guides and the + * 2026-08-17 ruling rejected by name. + * + * ## Why the narrow "subtraction" shape (objectui#4059, `@object-ui/fields`) + * + * `@reference` gives this compilation `@object-ui/components`' theme tokens, its + * class-based `dark` variant and its `tailwindcss-animate` plugin WITHOUT emitting + * a single byte of them. So the sheet built from this entry carries utilities + * only — no preflight, no `@theme` `:root` block, no base layer; + * `scripts/build-css.mjs` then subtracts every rule components' published sheet + * already ships. + * + * The two halves defend different things, and the division is worth stating + * because it was measured rather than assumed. The SUBTRACTION is what removes + * the ~1350 shared utilities: without it this package would publish a 172 kB + * near-copy of the components sheet instead of a 16 kB supplement. The NARROW + * ENTRY is what makes preflight and the `@theme` `:root` block impossible to + * emit in the first place — swapping it for a plain `@import 'tailwindcss'` was + * tried on this card (on the sibling plugin) and produced a published artifact + * of the same size, because the subtraction removes the duplicates too, + * which is precisely the point: that shape would leave a re-emitted theme block + * one Tailwind-emission change away from shipping, and a `:root` theme block + * loaded AFTER the base sheet overrides the consumer's own token overrides. + * Belt and braces, on purpose. + * + * The import ORDER above is not cosmetic either: this sheet is a supplement to + * the components sheet, never a replacement for it. + * + * ## What is subtracted — and what deliberately is not + * + * `scripts/build-css.mjs` subtracts `@object-ui/components`' sheet and nothing + * else. That is the one sheet every consumer of this plugin is guaranteed to have: + * components is a hard dependency of this package and its stylesheet is the + * quick-start's first import. This package also depends on `@object-ui/fields`, which ships a + * sheet of its own — subtracting THAT one too would shave a few hundred bytes and + * buy a second ordering requirement, making this sheet silently wrong for anyone + * who imports it without that one. Not worth it, and not what the ruling scoped. + */ +@reference '../../components/src/index.css'; + +/* + * `source(none)` is load-bearing, not tidiness. Tailwind's automatic source + * detection resolves against a base directory that defaults to the PROCESS CWD, + * and the `@reference` above pulls in components' entry, whose own + * `@import 'tailwindcss'` turns that detection on. Without `source(none)` the + * candidate set therefore depends on where the build was launched from — same + * commit, same command, two different published artifacts (measured for + * `@object-ui/fields`: 21 kB from its own directory, 287 kB from the repo root, + * objectui#4059; re-measured on components' entry for this card: 162 kB vs 432 kB). + * + * With it, the ONLY inputs are components' own `@source` line (reached through the + * `@reference`, and itself relative to that file) and the explicit lines below, so + * the output is byte-identical from any working directory. `scripts/__tests__/plugin-published-stylesheet.test.ts` + * asserts that byte-identity rather than trusting this comment. + */ +@import 'tailwindcss/utilities.css' layer(utilities) source(none); + +/* + * Only shipped source. A `*.test.tsx` never reaches a consumer, and neither does a + * helper that lives beside one (`src/__tests__/explainDouble.ts`), so a utility used solely by tests must not + * become a published byte. (Measured today the exclusions remove nothing — every + * class the tests use is also used by shipped source. They are here so that stays + * true by construction rather than by luck.) + */ +@source './**/*.{ts,tsx}'; +@source not './**/*.test.{ts,tsx}'; +@source not './**/__tests__/**'; diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index 0c669ac7b9..5d17050c6a 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -18,6 +18,19 @@ A lazy-loaded kanban board component for Object UI based on @dnd-kit for drag-an pnpm add @object-ui/plugin-kanban ``` +Then import the stylesheet this package publishes, after the base sheets. It is a +supplement — compiled against the `@object-ui/components` theme with that sheet's +rules subtracted — so the order matters, and without it the board renders with no +themed styling at all ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)): + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + ## Usage ### Automatic Registration (Side-Effect Import) diff --git a/packages/plugin-kanban/package.json b/packages/plugin-kanban/package.json index d30f7bbf50..1093e732f2 100644 --- a/packages/plugin-kanban/package.json +++ b/packages/plugin-kanban/package.json @@ -21,10 +21,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.umd.cjs" - } + }, + "./style.css": "./dist/index.css" }, "scripts": { - "build": "vite build", + "build": "vite build && node scripts/build-css.mjs", "test": "vitest run", "test:watch": "vitest", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", @@ -50,9 +51,12 @@ }, "devDependencies": { "@object-ui/data-objectstack": "workspace:*", + "@tailwindcss/postcss": "^4.3.3", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "^6.0.5", + "postcss": "^8.5.26", + "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "vite": "^8.2.1", "vite-plugin-dts": "^5.0.3" diff --git a/packages/plugin-kanban/scripts/build-css.mjs b/packages/plugin-kanban/scripts/build-css.mjs new file mode 100644 index 0000000000..46011fb439 --- /dev/null +++ b/packages/plugin-kanban/scripts/build-css.mjs @@ -0,0 +1,77 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Builds `dist/index.css` for `@object-ui/plugin-kanban` — the CSS half of this + * package's `build` script, run after `vite build`. + * + * All of the reasoning lives in two places and neither is repeated here: + * `src/index.css`'s header explains the narrow `@reference` shape, and + * `scripts/build-plugin-stylesheet.mjs` at the repository root explains the + * subtraction and the assertions that guard it (objectui#4929). This file holds + * only what is specific to THIS package. + */ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import postcss from 'postcss'; +import tailwind from '@tailwindcss/postcss'; + +import { isEntrypoint } from '../../../scripts/invoked-as.mjs'; +import { createPluginStylesheetBuilder } from '../../../scripts/build-plugin-stylesheet.mjs'; + +export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const PACKAGE_NAME = '@object-ui/plugin-kanban'; + +/** + * Utilities that MUST survive the subtraction, spanning both reasons a rule can + * be plugin-only. + * + * The first three resolve `@theme` tokens `@object-ui/components` declares but + * does not publish, so this build is the only producer they can ever have — if + * the subtraction over-reaches, these are what silently disappear, and no test + * that renders a board in this repo would notice (every in-repo host compiles + * this package's source directly and never loads this sheet). The last three are + * plain utilities that simply are not in components' sheet. + * + * Deliberately a handful of named specimens, not a count: a threshold would have + * to be re-tuned every time a card renderer gains a class, and the edit that + * silences a real regression would look exactly like the edit that keeps it + * current. + */ +export const MUST_SURVIVE = [ + 'bg-muted/10', + 'bg-card/60', + 'shadow-primary/25', + 'cursor-grabbing', + '[writing-mode:vertical-rl]', + 'snap-mandatory', +]; + +/** + * Leak ceiling, not a budget. Measured on this card: 50 classes survive the + * subtraction; a lost `source(none)` would put it in the thousands. + */ +export const CLASS_CEILING = 600; + +export const builder = createPluginStylesheetBuilder({ postcss, tailwind }); + +export const buildOptions = { + packageRoot: PACKAGE_ROOT, + packageName: PACKAGE_NAME, + mustSurvive: MUST_SURVIVE, + classCeiling: CLASS_CEILING, +}; + +if (isEntrypoint(import.meta.url)) { + const { css, survivors, survivingClasses, droppedRules, droppedAtRules } = + await builder.build(buildOptions); + console.log( + `✓ built dist/index.css (${(css.length / 1024).toFixed(2)} kB) — ` + + `${survivors.size} rules kept (${survivingClasses.size} classes), ` + + `${droppedRules} rules + ${droppedAtRules} at-rules already in @object-ui/components' sheet`, + ); +} diff --git a/packages/plugin-kanban/src/index.css b/packages/plugin-kanban/src/index.css new file mode 100644 index 0000000000..0fad430842 --- /dev/null +++ b/packages/plugin-kanban/src/index.css @@ -0,0 +1,92 @@ +/* + * Tailwind entry for `@object-ui/plugin-kanban` — BUILD-TIME ONLY. + * + * Consumers never compile this file. They import the compiled artifact, and the + * order is part of the contract: + * + * @import '@object-ui/components/style.css'; + * @import '@object-ui/plugin-kanban/style.css'; <- ./dist/index.css, built from this + * + * ## Why this file exists (objectui#4929) + * + * Until it did, a published-state consumer had NO source for this plugin's + * themed utilities. `@object-ui/components` and `@object-ui/fields` were the only + * packages shipping a stylesheet, and each scans its own `src` only, so a class + * used exclusively here could not appear in either by construction. `bg-muted/10`, `bg-card/60` and `text-muted-foreground/60` + * are ordinary appearance classes, not edge cases, and they rendered unstyled. + * + * Measured on this package at the commit that added this file: 28 plain and + * 19 themed utilities compile here and appear in neither published sheet. The + * plain ones a consumer could in principle regenerate by pointing `@source` at + * this package's `dist`. The themed ones they cannot: those resolve `@theme` + * tokens declared in `packages/components/src/index.css`, which that package does + * not publish (it ships `dist` only). A build inside this monorepo is their only + * possible producer — which is why the fix is a stylesheet we ship rather than a + * documentation note telling consumers to scan `node_modules` and hand-declare + * the theme, a direction objectui#4858 had just retired from the guides and the + * 2026-08-17 ruling rejected by name. + * + * ## Why the narrow "subtraction" shape (objectui#4059, `@object-ui/fields`) + * + * `@reference` gives this compilation `@object-ui/components`' theme tokens, its + * class-based `dark` variant and its `tailwindcss-animate` plugin WITHOUT emitting + * a single byte of them. So the sheet built from this entry carries utilities + * only — no preflight, no `@theme` `:root` block, no base layer; + * `scripts/build-css.mjs` then subtracts every rule components' published sheet + * already ships. + * + * The two halves defend different things, and the division is worth stating + * because it was measured rather than assumed. The SUBTRACTION is what removes + * the ~1350 shared utilities: without it this package would publish a 164 kB + * near-copy of the components sheet instead of an 11 kB supplement. The NARROW + * ENTRY is what makes preflight and the `@theme` `:root` block impossible to + * emit in the first place — swapping it for a plain `@import 'tailwindcss'` was + * tried on this card and produced a published artifact that is the same size + * (11.26 kB vs 11.41 kB) because the subtraction removes the duplicates too, + * which is precisely the point: that shape would leave a re-emitted theme block + * one Tailwind-emission change away from shipping, and a `:root` theme block + * loaded AFTER the base sheet overrides the consumer's own token overrides. + * Belt and braces, on purpose. + * + * The import ORDER above is not cosmetic either: this sheet is a supplement to + * the components sheet, never a replacement for it. + * + * ## What is subtracted — and what deliberately is not + * + * `scripts/build-css.mjs` subtracts `@object-ui/components`' sheet and nothing + * else. That is the one sheet every consumer of this plugin is guaranteed to have: + * components is a hard dependency of this package and its stylesheet is the + * quick-start's first import. This package also depends on `@object-ui/fields` and `@object-ui/plugin-detail`, which ships a + * sheet of its own — subtracting THAT one too would shave a few hundred bytes and + * buy a second ordering requirement, making this sheet silently wrong for anyone + * who imports it without that one. Not worth it, and not what the ruling scoped. + */ +@reference '../../components/src/index.css'; + +/* + * `source(none)` is load-bearing, not tidiness. Tailwind's automatic source + * detection resolves against a base directory that defaults to the PROCESS CWD, + * and the `@reference` above pulls in components' entry, whose own + * `@import 'tailwindcss'` turns that detection on. Without `source(none)` the + * candidate set therefore depends on where the build was launched from — same + * commit, same command, two different published artifacts (measured for + * `@object-ui/fields`: 21 kB from its own directory, 287 kB from the repo root, + * objectui#4059; re-measured on components' entry for this card: 162 kB vs 432 kB). + * + * With it, the ONLY inputs are components' own `@source` line (reached through the + * `@reference`, and itself relative to that file) and the explicit lines below, so + * the output is byte-identical from any working directory. `scripts/__tests__/plugin-published-stylesheet.test.ts` + * asserts that byte-identity rather than trusting this comment. + */ +@import 'tailwindcss/utilities.css' layer(utilities) source(none); + +/* + * Only shipped source. A `*.test.tsx` never reaches a consumer, and neither does a + * helper that lives beside one, so a utility used solely by tests must not + * become a published byte. (Measured today the exclusions remove nothing — every + * class the tests use is also used by shipped source. They are here so that stays + * true by construction rather than by luck.) + */ +@source './**/*.{ts,tsx}'; +@source not './**/*.test.{ts,tsx}'; +@source not './**/__tests__/**'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9994d43d0..6941818824 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2156,12 +2156,21 @@ importers: '@object-ui/sdui-parser': specifier: workspace:* version: link:../sdui-parser + '@tailwindcss/postcss': + specifier: ^4.3.3 + version: 4.3.3 '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) msw: specifier: ^2.15.0 version: 2.15.0(@types/node@26.2.0)(typescript@6.0.3) + postcss: + specifier: ^8.5.26 + version: 8.5.26 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2220,6 +2229,9 @@ importers: '@object-ui/data-objectstack': specifier: workspace:* version: link:../data-objectstack + '@tailwindcss/postcss': + specifier: ^4.3.3 + version: 4.3.3 '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2229,6 +2241,12 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + postcss: + specifier: ^8.5.26 + version: 8.5.26 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 typescript: specifier: ^6.0.3 version: 6.0.3 diff --git a/scripts/__tests__/package-files-exist.test.ts b/scripts/__tests__/package-files-exist.test.ts index f29a8a8d94..d6791a3172 100644 --- a/scripts/__tests__/package-files-exist.test.ts +++ b/scripts/__tests__/package-files-exist.test.ts @@ -837,9 +837,25 @@ describe('every declared `exports` subpath resolves to a file that ships (object } // The producibility population specifically, since it is the one this issue - // turns on and the one whose emptiness would be silent. Both `./style.css` - // exports must be in it whether or not the tree has been built. - expect(generatedCssExports.map((e) => e.pkg.name).sort()).toEqual(['@object-ui/components', '@object-ui/fields']); + // turns on and the one whose emptiness would be silent. Every `./style.css` + // export must be in it whether or not the tree has been built. + // + // This list GREW from `['@object-ui/components', '@object-ui/fields']` to the + // four below in objectui#4929, and the clause that moved it is the maintainer + // ruling of 2026-08-17 on that card: "every `@object-ui/plugin-*` ships its own + // stylesheet ... Scope today: `plugin-grid` + `plugin-kanban` (the two with + // measured classes); the build step is the pattern any future plugin inherits." + // So the set is expected to keep growing as further plugins adopt the step — + // and each addition has to arrive the same way, with a package that really + // declares `"./style.css"` and really builds it. Shrinking it, by contrast, + // means a package stopped producing a sheet it still promises: that is + // objectui#4059's defect returning, and the failure this line should raise. + expect(generatedCssExports.map((e) => e.pkg.name).sort()).toEqual([ + '@object-ui/components', + '@object-ui/fields', + '@object-ui/plugin-grid', + '@object-ui/plugin-kanban', + ]); }); it('every export target is inside something `files` ships', () => { diff --git a/scripts/__tests__/plugin-published-stylesheet.test.ts b/scripts/__tests__/plugin-published-stylesheet.test.ts new file mode 100644 index 0000000000..20807b09c4 --- /dev/null +++ b/scripts/__tests__/plugin-published-stylesheet.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import * as gridStylesheet from '../../packages/plugin-grid/scripts/build-css.mjs'; +import * as kanbanStylesheet from '../../packages/plugin-kanban/scripts/build-css.mjs'; +import { classesOf, COMPONENTS_ENTRY, REPO_ROOT } from '../build-plugin-stylesheet.mjs'; + +/** + * objectui#4929: `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` now + * publish a stylesheet, built in the subtraction shape `@object-ui/fields` + * established (objectui#4059). + * + * ## What has to be pinned, and why a naive test would pass while broken + * + * Two properties, and only both together mean anything: + * + * 1. the sheet CONTAINS the themed utilities that motivated it — the ones + * resolving `@theme` tokens that live in unpublished components source, so + * a build inside this monorepo is their only possible producer; + * 2. the sheet does NOT re-emit what `@object-ui/components` already ships. + * + * A sheet built without the subtraction step — the ~164 kB near-copy of the + * components sheet this shape exists to avoid — satisfies (1) perfectly. + * "Does it have the class" therefore proves nothing on its own, which is why the + * degenerate control below asserts a class components DOES carry is ABSENT here, + * and asserts it against the same package's pre-subtraction compile so the + * absence cannot be explained by the plugin never using the class. + * + * ## Why this test compiles instead of reading `dist/` + * + * CI runs the suite on an unbuilt worktree (`ci.yml`'s test job installs and runs + * `pnpm test`, no build step), so each package's `dist/index.css` is legitimately + * absent. A test that read the artifact would pass vacuously or be skipped — + * which is how a stylesheet gate stops being a gate. It runs the real builder + * instead, over the real sources, and supplies components' sheet compiled from + * ITS OWN source: `base`-pinned, so byte-identical to what that package's build + * writes (the vite-extracted `sidebar-fixes.css` it appends carries no utility + * rule, so nothing this test judges depends on the difference). + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * The themed utilities objectui#4929 measured — the load-bearing set, verbatim + * from the card, split by the package that uses each one. Re-derived on the + * merged tree at implementation time; the card's list of 21 held in full, and + * four more (`[&>h3]:text-foreground/80`, `border-l-primary/40`, + * `border-primary/30`, `hover:text-primary`) turned up that its literal-grep + * method could not see. + * + * These are named here rather than read from the builder's own `MUST_SURVIVE` + * on purpose: a test that asserts the subject's own list back at it pins + * nothing. + */ +const CARD_THEMED = { + 'plugin-grid': [ + 'border-foreground', + 'divide-border/50', + 'focus:border-input', + 'focus:ring-destructive/30', + 'text-muted-foreground/80', + ], + 'plugin-kanban': [ + 'bg-card/20', + 'bg-card/60', + 'bg-foreground/70', + 'bg-muted-foreground/30', + 'bg-muted/10', + 'bg-muted/15', + 'bg-muted/70', + 'border-primary/60', + 'hover:border-primary/40', + 'ring-destructive/30', + 'ring-primary/40', + 'ring-primary/60', + 'shadow-primary/25', + 'text-foreground/85', + 'text-muted-foreground/60', + 'text-primary/90', + ], +} as const; + +/** + * Utilities `@object-ui/components`' sheet carries. Each is also used by both + * plugins, so "absent from the plugin sheet" can only mean the subtraction ran. + */ +const ALREADY_SHIPPED = ['flex', 'text-sm', 'rounded-md', 'bg-background', 'sr-only']; + +const SUBJECTS = [ + { name: 'plugin-grid', mod: gridStylesheet }, + { name: 'plugin-kanban', mod: kanbanStylesheet }, +] as const; + +type Built = { + css: string; + classes: Set; + rawClasses: Set; + droppedRules: number; +}; + +const built = new Map(); + +beforeAll(async () => { + // One components compilation, shared by both subjects (~0.6 s). + const componentsSheetCss = (await gridStylesheet.builder.compileComponentsEntry()).toString(); + + for (const { name, mod } of SUBJECTS) { + const raw = await mod.builder.compile( + path.join(mod.PACKAGE_ROOT, 'src/index.css'), + mod.PACKAGE_ROOT, + ); + const result = await mod.builder.build({ + ...mod.buildOptions, + componentsSheetCss, + write: false, + }); + built.set(name, { + css: result.css, + classes: result.survivingClasses, + rawClasses: classesOf(raw), + droppedRules: result.droppedRules, + }); + } +}, 120_000); + +describe('published plugin stylesheets (objectui#4929)', () => { + it('gives every themed utility the card measured a producer', () => { + const everything = new Set( + SUBJECTS.flatMap(({ name }) => [...(built.get(name) as Built).classes]), + ); + const missing = Object.values(CARD_THEMED) + .flat() + .filter((cls) => !everything.has(cls)); + expect(missing).toEqual([]); + }); + + describe.each(SUBJECTS.map(({ name }) => name))('%s', (name) => { + const themed = CARD_THEMED[name]; + + it('emits the themed utilities only this build can produce', () => { + const { classes } = built.get(name) as Built; + expect(themed.filter((cls) => !classes.has(cls))).toEqual([]); + }); + + it('resolves those utilities through the unpublished @theme tokens', () => { + // The point of the `@reference`: `bg-muted/10` must come out as a real + // colour expression over `--color-muted`, not be dropped or left inert. + const { css } = built.get(name) as Built; + const themedTokens = /var\(--color-(muted|card|primary|foreground|destructive|border|input)/; + expect(themedTokens.test(css)).toBe(true); + }); + + it('does not re-emit what the components sheet already carries', () => { + const { classes, rawClasses } = built.get(name) as Built; + // The control is only meaningful if the plugin really compiles these. + expect(ALREADY_SHIPPED.filter((cls) => !rawClasses.has(cls))).toEqual([]); + expect(ALREADY_SHIPPED.filter((cls) => classes.has(cls))).toEqual([]); + expect(classes.size).toBeLessThan(rawClasses.size / 4); + }); + + it('carries utilities only — no preflight, no theme block', () => { + const { css } = built.get(name) as Built; + expect(css).not.toMatch(/@layer base/); + expect(css).not.toMatch(/^:root\s*[,{]/m); + expect(css).not.toMatch(/--color-[a-z-]+:\s/); + }); + + it('compiles the same bytes from any working directory', async () => { + const { mod } = SUBJECTS.find((s) => s.name === name)!; + const entry = path.join(mod.PACKAGE_ROOT, 'src/index.css'); + const fromPackage = await mod.builder.compile(entry, mod.PACKAGE_ROOT); + const fromRepoRoot = await mod.builder.compile(entry, REPO_ROOT); + expect(fromRepoRoot.toString()).toBe(fromPackage.toString()); + }, 60_000); + + it('declares the export AND the step that produces it', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'packages', name, 'package.json'), 'utf8'), + ) as { exports: Record; scripts: Record }; + // objectui#4059 was an export promising a file no build step wrote. + expect(manifest.exports['./style.css']).toBe('./dist/index.css'); + expect(manifest.scripts.build).toContain('node scripts/build-css.mjs'); + }); + }); + + it('reaches the components entry where the builder says it does', () => { + expect(fs.existsSync(COMPONENTS_ENTRY)).toBe(true); + expect(COMPONENTS_ENTRY.startsWith(REPO_ROOT)).toBe(true); + }); +}); diff --git a/scripts/build-plugin-stylesheet.mjs b/scripts/build-plugin-stylesheet.mjs new file mode 100644 index 0000000000..fb26552f6a --- /dev/null +++ b/scripts/build-plugin-stylesheet.mjs @@ -0,0 +1,421 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Builds a plugin package's `dist/index.css` from its `src/index.css` — the + * shared half of the step each `packages/plugin-/scripts/build-css.mjs` runs. + * + * ## What was wrong (objectui#4929) + * + * Only `@object-ui/components` and `@object-ui/fields` ever shipped a + * stylesheet, and each scans its own `src` only. No `@object-ui/plugin-*` + * package emitted CSS at all (`build: vite build`, no `.css` under `src`), so a + * class used exclusively by a plugin could not appear in either published sheet + * BY CONSTRUCTION. A published-state Vite app that installed + * `@object-ui/plugin-grid` / `plugin-kanban` and followed the quick-start + * rendered grid and kanban with 25 themed utilities that had no source anywhere + * in the world — `bg-muted/10`, `bg-card/60`, `text-muted-foreground/60` and + * friends, ordinary appearance classes — plus ~103 plain ones. + * + * The plain utilities a consumer could in principle regenerate by pointing + * `@source` at the package's `dist`. The themed ones they cannot: they resolve + * `@theme` tokens declared in `packages/components/src/index.css`, a file that + * package does not publish. A build inside this monorepo is their only possible + * producer. That is why the 2026-08-17 ruling chose a stylesheet per plugin over + * a documentation note teaching consumers to hand-declare the theme and scan + * `node_modules` — the advice objectui#4858 had just retired from the guides. + * + * ## The "narrow" shape, inherited from `@object-ui/fields` (objectui#4059) + * + * A plugin's `src/index.css` `@reference`s components' entry: theme tokens, the + * class-based `dark` variant and the animate plugin become available for + * resolution while emitting nothing, and only the utilities layer is imported, + * so there is no preflight and no `:root` theme block to begin with. This module + * then subtracts every rule components' BUILT sheet already ships. + * + * The subtraction is not optional tidiness. `@reference` pulls in components' + * own `@source` line, so the raw compilation of a plugin entry carries ~1350 + * utilities the consumer already has: 172 kB for `plugin-grid` before + * subtraction, ~8 kB after. Without this step each plugin would publish a + * near-complete copy of the components sheet. + * + * ## Why this is shared code and `packages/fields/scripts/build-css.mjs` is not + * + * That script came first and is where all of the reasoning above was worked out; + * this module is its logic generalised over `packageRoot`, because the ruling + * that ordered it says "the build step is the pattern any future plugin + * inherits" and a pattern that has to be copy-pasted is not inherited. Fields' + * copy is deliberately left alone here: it is a published package's build, its + * rewrite belongs in its own change with its own byte-for-byte verification, and + * this card scoped itself to two plugins. Collapsing the two is filed + * separately. + * + * ## Failure modes this module refuses to have + * + * A subtraction that drops too much would silently ship an under-styled package + * — the exact defect being fixed, wearing a green build. Four assertions run + * BEFORE anything is written, and each throws rather than writing a wrong sheet: + * every rule must be accounted for, the subtraction must have removed something, + * the sheet may not grow past a leak ceiling, and the utilities only this build + * can produce must still be present. + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); + +/** The repository root — this file lives in `/scripts/`. */ +export const REPO_ROOT = resolve(here, '..'); +export const COMPONENTS_ROOT = resolve(REPO_ROOT, 'packages/components'); +/** Components' Tailwind entry. Build-time only; never published. */ +export const COMPONENTS_ENTRY = resolve(COMPONENTS_ROOT, 'src/index.css'); +/** + * The stylesheet a consumer already has from `@object-ui/components/style.css` + * — that package's real build output, not a re-derivation of it. + */ +export const COMPONENTS_SHEET = resolve(COMPONENTS_ROOT, 'dist/index.css'); + +/** + * `var(--x, )` -> `var(--x)`, at any nesting depth. + * + * Tailwind inlines a fallback for every theme variable whose declaration is not + * emitted in the same sheet. A plugin's never are — that is the entire point of + * the `@reference` entry — so `.rounded-md` compiles here to + * `var(--radius-md, calc(var(--radius) - 2px))` and in components' sheet to + * `var(--radius-md)`. Same rule, same computed value once the components sheet + * is loaded, different bytes. Comparing raw text therefore finds hundreds of + * spurious differences and keeps the whole duplicate sheet. + * + * Normalisation is applied ONLY to the comparison key; the emitted CSS keeps its + * fallbacks. Hand-written rather than a regex because the fallbacks nest + * (`calc(var(…))`, `hsl(var(…))`) and a regex cannot match balanced parentheses. + */ +export function stripVarFallbacks(value) { + let out = ''; + for (let i = 0; i < value.length; i += 1) { + if (!value.startsWith('var(', i)) { + out += value[i]; + continue; + } + let depth = 0; + let comma = -1; + let end = -1; + for (let j = i + 3; j < value.length; j += 1) { + const ch = value[j]; + if (ch === '(') depth += 1; + else if (ch === ')') { + depth -= 1; + if (depth === 0) { + end = j; + break; + } + } else if (ch === ',' && depth === 1 && comma === -1) comma = j; + } + if (end === -1) { + out += value.slice(i); + break; + } + const name = value.slice(i + 4, comma === -1 ? end : comma).trim(); + out += `var(${name})`; + i = end; + } + return out; +} + +/** + * The at-rule context a node sits in, as a stable string — `@media (…)`, + * `@layer utilities` and so on, outermost first. + * + * Without it, `.lg\:max-w-5xl` inside `@media (min-width:64rem)` and a + * hypothetical top-level rule with the same selector would collide, and the + * subtraction could drop a responsive variant because an unrelated base rule + * matched. Keys are compared, never parsed, so the exact spelling only has to be + * consistent between the two compilations. + */ +export function contextOf(node) { + const parts = []; + for (let p = node.parent; p && p.type !== 'root'; p = p.parent) { + parts.unshift( + p.type === 'atrule' ? `@${p.name} ${(p.params ?? '').trim()}`.trim() : String(p.selector ?? ''), + ); + } + return parts.join(' > '); +} + +/** A rule's declarations, normalised so formatting differences cannot matter. */ +export function bodyOf(rule) { + return rule.nodes + .map((n) => + n.type === 'decl' + ? `${n.prop}:${stripVarFallbacks(String(n.value).trim())}${n.important ? '!important' : ''}` + : stripVarFallbacks(n.toString().replace(/\s+/g, ' ').trim()), + ) + .join(';'); +} + +export const ruleKey = (rule) => `${contextOf(rule)}||${rule.selector.trim()}`; +/** Whole-node identity for at-rules that carry no selector (`@property`, `@keyframes`). */ +export const atRuleKey = (at) => `${contextOf(at)}||@${at.name} ${(at.params ?? '').trim()}`.trim(); +const normalise = (node) => stripVarFallbacks(node.toString().replace(/\s+/g, ' ').trim()); + +/** Class names a selector targets, with CSS escapes resolved (`.bg-muted\/10` -> `bg-muted/10`). */ +export function classesIn(selector) { + const found = []; + const re = /\.((?:\\.|[^\s.,>+~()[\]:#*'"\\])+)/g; + let m; + while ((m = re.exec(selector))) { + found.push( + m[1] + .replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))) + .replace(/\\(.)/g, '$1'), + ); + } + return found; +} + +/** Every class the sheet targets. */ +export function classesOf(root) { + const classes = new Set(); + root.walkRules((rule) => { + for (const sel of rule.selectors) for (const cls of classesIn(sel)) classes.add(cls); + }); + return classes; +} + +/** Everything the components sheet already provides, indexed for lookup. */ +export function indexSheet(rootNode) { + const rules = new Map(); + const atRules = new Map(); + rootNode.walkRules((rule) => { + const key = ruleKey(rule); + if (!rules.has(key)) rules.set(key, new Set()); + rules.get(key).add(bodyOf(rule)); + }); + rootNode.walkAtRules((at) => { + // Container at-rules are represented by the rules inside them, via contextOf. + if (at.nodes?.some((n) => n.type === 'rule')) return; + const key = atRuleKey(at); + if (!atRules.has(key)) atRules.set(key, new Set()); + atRules.get(key).add(normalise(at)); + }); + return { rules, atRules }; +} + +function header(packageName) { + return [ + `/*! ${packageName} — utilities this package adds on top of @object-ui/components.`, + ' *', + ' * IMPORT AFTER the components sheet; this is a supplement, not a standalone stylesheet:', + ' *', + " * @import '@object-ui/components/style.css';", + ` * @import '${packageName}/style.css';`, + ' *', + ' * Preflight, the theme tokens and every utility this package shares with', + ' * @object-ui/components live in that sheet and are deliberately not repeated here.', + ' * Generated by scripts/build-css.mjs — do not edit.', + ' */', + ].join('\n'); +} + +/** + * Binds the builder to the caller's `postcss` and `@tailwindcss/postcss`. + * + * They are injected rather than imported because this file sits at the + * repository root, outside every package, and pnpm's isolated `node_modules` + * resolves a bare specifier against the IMPORTING FILE's location. Each plugin + * package declares and imports the two itself, which is also the honest + * declaration: they are that package's build dependencies, not the repo's. + */ +export function createPluginStylesheetBuilder({ postcss, tailwind }) { + /** + * Compile a Tailwind entry. + * + * `base` pins Tailwind's automatic source detection, which otherwise resolves + * against the process cwd. A plugin entry also carries `source(none)`, so the + * two together make the output independent of the working directory — the + * property `scripts/__tests__/plugin-published-stylesheet.test.ts` asserts. + */ + async function compile(entryFile, base) { + const css = await readFile(entryFile, 'utf8'); + const result = await postcss([tailwind({ base })]).process(css, { from: entryFile }); + return postcss.parse(result.css, { from: entryFile }); + } + + /** + * Components' sheet compiled from ITS OWN SOURCE, pinned to its directory. + * + * The build below deliberately reads that package's built artifact instead — + * it is by definition "what the consumer already has". This entry point exists + * for the test suite, which runs on an unbuilt worktree in CI and so has no + * `dist` to read. Byte-identical to compiling from `packages/components` as + * that package's own build does, because `base` removes the cwd sensitivity. + */ + const compileComponentsEntry = () => compile(COMPONENTS_ENTRY, COMPONENTS_ROOT); + + async function readComponentsSheet() { + try { + return await readFile(COMPONENTS_SHEET, 'utf8'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + throw new Error( + [ + `@object-ui/components has not been built: ${COMPONENTS_SHEET} does not exist.`, + '', + 'This build subtracts the utilities that package already ships, so it needs that sheet', + 'to exist before it can decide what is left over. `turbo run build` orders this', + "correctly via the `build` task's `dependsOn: [\"^build\"]`; a bare single-package", + 'build does not.', + '', + ' pnpm --filter @object-ui/components build', + ].join('\n'), + ); + } + } + + /** + * Compile `/src/index.css`, subtract what components' sheet + * already ships, verify, and (unless `write` is false) write `dist/index.css`. + */ + async function build({ + packageRoot, + packageName, + mustSurvive, + classCeiling, + componentsSheetCss, + write = true, + }) { + const entry = resolve(packageRoot, 'src/index.css'); + const output = resolve(packageRoot, 'dist/index.css'); + + const sheet = await compile(entry, packageRoot); + const shipped = indexSheet( + postcss.parse(componentsSheetCss ?? (await readComponentsSheet()), { from: COMPONENTS_SHEET }), + ); + + // Snapshot the full compilation BEFORE mutating it, so the verification + // below has something independent to check the survivors against. + const fullRules = []; + sheet.walkRules((rule) => + fullRules.push({ key: ruleKey(rule), body: bodyOf(rule), selector: rule.selector.trim() }), + ); + + let droppedRules = 0; + let droppedAtRules = 0; + + sheet.walkRules((rule) => { + if (shipped.rules.get(ruleKey(rule))?.has(bodyOf(rule))) { + rule.remove(); + droppedRules += 1; + } + }); + + sheet.walkAtRules((at) => { + if (at.nodes?.some((n) => n.type === 'rule')) return; + if (shipped.atRules.get(atRuleKey(at))?.has(normalise(at))) { + at.remove(); + droppedAtRules += 1; + } + }); + + // Drop at-rule shells the subtraction emptied out (`@media` wrappers whose + // every rule was already shipped), innermost first. + let pruned = true; + while (pruned) { + pruned = false; + sheet.walkAtRules((at) => { + if (at.nodes && at.nodes.length === 0) { + at.remove(); + pruned = true; + } + }); + } + + // ----------------------------------------------------------------------- + // Verification: nothing may go missing. + // ----------------------------------------------------------------------- + const survivors = new Set(); + sheet.walkRules((rule) => survivors.add(`${ruleKey(rule)}||${bodyOf(rule)}`)); + + const lost = fullRules.filter( + (r) => !survivors.has(`${r.key}||${r.body}`) && !shipped.rules.get(r.key)?.has(r.body), + ); + if (lost.length > 0) { + throw new Error( + [ + `${lost.length} rule(s) vanished in the components-sheet subtraction and are in neither output.`, + `Shipping this file would under-style ${packageName} — the exact defect objectui#4929 fixed.`, + '', + ...lost.slice(0, 20).map((r) => ` ${r.selector} [${r.key}]`), + ].join('\n'), + ); + } + + // A subtraction that removed nothing means the two compilations stopped + // sharing a key shape (a Tailwind upgrade changing layer names, say). The + // output would still be CORRECT — merely the ~170 kB duplicate this shape + // exists to avoid — so this is a loud failure rather than a silent + // regression to the wide sheet. + if (droppedRules === 0) { + throw new Error( + `The components sheet subtracted nothing at all from ${packageName}. Expected ~1350 shared ` + + 'utilities to be removed; the two sheets are no longer producing comparable keys, so this ' + + 'build would ship a near-complete copy of the components sheet (objectui#4929).', + ); + } + + const survivingClasses = classesOf(sheet); + + /** + * The opposite failure to over-subtraction: a sheet that swallowed + * utilities belonging to OTHER packages. `src/index.css` pins its inputs + * with `source(none)` precisely so this cannot happen; the ceiling asserts + * the pin is still doing its job, because the symptom is otherwise + * invisible — the build succeeds, every check above passes, and the package + * quietly publishes a stylesheet an order of magnitude too big. + * + * A leak detector, not a budget: it sits far above the measured value on + * purpose, because a number that needed re-tuning every time a widget + * gained a class would be edited into uselessness. + */ + if (survivingClasses.size > classCeiling) { + throw new Error( + [ + `${packageName}'s sheet carries ${survivingClasses.size} classes; anything over ${classCeiling} means it is no longer just this package's.`, + '', + "Tailwind's automatic source detection resolves against a base directory that defaults to", + "the process cwd, so a lost `source(none)` in src/index.css lets the candidate set expand", + "to the whole workspace — which builds a valid, much larger stylesheet full of other", + 'packages\u2019 utilities rather than failing (objectui#4059, objectui#4929).', + ].join('\n'), + ); + } + + const vanished = mustSurvive.filter((cls) => !survivingClasses.has(cls)); + if (vanished.length > 0) { + throw new Error( + [ + `The subtraction removed ${vanished.length} utility(ies) that only this build can produce:`, + ...vanished.map((c) => ` .${c}`), + '', + 'That is over-subtraction, and it ships as an under-styled package with a green build —', + 'the objectui#4929 defect restored. Check that the components sheet being subtracted is', + "that package's own build output and has not been widened to include this package's classes.", + ].join('\n'), + ); + } + + const css = `${header(packageName)}\n${sheet.toString()}\n`; + if (write) { + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, css, 'utf8'); + } + + return { css, output, survivors, survivingClasses, droppedRules, droppedAtRules }; + } + + return { compile, compileComponentsEntry, readComponentsSheet, build }; +} diff --git a/turbo.json b/turbo.json index 050f78975d..6d87e5b053 100644 --- a/turbo.json +++ b/turbo.json @@ -16,7 +16,9 @@ "$TURBO_DEFAULT$", "$TURBO_ROOT$/tsconfig.json", "$TURBO_ROOT$/tsconfig.base.json", - "$TURBO_ROOT$/scripts/vite-*.ts" + "$TURBO_ROOT$/scripts/vite-*.ts", + "$TURBO_ROOT$/scripts/build-plugin-stylesheet.mjs", + "$TURBO_ROOT$/scripts/invoked-as.mjs" ] }, "test": {