- 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
1992 lines
66 KiB
Markdown
1992 lines
66 KiB
Markdown
# 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 70–90%, 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 (150–200ms).
|
||
|
||
```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 > 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*
|