generated/claude/skills/power-ui/SKILL.md
Build power-user interfaces: keyboard-first, information-dense, AI-present. Covers row patterns, keyboard layers, AI integration, liveness, nav chrome, color discipline, triage UX, visual impact planning, and checklists. Triggers on: 'power-ui', 'keyboard first', 'data table', 'information dense', 'command palette', 'liveness', 'nav chrome', 'color discipline', 'triage UX', 'bulk action bar', 'suggestion chips', 'drawer auto-advance', 'visual impact', 'row component'. Full access mode.
npx skillsauth add mcouthon/agents power-uiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Patterns and checklists for power-user interfaces: information-dense, fully keyboard-operable, AI-present. Stack: React + TypeScript + Tailwind CSS. Adapt token names to your design system.
This skill is for power-user tools — apps where the primary user is a daily-driver who values speed, density, and keyboard mastery over discoverability and onboarding.
Good fits: personal dashboards, admin consoles, developer tools, triage interfaces, AI command centers, ops tools.
Not for: marketing sites, onboarding flows, consumer apps prioritizing discoverability, or tools where most users are occasional visitors. For those, use /design alone.
The /design skill builds your visual foundation — tokens, typography, color, shadows, animation system, component styling. This skill layers interaction architecture on top: keyboard navigation, information density, AI presence, page structure.
Workflow: Start with /design for tokens and primitives → apply this skill for page structure and interaction patterns.
When both skills apply, this skill overrides these /design defaults:
| /design default | power-ui override | Why |
| ------------------------------------- | --------------------------------------------------------- | ------------------------------------- |
| Card variants for content display | 36–40px flat rows for lists; cards for detail panels only | Density — 3× more items visible |
| PageHeader with title/description | SectionLabel (ALL-CAPS, 11px, count + meta) | Vertical space — ~60px saved per page |
| Generous spacing personality | Dense spacing default | Power users scan, not browse |
| Layered shadow depth for premium feel | Borders-only or minimal shadows | Shadows don't aid scan-line rhythm |
Everything not in this table follows /design as-is. The two skills are complementary, not competing.
j/k to move, Enter to act, Escape to retreat.Color is signal, not decoration. Every hue must answer: "what does this tell the user?" If the answer is nothing, use monochrome.
| Rule | Guidance |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ≤ 4 semantic hues | Primary (interactive), success, warning, danger. That's it. If you add a fifth hue, you need to justify what signal it carries that the existing four cannot. |
| Interactive-only primary | Accent/primary color appears ONLY on elements users click — buttons, links, focus rings, selected-row accents. Never on decorative icons, informational labels, or category badges. |
| Monochrome-default badges | Category, type, and source badges use gray (muted variant). Only urgency/status badges (critical, overdue, error) earn a semantic color. |
| Monochrome avatars | User/entity avatars are grayscale (initial on surface-raised). No per-entity color hashing — deterministic hue-from-name creates uncontrolled rainbow noise that blows the hue budget. |
| Anti-AI-purple zone | LLM-generated UIs statistically cluster around purple (hues 260–310 at high chroma). If your primary lands there, drop the chroma significantly or shift the hue — otherwise the app reads as "AI default" rather than intentional. |
| Single-hue gradients | If using gradients, stay within one hue family (lightness/chroma ramp at a fixed hue angle). Multi-hue gradients fight semantic color meaning. |
Hue budget example (adapt hues/tokens to your palette):
| Role | Example hue | Token | Used for |
| ------- | ----------- | --------- | ------------------------------------------------ |
| Primary | Blue ~240 | primary | Buttons, links, focus rings, selected-row accent |
| Danger | Red ~25 | error | Critical alerts, overdue, destructive actions |
| Caution | Amber ~70 | warning | High priority, needs-attention, VIP |
| Success | Green ~155 | success | Completed, passing, healthy |
An info hue (blue) may exist but should be rare — informational banners only, never alongside more than 2 other semantic hues on one screen.
Token convention: Code examples below use raw Tailwind values (
text-[11px],bg-white/[0.03]) for portability. In your project, replace these with semantic tokens from your design system:| Example value | Replace with | Purpose | | -------------------------- | ------------------- | ------------------- | |
text-[11px]|text-caption| Caption/label text | |text-[13px]|text-body-sm| Body text (small) | |bg-white/[0.03]|bg-surface-hover| Subtle hover state | |text-primary/70|text-accent-muted| Muted accent text | |text-muted-foreground/50|text-faint| Faintest text level |See the
/designskill for building your token system.
Theme note: Examples use dark-mode-first values. For light mode, invert the opacity pattern:
bg-black/[0.03]instead ofbg-white/[0.03].
Replace generic page <h1> headers with a structured label: ALL-CAPS entity name · count + optional meta + far-right action cluster.
interface SectionLabelProps {
label: string; // ALL-CAPS, e.g. "ISSUES"
count?: number;
meta?: string; // e.g. "12 unread"
actions?: React.ReactNode;
}
function SectionLabel({ label, count, meta, actions }: SectionLabelProps) {
return (
<div className="flex items-center gap-3 py-2">
<span className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
{label}
</span>
{count !== undefined && (
<span className="font-mono text-[11px] tabular-nums text-muted-foreground/50">
· {count}
</span>
)}
{meta && (
<span className="ml-auto text-[11px] text-muted-foreground/50">
{meta}
</span>
)}
{actions && (
<div className={cn("flex items-center gap-1.5", !meta && "ml-auto")}>
{actions}
</div>
)}
</div>
);
}
All list views use flat, fixed-height rows. Never use cards for data grids.
Anatomy:
[Visual] [Identifier] [Title ·············] [Meta] [Timestamp] [Hover actions]
shrink-0)font-mono text-[11px] tabular-nums text-primary/70 shrink-0min-w-0 flex-1 truncate text-[13px], bold for unread, muted for seenw-8 font-mono text-[11px] tabular-nums text-muted-foreground/50 shrink-0opacity-0 group-hover:opacity-100, 28px icon buttonsfunction DataRow({
id,
title,
isNew,
isFocused,
isActive,
timestamp,
onSelect,
onAction,
rowRef,
}) {
return (
<div
ref={rowRef}
role="row"
tabIndex={-1}
aria-selected={isActive}
onClick={onSelect}
className={cn(
"group flex items-center h-9 px-3 gap-2 cursor-pointer border-b border-border transition-colors duration-75",
"hover:bg-white/[0.03]",
isActive && "bg-primary/[0.08] border-l-2 border-l-primary",
isFocused && !isActive && "bg-white/[0.05]",
isFocused && "ring-1 ring-inset ring-primary/30",
)}
>
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
isNew ? "bg-blue-400" : "bg-muted-foreground/40",
)}
/>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-primary/70">
{id}
</span>
<span
className={cn(
"min-w-0 flex-1 truncate text-[13px]",
isNew ? "font-medium" : "text-muted-foreground",
)}
>
{title}
</span>
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="w-8 text-right font-mono text-[11px] tabular-nums text-muted-foreground/50">
{timestamp}
</span>
</div>
{/* Hover action cluster — see Section 2.2 hover pattern */}
<div
className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Tooltip content="Archive (e)">
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-surface-raised"
onClick={() => onAction("archive")}
aria-label="Archive"
>
<Archive size={13} />
</button>
</Tooltip>
<Tooltip content="Ask AI (a)">
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-surface-raised"
onClick={() =>
askAI(navigate, `Tell me about: "${title}" (ID: ${id})`)
}
aria-label="Ask AI"
>
<MessageSquare size={13} />
</button>
</Tooltip>
</div>
</div>
);
}
Detail views open as a right-side slide-over (~55vw) — not inline expansion, not a modal. Row click or Enter opens it; Escape or backdrop click closes it.
function SlideOver({
open,
onClose,
children,
}: {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<>
{open && (
<div
className="fixed inset-0 z-40 bg-black/20"
onClick={onClose}
aria-hidden
/>
)}
<div
className={cn(
"fixed right-0 top-0 bottom-0 z-50 w-[55vw] max-w-3xl bg-surface border-l border-border",
"transition-transform duration-200 ease-out",
open ? "translate-x-0" : "translate-x-full",
)}
role="dialog"
aria-modal="true"
>
<div className="flex flex-col h-full overflow-hidden">{children}</div>
</div>
</>
);
}
Escape must close the drawer before the page's list-navigation Escape handler fires — use capture or priority ordering in your keyboard hook.
<SkeletonRow /> {/* repeat 8–12×, same height as real row — loading state */}
<EmptyState {/* empty state */}
icon={Inbox} title="No items"
description="Items appear here once ingested."
action={<Button size="sm">Refresh</Button>}
/>
<ErrorBanner message="Failed to load." onRetry={refetch} /> {/* error state */}
A floating bar that appears when ≥2 items are selected in a list. Shows available bulk actions and a selection count.
When it appears: multi-select active (checkboxes or Shift+click range). Disappears when selection is cleared.
Position: fixed bottom-center, z-40 (above list, below drawer at z-50).
function BulkActionBar({
selectedCount,
totalFiltered,
onArchiveAll,
onDoneAll,
onSelectAllFiltered,
onClearSelection,
}) {
if (selectedCount === 0) return null;
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-lg border border-border bg-surface px-4 py-2 shadow-lg">
{/* Selection count — tabular-nums for stable width */}
<span className="text-[13px] font-medium tabular-nums">
{selectedCount} selected
</span>
{/* "Select all N matching filter" — selects entire filtered set, not just visible page */}
<button onClick={onSelectAllFiltered}>
Select all {totalFiltered} matching filter
</button>
<span className="h-4 w-px bg-border" />
{/* Bulk action buttons — one per domain action */}
<button onClick={onArchiveAll}>
<Archive size={13} /> Archive all
</button>
<button onClick={onDoneAll}>
<Check size={13} /> Done all
</button>
<span className="h-4 w-px bg-border" />
{/* Clear selection */}
<button onClick={onClearSelection}>Clear</button>
</div>
);
}
Smart select button: "Select all N matching filter" selects every item in the current filtered view — not just the visible page. For common triage flows, add a domain-specific shortcut: e.g., "Select all low-priority" one-click selects items matching a priority filter.
Keyboard: wire Ctrl+A / ⌘A (when !isInputFocused()) to select all filtered; Escape clears selection.
Proactive AI-suggested actions rendered as lightweight accept/dismiss chips on each row. Suggestions are computed on read — never stored in the database.
Where they appear: right side of the row, before the hover action cluster, or below the row as a secondary line when the suggestion includes a reason.
Pattern:
| Property | Behavior | | ---------- | ------------------------------------------------------------------------------- | | Source | Rule engine or classifier annotates each item at API response time | | Storage | Transient — field on the response DTO, not a database column | | Dismiss | Hides chip for the current session (local React state); reappears on reload | | Accept | Performs the suggested action (archive, snooze, etc.) via the standard mutation | | No suggest | Row renders normally — no chip, no empty placeholder |
interface SuggestionChipProps {
action: string; // "archive" | "snooze" | "respond"
reason: string; // "Noise-priority item" | "VIP sender — may need reply"
onAccept: () => void;
onDismiss: () => void;
}
function SuggestionChip({
action,
reason,
onAccept,
onDismiss,
}: SuggestionChipProps) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-raised px-2 py-0.5 text-[11px]">
<span className="text-muted-foreground">{reason}</span>
<button
onClick={(e) => {
e.stopPropagation();
onAccept();
}}
className="ml-1 rounded px-1.5 py-0.5 font-medium text-primary hover:bg-primary/10"
>
{action}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
className="rounded px-1 py-0.5 text-muted-foreground/50 hover:text-muted-foreground"
aria-label="Dismiss suggestion"
>
✕
</button>
</span>
);
}
Why rule-based, not LLM: Suggestions render on every list page load (potentially 50+ items). LLM calls per item add unacceptable latency and cost. Use structured metadata (priority, category, sender signals) for deterministic rules. Reserve LLM for upstream classification that already annotates items before they reach the list.
Integration in a row:
{
/* Inside DataRow, after title span, before hover actions */
}
{
item.suggestedAction && !dismissed.has(item.id) && (
<SuggestionChip
action={item.suggestedAction.action}
reason={item.suggestedAction.reason}
onAccept={() => handleAction(item.id, item.suggestedAction.action)}
onDismiss={() => setDismissed((prev) => new Set(prev).add(item.id))}
/>
);
}
When a user completes a triage action inside the detail drawer (done, archive, snooze), the drawer automatically advances to the next item in the list instead of closing.
Interaction flow:
User opens item N → acts (archive/done/snooze) → item N removed from list
→ drawer swaps to item N+1 (slide or crossfade transition)
→ user continues triaging without closing/reopening
Edge cases:
| Situation | Behavior |
| ----------------- | --------------------------------------------- |
| Last item in list | Drawer closes; list shows EmptyState |
| Empty after bulk | Drawer closes; list shows EmptyState |
| Action fails | Item stays in drawer; error toast; no advance |
Implementation hint — shift focusedIndex, swap drawer content:
// Inside useDetailDrawer or equivalent hook
function handleTriageAction(itemId: string, action: string) {
const currentIndex = items.findIndex((i) => i.id === itemId);
const removedItem = items[currentIndex]; // capture before state update
// Optimistically remove item from list
setItems((prev) => prev.filter((i) => i.id !== itemId));
// Advance drawer to next item (or close if last)
const nextIndex = Math.min(currentIndex, items.length - 2);
if (nextIndex >= 0) {
setActiveItemId(
items[nextIndex === currentIndex ? currentIndex + 1 : nextIndex]?.id,
);
} else {
closeDrawer();
}
// Fire mutation
mutation.mutate(
{ itemId, action },
{
onError: () => {
setItems((prev) => [...prev, removedItem]); // revert
setActiveItemId(itemId);
},
},
);
}
The drawer content should crossfade (150–200ms opacity transition) rather than slide, so the user perceives "next item appeared" rather than "drawer closed and reopened." Keep the focusedIndex in sync so j/k navigation continues from the new item.
The sidebar rail IS the chrome. No top header bar, no hamburger menu. A narrow icon rail on the left provides navigation, ambient status, and grouping — always visible, never collapsible.
A narrow command rail (48–56px wide) — not a full sidebar with text labels. Monochrome icons only; text labels appear as tooltips on hover.
Active states — opacity-based, not color-based:
| State | Style |
| -------- | ------------------------------------------- |
| Inactive | Icon at 40% opacity, no background |
| Hover | Icon at 70% opacity, no background |
| Active | Icon at 90% opacity, bg-white/[0.05] fill |
No borders, no accent colors, no colored backgrounds on active items. The subtle 5% white fill is the only differentiation — this keeps the rail calm and monochrome.
Grouping — organize by function, not alphabet:
| Group | Contents | Position | | ------- | --------------------------------------------- | ----------- | | Primary | Core workflows the user visits daily | Top | | Admin | Settings, configuration, system management | Bottom area | | System | Connection status, theme toggle, user/profile | Rail footer |
Separate groups with subtle spacing (8–12px gap), not dividers or labels.
interface NavItemProps {
icon: React.ComponentType<{ size?: number }>;
label: string; // Tooltip text
path: string;
isActive: boolean;
badge?: number; // Unread/active count
pulse?: boolean; // Activity indicator (cross-ref §5.1)
}
function NavItem({
icon: Icon,
label,
path,
isActive,
badge,
pulse,
}: NavItemProps) {
return (
<Tooltip content={label} side="right">
{/* Replace with your router's <Link> or onClick + navigate */}
<button
onClick={() => navigate(path)}
aria-label={label}
aria-current={isActive ? "page" : undefined}
className={cn(
"relative flex items-center justify-center w-10 h-10 rounded-md transition-all duration-150",
isActive
? "bg-white/[0.05] text-foreground/90"
: "text-foreground/40 hover:text-foreground/70",
)}
>
<Icon size={18} />
{badge !== undefined && badge > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] rounded-full bg-primary text-[9px] font-medium text-primary-foreground flex items-center justify-center px-0.5">
{badge > 99 ? "99+" : badge}
</span>
)}
{pulse && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
)}
</button>
</Tooltip>
);
}
Adding a nav item: one NavItem in the appropriate group. Wire isActive to the current route. Use a monochrome icon from your icon library (Lucide, Heroicons, etc.) — no custom colored SVGs.
Rail items are reachable via g + letter chord navigation (see Keyboard Architecture, Layer 1) — Tab-based rail traversal is unnecessary since chord navigation is faster for power users.
System status lives in the sidebar — not a header bar, not a toast, not a modal.
Where status belongs:
| Indicator | Location | Cross-reference |
| ------------------------- | ------------------------ | ----------------------- |
| Connection health dot | Rail footer | §5.3 ConnectionStatus |
| Background activity pulse | On the relevant nav item | §5.1 ActivityPulse |
| Unread/active count badge | On the relevant nav item | NavItem badge prop |
Traditional header bars (logo + nav links + search + avatar) waste 44–64px of vertical space and duplicate functionality that belongs elsewhere.
Where header bar contents go instead:
| Traditional header element | Keyboard-first location | Why |
| -------------------------- | ------------------------------------------------------------- | --------------------------------------------- |
| Logo / app name | Not needed — daily-driver users know what app they're in | Reclaim 44px+ vertical space |
| Navigation links | Sidebar rail (§3.1) | Always visible, grouped by function |
| Search bar | Command palette trigger ⌘K (Keyboard Architecture, Layer 3) | Search is an action, not a fixture |
| User avatar / menu | Rail footer or command palette | Infrequent action, doesn't earn top-row space |
| Theme toggle | Rail footer or command palette | One-time setting, not persistent chrome |
| Notifications bell | Badge count on relevant nav item | Contextual, not generic |
The math: A 44px header on a 900px viewport is ~5% of your screen permanently consumed by chrome. On a 36–40px-row layout, that's ~1.2 rows of data you'll never get back. The sidebar rail costs ~52px of horizontal space but gives you the full vertical viewport for content.
The sidebar IS the chrome. Navigation, status, and identity all live in the rail. The main content area is 100% data.
Four layers, always present. Register all four when adding a new page.
g + letter)Chord-based routing, wired once at app root.
const GO_TARGETS: Record<string, { label: string; path: string }> = {
i: { label: "Inbox", path: "/inbox" },
t: { label: "Tasks", path: "/tasks" },
c: { label: "Chat", path: "/chat" },
s: { label: "Settings", path: "/settings" },
// one entry per top-level section
};
// In root layout:
useEffect(() => {
let gPending = false;
const onKey = (e: KeyboardEvent) => {
if (isInputFocused()) return;
if (e.key === "g") {
gPending = true;
setTimeout(() => {
gPending = false;
}, 1000);
return;
}
if (gPending && GO_TARGETS[e.key]) {
navigate(GO_TARGETS[e.key].path);
gPending = false;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [navigate]);
Adding a page: one entry in GO_TARGETS, unused letter.
j/k cursor)Per-page hook. Manages focused index, scrolls into view, dispatches action keys.
function useListKeyboard({ itemCount, onNavigate, onAction, enabled = true }) {
const [focusedIndex, setFocusedIndex] = useState(-1);
useEffect(() => {
if (!enabled) return;
const handler = (e: KeyboardEvent) => {
if (isInputFocused()) return;
if (e.key === "j" || e.key === "ArrowDown") {
e.preventDefault();
setFocusedIndex((i) => Math.min(i + 1, itemCount - 1));
} else if (e.key === "k" || e.key === "ArrowUp") {
e.preventDefault();
setFocusedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && focusedIndex >= 0)
onNavigate?.(focusedIndex);
else if (e.key === "Escape") setFocusedIndex(-1);
else if (focusedIndex >= 0) onAction?.(e.key, focusedIndex);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [enabled, focusedIndex, itemCount, onNavigate, onAction]);
return { focusedIndex, setFocusedIndex };
}
const isInputFocused = () => {
const el = document.activeElement;
return (
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
(el as HTMLElement)?.isContentEditable
);
};
Pass isFocused={focusedIndex === index} to each row and a rowRef callback for scroll-into-view. Disable the hook while drawer or search are open.
Implementation note: The
focusedIndexread inside thekeydownhandler may be stale due to closure capture. Use a ref (focusedIndexRef.current) or a reducer for production code.
Global fuzzy search + action launcher. Opens with ⌘K, Escape closes.
function CommandPalette({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const results = useSearch(query); // entity + action search
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl p-0">
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search or jump to…"
className="w-full px-4 py-3 bg-transparent text-[14px] outline-none border-b border-border"
/>
<ul role="listbox" className="max-h-96 overflow-y-auto py-1">
{results.map((r) => (
<CommandResult key={r.id} result={r} onSelect={onClose} />
))}
</ul>
</DialogContent>
</Dialog>
);
}
Every result should offer an "Ask AI about this" secondary action.
ShortcutBar — persistent bottom strip, route-driven hints.
function getHints(pathname: string): Hint[] {
if (pathname === "/inbox")
return [
{ keys: ["j", "k"], label: "navigate" },
{ keys: ["↵"], label: "open" },
{ keys: ["e"], label: "archive" },
{ keys: ["a"], label: "ask AI" },
{ keys: ["?"], label: "help" },
];
// ... per-page branches
return [
{ keys: ["g…"], label: "go to" },
{ keys: ["⌘K"], label: "search" },
{ keys: ["?"], label: "help" },
];
}
ShortcutOverlay — full reference opened with ?.
const SECTIONS: ShortcutSection[] = [
{
heading: "Navigation",
rows: [
{ keys: [["g"], ["i"]], label: "Go to Inbox" },
// one row per GO_TARGET
],
},
{
heading: "Global",
rows: [
{ keys: [["⌘K"]], label: "Command palette" },
{ keys: [["?"]], label: "Keyboard reference" },
{ keys: [["Esc"]], label: "Close / cancel" },
],
},
{
heading: "List pages",
rows: [
{ keys: [["j"], ["k"]], label: "Next / Previous" },
{ keys: [["↵"]], label: "Open item" },
{ keys: [["a"]], label: "Ask AI about item" },
{ keys: [["/"]], label: "Focus search" },
],
},
// append one section per page
];
Adding a page: one Hint[] branch in getHints() + one ShortcutSection in SECTIONS.
When multiple layers could handle the same keypress, highest priority wins. Lower layers must not fire.
| Priority | Layer | Example |
| ----------- | ------------------------- | ------------------------------------------------------ |
| 1 (highest) | Modal / Drawer focus trap | Escape closes drawer before clearing list focus |
| 2 | Command palette | Escape closes palette; typing routes to search input |
| 3 | List navigation | j/k/Enter when no overlay is open |
| 4 (lowest) | Global navigation | g+letter chords |
Implementation pattern — enabled booleans:
Each layer's hook accepts an enabled flag. Disable lower layers when a higher layer is active:
// Page component
const [drawerOpen, setDrawerOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
useListKeyboard({
itemCount: items.length,
onNavigate: openDrawer,
enabled: !drawerOpen && !paletteOpen,
});
For apps with many layers, centralize with a shared context:
const layer = useActiveLayer(); // returns 'modal' | 'palette' | 'list' | 'global'
useListKeyboard({ enabled: layer === "list" });
Escape key chain — explicit priority order:
if (drawerOpen) → close drawer
else if (paletteOpen) → close palette
else if (focusedIndex >= 0) → clear list focus (set to -1)
else → no-op
Each consumer calls e.stopPropagation() after handling, so lower layers never see the event.
Common conflicts and solutions:
| Key | Conflict | Solution |
| --------- | ------------------------------------------ | ---------------------------------------------------------------------- |
| / | Focus search input vs type in input | Guard with isInputFocused() — only intercept when no input has focus |
| Enter | Open list item vs submit form | List hook skips when document.activeElement is a form control |
| Escape | Multiple consumers (drawer, palette, list) | Priority chain above — highest open layer consumes first |
| j / k | Scroll page vs list navigation | e.preventDefault() in list hook; guard with isInputFocused() |
A static screen feels broken. Every page must communicate activity, progress, and connectivity without the user asking.
An animated indicator showing that background processes are running. Place near the entity being processed, in the sidebar nav, or in a status area.
function ActivityPulse({ active, label }: { active: boolean; label?: string }) {
return (
<span
className="inline-flex items-center gap-1.5"
aria-label={label ?? (active ? "Processing" : "Idle")}
>
<span
className={cn(
"h-2 w-2 rounded-full transition-colors duration-300",
active ? "bg-emerald-400 animate-pulse" : "bg-muted-foreground/30",
)}
/>
{label && (
<span className="text-[11px] text-muted-foreground">{label}</span>
)}
</span>
);
}
Placement guidance:
SectionLabel meta slot: <ActivityPulse active={isSyncing} />.Skeleton rows must match real row height to prevent layout shift. For 36–40px data rows, use h-9 (or h-10 for metadata-dense rows). Render 8–12 skeleton rows to fill the viewport.
function SkeletonRow() {
return (
<div className="flex items-center h-9 px-3 gap-2 border-b border-border animate-shimmer">
{/* Status dot placeholder */}
<span className="h-2 w-2 rounded-full bg-muted-foreground/10" />
{/* ID placeholder */}
<span className="h-3 w-12 rounded bg-muted-foreground/10" />
{/* Title placeholder */}
<span className="h-3 flex-1 max-w-[60%] rounded bg-muted-foreground/10" />
{/* Timestamp placeholder */}
<span className="h-3 w-8 rounded bg-muted-foreground/10 ml-auto" />
</div>
);
}
function SkeletonList({ rows = 10 }: { rows?: number }) {
return (
<div role="status" aria-label="Loading">
{Array.from({ length: rows }, (_, i) => (
<SkeletonRow key={i} />
))}
</div>
);
}
animate-shimmer is a custom Tailwind class (not built-in) — the CSS keyframe block below is required:
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
background: linear-gradient(
90deg,
transparent 25%,
rgba(255 255 255 / 0.03) 50%,
transparent 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
Critical rule: Skeleton h-9 must match DataRow h-9. If your rows use a different height, match it exactly. Height mismatch causes visible jump when real data loads.
An ambient dot showing system connectivity. Place in the sidebar footer or a persistent status area — never in a modal or toast.
| State | Color | Meaning |
| -------- | -------------------------- | --------------------- |
| Healthy | bg-emerald-400 | All systems connected |
| Degraded | bg-amber-400 | Partial connectivity |
| Down | bg-red-400 animate-pulse | Connection lost |
function ConnectionStatus({
state,
details,
}: {
state: "healthy" | "degraded" | "down";
details?: string;
}) {
const colors = {
healthy: "bg-emerald-400",
degraded: "bg-amber-400",
down: "bg-red-400 animate-pulse",
};
return (
<Tooltip content={details ?? state}>
<span
className={cn("inline-block h-2 w-2 rounded-full", colors[state])}
role="status"
aria-label={`Connection: ${state}${details ? ` — ${details}` : ""}`}
/>
</Tooltip>
);
}
Placement: Sidebar footer, bottom-left, next to version or user info. Always visible — never hidden behind a click.
When new items arrive in a list, they should fade in at the top with a brief highlight — not cause a hard re-render that resets scroll position or focus.
Pattern:
opacity-0 → opacity-100 over 200ms.bg-primary/[0.06]) that fades after 1–2 seconds.focusedIndex — if the user is at index 3, they should stay on the same item, not shift down.Optimistic UI for user actions:
Remove acted-on items immediately — never wait for a server round-trip. Use the useOptimisticAction pattern to encapsulate the update → mutate → revert cycle:
function useOptimisticAction<T extends { id: string }>(
setItems: React.Dispatch<React.SetStateAction<T[]>>,
showErrorToast: (message: string) => void,
) {
return useCallback(
(
item: T,
mutationFn: (id: string) => Promise<void>,
opts?: { onSuccess?: () => void },
) => {
// Assumes item exists in current list
// 1. Capture position for revert
const snapshot = { item, index: -1 };
setItems((prev) => {
snapshot.index = prev.findIndex((i) => i.id === item.id);
return prev.filter((i) => i.id !== item.id); // optimistic remove
});
// 2. Fire mutation
mutationFn(item.id)
.then(() => opts?.onSuccess?.())
.catch(() => {
// 3. Revert: re-insert at original position
setItems((prev) => {
const next = [...prev];
next.splice(snapshot.index, 0, snapshot.item);
return next;
});
showErrorToast(`Action failed — item restored.`);
});
},
[setItems, showErrorToast],
);
}
Usage (simple list action):
const optimisticAction = useOptimisticAction(setItems, toast.error);
// In a row's archive button:
<button onClick={() => optimisticAction(item, archiveItem)}>Archive</button>;
For drawer auto-advance (triage flows where the detail panel should swap to the next item after an action), see §2.7 — it extends this pattern with focus-index management and drawer content transitions.
When to use optimistic UI:
| Scenario | Optimistic? | Why | | ---------------------------------------------- | ----------- | ----------------------------------------------------------------------- | | User-initiated actions (archive, done, delete) | ✅ Yes | Action intent is clear; revert is simple (restore item) | | Toggle actions (star, pin, mute) | ✅ Yes | Binary state flip; revert is the inverse toggle | | Bulk actions on selected items | ✅ Yes | Same as single-item but batched; revert restores the set | | Inline edits (rename, re-label) | ✅ Yes | Single field update; revert restores old value | | Data fetches / list refreshes | ❌ No | No user intent to predict; show loading state instead | | Server-computed values (scores, rankings) | ❌ No | Client can't predict the result; wait for server response | | Multi-step operations (wizards, workflows) | ❌ No | Intermediate state is complex; partial revert is error-prone | | Actions with confirmation dialogs | ❌ No | The dialog already absorbs perceived latency; optimism adds no UX value |
Surface AI at point of need — rows, detail panels, command palette. Never behind menus.
// lib/ask-ai.ts
// Replace "/chat" with your app's AI interface route,
// or adapt to open an inline AI panel.
export function askAI(navigate: NavigateFunction, message: string): void {
navigate("/chat", { state: { prefill: message } });
}
// Chat page: read prefill on mount and auto-submit
const { state } = useLocation();
useEffect(() => {
if (state?.prefill) {
setInput(state.prefill);
submitMessage(state.prefill);
}
}, []);
Message format — include entity type, title, and ID:
// Row hover button
onClick={() => askAI(navigate, `Tell me about this issue: "${item.title}" (ID: ${item.id})`)}
// Detail panel header button
onClick={() => askAI(navigate, `Summarize ${item.id}: "${item.title}". What should I do next?`)}
// Command palette secondary action
{ label: `Ask AI about "${result.title}"`, onSelect: () => askAI(navigate, `...`) }
Show AI confidence inline for any AI-generated content (rankings, suggestions, summaries):
function ConfidenceDots({ score }: { score: number }) {
// 0–1
const filled = Math.round(score * 4);
return (
<span className="flex items-center gap-0.5">
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className={cn(
"w-1 h-1 rounded-full",
i < filled ? "bg-primary" : "bg-muted",
)}
/>
))}
</span>
);
}
Spreading micro-adjustments across many files — 2-4px spacing tweaks, subtle color shifts, slightly different font weights — produces zero visible transformation. Each change is correct in isolation, invisible in aggregate. The user sees "nothing changed" despite dozens of modified files.
| ❌ Bad (by concern) | ✅ Good (by page) | | -------------------------------- | ------------------------------- | | Phase 1: Update all color tokens | Phase 1: Redesign Inbox page | | Phase 2: Update all shadows | Phase 2: Redesign Focus page | | Phase 3: Update all typography | Phase 3: Redesign Settings page | | Phase 4: Update all components | Phase 4: Redesign Chat page |
Why "by concern" fails: each phase touches every file in the codebase, produces no visible page-level transformation, and makes rollback impossible — reverting Phase 2 means unwinding shadow changes across every component.
Why "by page" works: each phase produces a complete, shippable transformation for one area. Users see an obvious before/after. Rollback is surgical — revert one page without touching others. Progress is visible and demoable after every phase.
SectionLabel with ALL-CAPS label + count + meta + actions (no generic <h1>)EmptyState with icon + title + description + CTAErrorBanner with retry for error stateuseListKeyboard wired for every list on the pageShortcutBar getHints()ShortcutOverlay SECTIONSg+letter entry added to GO_TARGETS (new top-level pages only)h-9 / h-10 (36–40px) — single flex line, no wrappinggroup flex items-center h-9 px-3 gap-2 cursor-pointer border-b border-borderonClick opens slide-over or triggers primary action*RowSkeleton companion for loading statehover:bg-white/[0.03]bg-primary/[0.08] border-l-2 border-l-primarybg-white/[0.05] ring-1 ring-inset ring-primary/30isFocused: boolean, isActive: boolean, rowRef: RefCallbackmin-w-0 flex-1 truncate text-[13px] — never wrapsfont-mono text-[11px] tabular-nums in faintest foregroundfont-mono text-[11px] tabular-nums text-primary/70opacity-0 group-hover:opacity-100 transition-opacityp-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-surface-raisedaskAI(navigate, contextualMessage)<Tooltip content="Action (key)"> showing keyboard shortcutonClick={(e) => e.stopPropagation()} on action wrapperrole="row" · tabIndex={-1} · aria-selected={isActive}aria-label on every icon-only button| Anti-pattern | Problem | Fix |
| ------------------------------------ | ------------------------------------------ | -------------------------------------------------- |
| Cards for data-grid lists | 4× space waste; breaks scan rhythm | Use 36–40px flat rows |
| Modal for detail view | Loses list context | Use slide-over drawer (~55vw) |
| AI hidden in settings/menus | AI never gets used | Surface "Ask AI" on hover in every row |
| Mouse-required primary actions | Excludes keyboard users | Wire every action to a key |
| Decorative icons | Visual noise without signal | Every icon must carry semantic meaning |
| Wrapping row content | Destroys scan-line rhythm | truncate on all text, single flex line |
| Raw color values in JSX | Breaks theming | Token classes only (text-primary, not #3b82f6) |
| Generic <h1> page headers | Wastes vertical real estate | SectionLabel with count + meta |
| Uncontrolled color / rainbow badges | Blows hue budget; color loses signal value | ≤4 semantic hues; monochrome-default badges (§1.1) |
| Triage actions that close the drawer | User re-opens drawer 50× per session | Drawer auto-advance to next item (§2.7) |
Screenshot test: Before shipping, ask — would someone screenshot this as an example of excellent UI? Common failures: excess whitespace, no keyboard hints visible, AI nowhere in sight, inconsistent row heights.
5-second test: A stranger should identify the page's purpose and primary action within 5 seconds. If not, the information hierarchy is broken.
Final pre-ship gut check (see Quality Gates in the New Page Checklist for detailed per-page verification):
When analyzing competitors or inspiration:
| Steal | Don't copy | | ----------------------------------------------------- | ---------------------------------------- | | Interaction patterns (keyboard model, triage flow) | Visual identity (colors, logo treatment) | | Information density model (row height, column layout) | Domain-specific IA (their nav hierarchy) | | Liveness cues (how they show activity) | Features specific to their domain | | Shortcut discovery patterns (how hints appear) | Onboarding flows (different audience) |
Document your reference mapping: "From [App]: steal [pattern], skip [feature]."
| Skill | Use when |
| ---------- | --------------------------------------------------------------------------------- |
| /design | Starting a design system from scratch; token philosophy; visual craft principles |
| /testing | Writing behavioral tests for keyboard interactions and component state |
| /debug | Diagnosing keyboard event conflicts, focus trapping issues, z-index stacking bugs |
These two skills target different layers of the stack:
/design = visual foundation layer (tokens, typography, color palettes, shadow systems, animation curves, core component styling)power-ui = interaction architecture layer (keyboard navigation, information density, AI presence, page structure, row patterns)When building a power-user app: load both skills. Let /design set up your token system and component primitives first. Then apply power-ui for page layouts, row patterns, and keyboard wiring. Where they conflict (see override table in Section 0), this skill wins.
When building a standard SaaS app: use /design alone. This skill's density and keyboard patterns add unnecessary complexity for apps without a power-user audience.
development
Systematic debugging with hypothesis-driven investigation. Use when something is broken, tests are failing, unexpected behavior occurs, or errors need investigation. Triggers on: 'this is broken', 'debug', 'why is this failing', 'unexpected error', 'not working', 'bug', 'fix this issue', 'investigate', 'tests failing', 'trace the error', 'use debug mode'. Full access mode - can run commands, add logging, and fix issues.
development
Systematic debugging with hypothesis-driven investigation. Use when something is broken, tests are failing, unexpected behavior occurs, or errors need investigation. Triggers on: 'this is broken', 'debug', 'why is this failing', 'unexpected error', 'not working', 'bug', 'fix this issue', 'investigate', 'tests failing', 'trace the error', 'use debug mode'. Full access mode - can run commands, add logging, and fix issues.
testing
Behavioral testing strategy — deciding what to test and how. Use when writing tests, reviewing test quality, or fixing tests that test mocks instead of behavior. Triggers on: 'use testing mode', 'write tests', 'test strategy', 'tests are brittle', 'tests test mocks', 'improve test quality', 'what should I test'. Full access mode - can write and run tests.
development
Use when finding code smells, auditing TODOs, removing dead code, cleaning up unused imports, or assessing code quality. Triggers on: 'use tech-debt mode', 'tech debt', 'code smells', 'clean up', 'remove dead code', 'delete unused', 'simplify'. Full access mode - can modify files and run tests.