Skip to content

Commit 2bbf7bd

Browse files
authored
Merge pull request #52 from pras75299/feat/tailwind-v4-dual-artifact
feat(cli): tailwind v4 dual-artifact path with CSS-append for @theme
2 parents fdf7e38 + 7cf1995 commit 2bbf7bd

12 files changed

Lines changed: 545 additions & 32 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ If you are exploring or contributing to the repository, these are the main place
103103
|---|---|---|
104104
| **Node.js (CLI users)** | ≥ 18 | 20 LTS / 22 LTS recommended. The monorepo itself uses Node 24 — that's for contributors only and does not affect end users of `uniqueui-cli`. |
105105
| **React** | 18 or 19 | Components are written to run on both. |
106-
| **Tailwind CSS** | 3.4+ or 4.x | The CLI auto-merges `theme.extend` into a `tailwind.config.*` file (v3 model). On v4 (CSS-first `@theme`) copy keyframes/animation from the component's registry JSON into your `@theme` block manually — see the [compatibility matrix](https://uniqueui-platform.vercel.app/docs/compatibility). |
106+
| **Tailwind CSS** | 3.4+ or 4.x | The CLI detects which major you're on. v3: merges `theme.extend` into your `tailwind.config.*`. v4: appends a marker-wrapped `@theme` snippet to the file at `components.json#tailwind.css`. Idempotent on re-runs; respects `--dry-run`. See the [compatibility matrix](https://uniqueui-platform.vercel.app/docs/compatibility). |
107107

108108
> All components use [Motion](https://motion.dev) (formerly Framer Motion). The CLI installs `motion` automatically.
109109

apps/www/app/docs/compatibility/page.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ const stack: Row[] = [
9999
{
100100
label: "Tailwind CSS v4",
101101
version: "≥ 4.0",
102-
status: "manual",
102+
status: "tested",
103103
notes:
104-
"The docs site uses v4. The CLI's auto-merge still targets a v3-style config file. If your project is v4 CSS-first, copy the component's keyframes / theme tokens into your `@theme` block manually. We are tracking a first-class v4 path in the backlog.",
104+
"The CLI detects v4 (via `@tailwindcss/postcss`, a 4.x `tailwindcss` range, or `@import \"tailwindcss\"` in your globals.css) and appends a marker-wrapped `@theme` snippet to your `tailwind.css` path. Idempotent across re-runs and respected by `--dry-run`.",
105105
},
106106
{
107107
label: "Motion (formerly Framer Motion)",
@@ -251,17 +251,23 @@ export default function CompatibilityPage() {
251251
<Matrix title="Frameworks" rows={frameworks} />
252252
<Matrix title="CLI flows" rows={cliFlows} />
253253

254-
<section className="space-y-3 rounded-xl border border-amber-300/60 bg-amber-50 p-5 text-sm leading-relaxed text-amber-800 dark:border-amber-500/20 dark:bg-amber-500/5 dark:text-amber-200">
255-
<p className="font-semibold">Tailwind v4 — what to do today</p>
254+
<section className="space-y-3 rounded-xl border border-emerald-300/60 bg-emerald-50 p-5 text-sm leading-relaxed text-emerald-900 dark:border-emerald-500/20 dark:bg-emerald-500/5 dark:text-emerald-200">
255+
<p className="font-semibold">Tailwind v4 — how it works</p>
256256
<p>
257257
A v4 project&apos;s tokens live in CSS (<code className="font-mono text-xs">@theme</code> in your global stylesheet),
258-
not in a JS <code className="font-mono text-xs">tailwind.config</code>. When a UniqueUI component declares
259-
custom keyframes (e.g. <code className="font-mono text-xs">moving-border</code>, <code className="font-mono text-xs">aurora-background</code>),
260-
the CLI&apos;s automatic merge won&apos;t find a config object to write into, so you need to copy the
261-
<code className="font-mono text-xs"> theme.extend.keyframes</code> and
262-
<code className="font-mono text-xs"> theme.extend.animation</code> entries from the component&apos;s registry JSON
263-
(<code className="font-mono text-xs">https://uniqueui-platform.vercel.app/registry/&lt;slug&gt;.json</code>) into your
264-
<code className="font-mono text-xs"> @theme</code> block. A first-class v4 emission path is on the roadmap.
258+
not in a JS <code className="font-mono text-xs">tailwind.config</code>. When you run
259+
<code className="font-mono text-xs"> uniqueui add &lt;component&gt;</code>, the CLI detects v4 (from
260+
<code className="font-mono text-xs"> @tailwindcss/postcss</code>, a 4.x <code className="font-mono text-xs">tailwindcss</code> range,
261+
or <code className="font-mono text-xs">@import &quot;tailwindcss&quot;</code> in your CSS) and, if the component ships a
262+
<code className="font-mono text-xs"> tailwindCss</code> snippet, appends it to the file at
263+
<code className="font-mono text-xs"> components.json#tailwind.css</code> wrapped in
264+
<code className="font-mono text-xs">{" /* uniqueui:start <slug> */ "}</code> markers. Re-runs are idempotent;
265+
<code className="font-mono text-xs"> --dry-run</code> prints the snippet without writing.
266+
</p>
267+
<p>
268+
On a v3 project the same command takes the legacy path and merges
269+
<code className="font-mono text-xs"> theme.extend.animation</code> / <code className="font-mono text-xs">keyframes</code> into your
270+
<code className="font-mono text-xs"> tailwind.config.{"{js,ts}"}</code>. You don&apos;t pick a mode — the CLI does.
265271
</p>
266272
</section>
267273

apps/www/public/registry.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
}
3434
}
3535
}
36-
}
36+
},
37+
"tailwindCss": "@theme {\n --animate-border-spin: border-spin 3s linear infinite;\n\n @keyframes border-spin {\n 100% {\n transform: rotate(-360deg);\n }\n }\n}\n"
3738
},
3839
{
3940
"name": "typewriter-text",
@@ -336,7 +337,8 @@
336337
}
337338
}
338339
}
339-
}
340+
},
341+
"tailwindCss": "@theme {\n --animate-meteor: meteor 5s linear infinite;\n\n @keyframes meteor {\n 0% {\n transform: rotate(215deg) translateX(0);\n opacity: 1;\n }\n 70% {\n opacity: 1;\n }\n 100% {\n transform: rotate(215deg) translateX(-500px);\n opacity: 0;\n }\n }\n}\n"
340342
},
341343
{
342344
"name": "mini-mac-keyboard",

apps/www/public/registry/meteors-card.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,6 @@
3939
}
4040
}
4141
}
42-
}
42+
},
43+
"tailwindCss": "@theme {\n --animate-meteor: meteor 5s linear infinite;\n\n @keyframes meteor {\n 0% {\n transform: rotate(215deg) translateX(0);\n opacity: 1;\n }\n 70% {\n opacity: 1;\n }\n 100% {\n transform: rotate(215deg) translateX(-500px);\n opacity: 0;\n }\n }\n}\n"
4344
}

