diff --git a/.changeset/fields-style-css-build.md b/.changeset/fields-style-css-build.md new file mode 100644 index 0000000000..f8e4ac618b --- /dev/null +++ b/.changeset/fields-style-css-build.md @@ -0,0 +1,19 @@ +--- +'@object-ui/fields': minor +--- + +Build and publish `@object-ui/fields/style.css` — the subpath the package has always declared and never shipped + +`packages/fields/package.json` has declared `"./style.css": "./dist/index.css"` for the package's entire life, while its build was `tsc && vite build` and the package contained no `.css` file for Vite to extract. **No published version up to and including 17.3.0 contains a stylesheet** — the `@object-ui/fields@17.3.0` tarball has zero `.css` files in it. The subpath did not merely render badly, it failed to resolve: a consumer writing the `@import '@object-ui/fields/style.css'` that the quick-start guide taught got a build error. This release is the first one where that import works, so it is a new capability rather than a repair of a working one, and no existing consumer can be relying on the old behaviour — an import that never resolved has no working callers. + +Removing the export was the cheaper option and was rejected on a measurement: fields' class surface is not a subset of what `@object-ui/components` publishes. 155 classes exist only here, and 17 of them (`hover:bg-accent/30`, `ring-destructive/50`, `bg-primary/20`, …) resolve `@theme` tokens declared in unpublished package source, so no consumer-side Tailwind configuration can generate them. Dropping the export would have made the field widgets permanently under-styled with no supported remedy. + +The new sheet is a **supplement, not a replacement** — it is compiled against the components theme and then has every rule that package's sheet already ships subtracted from it, so it is ~22 kB rather than another ~180 kB of near-duplicate CSS. Import it after the components sheet: + +```css +@import 'tailwindcss'; +@import '@object-ui/components/style.css'; +@import '@object-ui/fields/style.css'; +``` + +Also adds a workspace-wide guard (`scripts/__tests__/package-files-exist.test.ts`) that fails when any package exports a subpath its published tarball cannot contain, so a stylesheet export with nothing building it cannot recur silently. diff --git a/content/docs/guide/quick-start.md b/content/docs/guide/quick-start.md index 624adfd26b..0ccdbd22f0 100644 --- a/content/docs/guide/quick-start.md +++ b/content/docs/guide/quick-start.md @@ -55,9 +55,14 @@ Add to your `src/index.css`: ```css @import "tailwindcss"; @import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; ``` -`style.css` is the stylesheet `@object-ui/components` compiled from its own sources, and it already carries every utility its components use — the themed ones (`bg-primary`, `border-input`) included. 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 the import already gave you. +Each `style.css` is a stylesheet the package compiles from its own sources at build time, and between them they carry every utility ObjectUI renders with — the themed ones (`bg-primary`, `border-input`) included. + +**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. + +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 578521938e..2466055583 100644 --- a/content/docs/guide/theming.md +++ b/content/docs/guide/theming.md @@ -64,17 +64,22 @@ Components reference these tokens through Tailwind: There is no `tailwind.config.js` step. ObjectUI is Tailwind 4, which is configured in CSS: the packages have no such file of their own, and consuming them does not need one on your side either. -Import the published stylesheet after your own Tailwind entry: +Import the published stylesheets after your own Tailwind entry: ```css /* src/index.css */ @import "tailwindcss"; @import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; ``` -`style.css` is the stylesheet `@object-ui/components` compiles at build time from its own sources — the subpath is a real export, mapped to that package's `dist/index.css`. It already carries every utility its components use **and** the `@theme` block those utilities are built on, so the whole Shadcn palette (`bg-background`, `bg-primary`, `border-input`, `ring-ring`) arrives with the import. You do not restate those tokens in a config of your own. +Each `style.css` is a real export, mapped to that package's `dist/index.css` and compiled at build time from the package's own sources. -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`) that `style.css` already contains, and it cannot produce the themed ones at all: the `@theme` block they come from lives in the package's own source, which is not published. Your Tailwind entry goes on generating the classes *your* source uses, exactly as before. +`@object-ui/components/style.css` is the base of the pair. It carries every utility its components use **and** the `@theme` block those utilities are built on, so the whole Shadcn palette (`bg-background`, `bg-primary`, `border-input`, `ring-ring`) arrives with that one import. You do not restate those tokens in a config of your own. + +`@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. + +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 be35581550..4681581a16 100644 --- a/content/docs/guide/troubleshooting.md +++ b/content/docs/guide/troubleshooting.md @@ -44,19 +44,24 @@ npx objectui doctor **Symptom:** Tailwind utility classes are not applied. Components render without styling. -**Cause:** You are not importing the stylesheet the ObjectUI packages publish. Their utilities — including every themed one, such as `bg-primary` and `border-input` — are compiled at build time into the package's `style.css`, and nothing in your own build can reproduce the themed ones. +**Cause:** You are not importing the stylesheets the ObjectUI packages publish. Their utilities — including every themed one, such as `bg-primary` and `border-input` — are compiled at build time into each package's `style.css`, and nothing in your own build can reproduce the themed ones. -**Fix:** Import them in your main CSS file, after your own Tailwind entry: +**Fix:** Import them in your main CSS file, after your own Tailwind entry, in this order: ```css /* src/index.css */ @import 'tailwindcss'; @import '@object-ui/components/style.css'; +@import '@object-ui/fields/style.css'; ``` -`@object-ui/components` is the package that publishes a working `style.css`. (`@object-ui/fields` declares the same subpath, but its published package contains no stylesheet — see [#4059](https://github.com/objectstack-ai/objectui/issues/4059) — so importing it fails to resolve. Do not add it.) Then check that the Tailwind 4 build plugin is actually installed and wired up — `@tailwindcss/postcss` in `postcss.config.mjs`, or `@tailwindcss/vite` in `vite.config.ts`. Without it, `@import 'tailwindcss'` is passed through as a plain CSS import and no utilities are generated at all. +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. -> **Do not** try to fix this by adding `node_modules` paths to a `content` array or an `@source` line. ObjectUI is Tailwind 4 and has no `tailwind.config.js`; Tailwind 4 does not load one unless you opt in with `@config`, so on most projects those paths do nothing whatsoever. Even when they are read, scanning the published files only regenerates the shape-only utilities (`inline-flex`, `rounded-md`, `h-9`) that `style.css` already contains — it can never produce the themed ones, because the `@theme` block declaring their tokens lives in the package's unpublished source. Missing theme colours are always the missing `style.css` import, never a missing path. +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. + +Then check that the Tailwind 4 build plugin is actually installed and wired up — `@tailwindcss/postcss` in `postcss.config.mjs`, or `@tailwindcss/vite` in `vite.config.ts`. Without it, `@import 'tailwindcss'` is passed through as a plain CSS import and no utilities are generated at all. + +> **Do not** try to fix this by adding `node_modules` paths to a `content` array or an `@source` line. ObjectUI is Tailwind 4 and has no `tailwind.config.js`; Tailwind 4 does not load one unless you opt in with `@config`, so on most projects those paths do nothing whatsoever. Even when they are read, scanning the published files only regenerates the shape-only utilities (`inline-flex`, `rounded-md`, `h-9`) the two sheets already contain — it can never produce the themed ones, because the `@theme` block declaring their tokens lives in unpublished package source. Missing theme colours are always a missing `style.css` import, never a missing path. ## 3. Missing Peer Dependencies diff --git a/packages/fields/package.json b/packages/fields/package.json index ee28a03ff0..d8bbb2a45b 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -24,7 +24,7 @@ "LICENSE" ], "scripts": { - "build": "tsc && vite build", + "build": "tsc && vite build && node scripts/build-css.mjs", "clean": "rm -rf dist", "type-check": "tsc --noEmit", "test": "vitest run", @@ -50,9 +50,12 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@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.0", "vite-plugin-dts": "^5.0.3" diff --git a/packages/fields/scripts/build-css.mjs b/packages/fields/scripts/build-css.mjs new file mode 100644 index 0000000000..ab1ec49177 --- /dev/null +++ b/packages/fields/scripts/build-css.mjs @@ -0,0 +1,418 @@ +/** + * 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` from `src/index.css` — the file `@object-ui/fields` + * has always PROMISED via its `"./style.css"` export and never once shipped. + * + * ## What was wrong (objectui#4059) + * + * `packages/fields/package.json` declared `"./style.css": "./dist/index.css"` + * while `scripts.build` was `tsc && vite build` and `src` held no `.css` file + * at all, so Vite's library build had nothing to extract. Every published + * tarball up to and including 17.3.0 contains ZERO `.css` files (measured over + * the full `tar -tzf` listing), which means a consumer's + * `@import '@object-ui/fields/style.css'` did not merely render badly — it + * failed to resolve and took their build down with it. + * + * ## Why the file has to exist rather than the export be deleted + * + * Deleting the export was the cheaper fix and was ruled out on a measurement: + * fields' class surface is NOT a subset of what `@object-ui/components`' + * published sheet carries. Compiling fields' own source against components' + * theme and diffing against `@object-ui/components@17.3.0`'s published + * `dist/index.css` leaves 155 classes that exist only here — `cursor-crosshair` + * (SignatureField), `group/email` (index.tsx), the whole tag colour map + * (`text-indigo-700`, `bg-pink-50`, …), `min-w-[8ch]` (TagsField), + * `hover:fill-yellow-500` (RatingField), and so on. + * + * 17 of those depend on the ObjectUI `@theme` block, which lives in + * `packages/components/src/index.css` and is NOT published (that package ships + * `dist` only). `bg-primary/20`, `hover:bg-accent/30`, `ring-destructive/50` + * and friends therefore have exactly one possible producer in the world: a + * build inside this monorepo that can see that theme. No amount of consumer-side + * `@source` configuration can regenerate them. + * + * ## The "narrow" shape, and why it is not just `@import 'tailwindcss'` + * + * The naive fix — give fields a normal Tailwind entry — re-emits preflight, the + * theme and ~1350 utilities the consumer already got from + * `@object-ui/components/style.css`, i.e. ~180 kB of near-pure duplication. The + * same objection retired two `@source` lines from the quick-start guide in + * objectui#3884 for costing 100 kB and buying 14 selectors; it applies with more + * force to a stylesheet we ship ourselves. + * + * So this build emits the DIFFERENCE and nothing else: + * + * 1. `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 imports only the utilities + * layer, so there is no preflight and no `:root` theme block to begin with. + * 2. This script compiles components' stylesheet too, in-process, and + * subtracts every rule that sheet already ships. + * + * The result is a supplement of a few kB. It is only correct when loaded AFTER + * the components sheet, which is what the `exports` docs and the guides now say. + * + * ## The coupling, stated plainly + * + * `src/index.css` `@reference`s `packages/components/src/index.css` over a + * build-time relative path, and this script reads that package's BUILT + * `dist/index.css`. Both are inside the monorepo and neither is reachable from + * the published tarball — the same shape of coupling `@object-ui/components`' + * own `scripts/build-css.mjs` has to its `src/index.css`, one directory further + * away. Nothing in the published artifact refers to either path. + * + * Reading components' built artifact rather than re-compiling its entry is + * deliberate: it is by definition "what the consumer already has", and it is + * not sensitive to the working directory. Re-compiling was tried first and is + * subtly wrong — Tailwind's automatic source detection resolves against the + * process cwd, so compiling components' entry from THIS package's directory + * scanned `packages/fields` and folded fields' own classes into the set being + * subtracted. Every fields-only rule then looked "already shipped" and the + * verification below, which trusts that set, could not see it. + * + * ## Failure modes this script refuses to have + * + * A subtraction that drops too much would silently ship an under-styled + * package — exactly the defect being fixed, wearing a green build. Three + * assertions run BEFORE the file is written, and each throws rather than + * writing a wrong sheet: every rule must be accounted for, the subtraction must + * have removed something, and the utilities that 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'; +import postcss from 'postcss'; +import tailwindPostcss from '@tailwindcss/postcss'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..'); +const componentsRoot = resolve(root, '../components'); + +const input = resolve(root, 'src/index.css'); +const output = resolve(root, 'dist/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. + */ +const componentsSheetPath = resolve(componentsRoot, 'dist/index.css'); + +async function compile(file) { + const css = await readFile(file, 'utf8'); + const result = await postcss([tailwindPostcss()]).process(css, { from: file, to: output }); + return postcss.parse(result.css, { from: file }); +} + +/** + * `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. Ours 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 ~750 spurious + * differences and keeps the whole duplicate sheet. + * + * Normalisation is applied ONLY to the comparison key; the emitted CSS keeps its + * fallbacks, so a rule that does survive still degrades sensibly on its own. + * Hand-written rather than a regex because the fallbacks nest (`calc(var(…))`, + * `hsl(var(…))`) and a regex cannot match balanced parentheses. + */ +function stripVarFallbacks(value) { + let out = ''; + for (let i = 0; i < value.length; i += 1) { + if (!value.startsWith('var(', i)) { + out += value[i]; + continue; + } + // Find this var()'s matching close paren, and the top-level comma inside it. + 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 (…)` and + * `@layer utilities` and so on, outermost first. + * + * Without this, `.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 — and both come from the same + * Tailwind version in the same process. + */ +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. */ +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(';'); +} + +const ruleKey = (rule) => `${contextOf(rule)}||${rule.selector.trim()}`; +/** Whole-node identity for at-rules that carry no selector (`@property`, `@keyframes`). */ +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 (`.min-w-\[8ch\]` -> `min-w-[8ch]`). */ +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; +} + +/** + * Utilities that MUST survive the subtraction, spanning both reasons a rule can + * be fields-only. + * + * The first three resolve `@theme` tokens that `@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 field in this repo would notice (every in-repo host compiles + * fields' 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 widget gains a class, and the edit that silences a + * real regression would look exactly like the edit that keeps it current. + */ +const MUST_SURVIVE = [ + 'bg-primary/20', + 'hover:bg-accent/30', + 'ring-destructive/50', + 'cursor-crosshair', + 'min-w-[8ch]', + 'hover:fill-yellow-500', +]; + +/** Everything the components sheet already provides, indexed for lookup. */ +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 }; +} + +const fieldsSheet = await compile(input); + +let componentsSheet; +try { + componentsSheet = postcss.parse(await readFile(componentsSheetPath, 'utf8'), { from: componentsSheetPath }); +} catch (error) { + if (error?.code !== 'ENOENT') throw error; + throw new Error( + [ + `@object-ui/components has not been built: ${componentsSheetPath} 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'), + ); +} + +const shipped = indexSheet(componentsSheet); + +// Snapshot the full compilation BEFORE mutating it, so the verification below +// has something independent to check the survivors against. +const fullRules = []; +fieldsSheet.walkRules((rule) => fullRules.push({ key: ruleKey(rule), body: bodyOf(rule), selector: rule.selector.trim() })); + +let droppedRules = 0; +let droppedAtRules = 0; + +fieldsSheet.walkRules((rule) => { + if (shipped.rules.get(ruleKey(rule))?.has(bodyOf(rule))) { + rule.remove(); + droppedRules += 1; + } +}); + +fieldsSheet.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; + fieldsSheet.walkAtRules((at) => { + if (at.nodes && at.nodes.length === 0) { + at.remove(); + pruned = true; + } + }); +} + +// --------------------------------------------------------------------------- +// Verification: nothing may go missing. +// --------------------------------------------------------------------------- +const survivors = new Set(); +fieldsSheet.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 the package — the exact defect objectui#4059 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 ~180 kB duplicate this variant exists to avoid — +// so this is a loud failure rather than a silent regression to the wide shape. +if (droppedRules === 0) { + throw new Error( + 'The components sheet subtracted nothing at all. Expected ~1350 shared utilities to be removed; ' + + 'the two sheets are no longer producing comparable keys, so this build would ship the wide ' + + 'duplicate sheet instead of the narrow supplement (objectui#4059).', + ); +} + +const survivingClasses = new Set(); +fieldsSheet.walkRules((rule) => { + for (const sel of rule.selectors) for (const cls of classesIn(sel)) survivingClasses.add(cls); +}); + +/** + * 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 — see the comment there. This ceiling is the assertion that the pin is + * still doing its job, because the symptom is otherwise invisible: the build + * succeeds, every check above passes (nothing was lost, plenty was dropped, the + * sentinels survived) and the package just quietly publishes a stylesheet an + * order of magnitude too big, carrying rules it has no business shipping. + * + * Measured on main@59df371f7: 157 classes correct, 1923 when the pin was absent + * and the build ran from the repo root. The ceiling sits far above the real + * value on purpose — it is a leak detector, not a budget, and a number that + * needed re-tuning every time a widget gained a class would be edited into + * uselessness. + */ +const CLASS_CEILING = 600; + +if (survivingClasses.size > CLASS_CEILING) { + throw new Error( + [ + `This sheet carries ${survivingClasses.size} classes; anything over ${CLASS_CEILING} means it is no longer just this package's.`, + '', + "Tailwind's automatic source detection resolves against 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\' utilities rather', + 'than failing (objectui#4059).', + ].join('\n'), + ); +} + +const vanished = MUST_SURVIVE.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#4059 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 header = [ + '/*! @object-ui/fields — 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 '@object-ui/fields/style.css';", + ' *', + ' * Preflight, the theme tokens and every utility the two packages share live in the', + ' * components sheet and are deliberately not repeated here. Generated by', + ' * packages/fields/scripts/build-css.mjs — do not edit.', + ' */', +].join('\n'); + +await mkdir(dirname(output), { recursive: true }); +// Tailwind's own banner stays (it carries the upstream MIT attribution); ours goes after it. +const css = `${header}\n${fieldsSheet.toString()}\n`; +await writeFile(output, css, 'utf8'); + +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/fields/src/index.css b/packages/fields/src/index.css new file mode 100644 index 0000000000..a610b1a776 --- /dev/null +++ b/packages/fields/src/index.css @@ -0,0 +1,44 @@ +/* + * Tailwind entry for `@object-ui/fields` — BUILD-TIME ONLY. + * + * Consumers never compile this file. They import the compiled artifact: + * + * @import '@object-ui/components/style.css'; + * @import '@object-ui/fields/style.css'; <- ./dist/index.css, built from this + * + * The `@reference` below is what makes the "narrow" shape possible + * (objectui#4059): it 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. `@object-ui/components`' own stylesheet owns all of that, and + * `scripts/build-css.mjs` then subtracts every rule that sheet already ships. + * + * That is why the import ORDER above is not cosmetic: this sheet is a + * supplement to the components sheet, not a replacement for it. + * + * This file has no `@import 'tailwindcss'` on purpose — that would pull in + * preflight and re-emit the shared theme, which is exactly the ~160 kB + * duplication the narrow variant exists to avoid. + */ +@reference '../../components/src/index.css'; + +/* + * `source(none)` is load-bearing, not tidiness. Tailwind's automatic source + * detection resolves against the PROCESS CWD, and `@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: run from this package it scanned `packages/fields` and + * produced a 21 kB sheet, run from the repo root it scanned the whole workspace + * and produced a 287 kB one full of other packages' utilities. Same commit, same + * command, two different published artifacts. + * + * With it, the ONLY inputs are the explicit `@source` lines below, and the + * output is byte-identical from any working directory. + */ +@import 'tailwindcss/utilities.css' layer(utilities) source(none); + +/* Only shipped source: `*.test.tsx` never reaches a consumer, so utilities used + solely by tests must not be compiled into the published sheet. */ +@source './**/*.{ts,tsx}'; +@source not './**/*.test.{ts,tsx}'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 042edc83e8..09270aa465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1242,6 +1242,9 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: + '@tailwindcss/postcss': + specifier: ^4.3.3 + version: 4.3.3 '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1251,6 +1254,12 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(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 ac71e816d2..f29a8a8d94 100644 --- a/scripts/__tests__/package-files-exist.test.ts +++ b/scripts/__tests__/package-files-exist.test.ts @@ -85,6 +85,10 @@ interface WorkspacePackage { private: boolean; files: string[] | undefined; hasBuildScript: boolean; + /** The `build` script verbatim — the objectui#4059 guard reads it for a CSS step. */ + buildScript: string | undefined; + /** The `exports` field verbatim, or undefined when the manifest omits it. */ + exports: unknown; /** The `license` field verbatim, or undefined when the manifest omits it. */ license: string | undefined; } @@ -149,6 +153,8 @@ function readWorkspacePackages(): WorkspacePackage[] { private: Boolean(json.private), files: Array.isArray(json.files) ? json.files : undefined, hasBuildScript: Boolean(json.scripts?.build), + buildScript: typeof json.scripts?.build === 'string' ? json.scripts.build : undefined, + exports: json.exports, license: typeof json.license === 'string' ? json.license : undefined, }); } @@ -590,3 +596,405 @@ describe('published packages that declare a license ship its text (objectui#3696 } }); }); + +/** + * objectui#4059: the two guards above ask whether a path a package promised in + * `files` is real, and whether a package that claims a license ships its text. + * Neither can see the OTHER promise a manifest makes — `exports`. + * + * ## The specimen + * + * `@object-ui/fields` declared + * + * "exports": { "./style.css": "./dist/index.css" } + * + * while `scripts.build` was `tsc && vite build`, `src` held no `.css` file at + * all, and nothing anywhere produced `dist/index.css`. Every published tarball + * up to and including 17.3.0 contained ZERO `.css` files. A consumer writing the + * `@import '@object-ui/fields/style.css'` that `content/docs/guide/quick-start.md` + * taught did not get a badly-styled page — the specifier failed to resolve and + * took their build down. + * + * Nothing in this repo noticed for the package's entire life, and the reason is + * structural rather than careless: no in-repo consumer imports the subpath. + * `apps/console`, `examples/console-starter` and `examples/byo-backend-console` + * all reach fields' SOURCE through `@source '.../packages/fields/src/**'`, a + * workspace-relative path no published consumer can write. So the repo's own + * rendering proved nothing about the artifact, and the export was free to lie. + * + * ## Why the objectui#3663 guard above does not already cover this + * + * It would forgive this defect, correctly, under its own rules: `dist/index.css` + * is git-ignored, untracked, and `@object-ui/fields` has a `build` script, so the + * path is "declared build output" and taken on the repo's word. That guard says + * so itself — *"What this guard deliberately does NOT prove: that a build output + * is really produced."* This section closes exactly that gap for the one file + * type where the question is decidable statically. + * + * ## The two questions asked here + * + * 1. **Packability.** Is the target inside something `files` ships? An export + * pointing outside the packed set can never resolve, whatever the build + * does. (`@object-ui/app-shell` is the live positive case: it exports + * `./styles.css` -> `./src/styles.css` and lists `src/styles.css` in `files` + * precisely so the subpath resolves.) + * 2. **Producibility, for stylesheets.** A `.css` target that is not on disk + * has to be generated by something. Unlike `dist/index.js` — which any + * bundler build emits by construction — a stylesheet appears only if the + * package has a CSS source in its entry graph or an explicit CSS build + * step. Both are readable from the manifest and the tree, so this one does + * not need a build to answer. + * + * `.js`/`.d.ts` targets are deliberately NOT given a producibility test: there is + * no honest static answer for them, and a guard that guessed would either be + * noise or a false green. The gap is named rather than papered over — same + * discipline as the objectui#3663 block above. + */ + +/** A single `exports` target, flattened out of the condition tree. */ +interface DeclaredExport { + pkg: WorkspacePackage; + /** The subpath key, e.g. `.` or `./style.css`. */ + subpath: string; + /** The condition path that reached it, e.g. `import` or `types`; empty for a bare string. */ + condition: string; + /** The target verbatim, e.g. `./dist/index.css`. */ + target: string; + /** Repo-relative, forward-slashed. */ + relPath: string; + /** `exports` may use `*` patterns (`@object-ui/i18n` does); those resolve to no single file. */ + hasWildcard: boolean; + onDisk: boolean; +} + +/** + * Flattens `exports` into one entry per target string. + * + * Conditions nest arbitrarily (`{ ".": { "import": { "types": "..." } } }`), and + * every leaf string is a path that has to resolve, so the walk recurses rather + * than reading a fixed set of condition names. A leaf that is `null` is npm's + * "explicitly not exported" and carries no path. + */ +function flattenExports(pkg: WorkspacePackage): DeclaredExport[] { + const out: DeclaredExport[] = []; + + const walk = (node: unknown, subpath: string, condition: string): void => { + if (node === null || node === undefined) return; + if (typeof node === 'string') { + const target = node; + // Only relative targets name a file in this package; a bare specifier is a + // re-export of a dependency and is that package's problem, not ours. + if (!target.startsWith('./')) return; + const relPath = path.posix.join(pkg.dir, target.slice(2)); + out.push({ + pkg, + subpath, + condition, + target, + relPath, + hasWildcard: target.includes('*'), + onDisk: !target.includes('*') && fs.existsSync(path.join(repoRoot, relPath)), + }); + return; + } + if (typeof node !== 'object') return; + for (const [key, value] of Object.entries(node as Record)) { + if (key.startsWith('.')) walk(value, key, condition); + else walk(value, subpath, condition ? `${condition}.${key}` : key); + } + }; + + // `exports` may be a bare string (sugar for `{ ".": "..." }`). + walk(pkg.exports, '.', ''); + return out; +} + +/** + * Paths npm packs whether or not `files` lists them. Copied from the + * objectui#3696 block above, which measured this set with `npm pack --dry-run` + * rather than reading the docs. + */ +const ALWAYS_PACKED_RE = /^(package\.json|readme(\.[^.]+)?|licen[cs]e(\.[^.]+)?|copying(\.[^.]+)?)$/i; + +/** Whether `files` (or npm's always-packed set) covers a target path. */ +function isPackable(entry: DeclaredExport): boolean { + // No `files` field at all means npm packs everything not otherwise ignored. + if (entry.pkg.files === undefined) return true; + + const rel = entry.target.slice(2); + if (ALWAYS_PACKED_RE.test(rel)) return true; + + return entry.pkg.files.some((declared) => { + const normalised = declared.replace(/^\.\//, '').replace(/\/$/, ''); + return rel === normalised || rel.startsWith(`${normalised}/`); + }); +} + +/** + * Whether a package can produce a stylesheet at all. + * + * Derived from two independently sufficient facts, never a package-name list: + * + * 1. its `build` script runs a CSS step — both real ones are spelled + * `node scripts/build-css.mjs`, so the marker is the script name rather + * than any particular package; or + * 2. it has a `.css` file under `src/`, which a bundler library build extracts + * into `dist` when it is in the entry graph. + * + * A name-keyed allowlist was the alternative and is worse in the specific way + * this file keeps warning about: the edit that exempts a genuinely broken + * package would be indistinguishable from the edit that teaches the guard about + * a new build pipeline. Both facts here are claims about the package's own + * plumbing, and both are wrong in an obvious way when they are wrong. + */ +function hasCssProducer(pkg: WorkspacePackage): boolean { + if (pkg.buildScript !== undefined && /build-css/.test(pkg.buildScript)) return true; + + const srcDir = path.join(repoRoot, pkg.dir, 'src'); + if (!fs.existsSync(srcDir)) return false; + + const stack = [srcDir]; + while (stack.length > 0) { + const dir = stack.pop()!; + for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) { + if (dirent.name === 'node_modules') continue; + const full = path.join(dir, dirent.name); + if (dirent.isDirectory()) stack.push(full); + else if (dirent.name.endsWith('.css')) return true; + } + } + return false; +} + +const packagesWithExports = packages.filter((p) => p.exports !== undefined); +const declaredExports = packagesWithExports.flatMap(flattenExports); + +/** Export targets that must be GENERATED — absent, and not a wildcard pattern. */ +const generatedExports = declaredExports.filter((e) => !e.hasWildcard && !e.onDisk); +const exportIgnored = gitIgnoredPaths(generatedExports.map((e) => e.relPath)); + +/** + * Stylesheet targets that no committed file supplies, so something has to build + * them. + * + * Keyed on git's index rather than on `onDisk`, and that difference is the whole + * point: `onDisk` answers differently in a built tree than in a fresh clone, so + * a producibility check built on it would quietly skip the package it is meant + * to watch for anyone who had run a build. This was not theoretical — the + * reverse verification for objectui#4059 first ran against a tree where + * `packages/fields/dist/index.css` was already sitting there from a local build, + * and the assertion below passed on the very state it exists to reject. Git's + * index does not move when you run `pnpm build`, so this population is the same + * in CI, in a fresh clone and on a machine mid-work. + * + * `@object-ui/app-shell` falls out of scope here for the right reason: its + * `./styles.css` points at `src/styles.css`, which is tracked, so it is a source + * file that ships rather than something a build owes. + */ +const generatedCssExports = declaredExports.filter( + (e) => !e.hasWildcard && e.target.endsWith('.css') && !tracked.has(e.relPath), +); + +/** The same derived build-output exemption the objectui#3663 guard uses. */ +function isExportBuildOutput(entry: DeclaredExport): boolean { + return exportIgnored.has(entry.relPath) && !tracked.has(entry.relPath) && entry.pkg.hasBuildScript; +} + +/** + * Live objectui#4059 defects that were on `main` when this guard landed. + * + * A RATCHET in the shape the two baselines above established: a NEW violation + * fails without consulting this map, and an entry here whose defect is gone + * fails too, so it can only shrink. + * + * Empty, and that is the resting state, not an oversight — the one defect it was + * written for (`@object-ui/fields`' `./style.css`) is fixed in the same commit + * that adds this guard, by giving the package a real CSS build rather than by + * deleting the export. Landing the guard first would have put a red gate on + * `main`; landing the fix without the guard would leave the class free to recur. + */ +const KNOWN_UNSHIPPABLE_EXPORTS: Record = {}; + +const exportKey = (e: DeclaredExport): string => `${e.pkg.name}${e.subpath === '.' ? '' : e.subpath.slice(1)} -> ${e.target}`; + +describe('every declared `exports` subpath resolves to a file that ships (objectui#4059)', () => { + it('discovers the exports surface (guard cannot pass by finding nothing)', () => { + // Same anti-vacuity floor as the two guards above, sitting just under the + // values measured on main@59df371f7 (38 packages declaring `exports`, 140 + // target strings). A broken flattener, a lost workspace root or a renamed + // field would otherwise report success over an empty set — which is the + // failure mode this whole file exists to prevent. + expect(packagesWithExports.length).toBeGreaterThanOrEqual(36); + expect(declaredExports.length).toBeGreaterThanOrEqual(130); + + // The specimen and its control must be in scope BY NAME and BY SUBPATH. Both + // declare the identical `"./style.css": "./dist/index.css"`; the only + // difference was ever that one of them built the file. + for (const name of ['@object-ui/fields', '@object-ui/components']) { + const styleExport = declaredExports.find((e) => e.pkg.name === name && e.subpath === './style.css'); + expect(styleExport, `${name} must still declare a ./style.css export for this guard to watch`).toBeDefined(); + expect(styleExport!.target).toBe('./dist/index.css'); + } + + // 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']); + }); + + it('every export target is inside something `files` ships', () => { + const violations = declaredExports + .filter((e) => !isPackable(e)) + .filter((e) => !Object.hasOwn(KNOWN_UNSHIPPABLE_EXPORTS, exportKey(e))) + .map( + (e) => + `${e.pkg.name} exports "${e.subpath}" -> "${e.target}", but "files" (${JSON.stringify(e.pkg.files)}) ` + + 'does not cover that path, so npm will not pack it', + ); + + expect( + violations, + [ + 'An `exports` subpath points outside the published tarball.', + 'npm packs `files` and nothing else, so this specifier can never resolve for a consumer', + 'no matter what the build produces (objectui#4059).', + '', + 'Fix it in whichever direction is true:', + ' - the file should ship -> add its path to `files` (that is why @object-ui/app-shell', + ' lists "src/styles.css" alongside "dist")', + ' - the export is wrong -> point it at the built artifact, or delete the subpath', + '', + ...violations, + ].join('\n'), + ).toEqual([]); + }); + + it('every export target exists on disk, or is declared build output', () => { + const violations = generatedExports + .filter((e) => !isExportBuildOutput(e)) + .filter((e) => !Object.hasOwn(KNOWN_UNSHIPPABLE_EXPORTS, exportKey(e))) + .map( + (e) => + `${e.pkg.name} exports "${e.subpath}" -> "${e.target}", but ${e.relPath} does not exist` + + (e.pkg.hasBuildScript ? '' : ' (and the package has no `build` script that could create it)'), + ); + + expect( + violations, + [ + 'An `exports` subpath names a path that is neither on disk nor recognisable as build output.', + 'Same derived exemption as the `files` guard above: git-ignored + untracked + the package', + 'has a `build` script (objectui#4059).', + '', + ...violations, + ].join('\n'), + ).toEqual([]); + }); + + it('a stylesheet export is backed by something that can actually build a stylesheet', () => { + // THE objectui#4059 ASSERTION. Everything above would have passed on the + // broken `@object-ui/fields`: `dist` is in `files`, `dist/index.css` is + // git-ignored and untracked, and the package has a `build` script. What was + // missing was any way to produce a stylesheet, and that is decidable here. + const violations = generatedCssExports + .filter((e) => !hasCssProducer(e.pkg)) + .filter((e) => !Object.hasOwn(KNOWN_UNSHIPPABLE_EXPORTS, exportKey(e))) + .map( + (e) => + `${e.pkg.name} exports "${e.subpath}" -> "${e.target}", but the package has no CSS source ` + + `under src/ and its build script does not run a CSS step (build: ${JSON.stringify(e.pkg.buildScript)})`, + ); + + expect( + violations, + [ + 'A package exports a stylesheet it cannot build.', + '', + 'This is the objectui#4059 defect verbatim: @object-ui/fields declared', + '"./style.css": "./dist/index.css" with `build: "tsc && vite build"` and not one .css file', + 'in the package. Every tarball through 17.3.0 shipped without it, and a consumer\'s', + '`@import "@object-ui/fields/style.css"` failed to resolve and broke their build.', + '', + 'A bundler emits a stylesheet only when one is in the entry graph, so unlike dist/index.js', + 'this cannot be assumed. Fix it in whichever direction is true:', + ' - the package should ship CSS -> give it a CSS entry and a build step', + ' (packages/fields/scripts/build-css.mjs is the model)', + ' - it should not -> delete the subpath from `exports`', + '', + ...violations, + ].join('\n'), + ).toEqual([]); + }); + + it('the objectui#4059 baseline only shrinks', () => { + // The other half of the ratchet, in the shape objectui#3701 established: + // report WHY an entry stopped being a live defect rather than assuming, so a + // stale line is never read as proof that the file now builds. + const live = new Set( + [ + ...declaredExports.filter((e) => !isPackable(e)), + ...generatedExports.filter((e) => !isExportBuildOutput(e)), + ...generatedCssExports.filter((e) => !hasCssProducer(e.pkg)), + ].map(exportKey), + ); + const stale = Object.keys(KNOWN_UNSHIPPABLE_EXPORTS).filter((key) => !live.has(key)); + + const causeOf = (key: string): string => { + const entry = declaredExports.find((e) => exportKey(e) === key); + if (!entry) return 'no export declares it any more: the subpath was deleted, or its package left the workspace'; + if (entry.onDisk) return 'the target now exists on disk'; + if (entry.target.endsWith('.css') && hasCssProducer(entry.pkg)) + return 'its package can now build a stylesheet (CSS source under src/, or a CSS build step)'; + return 'still declared and still absent, but now excused as build output'; + }; + + expect( + stale, + [ + 'A KNOWN_UNSHIPPABLE_EXPORTS entry no longer describes a live defect.', + 'Delete its line to bank the progress — that is the right move under every cause below.', + '', + ...stale.map((key) => `${key} (${KNOWN_UNSHIPPABLE_EXPORTS[key].issue}) — ${causeOf(key)}`), + ].join('\n'), + ).toEqual([]); + }); + + it('pins the CSS build added in objectui#4059', () => { + // The general assertion above covers this, but naming it means a revert + // points straight at the issue that explains why the build step has to be + // there — and, unlike the general assertion, this cannot be satisfied by + // adding a baseline line. + const script = path.join(repoRoot, 'packages/fields/scripts/build-css.mjs'); + expect( + fs.existsSync(script), + 'packages/fields/scripts/build-css.mjs is what makes @object-ui/fields\' "./style.css" export ' + + 'true. Without it the package publishes a subpath that resolves to nothing, which is what ' + + 'every release through 17.3.0 did (objectui#4059).', + ).toBe(true); + + const fields = packages.find((p) => p.name === '@object-ui/fields'); + expect(fields?.buildScript, '@object-ui/fields must still RUN its CSS build, not merely contain it').toMatch( + /build-css/, + ); + }); + + it('the producer predicate answers each of its cases', () => { + // The predicate's limbs, asserted directly against live specimens. Two of + // them have exactly one specimen each in the tree, so without this they + // would be logic a later edit could invert with nothing turning red. + const byName = (name: string) => packages.find((p) => p.name === name)!; + + // Limb 1: an explicit CSS build step. + expect(hasCssProducer(byName('@object-ui/components')), 'components runs scripts/build-css.mjs').toBe(true); + expect(hasCssProducer(byName('@object-ui/fields')), 'fields runs scripts/build-css.mjs (objectui#4059)').toBe(true); + + // Limb 2: a CSS source in the package, with no build-css step. + const appShell = byName('@object-ui/app-shell'); + expect(appShell.buildScript ?? '').not.toMatch(/build-css/); + expect(hasCssProducer(appShell), 'app-shell has src/styles.css').toBe(true); + + // Neither limb: the shape the guard has to reject. @object-ui/types is a + // pure-TypeScript package — it has a build, and no CSS anywhere. + expect(hasCssProducer(byName('@object-ui/types')), 'types has no CSS at all').toBe(false); + }); +});