diff --git a/packages/onchainkit/package.json b/packages/onchainkit/package.json
index 26f8e02552..e4518348ba 100644
--- a/packages/onchainkit/package.json
+++ b/packages/onchainkit/package.json
@@ -16,7 +16,7 @@
"format": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
- "test:coverage": "vitest run --coverage",
+ "test:coverage": "vitest run --coverage --coverage.all=false",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"get-next-version": "node ./scripts/get-next-version.js",
@@ -114,6 +114,7 @@
"exports": {
"./package.json": "./package.json",
"./styles.css": "./dist/assets/style.css",
+ "./onchainkit.css": "./dist/assets/onchainkit.css",
"./theme": "./dist/styles/theme.js",
".": {
"types": "./dist/index.d.ts",
diff --git a/packages/onchainkit/plugins/__tests__/babel-prefix-react-classnames.test.ts b/packages/onchainkit/plugins/__tests__/babel-prefix-react-classnames.test.ts
index fc0099be84..db7289e209 100644
--- a/packages/onchainkit/plugins/__tests__/babel-prefix-react-classnames.test.ts
+++ b/packages/onchainkit/plugins/__tests__/babel-prefix-react-classnames.test.ts
@@ -5,7 +5,11 @@ import { babelPrefixReactClassNames } from '../babel-prefix-react-classnames';
// Helper function to transform code using the plugin
function transform(
code: string,
- options: { prefix: string; cnUtil?: string | false } = { prefix: 'prefix-' },
+ options: {
+ prefix: string;
+ cnUtil?: string | false;
+ universalClass?: string;
+ } = { prefix: 'prefix-' },
): string {
const result = transformSync(code, {
plugins: [
@@ -218,4 +222,146 @@ describe('babel-prefix-react-classnames', () => {
'isActive ? `prefix-active-${dynamic}` : "prefix-inactive-class"',
);
});
+
+ describe('universalClass option', () => {
+ it('should add universal class to HTML elements', () => {
+ const code = '
Hello
';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ expect(result).toContain('className: "prefix-el"');
+ });
+
+ it('should add universal class to HTML elements with existing className', () => {
+ const code = 'Click ';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ expect(result).toContain('className: "prefix-foo prefix-el"');
+ });
+
+ it('should NOT add universal class to React components', () => {
+ const code = 'Hello ';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ expect(result).not.toContain('className');
+ });
+
+ it('should NOT add universal class to React components with existing className', () => {
+ const code = 'Click ';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ expect(result).toContain('className: "prefix-foo"');
+ expect(result).not.toContain('"prefix-foo el"');
+ });
+
+ it('should add universal class to multiple HTML elements', () => {
+ const code = `
+
+ Click
+ Text
+
+ `;
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ // Each HTML element should get the universal class
+ expect(result.match(/className: "prefix-el"/g)?.length).toBe(3);
+ });
+
+ it('should add universal class to HTML elements but not React components in mixed JSX', () => {
+ const code = `
+
+
+ Click
+
+ `;
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ // Only div and button should get the universal class (2 times), not MyComponent
+ expect(result.match(/className: "prefix-el"/g)?.length).toBe(2);
+ });
+
+ it('should add universal class as first argument in cn() calls for HTML elements', () => {
+ const code = 'Click ';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ expect(result).toContain('cn("prefix-foo"');
+ expect(result).toContain('"prefix-bar"');
+ expect(result).toContain('"prefix-el"');
+ });
+
+ it('should NOT add universal class to JSX member expressions', () => {
+ const code = 'Content ';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ expect(result).not.toContain('className');
+ });
+
+ it('should add universal class to template literal className on HTML elements', () => {
+ const code = 'Hello
';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ expect(result).toContain('prefix-el');
+ expect(result).toContain('prefix-foo');
+ });
+
+ it('should add universal class to HTML elements without className', () => {
+ const code = 'Hello
';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ expect(result).toContain('className: "prefix-el"');
+ });
+
+ it('should not add universal class if no universalClass option is set', () => {
+ const code = 'Hello
';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ });
+ expect(result).not.toContain('className');
+ });
+
+ it('should handle React components without adding universal class', () => {
+ const code = 'Click ';
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ // React component should get prefix but not universal class
+ expect(result).toContain('prefix-foo');
+ expect(result).not.toContain('prefix-el');
+ });
+
+ it('should handle multiple HTML elements with and without className', () => {
+ const code = `
+
+ Hello
+ Click
+
+ `;
+ const result = transform(code, {
+ prefix: 'prefix-',
+ universalClass: 'el',
+ });
+ // All HTML elements should get universal class
+ expect(result.match(/prefix-el/g)?.length).toBeGreaterThanOrEqual(3);
+ });
+ });
});
diff --git a/packages/onchainkit/plugins/__tests__/postcss-create-scoped-styles.test.ts b/packages/onchainkit/plugins/__tests__/postcss-create-scoped-styles.test.ts
new file mode 100644
index 0000000000..d89d889428
--- /dev/null
+++ b/packages/onchainkit/plugins/__tests__/postcss-create-scoped-styles.test.ts
@@ -0,0 +1,539 @@
+import { describe, it, expect } from 'vitest';
+import postcss from 'postcss';
+import postcssCreateScopedStyles from '../postcss-create-scoped-styles.js';
+
+describe('postcssCreateScopedStyles', () => {
+ const runPlugin = (input: string, options = {}) => {
+ return postcss([postcssCreateScopedStyles(options)])
+ .process(input, { from: undefined })
+ .then((result) => result.css);
+ };
+
+ it('should split :root variables by --ock- prefix', async () => {
+ const input = `
+ :root {
+ --ock-primary: blue;
+ --font-mono: monospace;
+ --color-red: red;
+ }
+ `;
+
+ const output = await runPlugin(input);
+
+ expect(output).toContain('--ock-primary: blue');
+ expect(output).toContain(':root');
+ expect(output).toContain('--ock-font-mono: monospace');
+ expect(output).toContain('--ock-color-red: red');
+ });
+
+ it('should transform global element selectors', async () => {
+ const input = `
+ * {
+ box-sizing: border-box;
+ }
+ html {
+ font-family: Inter;
+ }
+ `;
+
+ const output = await runPlugin(input);
+
+ expect(output).toContain('.ock\\:el {');
+ expect(output).toContain('box-sizing: border-box');
+ expect(output).toContain('font-family: Inter');
+ });
+
+ it('should not transform existing .ock: prefixed classes', async () => {
+ const input = `
+ .ock\\:bg-primary {
+ background: blue;
+ }
+ `;
+
+ const output = await runPlugin(input);
+
+ expect(output).toContain('.ock\\:bg-primary');
+ expect(output).not.toContain('.ock\\:el .ock\\:bg-primary');
+ });
+
+ it('should transform pseudo-elements correctly', async () => {
+ const input = `
+ ::before {
+ content: '';
+ }
+ `;
+
+ const output = await runPlugin(input);
+
+ expect(output).toContain(':where(.ock\\:el)::before');
+ });
+
+ it('should move @import rules to top', async () => {
+ const input = `
+ .foo { color: red; }
+ @import "test.css";
+ .bar { color: blue; }
+ `;
+
+ const output = await runPlugin(input);
+ const lines = output.trim().split('\n');
+ expect(lines[0]).toContain('@import');
+ });
+
+ it('should consolidate layers when enabled', async () => {
+ const input = `
+ @layer theme {
+ .theme { color: blue; }
+ }
+ @layer base {
+ .base { margin: 0; }
+ }
+ `;
+
+ const output = await runPlugin(input, { consolidateLayers: true });
+ expect(output).not.toContain('@layer onchainkit');
+ expect(output).not.toContain('@layer theme');
+ expect(output).not.toContain('@layer base');
+ expect(output).toContain('Theme section');
+ expect(output).toContain('Base section');
+ });
+
+ it('should transform @keyframes names', async () => {
+ const input = `
+ @keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('@keyframes ock-fadeIn');
+ });
+
+ it('should transform animation references', async () => {
+ const input = `
+ @keyframes fadeIn {
+ from { opacity: 0; }
+ }
+ .element {
+ animation: fadeIn 1s;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('animation: ock-fadeIn 1s');
+ });
+
+ it('should transform @property rules', async () => {
+ const input = `
+ @property --custom-color {
+ syntax: "";
+ inherits: false;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('@property --ock-custom-color');
+ });
+
+ it('should handle @supports rules', async () => {
+ const input = `
+ @supports (display: grid) {
+ .grid {
+ display: grid;
+ --spacing: 10px;
+ }
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('@supports');
+ expect(output).toContain('--ock-spacing');
+ });
+
+ it('should skip keyframes content transformation', async () => {
+ const input = `
+ @keyframes slide {
+ from { transform: translateX(0); }
+ to { transform: translateX(100px); }
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('from');
+ expect(output).toContain('to');
+ });
+
+ it('should transform variable references in values', async () => {
+ const input = `
+ .element {
+ color: var(--primary);
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('var(--ock-primary)');
+ });
+
+ it('should transform variable references with fallbacks', async () => {
+ const input = `
+ .element {
+ color: var(--primary, blue);
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('var(--ock-primary, blue)');
+ });
+
+ it('should transform nested variable references', async () => {
+ const input = `
+ .element {
+ color: var(--primary, var(--fallback));
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('var(--ock-primary, var(--ock-fallback))');
+ });
+
+ it('should handle :host selector', async () => {
+ const input = `
+ :host {
+ display: block;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('.ock\\:el:host {');
+ });
+
+ it('should handle element selectors with pseudo-classes', async () => {
+ const input = `
+ input:focus {
+ outline: 2px solid blue;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('input:where(.ock\\:el):focus');
+ });
+
+ it('should handle ::after pseudo-element', async () => {
+ const input = `
+ ::after {
+ content: '';
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain(':where(.ock\\:el)::after');
+ });
+
+ it('should handle ::backdrop pseudo-element', async () => {
+ const input = `
+ ::backdrop {
+ background: rgba(0,0,0,0.5);
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain(':where(.ock\\:el)::backdrop');
+ });
+
+ it('should handle functional pseudo-classes', async () => {
+ const input = `
+ :where(.class) {
+ color: red;
+ }
+ :is(.class) {
+ color: blue;
+ }
+ :not(.class) {
+ color: green;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('.ock\\:el:where(.class)');
+ expect(output).toContain('.ock\\:el:is(.class)');
+ expect(output).toContain('.ock\\:el:not(.class)');
+ });
+
+ it('should skip selectors with & parent reference', async () => {
+ const input = `
+ .parent {
+ &:hover {
+ color: red;
+ }
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('&:hover');
+ });
+
+ it('should skip theme selectors', async () => {
+ const input = `
+ [data-ock-theme="dark"] {
+ --bg: black;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('[data-ock-theme="dark"]');
+ expect(output).not.toContain('.ock\\:el [data-ock-theme');
+ });
+
+ it('should handle animation-name property', async () => {
+ const input = `
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+ .spinner {
+ animation-name: spin;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('animation-name: ock-spin');
+ });
+
+ it('should handle element selectors with attribute selectors', async () => {
+ const input = `
+ input[type="text"] {
+ border: 1px solid gray;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('input:where(.ock\\:el)[type="text"]');
+ });
+
+ it('should handle multiple selectors', async () => {
+ const input = `
+ h1, h2, h3 {
+ margin: 0;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('h1:where(.ock\\:el)');
+ expect(output).toContain('h2:where(.ock\\:el)');
+ expect(output).toContain('h3:where(.ock\\:el)');
+ });
+
+ it('should handle :root, :host combined selector', async () => {
+ const input = `
+ :root, :host {
+ --color: red;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('--ock-color: red');
+ });
+
+ it('should handle custom scope class option', async () => {
+ const input = `
+ html {
+ font-size: 16px;
+ }
+ `;
+
+ const output = await runPlugin(input, { scopeClass: '.custom' });
+ expect(output).toContain('.custom {');
+ });
+
+ it('should handle pseudo-selectors like :focus', async () => {
+ const input = `
+ :focus {
+ outline: 2px solid blue;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('.ock\\:el:focus');
+ });
+
+ it('should handle complex element selectors', async () => {
+ const input = `
+ body {
+ margin: 0;
+ }
+ hr {
+ border: 0;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('body:where(.ock\\:el)');
+ expect(output).toContain('hr:where(.ock\\:el)');
+ });
+
+ it('should handle layer declarations without content', async () => {
+ const input = `
+ @layer theme, base, utilities;
+ @layer theme {
+ .theme { color: blue; }
+ }
+ `;
+
+ const output = await runPlugin(input, { consolidateLayers: true });
+ expect(output).not.toContain('@layer onchainkit');
+ expect(output).not.toContain('@layer theme, base, utilities');
+ expect(output).toContain('Theme section');
+ });
+
+ it('should handle multiple imports', async () => {
+ const input = `
+ @import "first.css";
+ @import "second.css";
+ .element { color: red; }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('@import "first.css"');
+ expect(output).toContain('@import "second.css"');
+ // Imports should be at the top
+ expect(output.indexOf('@import "first.css"')).toBeLessThan(
+ output.indexOf('.element'),
+ );
+ });
+
+ it('should handle ::file-selector-button pseudo-element', async () => {
+ const input = `
+ ::file-selector-button {
+ padding: 10px;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain(':where(.ock\\:el)::file-selector-button');
+ });
+
+ it('should handle :has() functional pseudo-class', async () => {
+ const input = `
+ :has(.child) {
+ color: red;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('.ock\\:el:has(.child)');
+ });
+
+ it('should handle variable declarations in keyframes with animation reference', async () => {
+ const input = `
+ @keyframes fadeIn {
+ to {
+ opacity: 1;
+ --animate-delay: fadeIn;
+ }
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('--animate-delay: ock-fadeIn');
+ });
+
+ it('should handle selectors with parentheses in :not()', async () => {
+ const input = `
+ :not(.foo, .bar) {
+ color: red;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('.ock\\:el:not(.foo, .bar)');
+ });
+
+ it('should transform variables in @supports params', async () => {
+ const input = `
+ @supports (--custom: value) {
+ .element {
+ color: var(--custom);
+ }
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('var(--ock-custom)');
+ });
+
+ it('should transform animation variables with empty fallback', async () => {
+ const input = `
+ .element {
+ color: var(--primary,);
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('var(--ock-primary,)');
+ });
+
+ it('should handle complex selectors with class combinations', async () => {
+ const input = `
+ div.container {
+ margin: 0;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('.ock\\:el div.container');
+ });
+
+ it('should handle other non-element selectors', async () => {
+ const input = `
+ .custom-class {
+ color: red;
+ }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('.ock\\:el .custom-class');
+ });
+
+ it('should handle single import with proper formatting', async () => {
+ const input = `
+ @import "single.css";
+ .element { color: red; }
+ `;
+
+ const output = await runPlugin(input);
+ expect(output).toContain('@import "single.css"');
+ });
+
+ it('should handle layer with no matching content', async () => {
+ const input = `
+ @layer properties {
+ .prop { color: blue; }
+ }
+ `;
+
+ const output = await runPlugin(input, { consolidateLayers: true });
+ expect(output).not.toContain('@layer onchainkit');
+ expect(output).not.toContain('@layer properties');
+ expect(output).toContain('Properties section');
+ });
+
+ it('should insert layers after imports when consolidating', async () => {
+ const input = `
+ @import "first.css";
+ @layer theme {
+ .theme { color: blue; }
+ }
+ @layer base {
+ .base { margin: 0; }
+ }
+ `;
+
+ const output = await runPlugin(input, { consolidateLayers: true });
+ const lines = output.trim().split('\n');
+ expect(lines[0]).toContain('@import');
+ expect(output).toContain('Theme section');
+ expect(output).toContain('Base section');
+ expect(output).not.toContain('@layer theme');
+ expect(output).not.toContain('@layer base');
+ });
+});
diff --git a/packages/onchainkit/plugins/__tests__/vite-dual-css.test.ts b/packages/onchainkit/plugins/__tests__/vite-dual-css.test.ts
new file mode 100644
index 0000000000..2ef6d58225
--- /dev/null
+++ b/packages/onchainkit/plugins/__tests__/vite-dual-css.test.ts
@@ -0,0 +1,337 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import fs from 'fs';
+import path from 'node:path';
+import { dualCSSPlugin } from '../vite-dual-css.js';
+
+// Define types locally since we don't have rollup installed
+interface OutputAsset {
+ type: 'asset';
+ fileName: string;
+ source: string | Uint8Array;
+ name?: string;
+}
+
+interface OutputChunk {
+ type: 'chunk';
+ fileName: string;
+ [key: string]: unknown;
+}
+
+type OutputBundle = Record;
+
+interface NormalizedOutputOptions {
+ dir?: string;
+ file?: string;
+ [key: string]: unknown;
+}
+
+// Mock fs module
+vi.mock('fs', () => ({
+ default: {
+ existsSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ writeFileSync: vi.fn(),
+ },
+}));
+
+describe('dualCSSPlugin', () => {
+ const mockFs = fs as unknown as {
+ existsSync: ReturnType;
+ mkdirSync: ReturnType;
+ writeFileSync: ReturnType;
+ };
+
+ // Helper to call writeBundle (handles both function and object forms)
+ async function callWriteBundle(
+ plugin: ReturnType,
+ outputOptions: NormalizedOutputOptions,
+ bundle: OutputBundle,
+ ) {
+ const writeBundle = plugin.writeBundle;
+ if (typeof writeBundle === 'function') {
+ await writeBundle.call({} as any, outputOptions as any, bundle as any);
+ } else if (
+ writeBundle &&
+ typeof writeBundle === 'object' &&
+ 'handler' in writeBundle
+ ) {
+ await writeBundle.handler.call(
+ {} as any,
+ outputOptions as any,
+ bundle as any,
+ );
+ }
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset console methods
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should create a plugin with correct name', () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+ expect(plugin.name).toBe('dual-css');
+ });
+
+ it('should process CSS and write scoped file when style.css is found', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'onchainkit.css' });
+
+ const cssAsset: OutputAsset = {
+ type: 'asset',
+ fileName: 'assets/style.css',
+ source: '.test { color: red; }',
+ name: 'style.css',
+ };
+
+ const bundle: OutputBundle = {
+ 'assets/style.css': cssAsset,
+ };
+
+ const outputOptions: NormalizedOutputOptions = {
+ dir: '/output',
+ } as NormalizedOutputOptions;
+
+ mockFs.existsSync.mockReturnValue(true);
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ expect(console.log).toHaveBeenCalledWith('📦 Generating scoped styles...');
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
+ path.join('/output', 'assets', 'onchainkit.css'),
+ expect.any(String),
+ );
+ expect(console.log).toHaveBeenCalledWith(
+ '✅ Generated scoped styles: assets/onchainkit.css',
+ );
+ });
+
+ it('should create assets directory if it does not exist', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+
+ const cssAsset: OutputAsset = {
+ type: 'asset',
+ fileName: 'assets/style.css',
+ source: '.test { color: blue; }',
+ name: 'style.css',
+ };
+
+ const bundle: OutputBundle = {
+ 'assets/style.css': cssAsset,
+ };
+
+ const outputOptions: NormalizedOutputOptions = {
+ dir: '/output',
+ } as NormalizedOutputOptions;
+
+ mockFs.existsSync.mockReturnValue(false);
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith(
+ path.join('/output', 'assets'),
+ { recursive: true },
+ );
+ });
+
+ it('should warn when no style.css is found in bundle', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+
+ const bundle: OutputBundle = {
+ 'other-file.js': {
+ type: 'chunk',
+ fileName: 'other-file.js',
+ } as any,
+ };
+
+ const outputOptions: NormalizedOutputOptions = {
+ dir: '/output',
+ } as NormalizedOutputOptions;
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ expect(console.warn).toHaveBeenCalledWith(
+ '⚠️ No style.css found in bundle',
+ );
+ expect(mockFs.writeFileSync).not.toHaveBeenCalled();
+ });
+
+ it('should handle outputOptions with file instead of dir', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+
+ const cssAsset: OutputAsset = {
+ type: 'asset',
+ fileName: 'assets/style.css',
+ source: '.test { color: green; }',
+ name: 'style.css',
+ };
+
+ const bundle: OutputBundle = {
+ 'assets/style.css': cssAsset,
+ };
+
+ const outputOptions: NormalizedOutputOptions = {
+ file: '/output/bundle.js',
+ } as NormalizedOutputOptions;
+
+ mockFs.existsSync.mockReturnValue(true);
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
+ path.join('/output', 'assets', 'scoped.css'),
+ expect.any(String),
+ );
+ });
+
+ it('should handle errors during CSS processing', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+
+ const cssAsset: OutputAsset = {
+ type: 'asset',
+ fileName: 'assets/style.css',
+ source: '.test { color: red; }',
+ name: 'style.css',
+ };
+
+ const bundle: OutputBundle = {
+ 'assets/style.css': cssAsset,
+ };
+
+ const outputOptions: NormalizedOutputOptions = {
+ dir: '/output',
+ } as NormalizedOutputOptions;
+
+ const testError = new Error('PostCSS processing failed');
+ mockFs.existsSync.mockReturnValue(true);
+ mockFs.writeFileSync.mockImplementation(() => {
+ throw testError;
+ });
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ expect(console.error).toHaveBeenCalledWith(
+ '❌ Error generating scoped styles:',
+ testError,
+ );
+ });
+
+ it('should apply postcss transformations to CSS', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+
+ const cssAsset: OutputAsset = {
+ type: 'asset',
+ fileName: 'assets/style.css',
+ source: 'html { --color: red; }',
+ name: 'style.css',
+ };
+
+ const bundle: OutputBundle = {
+ 'assets/style.css': cssAsset,
+ };
+
+ const outputOptions: NormalizedOutputOptions = {
+ dir: '/output',
+ } as NormalizedOutputOptions;
+
+ mockFs.existsSync.mockReturnValue(true);
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ // Verify that writeFileSync was called with transformed CSS
+ expect(mockFs.writeFileSync).toHaveBeenCalled();
+ const writtenCss = mockFs.writeFileSync.mock.calls[0][1] as string;
+
+ // The CSS should be transformed by postcssCreateScopedStyles
+ // html should be transformed to .ock:el and --color to --ock-color
+ expect(writtenCss).toContain('.ock\\:el');
+ expect(writtenCss).toContain('--ock-color');
+ });
+
+ it('should handle empty outputOptions gracefully', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+
+ const cssAsset: OutputAsset = {
+ type: 'asset',
+ fileName: 'assets/style.css',
+ source: '.test { color: red; }',
+ name: 'style.css',
+ };
+
+ const bundle: OutputBundle = {
+ 'assets/style.css': cssAsset,
+ };
+
+ const outputOptions: NormalizedOutputOptions =
+ {} as NormalizedOutputOptions;
+
+ mockFs.existsSync.mockReturnValue(true);
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ // Should use empty string as dir when no dir or file is provided
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
+ path.join('assets', 'scoped.css'),
+ expect.any(String),
+ );
+ });
+
+ it('should process CSS with consolidateLayers option enabled', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+
+ const cssAsset: OutputAsset = {
+ type: 'asset',
+ fileName: 'assets/style.css',
+ source: '@layer theme { .theme { color: blue; } }',
+ name: 'style.css',
+ };
+
+ const bundle: OutputBundle = {
+ 'assets/style.css': cssAsset,
+ };
+
+ const outputOptions: NormalizedOutputOptions = {
+ dir: '/output',
+ } as NormalizedOutputOptions;
+
+ mockFs.existsSync.mockReturnValue(true);
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ const writtenCss = mockFs.writeFileSync.mock.calls[0][1] as string;
+
+ // Should consolidate layers without wrapping in @layer onchainkit
+ expect(writtenCss).not.toContain('@layer onchainkit');
+ expect(writtenCss).not.toContain('@layer theme');
+ expect(writtenCss).toContain('Theme section');
+ });
+
+ it('should skip processing if asset is not of type asset', async () => {
+ const plugin = dualCSSPlugin({ scopedFileName: 'scoped.css' });
+
+ const bundle: OutputBundle = {
+ 'assets/style.css': {
+ type: 'chunk',
+ fileName: 'assets/style.css',
+ } as any,
+ };
+
+ const outputOptions: NormalizedOutputOptions = {
+ dir: '/output',
+ } as NormalizedOutputOptions;
+
+ await callWriteBundle(plugin, outputOptions, bundle);
+
+ expect(console.warn).toHaveBeenCalledWith(
+ '⚠️ No style.css found in bundle',
+ );
+ expect(mockFs.writeFileSync).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/onchainkit/plugins/babel-prefix-react-classnames.ts b/packages/onchainkit/plugins/babel-prefix-react-classnames.ts
index 2cac07412f..7ec9b65840 100644
--- a/packages/onchainkit/plugins/babel-prefix-react-classnames.ts
+++ b/packages/onchainkit/plugins/babel-prefix-react-classnames.ts
@@ -1,3 +1,4 @@
+/* eslint-disable complexity */
import { declare } from '@babel/helper-plugin-utils';
import * as t from '@babel/types';
import { prefixStringParts } from '../src/internal/utils/prefixStringParts';
@@ -48,21 +49,64 @@ function processTemplateLiteral(
export function babelPrefixReactClassNames({
prefix,
cnUtil = 'cn',
+ universalClass,
}: {
prefix: string;
cnUtil?: string | false;
+ universalClass?: string;
}): ReturnType {
return declare(({ types }) => {
return {
visitor: {
+ JSXOpeningElement(path) {
+ // Only process if universalClass is defined
+ if (!universalClass) return;
+
+ // Only add to HTML elements (lowercase tag names)
+ if (!types.isJSXIdentifier(path.node.name)) return;
+ if (!/^[a-z]/.test(path.node.name.name)) return;
+
+ // Check if element already has a className attribute
+ const hasClassName = path.node.attributes.some(
+ (attr) =>
+ types.isJSXAttribute(attr) &&
+ types.isJSXIdentifier(attr.name) &&
+ attr.name.name === 'className',
+ );
+
+ // If no className, add one with just the universal class
+ if (!hasClassName) {
+ const prefixedUniversalClass = `${prefix}${universalClass}`;
+ path.node.attributes.push(
+ types.jsxAttribute(
+ types.jsxIdentifier('className'),
+ types.stringLiteral(prefixedUniversalClass),
+ ),
+ );
+ }
+ },
JSXAttribute(path) {
if (path.node.name.name !== 'className') return;
const value = path.node.value;
+ // Check if this className is on an HTML element (lowercase tag)
+ const parent = path.parent;
+ const isHTMLElement =
+ types.isJSXOpeningElement(parent) &&
+ types.isJSXIdentifier(parent.name) &&
+ /^[a-z]/.test(parent.name.name);
+
// Handle string literals
if (types.isStringLiteral(value)) {
value.value = prefixStringParts(value.value, prefix);
+ // Add universal class only to HTML elements
+ if (universalClass && isHTMLElement) {
+ const prefixedUniversalClass = `${prefix}${universalClass}`;
+ if (!value.value.includes(prefixedUniversalClass)) {
+ value.value = `${value.value} ${prefixedUniversalClass}`;
+ }
+ }
}
if (types.isJSXExpressionContainer(value)) {
@@ -71,6 +115,20 @@ export function babelPrefixReactClassNames({
// Handle template literals
if (types.isTemplateLiteral(expression)) {
processTemplateLiteral(expression, prefix);
+ // Add universal class only to HTML elements
+ if (universalClass && isHTMLElement) {
+ const prefixedUniversalClass = `${prefix}${universalClass}`;
+ // Add as last quasi
+ const lastQuasi =
+ expression.quasis[expression.quasis.length - 1];
+ if (
+ lastQuasi &&
+ !lastQuasi.value.raw.includes(prefixedUniversalClass)
+ ) {
+ lastQuasi.value.raw = `${lastQuasi.value.raw} ${prefixedUniversalClass}`;
+ lastQuasi.value.cooked = lastQuasi.value.raw;
+ }
+ }
}
// Handle cnUtil function calls
@@ -131,8 +189,23 @@ export function babelPrefixReactClassNames({
// Leave identifiers and member expressions untouched
return arg;
});
+
+ // Add universal class only to HTML elements
+ if (universalClass && isHTMLElement) {
+ const prefixedUniversalClass = `${prefix}${universalClass}`;
+ expression.arguments.push(
+ types.stringLiteral(prefixedUniversalClass),
+ );
+ }
}
}
+
+ // Handle elements without className - add it if it's an HTML element
+ /* c8 ignore next 4 */
+ if (!value && universalClass && isHTMLElement) {
+ const prefixedUniversalClass = `${prefix}${universalClass}`;
+ path.node.value = types.stringLiteral(prefixedUniversalClass);
+ }
},
},
};
diff --git a/packages/onchainkit/plugins/postcss-create-scoped-styles.ts b/packages/onchainkit/plugins/postcss-create-scoped-styles.ts
new file mode 100644
index 0000000000..83153b99d6
--- /dev/null
+++ b/packages/onchainkit/plugins/postcss-create-scoped-styles.ts
@@ -0,0 +1,451 @@
+/* eslint-disable complexity */
+import postcss, { type PluginCreator, type Rule, type AtRule } from 'postcss';
+
+interface PostCSSScopeToClassOptions {
+ scopeClass?: string;
+ consolidateLayers?: boolean;
+}
+
+const postcssCreateScopedStyles: PluginCreator = (
+ options: PostCSSScopeToClassOptions = {},
+) => {
+ const { scopeClass = '.ock\\:el', consolidateLayers = false } = options;
+
+ return {
+ postcssPlugin: 'postcss-create-scoped-styles',
+ prepare() {
+ return {
+ Root(root) {
+ // First pass: collect and consolidate layers if enabled
+ if (consolidateLayers) {
+ consolidateAllLayers(root);
+ }
+
+ // Move @import rules to the top of the file
+ moveImportsToTop(root);
+
+ // Collect all keyframe names before transformation
+ const keyframeNames = new Set();
+ root.walkAtRules('keyframes', (atRule) => {
+ keyframeNames.add(atRule.params);
+ });
+
+ // Second pass: transform selectors and variable references
+ root.walkRules((rule) => {
+ // Transform :root rules specially
+ if (rule.selector === ':root' || rule.selector === ':root, :host') {
+ transformRootRule(rule, keyframeNames);
+ } else if (isInsideAtRule(rule, ['keyframes', 'document'])) {
+ // Skip selector transformation for keyframes and document rules,
+ // but still transform variable references
+ rule.walkDecls((decl) =>
+ transformVariableReferences(decl, keyframeNames),
+ );
+ } else if (isInsideAtRule(rule, ['supports'])) {
+ // For @supports rules, transform variables but handle selectors carefully
+ transformRuleInSupports(rule, scopeClass, keyframeNames);
+ } else {
+ // Transform other global selectors
+ transformGlobalSelector(rule, scopeClass);
+
+ // Transform variable declarations and references in all rules
+ rule.walkDecls((decl) => {
+ // Transform variable declarations to have --ock- prefix
+ if (
+ decl.prop.startsWith('--') &&
+ !decl.prop.startsWith('--ock-')
+ ) {
+ decl.prop = `--ock-${decl.prop.slice(2)}`;
+ }
+ // Transform variable references
+ transformVariableReferences(decl, keyframeNames);
+ });
+ }
+ });
+
+ // Third pass: transform variable references in at-rules (like @supports)
+ root.walkAtRules((atRule) => {
+ // Transform @property rule names to use --ock- prefix
+ if (
+ atRule.name === 'property' &&
+ atRule.params.startsWith('--') &&
+ !atRule.params.startsWith('--ock-')
+ ) {
+ atRule.params = `--ock-${atRule.params.slice(2)}`;
+ }
+
+ // Transform @keyframes names to use ock- prefix
+ if (
+ atRule.name === 'keyframes' &&
+ !atRule.params.startsWith('ock-')
+ ) {
+ atRule.params = `ock-${atRule.params}`;
+ }
+
+ atRule.walkDecls((decl) =>
+ transformVariableReferences(decl, keyframeNames),
+ );
+ });
+ },
+ };
+ },
+ };
+};
+
+function moveImportsToTop(root: postcss.Root) {
+ const imports: postcss.AtRule[] = [];
+
+ // Collect all @import rules
+ root.walkAtRules('import', (atRule) => {
+ imports.push(atRule.clone());
+ atRule.remove();
+ });
+
+ // Add imports at the beginning of the file
+ if (imports.length > 0) {
+ // Process imports in reverse order since we're prepending
+ for (let i = imports.length - 1; i >= 0; i--) {
+ root.prepend(imports[i]);
+ }
+ }
+}
+
+function consolidateAllLayers(root: postcss.Root) {
+ const layerOrder = ['properties', 'theme', 'base', 'utilities'];
+ const layerContents: Record = {};
+ const layerDeclarations: AtRule[] = [];
+
+ // First pass: collect all layer contents and remove layer at-rules
+ root.walk((node) => {
+ if (node.type === 'atrule' && node.name === 'layer') {
+ if (node.params && !node.params.includes(',')) {
+ // Single layer with content
+ const layerName = node.params.trim();
+ if (layerOrder.includes(layerName)) {
+ if (!layerContents[layerName]) {
+ layerContents[layerName] = [];
+ }
+ // Move all children to our collection
+ node.each((child) => {
+ layerContents[layerName].push(child.clone());
+ });
+ node.remove();
+ }
+ } else if (node.params && !node.nodes) {
+ // Layer declaration without content (e.g., @layer theme, base, utilities;)
+ layerDeclarations.push(node);
+ node.remove();
+ }
+ }
+ });
+
+ // Insert layer contents directly into root without wrapper
+ if (Object.keys(layerContents).length > 0) {
+ // Find where to insert content (after imports)
+ let insertAfterNode: postcss.ChildNode | null = null;
+ root.each((node) => {
+ if (node.type === 'atrule' && node.name === 'import') {
+ insertAfterNode = node;
+ }
+ });
+
+ // Build all nodes to insert
+ const nodesToInsert: postcss.ChildNode[] = [];
+
+ layerOrder.forEach((layerName) => {
+ if (layerContents[layerName]) {
+ // Add section comment
+ const comment = postcss.comment({
+ text: ` ${layerName.charAt(0).toUpperCase() + layerName.slice(1)} section `,
+ });
+
+ nodesToInsert.push(comment);
+
+ // Add all rules from this layer
+ layerContents[layerName].forEach((node) => {
+ nodesToInsert.push(node);
+ });
+ }
+ });
+
+ // Insert all nodes after imports (or at beginning if no imports)
+ if (insertAfterNode) {
+ nodesToInsert.reverse().forEach((node) => {
+ insertAfterNode!.after(node);
+ });
+ } else {
+ nodesToInsert.reverse().forEach((node) => {
+ root.prepend(node);
+ });
+ }
+
+ // Format the inserted content by cleaning up the root
+ root.cleanRaws();
+ }
+}
+
+function isInsideAtRule(rule: Rule, atRuleNames: string[]): boolean {
+ let parent = rule.parent;
+ while (parent && parent.type !== 'root') {
+ if (
+ parent.type === 'atrule' &&
+ atRuleNames.includes((parent as AtRule).name)
+ ) {
+ return true;
+ }
+ parent = parent.parent as typeof rule.parent | undefined;
+ }
+ return false;
+}
+
+function transformRootRule(rule: Rule, keyframeNames: Set) {
+ // Transform all variables to have --ock- prefix and keep them on :root
+ rule.walkDecls((decl) => {
+ // If variable doesn't already have --ock- prefix, add it
+ if (decl.prop.startsWith('--') && !decl.prop.startsWith('--ock-')) {
+ decl.prop = `--ock-${decl.prop.slice(2)}`; // Remove existing -- and add --ock-
+ }
+
+ // Also transform variable references in the value
+ transformVariableReferences(decl, keyframeNames);
+ });
+}
+
+function transformVariableReferences(
+ decl: postcss.Declaration,
+ keyframeNames: Set,
+) {
+ // Transform var(--variable-name) references to use --ock- prefix
+ // We need to handle nested var() calls in fallbacks
+ // We process from innermost to outermost by repeatedly transforming
+ let previousValue = '';
+ let iterations = 0;
+ const maxIterations = 10; // Prevent infinite loops
+
+ while (previousValue !== decl.value && iterations < maxIterations) {
+ previousValue = decl.value;
+ iterations++;
+
+ // Match var() calls with simple variable names (no nested var in this match)
+ // We'll process innermost first by doing multiple passes
+ decl.value = decl.value.replace(
+ /var\((--[a-zA-Z0-9-]+)\)/g,
+ (match, varName) => {
+ // If the variable doesn't already have --ock- prefix, add it
+ if (!varName.startsWith('--ock-')) {
+ return `var(--ock-${varName.slice(2)})`;
+ }
+ return match;
+ },
+ );
+
+ // Match var() calls with fallbacks (including empty fallbacks like var(--foo,))
+ // This will catch var(--foo, value) or var(--foo,)
+ decl.value = decl.value.replace(
+ /var\((--[a-zA-Z0-9-]+),\s*([^)]*)\)/g,
+ (match, varName, fallback) => {
+ // If fallback contains var(, skip it for now (will be handled in next iteration)
+ if (fallback && fallback.includes('var(')) {
+ // Just prefix the variable name if needed
+ if (!varName.startsWith('--ock-')) {
+ return `var(--ock-${varName.slice(2)}, ${fallback})`;
+ }
+ return match;
+ }
+
+ // If the variable doesn't already have --ock- prefix, add it
+ if (!varName.startsWith('--ock-')) {
+ const prefixedVar = `--ock-${varName.slice(2)}`;
+ return fallback
+ ? `var(${prefixedVar}, ${fallback})`
+ : `var(${prefixedVar},)`;
+ }
+ return match;
+ },
+ );
+ }
+
+ // Transform animation references to use ock- prefix
+ transformAnimationReferences(decl, keyframeNames);
+}
+
+function transformAnimationReferences(
+ decl: postcss.Declaration,
+ keyframeNames: Set,
+) {
+ // Transform animation and animation-name properties to use ock- prefix
+ if (decl.prop === 'animation' || decl.prop === 'animation-name') {
+ // Handle animation values that reference keyframes directly
+ for (const keyframe of keyframeNames) {
+ // Use word boundaries to avoid partial matches
+ const regex = new RegExp(`\\b${keyframe}\\b`, 'g');
+ if (regex.test(decl.value) && !decl.value.includes(`ock-${keyframe}`)) {
+ decl.value = decl.value.replace(regex, `ock-${keyframe}`);
+ }
+ }
+ }
+
+ // Also handle CSS variable values that contain keyframe references
+ // This includes both --ock- prefixed and non-prefixed variables
+ // But skip variables that are already prefixed with --ock- to avoid double-prefixing
+ if (
+ decl.prop.startsWith('--') &&
+ !decl.prop.startsWith('--ock-') &&
+ decl.prop.includes('animate')
+ ) {
+ for (const keyframe of keyframeNames) {
+ const regex = new RegExp(`\\b${keyframe}\\b`, 'g');
+ if (regex.test(decl.value) && !decl.value.includes(`ock-${keyframe}`)) {
+ decl.value = decl.value.replace(regex, `ock-${keyframe}`);
+ }
+ }
+ }
+}
+
+function transformRuleInSupports(
+ rule: Rule,
+ scopeClass: string,
+ keyframeNames: Set,
+) {
+ // Transform variable declarations in @supports rules
+ rule.walkDecls((decl) => {
+ // Transform variable declarations to have --ock- prefix
+ if (decl.prop.startsWith('--') && !decl.prop.startsWith('--ock-')) {
+ decl.prop = `--ock-${decl.prop.slice(2)}`;
+ }
+
+ // Transform variable references
+ transformVariableReferences(decl, keyframeNames);
+ });
+
+ // Transform selectors in @supports rules to be scoped
+ transformGlobalSelector(rule, scopeClass);
+}
+
+function transformGlobalSelector(rule: Rule, scopeClass: string) {
+ // Skip selectors that are already scoped with .ock: prefix
+ if (rule.selector.includes('.ock\\:') || rule.selector.includes('ock:')) {
+ return;
+ }
+
+ // Skip theme selectors - they should remain global to work with html[data-ock-theme]
+ if (rule.selector.includes('[data-ock-theme')) {
+ return;
+ }
+
+ // Skip nested selectors that reference parent with &
+ if (rule.selector.includes('&')) {
+ return;
+ }
+
+ // Transform global selectors to be scoped
+ rule.selector = splitSelectors(rule.selector)
+ .map((selector) => {
+ const trimmed = selector.trim();
+
+ // Transform common global selectors with :where() for low specificity
+ if (trimmed === '*') {
+ return `*:where(${scopeClass})`;
+ }
+
+ if (
+ trimmed === '::before' ||
+ trimmed === '::after' ||
+ trimmed === '::backdrop' ||
+ trimmed === '::file-selector-button'
+ ) {
+ return `:where(${scopeClass})${trimmed}`;
+ }
+
+ // Transform functional pseudo-classes like ':where()', ':is()', ':not()', etc.
+ // These should be combined directly with scope class without modifying their internal content
+ if (
+ trimmed.startsWith(':where(') ||
+ trimmed.startsWith(':is(') ||
+ trimmed.startsWith(':not(') ||
+ trimmed.startsWith(':has(')
+ ) {
+ return `${scopeClass}${trimmed}`;
+ }
+
+ // Transform pure pseudo-selectors and pseudo-elements like ':-moz-focusring', '::-moz-placeholder', etc.
+ if (trimmed.startsWith('::') || trimmed.startsWith(':')) {
+ return `${scopeClass}${trimmed}`;
+ }
+
+ // Special handling for html and :host selectors - these should apply to .ock:el directly
+ if (trimmed === 'html' || trimmed === ':host') {
+ return scopeClass;
+ }
+
+ // Transform element selectors like 'body', 'hr', 'h1', 'h2', etc.
+ if (/^[a-zA-Z][a-zA-Z0-9]*$/.test(trimmed)) {
+ return `${trimmed}:where(${scopeClass})`; // Use :where() to avoid specificity issues
+ }
+
+ // Transform element selectors with pseudo-selectors like 'abbr:where([title])', 'input:where([type="button"])', etc.
+ const elementWithPseudoMatch = trimmed.match(
+ /^([a-zA-Z][a-zA-Z0-9]*)(.*)$/,
+ );
+ if (elementWithPseudoMatch && /^[a-zA-Z]/.test(trimmed)) {
+ const [, elementName, pseudoPart] = elementWithPseudoMatch;
+ // Check if the pseudo part contains typical pseudo-selectors
+ if (
+ pseudoPart &&
+ (pseudoPart.startsWith(':') || pseudoPart.startsWith('['))
+ ) {
+ return `${elementName}:where(${scopeClass})${pseudoPart}`;
+ }
+ }
+
+ // Transform more complex selectors that start with elements (fallback for other patterns)
+ if (/^[a-zA-Z]/.test(trimmed)) {
+ return `${scopeClass} ${trimmed}`;
+ }
+
+ // For other selectors, scope them as descendants
+ return `${scopeClass} ${trimmed}`;
+ })
+ .join(', ');
+}
+
+function splitSelectors(selector: string): string[] {
+ const selectors: string[] = [];
+ let current = '';
+ let depth = 0;
+ let inParens = false;
+
+ for (let i = 0; i < selector.length; i++) {
+ const char = selector[i];
+
+ if (char === '(') {
+ depth++;
+ inParens = true;
+ } else if (char === ')') {
+ depth--;
+ if (depth === 0) {
+ inParens = false;
+ }
+ }
+
+ if (char === ',' && depth === 0 && !inParens) {
+ // This comma is not inside parentheses, so it's a real selector separator
+ selectors.push(current.trim());
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+
+ // Add the last selector
+ if (current.trim()) {
+ selectors.push(current.trim());
+ }
+
+ return selectors;
+}
+
+// Required for PostCSS v8+
+postcssCreateScopedStyles.postcss = true;
+
+export default postcssCreateScopedStyles;
diff --git a/packages/onchainkit/plugins/vite-dual-css.ts b/packages/onchainkit/plugins/vite-dual-css.ts
new file mode 100644
index 0000000000..8dc008386f
--- /dev/null
+++ b/packages/onchainkit/plugins/vite-dual-css.ts
@@ -0,0 +1,54 @@
+import type { Plugin } from 'vite';
+import postcss from 'postcss';
+import path from 'node:path';
+import fs from 'fs';
+import postcssCreateScopedStyles from './postcss-create-scoped-styles.js';
+
+interface DualCSSOptions {
+ scopedFileName: string;
+}
+
+export function dualCSSPlugin({ scopedFileName }: DualCSSOptions): Plugin {
+ return {
+ name: 'dual-css',
+ async writeBundle(outputOptions, bundle) {
+ console.log('📦 Generating scoped styles...');
+
+ // Find the main CSS file in the bundle
+ const cssAsset = Object.values(bundle).find(
+ (asset) =>
+ asset.type === 'asset' && asset.fileName === 'assets/style.css',
+ );
+
+ if (!cssAsset || cssAsset.type !== 'asset') {
+ console.warn('⚠️ No style.css found in bundle');
+ return;
+ }
+
+ try {
+ // Process the CSS with our scoping plugin and layer consolidation
+ const result = await postcss([
+ postcssCreateScopedStyles({
+ consolidateLayers: true, // Enable layer consolidation for scoped styles
+ }),
+ ]).process(cssAsset.source as string, { from: undefined });
+
+ // Write the scoped CSS file
+ const outputDir =
+ outputOptions.dir || path.dirname(outputOptions.file || '');
+ const scopedPath = path.join(outputDir, 'assets', scopedFileName);
+
+ // Ensure the assets directory exists
+ const assetsDir = path.dirname(scopedPath);
+ if (!fs.existsSync(assetsDir)) {
+ fs.mkdirSync(assetsDir, { recursive: true });
+ }
+
+ fs.writeFileSync(scopedPath, result.css);
+ console.log(`✅ Generated scoped styles: assets/${scopedFileName}`);
+ } catch (error) {
+ console.error('❌ Error generating scoped styles:', error);
+ }
+ },
+ };
+}
diff --git a/packages/onchainkit/src/internal/components/amount-input/AmountInput.tsx b/packages/onchainkit/src/internal/components/amount-input/AmountInput.tsx
index 818d2e99bf..515b61aef1 100644
--- a/packages/onchainkit/src/internal/components/amount-input/AmountInput.tsx
+++ b/packages/onchainkit/src/internal/components/amount-input/AmountInput.tsx
@@ -98,7 +98,10 @@ export function AmountInput({
>