towerops/docs/references/ux-frontend-best-practices.md
Graham McIntie 1590f78bdc fix: input validation, SSRF, API hardening, and cookie security
- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia)
- Jason.decode! → Jason.decode with error handling for untrusted input
- inspect() leak: replace with generic error messages, log details server-side
- SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes
- SSRF validation added to HTTP monitoring executor and integration credentials
- GraphQL complexity limits: always applied, not just in prod
- GraphQL introspection: also check GET query params, not just body
- Stripe webhook: explicit nil/empty checks for signature and body
- Cookie security: secure flag for session (prod), http_only+secure for remember_me
- Honeybadger API key: read from env var with fallback
- String.to_integer → Integer.parse with fallback for URL params
- String.to_atom → whitelist map for HTTP methods
- Gaiia webhook: remove secret_len and expected signature from log
- Admin API: add rate limiting pipeline
- to_atom_keys: per-key fallback instead of all-or-nothing rescue
2026-03-14 14:48:59 -05:00

1992 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Modern UX & Front-End Design Best Practices
> Reference guide for B2B SaaS dashboard applications, with emphasis on ISP/network monitoring tools.
> All examples use Tailwind CSS. Phoenix LiveView patterns included where relevant.
---
## Table of Contents
1. [Design Systems](#1-design-systems)
2. [Layout Patterns](#2-layout-patterns)
3. [Data Display](#3-data-display)
4. [Forms & Input](#4-forms--input)
5. [Navigation](#5-navigation)
6. [Accessibility](#6-accessibility)
7. [Animation & Transitions](#7-animation--transitions)
8. [Phoenix LiveView Specific](#8-phoenix-liveview-specific)
9. [Color & Typography](#9-color--typography)
10. [Anti-Patterns](#10-anti-patterns)
---
## 1. Design Systems
### Component Hierarchy
Organize components in three tiers:
| Tier | Examples | Characteristics |
|------|----------|-----------------|
| **Primitives** | Button, Badge, Input, Label | Stateless, no business logic, maximum reuse |
| **Composites** | Card, DataTable, FormGroup, Modal | Combine primitives, may have local state |
| **Features** | DevicePanel, AlertFeed, BandwidthChart | Domain-specific, connected to data |
```
components/
├── ui/ # Primitives — button, badge, input, tooltip
├── composite/ # Composites — card, data_table, form_group
└── features/ # Features — device_panel, alert_feed
```
### Design Tokens
Define tokens in `tailwind.config.js` — never use raw hex in templates:
```js
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
// Semantic tokens
surface: {
DEFAULT: 'var(--color-surface)', // white / gray-900
raised: 'var(--color-surface-raised)', // gray-50 / gray-800
overlay: 'var(--color-surface-overlay)', // white / gray-800
},
border: {
DEFAULT: 'var(--color-border)', // gray-200 / gray-700
strong: 'var(--color-border-strong)', // gray-300 / gray-600
},
status: {
healthy: '#10b981', // emerald-500
warning: '#f59e0b', // amber-500
critical: '#ef4444', // red-500
unknown: '#6b7280', // gray-500
maint: '#3b82f6', // blue-500
},
},
spacing: {
'sidebar': '16rem', // 256px collapsed
'sidebar-sm': '4rem', // 64px collapsed
'topbar': '4rem', // 64px
},
fontSize: {
'kpi': ['2rem', { lineHeight: '2.5rem', fontWeight: '700' }],
'metric': ['1.5rem', { lineHeight: '2rem', fontWeight: '600' }],
},
},
},
}
```
### Tailwind Best Practices
**Avoid class soup — extract components, not utilities:**
```html
<!-- ❌ Class soup — repeated everywhere -->
<button class="inline-flex items-center justify-center rounded-md bg-indigo-600
px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
Save
</button>
<!-- ✅ Extract to a component (Phoenix) -->
<.button variant="primary" size="md">Save</.button>
```
**The component function:**
```elixir
# core_components.ex
attr :variant, :string, default: "primary", values: ~w(primary secondary ghost danger)
attr :size, :string, default: "md", values: ~w(sm md lg)
attr :disabled, :boolean, default: false
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
class={[
# Base
"inline-flex items-center justify-center rounded-md font-medium",
"shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
"disabled:opacity-50 disabled:cursor-not-allowed",
# Size
@size == "sm" && "px-3 py-1.5 text-xs",
@size == "md" && "px-4 py-2 text-sm",
@size == "lg" && "px-6 py-3 text-base",
# Variant
@variant == "primary" && "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500",
@variant == "secondary" && "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-indigo-500",
@variant == "ghost" && "bg-transparent text-gray-600 hover:bg-gray-100 hover:text-gray-900 shadow-none",
@variant == "danger" && "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
]}
disabled={@disabled}
{@rest}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
```
### Dark Mode
Use Tailwind's `dark:` variant with CSS custom properties for seamless theming:
```css
/* app.css */
:root {
--color-surface: theme('colors.white');
--color-surface-raised: theme('colors.gray.50');
--color-surface-overlay: theme('colors.white');
--color-border: theme('colors.gray.200');
--color-border-strong: theme('colors.gray.300');
--color-text-primary: theme('colors.gray.900');
--color-text-secondary: theme('colors.gray.500');
}
.dark {
--color-surface: theme('colors.gray.900');
--color-surface-raised: theme('colors.gray.800');
--color-surface-overlay: theme('colors.gray.800');
--color-border: theme('colors.gray.700');
--color-border-strong: theme('colors.gray.600');
--color-text-primary: theme('colors.gray.100');
--color-text-secondary: theme('colors.gray.400');
}
```
**Toggle strategy:**
```js
// Respect system preference, allow manual override
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
```
---
## 2. Layout Patterns
### App Shell
The canonical B2B SaaS layout: fixed sidebar + top bar + scrollable content area.
```html
<div class="flex h-screen bg-surface text-[var(--color-text-primary)]">
<!-- Sidebar -->
<aside class="hidden lg:flex lg:flex-col lg:w-sidebar border-r border-border
bg-surface-raised flex-shrink-0">
<!-- Logo -->
<div class="h-topbar flex items-center px-6 border-b border-border">
<img src="/logo.svg" class="h-8 w-auto" alt="TowerOps" />
</div>
<!-- Nav -->
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<!-- Active item -->
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2
bg-indigo-50 text-indigo-700 font-medium
dark:bg-indigo-500/10 dark:text-indigo-400">
<svg class="h-5 w-5 shrink-0"><!-- icon --></svg>
<span>Dashboard</span>
</a>
<!-- Inactive item -->
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2
text-gray-600 hover:bg-gray-100 hover:text-gray-900
dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200">
<svg class="h-5 w-5 shrink-0"><!-- icon --></svg>
<span>Devices</span>
</a>
</nav>
<!-- Footer / user -->
<div class="border-t border-border p-4">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center
text-sm font-medium text-indigo-700">GM</div>
<div class="text-sm">
<p class="font-medium">Graham</p>
<p class="text-[var(--color-text-secondary)] text-xs">Admin</p>
</div>
</div>
</div>
</aside>
<!-- Main content area -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Top bar -->
<header class="h-topbar flex items-center justify-between px-6
border-b border-border bg-surface flex-shrink-0">
<!-- Mobile menu button -->
<button class="lg:hidden -ml-2 p-2 rounded-md text-gray-500 hover:text-gray-700">
<svg class="h-6 w-6"><!-- hamburger --></svg>
</button>
<!-- Breadcrumbs / page title -->
<div class="flex items-center gap-2 text-sm">
<span class="text-[var(--color-text-secondary)]">Network</span>
<span class="text-[var(--color-text-secondary)]">/</span>
<span class="font-medium">Devices</span>
</div>
<!-- Right actions -->
<div class="flex items-center gap-4">
<!-- Search trigger -->
<button class="flex items-center gap-2 rounded-lg border border-border
px-3 py-1.5 text-sm text-[var(--color-text-secondary)]
hover:bg-gray-50 dark:hover:bg-gray-800">
<svg class="h-4 w-4"><!-- search icon --></svg>
<span>Search...</span>
<kbd class="hidden sm:inline-flex items-center rounded border border-border
px-1.5 text-xs font-mono text-[var(--color-text-secondary)]">⌘K</kbd>
</button>
<!-- Notifications -->
<button class="relative p-2 rounded-md text-gray-500 hover:text-gray-700">
<svg class="h-5 w-5"><!-- bell --></svg>
<span class="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500"></span>
</button>
</div>
</header>
<!-- Scrollable content -->
<main class="flex-1 overflow-y-auto p-6">
<!-- Page content goes here -->
</main>
</div>
</div>
```
### Collapsible Sidebar
```html
<!-- Sidebar with collapse state -->
<aside
class="flex flex-col border-r border-border bg-surface-raised flex-shrink-0
transition-[width] duration-200 ease-in-out"
:class="collapsed ? 'w-sidebar-sm' : 'w-sidebar'"
>
<!-- Nav items adapt -->
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2">
<svg class="h-5 w-5 shrink-0"><!-- icon --></svg>
<span x-show="!collapsed" x-transition class="truncate">Dashboard</span>
</a>
<!-- Collapse toggle at bottom -->
<button
@click="collapsed = !collapsed"
class="m-3 p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<svg class="h-5 w-5 transition-transform" :class="collapsed && 'rotate-180'">
<!-- chevron-left -->
</svg>
</button>
</aside>
```
### Mobile Sidebar (Slide-over)
```html
<!-- Mobile overlay -->
<div x-show="mobileMenuOpen" class="lg:hidden fixed inset-0 z-40 flex">
<!-- Backdrop -->
<div
x-show="mobileMenuOpen"
x-transition:enter="transition-opacity duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="mobileMenuOpen = false"
class="fixed inset-0 bg-black/50"
></div>
<!-- Panel -->
<aside
x-show="mobileMenuOpen"
x-transition:enter="transition-transform duration-300"
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform duration-300"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="relative w-sidebar bg-surface-raised flex flex-col"
>
<!-- Same nav content as desktop sidebar -->
</aside>
</div>
```
### Dashboard Grid
Use CSS Grid with responsive breakpoints for KPI cards and widgets:
```html
<!-- KPI row -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="rounded-lg border border-border bg-surface p-5">
<p class="text-sm text-[var(--color-text-secondary)]">Total Devices</p>
<p class="mt-1 text-kpi">1,247</p>
<p class="mt-1 flex items-center gap-1 text-xs text-emerald-600">
<svg class="h-3 w-3"><!-- arrow-up --></svg>
+12 this week
</p>
</div>
<!-- ... more cards -->
</div>
<!-- Widget grid — mixed sizes -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Large chart — spans 2 columns -->
<div class="lg:col-span-2 rounded-lg border border-border bg-surface p-5">
<h3 class="text-sm font-medium mb-4">Bandwidth Usage (24h)</h3>
<div class="h-64"><!-- chart --></div>
</div>
<!-- Side panel -->
<div class="rounded-lg border border-border bg-surface p-5">
<h3 class="text-sm font-medium mb-4">Recent Alerts</h3>
<!-- alert list -->
</div>
</div>
```
### Card Component
```html
<!-- Standard card -->
<div class="rounded-lg border border-border bg-surface overflow-hidden">
<!-- Optional header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 class="text-sm font-medium">Device Health</h3>
<button class="text-xs text-indigo-600 hover:text-indigo-700 font-medium">View All</button>
</div>
<!-- Body -->
<div class="p-5">
<!-- content -->
</div>
<!-- Optional footer -->
<div class="px-5 py-3 bg-surface-raised border-t border-border">
<p class="text-xs text-[var(--color-text-secondary)]">Last updated 2 min ago</p>
</div>
</div>
```
### Skeleton Loading
```html
<!-- Skeleton card -->
<div class="rounded-lg border border-border bg-surface p-5 animate-pulse">
<div class="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-3"></div>
<div class="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
<div class="h-2 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<!-- Skeleton table rows -->
<div class="space-y-3 animate-pulse">
<div class="flex gap-4">
<div class="h-4 w-1/4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 w-1/3 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 w-1/6 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 w-1/4 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<!-- repeat 5-8 times -->
</div>
```
**Skeleton shimmer effect (optional CSS):**
```css
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-shimmer {
background: linear-gradient(90deg,
theme('colors.gray.200') 25%,
theme('colors.gray.100') 50%,
theme('colors.gray.200') 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.dark .skeleton-shimmer {
background: linear-gradient(90deg,
theme('colors.gray.700') 25%,
theme('colors.gray.600') 50%,
theme('colors.gray.700') 75%
);
}
```
---
## 3. Data Display
### Tables
Tables are the backbone of network monitoring UIs. Get them right.
```html
<div class="rounded-lg border border-border bg-surface overflow-hidden">
<!-- Table toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center
justify-between gap-3 px-5 py-4 border-b border-border">
<!-- Search -->
<div class="relative w-full sm:w-72">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4
text-[var(--color-text-secondary)]"><!-- search --></svg>
<input
type="search"
placeholder="Search devices..."
class="w-full rounded-md border border-border bg-surface pl-9 pr-3 py-1.5
text-sm placeholder:text-[var(--color-text-secondary)]
focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
<!-- Filters + bulk actions -->
<div class="flex items-center gap-2">
<select class="rounded-md border border-border bg-surface px-3 py-1.5
text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500">
<option>All Status</option>
<option>Online</option>
<option>Offline</option>
<option>Warning</option>
</select>
<button class="rounded-md border border-border px-3 py-1.5 text-sm
hover:bg-gray-50 dark:hover:bg-gray-800">
Filters
<span class="ml-1 inline-flex items-center justify-center h-5 w-5
rounded-full bg-indigo-100 text-indigo-700 text-xs font-medium
dark:bg-indigo-500/20 dark:text-indigo-400">2</span>
</button>
</div>
</div>
<!-- Bulk action bar (shown when rows selected) -->
<div class="hidden flex items-center gap-3 px-5 py-2.5 bg-indigo-50
dark:bg-indigo-500/10 border-b border-indigo-100 dark:border-indigo-500/20">
<span class="text-sm font-medium text-indigo-700 dark:text-indigo-400">
3 selected
</span>
<button class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">Reboot</button>
<button class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">Export</button>
<button class="text-sm text-red-600 hover:text-red-800 font-medium">Delete</button>
</div>
<!-- The table -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border bg-surface-raised text-left">
<th class="px-5 py-3 w-10">
<input type="checkbox" class="rounded border-gray-300 text-indigo-600
focus:ring-indigo-500" />
</th>
<!-- Sortable column -->
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]
cursor-pointer hover:text-[var(--color-text-primary)] select-none">
<div class="flex items-center gap-1">
Hostname
<svg class="h-4 w-4"><!-- sort indicator --></svg>
</div>
</th>
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]">IP Address</th>
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]">Status</th>
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]">Uptime</th>
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]">Last Seen</th>
<th class="px-5 py-3 w-10"></th> <!-- actions column -->
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-5 py-3">
<input type="checkbox" class="rounded border-gray-300 text-indigo-600" />
</td>
<td class="px-5 py-3 font-medium">core-router-01</td>
<td class="px-5 py-3 font-mono text-xs">10.0.1.1</td>
<td class="px-5 py-3">
<span class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5
text-xs font-medium bg-emerald-50 text-emerald-700
dark:bg-emerald-500/10 dark:text-emerald-400">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
Online
</span>
</td>
<td class="px-5 py-3">142d 7h</td>
<td class="px-5 py-3 text-[var(--color-text-secondary)]">2 min ago</td>
<td class="px-5 py-3">
<button class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="h-4 w-4 text-gray-400"><!-- ellipsis --></svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between px-5 py-3 border-t border-border">
<p class="text-sm text-[var(--color-text-secondary)]">
Showing <span class="font-medium">1</span> to <span class="font-medium">25</span>
of <span class="font-medium">1,247</span>
</p>
<div class="flex items-center gap-1">
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50 disabled:opacity-50" disabled>Previous</button>
<button class="rounded-md px-3 py-1.5 text-sm bg-indigo-600 text-white">1</button>
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50">2</button>
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50">3</button>
<span class="px-2 text-gray-400">...</span>
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50">50</button>
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50">Next</button>
</div>
</div>
</div>
```
**Table design rules:**
- Left-align text, right-align numbers
- Use `font-mono text-xs` for IPs, MACs, serial numbers
- Status columns: colored badge with dot indicator
- Always provide a row hover state
- Sticky header on long tables: `sticky top-0 z-10`
- Avoid wrapping — use `truncate` and `max-w-*` with tooltips
### Charts — Which Type When
| Data Type | Chart | Example |
|-----------|-------|---------|
| **Time series** | Line/Area | Bandwidth, latency, CPU over time |
| **Composition** | Stacked area/bar | Traffic by protocol over time |
| **Comparison** | Horizontal bar | Top 10 talkers by bandwidth |
| **Distribution** | Histogram | Latency distribution |
| **Single value + trend** | KPI card + sparkline | Current throughput |
| **Part of whole** | Donut (never pie) | Device status breakdown |
| **Geographic** | Map with pins/heatmap | Tower locations, coverage |
| **Correlation** | Scatter plot | Packet loss vs. distance |
**Chart guidelines for monitoring dashboards:**
- Default to **24h** time range with **auto-refresh**
- Always show the y-axis scale and units (Mbps, ms, %)
- Use **area fills** with low opacity (`fill-opacity: 0.1`) for single-line charts
- Color thresholds: green < 70%, amber 7090%, red > 90%
- Provide time range selector: 1h | 6h | 24h | 7d | 30d
- Tooltip should show exact timestamp + value
- Loading state: show axes + gray placeholder area
### KPI Widget
```html
<div class="rounded-lg border border-border bg-surface p-5">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-[var(--color-text-secondary)]">Packet Loss</p>
<!-- Icon or sparkline -->
<svg class="h-5 w-5 text-gray-400"><!-- icon --></svg>
</div>
<div class="mt-2 flex items-baseline gap-2">
<p class="text-kpi">0.02%</p>
<!-- Trend badge -->
<span class="inline-flex items-center gap-0.5 text-xs font-medium text-emerald-600">
<svg class="h-3 w-3"><!-- arrow-down (good for packet loss) --></svg>
-0.01%
</span>
</div>
<p class="mt-1 text-xs text-[var(--color-text-secondary)]">vs. 0.03% yesterday</p>
</div>
```
### Empty / Error / Loading States
Every data view needs all three. **Never show a blank screen.**
```html
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-16 px-4 text-center">
<div class="rounded-full bg-gray-100 dark:bg-gray-800 p-4 mb-4">
<svg class="h-8 w-8 text-gray-400"><!-- relevant icon --></svg>
</div>
<h3 class="text-sm font-medium mb-1">No devices found</h3>
<p class="text-sm text-[var(--color-text-secondary)] mb-4 max-w-sm">
Add your first device to start monitoring your network.
</p>
<button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white
hover:bg-indigo-700">
Add Device
</button>
</div>
<!-- Error state -->
<div class="flex flex-col items-center justify-center py-16 px-4 text-center">
<div class="rounded-full bg-red-50 dark:bg-red-500/10 p-4 mb-4">
<svg class="h-8 w-8 text-red-500"><!-- exclamation --></svg>
</div>
<h3 class="text-sm font-medium mb-1">Failed to load devices</h3>
<p class="text-sm text-[var(--color-text-secondary)] mb-4 max-w-sm">
Something went wrong. Please try again or contact support if the problem persists.
</p>
<button class="rounded-md border border-border px-4 py-2 text-sm font-medium
hover:bg-gray-50">
Retry
</button>
</div>
<!-- Loading — prefer skeleton over spinner for content areas -->
<!-- Use spinners only for actions (buttons, small updates) -->
<div class="flex items-center justify-center py-16">
<svg class="animate-spin h-6 w-6 text-indigo-600" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
```
**State hierarchy:**
1. **Loading** → skeleton (content areas) or spinner (actions)
2. **Error** → message + retry action
3. **Empty** → illustration + explanation + primary action
4. **Data** → the actual content
---
## 4. Forms & Input
### Validation Patterns
**Inline validation — validate on blur, show errors immediately:**
```html
<div>
<label for="hostname" class="block text-sm font-medium mb-1.5">Hostname</label>
<input
id="hostname"
type="text"
class="w-full rounded-md border px-3 py-2 text-sm
border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500"
aria-invalid="true"
aria-describedby="hostname-error"
value="invalid hostname!"
/>
<p id="hostname-error" class="mt-1.5 text-xs text-red-600 flex items-center gap-1">
<svg class="h-3.5 w-3.5 shrink-0"><!-- exclamation-circle --></svg>
Hostname must contain only lowercase letters, numbers, and hyphens.
</p>
</div>
<!-- Valid state -->
<div>
<label for="ip" class="block text-sm font-medium mb-1.5">IP Address</label>
<div class="relative">
<input
id="ip"
type="text"
class="w-full rounded-md border border-emerald-500 px-3 py-2 pr-9 text-sm
focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
value="10.0.1.100"
/>
<svg class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4
text-emerald-500"><!-- check-circle --></svg>
</div>
</div>
```
**Validation rules:**
- Validate on blur (not on every keystroke)
- Show success states for complex fields (IP addresses, CIDR notation)
- Group related errors at the top of the form for long forms
- Never clear the user's input on error
- For LiveView: use `phx-change` with debounce for real-time validation
### Radio Card Groups
Perfect for selecting device types, plan tiers, or monitoring profiles:
```html
<fieldset>
<legend class="text-sm font-medium mb-3">Device Type</legend>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<!-- Selected card -->
<label class="relative flex cursor-pointer rounded-lg border-2 border-indigo-600
bg-indigo-50 dark:bg-indigo-500/10 p-4">
<input type="radio" name="device_type" value="router" class="sr-only"
checked aria-describedby="type-router-desc" />
<div class="flex flex-col">
<span class="text-sm font-medium">Router</span>
<span id="type-router-desc" class="mt-1 text-xs text-[var(--color-text-secondary)]">
Core routing equipment
</span>
</div>
<!-- Check indicator -->
<svg class="absolute top-3 right-3 h-5 w-5 text-indigo-600"><!-- check-circle --></svg>
</label>
<!-- Unselected card -->
<label class="relative flex cursor-pointer rounded-lg border-2 border-border
hover:border-gray-300 dark:hover:border-gray-600 p-4
transition-colors">
<input type="radio" name="device_type" value="switch" class="sr-only" />
<div class="flex flex-col">
<span class="text-sm font-medium">Switch</span>
<span class="mt-1 text-xs text-[var(--color-text-secondary)]">
Layer 2/3 switching
</span>
</div>
</label>
<!-- Unselected card -->
<label class="relative flex cursor-pointer rounded-lg border-2 border-border
hover:border-gray-300 dark:hover:border-gray-600 p-4
transition-colors">
<input type="radio" name="device_type" value="ap" class="sr-only" />
<div class="flex flex-col">
<span class="text-sm font-medium">Access Point</span>
<span class="mt-1 text-xs text-[var(--color-text-secondary)]">
Wireless access point
</span>
</div>
</label>
</div>
</fieldset>
```
### Search & Filter Patterns
**Combo filter bar:**
```html
<div class="flex flex-wrap items-center gap-2">
<!-- Active filters as pills -->
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1
text-xs font-medium text-indigo-700
dark:bg-indigo-500/10 dark:text-indigo-400">
Status: Online
<button class="ml-0.5 hover:text-indigo-900">
<svg class="h-3 w-3"><!-- x --></svg>
</button>
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1
text-xs font-medium text-indigo-700
dark:bg-indigo-500/10 dark:text-indigo-400">
Type: Router
<button class="ml-0.5 hover:text-indigo-900">
<svg class="h-3 w-3"><!-- x --></svg>
</button>
</span>
<!-- Clear all -->
<button class="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]
underline">
Clear all
</button>
</div>
```
### Auto-Save Indicator
```html
<!-- In form header or toolbar -->
<div class="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<!-- Saving -->
<svg class="animate-spin h-3 w-3"><!-- spinner --></svg>
<span>Saving...</span>
<!-- Saved -->
<svg class="h-3 w-3 text-emerald-500"><!-- check --></svg>
<span>Saved</span>
<!-- Error -->
<svg class="h-3 w-3 text-red-500"><!-- x --></svg>
<span class="text-red-600">Failed to save</span>
<button class="underline text-red-600">Retry</button>
</div>
```
### Confirmation Dialogs
Use for destructive actions only. **Never confirm routine saves.**
```html
<!-- Confirmation modal -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="w-full max-w-md rounded-lg bg-surface shadow-xl">
<div class="p-6">
<!-- Icon -->
<div class="mx-auto h-12 w-12 rounded-full bg-red-50 dark:bg-red-500/10
flex items-center justify-center mb-4">
<svg class="h-6 w-6 text-red-600"><!-- exclamation-triangle --></svg>
</div>
<h3 class="text-lg font-semibold text-center mb-2">Delete Device</h3>
<p class="text-sm text-[var(--color-text-secondary)] text-center">
Are you sure you want to delete <span class="font-medium">core-router-01</span>?
All monitoring data will be permanently removed. This action cannot be undone.
</p>
</div>
<div class="flex gap-3 px-6 pb-6">
<button class="flex-1 rounded-md border border-border px-4 py-2 text-sm
font-medium hover:bg-gray-50">
Cancel
</button>
<button class="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium
text-white hover:bg-red-700">
Delete
</button>
</div>
</div>
</div>
```
**Rules for confirmation dialogs:**
- Destructive button on the **right**, cancel on the **left**
- Name the action specifically ("Delete Device" not "Are you sure?")
- Include the resource name in the message
- Red color for destructive action button
- For high-stakes actions, require typing the resource name
---
## 5. Navigation
### Breadcrumbs
```html
<nav aria-label="Breadcrumb" class="flex items-center gap-2 text-sm">
<a href="/" class="text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)]">Home</a>
<svg class="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0">
<!-- chevron-right -->
</svg>
<a href="/network" class="text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)]">Network</a>
<svg class="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0">
<!-- chevron-right -->
</svg>
<span class="font-medium" aria-current="page">core-router-01</span>
</nav>
```
### Tabs
```html
<div class="border-b border-border">
<nav class="-mb-px flex gap-6" aria-label="Tabs">
<!-- Active tab -->
<a href="#"
class="border-b-2 border-indigo-600 pb-3 text-sm font-medium text-indigo-600
dark:text-indigo-400"
aria-current="page">
Overview
</a>
<!-- Inactive tab -->
<a href="#"
class="border-b-2 border-transparent pb-3 text-sm font-medium
text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)] hover:border-gray-300">
Interfaces
</a>
<a href="#"
class="border-b-2 border-transparent pb-3 text-sm font-medium
text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)] hover:border-gray-300">
Alerts
<!-- Badge for count -->
<span class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium
text-red-700 dark:bg-red-500/10 dark:text-red-400">3</span>
</a>
<a href="#"
class="border-b-2 border-transparent pb-3 text-sm font-medium
text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)] hover:border-gray-300">
Configuration
</a>
</nav>
</div>
```
### Command Palette (Cmd+K)
Essential for power users in network monitoring tools:
```html
<!-- Command palette overlay -->
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] p-4 bg-black/50">
<div class="w-full max-w-xl rounded-xl bg-surface shadow-2xl border border-border
overflow-hidden">
<!-- Search input -->
<div class="flex items-center gap-3 px-4 border-b border-border">
<svg class="h-5 w-5 text-[var(--color-text-secondary)] shrink-0">
<!-- search icon -->
</svg>
<input
type="text"
placeholder="Search devices, commands, pages..."
class="w-full border-0 bg-transparent py-4 text-sm
placeholder:text-[var(--color-text-secondary)]
focus:outline-none focus:ring-0"
autofocus
/>
<kbd class="shrink-0 rounded border border-border px-1.5 py-0.5
text-xs font-mono text-[var(--color-text-secondary)]">ESC</kbd>
</div>
<!-- Results -->
<div class="max-h-80 overflow-y-auto py-2">
<!-- Group heading -->
<p class="px-4 py-1.5 text-xs font-medium text-[var(--color-text-secondary)]
uppercase tracking-wider">
Devices
</p>
<!-- Result item (highlighted) -->
<div class="mx-2 flex items-center gap-3 rounded-lg px-3 py-2.5
bg-indigo-50 dark:bg-indigo-500/10 cursor-pointer">
<span class="h-2 w-2 rounded-full bg-emerald-500 shrink-0"></span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">core-router-01</p>
<p class="text-xs text-[var(--color-text-secondary)]">10.0.1.1 · Router</p>
</div>
<kbd class="text-xs text-[var(--color-text-secondary)]"></kbd>
</div>
<!-- Result item (normal) -->
<div class="mx-2 flex items-center gap-3 rounded-lg px-3 py-2.5
hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer">
<span class="h-2 w-2 rounded-full bg-emerald-500 shrink-0"></span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">core-router-02</p>
<p class="text-xs text-[var(--color-text-secondary)]">10.0.1.2 · Router</p>
</div>
</div>
<!-- Group heading -->
<p class="px-4 py-1.5 mt-2 text-xs font-medium text-[var(--color-text-secondary)]
uppercase tracking-wider">
Actions
</p>
<div class="mx-2 flex items-center gap-3 rounded-lg px-3 py-2.5
hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer">
<svg class="h-5 w-5 text-[var(--color-text-secondary)]"><!-- icon --></svg>
<p class="text-sm">Add new device</p>
<div class="ml-auto flex gap-1">
<kbd class="rounded border border-border px-1.5 py-0.5 text-xs font-mono
text-[var(--color-text-secondary)]"></kbd>
<kbd class="rounded border border-border px-1.5 py-0.5 text-xs font-mono
text-[var(--color-text-secondary)]">N</kbd>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center gap-4 px-4 py-2.5 border-t border-border
text-xs text-[var(--color-text-secondary)]">
<span class="flex items-center gap-1">
<kbd class="rounded border border-border px-1 text-xs">↑↓</kbd> Navigate
</span>
<span class="flex items-center gap-1">
<kbd class="rounded border border-border px-1 text-xs"></kbd> Open
</span>
<span class="flex items-center gap-1">
<kbd class="rounded border border-border px-1 text-xs">ESC</kbd> Close
</span>
</div>
</div>
</div>
```
### Notifications
**Toast notifications** — for transient feedback:
```html
<!-- Toast container — fixed to bottom-right -->
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-96">
<!-- Success toast -->
<div class="flex items-start gap-3 rounded-lg border border-border bg-surface
p-4 shadow-lg" role="alert">
<svg class="h-5 w-5 text-emerald-500 shrink-0 mt-0.5"><!-- check-circle --></svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">Device added</p>
<p class="mt-0.5 text-xs text-[var(--color-text-secondary)]">
core-switch-03 is now being monitored.
</p>
</div>
<button class="shrink-0 text-gray-400 hover:text-gray-600">
<svg class="h-4 w-4"><!-- x --></svg>
</button>
</div>
<!-- Error toast -->
<div class="flex items-start gap-3 rounded-lg border border-red-200 dark:border-red-500/30
bg-red-50 dark:bg-red-500/10 p-4 shadow-lg" role="alert">
<svg class="h-5 w-5 text-red-500 shrink-0 mt-0.5"><!-- x-circle --></svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-red-800 dark:text-red-400">Connection failed</p>
<p class="mt-0.5 text-xs text-red-600 dark:text-red-400/80">
Could not reach 10.0.1.5. Check SNMP credentials.
</p>
</div>
<button class="shrink-0 text-red-400 hover:text-red-600">
<svg class="h-4 w-4"><!-- x --></svg>
</button>
</div>
</div>
```
**Toast rules:**
- Auto-dismiss success toasts after 5 seconds
- Error toasts persist until dismissed
- Max 3 visible toasts; queue the rest
- Position: bottom-right for desktop, top-center for mobile
**Banner notifications** — for system-wide alerts:
```html
<!-- Warning banner -->
<div class="flex items-center gap-3 bg-amber-50 dark:bg-amber-500/10 px-4 py-2.5
border-b border-amber-200 dark:border-amber-500/20">
<svg class="h-4 w-4 text-amber-600 shrink-0"><!-- exclamation-triangle --></svg>
<p class="text-sm text-amber-800 dark:text-amber-300">
<span class="font-medium">Scheduled maintenance:</span>
Core network upgrade on March 15 at 02:00 UTC.
<a href="#" class="underline font-medium">Learn more</a>
</p>
<button class="ml-auto shrink-0 text-amber-600 hover:text-amber-800">
<svg class="h-4 w-4"><!-- x --></svg>
</button>
</div>
```
**Notification badge:**
```html
<!-- Sidebar nav item with badge -->
<a href="#" class="flex items-center justify-between rounded-lg px-3 py-2 text-sm
text-gray-600 hover:bg-gray-100">
<div class="flex items-center gap-3">
<svg class="h-5 w-5"><!-- bell --></svg>
<span>Alerts</span>
</div>
<span class="inline-flex items-center justify-center h-5 min-w-5 rounded-full
bg-red-600 px-1.5 text-xs font-medium text-white">
12
</span>
</a>
```
### Onboarding
**Setup checklist:**
```html
<div class="rounded-lg border border-border bg-surface p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium">Getting Started</h3>
<span class="text-xs text-[var(--color-text-secondary)]">2 of 4 complete</span>
</div>
<!-- Progress bar -->
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700 mb-4">
<div class="h-1.5 rounded-full bg-indigo-600 transition-all duration-500"
style="width: 50%"></div>
</div>
<ul class="space-y-3">
<!-- Completed -->
<li class="flex items-center gap-3 text-sm">
<div class="h-5 w-5 rounded-full bg-emerald-100 dark:bg-emerald-500/20
flex items-center justify-center shrink-0">
<svg class="h-3 w-3 text-emerald-600"><!-- check --></svg>
</div>
<span class="text-[var(--color-text-secondary)] line-through">Create your account</span>
</li>
<!-- Completed -->
<li class="flex items-center gap-3 text-sm">
<div class="h-5 w-5 rounded-full bg-emerald-100 dark:bg-emerald-500/20
flex items-center justify-center shrink-0">
<svg class="h-3 w-3 text-emerald-600"><!-- check --></svg>
</div>
<span class="text-[var(--color-text-secondary)] line-through">Install the agent</span>
</li>
<!-- Current step -->
<li class="flex items-center gap-3 text-sm">
<div class="h-5 w-5 rounded-full border-2 border-indigo-600
flex items-center justify-center shrink-0">
<div class="h-2 w-2 rounded-full bg-indigo-600"></div>
</div>
<a href="#" class="font-medium text-indigo-600 hover:text-indigo-700">
Add your first device
</a>
</li>
<!-- Pending -->
<li class="flex items-center gap-3 text-sm">
<div class="h-5 w-5 rounded-full border-2 border-gray-300 dark:border-gray-600
shrink-0"></div>
<span class="text-[var(--color-text-secondary)]">Configure alerting</span>
</li>
</ul>
</div>
```
**Progressive disclosure:** Show complexity only when needed.
```html
<!-- Toggle advanced options -->
<div class="border-t border-border pt-4 mt-4">
<button
class="flex items-center gap-2 text-sm font-medium text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)]"
@click="showAdvanced = !showAdvanced"
>
<svg class="h-4 w-4 transition-transform" :class="showAdvanced && 'rotate-90'">
<!-- chevron-right -->
</svg>
Advanced Settings
</button>
<div x-show="showAdvanced" x-collapse class="mt-4 space-y-4">
<!-- Advanced fields here -->
</div>
</div>
```
---
## 6. Accessibility
### ARIA Essentials
```html
<!-- Live region for real-time status updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
Device core-router-01 status changed to offline
</div>
<!-- Status badge — announce the full meaning -->
<span role="status" class="inline-flex items-center gap-1.5 rounded-full
bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true"></span>
Online
</span>
<!-- Icon-only buttons MUST have labels -->
<button aria-label="Delete device" class="p-2 rounded-md hover:bg-gray-100">
<svg class="h-5 w-5" aria-hidden="true"><!-- trash --></svg>
</button>
<!-- Sortable table headers -->
<th scope="col" aria-sort="ascending">
<button class="flex items-center gap-1">
Hostname
<svg aria-hidden="true" class="h-4 w-4"><!-- sort-asc --></svg>
</button>
</th>
<!-- Modal dialog -->
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Delete Device</h2>
<!-- content -->
</div>
<!-- Tabs -->
<div role="tablist" aria-label="Device details">
<button role="tab" aria-selected="true" aria-controls="panel-overview"
id="tab-overview">Overview</button>
<button role="tab" aria-selected="false" aria-controls="panel-interfaces"
id="tab-interfaces" tabindex="-1">Interfaces</button>
</div>
<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">
<!-- content -->
</div>
```
### Keyboard Navigation
| Component | Keys | Behavior |
|-----------|------|----------|
| **Modal** | `Escape` | Close |
| **Tabs** | `←` `→` | Switch tabs |
| **Dropdown** | `↑` `↓` `Enter` `Escape` | Navigate, select, close |
| **Table** | `Space` | Toggle row selection |
| **Command palette** | `⌘K` / `Ctrl+K` | Open |
| **Toast** | Auto-focus close button | Dismiss |
### Focus Management
```html
<!-- Focus trap in modal (use a library or JS hook) -->
<!-- First focusable element gets focus on open -->
<!-- Tab wraps within the modal -->
<!-- Focus returns to trigger element on close -->
<!-- Skip link (first element in body) -->
<a href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
focus:z-50 focus:rounded-md focus:bg-indigo-600 focus:px-4 focus:py-2
focus:text-white focus:text-sm focus:font-medium">
Skip to main content
</a>
<!-- Focus ring styling — visible only on keyboard navigation -->
<!-- Tailwind handles this with focus-visible: -->
<button class="rounded-md px-4 py-2 focus:outline-none
focus-visible:ring-2 focus-visible:ring-indigo-500
focus-visible:ring-offset-2">
Action
</button>
```
### Color Contrast
**WCAG 2.1 AA minimum contrast ratios:**
| Element | Ratio | Example |
|---------|-------|---------|
| Normal text (< 18px) | **4.5:1** | `gray-600` on white = 5.4:1 |
| Large text (≥ 18px bold / 24px) | **3:1** | `gray-500` on white = 4.6:1 |
| UI components & graphics | **3:1** | Border, icons, form controls |
**Don't rely on color alone:**
- Red text for errors, green text for success (color blind users lose info)
- Red text + error icon + descriptive text
- Status dot + text label ("Online", "Offline")
- Chart lines with different patterns/shapes, not just colors
```html
<!-- ✅ Status uses color + icon + text -->
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-red-700">
<svg class="h-4 w-4" aria-hidden="true"><!-- x-circle --></svg>
Critical
</span>
<!-- ✅ For charts, use patterns or distinct shapes alongside color -->
```
---
## 7. Animation & Transitions
### CSS Transitions
Use for state changes, not entrance animations. Keep them fast (150200ms).
```html
<!-- Standard transition classes -->
<button class="transition-colors duration-150 ease-in-out
bg-indigo-600 hover:bg-indigo-700">Save</button>
<div class="transition-all duration-200 ease-out
hover:shadow-md hover:-translate-y-0.5">
<!-- Card with lift effect -->
</div>
<!-- Sidebar collapse -->
<aside class="transition-[width] duration-200 ease-in-out">
```
**Duration guidelines:**
- **Micro-interactions** (hover, focus): `duration-150`
- **Component transitions** (expand, collapse): `duration-200`
- **Page/panel transitions**: `duration-300`
- **Never exceed 500ms** for UI transitions
### Skeleton Screens
Prefer skeletons over spinners for content loading:
```html
<!-- Skeleton pulse for a stats card -->
<div class="rounded-lg border border-border bg-surface p-5">
<div class="animate-pulse space-y-3">
<div class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-8 w-24 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-2.5 w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
```
**When to use each:**
- **Skeleton**: Loading content areas, tables, cards, pages
- **Spinner**: Submitting forms, button loading states, small inline updates
- **Progress bar**: File uploads, multi-step processes with known completion
- **Nothing**: If load time < 200ms, show nothing (avoids flash)
### Optimistic UI
Update the UI immediately, reconcile when the server responds:
```
User clicks "Acknowledge Alert"
→ Immediately: badge changes to "Acknowledged", toast shows "Alert acknowledged"
→ Server responds 200: done
→ Server responds error: revert badge, show error toast "Failed to acknowledge alert. Retry?"
```
### Micro-Interactions
```html
<!-- Notification bell with shake on new alert -->
<button class="relative p-2 rounded-md hover:bg-gray-100
group transition-colors">
<svg class="h-5 w-5 text-gray-500 group-hover:text-gray-700
transition-colors"><!-- bell --></svg>
<!-- Animated ping on new notification -->
<span class="absolute top-1 right-1 flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full
rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500"></span>
</span>
</button>
<!-- Expanding row in table -->
<tr class="cursor-pointer" @click="expanded = !expanded">
<!-- row content -->
</tr>
<tr x-show="expanded"
x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2">
<td colspan="7" class="px-5 py-4 bg-surface-raised">
<!-- Expanded detail -->
</td>
</tr>
<!-- Counter/number animation (use JS for this) -->
<!-- Animate from old value to new value over ~400ms -->
```
### Reduced Motion
Always respect the user preference:
```html
<!-- Tailwind's motion-safe / motion-reduce -->
<div class="motion-safe:animate-pulse motion-reduce:opacity-70">
<!-- skeleton -->
</div>
<div class="motion-safe:transition-transform motion-safe:hover:-translate-y-0.5">
<!-- card -->
</div>
```
```css
/* In CSS when Tailwind classes aren't enough */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
---
## 8. Phoenix LiveView Specific
### LiveView + Tailwind Patterns
**Component structure:**
```elixir
# lib/towerops_web/components/ui/status_badge.ex
defmodule ToweropsWeb.Components.UI.StatusBadge do
use Phoenix.Component
@status_styles %{
"healthy" => "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400",
"warning" => "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400",
"critical" => "bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-400",
"unknown" => "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
"maint" => "bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"
}
@dot_colors %{
"healthy" => "bg-emerald-500",
"warning" => "bg-amber-500",
"critical" => "bg-red-500",
"unknown" => "bg-gray-400",
"maint" => "bg-blue-500"
}
attr :status, :string, required: true, values: Map.keys(@status_styles)
attr :label, :string, default: nil
def status_badge(assigns) do
assigns =
assigns
|> assign(:style, @status_styles[assigns.status])
|> assign(:dot, @dot_colors[assigns.status])
|> assign_new(:label, fn -> Phoenix.Naming.humanize(assigns.status) end)
~H"""
<span class={["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium", @style]}>
<span class={["h-1.5 w-1.5 rounded-full", @dot]} aria-hidden="true"></span>
<%= @label %>
</span>
"""
end
end
```
### JS Hooks for Client-Side Interactivity
```js
// assets/js/hooks/index.js
export const Hooks = {
// Chart hook — render/update charts client-side
Chart: {
mounted() {
this.chart = createChart(this.el, JSON.parse(this.el.dataset.config));
},
updated() {
const data = JSON.parse(this.el.dataset.series);
this.chart.updateSeries(data);
},
destroyed() {
this.chart?.destroy();
}
},
// Copy to clipboard
Clipboard: {
mounted() {
this.el.addEventListener("click", () => {
const text = this.el.dataset.copy;
navigator.clipboard.writeText(text).then(() => {
// Push event back to LiveView for toast
this.pushEvent("copied", { text });
});
});
}
},
// Command palette (Cmd+K)
CommandPalette: {
mounted() {
this.handleKeydown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
this.pushEvent("toggle_command_palette", {});
}
if (e.key === "Escape") {
this.pushEvent("close_command_palette", {});
}
};
document.addEventListener("keydown", this.handleKeydown);
},
destroyed() {
document.removeEventListener("keydown", this.handleKeydown);
}
},
// Auto-resize textarea
AutoResize: {
mounted() {
this.resize = () => {
this.el.style.height = "auto";
this.el.style.height = this.el.scrollHeight + "px";
};
this.el.addEventListener("input", this.resize);
this.resize();
}
},
// Preserve scroll position on LiveView updates
ScrollPreserve: {
mounted() {
this.scrollTop = 0;
},
beforeUpdate() {
this.scrollTop = this.el.scrollTop;
},
updated() {
this.el.scrollTop = this.scrollTop;
}
},
// Dark mode toggle (persists to localStorage)
DarkMode: {
mounted() {
this.el.addEventListener("click", () => {
const isDark = document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
this.pushEvent("theme_changed", { theme: isDark ? "dark" : "light" });
});
}
},
// Infinite scroll
InfiniteScroll: {
mounted() {
this.observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
this.pushEvent("load_more", {});
}
},
{ rootMargin: "200px" }
);
this.observer.observe(this.el);
},
destroyed() {
this.observer?.disconnect();
}
}
};
```
### Live Navigation UX
```elixir
# Use live_redirect for same-LiveView navigation (no page reload)
# Use push_navigate for cross-LiveView navigation
# In router.ex — group under live_session for shared layout
live_session :authenticated, on_mount: [ToweropsWeb.UserAuth] do
live "/dashboard", DashboardLive
live "/devices", DevicesLive.Index
live "/devices/:id", DevicesLive.Show
live "/alerts", AlertsLive.Index
end
```
```elixir
# Loading indicator during live navigation
# In root layout:
<div
class="fixed top-0 left-0 right-0 h-0.5 bg-indigo-600 z-50
transition-all duration-300 ease-out"
style="width: 0%"
phx-hook="NavigationProgress"
></div>
```
```js
// Navigation progress hook
NavigationProgress: {
mounted() {
this.width = 0;
window.addEventListener("phx:page-loading-start", () => {
this.width = 70;
this.el.style.width = this.width + "%";
this.el.style.opacity = "1";
});
window.addEventListener("phx:page-loading-stop", () => {
this.el.style.width = "100%";
setTimeout(() => {
this.el.style.opacity = "0";
setTimeout(() => {
this.el.style.width = "0%";
}, 300);
}, 200);
});
}
}
```
### LiveView Form Patterns
```elixir
# Form with real-time validation
def render(assigns) do
~H"""
<.form
for={@form}
phx-change="validate"
phx-submit="save"
phx-debounce="300"
class="space-y-6"
>
<div>
<.label for={@form[:hostname]}>Hostname</.label>
<.input
field={@form[:hostname]}
type="text"
placeholder="e.g., core-router-01"
phx-debounce="blur"
/>
</div>
<div>
<.label for={@form[:ip_address]}>IP Address</.label>
<.input
field={@form[:ip_address]}
type="text"
placeholder="e.g., 10.0.1.1"
phx-debounce="blur"
/>
</div>
<!-- Conditional fields based on selection -->
<div>
<.label for={@form[:device_type]}>Device Type</.label>
<.input field={@form[:device_type]} type="select"
options={[{"Router", "router"}, {"Switch", "switch"}, {"AP", "ap"}]} />
</div>
<%= if @form[:device_type].value == "ap" do %>
<div phx-mounted={JS.transition("fade-in-scale", time: 200)}>
<.label for={@form[:ssid]}>SSID</.label>
<.input field={@form[:ssid]} type="text" />
</div>
<% end %>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-border">
<.button type="button" variant="secondary" phx-click="cancel">Cancel</.button>
<.button type="submit" phx-disable-with="Saving...">Save Device</.button>
</div>
</.form>
"""
end
def handle_event("validate", %{"device" => params}, socket) do
form =
%Device{}
|> Device.changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, form: form)}
end
def handle_event("save", %{"device" => params}, socket) do
case Devices.create_device(params) do
{:ok, device} ->
{:noreply,
socket
|> put_flash(:info, "Device #{device.hostname} created successfully.")
|> push_navigate(to: ~p"/devices/#{device}")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
```
### Real-Time Updates with PubSub
```elixir
# Subscribe on mount
def mount(_params, _session, socket) do
if connected?(socket) do
ToweropsWeb.Endpoint.subscribe("devices:status")
ToweropsWeb.Endpoint.subscribe("alerts:new")
end
{:ok, assign(socket, devices: Devices.list_devices())}
end
# Handle broadcasts
def handle_info(%{topic: "devices:status", payload: %{device_id: id, status: status}}, socket) do
devices =
Enum.map(socket.assigns.devices, fn device ->
if device.id == id, do: %{device | status: status}, else: device
end)
{:noreply, assign(socket, devices: devices)}
end
# Publish from context
def update_device_status(device, status) do
device
|> Device.status_changeset(%{status: status})
|> Repo.update()
|> case do
{:ok, device} ->
ToweropsWeb.Endpoint.broadcast("devices:status", "status_changed", %{
device_id: device.id,
status: device.status
})
{:ok, device}
error ->
error
end
end
```
**LiveView real-time update with CSS transition:**
```elixir
# Highlight row briefly when data changes
<tr id={"device-#{device.id}"}
class="hover:bg-gray-50 dark:hover:bg-gray-800/50
transition-colors duration-500"
phx-hook="FlashUpdate">
<!-- When this element updates, the hook adds a highlight class briefly -->
</tr>
```
```js
FlashUpdate: {
updated() {
this.el.classList.add("bg-indigo-50", "dark:bg-indigo-500/5");
setTimeout(() => {
this.el.classList.remove("bg-indigo-50", "dark:bg-indigo-500/5");
}, 1500);
}
}
```
---
## 9. Color & Typography
### Gray Scales
Use a single gray scale consistently. Tailwind's `gray` (the default, formerly `coolGray`) works well for UI-heavy apps:
```
gray-50: #f9fafb — subtle backgrounds
gray-100: #f3f4f6 — hover states, skeleton fills
gray-200: #e5e7eb — borders, dividers
gray-300: #d1d5db — disabled borders, secondary icons
gray-400: #9ca3af — placeholder text, disabled text
gray-500: #6b7280 — secondary text (light mode)
gray-600: #4b5563 — primary body text
gray-700: #374151 — dark mode borders
gray-800: #1f2937 — dark mode surfaces
gray-900: #111827 — dark mode base, headings
gray-950: #030712 — darkest background
```
### Accent Colors
Pick **one primary** + **semantic status colors**:
```js
// tailwind.config.js
colors: {
primary: {
50: '#eef2ff', // indigo-50
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1', // indigo-500 — primary accent
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4e',
},
// Status colors — use sparingly, with purpose
success: colors.emerald,
warning: colors.amber,
danger: colors.red,
info: colors.blue,
}
```
### Dark Mode Color Mapping
| Element | Light | Dark |
|---------|-------|------|
| Page background | `white` / `gray-50` | `gray-900` / `gray-950` |
| Card/surface | `white` | `gray-800` |
| Raised surface | `gray-50` | `gray-700` |
| Border | `gray-200` | `gray-700` |
| Primary text | `gray-900` | `gray-100` |
| Secondary text | `gray-500` | `gray-400` |
| Accent bg | `indigo-50` | `indigo-500/10` |
| Accent text | `indigo-700` | `indigo-400` |
| Status badges | `emerald-50` / `emerald-700` | `emerald-500/10` / `emerald-400` |
**Key principle:** In dark mode, use lower opacity backgrounds (`bg-indigo-500/10`) instead of dark shade equivalents. It adapts better.
### Typography Scale
```css
/* Font stack — system fonts for speed */
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
/* For monospace (IPs, code, configs) */
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
```
| Use | Tailwind | Size | Weight |
|-----|----------|------|--------|
| Page title | `text-xl font-semibold` | 20px | 600 |
| Section heading | `text-lg font-semibold` | 18px | 600 |
| Card heading | `text-sm font-medium` | 14px | 500 |
| Body text | `text-sm` | 14px | 400 |
| Small/caption | `text-xs` | 12px | 400 |
| KPI number | `text-kpi` (custom) | 32px | 700 |
| Metric number | `text-metric` (custom) | 24px | 600 |
| Monospace data | `font-mono text-xs` | 12px | 400 |
| Badge/label | `text-xs font-medium` | 12px | 500 |
**Rules:**
- **14px (`text-sm`) is the base** for B2B SaaS not 16px. Dashboard density matters.
- Use `font-medium` (500) for emphasis, `font-semibold` (600) for headings
- Avoid `font-bold` (700) except for KPI numbers
- Never use ALL CAPS for more than 2-3 word labels
- `tracking-wider` for uppercase labels: `text-xs font-medium uppercase tracking-wider`
---
## 10. Anti-Patterns
### ❌ Modal Overuse
**Problem:** Using modals for everything forms, confirmations, details, settings.
**Why it's bad:**
- Blocks the user from referencing underlying content
- Stacking modals creates confusion
- Mobile modals are painful
- Interrupts flow for routine actions
**Instead:**
| Action | Use |
|--------|-----|
| Quick edit (name, status) | Inline edit |
| Create/edit form | Slide-over panel or dedicated page |
| Details view | Expandable row or side panel |
| Confirmation | Only for **destructive** + **irreversible** actions |
| Settings | Dedicated page with sections |
```html
<!-- ✅ Slide-over panel instead of modal for forms -->
<div class="fixed inset-y-0 right-0 z-40 w-full max-w-lg
border-l border-border bg-surface shadow-xl
transform transition-transform duration-300"
:class="open ? 'translate-x-0' : 'translate-x-full'">
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 class="text-lg font-semibold">Edit Device</h2>
<button @click="open = false" class="p-2 rounded-md hover:bg-gray-100">
<svg class="h-5 w-5"><!-- x --></svg>
</button>
</div>
<div class="overflow-y-auto p-6" style="height: calc(100vh - 65px)">
<!-- form content -->
</div>
</div>
```
### ❌ Notification Fatigue
**Problem:** Alerting on everything, alert storms, redundant notifications.
**Symptoms:**
- Users ignore all alerts
- Alert count badge permanently shows 99+
- Multiple channels notifying for the same event
**Rules:**
- **Deduplicate**: Group related alerts (if 20 devices go down because a switch died, show 1 alert about the switch)
- **Severity levels**: Only push-notify on Critical. Show Warning in-app. Log Info.
- **Snooze/acknowledge**: Let users silence specific alerts
- **Escalation, not repetition**: Alert once, escalate if no response after N minutes
- **Smart defaults**: Don't enable all alerts by default progressive opt-in
### ❌ Settings Bloat
**Problem:** Exposing every configuration option to every user.
**Instead:**
- **Smart defaults** that work for 80% of users
- **Role-based visibility**: Admins see advanced settings, operators see what they need
- **Progressive disclosure**: Basic settings visible, "Advanced" collapsed
- **Contextual settings**: Put them where they're relevant, not in a mega settings page
```html
<!-- ❌ Mega settings page with 50 fields -->
<!-- ✅ Settings organized into clear sections with smart defaults -->
<nav class="w-48 shrink-0 space-y-1">
<a class="block rounded-md px-3 py-2 text-sm bg-gray-100 font-medium">General</a>
<a class="block rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">Notifications</a>
<a class="block rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">SNMP</a>
<a class="block rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">Integrations</a>
<a class="block rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">API Keys</a>
</nav>
```
### ❌ Vanity Metrics
**Problem:** Dashboards full of numbers that look impressive but don't help anyone.
**"Total API calls: 14,203,847"** So what?
**Better:**
- Show metrics that **drive action**: "3 devices offline", "Latency > 100ms on 2 links"
- Include **context**: "Packet loss 0.5% (↑ 0.3% from baseline)"
- Add **thresholds**: Color/icon change when metrics leave normal range
- Provide **drill-down**: Click the KPI to see the underlying data
```html
<!-- ❌ Vanity metric -->
<div class="p-5">
<p class="text-sm text-gray-500">Total Packets Processed</p>
<p class="text-3xl font-bold">847,293,471</p>
</div>
<!-- ✅ Actionable metric -->
<div class="p-5">
<p class="text-sm text-gray-500">Devices Needing Attention</p>
<p class="text-3xl font-bold text-amber-600">7</p>
<div class="mt-2 space-y-1">
<p class="text-xs text-red-600">2 offline &gt; 30 min</p>
<p class="text-xs text-amber-600">3 high packet loss</p>
<p class="text-xs text-amber-600">2 nearing capacity</p>
</div>
<a href="#" class="mt-2 inline-block text-xs font-medium text-indigo-600
hover:text-indigo-700">View all →</a>
</div>
```
### ❌ Other Common Anti-Patterns
| Anti-Pattern | Problem | Better Approach |
|-------------|---------|-----------------|
| **Loading spinner everywhere** | User has no sense of what's coming | Skeleton screens that mirror actual layout |
| **Infinite scroll on data tables** | Can't bookmark position, loses context | Paginated with page size selector |
| **Auto-refresh entire page** | Loses user state (scroll, selection) | LiveView partial updates via PubSub |
| **Hamburger menu on desktop** | Hides primary navigation | Always-visible sidebar on desktop |
| **Tooltip for essential info** | Mobile users can't hover | Show inline or in a visible label |
| **Disabled buttons with no explanation** | User doesn't know why they can't act | Tooltip explaining why, or hide the action |
| **"Are you sure?" for everything** | Confirmation blindness | Only for destructive actions; use undo for the rest |
| **Giant forms** | Abandonment, cognitive overload | Multi-step wizard or progressive disclosure |
| **Inconsistent empty states** | Some pages blank, some have messages | Every view handles empty, loading, and error |
---
## Quick Reference: Status Color System for Network Monitoring
```html
<!-- Consistent status indicators across the entire app -->
<!-- Online/Healthy -->
<span class="text-emerald-600 dark:text-emerald-400"></span>
<!-- CSS: bg-emerald-500 for dots, bg-emerald-50 / bg-emerald-500/10 for badges -->
<!-- Warning -->
<span class="text-amber-500 dark:text-amber-400"></span>
<!-- Critical/Offline -->
<span class="text-red-500 dark:text-red-400"></span>
<!-- Unknown/Unreachable -->
<span class="text-gray-400 dark:text-gray-500"></span>
<!-- Maintenance -->
<span class="text-blue-500 dark:text-blue-400"></span>
<!-- Degraded -->
<span class="text-orange-500 dark:text-orange-400"></span>
```
**Apply this everywhere:** sidebar nav icons, table rows, map pins, KPI cards, device headers, alert badges. Consistency is everything.
---
*Last updated: 2026-03-13*