Frontend Design Guide
Automatically applies when working on frontend UI/UX tasks. Creates distinctive, production-grade interfaces with consistent styling.
Design System Stack
| Layer |
Technology |
Purpose |
| Tokens |
shared/theme.css (CSS custom properties) |
Color, spacing, typography, radius, shadows, z-index |
| Themes |
shared/ThemeToggle.jsx (multiple presets) |
Theme switching via data-theme attribute on <html> |
| Animations |
shared/animations.css |
Shared keyframes + reduced-motion support |
| Utilities |
Tailwind CSS v4 (@tailwindcss/vite) |
Utility-first classes, no preflight (coexists with vanilla CSS) |
| Components |
shadcn/ui |
Accessible primitives: Button, Dialog, Input, Badge, Table, etc. |
| Existing CSS |
Per-project App.css |
Unlayered vanilla CSS (wins specificity over Tailwind @layer) |
Design Tokens (shared/theme.css)
All colors, spacing, and visual properties use CSS custom properties. Never hardcode hex values — use tokens.
Color Tokens
| Token |
Dark Value |
Usage |
--bg-app |
#111827 |
Page/app background |
--bg-surface |
#1F2937 |
Card/panel backgrounds |
--bg-elevated |
#374151 |
Hover states, secondary bg |
--bg-inset |
#0F172A |
Inset/recessed areas |
--text-primary |
#F9FAFB |
Main content text |
--text-secondary |
#9CA3AF |
Labels, descriptions |
--text-muted |
#6B7280 |
Hints, timestamps |
--accent |
#3B82F6 |
Primary actions, links |
--accent-hover |
#2563EB |
Hover on accent |
--accent-muted |
#60A5FA |
Light accent variant |
--status-error |
#EF4444 |
Errors, late items |
--status-warning |
#F59E0B |
Warnings, approaching due |
--status-success |
#10B981 |
Success, on-time |
--status-info |
#3B82F6 |
Informational |
--border-default |
#374151 |
Standard borders |
--border-subtle |
#1F2937 |
Subtle dividers |
Spacing Scale
--space-xs (0.25rem) through --space-3xl (3rem). Use in CSS: padding: var(--space-md).
Typography Scale
--text-xs (0.75rem) through --text-3xl (1.875rem). Use: font-size: var(--text-sm).
Radius, Shadows, Z-Index
- Radius:
--radius-sm through --radius-full
- Shadows:
--shadow-sm through --shadow-xl
- Z-index:
--z-base (1) through --z-toast (9999)
Tailwind v4 Integration
Tailwind v4 is installed via @tailwindcss/vite plugin. No preflight — existing CSS is preserved.
@theme inline Mapping
The @theme inline block in index.css maps your tokens to Tailwind utilities:
shadcn/ui standard names (used by shadcn components internally):
bg-primary → var(--accent) (your accent color)
text-primary-foreground → #ffffff (text on primary buttons)
bg-background → var(--bg-app)
text-foreground → var(--text-primary)
bg-card → var(--bg-surface)
bg-secondary / bg-muted / bg-accent → var(--bg-elevated) (shadcn "accent" = hover bg)
text-muted-foreground → var(--text-muted)
bg-destructive → var(--status-error)
border (default) → var(--border-default)
ring-ring → var(--accent)
Custom names (use in your own Tailwind code):
bg-app, bg-surface, bg-elevated, bg-inset — layout backgrounds
bg-accent-primary, bg-accent-hover — accent color (distinct from shadcn bg-accent)
bg-error, bg-warning, bg-success, bg-info — status colors
text-text-primary, text-text-secondary, text-text-muted — text colors
border-border-subtle — subtle borders
Specificity Rules
- Unlayered CSS (existing
App.css) always wins over Tailwind's @layer utilities
- When migrating a component to Tailwind, remove its CSS rules to avoid conflicts
rgba() values stay as-is until converted to oklch() or Tailwind opacity modifiers (bg-primary/15)
shadcn/ui Components
Setup
- Config:
components.json (style: new-york, jsx, zinc base)
- Utility:
src/lib/utils.js — cn() function (clsx + tailwind-merge)
- Path alias:
@/* → ./src/* (in vite.config.js + jsconfig.json)
- Components:
src/components/ui/*.jsx
Available Components
| Component |
Import |
Usage |
Button |
@/components/ui/button |
Actions, CTA. Variants: default, destructive, outline, secondary, ghost, link |
Dialog |
@/components/ui/dialog |
Modal dialogs. Use DialogContent, DialogHeader, DialogTitle, etc. |
Input |
@/components/ui/input |
Form text inputs |
Badge |
@/components/ui/badge |
Status badges. Variants: default, secondary, destructive, outline |
Table |
@/components/ui/table |
Data tables (Table, TableHeader, TableBody, TableRow, TableCell) |
DropdownMenu |
@/components/ui/dropdown-menu |
Context menus, action menus |
Tooltip |
@/components/ui/tooltip |
Hover tooltips (needs TooltipProvider) |
Sonner (Toast) |
@/components/ui/sonner |
Toast notifications via sonner |
Card |
@/components/ui/card |
Content cards (Card, CardHeader, CardTitle, CardContent) |
Tabs |
@/components/ui/tabs |
Tab navigation (Tabs, TabsList, TabsTrigger, TabsContent) |
Usage Pattern
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
function MyComponent({ isLate }) {
return (
<div className="flex items-center gap-2">
<Badge variant={isLate ? 'destructive' : 'default'}>
{isLate ? 'Late' : 'On Time'}
</Badge>
<Button variant="outline" size="sm">
View Details
</Button>
</div>
)
}
Adding New shadcn Components
npx shadcn@latest add <component-name> --yes --overwrite
Components generate as .jsx into src/components/ui/. No TypeScript cleanup needed.
Theme System
Theme Presets
Themes work by swapping CSS custom property values via [data-theme="X"] selectors in shared/theme.css. A ThemeToggle component persists the choice to localStorage.
Example presets to support:
| Group |
Themes |
| Auto |
System (follows OS) |
| Standard |
Dark (default), Light, Dim, High Contrast |
| Popular |
Midnight, Nord, Dracula, Catppuccin |
| Classic |
Solarized Dark, Solarized Light |
Adding a New Theme
- Add theme object to
THEMES array in ThemeToggle.jsx
- Add
[data-theme="your-theme"] selector block in theme.css with all token overrides
- Light themes: set
color-scheme: light and ensure stronger contrast (--text-primary: #0F172A)
Component Patterns
KPI Cards
Use a consistent KPICard component for metric cards across all projects:
- Grid: 4 columns desktop, 2 mobile (
@media max-width: 768px)
- Structure: label, value (large), unit/trend, optional delta
Charts (Recharts)
Use shared tooltip styles if your project exports them:
import { CHART_TOOLTIP } from '@your-shared/constants'
<Tooltip {...CHART_TOOLTIP} />
Legend standard (enforced — e.g. via scripts/lint-chart-legends.mjs).
A multi-series chart (2+ bars/lines/areas, or a .map() that emits a series per
datum) MUST give the reader a labeled key for its colors, two ways:
- Visible legend — add
<Legend wrapperStyle={{ fontSize: 11 }} /> (11px keeps it
readable on compact cards). Every series needs a name= prop so the legend (and
tooltip) labels it.
- Color-aware tooltip — the popup must show each series' color, not a uniform gray.
A shared
CHART_TOOLTIP that sets itemStyle:{color:'var(--text-secondary)'} flattens
every row to one color. On a multi-series chart, pass a custom content= that colors
each row by entry.color instead:
// Color-aware tooltip: a mini legend in the popup (swatch + name + value).
function ChartTooltip({ active, payload, label }, fmt = (v) => v) {
if (!active || !payload?.length) return null
return (
<div className="chart-tooltip">
<p className="tooltip-label">{label}</p>
{payload.map((e, i) => (
<p key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: e.color }} />
<span>{e.name}</span>
<span style={{ marginLeft: 'auto', fontWeight: 600 }}>{e.value != null ? fmt(e.value) : '—'}</span>
</p>
))}
</div>
)
}
// ...
<Tooltip content={<ChartTooltip />} />
- Single-series charts are exempt — the card title names the one series; a legend is noise.
- Custom / non-
<Legend> key (e.g. a color-dot caption or a deliberately key-less chart):
annotate it so lint skips it — {/* chart-legend-ok: <reason> */} on or just above the chart element.
- If using a ratcheted lint gate: existing charts are grandfathered; it fails only on a NEW
multi-series chart that lacks a legend or color-aware tooltip.
Additional chart patterns:
- Dark tooltips require THREE props:
contentStyle, labelStyle, itemStyle
- Bundle split: Keep Recharts in
manualChunks (vite.config.js)
- ResponsiveContainer:
height={isMobile ? 200 : 250} standard
Tables
table-layout: fixed + <colgroup> for resizable columns
- Column widths persist to localStorage
- Sticky headers:
position: sticky; top: 0; z-index: var(--z-sticky)
- Alternate row coloring:
tr:nth-child(even) with subtle surface variation
Modals
Use a shared modal component with:
- Focus trap (
useModalA11y or native <dialog> + showModal())
- Overlay-click-to-close:
if (e.target === e.currentTarget) onClose() on the overlay — do NOT use stopPropagation on inner content (breaks children that render outside the DOM tree via ReactDOM.create* APIs)
- Backdrop:
rgba(0, 0, 0, 0.5) with optional blur
- Escape key close
- Scroll lock on body while open
Collapsible groups — Expand all / Collapse all
Any view that renders a list of collapsible sections/accordions/groups MUST offer bulk Expand all / Collapse all controls — don't re-implement per-group useState(open) with no bulk control, and don't let each new grouped view invent its own toggle shape.
- Extract a shared hook (
useExpandCollapseAll(defaultOpen)) that returns { isOpen(id), toggle(id), expandAll(), collapseAll() } and tolerates a changing id set — newly-loaded groups should inherit the current default so it works before data loads and after the list grows.
- Extract a paired control component for the toolbar/filter bar (
<ExpandCollapseControls />).
- Lift the per-group open state to the parent so the bulk controls actually drive every group, rather than each group owning its own isolated state.
Apply this to every new grouped/accordion view, not just the first one that needed it — it's cheap to reuse and expensive to reinvent per view.
CSS Architecture
| Location |
Scope |
shared/theme.css |
Design tokens (all projects) |
shared/animations.css |
Keyframes (all projects) |
{project}/src/index.css |
Tailwind imports + @theme mapping |
{project}/src/App.css |
Vanilla CSS (view-specific styles) |
{project}/src/components/ui/ |
shadcn/ui components |
Migration Path
When rewriting a view to use Tailwind + shadcn:
- Build the new version alongside the old (or in a test route)
- Use shadcn components for structure (Dialog, Table, Card, Tabs, Button)
- Use Tailwind utilities for layout and spacing
- Remove the old CSS rules from App.css
- Keep domain-semantic styles (status colors, chart configs) in CSS or constants
Aligning an Existing Component to the Design System
Lighter than a full rewrite — use this when reskinning a component that already works but has drifted off-token (hardcoded hex colors, arbitrary px values, transition: all). Observed working loop, confirmed across multiple sessions:
- Read the source of truth first — the token file (this doc's Color Tokens section, or wherever your project defines them) plus the reference primitives the rest of the app already uses (card/badge/button/modal components). Match those; don't invent a parallel style.
- Grep the target for hardcoded drift before editing:
grep -nE "#[0-9a-fA-F]{3,6}|rgba?\(" <target>.css # raw colors that should be tokens
grep -nE "[0-9]+px" <target>.css # arbitrary sizes vs your spacing/type scale
grep -n "transition: all" <target>.css # vague transitions — name the properties
- Rewrite CSS to tokens — swap raw values for token references, arbitrary px for the spacing/type scale,
transition: all for explicit named properties. Keep domain-semantic colors (status/category colors that are intentionally not themed) as-is.
- Build and verify in a real browser — a token swap can silently change contrast or layout. Look at an actual screenshot, not bounding-box math or computed geometry.
Accessibility
- Color contrast: 4.5:1 body, 3:1 large text (High Contrast theme = WCAG AAA)
- Interactive targets: min 44x44px on mobile
- Focus indicators: visible ring via
focus-visible:ring-ring/50
- Reduced motion:
@media (prefers-reduced-motion: reduce) zeroes all animations
- Status: never color-only — combine with icon/text
Anti-Patterns to Avoid (AI-Slop Signals)
These patterns appear in the majority of AI-generated UIs. Avoid them by default unless there is a specific, intentional reason.
| Anti-pattern |
What it looks like |
Better alternative |
| Purple/indigo gradients |
background: linear-gradient(135deg, #6366F1, #8B5CF6) as a "hero" |
Use your --accent token consistently; gradients only for data viz |
| Inter-by-default |
Loading Inter because it's the AI default, not because it fits |
Use fonts with discipline (defined scale, no arbitrary sizes) |
| Bento grids |
3x2 card grid where every card has a different height and an icon top-left |
Grid layout with consistent card heights; icon choice driven by meaning, not decoration |
| Glass morphism |
backdrop-filter: blur(12px) + background: rgba(255,255,255,0.1) + border: 1px solid rgba(255,255,255,0.2) |
Solid surface elevation using --bg-elevated; blur only for modals over complex backgrounds |
| Spark lines everywhere |
Tiny 40px sparklines added to every metric card to look "data-rich" |
Sparklines only when the trend is the insight; use plain numbers when the current value is what matters |
| Side-tab indicator borders |
Left border on active tab item as the only active state signal |
Combine border with background color change; border-only fails on low contrast themes |
| Identical pill buttons |
All buttons are border-radius: 999px regardless of context |
Radius should match the component family — table action buttons at --radius-sm, modal CTAs at --radius-md |
| Decorative hero illustrations |
3D robot or abstract illustration as page header |
Data visualization or functional illustration only; no illustration if it conveys no information |
| Shadow stacking |
box-shadow on every card + every button + every input |
Pick one elevation tier per component type and stick to it |
| Random animation |
transition: all 0.3s ease added to every element |
Only transition properties that actually change; explicit property names + durations matched to interaction type |
Porting a Showcase Variant to Real Code
After /design-showcase and the user picks a variant, porting it into the real component has a specific failure mode worth calling out.
The drift trap: Worktree agents dispatched via Agent({ isolation: "worktree" }) branch from a shared reference point, NOT current HEAD. If parallel sessions have been modifying the target file, the worktree agent ports against a stale base — cherry-picking then conflicts.
Detection: before accepting an agent's port commit, check git log <worktree-base>..HEAD -- <target-file>. If non-empty, the agent was working from stale context.
Resolution pattern:
- If the CSS additions are purely additive (new scoped classes) — cherry-pick with
git checkout --ours <target.jsx> to keep your current JSX and accept ONLY the CSS.
- Apply the JSX design edits directly with the
Edit tool against current HEAD.
- Build, commit, push.
Prefer additive edits over reordering. If the showcase variant spec includes "reorder sections", save that for a separate follow-up — it's much riskier than adding a new pill or strip. Ship the high-signal additions first.
Tactical Patterns
- Recharts tooltips: Need THREE style props for dark theme (
contentStyle, labelStyle, itemStyle)
- Recharts bundle: Split via
manualChunks in vite.config.js — always keep this config
- Dropdown in scrollable tables:
position:fixed + getBoundingClientRect() — absolute gets clipped by overflow-x:auto
- localStorage validation: Cross-reference saved columns against
DEFAULT_COLUMNS to filter stale entries
- getDueDateCategory "this week" filter: must include
late + today + tomorrow + thisWeek (not just thisWeek)
- ISO date timezone shift:
new Date('2026-03-20') → midnight UTC → day before in local timezone. Fix: parse YYYY-MM-DD as new Date(year, month-1, day) for local display. Don't round-trip already-ISO strings through toISOString()
- Unstable array/object deps + AbortController = infinite loop: When a
useEffect depends on a prop-derived array (e.g. const ids = idsProp || []) and the parent passes a fresh literal each render, the effect re-fires every render. If that effect creates an AbortController with a cleanup that calls abort(), every render aborts the in-flight fetch. Symptom: modal stuck on "Loading..." forever, network panel shows canceled requests in a tight loop. react-hooks/exhaustive-deps does NOT catch it — the dep is listed correctly, just unstable. Fix: memoize the derived value against a primitive key (e.g. array.join('|')) or have the parent wrap in useMemo.
- CSS shorthand vs longhand override:
overflow: hidden (shorthand) is NOT reliably overridden by a later overflow-y: auto (longhand) in some cascade scenarios. Use the matching shorthand (overflow: hidden auto) to override. Also: overflow-y: auto is a no-op when the container has min-height instead of height; min-height is ignored on <td> elements per CSS spec.
- Flex scroll chain requires every ancestor to be a flex container: For
overflow-y: auto on a deeply nested element to work, every ancestor must be display: flex; flex-direction: column with min-height: 0 (or height: 0). One plain block container breaks flex: 1 on its children — content overflows without constraint.
- CSS pattern audit before adding styles: Before adding
@keyframes or new CSS classes to a large App.css, grep existing patterns first to avoid duplicate keyframes and conflicting class names.
See Also
/design-showcase — for exploratory redesigns, run a showcase first. Dispatches a single Opus agent that builds a self-contained HTML page with 3-7 design variants side-by-side. User picks a direction, then implement using the tokens and patterns above.
/design-reverse-engineer — extract design tokens and component patterns from any URL; use when anchoring a new view to a proven reference before building
/webapp-testing — Playwright visual verification after implementing a chosen variant
reference-sites.md (in .claude/skills/design-reverse-engineer/) — curated list of high-quality reference sites by domain
1---2name: frontend-design3description: Design system guide — CSS tokens, Tailwind v4, shadcn/ui components, theming, and UI conventions for consistent dark-themed dashboard interfaces.4---56# Frontend Design Guide78Automatically applies when working on frontend UI/UX tasks. Creates distinctive, production-grade interfaces with consistent styling.910## Design System Stack1112| Layer | Technology | Purpose |13|-------|-----------|---------|14| **Tokens** | `shared/theme.css` (CSS custom properties) | Color, spacing, typography, radius, shadows, z-index |15| **Themes** | `shared/ThemeToggle.jsx` (multiple presets) | Theme switching via `data-theme` attribute on `<html>` |16| **Animations** | `shared/animations.css` | Shared keyframes + reduced-motion support |17| **Utilities** | Tailwind CSS v4 (`@tailwindcss/vite`) | Utility-first classes, no preflight (coexists with vanilla CSS) |18| **Components** | shadcn/ui | Accessible primitives: Button, Dialog, Input, Badge, Table, etc. |19| **Existing CSS** | Per-project `App.css` | Unlayered vanilla CSS (wins specificity over Tailwind @layer) |2021## Design Tokens (`shared/theme.css`)2223All colors, spacing, and visual properties use CSS custom properties. **Never hardcode hex values** — use tokens.2425### Color Tokens2627| Token | Dark Value | Usage |28|-------|-----------|-------|29| `--bg-app` | `#111827` | Page/app background |30| `--bg-surface` | `#1F2937` | Card/panel backgrounds |31| `--bg-elevated` | `#374151` | Hover states, secondary bg |32| `--bg-inset` | `#0F172A` | Inset/recessed areas |33| `--text-primary` | `#F9FAFB` | Main content text |34| `--text-secondary` | `#9CA3AF` | Labels, descriptions |35| `--text-muted` | `#6B7280` | Hints, timestamps |36| `--accent` | `#3B82F6` | Primary actions, links |37| `--accent-hover` | `#2563EB` | Hover on accent |38| `--accent-muted` | `#60A5FA` | Light accent variant |39| `--status-error` | `#EF4444` | Errors, late items |40| `--status-warning` | `#F59E0B` | Warnings, approaching due |41| `--status-success` | `#10B981` | Success, on-time |42| `--status-info` | `#3B82F6` | Informational |43| `--border-default` | `#374151` | Standard borders |44| `--border-subtle` | `#1F2937` | Subtle dividers |4546### Spacing Scale4748`--space-xs` (0.25rem) through `--space-3xl` (3rem). Use in CSS: `padding: var(--space-md)`.4950### Typography Scale5152`--text-xs` (0.75rem) through `--text-3xl` (1.875rem). Use: `font-size: var(--text-sm)`.5354### Radius, Shadows, Z-Index5556- Radius: `--radius-sm` through `--radius-full`57- Shadows: `--shadow-sm` through `--shadow-xl`58- Z-index: `--z-base` (1) through `--z-toast` (9999)5960## Tailwind v4 Integration6162Tailwind v4 is installed via `@tailwindcss/vite` plugin. **No preflight** — existing CSS is preserved.6364### @theme inline Mapping6566The `@theme inline` block in `index.css` maps your tokens to Tailwind utilities:6768**shadcn/ui standard names** (used by shadcn components internally):69- `bg-primary` → `var(--accent)` (your accent color)70- `text-primary-foreground` → `#ffffff` (text on primary buttons)71- `bg-background` → `var(--bg-app)`72- `text-foreground` → `var(--text-primary)`73- `bg-card` → `var(--bg-surface)`74- `bg-secondary` / `bg-muted` / `bg-accent` → `var(--bg-elevated)` (shadcn "accent" = hover bg)75- `text-muted-foreground` → `var(--text-muted)`76- `bg-destructive` → `var(--status-error)`77- `border` (default) → `var(--border-default)`78- `ring-ring` → `var(--accent)`7980**Custom names** (use in your own Tailwind code):81- `bg-app`, `bg-surface`, `bg-elevated`, `bg-inset` — layout backgrounds82- `bg-accent-primary`, `bg-accent-hover` — accent color (distinct from shadcn `bg-accent`)83- `bg-error`, `bg-warning`, `bg-success`, `bg-info` — status colors84- `text-text-primary`, `text-text-secondary`, `text-text-muted` — text colors85- `border-border-subtle` — subtle borders8687### Specificity Rules8889- Unlayered CSS (existing `App.css`) **always wins** over Tailwind's `@layer utilities`90- When migrating a component to Tailwind, remove its CSS rules to avoid conflicts91- `rgba()` values stay as-is until converted to `oklch()` or Tailwind opacity modifiers (`bg-primary/15`)9293## shadcn/ui Components9495### Setup9697- Config: `components.json` (style: new-york, jsx, zinc base)98- Utility: `src/lib/utils.js` — `cn()` function (clsx + tailwind-merge)99- Path alias: `@/*` → `./src/*` (in vite.config.js + jsconfig.json)100- Components: `src/components/ui/*.jsx`101102### Available Components103104| Component | Import | Usage |105|-----------|--------|-------|106| `Button` | `@/components/ui/button` | Actions, CTA. Variants: default, destructive, outline, secondary, ghost, link |107| `Dialog` | `@/components/ui/dialog` | Modal dialogs. Use DialogContent, DialogHeader, DialogTitle, etc. |108| `Input` | `@/components/ui/input` | Form text inputs |109| `Badge` | `@/components/ui/badge` | Status badges. Variants: default, secondary, destructive, outline |110| `Table` | `@/components/ui/table` | Data tables (Table, TableHeader, TableBody, TableRow, TableCell) |111| `DropdownMenu` | `@/components/ui/dropdown-menu` | Context menus, action menus |112| `Tooltip` | `@/components/ui/tooltip` | Hover tooltips (needs TooltipProvider) |113| `Sonner` (Toast) | `@/components/ui/sonner` | Toast notifications via sonner |114| `Card` | `@/components/ui/card` | Content cards (Card, CardHeader, CardTitle, CardContent) |115| `Tabs` | `@/components/ui/tabs` | Tab navigation (Tabs, TabsList, TabsTrigger, TabsContent) |116117### Usage Pattern118119```jsx120import { Button } from '@/components/ui/button'121import { Badge } from '@/components/ui/badge'122import { cn } from '@/lib/utils'123124function MyComponent({ isLate }) {125 return (126 <div className="flex items-center gap-2">127 <Badge variant={isLate ? 'destructive' : 'default'}>128 {isLate ? 'Late' : 'On Time'}129 </Badge>130 <Button variant="outline" size="sm">131 View Details132 </Button>133 </div>134 )135}136```137138### Adding New shadcn Components139140```bash141npx shadcn@latest add <component-name> --yes --overwrite142```143144Components generate as `.jsx` into `src/components/ui/`. No TypeScript cleanup needed.145146## Theme System147148### Theme Presets149150Themes work by swapping CSS custom property values via `[data-theme="X"]` selectors in `shared/theme.css`. A `ThemeToggle` component persists the choice to localStorage.151152Example presets to support:153154| Group | Themes |155|-------|--------|156| Auto | System (follows OS) |157| Standard | Dark (default), Light, Dim, High Contrast |158| Popular | Midnight, Nord, Dracula, Catppuccin |159| Classic | Solarized Dark, Solarized Light |160161### Adding a New Theme1621631. Add theme object to `THEMES` array in `ThemeToggle.jsx`1642. Add `[data-theme="your-theme"]` selector block in `theme.css` with all token overrides1653. Light themes: set `color-scheme: light` and ensure stronger contrast (`--text-primary: #0F172A`)166167## Component Patterns168169### KPI Cards170171Use a consistent `KPICard` component for metric cards across all projects:172- Grid: 4 columns desktop, 2 mobile (`@media max-width: 768px`)173- Structure: label, value (large), unit/trend, optional delta174175### Charts (Recharts)176177Use shared tooltip styles if your project exports them:178```jsx179import { CHART_TOOLTIP } from '@your-shared/constants'180<Tooltip {...CHART_TOOLTIP} />181```182183**Legend standard (enforced — e.g. via `scripts/lint-chart-legends.mjs`).**184A **multi-series** chart (2+ bars/lines/areas, or a `.map()` that emits a series per185datum) MUST give the reader a labeled key for its colors, two ways:1861871. **Visible legend** — add `<Legend wrapperStyle={{ fontSize: 11 }} />` (11px keeps it188 readable on compact cards). Every series needs a `name=` prop so the legend (and189 tooltip) labels it.1902. **Color-aware tooltip** — the popup must show each series' color, not a uniform gray.191 A shared `CHART_TOOLTIP` that sets `itemStyle:{color:'var(--text-secondary)'}` **flattens192 every row to one color**. On a multi-series chart, pass a custom `content=` that colors193 each row by `entry.color` instead:194195```jsx196// Color-aware tooltip: a mini legend in the popup (swatch + name + value).197function ChartTooltip({ active, payload, label }, fmt = (v) => v) {198 if (!active || !payload?.length) return null199 return (200 <div className="chart-tooltip">201 <p className="tooltip-label">{label}</p>202 {payload.map((e, i) => (203 <p key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>204 <span style={{ width: 9, height: 9, borderRadius: '50%', background: e.color }} />205 <span>{e.name}</span>206 <span style={{ marginLeft: 'auto', fontWeight: 600 }}>{e.value != null ? fmt(e.value) : '—'}</span>207 </p>208 ))}209 </div>210 )211}212// ...213<Tooltip content={<ChartTooltip />} />214```215216- **Single-series** charts are exempt — the card title names the one series; a legend is noise.217- **Custom / non-`<Legend>` key** (e.g. a color-dot caption or a deliberately key-less chart):218 annotate it so lint skips it — `{/* chart-legend-ok: <reason> */}` on or just above the chart element.219- If using a ratcheted lint gate: existing charts are grandfathered; it fails only on a NEW220 multi-series chart that lacks a legend or color-aware tooltip.221222Additional chart patterns:223- **Dark tooltips require THREE props**: `contentStyle`, `labelStyle`, `itemStyle`224- **Bundle split**: Keep Recharts in `manualChunks` (vite.config.js)225- **ResponsiveContainer**: `height={isMobile ? 200 : 250}` standard226227### Tables228229- `table-layout: fixed` + `<colgroup>` for resizable columns230- Column widths persist to localStorage231- Sticky headers: `position: sticky; top: 0; z-index: var(--z-sticky)`232- Alternate row coloring: `tr:nth-child(even)` with subtle surface variation233234### Modals235236Use a shared modal component with:237- Focus trap (`useModalA11y` or native `<dialog>` + `showModal()`)238- Overlay-click-to-close: `if (e.target === e.currentTarget) onClose()` on the overlay — do NOT use `stopPropagation` on inner content (breaks children that render outside the DOM tree via `ReactDOM.create*` APIs)239- Backdrop: `rgba(0, 0, 0, 0.5)` with optional blur240- Escape key close241- Scroll lock on body while open242243### Collapsible groups — Expand all / Collapse all244245Any view that renders a list of collapsible sections/accordions/groups MUST offer bulk **Expand all / Collapse all** controls — don't re-implement per-group `useState(open)` with no bulk control, and don't let each new grouped view invent its own toggle shape.246247- Extract a shared hook (`useExpandCollapseAll(defaultOpen)`) that returns `{ isOpen(id), toggle(id), expandAll(), collapseAll() }` and tolerates a changing id set — newly-loaded groups should inherit the current default so it works before data loads and after the list grows.248- Extract a paired control component for the toolbar/filter bar (`<ExpandCollapseControls onExpandAll={...} onCollapseAll={...} />`).249- Lift the per-group open state to the parent so the bulk controls actually drive every group, rather than each group owning its own isolated state.250251Apply this to every new grouped/accordion view, not just the first one that needed it — it's cheap to reuse and expensive to reinvent per view.252253## CSS Architecture254255| Location | Scope |256|----------|-------|257| `shared/theme.css` | Design tokens (all projects) |258| `shared/animations.css` | Keyframes (all projects) |259| `{project}/src/index.css` | Tailwind imports + @theme mapping |260| `{project}/src/App.css` | Vanilla CSS (view-specific styles) |261| `{project}/src/components/ui/` | shadcn/ui components |262263### Migration Path264265When rewriting a view to use Tailwind + shadcn:2661. Build the new version alongside the old (or in a test route)2672. Use shadcn components for structure (Dialog, Table, Card, Tabs, Button)2683. Use Tailwind utilities for layout and spacing2694. Remove the old CSS rules from App.css2705. Keep domain-semantic styles (status colors, chart configs) in CSS or constants271272## Aligning an Existing Component to the Design System273274Lighter than a full rewrite — use this when reskinning a component that already works but has drifted off-token (hardcoded hex colors, arbitrary px values, `transition: all`). Observed working loop, confirmed across multiple sessions:2752761. **Read the source of truth first** — the token file (this doc's Color Tokens section, or wherever your project defines them) plus the reference primitives the rest of the app already uses (card/badge/button/modal components). Match those; don't invent a parallel style.2772. **Grep the target for hardcoded drift** before editing:278 ```bash279 grep -nE "#[0-9a-fA-F]{3,6}|rgba?\(" <target>.css # raw colors that should be tokens280 grep -nE "[0-9]+px" <target>.css # arbitrary sizes vs your spacing/type scale281 grep -n "transition: all" <target>.css # vague transitions — name the properties282 ```2833. **Rewrite CSS to tokens** — swap raw values for token references, arbitrary px for the spacing/type scale, `transition: all` for explicit named properties. Keep domain-semantic colors (status/category colors that are intentionally not themed) as-is.2844. **Build and verify in a real browser** — a token swap can silently change contrast or layout. Look at an actual screenshot, not bounding-box math or computed geometry.285286## Accessibility287288- Color contrast: 4.5:1 body, 3:1 large text (High Contrast theme = WCAG AAA)289- Interactive targets: min 44x44px on mobile290- Focus indicators: visible ring via `focus-visible:ring-ring/50`291- Reduced motion: `@media (prefers-reduced-motion: reduce)` zeroes all animations292- Status: never color-only — combine with icon/text293294## Anti-Patterns to Avoid (AI-Slop Signals)295296These patterns appear in the majority of AI-generated UIs. Avoid them by default unless there is a specific, intentional reason.297298| Anti-pattern | What it looks like | Better alternative |299|---|---|---|300| **Purple/indigo gradients** | `background: linear-gradient(135deg, #6366F1, #8B5CF6)` as a "hero" | Use your `--accent` token consistently; gradients only for data viz |301| **Inter-by-default** | Loading Inter because it's the AI default, not because it fits | Use fonts with discipline (defined scale, no arbitrary sizes) |302| **Bento grids** | 3x2 card grid where every card has a different height and an icon top-left | Grid layout with consistent card heights; icon choice driven by meaning, not decoration |303| **Glass morphism** | `backdrop-filter: blur(12px)` + `background: rgba(255,255,255,0.1)` + `border: 1px solid rgba(255,255,255,0.2)` | Solid surface elevation using `--bg-elevated`; blur only for modals over complex backgrounds |304| **Spark lines everywhere** | Tiny 40px sparklines added to every metric card to look "data-rich" | Sparklines only when the trend is the insight; use plain numbers when the current value is what matters |305| **Side-tab indicator borders** | Left border on active tab item as the only active state signal | Combine border with background color change; border-only fails on low contrast themes |306| **Identical pill buttons** | All buttons are `border-radius: 999px` regardless of context | Radius should match the component family — table action buttons at `--radius-sm`, modal CTAs at `--radius-md` |307| **Decorative hero illustrations** | 3D robot or abstract illustration as page header | Data visualization or functional illustration only; no illustration if it conveys no information |308| **Shadow stacking** | `box-shadow` on every card + every button + every input | Pick one elevation tier per component type and stick to it |309| **Random animation** | `transition: all 0.3s ease` added to every element | Only transition properties that actually change; explicit property names + durations matched to interaction type |310311## Porting a Showcase Variant to Real Code312313After `/design-showcase` and the user picks a variant, porting it into the real component has a specific failure mode worth calling out.314315**The drift trap**: Worktree agents dispatched via `Agent({ isolation: "worktree" })` branch from a shared reference point, NOT current HEAD. If parallel sessions have been modifying the target file, the worktree agent ports against a stale base — cherry-picking then conflicts.316317**Detection**: before accepting an agent's port commit, check `git log <worktree-base>..HEAD -- <target-file>`. If non-empty, the agent was working from stale context.318319**Resolution pattern**:3201. If the CSS additions are purely additive (new scoped classes) — cherry-pick with `git checkout --ours <target.jsx>` to keep your current JSX and accept ONLY the CSS.3212. Apply the JSX design edits directly with the `Edit` tool against current HEAD.3223. Build, commit, push.323324**Prefer additive edits over reordering**. If the showcase variant spec includes "reorder sections", save that for a separate follow-up — it's much riskier than adding a new pill or strip. Ship the high-signal additions first.325326## Tactical Patterns327328- **Recharts tooltips**: Need THREE style props for dark theme (`contentStyle`, `labelStyle`, `itemStyle`)329- **Recharts bundle**: Split via `manualChunks` in vite.config.js — always keep this config330- **Dropdown in scrollable tables**: `position:fixed` + `getBoundingClientRect()` — `absolute` gets clipped by `overflow-x:auto`331- **localStorage validation**: Cross-reference saved columns against `DEFAULT_COLUMNS` to filter stale entries332- **getDueDateCategory "this week" filter**: must include `late + today + tomorrow + thisWeek` (not just `thisWeek`)333- **ISO date timezone shift**: `new Date('2026-03-20')` → midnight UTC → day before in local timezone. Fix: parse YYYY-MM-DD as `new Date(year, month-1, day)` for local display. Don't round-trip already-ISO strings through `toISOString()`334- **Unstable array/object deps + AbortController = infinite loop**: When a `useEffect` depends on a prop-derived array (e.g. `const ids = idsProp || []`) and the parent passes a fresh literal each render, the effect re-fires every render. If that effect creates an `AbortController` with a cleanup that calls `abort()`, every render aborts the in-flight fetch. Symptom: modal stuck on "Loading..." forever, network panel shows `canceled` requests in a tight loop. `react-hooks/exhaustive-deps` does NOT catch it — the dep is listed correctly, just unstable. Fix: memoize the derived value against a primitive key (e.g. `array.join('|')`) or have the parent wrap in `useMemo`.335- **CSS shorthand vs longhand override**: `overflow: hidden` (shorthand) is NOT reliably overridden by a later `overflow-y: auto` (longhand) in some cascade scenarios. Use the matching shorthand (`overflow: hidden auto`) to override. Also: `overflow-y: auto` is a no-op when the container has `min-height` instead of `height`; `min-height` is ignored on `<td>` elements per CSS spec.336- **Flex scroll chain requires every ancestor to be a flex container**: For `overflow-y: auto` on a deeply nested element to work, every ancestor must be `display: flex; flex-direction: column` with `min-height: 0` (or `height: 0`). One plain block container breaks `flex: 1` on its children — content overflows without constraint.337- **CSS pattern audit before adding styles**: Before adding `@keyframes` or new CSS classes to a large `App.css`, grep existing patterns first to avoid duplicate keyframes and conflicting class names.338339## See Also340341- `/design-showcase` — for exploratory redesigns, run a showcase first. Dispatches a single Opus agent that builds a self-contained HTML page with 3-7 design variants side-by-side. User picks a direction, then implement using the tokens and patterns above.342- `/design-reverse-engineer` — extract design tokens and component patterns from any URL; use when anchoring a new view to a proven reference before building343- `/webapp-testing` — Playwright visual verification after implementing a chosen variant344- `reference-sites.md` (in `.claude/skills/design-reverse-engineer/`) — curated list of high-quality reference sites by domain