Imported from LucasBassetti/godui (
AGENTS.md). Install upstream withnpx skills add LucasBassetti/godui. Copyright stays with the author.
GodUI — Agent Rules
GodUI is a design system monorepo (pnpm + Turbo): @godui/components (packages/components, core — animated shadcn/ui drop-ins), @godui/lab (packages/lab, Lab — expressive, experimental pieces beyond the shadcn catalog, maintained as-is, report-only GPU badge), docs (apps/docs, Next.js + Fumadocs), and Storybook (apps/storybook).
Skills live in .agents/skills/ (.claude/skills and .cursor/skills symlink to it). Read the relevant SKILL.md before starting the matching task.
Commands
Use pnpm — never npm or yarn.
| Command | Purpose |
|---|---|
pnpm check / pnpm check:fix |
Biome lint + format (:fix writes) |
pnpm test |
Vitest |
pnpm build:registry |
Build both registries → apps/docs/public/r (+ /r/lab) |
pnpm --filter storybook test:motion-trace |
Runtime GPU-only trace (Playwright + Chrome tracing) |
pnpm --filter @godui/lab motion:report |
Regenerate the Lab GPU report |
pnpm dev |
Turbo dev (all apps) |
pnpm storybook |
Storybook |
When to invoke which skill
- Creating or modifying a component → invoke
godui-component-creationfirst. (component-creationis the generic background variant.) - Any color / token / palette / dark-mode / contrast work → invoke
oklch-skill. - UI polish — animation, hover, shadow, border-radius, typography, micro-interaction, "feels off" → invoke
make-interfaces-feel-better(andfrontend-design).
Component checklist (core shadcn drop-ins) — not done until all exist
- Source:
packages/components/src/ui/<name>.tsx, mirroring shadcn new-york-v4components/ui/<name>.tsx— same exports, props,data-slots and Radix packages. Header comment names the shadcn version it mirrors. API changes are additive only. export * from "./ui/<name>";added topackages/components/src/index.ts- Entry in root
registry.json(registry:ui,registryDependenciesincludes@godui/godui-motion+ the shadcn deps upstream declares), thenpnpm build:registry - Storybook story
apps/storybook/src/stories/ui/<name>.stories.tsx(titleUI/<Name>with spaces for multi-word names, e.g.UI/Alert Dialog→ story idui-alert-dialog, matching the docs slug;tags: ["autodocs"]) and a trace specapps/storybook/motion-trace/<name>.spec.tsusingtraceInteraction+expectGpuOnly - Vitest
packages/components/src/ui/<name>.test.tsx: parity, behaviour (open/close, keyboard), reduced motion. Parity = vendor shadcn's source (node packages/components/scripts/vendor-shadcn.mjs <name>→test/shadcn/<name>.tsx, never edit it), render the sameUsage({ ui })through both modules andexpectSlotParity(slotTree(), expected, [extraSlots])(test/parity.ts); typingui: typeof Shadcnand passing GodUI makes tsc prove props are a superset - Docs page
apps/docs/content/docs/components/<name>/index.mdx(no category folder) +learn.mdxbeside it;<name>inapps/docs/content/docs/components/meta.jsonandcomponents/<name>in the rootapps/docs/content/docs/meta.json(components are listed in the main sidebar; Lab is header-only); a<PreviewCard>under its group incomponents/index.mdxplus a hover-animated skeleton previewapps/docs/src/components/card-previews/core/<name>.tsxregistered incard-previews/registry.tsx. Nodatefrontmatter on core pages (no New badges). Learn scenes show no words: real components get skeleton bars/icons
Project gotchas (non-obvious — these bite repeatedly)
-
GPU-only, strict, no allowlist. Core may animate only
transform/translate/scale/rotate,opacity,filter. Notransition,transition-colors,transition-shadow,transition-all, no animatedheight/width/box-shadow/background-position/color. Sizes snap; moved siblings FLIP withuseFlipGroup. Hover color changes snap or fade an overlay'sopacity.pnpm --filter @godui/components testgates it (src/motion-gate/). -
Motion tokens come from
godui-motion(corestyles.css+ thegodui-motionregistry item):animate-godui-*keyframes on Radixdata-[state=open|closed],ease-spring-snappy|smooth|bouncy(CSSlinear()),--godui-duration-*. Reduced motion is built into theanimate-godui-*keyframes (root-only--godui-motionmultiplier) anduseFlipGroup; component-level transform transitions (switch thumb, tab indicator, hover lifts) still needmotion-reduce:handling. -
Lab lives in
packages/lab(registryregistry-lab.json→/r/lab/, namespace@godui-lab, docs/docs/lab/<category>/<name>, storiesLab/<Category>/<Name>). Not held to the core GPU-only contract (report-only GPU badge). Core shadcn drop-ins go inpackages/components. Don't add new components to Lab. Lab was called Extras:next.config.tskeeps/docs/extras/*(308) and/r/extras/*(rewrite, same JSON) working, and the MCP still accepts@godui-extras/— don't remove those. -
No new CSS files; no
@layer componentsblocks. Author component styles as inline Tailwind utilities in the.tsx(group/peer+data-[…]variants + arbitrary properties for masks/3D/gradients).styles.cssis the Tailwind entry only —@import,@theme,@custom-variant,@keyframes. Reference animations withanimate-<name>utilities, never a${var}nested inside an arbitrary value (the scanner can't resolve it — write the class literal). -
Keyframes are per-component, not in the shared theme (shared enter/exit keyframes live in
godui-motion). A component's@keyframes+ its--animate-*token live in two places:styles.css(so Storybook/docs render) and that component's ownregistry.jsonentry (cssVars.themefor the token +cssfor the@keyframes). Keep them out of thegodui-themeentry — the theme is pure design tokens, so installing one component pulls only its own animations. Shared keyframes (e.g.magic-rainbowon button/tab/input) are repeated in each entry; the shadcn CLI dedupes them on install. No per-component@layer componentsblock. -
registry.jsonis hand-maintained. Add the new entry, runpnpm build:registry; do not reformat existing entries. -
Static Tailwind classes only. Never build class names dynamically (
grid-cols-${n}) — the scanner can't see interpolated classes. Map to static strings. -
No raw
var(--color-*)inside@layerblocks — renders the wrong theme color. Use Tailwind utilities (bg-primary,text-foreground, …). -
Border-ring mask needs inline longhand mask props. Tailwind
[mask:...]shorthand resets clip/composite — write the longhands. -
Theme tokens: sRGB-clamped base chroma +
@media (color-gamut: p3)to restore richer chroma.oklch-skillgoverns all color tokens. -
Use the z-index scale (
z-base,z-raised,z-overlay,z-sticky,z-popover,z-modal,z-toast), never arbitrary z values. -
Add
"use client"only where shadcn has it or the component uses hooks / client APIs. Core components follow shadcn v4: React 19 function components withrefas a prop (noforwardRef). Lab components keep their existingforwardRefstyle. -
Core source imports are install-shaped (
@/lib/utils,@/components/ui/<x>,@/hooks/<x>), never relative — the shadcn CLI rewrites@/on install. The aliases live in 4 places:packages/components/{tsconfig.json,vitest.config.ts},apps/docs/tsconfig.json(exact@/lib/utilsonly — docs owns other@/lib/*),apps/storybook/{tsconfig.json,vite.config.ts}. -
Third-party CSS injected unlayered (sonner, vaul) beats Tailwind's
@layer utilitiesat any specificity — overriding it needs!([transition-property:opacity]!). Audit the library's stylesheet in a unit test so upgrades can't add paint transitions back. -
Chrome won't composite the individual
rotate/scale/translateproperties on an<svg>(compositeFailed 1<<19). Rotate icons with[transform:rotate(…)]+transition-[transform], or animate a wrapper. -
More Chrome compositing traps (each one measured as
compositeFailedin a trace): an animation with no visible change (hold at opacity 0.99 / fade 0 → 0.01, never a true no-op);filter: blurthat moves pixels (dropped from the Calendar month change); a later opacity keyframe on an element that earlier ran an opacity transition, and a CSStranslatetransition on an element whose WAAPItranslateanimations were cancelled (reason 64 — move the second motion to a different property, e.g.transform: translate()). A CSS transition and a WAAPI animation drift apart on reversal (Chrome shortens reversed transitions): put pieces that must stay glued on one WAAPI clock. Two that cost layout, not compositing (counted as layout frames in a trace): anabsolute/out-of-flow pseudo-element inside an element running a transform animation lays out every frame (make it an in-flow block, e.g.after:block after:size-full); and an opacity reaching or leaving 1 adds or drops a paint layer, one layout per flip (give the elementisolation: isolateso it always has one, or keep it from flipping). -
Mount rule: a selection pop plays only after a change, never on first paint, remount or menu open (menu items mount on every open). Pattern =
checkbox.tsx:useStateof the last value, compare during render, setdata-animate(useAnimateOnChange,@/hooks/use-animate-on-change, ships ingodui-motion), gate the keyframe on it (data-[animate=true]:/group-data-[animate=true]/<item>:). When the animated element is Presence-managed (a Radix*Indicator), the gate must also require its "on" state (…:data-[state=checked]:animate-…): otherwise the commit that unchecks it adds the keyframe, Presence sees a new animation and the indicator re-pops before it disappears. Radio items read their group's value from a GodUI context; parts with no checked prop (Combobox check, chips) pop only if they mount after their container (amountedstate its layout effect sets, read once inuseState). Test both "does not pop on first paint" and "pops after a change", assert the keyframe class is never applied bare, and trace that a deselected indicator is gone within a frame (motion-trace/deselect.ts). -
Animating a fill in the caller's colors (Checkbox ink): keep shadcn's
data-[state=checked]:bg-*on the root but don't paint it (bg-clip-text; the root has no text), and let the animated child pick the color up withbg-inherit(an intermediate element inherits it without painting it the same way). For an exit, the root's colors are already the unchecked ones: pin the old ones inline on the exiting element. Radix calls an uncontrolledonCheckedChangeafter the commit, so read pre-change DOM inonClickCapture. A child that rests at a non-nonescale/translategets its own layer and paints over in-flow later siblings; make thoserelative. -
Ref merges forward a React 19 callback ref's cleanup: merge a local ref with the caller's via
useMergedRef(local, ref)(@/hooks/use-merged-ref, ships ingodui-motion); it returns a cleanup that calls the caller's (elseref(null)) and nulls the local ref. Never hand-rolltypeof ref === "function". -
Consumers' jsdom has no
getAnimations,matchMediaoranimationend. Anything that waits on an animation needs a fallback that settles at once (Calendar fill layer) or on a token-length timer (SidebaruseMovingwaits only on the animations its move started — fresh ones — and gives up one--godui-duration-slowafter the longest should have ended, scaled byplaybackRate; derive such caps from the animations, never a fixed token: traces slow the clock); guardtypeof window.matchMedia === "function". This repo'svitest.setup.tspolyfills them, which hides the bug — test with the polyfill deleted. -
react-day-picker
animate: rdpclassList.adds theclassNamesanimation values, so each must be a single token (a space throws and unmounts the calendar); its cleanup runs on the old caption'sanimationend, which strips the enter classes, so that caption animation sets the clock for the whole month change. -
Radix NavigationMenu (1.2.22) drops Presence's ref under React 19 (
NavigationMenuViewportItemoverridesref), so viewport content exits never play;navigation-menu-viewport-frame.tsxre-inserts an inert ghost of the exiting content. It also toggles a hover-opened item shut on the following click; the frame swallows that click. Re-check both on Radix upgrades. -
Trace specs:
traceInteraction(page, { storyId, setup?, act, windowMs })—setupruns before tracing (open a dialog to trace its close).expectGpuOnly(result, { maxLayoutFrames })counts frames containing layout (events < 8ms apart merge) (default 1, the frame animations finish); raise it only for a documented discrete snap (accordion close = 2: the commit, then the hold ending). Enter keyframes fillbackwards(a forwards fill keeps a transform that makes the element the containing block for fixed-position children — it clipped dropdown sub-menus); each enter animation that finishes costs one discrete layout frame, so components whose animations end at staggered times (drawer, tooltip hop) allow 2. A trace that passes must be shown to fail when the GPU fix is removed, or it proves nothing (e.g. same-height toasts never exercise height). -
Sliding indicators use
useActiveIndicator(@/hooks/use-active-indicator, ships ingodui-motion): Tabs, single Toggle Group and Command. It slides on attribute changes (selection) and snaps on inserts/removals/resizes. The item's own active background must stay until the container reportsdata-indicator="ready": gate it withgroup-not-data-[indicator=ready]/<group>:(Tabs) ornot-in-[[data-slot=<container>][data-indicator=ready]]:(Command), so the server render matches shadcn. If the item component is also used on its own (Toggle), leave shadcn's class untouched and add the override in the container's item instead (in-[[data-slot=toggle-group][data-indicator=ready]]:data-[state=on]:bg-transparent); a gated variant is emitted after a call site's plaindata-[state=on]:bg-*and would beat it. Don't let an injected indicator break shadcn'sfirst:/last:item classes; scope them with[&:nth-child(1_of_[data-slot=<item>])]. -
useFlipGroupshares one running-animation map across all groups (an element moved by one group can be another group's candidate, e.g. sibling Collapsibles), and re-baselines only once nothing is mid-FLIP. -
FLIP signals: components whose height snaps (Combobox chips) render a tiny signal child that bumps a reducer on mount/unmount;
useFlipGroupwatches that version. Skip the signal underasChild(Slot needs one child). -
Accordion fakes a height animation exactly (the pattern for any reveal): the panel's
overflow-hiddenbox translates-h → 0while its content translates+h → 0— they cancel, so text holds still and only the clip edge sweeps — and the rows below FLIP from-hon the same duration + curve, so they ride the edge. AMutationObserveron items'data-state+flushSyncruns it in the click's task (before the snapped layout paints). Closing panels goabsoluteat once (rows rise immediately) and a near-no-op hold keyframe (opacity → 0.99; Chrome won't composite a no-visible-change animation) keeps Radix from hiding them mid-sweep. Radix keeps a closed panel's element (hidden, children dropped) — forwards fills survive on it. A box translated over its trigger needspointer-events-none(contentpointer-events-auto). The engine isuseReveal(@/hooks/use-reveal, ships ingodui-motion), shared with Collapsible; the hold keyframe isgodui-reveal-hold(godui-motion). Collapsible has no wrapper: it counter-translates the box's direct children, clips only whiledata-sweeping, and falls back to a fade for loose text,inline/display: contentschildren or a box that paints. Nested roots glide by their own displacement minus the nearest ancestor root's.useFlipGroupwon't re-baseline while a foreign finite translate animation (a sweep) runs on its container or candidates. -
Docs demos live in
apps/docs/src/components/demos/core/<name>-demo.tsxand import from@godui/components; the Examplecodestring is the demo with imports rewritten to@/components/ui/*. Learn articles reuse the kit inapps/docs/src/components/learn/core/(KeyframeScene,SpringCurveScene,FlipScene,AutoPlayScene,LiveResult).
Before claiming done / committing
- Parallel agents share one git index. Stage with an explicit path list (never
git add -A/.), checkgit diff --cached --name-onlyright before committing, and unstage anything that isn't yours withgit restore --staged <path>. Build Storybook / serve traces on your own output dir and port. - Run
pnpm checkandpnpm test— both must pass. - If
registry.jsonor any component changed, runpnpm build:registry.
