@decocms/blocks-cli 7.20.10 → 7.21.1

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.
@@ -10,6 +10,7 @@ import { log, logPhase } from "./types";
10
10
  import { extractSectionMetadata } from "./analyzers/section-metadata";
11
11
  import { classifyIslands } from "./analyzers/island-classifier";
12
12
  import { inventoryLoaders } from "./analyzers/loader-inventory";
13
+ import { extractTailwindConfig } from "./analyzers/tailwind-config";
13
14
 
14
15
  const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
15
16
  ["preact-hooks", /from\s+["']preact\/hooks["']/],
@@ -632,6 +633,18 @@ export function analyze(ctx: MigrationContext): void {
632
633
  ctx.themeColors = theme.colors;
633
634
  ctx.fontFamily = theme.fontFamily;
634
635
 
636
+ // Extract the source site's tailwind.config.ts BEFORE it gets deleted in
637
+ // cleanup — otherwise custom colors/fontFamily/safelist are silently
638
+ // dropped (see decocms/blocks#369, gotcha #48 in the migration skill).
639
+ ctx.tailwindConfig = extractTailwindConfig(ctx.sourceDir);
640
+ const twColorCount = Object.keys(ctx.tailwindConfig.colors).length;
641
+ if (twColorCount > 0) {
642
+ console.log(` Tailwind config: ${twColorCount} custom color(s) ported from tailwind.config.ts`);
643
+ }
644
+ if (ctx.tailwindConfig.reviewItems.length > 0) {
645
+ ctx.manualReviewItems.push(...ctx.tailwindConfig.reviewItems);
646
+ }
647
+
635
648
  console.log(` Site: ${ctx.siteName}`);
636
649
  console.log(` Platform: ${ctx.platform}${ctx.vtexAccount ? ` (account: ${ctx.vtexAccount})` : ""}`);
637
650
  console.log(` GTM ID: ${ctx.gtmId || "none"}`);
@@ -20,6 +20,7 @@ function makeCtx(sourceDir: string, dryRun = false): MigrationContext {
20
20
  importMap: {},
21
21
  discoveredNpmDeps: {},
22
22
  themeColors: {},
23
+ tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
23
24
  fontFamily: null,
24
25
  files: [],
25
26
  sectionMetas: [],
@@ -16,6 +16,7 @@ function makeCtx(sourceDir: string, dryRun = false): MigrationContext {
16
16
  importMap: {},
17
17
  discoveredNpmDeps: {},
18
18
  themeColors: {},
19
+ tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
19
20
  fontFamily: null,
20
21
  files: [],
21
22
  sectionMetas: [],
@@ -172,6 +173,53 @@ describe("compile (phase 8)", () => {
172
173
  expect(result.typecheck.passed).toBe(false);
173
174
  expect(result.build.passed).toBe(true);
174
175
  });
176
+
177
+ it("skips the CSS compile check when src/styles/app.css doesn't exist", () => {
178
+ fs.mkdirSync(path.join(tmpDir, "node_modules"), { recursive: true });
179
+ const ctx = makeCtx(tmpDir);
180
+ const { runner, calls } = fakeRunner({
181
+ "npx tsc --noEmit": { ok: true },
182
+ });
183
+ const result = compile(ctx, { runner });
184
+ expect(result.css.ran).toBe(false);
185
+ expect(calls).toEqual(["npx tsc --noEmit"]);
186
+ });
187
+
188
+ it("runs and fails the CSS compile check when app.css exists and the CLI errors", () => {
189
+ fs.mkdirSync(path.join(tmpDir, "node_modules"), { recursive: true });
190
+ fs.mkdirSync(path.join(tmpDir, "src", "styles"), { recursive: true });
191
+ fs.writeFileSync(path.join(tmpDir, "src", "styles", "app.css"), "@import \"tailwindcss\";\n");
192
+ const ctx = makeCtx(tmpDir);
193
+ const { runner } = fakeRunner({
194
+ "npx tsc --noEmit": { ok: true },
195
+ });
196
+ const cssAwareRunner = (cmd: string, cwd: string) => {
197
+ if (cmd.includes("@tailwindcss/cli")) {
198
+ return { ok: false, output: "Cannot apply unknown utility class `font-bebas-neue`" };
199
+ }
200
+ return runner(cmd, cwd);
201
+ };
202
+ const result = compile(ctx, { runner: cssAwareRunner });
203
+ expect(result.css.ran).toBe(true);
204
+ expect(result.css.passed).toBe(false);
205
+ expect(result.css.output).toContain("font-bebas-neue");
206
+ expect(result.shouldFail).toBe(false); // non-strict: warning only
207
+ });
208
+
209
+ it("promotes a CSS compile failure to abort in --strict mode", () => {
210
+ fs.mkdirSync(path.join(tmpDir, "node_modules"), { recursive: true });
211
+ fs.mkdirSync(path.join(tmpDir, "src", "styles"), { recursive: true });
212
+ fs.writeFileSync(path.join(tmpDir, "src", "styles", "app.css"), "@import \"tailwindcss\";\n");
213
+ const ctx = makeCtx(tmpDir);
214
+ const cssAwareRunner = (cmd: string): CompileRunResult => {
215
+ if (cmd.includes("@tailwindcss/cli")) return { ok: false, output: "invalid CSS" };
216
+ return { ok: true };
217
+ };
218
+ const result = compile(ctx, { runner: cssAwareRunner, strict: true });
219
+ expect(result.typecheck.passed).toBe(true);
220
+ expect(result.css.passed).toBe(false);
221
+ expect(result.shouldFail).toBe(true);
222
+ });
175
223
  });
176
224
 
177
225
  describe("truncate helper", () => {
@@ -25,6 +25,7 @@ import { execSync } from "node:child_process";
25
25
  import * as fs from "node:fs";
26
26
  import * as path from "node:path";
27
27
  import { gray, green, red, yellow } from "./colors";
28
+ import { checkCssCompiles } from "./css-compile-check";
28
29
  import type { MigrationContext } from "./types";
29
30
  import { logPhase } from "./types";
30
31
 
@@ -48,6 +49,15 @@ export interface CompileResult {
48
49
  skipped: boolean;
49
50
  typecheck: { ran: boolean; passed: boolean; output?: string };
50
51
  build: { ran: boolean; passed: boolean; output?: string };
52
+ /**
53
+ * Real Tailwind v4 compilation of src/styles/app.css via the site's own
54
+ * `@tailwindcss/cli` install. Catches the exact failure mode of
55
+ * decocms/blocks#369 / skill gotcha #48 (dropped custom theme tokens ->
56
+ * "Cannot apply unknown utility class") during migration instead of at
57
+ * runtime. `unmatchedClassWarnings` is informational only — it never
58
+ * fails the build (see css-compile-check.ts for why).
59
+ */
60
+ css: { ran: boolean; passed: boolean; output?: string; unmatchedClassWarnings: string[] };
51
61
  /** True iff compile decided the migration should fail (only in strict mode). */
52
62
  shouldFail: boolean;
53
63
  }
@@ -96,6 +106,7 @@ export function compile(
96
106
  skipped: false,
97
107
  typecheck: { ran: false, passed: false },
98
108
  build: { ran: false, passed: false },
109
+ css: { ran: false, passed: false, unmatchedClassWarnings: [] },
99
110
  shouldFail: false,
100
111
  };
101
112
 
@@ -136,6 +147,32 @@ export function compile(
136
147
  console.log("");
137
148
  }
138
149
 
150
+ // CSS compile check — real Tailwind v4 build of src/styles/app.css.
151
+ console.log(" Running: CSS compile check (npx @tailwindcss/cli)...");
152
+ const cssResult = checkCssCompiles(ctx, runner);
153
+ result.css.ran = cssResult.ran;
154
+ result.css.passed = cssResult.passed;
155
+ result.css.unmatchedClassWarnings = cssResult.unmatchedClassWarnings;
156
+
157
+ if (!cssResult.ran) {
158
+ console.log(` ${gray("–")} Skipped (no src/styles/app.css)`);
159
+ } else if (cssResult.passed) {
160
+ console.log(` ${green("✓")} CSS compiled`);
161
+ if (cssResult.unmatchedClassWarnings.length > 0) {
162
+ console.log(
163
+ ` ${yellow("⚠")} ${cssResult.unmatchedClassWarnings.length} class(es) used in src/ produced no CSS output (dropped theme token or typo?):`,
164
+ );
165
+ console.log(` ${cssResult.unmatchedClassWarnings.slice(0, 20).join(", ")}`);
166
+ }
167
+ } else {
168
+ result.css.output = cssResult.output;
169
+ const output = truncate(cssResult.output ?? "", MAX_OUTPUT_LINES);
170
+ const icon = opts.strict ? red("✗") : yellow("⚠");
171
+ console.log(` ${icon} CSS compile failed:\n`);
172
+ console.log(output);
173
+ console.log("");
174
+ }
175
+
139
176
  // Optional Vite build (heavier, opt-in).
140
177
  if (opts.withBuild) {
141
178
  console.log(" Running: Vite build (npx vite build)...");
@@ -158,6 +195,7 @@ export function compile(
158
195
  // Decide whether to fail the migration.
159
196
  const anyFailed =
160
197
  (result.typecheck.ran && !result.typecheck.passed) ||
198
+ (result.css.ran && !result.css.passed) ||
161
199
  (result.build.ran && !result.build.passed);
162
200
 
163
201
  if (anyFailed && opts.strict) {
@@ -0,0 +1,103 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { report } from "./phase-report";
6
+ import { createContext } from "./types";
7
+
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "phase-report-test-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("report — CSS Migration section", () => {
19
+ it("summarizes tokens ported from tailwind.config.ts", () => {
20
+ const ctx = createContext(tmpDir);
21
+ ctx.tailwindConfig = {
22
+ colors: { perola: "#F5F0E8", "brand-500": "#B10200" },
23
+ fontFamily: { "bebas-neue": "Bebas Neue, sans-serif" },
24
+ screens: {},
25
+ safelist: ["bg-red-500"],
26
+ safelistPatterns: [],
27
+ plugins: [],
28
+ reviewItems: [],
29
+ };
30
+
31
+ report(ctx);
32
+
33
+ const content = fs.readFileSync(path.join(tmpDir, "MIGRATION_REPORT.md"), "utf-8");
34
+ expect(content).toContain("## CSS Migration");
35
+ expect(content).toContain("2 color(s), 1 font stack(s), 0 breakpoint(s)");
36
+ expect(content).toContain("1 safelist entrie(s)");
37
+ });
38
+
39
+ it("lists CSS-specific manual review items separately from unrelated ones", () => {
40
+ const ctx = createContext(tmpDir);
41
+ ctx.manualReviewItems = [
42
+ {
43
+ file: "src/styles/app.css",
44
+ reason: "safelist pattern /^grid-cols-/ cannot be auto-converted",
45
+ severity: "warning",
46
+ },
47
+ {
48
+ file: "src/components/Foo.tsx",
49
+ reason: "useComponent(...) call site detected — needs manual React conversion",
50
+ severity: "error",
51
+ },
52
+ ];
53
+
54
+ report(ctx);
55
+
56
+ const content = fs.readFileSync(path.join(tmpDir, "MIGRATION_REPORT.md"), "utf-8");
57
+ const cssSection = content.split("## CSS Migration")[1].split("## Always Check")[0];
58
+ expect(cssSection).toContain("safelist pattern");
59
+ expect(cssSection).not.toContain("useComponent");
60
+ });
61
+
62
+ it("reports no CSS findings when none exist", () => {
63
+ const ctx = createContext(tmpDir);
64
+ report(ctx);
65
+ const content = fs.readFileSync(path.join(tmpDir, "MIGRATION_REPORT.md"), "utf-8");
66
+ expect(content).toContain("No CSS-specific findings from this run.");
67
+ });
68
+
69
+ it("does not miscategorize an unrelated finding whose reason merely contains 'collapse' as a substring of another word", () => {
70
+ const ctx = createContext(tmpDir);
71
+ ctx.manualReviewItems = [
72
+ {
73
+ file: "src/components/Nav.tsx",
74
+ reason: "the mobile nav state collapsed unexpectedly during the smoke test — needs manual QA",
75
+ severity: "warning",
76
+ },
77
+ ];
78
+
79
+ report(ctx);
80
+
81
+ const content = fs.readFileSync(path.join(tmpDir, "MIGRATION_REPORT.md"), "utf-8");
82
+ const cssSection = content.split("## CSS Migration")[1].split("## Always Check")[0];
83
+ expect(cssSection).toContain("No CSS-specific findings from this run.");
84
+ expect(cssSection).not.toContain("collapsed unexpectedly");
85
+ });
86
+
87
+ it("still categorizes a genuine DaisyUI collapse finding as CSS-specific", () => {
88
+ const ctx = createContext(tmpDir);
89
+ ctx.manualReviewItems = [
90
+ {
91
+ file: "src/components/Filters.tsx",
92
+ reason: "DaisyUI .collapse usage found — its expand/collapse chain breaks under Tailwind v4.",
93
+ severity: "warning",
94
+ },
95
+ ];
96
+
97
+ report(ctx);
98
+
99
+ const content = fs.readFileSync(path.join(tmpDir, "MIGRATION_REPORT.md"), "utf-8");
100
+ const cssSection = content.split("## CSS Migration")[1].split("## Always Check")[0];
101
+ expect(cssSection).toContain("DaisyUI .collapse usage found");
102
+ });
103
+ });
@@ -1,8 +1,39 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { MigrationContext } from "./types";
3
+ import type { MigrationContext, ReviewItem } from "./types";
4
4
  import { logPhase } from "./types";
5
5
 
6
+ // Word-only keywords are matched with \b boundaries so a generic English
7
+ // word like "collapse" doesn't misfire on an unrelated finding's reason
8
+ // text (e.g. a JS/UX note that happens to mention "collapse" in a
9
+ // non-DaisyUI sense). Path-/symbol-like keywords (containing non-word
10
+ // characters) are matched as plain substrings since \b doesn't apply to them.
11
+ const CSS_REVIEW_KEYWORDS = [
12
+ "tailwind.config.ts",
13
+ "safelist",
14
+ "oklch",
15
+ "css-styling.md",
16
+ "@theme",
17
+ "@apply",
18
+ "@utility",
19
+ "DaisyUI",
20
+ "collapse",
21
+ ];
22
+
23
+ const CSS_REVIEW_KEYWORD_MATCHERS = CSS_REVIEW_KEYWORDS.map((k) =>
24
+ /^\w+$/.test(k) ? new RegExp(`\\b${k}\\b`, "i") : k,
25
+ );
26
+
27
+ function isCssReviewItem(item: ReviewItem): boolean {
28
+ return (
29
+ item.file.endsWith("app.css") ||
30
+ item.file === "tailwind.config.ts" ||
31
+ CSS_REVIEW_KEYWORD_MATCHERS.some((k) =>
32
+ typeof k === "string" ? item.reason.includes(k) : k.test(item.reason),
33
+ )
34
+ );
35
+ }
36
+
6
37
  const FRAMEWORK_FINDINGS = [
7
38
  "Session/analytics SDK is boilerplate duplicated across all sites — should be a single framework function",
8
39
  "GTM event system (useGTMEvent, data-gtm-* listeners) is universal pattern — should be in @decocms/blocks",
@@ -129,6 +160,37 @@ export function report(ctx: MigrationContext): void {
129
160
  lines.push("");
130
161
  }
131
162
 
163
+ // CSS Migration — replaces the old generic "DaisyUI/Tailwind" checkboxes
164
+ // with what was actually ported/detected for THIS site, so a human
165
+ // doesn't have to re-derive it from scratch.
166
+ lines.push("## CSS Migration");
167
+ lines.push("");
168
+ const twColorCount = Object.keys(ctx.tailwindConfig.colors).length;
169
+ const twFontCount = Object.keys(ctx.tailwindConfig.fontFamily).length;
170
+ const twScreenCount = Object.keys(ctx.tailwindConfig.screens).length;
171
+ const twSafelistCount = ctx.tailwindConfig.safelist.length;
172
+ lines.push(
173
+ `- **tailwind.config.ts porting**: ${twColorCount} color(s), ${twFontCount} font stack(s), ${twScreenCount} breakpoint(s) ported into \`src/styles/app.css\` \`@theme\`; ${twSafelistCount} safelist entrie(s) ported via \`@source inline(...)\`.`,
174
+ );
175
+ lines.push(
176
+ "- **Real CSS compile check**: runs in the compile phase (`npx @tailwindcss/cli`) against `src/styles/app.css` — catches unknown-utility-class errors before they ship. Re-run any time with `bun run tailwind:lint`.",
177
+ );
178
+
179
+ const cssReviewItems = ctx.manualReviewItems.filter(isCssReviewItem);
180
+ if (cssReviewItems.length > 0) {
181
+ lines.push("");
182
+ lines.push(`**${cssReviewItems.length} CSS-specific finding(s) requiring manual review:**`);
183
+ lines.push("");
184
+ for (const item of cssReviewItems) {
185
+ const icon = item.severity === "error" ? "🔴" : item.severity === "warning" ? "🟡" : "🔵";
186
+ lines.push(`${icon} **\`${item.file}\`**: ${item.reason}`);
187
+ }
188
+ } else {
189
+ lines.push("");
190
+ lines.push("- No CSS-specific findings from this run.");
191
+ }
192
+ lines.push("");
193
+
132
194
  // Always-present manual review items
133
195
  lines.push("## Always Check (site-specific)");
134
196
  lines.push("");
@@ -136,9 +198,7 @@ export function report(ctx: MigrationContext): void {
136
198
  lines.push("- [ ] `src/setup/section-loaders.ts` — verify extracted loaders work correctly");
137
199
  lines.push("- [ ] `src/hooks/useCart.ts` — wire to actual server functions for your platform");
138
200
  lines.push("- [ ] `src/worker-entry.ts` — verify CSP, proxy, and segment builder");
139
- lines.push("- [ ] DaisyUI v4 to v5 class name changes");
140
- lines.push("- [ ] Tailwind v3 to v4: verify all utility classes still work");
141
- lines.push("- [ ] Check `src/styles/app.css` theme colors match the original design");
201
+ lines.push("- [ ] See the **CSS Migration** section above for DaisyUI v4→v5 / Tailwind v3→v4 findings specific to this site");
142
202
  lines.push("- [ ] Run `npm run generate:blocks` and `npm run generate:schema` after migration");
143
203
  lines.push("");
144
204
 
@@ -28,6 +28,7 @@ function makeCtx(sourceDir: string): MigrationContext {
28
28
  importMap: {},
29
29
  discoveredNpmDeps: {},
30
30
  themeColors: {},
31
+ tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
31
32
  fontFamily: null,
32
33
  files: [],
33
34
  sectionMetas: [],
@@ -0,0 +1,150 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { createContext } from "../types";
6
+ import { generateAppCss } from "./app-css";
7
+
8
+ describe("generateAppCss — tailwind.config.ts porting", () => {
9
+ it("emits custom colors, fonts, and breakpoints ported from tailwind.config.ts", () => {
10
+ const ctx = createContext("/tmp/does-not-exist");
11
+ ctx.tailwindConfig = {
12
+ colors: { perola: "#F5F0E8", "brand-500": "#B10200" },
13
+ fontFamily: { "bebas-neue": "Bebas Neue, sans-serif" },
14
+ screens: { "3xl": "1920px" },
15
+ safelist: ["bg-red-500", "text-brand-500"],
16
+ safelistPatterns: [],
17
+ plugins: [],
18
+ reviewItems: [],
19
+ };
20
+
21
+ const css = generateAppCss(ctx);
22
+
23
+ expect(css).toContain("--color-perola: #F5F0E8;");
24
+ expect(css).toContain("--color-brand-500: #B10200;");
25
+ expect(css).toContain("--font-bebas-neue: Bebas Neue, sans-serif;");
26
+ expect(css).toContain("--breakpoint-3xl: 1920px;");
27
+ expect(css).toContain('@source inline("bg-red-500 text-brand-500");');
28
+ });
29
+
30
+ it("does not duplicate a custom color that collides with a daisyUI semantic key", () => {
31
+ const ctx = createContext("/tmp/does-not-exist");
32
+ ctx.tailwindConfig.colors = { primary: "#000000" };
33
+
34
+ const css = generateAppCss(ctx);
35
+ // The daisyUI theme's own --color-primary is emitted once in the plugin
36
+ // block; the ported tailwind.config.ts value must not add a second,
37
+ // conflicting @theme declaration.
38
+ const themeBlockMatch = css.match(/@theme \{[\s\S]*?\n\}/);
39
+ expect(themeBlockMatch).not.toBeNull();
40
+ const occurrences = (themeBlockMatch![0].match(/--color-primary:/g) ?? []).length;
41
+ expect(occurrences).toBe(0);
42
+ });
43
+
44
+ it("flags unconvertible safelist regex patterns as manual review items", () => {
45
+ const ctx = createContext("/tmp/does-not-exist");
46
+ ctx.tailwindConfig.safelistPatterns = ["/^grid-cols-/"];
47
+
48
+ generateAppCss(ctx);
49
+
50
+ expect(
51
+ ctx.manualReviewItems.some((i) => i.reason.includes("/^grid-cols-/")),
52
+ ).toBe(true);
53
+ });
54
+
55
+ it("uses a custom tailwind.config.ts gray-* override instead of declaring the v3 default gray scale a second time", () => {
56
+ const ctx = createContext("/tmp/does-not-exist");
57
+ ctx.tailwindConfig.colors = { "gray-50": "#111111", "gray-500": "#222222" };
58
+
59
+ const css = generateAppCss(ctx);
60
+ const themeBlockMatch = css.match(/@theme \{[\s\S]*?\n\}/);
61
+ expect(themeBlockMatch).not.toBeNull();
62
+ const themeBlock = themeBlockMatch![0];
63
+
64
+ // gray-50/500 must be declared exactly once, with the custom value —
65
+ // not once with the custom value and once more with the hardcoded
66
+ // default (which would silently win via CSS's last-declaration-wins).
67
+ expect((themeBlock.match(/--color-gray-50:/g) ?? []).length).toBe(1);
68
+ expect(themeBlock).toContain("--color-gray-50: #111111;");
69
+ expect(themeBlock).not.toContain("--color-gray-50: #f9fafb;");
70
+ expect((themeBlock.match(/--color-gray-500:/g) ?? []).length).toBe(1);
71
+ expect(themeBlock).toContain("--color-gray-500: #222222;");
72
+ // Untouched shades still fall back to the v3 default.
73
+ expect(themeBlock).toContain("--color-gray-950: #030712;");
74
+ });
75
+ });
76
+
77
+ describe("generateAppCss — oklch fixer scope (gotcha #43)", () => {
78
+ let tmpDir: string;
79
+
80
+ beforeEach(() => {
81
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "app-css-oklch-scope-test-"));
82
+ });
83
+
84
+ afterEach(() => {
85
+ fs.rmSync(tmpDir, { recursive: true, force: true });
86
+ });
87
+
88
+ it("does not rewrite a hex/oklch(var()) pair declared in the site's own original CSS", () => {
89
+ fs.writeFileSync(
90
+ path.join(tmpDir, "tailwind.css"),
91
+ `@tailwind base;
92
+ @tailwind components;
93
+ @tailwind utilities;
94
+
95
+ :root {
96
+ --icon-color: #1a1a1a;
97
+ }
98
+ .icon {
99
+ fill: oklch(var(--icon-color));
100
+ }
101
+ `,
102
+ "utf-8",
103
+ );
104
+
105
+ const ctx = createContext(tmpDir);
106
+ const css = generateAppCss(ctx);
107
+
108
+ // The site's own declaration must survive untouched — the oklch fixer
109
+ // must not reach into content it doesn't own.
110
+ expect(css).toContain("--icon-color: #1a1a1a;");
111
+ expect(css).not.toMatch(/--icon-color:\s*[\d.]+\s+[\d.]+\s+[\d.]+;/);
112
+ });
113
+ });
114
+
115
+ describe("generateAppCss — @apply rewrites classes behind a modifier prefix", () => {
116
+ let tmpDir: string;
117
+
118
+ beforeEach(() => {
119
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "app-css-apply-variant-test-"));
120
+ });
121
+
122
+ afterEach(() => {
123
+ fs.rmSync(tmpDir, { recursive: true, force: true });
124
+ });
125
+
126
+ it("renames a variant-prefixed class inside @apply, not just bare tokens", () => {
127
+ fs.writeFileSync(
128
+ path.join(tmpDir, "tailwind.css"),
129
+ `@tailwind base;
130
+ @tailwind components;
131
+ @tailwind utilities;
132
+
133
+ @layer components {
134
+ .btn-brand {
135
+ @apply md:flex-grow hover:ring rounded;
136
+ }
137
+ }
138
+ `,
139
+ "utf-8",
140
+ );
141
+
142
+ const ctx = createContext(tmpDir);
143
+ const css = generateAppCss(ctx);
144
+
145
+ expect(css).toContain("md:grow");
146
+ expect(css).toContain("hover:ring-3");
147
+ expect(css).not.toContain("md:flex-grow");
148
+ expect(css).not.toMatch(/hover:ring(?!-3)/);
149
+ });
150
+ });