Your AI assistant, now fluent in your design system.
A Claude Code skill that teaches Claude, Cursor, and Copilot the Softuq rules before they write a single line of UI. Typography, spacing, tokens, components — enforced, not suggested.
npx softuq skillWhy a skill
CLAUDE.md is a long doc. A skill is a checklist.
Same rules, different delivery. Agents read the skill before writing code — not in passing.
Pre-flight, not post-hoc
Agent runs a checklist before writing any UI — catches mistakes at source, not in review.
Enforces, doesn't suggest
7 hard rules + 9 red flags. Not guidelines — requirements.
Lazy-loaded context
Core SKILL.md is ~8KB. Deep references pulled on-demand. Agent's context stays lean.
Works with any AI
Standard skill format — Claude Code, Cursor rules, Copilot. Copy-paste, no runtime.
Install
Pick your scope.
Three ways to install. Skill activates automatically once it's in .claude/skills/.
npx softuq skillInstalls to .claude/skills/softuq/ in the current directory. Active only in this project. Recommended for Softuq-based apps.
What's inside
One checklist, seven reference files.
The agent reads SKILL.md first. Deep references load on demand.
SKILL.mdPre-flight checklist & red flags
The entry point Claude reads first. 9 rules, mobile-first patterns, 21 anti-patterns.
references/typography.mdTypography
Body scale, heading rules (H1/H2 text-balance), paragraph hierarchy, input 16px floor.
references/spacing.mdSpacing
4px grid, stack hierarchy, page rhythm, app vs web, decision tree.
references/tokens.mdTokens & colors
Three-layer architecture, semantic utilities, OKLCH, radius, shadows.
references/icons.mdIcons
Lucide vs Simple Icons split, sizing, currentColor rules.
references/components.mdComponents
Full CVA template, forwardRef pattern, composable APIs.
references/content-quality.mdContent quality
Realistic names and brands, messy numbers, avatar rules, copy tone, emoji policy.
references/layout-variance.mdLayout variance
Hero variants, feature-row patterns beyond 3-column cards, hierarchy via weight, accent discipline.
examples/component.tsxCanonical component
Working Badge component — copy-paste template for new components.
Preview
The skill, in full.
Nothing hidden. This is exactly what Claude reads.
--- name: softuq description: Use before writing or modifying ANY UI in a Softuq project. Enforces design-system rules for typography, spacing, colors, tokens, icons, components, and layout. Triggers on work involving landing pages, hero sections, blocks, templates, cards, forms, inputs, any JSX/TSX file that renders visual UI, or discussions of styling, CSS, Tailwind classes, and design decisions. --- # Softuq Design System — Rules for AI coding agents You are working in a project using the **Softuq design system**. Every piece of UI must follow these rules. Check the pre-flight list before writing a single line. When in doubt, read the matching reference file in `references/`. ## Pre-flight checklist Before touching any JSX/TSX or CSS, confirm: 1. [ ] **Every `<p>` has an explicit `text-*` class** — never inherit browser default 2. [ ] **Every H1 and H2 has `text-balance`** — prevents single-word widows 3. [ ] **Spacing uses `var(--ds-space-*)` tokens** — not Tailwind `p-4`, `gap-6`, `mt-8` 4. [ ] **Colors use semantic utilities** (`text-fg-primary`, `bg-bg-card`, `border-edge-subtle`) — never hex or rgba 5. [ ] **Icons come from `lucide-react` (UI) or `@icons-pack/react-simple-icons` (brand)** — never inline `<svg>` 6. [ ] **New components follow the CVA pattern** — forwardRef, `type="button"`, variants before interface 7. [ ] **`<html>` has `data-theme="dark"` by default** — light mode is opt-in via provider 8. [ ] **Transitions use motion tokens** (`duration-fast/normal/slow`, `ease-soft/smooth/bounce`) — never `duration-200` or `ease-out` 9. [ ] **Icon-only buttons have `aria-label`**, interactive non-buttons have keyboard handlers, every `<img>` has an `alt` If you violate any of these, stop and fix before continuing. --- ## Typography Full reference: `references/typography.md` **Body text** | Context | Class | Rule | |---|---|---| | Hero lead (under H1) | `text-base sm:text-lg` | Largest body, prominent intro | | Section description (under H2) | `text-base` | Default marketing paragraph | | In-card description | `text-sm` | Smaller hierarchy | | Captions / helpers | `text-xs` | Smallest, usually dimmed | Always set the class. Never leave `<p>` bare — browser default is 16px fixed, bypasses our fluid scale. **Headings** - H1 / H2 — **always `text-balance`**. No exceptions. - Escape hatch for exact breaks: ` ` between last 2–3 words (never `<br />`) - Never hardcode widths to force line breaks - Font family comes from the `headingFont` axis (`sans` / `lora` / `playfair` / `fraunces`) — don't hardcode `font-serif` or `font-[Playfair]` on headings, the provider handles it **Inputs** Pin to `text-[16px]` on mobile to block iOS Safari auto-zoom on focus. Already built into `Input`, `Textarea`, `SearchInput`. Don't override with `text-sm` on mobile. --- ## Spacing Full reference: `references/spacing.md` All values are on a **4px grid**. Use `var(--ds-space-*)` tokens, not raw Tailwind spacing classes. **Stack hierarchy** (vertical rhythm inside blocks): | Token | Use | |---|---| | `--ds-space-stack-sm` | Title ↔ description (card header) | | `--ds-space-stack` | Hero H1 ↔ paragraph, group gaps | | `--ds-space-stack-lg` | Section header → content, bigger blocks | **Page rhythm**: | Token | Use | |---|---| | `--ds-space-page-x` | Horizontal page padding (fluid `clamp()`) | | `--ds-space-section-y` | Vertical section spacing (fluid `clamp()`) | | `--ds-space-gap` | Grid / flex gap | | `--ds-space-card` | Inner padding for cards, panels | **App vs web**: - Marketing pages: `px-[var(--ds-space-page-x)] py-[var(--ds-space-section-y)]` - App UI (dashboard, auth, admin): `p-[var(--ds-space-app-page-x)]`, `space-y-[var(--ds-space-app-stack)]`, `gap-[var(--ds-space-app-gap)]` — denser than web Never use bare Tailwind `p-4`, `gap-6`, `mt-8` on design-system blocks. They break the density preset. --- ## Colors & tokens Full reference: `references/tokens.md` **Three layers**: primitive (OKLCH) → semantic (dark/light) → component. Always reference the highest abstraction that fits. **Semantic utilities** (preferred): - Text: `text-fg-primary`, `text-fg-secondary`, `text-fg-muted`, `text-fg-inverse` - Background: `bg-bg-base`, `bg-bg-card`, `bg-bg-elevated`, `bg-bg-popover`, `bg-bg-input` - Border: `border-edge-subtle`, `border-edge-default`, `border-edge-strong`, `border-edge-accent` - Accent: `text-accent-text`, `bg-accent`, `bg-accent-muted`, `border-accent-border` **Rules**: - Never hardcode hex or rgba in components. If a semantic token doesn't exist, add it to `packages/tokens/src/semantic.css`, don't inline. - Primitives (`--gray-500`, `--blue-600`) only inside `semantic.css` — never directly in components. - `--dark-{5..100}` and `--light-{5..100}` are palette-aware alpha scales for overlays/scrims — never flip with theme. - `--border-accent` auto-follows active `--accent` — don't hardcode an accent color on focused states or featured cards. - When accent is `red` or `rose`, error/destructive states collide with the accent hue — differentiate with icons (lucide `CircleAlert`, `CircleX`) or shift the error UI to a darker/more saturated red than the accent itself; never rely on color alone to separate "primary action" from "destructive action". **Radius**: - `rounded-[var(--ds-radius-card)]`, `rounded-[var(--ds-radius-button)]`, `rounded-[var(--ds-radius-input)]` - Hardcoded `rounded-full` only for: Avatar (lg/full preset), Radio, Toggle, Progress - Nested radius rule: outer radius > inner radius (never reverse) --- ## Icons Full reference: `references/icons.md` - **UI primitives** (arrows, checks, chevrons, status) → `lucide-react` - **Brand logos** (GitHub, npm, Stripe, etc.) → `@icons-pack/react-simple-icons` - **Never inline raw `<svg>`** — use an icon library or add a new Lucide/Simple Icons import - Color via `currentColor` — inherited from parent text color - Size via `size-4`, `size-5`, `size-6` (not `w-4 h-4` pair) Per-framework packages swap automatically (Vue: `lucide-vue-next` + `@icons-pack/vue-simple-icons`). --- ## Components Full reference: `references/components.md` **File structure** (in order): ```tsx // 1. Imports import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; import * as React from "react"; // 2. CVA variants — BEFORE interface (interface extends VariantProps<typeof variants>) const buttonVariants = cva("base classes", { variants: { ... }, defaultVariants: { ... }, }); // 3. Interface — extends native element + VariantProps interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {} // 4. Component with forwardRef const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, ...props }, ref) => ( <button type="button" ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} /> ) ); Button.displayName = "Button"; // 5. Exports — types first, then values export type { ButtonProps }; export { Button, buttonVariants }; ``` **Rules**: - `React.forwardRef` on every component that renders a DOM element - `type="button"` on every `<button>` (prevents accidental form submits) - CVA for variants, `cn()` for class merging — nothing else - Section comments (`/* === Section === */`) only in files with 3+ logical sections (provider, toast). Button, Card, Alert: no comments. - `<p>` rules from typography section apply inside components too --- ## Mobile-first patterns These rules are baked into the DS components. When composing layouts that use them, respect the responsive behavior — don't override with fixed widths or heights. **Inputs (Input, SearchInput)** - `sm` variant: `h-10` on mobile, `h-8` on desktop — larger touch target on phones, compact on desktop - Already built into the component. Don't override height on mobile with `h-8` or similar. **TabsList (default variant)** - `w-full` on mobile (triggers stretch evenly via `flex-1`), `w-auto` + centered on desktop - Already built into the component. Don't add `w-full` on desktop — it stretches unnecessarily. **ToggleGroup as tab filter (app blocks)** - When used as a full-width tab bar: add `className="w-full [&>button]:flex-1 sm:w-auto sm:[&>button]:flex-none"` — items stretch on mobile, hug content on desktop - NOT built into the component (ToggleGroup has many uses). Apply via className when the context is a tab-like filter row. **Device/viewport picker (block & template previews)** - Hide on mobile: `className="hidden md:flex"` on the ToggleGroup - Default viewport from screen width: `useState(() => window.innerWidth < 768 ? "mobile" : "desktop")` - Mobile users can't resize iframes meaningfully — always show mobile preview. --- ## Motion All transitions must use the motion tokens — never hardcode `duration-200` or `ease-out`. **Duration tokens**: | Class | Value | Use | |---|---|---| | `duration-fast` | `150ms` | Hover tints, focus rings, checkbox toggle, small color changes | | `duration-normal` | `250ms` | Most UI — input focus, select open, accordion, dropdown items | | `duration-slow` | `400ms` | Modals, drawers, large scale/fade entrances | **Easing tokens**: | Class | Curve | Use | |---|---|---| | `ease-soft` | `cubic-bezier(0.4, 0, 0.2, 1)` | Default for UI interactions | | `ease-smooth` | `cubic-bezier(0.16, 1, 0.3, 1)` | Entrance/exit of floating surfaces (popover, toast) | | `ease-bounce` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Playful overshoot — rare, only for intentional delight | **Keyframe utilities** (for mount animations): `animate-fade-up`, `animate-fade-down`, `animate-scale-in`, `animate-slide-in-right/left/up/down`, `animate-fade-out`, `animate-pulse`. **Rules**: - Default pairing: `transition-colors duration-fast ease-soft` for hover tints, `transition-all duration-normal ease-soft` for most interactive elements - Never bare-Tailwind `duration-200` / `ease-in-out` / `duration-[300ms]` on DS components — breaks theme customization - Transform animations (scale, translate, rotate) use `duration-normal ease-soft` unless it's a large floating surface (then `duration-slow ease-smooth`) - `@keyframes` lives in `tailwind-theme.css` — add there, never inline - Provider-level `html` transition on theme flip is already wired (`transition: background-color var(--duration-normal) var(--ease-soft)`) — don't duplicate on `<body>` --- ## Dark / light theme The Softuq provider owns dark/light. Don't hand-roll it. **Toggling theme**: ```tsx const { resolvedTheme, setTheme } = useSoftuq(); const isLight = resolvedTheme === "light"; return ( <Button variant="ghost" size="icon-sm" aria-label="Toggle theme" onClick={() => setTheme(isLight ? "dark" : "light")}> {isLight ? <Sun className="size-4" /> : <Moon className="size-4" />} </Button> ); ``` `useSoftuq()` exposes `theme` (`"light" | "dark" | "system"`), `resolvedTheme` (`"light" | "dark"` — what's actually applied), and `setTheme(t)`. Use `resolvedTheme` for visual branching (icon swap, conditional class), `theme` only when surfacing the user's preference (e.g. a Select with "System" option). **Provider prop**: - `<SoftuqProvider theme="system">` — follows OS via `prefers-color-scheme` (default scaffolded by `softuq init`) - `<SoftuqProvider theme="dark">` / `theme="light"` — pin the site to one mode (ignore OS) **SSR / first-paint flicker**: - Next.js: `<SoftuqThemeScript />` (server component) renders an inline script in `<body>` that resolves the theme from `softuq-theme` cookie → `localStorage` (if `storageKey` set) → `matchMedia` → fallback, **before first paint**. `softuq init` wires this automatically. Layout reads the cookie via `next/headers` and sets `<html data-theme={...} suppressHydrationWarning>` for the SSR pass. - Vite: identical script inlined into `index.html` by `softuq init` (`#softuq-theme-init`). - Don't add your own pre-hydration script, theme effect, or `<html data-theme>` SSR logic — `init` already wired it. If something flickers, run `softuq doctor` first. **Persistence**: - `softuq-theme` cookie is written by the provider on every resolved-theme change — that's how SSR reads it back. Don't override the cookie name. - Pass `storageKey` to `SoftuqProvider` to also persist the full preset bundle (palette/accent/radius/spacing/font/headingFont/theme) in `localStorage` for cross-tab sync. --- ## Accessibility Softuq hits WCAG 2.1 AA by default — don't break it. **Contrast**: - Semantic text tokens are tuned for ≥4.5:1 on their intended surfaces. `text-fg-primary`, `text-fg-secondary`, `text-fg-muted` all pass AA body-text contrast on `bg-bg-base`, `bg-bg-card`, `bg-bg-elevated`. - **Never put `text-fg-muted` on `bg-bg-popover` or `bg-accent`** — contrast may drop below AA. Use `text-fg-primary` or `text-fg-inverse`. - Never hardcode colors that haven't been checked — stick to semantic utilities. **Focus**: - Every interactive element needs a visible focus ring. DS components ship with `focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base` — don't strip it. - Use `focus-visible:` (not `focus:`) — keyboard users get the ring, mouse clicks don't show it. - Custom interactive divs (`role="button"`): add `tabIndex={0}` + focus-visible ring manually. **Keyboard**: - Every action must be reachable without a mouse. If you add a click handler to a non-button (`<div onClick>`), convert it to `<button type="button">` or add `role="button" tabIndex={0}` + `onKeyDown` for Enter/Space. - Modals: trap focus inside, return to trigger on close (Dialog/Sheet handle this — don't roll your own). - Escape key closes overlays (handled by Dialog, Sheet, Popover, DropdownMenu). **ARIA & semantics**: - **Icon-only buttons must have `aria-label`**: `<Button size="icon" aria-label="Close"><X /></Button>` - Decorative icons beside text: add `aria-hidden="true"` on the icon so screen readers don't double-read - Use native elements first (`<button>`, `<a href>`, `<nav>`, `<main>`, `<aside>`) — reach for ARIA only when semantics are missing - Form inputs must have a `<Label htmlFor>` or be wrapped in `<FormField>` (which wires it for you) - Status messages: `<FormMessage>` uses `role="alert"` automatically — don't duplicate **Motion**: - Respect `prefers-reduced-motion` for non-essential animation. Wrap decorative transforms in: ```css @media (prefers-reduced-motion: reduce) { .motion-safe-only { animation: none; transition: none; } } ``` or use Tailwind's `motion-safe:animate-fade-up` / `motion-reduce:transition-none` variants. - Essential transitions (focus ring, theme flip) can stay — they're short and informative. **Images & media**: - Every `<img>` needs `alt=""` (decorative) or a meaningful alt. Missing `alt` = fail. - Iframes (previews, embeds): `title` attribute describing content. --- ## Content quality Full reference: `references/content-quality.md` Placeholder copy is the fastest way to make a Softuq layout look AI-generated. Every name, number, and image must feel specific rather than demo-filler. - **Names** — no "John Doe" / "Sarah Chen". Use realistic names with regional variety. - **Brands** — no "Acme" / "Nexus". Invent brand names contextual to the product. - **Numbers** — no round stats (`99.9%`, `10,000+`, `$99/mo`). Use messy realistic values (`99.973%`, `1,284`, `$87/mo`). Apply `font-mono`. - **Phone numbers** — region-formatted realistic values, never `123-456-7890`. - **Avatars** — `<Avatar>` with initials on tinted background, or real contextual photos. Never Lucide `<User />`. - **Imagery** — no stock clichés (handshake, lightbulb, growth arrow). Use product screenshots, geometric compositions, or `<Placeholder>`. - **Copy tone** — specific beats generic ("Postgres queries in 40ms" beats "Blazing-fast performance"). - **Emoji** — never in headings, section titles, buttons, or card headers. Use Lucide icons instead. --- ## Layout variance Full reference: `references/layout-variance.md` Tokens handle density and rhythm, but composition is on the agent — and agents default to centered heroes and 3-column card rows. Vary proactively. - **Heroes** — centered only when it's the single dominant block. Otherwise split 50/50, asymmetric 3:2, or left-anchored with deliberate whitespace. - **Feature rows** — three uniform `<Card>` in `grid-cols-3` is banned (the single most common AI tell). Use zig-zag, asymmetric grid, bento, horizontal scroller, or divided list. - **Hierarchy** — contrast comes from weight + color (`text-fg-primary` heading paired with `text-fg-muted` copy) before scaling to `text-6xl`. - **Accent discipline** — one accent element per block region. Not: accent button + badge + icon + border stacked. - **Mobile** — every asymmetric `md:grid-cols-*` must collapse to `grid-cols-1` below `md:`. --- ## Red flags If you catch yourself doing any of these, STOP and fix: | Anti-pattern | Instead | |---|---| | `<p>` without `text-*` class | `<p className="text-sm text-fg-secondary">` | | `<h1 className="text-6xl">` without `text-balance` | Add `text-balance` | | `className="p-6 gap-4 mt-8"` | `p-[var(--ds-space-card)] gap-[var(--ds-space-gap)] mt-[var(--ds-space-stack)]` | | `bg-[#111111]` or `color: "#fff"` | `bg-bg-base`, `text-fg-primary` | | Inline `<svg xmlns=...>` | `<ChevronRight />` from `lucide-react` | | `<Button><button>...</button></Button>` | `<DialogTrigger asChild><Button>...</Button></DialogTrigger>` | | Hardcoded `rounded-md` on DS components | `rounded-[var(--ds-radius-card)]` | | Forced `<br />` in headings | `text-balance` or ` ` | | `font-serif` / `font-[Playfair]` on h1/h2 | Use `headingFont` provider axis | | `w-4 h-4` on icon | `size-4` | | `h-8` on sm Input/SearchInput (overriding mobile height) | Don't — component already does `h-10 sm:h-8` | | Full-width TabsList on desktop | Don't — component already does `w-full sm:w-auto sm:self-center` | | ToggleGroup tab filter without mobile stretch | Add `w-full [&>button]:flex-1 sm:w-auto sm:[&>button]:flex-none` | | Device picker visible on mobile | Add `hidden md:flex` + default to `"mobile"` viewport | | `duration-200 ease-in-out` | `duration-normal ease-soft` | | Hardcoded `cubic-bezier(...)` | `ease-soft` / `ease-smooth` / `ease-bounce` | | Icon-only `<Button>` without `aria-label` | `<Button aria-label="Close"><X /></Button>` | | `<div onClick={...}>` as interactive | `<button type="button">` (or `role="button" tabIndex={0}` + `onKeyDown`) | | `focus:` ring (shows on mouse click) | `focus-visible:` ring | | `<img>` without `alt` | `alt=""` (decorative) or meaningful alt | | Custom `useState` + `localStorage` + `matchMedia` for dark/light | `useSoftuq().setTheme` + `resolvedTheme` | | Hand-rolled pre-hydration `<script>` for theme | Use `<SoftuqThemeScript />` (Next) — `softuq init` wires it | | Hardcoded `<html data-theme="dark">` to force a mode | `<SoftuqProvider theme="dark">` (or `"light"`) | --- ## When you're unsure - Spacing granularity? → Read `references/spacing.md` - Right text size for this context? → Read `references/typography.md` - Which semantic color? → Read `references/tokens.md` - How to build a new component? → Read `references/components.md` and copy the shape from `examples/component.tsx` - Placeholder names, numbers, or brand copy? → Read `references/content-quality.md` - Hero or feature-row layout variant? → Read `references/layout-variance.md` Full project docs live at `docs/` (tokens, guides, component pattern). For live reference visit `softuq.com/foundations`.
Own the rules, like you own the code.
The skill is a markdown file. Fork it, edit it, add your team's conventions — or strip it down to just what matters to you.