---
name: ui-components
description: |
  Generates standardized SmartStack UI components.
  Use this skill when:
  - Creating React page (.tsx) in src/pages/
  - Creating React component in src/components/
  - User asks to create cards, grids, tables, or Kanban
  - Claude detects creation of an entity list with display
  - User mentions "card", "grid", "table", "list", "kanban"
  - User asks for tooltips or infobubbles
  - Creating a page with entity display
  - Managing disabled states with explanatory messages
  - User mentions "dashboard", "chart", "kpi", "analytics", "graph"
  - Creating a dashboard with KPI cards or charts
  Scope: Pages, Components, Cards, Tables, Grids, Kanban boards, Tooltips, Dashboards, Charts
---

# Skill UI Components SmartStack

**Reference:** [_shared.md](../_shared.md) for architecture, i18n

## WHEN THIS SKILL ACTIVATES

| Trigger | Example |
|---------|---------|
| React page/component creation | File in `src/pages/**/*.tsx` or `src/components/**/*.tsx` |
| List creation | "Display products in cards" |
| Keywords | "card", "grid", "table", "kanban", "tooltip" |
| Disabled states | "Disable button with explanatory message" |
| Dashboard/Chart creation | "dashboard", "chart", "kpi", "analytics", "graph", "metrics" |

## Detailed patterns

- For EntityCard (mandatory): [patterns/entity-card.md](patterns/entity-card.md)
- For DataTable: [patterns/data-table.md](patterns/data-table.md)
- For Grid layouts: [patterns/grid-layout.md](patterns/grid-layout.md)
- For Kanban boards: [patterns/kanban.md](patterns/kanban.md)
- For Dashboard & Charts: [patterns/dashboard-chart.md](patterns/dashboard-chart.md)
- For styling rules: [style-guide.md](style-guide.md)
- For accessibility: [accessibility.md](accessibility.md)

## Component Availability

> **Know which components to import vs generate.** Using a non-existent import causes the LLM to fall back to raw HTML.

### Shared Components (import from SmartStack package)

| Component | Import | Use for |
|-----------|--------|---------|
| `EntityCard` | `@/components/ui/EntityCard` | Entity cards with avatar, badges, actions |
| `DataTable` | `@/components/ui/DataTable` | Sortable, searchable, paginated tables |
| `PageLoader` | `@/components/ui/PageLoader` | Suspense fallback spinner |
| `Tooltip` | `@/components/ui/Tooltip` | Tooltips with variants (error, warning, etc.) |
| `DocToggleButton` | `@/components/docs/DocToggleButton` | Documentation toggle in page headers |
| `Loader2` | `lucide-react` | Inline loading spinner icon |

### Generate Locally (inline pattern — do NOT import from shared)

| Component | Generate where | Pattern reference |
|-----------|---------------|-------------------|
| `StatCard` | Inline in dashboard page | [patterns/dashboard-chart.md](patterns/dashboard-chart.md) |
| `ChartCard` | Inline in dashboard page | [patterns/dashboard-chart.md](patterns/dashboard-chart.md) |
| `EntityLookup` | `@/components/ui/EntityLookup` | smartstack-frontend.md section 6 |
| `PeriodFilter` | Inline in dashboard page | [patterns/dashboard-chart.md](patterns/dashboard-chart.md) |

---

## CORE RULES (always apply)

### EntityCard is Mandatory
**ALWAYS use `EntityCard` for entity cards. NEVER use custom cards with divs.**
```typescript
import { EntityCard, ProviderCard, TemplateCard } from '@/components/ui/EntityCard';
```

### CSS Variables (NEVER hardcode colors)
**NEVER use hardcoded Tailwind colors.** ALWAYS use CSS variables for theme compliance.
```tsx
// CORRECT
<span className="bg-[var(--info-bg)] text-[var(--info-text)]">Badge</span>

// WRONG
<span className="bg-blue-500/10 text-blue-500">Badge</span>
```

### Responsive Grid
```tsx
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
  {items.map(item => <EntityCard key={item.id} {...mapToCardProps(item)} />)}
</div>
```

### Ghost Buttons: NO BORDER
```tsx
// Ghost action (NO BORDER!)
<button className="px-4 py-2 rounded-[var(--radius-button)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]">
  Action
</button>
```

### TypeScript Interfaces
**ALWAYS define explicit TypeScript interfaces for component props.** Never use `any`.

### Semantic HTML
**ALWAYS use `<button>` for click actions, `<a>` or `<Link>` for navigation.** Never use `<div role="button">`.

