Power Ui logo

Power Ui

Community
mcouthon
power-ui

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.

Overview

Publishermcouthon
Repositoryagents
Skill namepower-ui
Stars
79
Forks
11
Bundled files
Instructions only
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by mcouthon on GitHub. Read the source before you install it.

Installation

Install the Power Ui AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/mcouthon/agents.git /tmp/agents
mkdir -p .claude/skills
cp -r /tmp/agents/generated/claude/skills/power-ui .claude/skills/power-ui
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Power Ui in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Power Ui on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Power Ui is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Power-UI Construction Manual

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.


0. When to Use This Skill

Target Archetype

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.

Relationship with /design

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 defaultpower-ui overrideWhy
Card variants for content display36–40px flat rows for lists; cards for detail panels onlyDensity — 3× more items visible
PageHeader with title/descriptionSectionLabel (ALL-CAPS, 11px, count + meta)Vertical space — ~60px saved per page
Generous spacing personalityDense spacing defaultPower users scan, not browse
Layered shadow depth for premium feelBorders-only or minimal shadowsShadows don't aid scan-line rhythm

Everything not in this table follows /design as-is. The two skills are complementary, not competing.


1. Principles

  1. Dense over spacious. Every pixel earns its place. List rows are 36–40px. Cards are for detail panels — not data grids.
  2. Keyboard-first. Any task achievable by mouse must be achievable by keyboard. j/k to move, Enter to act, Escape to retreat.
  3. AI is a first-class citizen. Contextual "Ask AI" buttons are surface-level — not buried in menus or settings.
  4. Signal over decoration. Color, weight, and icons carry semantic meaning. No chrome for its own sake.
  5. Progressive reveal. Actions appear on hover/focus — they don't consume permanent space.
  6. The interface feels alive. Background activity is visible — pulse indicators, shimmer loading, real-time updates. Static screens feel broken.

1.1 Color Discipline

Color is signal, not decoration. Every hue must answer: "what does this tell the user?" If the answer is nothing, use monochrome.

RuleGuidance
≤ 4 semantic huesPrimary (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 primaryAccent/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 badgesCategory, type, and source badges use gray (muted variant). Only urgency/status badges (critical, overdue, error) earn a semantic color.
Monochrome avatarsUser/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 zoneLLM-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 gradientsIf 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):

RoleExample hueTokenUsed for
PrimaryBlue ~240primaryButtons, links, focus rings, selected-row accent
DangerRed ~25errorCritical alerts, overdue, destructive actions
CautionAmber ~70warningHigh priority, needs-attention, VIP
SuccessGreen ~155successCompleted, 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.


2. Component Patterns

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 valueReplace withPurpose
text-[11px]text-captionCaption/label text
text-[13px]text-body-smBody text (small)
bg-white/[0.03]bg-surface-hoverSubtle hover state
text-primary/70text-accent-mutedMuted accent text
text-muted-foreground/50text-faintFaintest text level

See the /design skill 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 of bg-white/[0.03].

2.1 Section Label

Replace generic page <h1> headers with a structured label: ALL-CAPS entity name · count + optional meta + far-right action cluster.

tsx
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>
  );
}

2.2 Data-Table Row (36–40px)

All list views use flat, fixed-height rows. Never use cards for data grids.

Anatomy:

[Visual] [Identifier] [Title ·············] [Meta] [Timestamp] [Hover actions]
  • Visual — status dot, priority bars, or source icon (14px, shrink-0)
  • Identifier — short ID, font-mono text-[11px] tabular-nums text-primary/70 shrink-0
  • Titlemin-w-0 flex-1 truncate text-[13px], bold for unread, muted for seen
  • Timestampw-8 font-mono text-[11px] tabular-nums text-muted-foreground/50 shrink-0
  • Hover actionsopacity-0 group-hover:opacity-100, 28px icon buttons
tsx
function 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>
  );
}

2.3 Slide-Over Drawer (Detail Panel)

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.

tsx
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.


2.4 Shared Primitives (Required on Every Page)

tsx
<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 */}

2.5 Bulk Action Bar

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).

tsx
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.


2.6 AI Suggestion Chips

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:

