From 2f3fb1f4747c29d069ffe9f387943b1a6a6cc5e1 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:37:50 +0300 Subject: [PATCH 1/2] chore: enforce ui-kit css gate at script, prek, CI, and agent-hook layers Component styling in ui-kit packages must go through packages/uno-preset; per-component .css files are banned except tokens.css and theme/ sheets. A prior PR shipped packages/ui-kit-system/src/loader.css, so make the rule structural instead of relying on review to catch it again. Co-Authored-By: Claude Fable 5 --- .claude/hooks/ui-kit-css-gate.sh | 43 ++++++++++++++++++++++++++++++++ .claude/settings.json | 9 +++++++ .github/workflows/ci.yml | 3 +++ .pre-commit-config.yaml | 6 +++++ AGENTS.md | 3 +++ scripts/check-ui-kit-css.ts | 41 ++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+) create mode 100755 .claude/hooks/ui-kit-css-gate.sh create mode 100644 scripts/check-ui-kit-css.ts diff --git a/.claude/hooks/ui-kit-css-gate.sh b/.claude/hooks/ui-kit-css-gate.sh new file mode 100755 index 000000000..8d16c4fe8 --- /dev/null +++ b/.claude/hooks/ui-kit-css-gate.sh @@ -0,0 +1,43 @@ +#!/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 + +printf '%s\n' "$FILE_PATH" | grep -Eq '(^|/)packages/ui-kit-[^/]+/src/.*\.css$' || exit 0 + +BASENAME="$(basename "$FILE_PATH")" +if [ "$BASENAME" = "tokens.css" ]; then + exit 0 +fi +if printf '%s\n' "$FILE_PATH" | 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..ddb62241d --- /dev/null +++ b/scripts/check-ui-kit-css.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import {execFileSync} from 'node:child_process' + +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.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() From 0732a509347ac13aa4edf5c3904cb1b948d11455 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:26:03 +0300 Subject: [PATCH 2/2] fix(css-gate): normalize dot segments before the theme allowlist packages/ui-kit-x/src/theme/../loader.css resolved to a banned path but passed the raw-string theme check. The script normalizes via posix.normalize and the hook strips ./ and dir/.. segments before matching, so traversal spellings are judged by their resolved target. Co-Authored-By: Claude Fable 5 --- .claude/hooks/ui-kit-css-gate.sh | 11 ++++++++--- scripts/check-ui-kit-css.ts | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/ui-kit-css-gate.sh b/.claude/hooks/ui-kit-css-gate.sh index 8d16c4fe8..e10b248ee 100755 --- a/.claude/hooks/ui-kit-css-gate.sh +++ b/.claude/hooks/ui-kit-css-gate.sh @@ -22,13 +22,18 @@ FILE_PATH="$(jq -r '.tool_input.file_path // empty' <<<"$INPUT" 2>/dev/null || t [ -n "$FILE_PATH" ] || exit 0 -printf '%s\n' "$FILE_PATH" | grep -Eq '(^|/)packages/ui-kit-[^/]+/src/.*\.css$' || 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 -BASENAME="$(basename "$FILE_PATH")" +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' "$FILE_PATH" | grep -Eq '(^|/)theme/'; then +if printf '%s\n' "$NORMALIZED" | grep -Eq '(^|/)theme/'; then exit 0 fi diff --git a/scripts/check-ui-kit-css.ts b/scripts/check-ui-kit-css.ts index ddb62241d..fe37a0196 100644 --- a/scripts/check-ui-kit-css.ts +++ b/scripts/check-ui-kit-css.ts @@ -1,5 +1,6 @@ #!/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' @@ -17,7 +18,9 @@ function isAllowlisted(path: string): boolean { } function findBannedPaths(paths: string[]): string[] { - return paths.filter((path) => UI_KIT_CSS_PATTERN.test(path) && !isAllowlisted(path)) + return paths + .map((path) => posix.normalize(path.replaceAll('\\', '/'))) + .filter((path) => UI_KIT_CSS_PATTERN.test(path) && !isAllowlisted(path)) } function scanTrackedFiles(): string[] {