| name | add-ui-primitive |
| description | Scaffold a new presentational primitive in components/UI following this project's non-negotiable component pattern (folder per component, types.ts, no FC, PropsWithChildren at the signature, Record variant maps, semantic tokens only). Use when adding a Button/Input/Modal-style reusable component, or when asked to add something to the UI library. |
| argument-hint | [ComponentName] |
| allowed-tools | Read, Write, Edit, Glob, Grep, Bash |
Add a primitive named $1 to components/UI.
0. Decide it belongs here first
A primitive is reusable and presentational — it has UI meaning outside any one feature, holds no business logic, and fetches nothing.
If the thing is feature-specific (an article card, the filter bar, the preferences panel) it does not go here. It goes in components/<feature>/ as a flat PascalCase file sharing that folder's types.ts. If it reads or writes state (like ThemeToggle does), it is not a primitive.
Read components/UI/CLAUDE.md before writing anything. ../Lumark/src/components/UI/Button/ is the reference implementation.
1. Create two files
components/UI/<Name>/<Name>.tsx
components/UI/<Name>/types.ts
No barrel index.ts, ever.
types.ts exports I<Name>Props plus the string-literal unions the component exposes:
import { ButtonHTMLAttributes, ReactNode } from "react";
export type <Name>VariantType = "primary" | "secondary";
export type <Name>SizeType = "sm" | "md" | "lg";
export interface I<Name>Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: <Name>VariantType;
<name>Size?: <Name>SizeType;
wrapperClassName?: string;
}
- Extend the root element's native attributes:
<button> → ButtonHTMLAttributes<HTMLButtonElement>, <input> → InputHTMLAttributes<HTMLInputElement>, <select> → SelectHTMLAttributes<HTMLSelectElement>, plain wrapper → HTMLAttributes<HTMLDivElement>.
children never goes in the interface.
Omit any native prop you redefine. HTMLAttributes types title as string; if yours is a ReactNode, use Omit<HTMLAttributes<HTMLDivElement>, "title">. Native size on input/select is a number — name yours inputElSize / selectElSize / <name>Size.
<Name>.tsx:
import { PropsWithChildren, useMemo } from "react";
import { I<Name>Props, <Name>SizeType } from "./types";
const <Name> = ({
children,
variant = "primary",
className = "",
...rest
}: PropsWithChildren<I<Name>Props>) => {
const sizeClasses = useMemo(() => {
const sizeMapping: Record<<Name>SizeType, string> = { sm: "...", md: "...", lg: "..." };
return sizeMapping[size] || sizeMapping.md;
}, [size]);
return <div className={`... ${sizeClasses} ${className}`} {...rest}>{children}</div>;
};
export default <Name>;
- Never
FC<…>. Type the destructured parameter directly.
- Wrap in
PropsWithChildren<…> only if it renders children. Otherwise the bare I<Name>Props.
{...rest} goes on the root — or on the most semantically important element when the component wraps its root (Input spreads onto the inner <input>, not the wrapper <div>).
- Variants are
Record<UnionType, string> in useMemo with a || mapping.default fallback.
- One
*ClassName prop per structural layer; plain className is reserved for the root.
2. Use semantic tokens only
bg-surface, text-muted-text, border-border-color, bg-primary, text-primary-foreground, ring-ring, bg-danger/text-danger/bg-danger-bg… — full table in components/UI/CLAUDE.md.
Never a hex, never a stock Tailwind color (bg-white, text-zinc-500), never a raw palette step (--neutral-400). If a token is missing, use /add-design-token.
You should not need a single dark: class — the tokens already swap.
Two traps that fail silently:
- Class names must be statically analyzable.
`bg-${variant}` is never generated by Tailwind. Write the full class in every branch.
peer-* compiles to a sibling combinator and cannot reach a descendant. See how Checkbox drives its tick.
3. Exercise every variant you added
There is no component gallery — pages/ui.tsx was deliberately deleted before delivery as developer scaffolding. So a variant nobody renders is a variant nobody has looked at.
Use the new primitive somewhere real (the feed, the filter drawer, the preferences drawer), and if its logic is worth protecting, test it. A primitive with a Record variant map and a fallback is trivially unit-testable.
4. Verify
yarn lint && yarn test && yarn build
Then run /check, which boots the app and drives it.