---
name: visual-consistency
description: >
  Enforce visual consistency across all UI in this prototype. Before implementing
  any UI element, scan for similar existing elements and match their exact
  className, spacing, and component pattern. USE FOR: any new page, section,
  card, form, table, badge, or layout element. DO NOT USE FOR: design token
  definitions (see design-token-usage skill) or unit/sizing rules (see
  responsive-layout skill).
applyTo: "src/**/*.{tsx,jsx}"
---

# Visual Consistency — Skill Instructions

## Core rule

Before writing a new UI element, search the codebase for the most similar existing element. Copy its `className` exactly. Only deviate when the new element genuinely differs in purpose.

```
grep -r "space-y-6" src/features/      # find page wrappers
grep -r "rounded-radius-lg" src/       # find card containers
grep -r "text-muted-foreground" src/   # find label/secondary text patterns
```

If you find an inconsistency between two existing elements that serve the same role, prefer the one that uses design tokens (`text-heading-6` not `text-xl font-heading font-semibold`).

---

## Form control consistency rules

Input, Textarea, and Dropdown items must all use the **same text token, radius token, and placeholder opacity**. These are enforced in the component files — check when using native elements.

### Input (`<Input>`)

Canonical className pattern (from component):

- Text: `text-body-3` (20px CS ChatThai)
- Radius: `rounded-radius-lg` (never bare `rounded-lg`)
- Placeholder: `placeholder:text-muted-foreground/60` (60% opacity — creates visual hierarchy)
- Background: `bg-background`
- Height: `h-10`

### Textarea (`<Textarea>`)

Must match Input exactly — same text, radius, placeholder opacity, background, and disabled state:

- Text: `text-body-3` ← was `text-base md:text-sm` (wrong)
- Radius: `rounded-radius-lg` ← was `rounded-lg` (wrong)
- Placeholder: `placeholder:text-muted-foreground/60` ← was full opacity (wrong)
- Background: `bg-background` ← was `bg-transparent` (wrong)

### Dropdown items (`<DropdownMenuItem>`, `<DropdownMenuRadioItem>`, etc.)

Must match Input text size — they appear in filter bars next to inputs:

- Menu items: `text-body-3`
- Group labels: `text-body-caption`
- Keyboard shortcuts: `text-body-disclaimer`

**Never** use raw Tailwind `text-sm`, `text-xs`, or `text-base` in these components.

### Native `<input>` elements (e.g. header search)

When you must write a native `<input>` instead of `<Input>`:

- Use `text-body-3` for text
- Use `placeholder:text-muted-foreground/60` — not full opacity
- Use `rounded-radius-lg` (or `rounded-full` only for header pill search)
- Match border, ring, and focus styles from the `<Input>` component

---

## Canonical patterns

### Page structure

Every page that lives inside `AppLayout` must follow this structure:

```tsx
// 1. Wrapper
<div className="space-y-6">
  // 2a. Simple header (title + subtitle only)
  <div>
    <h1 className="text-heading-6 text-foreground">หัวข้อหน้า</h1>
    <p className="text-body-2 text-muted-foreground mt-1">คำอธิบายสั้น ๆ</p>
  </div>
  // 2b. Header with action button (right-aligned)
  <div className="flex items-center justify-between">
    <div>
      <h1 className="text-heading-6 text-foreground">หัวข้อหน้า</h1>
      <p className="text-body-2 text-muted-foreground mt-1">คำอธิบาย</p>
    </div>
    <Button>เพิ่มรายการ</Button>
  </div>
  // 3. Content sections (cards, tables, grids…)
</div>
```

**Back navigation** (detail pages — above the title):

```tsx
<Link
  to={ROUTES.SOME_LIST}
  className="inline-flex items-center gap-1.5 text-body-3 text-muted-foreground hover:text-foreground transition-colors"
>
  <ArrowLeft size={16} />
  กลับไปรายการ
</Link>
```

---

### Cards

