diff --git a/.claude/hooks/ui-kit-css-gate.sh b/.claude/hooks/ui-kit-css-gate.sh new file mode 100755 index 000000000..e10b248ee --- /dev/null +++ b/.claude/hooks/ui-kit-css-gate.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Blocks Claude Code Write/Edit/MultiEdit calls that would create or modify a +# per-component .css file under a ui-kit package's src/, per the repo rule: +# component styling goes through packages/uno-preset, .css is only for +# tokens.css and theme/ sheets. Mirrors scripts/check-ui-kit-css.ts's +# pattern/allowlist so the three enforcement layers (this hook, prek, CI) +# agree on exactly one definition of "banned path". +# +# Same stdin JSON contract as turbo-filter-gate.sh: a PreToolUse hook reads +# the tool call as JSON on stdin and blocks by exiting 2 with a stderr +# message. Anything unparseable fails open. + +if ! command -v jq >/dev/null 2>&1; then + echo "ui-kit-css-gate: jq not on PATH, skipping check." >&2 + exit 0 +fi + +INPUT="$(cat)" +FILE_PATH="$(jq -r '.tool_input.file_path // empty' <<<"$INPUT" 2>/dev/null || true)" + +[ -n "$FILE_PATH" ] || exit 0 + +NORMALIZED="$FILE_PATH" +while printf '%s\n' "$NORMALIZED" | grep -Eq '(^|/)[^/]+/\.\.(/|$)|(^|/)\.(/|$)'; do + NORMALIZED="$(printf '%s\n' "$NORMALIZED" | sed -E 's#(^|/)[^/]+/\.\.(/|$)#\1#; s#(^|/)\.(/|$)#\1#; s#//#/#g')" +done + +printf '%s\n' "$NORMALIZED" | grep -Eq '(^|/)packages/ui-kit-[^/]+/src/.*\.css$' || exit 0 + +BASENAME="$(basename "$NORMALIZED")" +if [ "$BASENAME" = "tokens.css" ]; then + exit 0 +fi +if printf '%s\n' "$NORMALIZED" | grep -Eq '(^|/)theme/'; then + exit 0 +fi + +{ + echo "ui-kit-css-gate: BLOCKED: $FILE_PATH is a banned per-component ui-kit .css file." + echo + echo "component styles belong in packages/uno-preset (keyframes -> src/animation.ts, animation" + echo "shortcuts -> src/motion.ts, effects/rules/preflights -> the preset), .css is only for" + echo "tokens/themes." +} >&2 + +exit 2 diff --git a/.claude/settings.json b/.claude/settings.json index 3f5cf74db..3f34640b5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,6 +14,15 @@ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/turbo-filter-gate.sh" } ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ui-kit-css-gate.sh" + } + ] } ] }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffc4ed634..297a7cd33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -216,6 +216,9 @@ jobs: with: node-version: 22 cache: pnpm + # ui-kit component styling goes through packages/uno-preset; per-component .css files + # are banned. Needs only node + git, so it runs before install/build. + - run: node scripts/check-ui-kit-css.ts - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .turbo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c590f919e..090076794 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,3 +20,9 @@ repos: pass_filenames: true files: '\.(m?[jt]sx?|c[jt]s)$' exclude: '^(.*/)?(dist|build|\.turbo)/' + - id: ui-kit-css-gate + name: ui-kit CSS gate (no per-component .css) + entry: node scripts/check-ui-kit-css.ts + language: system + pass_filenames: true + files: '^packages/ui-kit-[^/]+/src/.*\.css$' diff --git a/AGENTS.md b/AGENTS.md index af0bf6944..de1e62d9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,9 @@ README.md; this file is the non-obvious operational rules. - TypeScript is strict (`noUncheckedIndexedAccess`, `verbatimModuleSyntax`, NodeNext). Avoid `any`/`as`/`@ts-ignore`. - oxfmt: no semicolons, single quotes, no bracket spacing, trailing commas, printWidth 120. +- Component styles go through `packages/uno-preset` (`animation.ts` / `motion.ts` / effects / + preflights); `.css` files in ui-kit `src/` are banned except `tokens.css` and `theme/` sheets, + enforced by prek + CI + the agent PreToolUse hook. ## TanStack Router diff --git a/scripts/check-ui-kit-css.ts b/scripts/check-ui-kit-css.ts new file mode 100644 index 000000000..fe37a0196 --- /dev/null +++ b/scripts/check-ui-kit-css.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import {execFileSync} from 'node:child_process' +import {posix} from 'node:path' + +const UI_KIT_CSS_PATTERN = /^packages\/ui-kit-[^/]+\/src\/.*\.css$/ +const GIT_SCAN_PATHSPEC = ':(glob)packages/ui-kit-*/src/**/*.css' + +const MESSAGE = + 'component styles belong in packages/uno-preset (keyframes -> src/animation.ts, animation ' + + 'shortcuts -> src/motion.ts, effects/rules/preflights -> the preset), .css is only for ' + + 'tokens/themes.' + +function isAllowlisted(path: string): boolean { + const segments = path.split('/') + const basename = segments[segments.length - 1] + if (basename === 'tokens.css') return true + return segments.includes('theme') +} + +function findBannedPaths(paths: string[]): string[] { + return paths + .map((path) => posix.normalize(path.replaceAll('\\', '/'))) + .filter((path) => UI_KIT_CSS_PATTERN.test(path) && !isAllowlisted(path)) +} + +function scanTrackedFiles(): string[] { + const output = execFileSync('git', ['ls-files', GIT_SCAN_PATHSPEC], {encoding: 'utf8'}) + return output.split('\n').filter((line) => line.length > 0) +} + +function main(): void { + const argPaths = process.argv.slice(2) + const candidatePaths = argPaths.length > 0 ? argPaths : scanTrackedFiles() + const banned = findBannedPaths(candidatePaths) + + if (banned.length === 0) return + + console.error('check-ui-kit-css: banned ui-kit component .css file(s) found:\n') + for (const path of banned) console.error(` ${path}`) + console.error(`\n${MESSAGE}`) + process.exitCode = 1 +} + +main()