diff --git a/.github/workflows/ui-prefix-check.yml b/.github/workflows/ui-prefix-check.yml
new file mode 100644
index 00000000..012662f0
--- /dev/null
+++ b/.github/workflows/ui-prefix-check.yml
@@ -0,0 +1,50 @@
+name: UI Tailwind prefix check
+
+on:
+ pull_request:
+ paths:
+ - 'packages/ui/**'
+ - '.github/workflows/ui-prefix-check.yml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/ui/**'
+ - '.github/workflows/ui-prefix-check.yml'
+
+jobs:
+ ui-prefix:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Resolve git base ref
+ id: base
+ run: |
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ echo "ref=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
+ elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
+ echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
+ else
+ echo "ref=origin/main" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Check ui: prefix on changed UI files
+ env:
+ UI_PREFIX_GIT_BASE: ${{ steps.base.outputs.ref }}
+ run: pnpm --filter @cio/ui prefix:check:changed
diff --git a/AGENTS.md b/AGENTS.md
index 929180b2..e3270796 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -304,6 +304,7 @@ Use `.server.ts` files for server-side code to isolate API keys.
### UI Components
- Add new UI components under `packages/ui/src` following existing folder patterns.
+- **All Tailwind utility classes in `packages/ui/src/**` must use the `ui:` prefix** (see `packages/ui/README.md`). Pre-commit and CI run `pnpm --filter @cio/ui prefix:check` on touched UI files; fix with `pnpm --filter @cio/ui prefix`.
- Document component usage and props in `packages/ui/README.md`.
- Add example usages and variants in Storybook stories under `packages/storybook/src`.
- See `packages/ui/README.md` and `packages/storybook/README.md` for full guidance.
diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte
index 35a0395d..a480b911 100644
--- a/apps/dashboard/src/lib/features/course/pages/settings.svelte
+++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte
@@ -369,10 +369,10 @@
diff --git a/apps/embeds/package.json b/apps/embeds/package.json
index 175120c3..cf83db43 100644
--- a/apps/embeds/package.json
+++ b/apps/embeds/package.json
@@ -19,6 +19,7 @@
"@cio/tsconfig": "workspace:*",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
+ "tailwindcss": "^4.1.18",
"svelte": "^5.39.11",
"dotenv": "^16.3.1",
"tsx": "^4.7.1",
diff --git a/lefthook.yml b/lefthook.yml
index e207bc08..9e45e000 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -9,3 +9,9 @@ pre-commit:
fail_text: |
❌ Prettier formatting check failed!
Please run 'pnpm format:changed' or 'pnpm format' to fix formatting issues and try again.
+ ui-prefix:
+ glob: 'packages/ui/src/**/*.{svelte,ts,tsx,js,jsx}'
+ run: pnpm --filter @cio/ui prefix:check:staged
+ fail_text: |
+ ❌ packages/ui Tailwind classes must use the ui: prefix.
+ Run 'pnpm --filter @cio/ui prefix' to fix staged UI files, then commit again.
diff --git a/packages/ui/README.md b/packages/ui/README.md
index 10034b88..c9f0bfb3 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -249,7 +249,10 @@ When adding a new component to this package, follow these steps:
- `pnpm build` - Build the package
- `pnpm dev` - Watch mode for CSS generation
- `pnpm format` - Format code with Prettier
-- `pnpm prefix:list` - List files that need the `ui:` prefix (dry-run)
+- `pnpm prefix:list` - List files that need the `ui:` prefix (dry-run, exit 0)
+- `pnpm prefix:check` - Fail if any file under `src/` needs the `ui:` prefix
+- `pnpm prefix:check:staged` - Fail if **staged** `packages/ui` files need the prefix (pre-commit)
+- `pnpm prefix:check:changed` - Fail if **changed** `packages/ui` files need the prefix (CI; set `UI_PREFIX_GIT_BASE`)
- `pnpm prefix` - Add `ui:` prefix to all Tailwind classes and format
### Configuration
diff --git a/packages/ui/add-ui-prefix.cjs b/packages/ui/add-ui-prefix.cjs
index a0aca4d3..fbf55eb2 100755
--- a/packages/ui/add-ui-prefix.cjs
+++ b/packages/ui/add-ui-prefix.cjs
@@ -2,25 +2,46 @@
/**
* Script to add 'ui:' prefix to Tailwind classes in packages/ui
- * Usage: node add-ui-prefix.cjs [--dry-run]
+ * Usage:
+ * node add-ui-prefix.cjs # fix all files
+ * node add-ui-prefix.cjs --dry-run # list fixes without writing (exit 0)
+ * node add-ui-prefix.cjs --check # fail if fixes are needed (exit 1)
+ * node add-ui-prefix.cjs --check --staged # check staged packages/ui files only
+ * node add-ui-prefix.cjs --check --changed # check changed packages/ui files vs base ref
+ * node add-ui-prefix.cjs --audit # fail on known prefix corruption patterns
*
- * This script will:
- * - Find all .svelte, .ts, .tsx, .js, .jsx files in packages/ui/src
- * - Add 'ui:' prefix to Tailwind utility classes
- * - Skip classes that already have the prefix
- * - Preserve existing code structure
+ * Env:
+ * UI_PREFIX_GIT_BASE - optional git ref for --changed (default: origin/main)
*/
const fs = require('fs');
const path = require('path');
+const { spawnSync } = require('child_process');
-const DRY_RUN = process.argv.includes('--dry-run');
+const CHECK_MODE = process.argv.includes('--check');
+const AUDIT_MODE = process.argv.includes('--audit');
+const DRY_RUN = process.argv.includes('--dry-run') || CHECK_MODE;
+const STAGED_ONLY = process.argv.includes('--staged');
+const CHANGED_ONLY = process.argv.includes('--changed');
const UI_PREFIX = 'ui:';
+const REPO_ROOT = path.join(__dirname, '../..');
+const UI_SRC_DIR = path.join(__dirname, 'src');
+const UI_SOURCE_EXTENSIONS = new Set(['.svelte', '.ts', '.tsx', '.js', '.jsx']);
+
+// Collapse duplicate ui: markers (e.g. ui:hover:ui:bg-accent → ui:hover:bg-accent)
+function normalizeUiClass(className) {
+ if (!className.includes(UI_PREFIX)) {
+ return className;
+ }
+
+ const stripped = className.split(UI_PREFIX).filter(Boolean).join('');
+ return `${UI_PREFIX}${stripped}`;
+}
// Check if a class looks like a Tailwind utility class
function isTailwindClass(className) {
- // Skip if already has ui: prefix
- if (className.startsWith(UI_PREFIX)) {
+ // Skip if already prefixed (including malformed mid-chain ui: after a variant)
+ if (className.startsWith(UI_PREFIX) || className.includes(':ui:')) {
return false;
}
@@ -66,30 +87,126 @@ function isTailwindClass(className) {
// Arbitrary variants (like [&_svg]:pointer-events-none, after:absolute, etc.)
/^\[.*\]:|^(after|before|first|last|only|nth|marker|placeholder|selection|backdrop):/,
// Common utilities
- /^(rounded|border|outline|shadow|ring|overflow|z-|order-|col-|row-|justify-|items-|content-|self-|place-|object-|cursor-|select-|resize-|appearance-|pointer-events-|visibility-|whitespace-|break-|overflow-|overscroll-|scroll-|will-change-|transform|transition|top|right|bottom|left)/
+ /^(rounded|border|outline|shadow|ring|overflow|z-|order-|col-|row-|justify-|items-|content-|self-|place-|object-|cursor-|select-|resize-|appearance-|pointer-events-|visibility-|whitespace-|break-|overflow-|overscroll-|scroll-|will-change-|transform|transition|top-|right-|bottom-|left-)/
];
return patterns.some((pattern) => pattern.test(className));
}
-// Process a single class string
-function processClassString(classString) {
- // Split by whitespace and process each class
- const classes = classString.split(/\s+/).filter((c) => c.trim());
- const processed = classes.map((cls) => {
- if (isTailwindClass(cls)) {
- return `${UI_PREFIX}${cls}`;
+function extractScopedClassNames(content) {
+ const scoped = new Set();
+ const styleBlocks = content.match(/