All card surfaces use the same border + radius pattern. Only the padding varies by card type.

| Card type                          | className                                                                   |
| ---------------------------------- | --------------------------------------------------------------------------- |
| Standard content card              | `border border-border rounded-radius-lg p-5`                                |
| Large profile / form card          | `border border-border rounded-radius-lg p-6`                                |
| Table container (no inner padding) | `border border-border rounded-radius-lg overflow-hidden`                    |
| KPI / metric card                  | `bg-card border border-border rounded-radius-lg p-5`                        |
| Subtle item row inside a card      | `flex items-start gap-3 p-3 bg-muted/50 rounded-radius-md`                  |
| Mini dept / count chip             | `flex items-center justify-between px-3 py-2 bg-muted/50 rounded-radius-md` |

**Card section title (h3 inside a card):**

```tsx
<h3 className="text-body-accent-2 text-foreground mb-4 flex items-center gap-2">
  <Icon size={14} className="text-primary" />
  ชื่อหมวด
</h3>
```

---

### Filter bar (list pages)

```tsx
<div className="flex flex-wrap items-center gap-3">
  {/* Search */}
  <div className="relative flex-1 max-w-sm">
    <Search
      size={16}
      className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
    />
    <Input className="pl-9" placeholder="ค้นหา..." />
  </div>

  {/* Filter dropdowns */}
  <DropdownMenu>
    <DropdownMenuTrigger
      className={cn(buttonVariants({ variant: "outline" }), "gap-2")}
    >
      <Filter size={14} />
      ตัวกรอง
      <ChevronDown size={14} />
    </DropdownMenuTrigger>
    ...
  </DropdownMenu>

  {/* Right-side action */}
  <Button className="ml-auto gap-2">
    <Plus size={16} />
    เพิ่มรายการ
  </Button>
</div>
```

---

### Key-value data display

Used inside detail cards to show field label + value pairs.

```tsx
// Field grid container
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
  {/* Each field */}
  <div>
    <p className="text-body-caption text-muted-foreground mb-0.5">ชื่อฟิลด์</p>
    <p className="text-body-3 text-foreground">{value ?? "—"}</p>
  </div>
</div>
```

Rules:

- Use `"—"` (em-dash) as the null/empty fallback, not `"-"` or `"N/A"`.
- Label is always `text-body-caption text-muted-foreground`.
- Value is always `text-body-3 text-foreground`.
- Monospace values (codes, IDs): add `font-mono` to the value `<p>`.

---

### Inline code / ID chips

```tsx
<span className="font-mono text-body-caption text-muted-foreground bg-muted px-2 py-0.5 rounded">
  {employee.code}
</span>
```

---

### Status badges

Use the consistent inline badge for active/inactive status. Do NOT use the `<Badge>` component for status — status uses semantic color pairs.

```tsx
// Active / positive
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success">
  ปฏิบัติงาน
</span>

// Inactive / neutral
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
  ไม่ปฏิบัติงาน
</span>

// Warning
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning">
  รอดำเนินการ
</span>

// Destructive
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive">
  ยกเลิก
</span>
```

Use `<Badge variant="secondary">` for **classification labels** (employee type, civil service track, languages, specializations) — not for live status.

Use `<Badge>` (default/primary) for **level / tier labels** (position level C1–C10, etc.).

---

### KPI / metric display

```tsx
// Large metric value
<p className="text-heading-4 text-foreground">{value}</p>
// (or text-primary for the highlighted metric)

// Metric label
<p className="text-body-caption text-muted-foreground">{label}</p>

// Secondary sub-text below metric
<p className="text-body-caption text-muted-foreground mt-1">{sub}</p>
```

Icon container in KPI card:

```tsx
<div className="w-9 h-9 rounded-radius-md bg-muted flex items-center justify-center">
  <Icon size={18} className="text-primary" />
</div>
```

---

### Grids

Standard grid breakpoints for this desktop-only app:

