@uxfront/layer-docs 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @uxfront/layer-docs
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 751810d: Stop registering the layer's own `main.css`, and ship the brand-palette guards consumers were each expected to write themselves.
8
+
9
+ **Breaking for any consumer that does not already register its own CSS entry.** The layer registered `app/assets/css/main.css` in `css` while every consumer also imported that same base from its brand CSS — because a brand `@theme` only compiles into real `:root` custom properties when it lives inside a Tailwind pass, which means importing the base rather than sitting beside it. Two entries, two Tailwind passes, and a byte-for-byte duplicate of every base utility in the shipped stylesheet: **+212 KB raw / +26.7 KB gzip (+94%)** on inkline's render-blocking `entry.css`, on every page, for every visitor (UXF-118).
10
+
11
+ The layer no longer registers it. Consumers register exactly one CSS entry of their own, whose first line imports the layer base — which is what all three consumers were already doing, one of them via a `modules:done` hook that un-registered what the layer had just registered. That hook can now be deleted.
12
+
13
+ **Migration.** If your app already has `css: ["./app/assets/css/main.css"]` pointing at a file that starts with `@import "@uxfront/layer-docs/app/assets/css/main.css"`, nothing changes except that your stylesheet halves; drop any hook that stripped the layer's entry. If it does not, add both — see README § Styling. There is no silent middle state: without a CSS entry the app ships no Tailwind utilities at all.
14
+
15
+ **What the duplication actually cost.** Payload, not layout. The duplicate pass measured on inkline was a complete superset of the first and was emitted wholly after it, so the last `sm:`/`lg:` variant still landed after the last conflicting base and won by source order — computed-style A/B across 4 pages × 4 viewports found zero rendering difference. That rescue was incidental rather than designed, which is why the cause is fixed rather than the symptom.
16
+
17
+ **New: `@uxfront/layer-docs/test`.** The two guards that would have caught this before ship, now parameterised by brand scale instead of copy-pasted per repo. `describeBrandPaletteCss` asserts, without a build, that your CSS entry imports the layer base before its `@theme` and defines the scale it maps onto `--ui-primary`. `describeSingleTailwindPass` reads the built stylesheet and asserts every base utility is emitted **exactly once** — catching a duplicate pass and a CSS entry that never reached the bundle with the same assertion. `vitest` is an optional peer dependency, needed only for these.
18
+
19
+ Also fixed: three code comments claimed the second pass "kills every `sm:`/`lg:` responsive variant". It does not, in the equal-superset case measured; the guards now describe the duplicate payload they actually guard.
20
+
3
21
  ## 0.2.1
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -60,6 +60,80 @@ That renders `uxd by UXFront` — the wordmark still links home, `by` is plain
60
60
  text, and `UXFront` links out. Omit `attribution` (or leave `label` empty) to
61
61
  render the wordmark alone.
62
62
 