PropertyBehavior
SourceRule engine or classifier annotates each item at API response time
StorageTransient — field on the response DTO, not a database column
DismissHides chip for the current session (local React state); reappears on reload
AcceptPerforms the suggested action (archive, snooze, etc.) via the standard mutation
No suggestRow renders normally — no chip, no empty placeholder
tsx
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:

tsx
{
  /* 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))}
    />
  );
}

2.7 Drawer Auto-Advance

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:

SituationBehavior
Last item in listDrawer closes; list shows EmptyState
Empty after bulkDrawer closes; list shows EmptyState
Action failsItem stays in drawer; error toast; no advance

Implementation hint — shift focusedIndex, swap drawer content:

tsx
// 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.


3. Navigation Chrome

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.

3.1 Sidebar Rail

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:

StateStyle
InactiveIcon at 40% opacity, no background
HoverIcon at 70% opacity, no background
ActiveIcon 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:

GroupContentsPosition
PrimaryCore workflows the user visits dailyTop
AdminSettings, configuration, system managementBottom area
SystemConnection status, theme toggle, user/profileRail footer

Separate groups with subtle spacing (8–12px gap), not dividers or labels.

tsx
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.


3.2 Ambient Status

System status lives in the sidebar — not a header bar, not a toast, not a modal.

Where status belongs:

IndicatorLocationCross-reference
Connection health dotRail footer§5.3 ConnectionStatus
Background activity pulseOn the relevant nav item§5.1 ActivityPulse
Unread/active count badgeOn the relevant nav itemNavItem badge prop
  • Connection dot: Always visible in the rail footer. Green/amber/red (see §5.3). The user should never have to click to discover connectivity state.
  • Activity pulse: When a section has background work running (syncing, processing, indexing), its nav item shows a small emerald pulse dot. This replaces spinners or toast messages.
  • Badge counts: Unread counts or active item counts on nav items. Use sparingly — only for items that genuinely need attention. A nav rail full of badges is noise.

3.3 No Header Bar

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 elementKeyboard-first locationWhy
Logo / app nameNot needed — daily-driver users know what app they're inReclaim 44px+ vertical space
Navigation linksSidebar rail (§3.1)Always visible, grouped by function
Search barCommand palette trigger ⌘K (Keyboard Architecture, Layer 3)Search is an action, not a fixture
User avatar / menuRail footer or command paletteInfrequent action, doesn't earn top-row space
Theme toggleRail footer or command paletteOne-time setting, not persistent chrome
Notifications bellBadge count on relevant nav itemContextual, 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.


4. Keyboard Architecture

Four layers, always present. Register all four when adding a new page.

Layer 1 — Global Navigation (g + letter)

Chord-based routing, wired once at app root.

tsx
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.


Layer 2 — List Navigation (j/k cursor)

Per-page hook. Manages focused index, scrolls into view, dispatches action keys.

tsx
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 focusedIndex read inside the keydown handler may be stale due to closure capture. Use a ref (focusedIndexRef.current) or a reducer for production code.


Layer 3 — Command Palette (⌘K)

Global fuzzy search + action launcher. Opens with ⌘K, Escape closes.

tsx
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.


Layer 4 — Shortcut Bar + Overlay

ShortcutBar — persistent bottom strip, route-driven hints.

tsx
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 ?.

tsx
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.


Layer Priority & Conflict Resolution

When multiple layers could handle the same keypress, highest priority wins. Lower layers must not fire.

PriorityLayerExample
1 (highest)Modal / Drawer focus trapEscape closes drawer before clearing list focus
2Command paletteEscape closes palette; typing routes to search input
3List navigationj/k/Enter when no overlay is open
4 (lowest)Global navigationg+letter chords

Implementation pattern — enabled booleans:

Each layer's hook accepts an enabled flag. Disable lower layers when a higher layer is active:

tsx
// 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:

tsx
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:

KeyConflictSolution
/Focus search input vs type in inputGuard with isInputFocused() — only intercept when no input has focus
EnterOpen list item vs submit formList hook skips when document.activeElement is a form control
EscapeMultiple consumers (drawer, palette, list)Priority chain above — highest open layer consumes first
j / kScroll page vs list navigatione.preventDefault() in list hook; guard with isInputFocused()

5. Liveness Patterns

A static screen feels broken. Every page must communicate activity, progress, and connectivity without the user asking.

5.1 Activity Pulse

An animated indicator showing that background processes are running. Place near the entity being processed, in the sidebar nav, or in a status area.

tsx
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:

  • Sidebar nav item — next to the label when that section has background work in progress.
  • Section header — inside SectionLabel meta slot: <ActivityPulse active={isSyncing} />.
  • Status bar — persistent footer area for global process status.

5.2 Skeleton Shimmer

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.

tsx
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:

css
@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.


5.3 Connection / Health Status

An ambient dot showing system connectivity. Place in the sidebar footer or a persistent status area — never in a modal or toast.

StateColorMeaning
Healthybg-emerald-400All systems connected
Degradedbg-amber-400Partial connectivity
Downbg-red-400 animate-pulseConnection lost
tsx
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.


5.4 Real-Time Updates

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:

  • New items prepend to the list with a CSS transition: opacity-0 → opacity-100 over 200ms.
  • Apply a temporary highlight class (e.g., bg-primary/[0.06]) that fades after 1–2 seconds.
  • Preserve the current 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:

tsx
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):

tsx
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:

ScenarioOptimistic?Why
User-initiated actions (archive, done, delete)✅ YesAction intent is clear; revert is simple (restore item)
Toggle actions (star, pin, mute)✅ YesBinary state flip; revert is the inverse toggle
Bulk actions on selected items✅ YesSame as single-item but batched; revert restores the set
Inline edits (rename, re-label)✅ YesSingle field update; revert restores old value
Data fetches / list refreshes❌ NoNo user intent to predict; show loading state instead
Server-computed values (scores, rankings)❌ NoClient can't predict the result; wait for server response
Multi-step operations (wizards, workflows)❌ NoIntermediate state is complex; partial revert is error-prone
Actions with confirmation dialogs❌ NoThe dialog already absorbs perceived latency; optimism adds no UX value

6. AI Integration

6.1 Contextual "Ask AI" Navigation

Surface AI at point of need — rows, detail panels, command palette. Never behind menus.

tsx
// 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:

tsx
// 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, `...`) }

6.2 AI Confidence Indicator

Show AI confidence inline for any AI-generated content (rankings, suggestions, summaries):

tsx
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>
  );
}

7. Visual Impact Planning

The Invisible Change Trap

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.

Phase Sizing Rules

  1. Every phase = one page or one major component, fully transformed. Don't split a page's redesign across multiple phases.
  2. 5-second test per phase: compare before/after screenshots side-by-side. If the change isn't visible within 5 seconds of looking, combine it with other changes until it is.
  3. Screenshot diff required: take before and after screenshots of the affected page. If they look the same at arm's length, the phase isn't meaningful — merge it into a larger phase.
  4. Technical-only phases are OK when they're prerequisites (token migration, hook refactors, build changes) — but label them clearly as "infrastructure" and don't count them as visual progress.

Organize by Page, Not by Concern

❌ Bad (by concern)✅ Good (by page)
Phase 1: Update all color tokensPhase 1: Redesign Inbox page
Phase 2: Update all shadowsPhase 2: Redesign Focus page
Phase 3: Update all typographyPhase 3: Redesign Settings page
Phase 4: Update all componentsPhase 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.


8. New Page Checklist

Structure

  • SectionLabel with ALL-CAPS label + count + meta + actions (no generic <h1>)
  • Skeleton rows (same height as real rows) for loading state
  • EmptyState with icon + title + description + CTA
  • ErrorBanner with retry for error state

Keyboard Integration

  • useListKeyboard wired for every list on the page
  • Page hints added to ShortcutBar getHints()
  • Page shortcuts added to ShortcutOverlay SECTIONS
  • g+letter entry added to GO_TARGETS (new top-level pages only)

Navigation & Discovery

  • Route registered in app router
  • Sidebar/nav item added (top-level pages)
  • ⌘K navigation command added to command palette
  • Entity registered in universal search (if searchable)
  • Cross-entity links where applicable (name → profile)

Quality Gates

  • Screenshot test — would you share this as an example of great UI?
  • Mouse-free test — can a user complete the #1 action without touching the mouse?
  • 5-second test — does a new user understand the page's purpose in 5 seconds?
  • AI-visible test — is AI's contribution surface-level, not buried?
  • Distinctiveness test — does the page have a visual element or interaction that no other app has?

Liveness

  • Skeleton loading matches real row height — no layout shift
  • 8-12 skeleton rows rendered during loading
  • Activity indicators visible when background processes run
  • New items animate into list (fade-in, not hard swap)

Navigation Chrome

  • Sidebar nav item added with monochrome icon (if top-level page)
  • Active state uses opacity shift, not color or border
  • Unread/active count badge (if applicable)

9. New Row Component Checklist

Layout

  • Fixed height h-9 / h-10 (36–40px) — single flex line, no wrapping
  • group flex items-center h-9 px-3 gap-2 cursor-pointer border-b border-border
  • onClick opens slide-over or triggers primary action
  • *RowSkeleton companion for loading state

States

  • Hover: hover:bg-white/[0.03]
  • Active (drawer open): bg-primary/[0.08] border-l-2 border-l-primary
  • Keyboard-focused: bg-white/[0.05] ring-1 ring-inset ring-primary/30
  • Props: isFocused: boolean, isActive: boolean, rowRef: RefCallback

Content

  • Title: min-w-0 flex-1 truncate text-[13px] — never wraps
  • Timestamps: font-mono text-[11px] tabular-nums in faintest foreground
  • Identifiers: font-mono text-[11px] tabular-nums text-primary/70

Hover Actions

  • Wrapper: opacity-0 group-hover:opacity-100 transition-opacity
  • Each button: p-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-surface-raised
  • "Ask AI" button included: askAI(navigate, contextualMessage)
  • Each button in <Tooltip content="Action (key)"> showing keyboard shortcut
  • onClick={(e) => e.stopPropagation()} on action wrapper

Accessibility

  • role="row" · tabIndex={-1} · aria-selected={isActive}
  • aria-label on every icon-only button

10. Anti-Patterns & Quality Gates

Anti-patternProblemFix
Cards for data-grid lists4× space waste; breaks scan rhythmUse 36–40px flat rows
Modal for detail viewLoses list contextUse slide-over drawer (~55vw)
AI hidden in settings/menusAI never gets usedSurface "Ask AI" on hover in every row
Mouse-required primary actionsExcludes keyboard usersWire every action to a key
Decorative iconsVisual noise without signalEvery icon must carry semantic meaning
Wrapping row contentDestroys scan-line rhythmtruncate on all text, single flex line
Raw color values in JSXBreaks themingToken classes only (text-primary, not #3b82f6)
Generic <h1> page headersWastes vertical real estateSectionLabel with count + meta
Uncontrolled color / rainbow badgesBlows hue budget; color loses signal value≤4 semantic hues; monochrome-default badges (§1.1)
Triage actions that close the drawerUser re-opens drawer 50× per sessionDrawer 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.

Wow Factor Checklist

Final pre-ship gut check (see Quality Gates in the New Page Checklist for detailed per-page verification):

  • Does this phase produce a visible 5-second change? (see Visual Impact Planning)
  • Would you use this page in a portfolio or demo?

Studying Reference Apps

When analyzing competitors or inspiration:

StealDon'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]."


11. Related Skills

SkillUse when
/designStarting a design system from scratch; token philosophy; visual craft principles
/testingWriting behavioral tests for keyboard interactions and component state
/debugDiagnosing keyboard event conflicts, focus trapping issues, z-index stacking bugs

Boundary with /design

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.

Frequently asked questions

What does the Power Ui AI skill do?

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.

Why use Power Ui on TypingMind?

Because you install it once and use it with any model. Power Ui is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Power Ui in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/mcouthon/agents/tree/main/generated/claude/skills/power-ui. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Power Ui?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Power Ui?

As many as you like. As long as a model supports skills, you can use Power Ui with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Power Ui AI skill free?

Yes. It is published on GitHub by mcouthon under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