| Use case               | className                          |
| ---------------------- | ---------------------------------- |
| KPI row (4 metrics)    | `grid grid-cols-4 gap-4`           |
| 3-column content       | `grid grid-cols-3 gap-4`           |
| 2-column content       | `grid grid-cols-2 gap-4`           |
| Detail field grid      | `grid grid-cols-2 gap-x-6 gap-y-3` |
| Single-column (narrow) | `grid grid-cols-1 gap-4 max-w-2xl` |

Do NOT use `sm:grid-cols-*` or `md:grid-cols-*` — desktop only.

---

### Icons

| Context                  | Size                       | Color                          |
| ------------------------ | -------------------------- | ------------------------------ |
| Card section heading     | `size={14}`                | `text-primary`                 |
| Filter bar / button icon | `size={14}` or `size={16}` | inherits button foreground     |
| Back navigation          | `size={16}`                | inherits link foreground       |
| KPI card                 | `size={18}`                | `text-primary` or status color |
| Table action             | `size={16}`                | `text-muted-foreground`        |
| Empty state illustration | `size={40}` or `size={48}` | `text-muted-foreground`        |

---

### Empty states

```tsx
<div className="flex flex-col items-center justify-center py-16 text-center">
  <Icon size={40} className="text-muted-foreground mb-3" />
  <p className="text-body-accent-3 text-foreground">ไม่พบข้อมูล</p>
  <p className="text-body-3 text-muted-foreground mt-1">คำอธิบายเพิ่มเติม</p>
</div>
```

Or use `<Empty>` component if available.

---

### Spacing consistency rules

- Sections within a page: `space-y-6` on the page wrapper.
- Items within a card: `space-y-4` or `space-y-3`.
- Icon + text pairs: `gap-1.5` (navigation), `gap-2` (buttons/headings), `gap-2.5` (avatar + name).
- Badge groups: `flex flex-wrap gap-2` (profile), `flex flex-wrap gap-1.5` (skill tags).

---

## Process — before writing new UI

1. **Identify the element type** — is it a page header? A card? A filter bar? A detail field?
2. **Find an existing example** — search for a similar element in `src/features/*/pages/`.
3. **Copy the `className` exactly** — do not paraphrase or restructure.
4. **Adjust only what differs** — content, data bindings, conditional logic. Not styling.
5. **If no example exists**, build from the canonical patterns in this skill, then add a note so future elements can reference yours.

---

## Anti-patterns — do NOT do these

| Anti-pattern                                      | Correct                                      |
| ------------------------------------------------- | -------------------------------------------- |
| `text-xl font-heading font-semibold`              | `text-heading-6 text-foreground`             |
| `text-sm text-muted-foreground` (page subtitle)   | `text-body-3 text-muted-foreground`          |
| `text-xs text-muted-foreground` (field label)     | `text-body-caption text-muted-foreground`    |
| `text-sm text-foreground` (field value)           | `text-body-3 text-foreground`                |
| `text-sm font-medium` (body accent)               | `text-body-accent-3 text-foreground`         |
| `text-sm font-heading font-semibold` (card h3)    | `text-body-accent-3 text-foreground`         |
| `text-lg/xl font-heading font-semibold` (card h2) | `text-heading-6 text-foreground`             |
| `text-2xl font-heading font-semibold` (KPI)       | `text-heading-4 text-foreground`             |
| Mixing `p-4` and `p-5` on cards of the same type  | Pick one — `p-5` for content cards           |
| `<Badge>` for status (active/inactive)            | Inline `span` with semantic color            |
| `<span>` with custom colors for classification    | `<Badge variant="secondary">`                |
| `rounded-lg` directly                             | `rounded-radius-lg` (uses token)             |
| `rounded-md` directly                             | `rounded-radius-md` (uses token)             |
| Icon size `size={12}` in card headings            | `size={14}`                                  |
| `gap-1` between icon and label text               | `gap-1.5` (navigation) or `gap-2` (headings) |