### Performance
- `memo()` for list item components
- `useCallback` for event handlers passed as props
- `useMemo` for expensive computations
- Unique `id` for list keys (NEVER array index)

## ABSOLUTE RULES

| DO | DON'T |
|----|-------|
| CSS variables for ALL colors | Hardcoded Tailwind colors (`bg-blue-500`) |
| `--success-dot`, `--warning-dot`, `--error-dot` for action buttons | `bg-emerald-500`, `bg-amber-500`, `bg-red-500` |
| Ghost buttons WITHOUT border | `border border-[var(--border-color)]` on ghost |
| EntityCard for entities | Custom cards with manual divs |
| Responsive grid 1->2->3->4 | Fixed non-responsive grid |
| h-full + flex-1 + mt-auto | Unaligned buttons in grid |
| Empty, loading, and error states | Native HTML tooltip |
| Explicit TypeScript interfaces | `any` or implicit types |
| `<button>` for click actions | `<div role="button">` |
| `memo()` for list item components | Unmemoized components in loops |
| `useCallback` for event handlers | Inline arrow functions in JSX |
| Unique `id` for list keys | Array index as key |

---

## Complete Page Templates

> **Use these templates as the starting point for every page.** They ensure CSS variables, i18n, responsive design, and correct component usage.

### List Page Template

```tsx
import { useState, useCallback, useEffect, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Loader2, AlertCircle } from 'lucide-react';
import { DocToggleButton } from '@/components/docs/DocToggleButton';
import { DataTable } from '@/components/ui/DataTable';

export function EntityListPage() {
  const { t } = useTranslation(['{module}']);
  const navigate = useNavigate();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [data, setData] = useState<Entity[]>([]);

  const loadData = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const result = await entityApi.getAll();
      setData(result?.items ?? []);
    } catch (err: any) {
      setError(err.message || t('{module}:errors.loadFailed', 'Failed to load data'));
    } finally {
      setLoading(false);
    }
  }, [t]);

  useEffect(() => { loadData(); }, [loadData]);

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <div className="text-center">
          <AlertCircle className="h-8 w-8 text-[var(--error-text)] mx-auto mb-2" />
          <p className="text-[var(--text-secondary)]">{error}</p>
          <button onClick={loadData} className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)]">
            {t('common:actions.retry', 'Retry')}
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold text-[var(--text-primary)]">
          {t('{module}:title', 'Module Title')}
        </h1>
        <div className="flex items-center gap-2">
          <DocToggleButton />
          <button onClick={() => navigate('create')} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)]">
            {t('{module}:actions.create', 'Create')}
          </button>
        </div>
      </div>

      {data.length === 0 ? (
        <div className="text-center py-12 text-[var(--text-secondary)]">
          {t('{module}:empty', 'No items found.')}
        </div>
      ) : (
        <DataTable
          data={data}
          columns={[
            { key: 'name', label: t('{module}:columns.name', 'Name'), sortable: true },
            { key: 'code', label: t('{module}:columns.code', 'Code'), sortable: true },
            { key: 'status', label: t('{module}:columns.status', 'Status'),
              render: (item) => (
                <span className={`px-2 py-0.5 rounded text-xs ${
                  item.isActive
                    ? 'bg-[var(--success-bg)] text-[var(--success-text)]'
                    : 'bg-[var(--error-bg)] text-[var(--error-text)]'
                }`}>
                  {item.isActive ? t('common:status.active', 'Active') : t('common:status.inactive', 'Inactive')}
                </span>
              )
            },
          ]}
          searchable
          pagination={{ pageSize: 10 }}
          onRowClick={(item) => navigate(`${item.id}`)}
        />
      )}
    </div>
  );
}
```

### Form Page Template (Create)

