Run this after fetching a registry item's JSON (SKILL.md → fetch commands) and before rewriting it. Every fail becomes one line in the rewrite plan (template at the bottom). rg/jq here are triage greps over the fetched source — fast, but pattern-matching, not real parsing. void lint . runs the same checks as real AST rules on committed files and is the ground truth; use it once the rewrite lands in the repo. Bug write-ups per pattern (with the library/component that shipped them): references/upstream-bugs.md. Paste-ready replacements: references/recipes.md.
Re-verify rule ids before citing them elsewhere — the rules files change:
grep -n 'id: "' packages/cli/src/rules/*.ts # design/seo/perf/a11y/geo ids, verbatim
grep -oE 'r\("[a-z0-9-]+"' packages/cli/src/rules/lint.ts # lint/* ids (prefix is added by the r() helper)
bun packages/cli/src/index.ts rules <id> # print one rule's why/fix/example
bun packages/cli/src/index.ts rules # list every ruleRegistry item metadata in one shot:
curl -sL <item>.json | jq '{dependencies, registryDependencies, css, cssVars}'
curl -sL <item>.json | jq -r '.files[].content' > /tmp/item.tsx # source to a scratch file for rg
diff <(jq -r '.dependencies[]' /tmp/item.json) <(jq -r '.dependencies|keys[]' package.json) # phantom/renamed deps1. Licence
Check: which licence class the item's source repo/registry falls under, and what the actual file header says (registries sometimes ship code the docs site doesn't mention).
head -20 /tmp/item.tsx | grep -i 'copyright\|license'
curl -sL <repo>/LICENSE # or LICENSING.md (coss/Origin UI), LICENCE.md (motion-primitives)Pass: MIT/Apache/Unlicense → may vendor with notice kept. Commons Clause (React Bits, Animate UI), proprietary (Aceternity, Hover.dev) or attribution-required (Skiper free) → clean-room only. Full matrix: SKILL.md → Licence matrix, references/catalogue.md.
Rule: none (legal, not lintable).
Fix: MIT-class → vendor + fix every other fail below, keep the header. Anything else → write the 5–10 line spec (SKILL.md → Rewrite onto void tokens), close the source, build clean-room.
2. SSR safety
Check: browser globals or non-deterministic values evaluated at module scope or during render (outside useEffect/event handlers).
rg -n 'window\.|document\.|navigator\.|matchMedia\(|localStorage' /tmp/item.tsx
rg -n 'Math\.random\(\)|Date\.now\(\)|new Date\(\)' /tmp/item.tsx
rg -n -B2 'window\.|Math\.random' /tmp/item.tsx | rg -v 'useEffect|addEventListener\(' # crude: flags hits not near an effectPass: zero hits outside useEffect/useSyncExternalStore/event handlers.
Rule: lint/browser-global-in-render, lint/random-in-render.
Fix: read browser APIs in an effect or useSyncExternalStore (server snapshot = the neutral default); replace Math.random() with a value seeded from the index. Pattern group: references/upstream-bugs.md §3.
3. Server HTML is complete
Check: headline, numbers and links are present and visible in the raw HTML, not hidden until JS runs.
rg -n 'opacity-0|opacity:\s*0|initial=\{\{\s*opacity:\s*0|initial="hidden"' /tmp/item.tsx
curl -s <dev-url> | grep -oE '<h1[^>]*>.*</h1>' # headline present, no opacity-0 class
curl -s <dev-url> | grep -o '12,400' # the real number, not "0" or empty
curl -s <dev-url> | grep -c '<a ' # links present without JSPass: real content in the curl output; no opacity-0/initial="hidden" on above-the-fold text; a numeric component never SSRs 0/"".
Rule: none static (grep + curl only); the runtime symptom shows up as perf/lcp-slow once it ships.
Fix: SSR the final value/text and animate position only (RevealText), or animate a number only if it starts offscreen (NumberTicker). Pattern group: references/upstream-bugs.md §2.
4. Cleanup is 1:1
Check: every subscription, observer, timer or library instance has a matching teardown.
rg -c 'addEventListener\(' /tmp/item.tsx; rg -c 'removeEventListener\(' /tmp/item.tsx
rg -c 'requestAnimationFrame\(' /tmp/item.tsx; rg -c 'cancelAnimationFrame\(' /tmp/item.tsx
rg -c 'setInterval\(|setTimeout\(' /tmp/item.tsx; rg -c 'clearInterval\(|clearTimeout\(' /tmp/item.tsx
rg -n 'new Lenis\(|createGlobe\(' /tmp/item.tsx; rg -n '\.destroy\(\)' /tmp/item.tsx
rg -c '\.on\(' /tmp/item.tsx; rg -c '\.off\(' /tmp/item.tsxPass: each "add" count has a matching "remove" count (not just present somewhere — check it's in the same effect's cleanup return).
Rule: lint/listener-without-cleanup, lint/raf-without-cancel, lint/timer-in-render, lint/unload-listener (an unload listener specifically blocks bfcache).
Fix: return the remove/disconnect/cancel/clear/destroy from the same useEffect, or pass { signal } from one AbortController and abort it. Pattern group: references/upstream-bugs.md §4.
5. Hooks correctness
Check: hooks called unconditionally at the top level; no component/MotionValue-subscription creation during render; stable effect deps.
rg -n '&&\s*<.*use[A-Z]\w*\(|\?\s*use[A-Z]\w*\(|style=\{\{[^}]*use[A-Z]\w*\(' /tmp/item.tsx # AST hint, approximate: use[A-Z]… inside &&/ternary/JSX literal
rg -n 'm\.create\(|createElement\(' /tmp/item.tsx # component defined/created in render
rg -n '\.on\((["'\'']change)' /tmp/item.tsx # MotionValue .on outside useMotionValueEvent
rg -n '\[.*ref\.current.*\]' /tmp/item.tsx # ref.current in a deps arrayPass: no hooks inside &&/ternary/JSX prop literals; m.create()/component factories only at module scope or useMemo; MotionValue subscriptions via useMotionValueEvent; refs never appear inside [] deps.
Rule: lint/conditional-hook, lint/motionvalue-subscribe-in-render, lint/svg-global-id (a related AST smell: a hardcoded SVG id instead of useId(), breaks the 2nd instance on the page).
Fix: hoist the hook call, feed the result in conditionally; useMotionValueEvent(value, 'change', cb); useId()-scope any SVG / id. Pattern group: references/upstream-bugs.md §5.
6. Zero per-frame React work
Check: no setState inside a pointer/scroll/rAF handler; no allocation inside a loop.
rg -n -B3 'set[A-Z]\w*\(' /tmp/item.tsx | rg -B3 'onPointerMove|onMouseMove|onScroll|requestAnimationFrame' # AST hint: setState reachable from a per-frame handler
rg -n -B2 'new Intl\.NumberFormat|new Color\(' /tmp/item.tsx # then confirm the call site is inside rAF/.on('change')/useAnimationFrame
rg -n 'getBoundingClientRect\(\)' /tmp/item.tsx # forced layout reads; flag if inside a loop/handlerPass: pointer/scroll paths write to a ref's style/CSS custom property or a MotionValue .set(), never setState; formatters/colour objects built once (module scope or useMemo), not per frame.
Rule: lint/setstate-per-pointer-move, lint/allocation-per-frame.
Fix: ref + one rAF writing el.style.translate/--x (recipe 2 SpotlightCard, recipe 7 Magnetic); hoist Intl.NumberFormat to useMemo. Pattern group: references/upstream-bugs.md §6.
7. Reduced motion
Check: the file honours the OS preference, and the honouring actually disables the loop (not just present).
rg -c 'prefers-reduced-motion|useReducedMotion|motion-reduce:|motion-safe:' /tmp/item.tsxPass: count > 0, and manually confirm it gates the actual animation (a global CSS reduced-motion rule alone doesn't cover JS rAF loops or inline style transforms — those need their own check).
Rule: lint/no-reduced-motion, smooth/reduced-motion-ignored.
Fix: a static finished frame with content intact; disable via motion-reduce:/@media (prefers-reduced-motion: reduce) for CSS, useReducedMotion()/ for JS. This is the single most common fail (~95% of registry items) — never skip it. Cross-cutting across every group in references/upstream-bugs.md.
8. Offscreen pause
Check: infinite animations stop when off-screen; JS loops also stop on tab hide.
rg -n 'IntersectionObserver|useInView|data-fx' /tmp/item.tsx
rg -n 'visibilitychange' /tmp/item.tsx # JS rAF/WebGL loops only
rg -n 'repeat:\s*Infinity|infinite\b' /tmp/item.tsx # candidates that need one of the abovePass: every repeat: Infinity/infinite/rAF loop is paired with an IntersectionObserver, useInView, or the shared [data-fx] gate; JS loops also react to visibilitychange. Pausing must cancel the loop (cancelAnimationFrame), not just skip drawing — a skipped-but-still-scheduled rAF still burns a callback every frame.
Rule: smooth/raf-loop-idle.
Fix: CSS-driven effects get data-fx + the shared FxGate (recipes.md §0); JS/WebGL loops cancel on visibilitychange and IntersectionObserver both. Pattern group: references/upstream-bugs.md §7.
9. DPR / resolution cap
Check: canvas/WebGL pixel ratio is capped, not raw or hardcoded.
rg -n 'devicePixelRatio' /tmp/item.tsx
rg -n 'devicePixelRatio\s*[,)]\s*1\.5|Math\.min\(.*devicePixelRatio' /tmp/item.tsx # the capped form
rg -n 'devicePixelRatio:\s*2|width\s*\*\s*2|height\s*\*\s*2' /tmp/item.tsx # hardcoded 2x (Magic UI globe)Pass: Math.min(devicePixelRatio, 1.5) (1 for full-bleed backdrops) read inside an effect, never a bare or hardcoded 2; full-bleed shader render scale ≤0.6.
Rule: lint/hardcoded-device-pixel-ratio.
Fix: cap and resize via ResizeObserver, never at module scope. Pattern group: references/upstream-bugs.md §10.
10. Frame-rate cap
Check: ambient T3/T4 loops throttle to ≤30fps instead of running the full rAF rate.
rg -n 'requestAnimationFrame' /tmp/item.tsx # then read the callback: does it throttle, or draw every frame unconditionally?
rg -n 'frameloop\s*=\s*.always.|fpsLimit|1000\s*/\s*[0-9]+' /tmp/item.tsxPass: ambient backgrounds (aurora, globes, particle fields) skip frames to land at ≤30fps; foreground/interactive canvas may run full rate. No static grep proves this — read the draw loop.
Rule: none static; verify with void smooth (dropped-frame % and the profiler timeline).
Fix: accumulate elapsed time and only draw past a 1000/30 threshold. Pattern group: references/upstream-bugs.md §10.
11. Properties animated
Check: continuous motion touches only transform/opacity; anything else is one-shot or a single small element.
rg -n '@keyframes' -A6 /tmp/item.tsx # read the keyframe body
rg -n 'width:|height:|top:|left:|margin|padding' /tmp/item.tsx # layout properties in an animated block
rg -n 'background-position|filter:|box-shadow|linearGradient.*x1|y1' /tmp/item.tsx # paint properties
rg -n 'transition:\s*all|transition-all\b' /tmp/item.tsxPass: no width/height/top/left/margin/padding inside a repeat/infinite block; paint properties (filter, background-position, box-shadow, SVG gradient coordinates) animate once or on one small element only, never continuously full-bleed; no transition: all.
Rule: lint/animate-layout-prop, smooth/animate-layout-property, lint/transition-all, smooth/transition-all.
Fix: translate/scale/rotate/opacity only; grid-template-rows: 0fr → 1fr for accordions; a static overlay instead of mask-image over animated children (measured: 10% dropped frames); will-change: transform on the one paint-animated element, never many. Pattern group: references/upstream-bugs.md §8 — also see the recipes.md aurora/shine gotcha (never stack a paint animation over an animated backdrop: 14–46% dropped frames measured).
12. Accessibility
Check: decorative layers hidden; duplicated content hidden from AT; real interactive elements; hover has a keyboard/focus equivalent; long-moving content can pause.
rg -c '\.map\(' /tmp/item.tsx # candidates for duplicated marquee/loop content
rg -n 'aria-hidden' /tmp/item.tsx # present on every duplicate + every decorative layer?
rg -n 'inert' /tmp/item.tsx # duplicates need aria-hidden AND inert (aria-hidden alone still leaves links focusable)
rg -n '<div[^>]*onClick|<span[^>]*onClick' /tmp/item.tsx # clickable non-interactive element
rg -n '<button(?![^>]*type=)' /tmp/item.tsx # <button> missing an explicit type (approximate)
rg -n ':hover|whileHover|group-hover' /tmp/item.tsx; rg -n 'focus-visible|:focus\b' /tmp/item.tsx # hover without a focus counterpartPass: decorative layers Check: hover-driven motion is gated to real pointers, and pointer handlers check the pointer type. Pass: every hover-triggered animation sits under Check: the item's dependencies fit the target tier, aren't phantom/renamed, and don't duplicate a library already in the app. Pass: deps map to the tier the effect actually needs (nothing/CSS → T1, Check: no raw colour literals or default Tailwind palette classes; motion timing uses tokens, not magic numbers. Pass: every colour is a Check: v3-only syntax that silently compiles to nothing or the wrong value under v4. Pass: Fill this in per item, in the PR/commit description, after running all 16 points: An item with zero fails after a real MIT-class licence may be vendored as-is (still keep the copyright header). Anything with a licence fail skips straight to clean-room regardless of how many other points pass.aria-hidden; duplicated content (marquee copies, TextRoll) has both aria-hidden and inert; clickable elements are real /; canvas/SVG art either aria-hidden or role="img" + label; every :hover/whileHover has a :focus-visible equivalent; content that moves for >5s can be paused (WCAG 2.2.2).
Rule: lint/duplicate-children-not-hidden, lint/div-button, lint/button-missing-type, lint/svg-global-id, a11y/target-size, a11y/focus-not-visible.
Fix: Array.from({length: repeat}, (_, i) => ; pair hover styles with focus-visible:. Pattern group: references/upstream-bugs.md §11.
13. Hover gating
rg -n '@media\s*\(hover:\s*hover\)|hover:hover.*pointer:fine' /tmp/item.tsx
rg -n 'pointerType' /tmp/item.tsx
rg -n ':hover|whileHover|group-hover' /tmp/item.tsx # candidates that should be inside the gate above@media (hover: hover) and (pointer: fine) (Tailwind: hover:hover: variant stacking, or a plain media query in CSS); pointer event handlers branch on e.pointerType !== 'touch' where a mouse-only effect (magnetic, spotlight) would otherwise fire from a tap.
Rule: none dedicated; covered by manual review plus lint/setstate-per-pointer-move for the handler itself.
Fix: wrap the CSS in the media query; check pointerType before running pointer-follow logic. Pattern noted in references/upstream-bugs.md §11 (hover-only pause on the marquee is the same root cause: hover semantics applied without a touch/keyboard fallback).14. Dependency weight and duplicates
jq '.dependencies' /tmp/item.json
diff <(jq -r '.dependencies[]?' /tmp/item.json | sort) <(jq -r '.dependencies|keys[]' package.json | sort) # phantom deps not in package.json
rg -n 'framer-motion' /tmp/item.tsx # duplicate runtime if the app already uses `motion`
rg -n '@base-ui-components/react' /tmp/item.tsx # renamed package (now @base-ui/react) — duplicate, stale
rg -n 'three|@react-three' /tmp/item.tsx # three/R3F for what might be a 2D quad
rg -n 'face-api' /tmp/item.tsx # ~700KB bundle bomb (React Bits GridScan)motion → T2, getContext('2d') → T3, ogl/three/cobe → T4 — see SKILL.md tier table); no framer-motion next to motion, no @base-ui-components/react next to @base-ui/react, no three.js for a single 2D quad.
Rule: lint/heavy-import, smooth/multiple-webgl-contexts, lint/webgl-in-map (a WebGL canvas rendered inside .map() — browsers evict contexts past ~8–16 per page).
Fix: down-tier (WebGL Aurora → CSS gradients, recipe 9) or swap for the matching library (@number-flow/react, cobe, torph — SKILL.md decision tree step 3). Also watch for DOM/node explosions from the same audit pass (Array(n).map() of animated nodes, e.g. Magic UI dot-pattern's ~5,130 s) — same fix direction, lower node count via CSS. Pattern groups: references/upstream-bugs.md §9, §10.15. Tokens
rg -n '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' /tmp/item.tsx
rg -n '\b(neutral|zinc|gray|slate|purple|indigo|violet|pink)-[0-9]{2,3}\b' /tmp/item.tsx
rg -n 'duration-\[[0-9]|ease-\[cubic-bezier|[0-9]{2,4}ms' /tmp/item.tsx # hardcoded durations/eases instead of --duration-*/--ease-*var(--color-*)/semantic utility (bg-surface, text-fg-muted, border-line); no default-palette classes; motion timing reads --ease-*/--duration-*.
Rule: lint/hardcoded-colors, design/purple-gradient (the violet→indigo hero trope), design/gradient-text (background-clip:text headlines), design/multiple-accents.
Fix: run the registry→void token table in SKILL.md → Rewrite onto void tokens; one hue via var(--brand) + color-mix(in oklab, var(--brand) 20%, transparent).16. Tailwind v4 correctness
rg -n 'bg-gradient-to-|flex-shrink-0|flex-grow-0' /tmp/item.tsx
rg -n 'duration-\[--|ease-\[--|[a-z-]+-\[--[a-z-]+\]' /tmp/item.tsx # v3 `[--var]` arbitrary-var syntax; v4 needs `(--var)`
rg -n '\bshadow\b(?!-)|\brounded\b(?!-)|\boutline-none\b' /tmp/item.tsx # bare/renamed v3 utilities
rg -n 'animate-[a-z-]+' /tmp/item.tsx # then confirm each has matching css/cssVars (jq check above) or a `--animate-*` in @theme
ls tailwind.config.* 2>/dev/null && rg -L '@config' src/**/*.css # JS config present but never loaded from the CSS entrybg-linear-to-* not bg-gradient-to-*; shrink-0 not flex-shrink-0; duration-(--x) not duration-[--x]; shadow-xs/rounded-xs/outline-hidden (v4 renames); every custom animate-* class has matching CSS shipped with the item or defined in @theme; a tailwind.config.*, if present, is loaded via @config or has been migrated into @theme.
Rule: lint/tw-unknown-class, lint/tw-v3-arbitrary-var, lint/tw-v3-renamed, lint/tw-js-config-ignored.
Fix: rename per the v3→v4 map in SKILL.md → Rewrite onto void tokens; move keyframes into @layer components or @theme; next build and grep the compiled CSS for the class to confirm it exists. Pattern group: references/upstream-bugs.md §1.Rewrite plan template
#
Point
Fail found
Fix applied
Rule id(s) closed
2
SSR safety
navigator.userAgent at module scopemoved into
useSyncExternalStore, server snapshot falselint/browser-global-in-render
7
Reduced motion
no
prefers-reduced-motion anywhereadded a finished static frame +
motion-reduce:lint/no-reduced-motion, smooth/reduced-motion-ignored
12
Accessibility
3 marquee copies, no
aria-hidden/inertaria-hidden={i>0} + inert={i>0} on copies 2–3lint/duplicate-children-not-hidden
15
Tokens
#9c40ff → #ffaa40 gradientone hue via
var(--brand)lint/hardcoded-colors, design/purple-gradient