63
+ ## Styling
64
+
65
+ **The consumer owns the single Tailwind entry.** The layer ships a palette-free
66
+ Tailwind base at `@uxfront/layer-docs/app/assets/css/main.css` and deliberately
67
+ does not register it itself. A brand `@theme` only compiles into real `:root`
68
+ custom properties when it lives inside a Tailwind pass, so your CSS file has to
69
+ _import_ the layer base rather than sit beside it as a second entry — two
70
+ entries each re-emit every base utility into the shipped stylesheet.
71
+
72
+ Register one CSS entry:
73
+
74
+ ```ts
75
+ // nuxt.config.ts
76
+ export default defineNuxtConfig({
77
+ extends: ["@uxfront/layer-docs"],
78
+ css: ["./app/assets/css/main.css"],
79
+ });
80
+ ```
81
+
82
+ whose first line imports the layer base, followed by your palette:
83
+
84
+ ```css
85
+ /* app/assets/css/main.css */
86
+ @import "@uxfront/layer-docs/app/assets/css/main.css";
87
+
88
+ /* The layer's own `@source` paths are package-relative (they resolve inside
89
+ node_modules), so re-scan your content and config for utility classes. */
90
+ @source "../../../content/**/*";
91
+ @source "../../app.config.ts";
92
+
93
+ @theme static {
94
+ --color-teal: hsl(189, 53%, 41%);
95
+ /* … the rest of the scale … */
96
+ }
97
+
98
+ :root {
99
+ --ui-primary: var(--color-teal);
100
+ }
101
+ ```
102
+
103
+ The scale name must match `ui.colors.primary` in your `app.config.ts` for Nuxt
104
+ UI to resolve component variants onto it.
105
+
106
+ ### Guardrail tests
107
+
108
+ Both halves of that contract fail silently, so the layer ships the guards.
109
+ `vitest` is an optional peer dependency, needed only for these.
110
+
111
+ ```ts
112
+ // test/brand-palette-css.test.ts — source-level, no build required
113
+ import { describeBrandPaletteCss } from "@uxfront/layer-docs/test";
114
+
115
+ describeBrandPaletteCss({
116
+ entry: new URL("../app/assets/css/main.css", import.meta.url),
117
+ scale: "teal",
118
+ });
119
+ ```
120
+
121
+ ```ts
122
+ // test/brand-palette-compiled.build.test.ts — requires a prior `nuxt build`
123
+ import { describeSingleTailwindPass } from "@uxfront/layer-docs/test";
124
+
125
+ describeSingleTailwindPass({
126
+ output: new URL("../.output/public/_nuxt", import.meta.url),
127
+ scale: "teal",
128
+ });
129
+ ```
130
+
131
+ The first asserts your CSS entry is a Tailwind entry and defines the palette.
132
+ The second reads the built stylesheet and asserts every base utility is emitted
133
+ **exactly once** — catching both a duplicate Tailwind pass (double payload) and
134
+ a CSS entry that never reached the bundle (no base utilities at all). Run it in
135
+ whichever CI job already builds the app; it needs `.output/` on disk.
136
+
63
137
  ## Compatibility
64
138
 
65
139
  Pinned to the Nuxt 4 documentation stack (Nuxt 4.4, Nuxt UI 4.8, Content 3.14,
package/nuxt.config.ts CHANGED
@@ -14,12 +14,31 @@ const { resolve } = createResolver(import.meta.url);
14
14
  * locale site "just works". A consumer that wants localisation registers the
15
15
  * module itself; `modules/config` + `useDocusI18n` pick it up automatically.
16
16
  *
17
+ * ## Styling: the consumer owns the single Tailwind entry
18
+ *
19
+ * This layer deliberately does NOT register `app/assets/css/main.css` in `css`.
20
+ * Its base is palette-free, so a consumer's brand `@theme` only compiles if it
21
+ * lives inside the same Tailwind pass — which means the consumer's CSS file has
22
+ * to *import* this base, not sit beside it. Registering it here as well gave
23
+ * every consumer two Tailwind entries and a byte-for-byte duplicate of every
24
+ * base utility in the shipped stylesheet (+26.7 KB gzip on inkline, UXF-118),
25
+ * and left each consumer to un-register what the layer had just registered.
26
+ *
27
+ * Consumers therefore register exactly one CSS entry, their own:
28
+ *
29
+ * ```ts
30
+ * // nuxt.config.ts
31
+ * css: ["./app/assets/css/main.css"],
32
+ * ```
33
+ *
34
+ * whose first line imports this base, followed by the brand `@theme`. Both
35
+ * halves are guarded by `@uxfront/layer-docs/test`; see README § Styling.
36
+ *
17
37
  * https://nuxt.com/docs/getting-started/layers
18
38
  */
19
39
  export default defineNuxtConfig({
20
40
  compatibilityDate: "2025-07-22",
21
41
  telemetry: false,
22
- css: [resolve("./app/assets/css/main.css")],
23
42
  // Expose `app/constants/` (DOCS_SECTIONS, findDocsSectionBySlug) as
24
43
  // auto-imports. The layer ships a neutral empty default; a consumer's own
25
44
  // `app/constants/` overrides it via auto-import dir precedence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxfront/layer-docs",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Neutral, brandable Nuxt-layer documentation theme. Consumers extend it and supply their own branding, content and section topology.",
5
5
  "keywords": [
6
6
  "docs",
@@ -23,6 +23,7 @@
23
23
  "i18n",
24
24
  "modules",
25
25
  "server",
26
+ "test",
26
27
  "utils",
27
28
  "nuxt.config.ts",
28
29
  "nuxt.schema.ts",
@@ -43,6 +44,10 @@
43
44
  "types": "./utils/content.ts",
44
45
  "import": "./utils/content.ts"
45
46
  },
47
+ "./test": {
48
+ "types": "./test/brand-palette.ts",
49
+ "import": "./test/brand-palette.ts"
50
+ },
46
51
  "./app/assets/css/main.css": "./app/assets/css/main.css",
47
52
  "./package.json": "./package.json"
48
53
  },
@@ -60,7 +65,8 @@
60
65
  "ufo": "^1.6.4"
61
66
  },
62
67
  "devDependencies": {
63
- "typescript": "^6.0.3"
68
+ "typescript": "^6.0.3",
69
+ "vitest": "^4.1.10"
64
70
  },
65
71
  "peerDependencies": {
66
72
  "@nuxt/content": "^3.14.0",
@@ -77,6 +83,7 @@
77
83
  "posthog-js": "^1.386.6",
78
84
  "tailwindcss": "^4.3.1",
79
85
  "typescript": "^6.0.3",
86
+ "vitest": "^4.1.10",
80
87
  "vue": "^3.5.38"
81
88
  },
82
89
  "peerDependenciesMeta": {
@@ -91,6 +98,9 @@
91
98
  },
92
99
  "typescript": {
93
100
  "optional": true
101
+ },
102
+ "vitest": {
103
+ "optional": true
94
104
  }
95
105
  },