```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
// For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';

export function EntityCreatePage() {
  const { t } = useTranslation(['{module}']);
  const navigate = useNavigate();
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [formData, setFormData] = useState<CreateEntityDto>({ name: '' });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      setSubmitting(true);
      setError(null);
      await entityApi.create(formData);
      navigate(-1);
    } catch (err: any) {
      setError(err.message || t('{module}:errors.createFailed', 'Creation failed'));
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <div className="space-y-6">
      <button onClick={() => navigate(-1)} className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
        <ArrowLeft className="w-4 h-4" />
        {t('common:actions.back', 'Back')}
      </button>

      <h1 className="text-2xl font-bold text-[var(--text-primary)]">
        {t('{module}:actions.create', 'Create {Entity}')}
      </h1>

      {error && (
        <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
          <span className="text-[var(--error-text)]">{error}</span>
        </div>
      )}

      <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
        {/* Text field */}
        <div>
          <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
            {t('{module}:form.name', 'Name')}
          </label>
          <input
            type="text"
            value={formData.name}
            onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
            className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] rounded-[var(--radius-input)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)]"
            required
          />
        </div>

        {/* FK Guid field — ALWAYS EntityLookup, NEVER <select> */}
        {/* <EntityLookup
          apiEndpoint="/api/{app}/{module}/departments"
          value={formData.departmentId}
          onChange={(id) => setFormData(prev => ({ ...prev, departmentId: id }))}
          mapOption={(dept) => ({ label: dept.name, value: dept.id })}
          placeholder={t('{module}:form.selectDepartment', 'Select...')}
        /> */}

        <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
          <button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]">
            {t('common:actions.cancel', 'Cancel')}
          </button>
          <button type="submit" disabled={submitting} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] hover:bg-[var(--color-accent-600)] disabled:opacity-50">
            {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
          </button>
        </div>
      </form>
    </div>
  );
}
```

### Dashboard Page Template

```tsx
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, AlertCircle, TrendingUp, TrendingDown, Users, Package } from 'lucide-react';
import { DocToggleButton } from '@/components/docs/DocToggleButton';

// StatCard — generated locally per dashboard (NOT a shared import)
interface StatCardProps {
  title: string;
  value: string | number;
  icon: React.ReactNode;
  trend?: { value: number; label: string };
}

const StatCard = memo(function StatCard({ title, value, icon, trend }: StatCardProps) {
  return (
    <div className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6">
      <div className="flex items-center justify-between">
        <div>
          <p className="text-sm text-[var(--text-secondary)]">{title}</p>
          <p className="text-2xl font-bold text-[var(--text-primary)] mt-1">{value}</p>
          {trend && (
            <div className={`flex items-center gap-1 mt-2 text-sm ${
              trend.value >= 0 ? 'text-[var(--success-text)]' : 'text-[var(--error-text)]'
            }`}>
              {trend.value >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
              <span>{Math.abs(trend.value)}% {trend.label}</span>
            </div>
          )}
        </div>
        <div className="p-3 bg-[var(--color-accent-50)] rounded-[var(--radius-card)]">
          {icon}
        </div>
      </div>
    </div>
  );
});

export function DashboardPage() {
  const { t } = useTranslation(['{module}']);
  const [loading, setLoading] = useState(true);
  const [stats, setStats] = useState<DashboardStats | null>(null);

  const loadStats = useCallback(async () => {
    try {
      setLoading(true);
      const result = await dashboardApi.getStats();
      setStats(result);
    } catch {
      // Handle error
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { loadStats(); }, [loadStats]);

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold text-[var(--text-primary)]">
          {t('{module}:dashboard.title', 'Dashboard')}
        </h1>
        <DocToggleButton />
      </div>

      {/* KPI Grid — responsive 1→2→4 */}
      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
        <StatCard
          title={t('{module}:dashboard.totalUsers', 'Total Users')}
          value={stats?.totalUsers ?? 0}
          icon={<Users className="w-6 h-6 text-[var(--color-accent-500)]" />}
          trend={{ value: 12, label: t('{module}:dashboard.vsLastMonth', 'vs last month') }}
        />
        {/* Add more StatCards... */}
      </div>
    </div>
  );
}
```

---

### Error States with Retry

**ALWAYS provide user-friendly error states with retry capability.**

```tsx
{error && (
  <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
    <div className="flex items-center">
      <AlertCircle className="h-5 w-5 text-[var(--error-text)]" />
      <span className="ml-2 text-[var(--error-text)]">{error}</span>
    </div>
    <button
      onClick={() => refetch()}
      className="mt-2 text-sm text-[var(--error-text)] hover:opacity-80 underline"
    >
      {t('common:actions.retry')}
    </button>
  </div>
)}
```

**Rules:**
- Use semantic error CSS variables (`--error-bg`, `--error-border`, `--error-text`)
- Always provide retry button for recoverable errors
- Use Lucide `AlertCircle` icon (not inline SVG)
- Keep error messages user-friendly (not technical stack traces)

<success_criteria>
- Component created following SmartStack UI patterns (CSS variables, Lucide icons, i18n)
- Correct file placement in src/pages/ or src/components/
- Loading, error, and empty states handled
- Responsive layout with SmartStack grid system
</success_criteria>