apps/www/public/registry/moving-border.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@
3232
}
3333
}
3434
}
35-
}
35+
},
36+
"tailwindCss": "@theme {\n --animate-border-spin: border-spin 3s linear infinite;\n\n @keyframes border-spin {\n 100% {\n transform: rotate(-360deg);\n }\n }\n}\n"
3637
}

packages/cli/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ npx uniqueui <command>
9595
|---|---|---|
9696
| **Node.js** | ≥ 18 | 20 LTS / 22 LTS recommended. The UniqueUI monorepo itself pins Node 24 for contributors — that does **not** apply to end users of this CLI. |
9797
| **React** | 18 or 19 | Components target both. |
98-
| **Tailwind CSS** | 3.4+ or 4.x | v3: tokens auto-merge into `tailwind.config.*`. v4: copy keyframes/animation into your `@theme` block — see below. |
98+
| **Tailwind CSS** | 3.4+ or 4.x | The CLI detects your major (via `@tailwindcss/postcss`, a 4.x `tailwindcss` range, or `@import "tailwindcss"` in your CSS). v3: tokens auto-merge into `tailwind.config.*`. v4: a marker-wrapped `@theme` snippet is appended to `components.json#tailwind.css`. Idempotent + dry-run safe. |
9999

100-
> **Tailwind v4 note:** `uniqueui add` merges component animation tokens into a JS/TS `tailwind.config.*` file. v4 projects that store tokens in CSS (`@theme { ... }`) will see the merge no-op safely — copy the `tailwindConfig` block printed by `add` into your `globals.css` `@theme` block manually. The full matrix lives at [`/docs/compatibility`](https://uniqueui-platform.vercel.app/docs/compatibility).
100+
> **Tailwind v4 note:** `uniqueui add` detects v4 projects and appends a marker-wrapped `@theme` snippet to the file pointed at by `components.json#tailwind.css` (default `app/globals.css`). Re-running is safe — slug markers make the append idempotent — and `--dry-run` prints the snippet without writing. On v3 projects the same command merges `theme.extend` into `tailwind.config.*` instead. The full matrix lives at [`/docs/compatibility`](https://uniqueui-platform.vercel.app/docs/compatibility).
101101
102102
> **Note:** All components use [Motion](https://motion.dev) (formerly Framer Motion). The CLI installs `motion` automatically when you add a component that needs it.
103103
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import fs from "fs-extra";
3+
import path from "path";
4+
import os from "os";
5+
import { appendTailwindCss, detectTailwindMajor, validateRegistryPayload } from "./add";
6+
7+
// Covers the Tailwind v4 dual-artifact path:
8+
// - detection from package.json (preferred signal) and globals.css (fallback)
9+
// - CSS append helper: idempotent, marker-block wrapped, dry-run safe
10+
11+
describe("detectTailwindMajor", () => {
12+
let tmpDir: string;
13+
14+
beforeEach(async () => {
15+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "uniqueui-tw-detect-"));
16+
});
17+
18+
afterEach(async () => {
19+
await fs.remove(tmpDir);
20+
});
21+
22+
it("returns 'v4' when @tailwindcss/postcss is in devDependencies — the canonical v4 signal", async () => {
23+
await fs.writeJson(path.join(tmpDir, "package.json"), {
24+
devDependencies: { "@tailwindcss/postcss": "^4.0.0" },
25+
});
26+
expect(detectTailwindMajor(tmpDir)).toBe("v4");
27+
});
28+
29+
it("returns 'v4' when tailwindcss is pinned to a 4.x range", async () => {
30+
await fs.writeJson(path.join(tmpDir, "package.json"), {
31+
dependencies: { tailwindcss: "^4.1.0" },
32+
});
33+
expect(detectTailwindMajor(tmpDir)).toBe("v4");
34+
});
35+
36+
it("returns 'v3' when tailwindcss is pinned to a 3.x range — must not trigger v4 CSS append", async () => {
37+
await fs.writeJson(path.join(tmpDir, "package.json"), {
38+
devDependencies: { tailwindcss: "^3.4.0" },
39+
});
40+
expect(detectTailwindMajor(tmpDir)).toBe("v3");
41+
});
42+
43+
it("falls back to CSS sniff (@import 'tailwindcss') when package.json is silent", async () => {
44+
await fs.writeJson(path.join(tmpDir, "package.json"), {});
45+
const cssPath = path.join(tmpDir, "app", "globals.css");
46+
await fs.outputFile(cssPath, '@import "tailwindcss";\n');
47+
expect(detectTailwindMajor(tmpDir, "app/globals.css")).toBe("v4");
48+
});
49+
50+
it("falls back to CSS sniff (@tailwind base) for v3 when package.json is silent", async () => {
51+
await fs.writeJson(path.join(tmpDir, "package.json"), {});
52+
const cssPath = path.join(tmpDir, "app", "globals.css");
53+
await fs.outputFile(cssPath, "@tailwind base;\n@tailwind utilities;\n");
54+
expect(detectTailwindMajor(tmpDir, "app/globals.css")).toBe("v3");
55+
});
56+
57+
it("returns 'unknown' when neither package.json nor globals.css give a signal — caller uses the safe v3 default", async () => {
58+
await fs.writeJson(path.join(tmpDir, "package.json"), {});
59+
expect(detectTailwindMajor(tmpDir, "missing.css")).toBe("unknown");
60+
});
61+
62+
// Edge-case coverage — these encode the priority order the function
63+
// documents (sibling-package > tailwindcss semver > CSS sniff > unknown)
64+
// so a future refactor can't quietly flip it.
65+
66+
it("prefers @tailwindcss/postcss over a 3.x tailwindcss range — a mid-migration project should be treated as v4", async () => {
67+
await fs.writeJson(path.join(tmpDir, "package.json"), {
68+
dependencies: { tailwindcss: "^3.4.0" },
69+
devDependencies: { "@tailwindcss/postcss": "^4.0.0" },
70+
});
71+
expect(detectTailwindMajor(tmpDir)).toBe("v4");
72+
});
73+
74+
it("treats a malformed package.json as 'unknown' — the JSON parse error must not crash the CLI", async () => {
75+
await fs.writeFile(path.join(tmpDir, "package.json"), "{ not valid json");
76+
expect(detectTailwindMajor(tmpDir)).toBe("unknown");
77+
});
78+
79+
it("prefers the v4 CSS pattern when both @import and @tailwind directives are present", async () => {
80+
await fs.writeJson(path.join(tmpDir, "package.json"), {});
81+
const cssPath = path.join(tmpDir, "app", "globals.css");
82+
await fs.outputFile(cssPath, '@import "tailwindcss";\n@tailwind base;\n@tailwind utilities;\n');
83+
expect(detectTailwindMajor(tmpDir, "app/globals.css")).toBe("v4");
84+
});
85+
86+
it("treats an empty CSS file as 'unknown' — nothing to sniff, do not guess", async () => {
87+
await fs.writeJson(path.join(tmpDir, "package.json"), {});
88+
const cssPath = path.join(tmpDir, "app", "globals.css");
89+
await fs.outputFile(cssPath, " \n\t\n");
90+
expect(detectTailwindMajor(tmpDir, "app/globals.css")).toBe("unknown");
91+
});
92+
});
93+
94+
describe("appendTailwindCss", () => {
95+
let tmpDir: string;
96+
let cssPath: string;
97+
let logSpy: ReturnType<typeof vi.spyOn>;
98+
let warnSpy: ReturnType<typeof vi.spyOn>;
99+
100+
const SNIPPET = `@theme {\n --animate-x: x 1s linear infinite;\n}\n`;
101+
102+
beforeEach(async () => {
103+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "uniqueui-tw-append-"));
104+
cssPath = path.join(tmpDir, "globals.css");
105+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
106+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
107+
});
108+
109+
afterEach(async () => {
110+
await fs.remove(tmpDir);
111+
logSpy.mockRestore();
112+
warnSpy.mockRestore();
113+
});
114+
115+
it("returns 'missing-file' and warns when the CSS path does not exist — the user must wire tailwind.css", async () => {
116+
const outcome = await appendTailwindCss(cssPath, SNIPPET, "x-comp");
117+
expect(outcome).toBe("missing-file");
118+
expect(warnSpy).toHaveBeenCalled();
119+
});
120+
121+
it("appends a slug-marker block on first run", async () => {
122+
await fs.writeFile(cssPath, '@import "tailwindcss";\n');
123+
const outcome = await appendTailwindCss(cssPath, SNIPPET, "x-comp");
124+
expect(outcome).toBe("appended");
125+
const content = await fs.readFile(cssPath, "utf8");
126+
expect(content).toContain("/* uniqueui:start x-comp */");
127+
expect(content).toContain("/* uniqueui:end x-comp */");
128+
expect(content).toContain("--animate-x: x 1s linear infinite");
129+
});
130+
131+
it("is idempotent — re-running with the same slug leaves exactly one balanced marker block whose contents match the snippet", async () => {
132+
await fs.writeFile(cssPath, '@import "tailwindcss";\n');
133+
await appendTailwindCss(cssPath, SNIPPET, "x-comp");
134+
const outcome = await appendTailwindCss(cssPath, SNIPPET, "x-comp");
135+
expect(outcome).toBe("already-present");
136+
const content = await fs.readFile(cssPath, "utf8");
137+
// Start/end counts must agree — a mismatch would silently corrupt the
138+
// user's CSS over many runs.
139+
const startCount = (content.match(/uniqueui:start x-comp/g) || []).length;
140+
const endCount = (content.match(/uniqueui:end x-comp/g) || []).length;
141+
expect(startCount).toBe(1);
142+
expect(endCount).toBe(1);
143+
// The body inside the markers must equal the source snippet.
144+
const inner = content.match(/\/\* uniqueui:start x-comp \*\/\n([\s\S]*?)\/\* uniqueui:end x-comp \*\//);
145+
expect(inner).toBeTruthy();
146+
expect(inner![1].trim()).toBe(SNIPPET.trim());
147+
});
148+
149+
it("replaces an existing block when called with force — picks up registry-side snippet updates", async () => {
150+
await fs.writeFile(cssPath, '@import "tailwindcss";\n');
151+
await appendTailwindCss(cssPath, SNIPPET, "x-comp");
152+
const updated = `@theme {\n --animate-x: x 2s ease-in-out infinite;\n}\n`;
153+
const outcome = await appendTailwindCss(cssPath, updated, "x-comp", { force: true });
154+
expect(outcome).toBe("replaced");
155+
const content = await fs.readFile(cssPath, "utf8");
156+
// Only one marker pair, and the body must reflect the new snippet.
157+
expect((content.match(/uniqueui:start x-comp/g) || []).length).toBe(1);
158+
expect((content.match(/uniqueui:end x-comp/g) || []).length).toBe(1);
159+
expect(content).toContain("x 2s ease-in-out infinite");
160+
expect(content).not.toContain("x 1s linear infinite");
161+
});
162+
163+
it("returns 'skipped' and does not write when force replace finds a start marker without a matching end marker", async () => {
164+
// Simulates a partially hand-edited file — the helper must refuse to guess.
165+
const corrupt = '@import "tailwindcss";\n/* uniqueui:start x-comp */\nlooks-truncated\n';
166+
await fs.writeFile(cssPath, corrupt);
167+
const outcome = await appendTailwindCss(cssPath, SNIPPET, "x-comp", { force: true });
168+
expect(outcome).toBe("skipped");
169+
const after = await fs.readFile(cssPath, "utf8");
170+
expect(after).toBe(corrupt);
171+
});
172+
173+
it("force dryRun previews the replacement without writing", async () => {
174+
await fs.writeFile(cssPath, '@import "tailwindcss";\n');
175+
await appendTailwindCss(cssPath, SNIPPET, "x-comp");
176+
const before = await fs.readFile(cssPath, "utf8");
177+
const updated = `@theme {\n --animate-x: x 2s ease infinite;\n}\n`;
178+
const outcome = await appendTailwindCss(cssPath, updated, "x-comp", { force: true, dryRun: true });
179+
expect(outcome).toBe("dry-run");
180+
const after = await fs.readFile(cssPath, "utf8");
181+
expect(after).toBe(before);
182+
});
183+
184+
it("does not write under dryRun — the file stays untouched", async () => {
185+
await fs.writeFile(cssPath, '@import "tailwindcss";\n');
186+
const before = await fs.readFile(cssPath, "utf8");
187+
const outcome = await appendTailwindCss(cssPath, SNIPPET, "x-comp", { dryRun: true });
188+
expect(outcome).toBe("dry-run");
189+
const after = await fs.readFile(cssPath, "utf8");
190+
expect(after).toBe(before);
191+
});
192+
193+
it("appends multiple distinct slugs side-by-side without colliding", async () => {
194+
await fs.writeFile(cssPath, '@import "tailwindcss";\n');
195+
await appendTailwindCss(cssPath, SNIPPET, "first");
196+
await appendTailwindCss(cssPath, "@theme { --animate-y: y 2s linear infinite; }\n", "second");
197+
const content = await fs.readFile(cssPath, "utf8");
198+
expect(content).toContain("/* uniqueui:start first */");
199+
expect(content).toContain("/* uniqueui:start second */");
200+
expect(content).toContain("--animate-x:");
201+
expect(content).toContain("--animate-y:");
202+
});
203+
});
204+
205+
describe("validateRegistryPayload — tailwindCss size cap", () => {
206+
// The 16 KB cap is the security boundary — a hostile registry could
207+
// otherwise inflate globals.css indefinitely. Test the boundary in both
208+
// directions so a future refactor can't silently relax (or tighten) it
209+
// without us noticing.
210+
211+
const baseEntry = {
212+
name: "x",
213+
dependencies: [],
214+
files: [
215+
{
216+
path: "x/component.tsx",
217+
content: "export {}",
218+
type: "registry:ui",
219+
},
220+
],
221+
};
222+
223+
it("accepts tailwindCss up to 16384 bytes", () => {
224+
const justUnder = "a".repeat(16_384);
225+
const result = validateRegistryPayload([{ ...baseEntry, tailwindCss: justUnder }]);
226+
expect(result).not.toBeNull();
227+
expect(result![0].tailwindCss).toHaveLength(16_384);
228+
});
229+
230+
it("rejects tailwindCss larger than 16384 bytes — the registry could otherwise grow user CSS unbounded", () => {
231+
const over = "a".repeat(16_385);
232+
expect(validateRegistryPayload([{ ...baseEntry, tailwindCss: over }])).toBeNull();
233+
});
234+
235+
it("rejects an empty tailwindCss string — an empty snippet is always a registry bug, not a no-op", () => {
236+
expect(validateRegistryPayload([{ ...baseEntry, tailwindCss: "" }])).toBeNull();
237+
});
238+
});

0 commit comments

Comments
 (0)