96
106
  "scripts": {
@@ -0,0 +1,224 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ /**
6
+ * Shared test preset for consumers of `@uxfront/layer-docs`.
7
+ *
8
+ * The layer ships a palette-free Tailwind base and expects the consumer to own
9
+ * the single Tailwind entry: one CSS file that imports the layer's base and
10
+ * then declares the brand `@theme`. Two invariants make that arrangement work,
11
+ * and both fail silently, so both get a guard here rather than a comment in
12
+ * three repos:
13
+ *
14
+ * 1. **The consumer's CSS entry is a Tailwind entry.** A `@theme` block only
15
+ * compiles into real `:root` custom properties when the file it lives in is
16
+ * part of a Tailwind pass. If it stops being one, the block ships to the
17
+ * browser verbatim, browsers discard the unknown at-rule, and every
18
+ * `*-primary` utility falls back to black/transparent site-wide.
19
+ * Guarded by {@link describeBrandPaletteCss} — source-level, no build.
20
+ *
21
+ * 2. **Exactly one Tailwind pass reaches the bundle.** A second entry (a bare
22
+ * `@import "tailwindcss"` in the consumer, or a layer that registers its own
23
+ * base in `css:` alongside the consumer's) re-emits every base utility a
24
+ * second time. Guarded by {@link describeSingleTailwindPass} — reads the
25
+ * compiled output, so it needs a prior `nuxt build`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * // apps/docs/test/brand-palette-css.test.ts
30
+ * import { describeBrandPaletteCss } from "@uxfront/layer-docs/test";
31
+ *
32
+ * describeBrandPaletteCss({
33
+ * entry: new URL("../app/assets/css/main.css", import.meta.url),
34
+ * scale: "teal",
35
+ * });
36
+ * ```
37
+ */
38
+
39
+ /** Resolve a `file:` URL or a plain path to an absolute filesystem path. */
40
+ function toPath(target: string | URL): string {
41
+ return typeof target === "string" ? target : fileURLToPath(target);
42
+ }
43
+
44
+ export interface BrandPaletteCssOptions {
45
+ /**
46
+ * The consumer's CSS entry — the file registered in `nuxt.config.ts`'s
47
+ * `css: []`. Pass `new URL("../app/assets/css/main.css", import.meta.url)`.
48
+ */
49
+ entry: string | URL;
50
+ /**
51
+ * Tailwind colour scale the brand palette defines, without the `--color-`
52
+ * prefix (`"teal"`, `"violet"`, `"purple"`). Must match `ui.colors.primary`
53
+ * in the consumer's `app.config.ts`.
54
+ */
55
+ scale: string;
56
+ }
57
+
58
+ /**
59
+ * Source-level guard for invariant 1: the brand palette compiles at all.
60
+ *
61
+ * Cheap — reads one file, runs in the default unit-test job, and catches the
62
+ * regression without a build. It cannot see invariant 2; that needs
63
+ * {@link describeSingleTailwindPass}.
64
+ */
65
+ export function describeBrandPaletteCss(options: BrandPaletteCssOptions): void {
66
+ const { entry, scale } = options;
67
+
68
+ // A Tailwind pass reached via the layer's base CSS, which owns the single
69
+ // `@import "tailwindcss"`. Importing `tailwindcss` directly here would also
70
+ // make the file an entry, but it would open a SECOND pass — the exact
71
+ // regression invariant 2 guards — so only the layer import is accepted.
72
+ const LAYER_BASE_IMPORT = /@import\s+["']@uxfront\/layer-docs\/[^"']*main\.css["']/;
73
+
74
+ describe(`brand palette CSS entrypoint (${scale})`, () => {
75
+ const css = readFileSync(toPath(entry), "utf8");
76
+
77
+ it("imports the layer's base CSS so it is part of the single Tailwind pass", () => {
78
+ expect(css).toMatch(LAYER_BASE_IMPORT);
79
+ });
80
+
81
+ it("imports the layer base before the first @theme block", () => {
82
+ const importIndex = css.search(LAYER_BASE_IMPORT);
83
+ // Match the block opener specifically, not the `@theme` word in a
84
+ // leading docblock comment.
85
+ const themeIndex = css.search(/@theme\s+static\s*\{/);
86
+ expect(importIndex).toBeGreaterThanOrEqual(0);
87
+ expect(themeIndex).toBeGreaterThanOrEqual(0);
88
+ expect(importIndex).toBeLessThan(themeIndex);
89
+ });
90
+
91
+ it(`defines the ${scale} scale and maps it onto --ui-primary`, () => {
92
+ expect(css).toMatch(new RegExp(`@theme\\s+static\\s*\\{[\\s\\S]*--color-${scale}\\s*:`));
93
+ expect(css).toMatch(new RegExp(`--ui-primary\\s*:\\s*var\\(\\s*--color-${scale}\\s*\\)`));
94
+ });
95
+ });
96
+ }
97
+
98
+ export interface SingleTailwindPassOptions {
99
+ /**
100
+ * Directory holding the built assets — usually
101
+ * `new URL("../.output/public/_nuxt", import.meta.url)`.
102
+ */
103
+ output: string | URL;
104
+ /**
105
+ * Tailwind colour scale the brand palette defines, without the `--color-`
106
+ * prefix. Asserts the palette survived into the compiled bundle.
107
+ */
108
+ scale: string;
109
+ /**
110
+ * Extra base utilities to assert on, appended to {@link BASE_UTILITIES}.
111
+ * Each entry is a full class selector, e.g. `".text-5xl"`.
112
+ */
113
+ utilities?: string[];
114
+ }
115
+
116
+ /**
117
+ * Ubiquitous unwrapped utilities the layer and Nuxt UI always emit. In a
118
+ * single-pass build each appears exactly once; a second pass makes them 2, and
119
+ * a bundle the layer base never reached makes them 0.
120
+ */
121
+ const BASE_UTILITIES = [".relative", ".absolute", ".flex", ".grid", ".hidden", ".block"];
122
+
123
+ /**
124
+ * Responsive utilities emitted inside `@media` blocks. Asserting these as well
125
+ * as {@link BASE_UTILITIES} is not redundancy — the two halves duplicate
126
+ * independently, and a base-only probe reports a duplicating build as clean.
127
+ *
128
+ * Measured on the same layer, same config, differing only in the Vite build
129
+ * used by `@nuxt/vite-builder`:
130
+ *
131
+ * | build | `.flex` | `.lg\:hidden` |
132
+ * |------------------|---------|---------------|
133
+ * | rollup (vite 7) | 2 | 2 |
134
+ * | rolldown | 1 | **2** |
135
+ * | single pass | 1 | 1 |
136
+ *
137
+ * Rolldown collapses the duplicated top-level rules and leaves the `@media`-
138
+ * wrapped ones — so the consumer on rolldown still shipped ~63 KB of duplicate
139
+ * responsive CSS while every base-utility count read 1.
140
+ *
141
+ * All three are emitted by this layer's own components (`AppHeader`,
142
+ * `AppSubHeader`, `DocsAsideRightBottom`), so every consumer of the layer has
143
+ * them regardless of its own markup.
144
+ */
145
+ const VARIANT_UTILITIES = [".lg\\:hidden", ".lg\\:block", ".max-lg\\:hidden"];
146
+
147
+ /**
148
+ * Compiled-output guard for invariant 2: exactly one Tailwind pass in the
149
+ * shipped stylesheet.
150
+ *
151
+ * **What this guards is payload, not layout.** A second pass emits a
152
+ * byte-for-byte duplicate of the stylesheet — measured at +212 KB raw /
153
+ * +26.7 KB gzip (+94%) on a consumer's render-blocking `entry.css`, on every
154
+ * page for every visitor (UXF-118). It does *not*, on the evidence gathered
155
+ * there, break the cascade: the duplicate pass observed was a complete superset
156
+ * of the first and emitted wholly after it, so the last `sm:`/`lg:` variant
157
+ * still landed after the last conflicting base and won by source order.
158
+ * Computed-style A/B across 4 pages × 4 viewports found zero rendering
159
+ * difference.
160
+ *
161
+ * That rescue is incidental, not designed — a non-superset second pass, or a
162
+ * different emission order, and the cascade does break. But the guard should
163
+ * describe what it actually measures, so nobody re-derives a rendering
164
+ * emergency from a payload regression.
165
+ *
166
+ * Two deliberate choices in what it asserts:
167
+ *
168
+ * - **Both unwrapped and `@media`-wrapped utilities** ({@link BASE_UTILITIES}
169
+ * and {@link VARIANT_UTILITIES}). They duplicate independently; a base-only
170
+ * probe reported a duplicating build as clean.
171
+ * - **`=== 1`, not `<= 1`.** The same assertion then catches the opposite
172
+ * failure — a consumer that never registered a CSS entry importing the layer
173
+ * base, and so ships no utilities at all.
174
+ *
175
+ * Requires a prior `nuxt build` / `nuxt generate`. Keep these in
176
+ * `*.build.test.ts` files excluded from the default unit run.
177
+ */
178
+ export function describeSingleTailwindPass(options: SingleTailwindPassOptions): void {
179
+ const { output, scale, utilities = [] } = options;
180
+
181
+ describe("compiled CSS: single Tailwind pass", () => {
182
+ const css = readEntryCss(toPath(output));
183
+ const assertedUtilities = [...BASE_UTILITIES, ...VARIANT_UTILITIES, ...utilities];
184
+
185
+ it("emits each utility exactly once (no duplicate Tailwind pass, and the layer base did reach the bundle)", () => {
186
+ const counts = Object.fromEntries(assertedUtilities.map((u) => [u, countUtility(css, u)]));
187
+ const expected = Object.fromEntries(assertedUtilities.map((u) => [u, 1]));
188
+ expect(counts).toEqual(expected);
189
+ });
190
+
191
+ it(`compiles the ${scale} scale and the --ui-primary mapping into the bundle`, () => {
192
+ expect(css).toMatch(new RegExp(`--color-${scale}\\s*:`));
193
+ expect(css).toMatch(new RegExp(`--ui-primary\\s*:\\s*var\\(\\s*--color-${scale}\\s*\\)`));
194
+ });
195
+ });
196
+ }
197
+
198
+ /** Read the built `entry.*.css`, comments stripped so counts reflect rules only. */
199
+ function readEntryCss(outputDir: string): string {
200
+ if (!existsSync(outputDir)) {
201
+ throw new Error(
202
+ `Compiled output not found at ${outputDir}. Build the app first ` +
203
+ `(nuxt build) before running the compiled-output guard.`,
204
+ );
205
+ }
206
+ const entries = readdirSync(outputDir)
207
+ .filter((file) => /^entry\..*\.css$/.test(file))
208
+ .sort();
209
+ if (entries.length === 0) {
210
+ throw new Error(`No entry.*.css found in ${outputDir}. Build the app first (nuxt build).`);
211
+ }
212
+ return readFileSync(`${outputDir}/${entries.at(-1)}`, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
213
+ }
214
+
215
+ /** Count how many times a utility is emitted as its own class selector. */
216
+ function countUtility(css: string, utility: string): number {
217
+ const escaped = utility.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
218
+ // A leading `.` immediately followed by the class name, not part of a longer
219
+ // variant selector such as `.sm\:text-5xl` (there the class is preceded by an
220
+ // escaped colon, so `.text-5xl` never appears) and not a prefix of a longer
221
+ // utility such as `.flex-col`.
222
+ const pattern = new RegExp(`(?<![\\w\\\\:-])${escaped}(?![\\w-])`, "g");
223
+ return (css.match(pattern) ?? []).length;
224
+ }