# Header Widget System - Design Specification

**Date**: 2025-11-26
**Status**: Design Phase
**Priority**: MEDIUM

---

## Overview

A plugin-aware header widget system that allows plugins and core managers to display status indicators, warnings, and notifications in the web interface header.

### Primary Use Cases

1. **UPS Hardware Missing**: Display warning when UPS HAT is not detected
2. **Plugin Notifications**: Allow plugins to register persistent status indicators
3. **System Warnings**: Battery low, update available, connection issues, etc.
4. **Device Status**: Multi-PM3 device connection status

---

## Architecture

### Widget Types

```typescript
type WidgetSeverity = "info" | "warning" | "error" | "success";

interface HeaderWidget {
  id: string;                    // Unique identifier (e.g., "ups.missing", "plugin.hello_world.status")
  source: string;                // Source identifier (e.g., "ups_manager", "plugin:hello_world")
  severity: WidgetSeverity;      // Visual severity level
  icon?: string;                 // Optional emoji/icon
  message: string;               // Display message
  dismissible: boolean;          // Can user dismiss?
  action?: {                     // Optional action button
    label: string;
    url: string;
  };
  metadata?: Record<string, any>; // Additional data
  created_at: string;            // ISO timestamp
  expires_at?: string;           // Optional expiry (ISO timestamp)
}
```

### Backend Components

#### 1. Widget Registry (Plugin Manager Extension)

```python
# app/backend/managers/plugin_manager.py

class WidgetSeverity(str, Enum):
    """Widget severity levels."""
    INFO = "info"
    WARNING = "warning"
    ERROR = "error"
    SUCCESS = "success"

@dataclass
class HeaderWidget:
    """Header widget data."""
    id: str
    source: str
    severity: WidgetSeverity
    message: str
    dismissible: bool = True
    icon: Optional[str] = None
    action_label: Optional[str] = None
    action_url: Optional[str] = None
    metadata: Optional[Dict[str, Any]] = None
    created_at: Optional[str] = None
    expires_at: Optional[str] = None

class PluginManager:
    def __init__(self):
        # Existing code...
        self._header_widgets: Dict[str, HeaderWidget] = {}
        self._dismissed_widgets: Set[str] = set()  # User-dismissed widgets

    def register_widget(self, widget: HeaderWidget) -> bool:
        """Register a header widget.

        Args:
            widget: HeaderWidget to register

        Returns:
            True if registered successfully
        """
        if widget.id in self._dismissed_widgets:
            return False  # User dismissed this widget

        self._header_widgets[widget.id] = widget
        return True

    def unregister_widget(self, widget_id: str):
        """Remove a widget from the registry."""
        self._header_widgets.pop(widget_id, None)

    def get_active_widgets(self) -> List[HeaderWidget]:
        """Get all active, non-expired widgets."""
        now = datetime.now()
        active = []

        for widget in self._header_widgets.values():
            # Check if expired
            if widget.expires_at:
                expiry = datetime.fromisoformat(widget.expires_at)
                if now > expiry:
                    continue

            active.append(widget)

        return active

    def dismiss_widget(self, widget_id: str):
        """Mark widget as dismissed by user."""
        self._dismissed_widgets.add(widget_id)
        self.unregister_widget(widget_id)
```

#### 2. UPS Manager Integration

```python
# app/backend/managers/ups_manager.py

class UPSManager:
    def __init__(self):
        # Existing code...
        self._plugin_manager = None  # Injected on startup

    def set_plugin_manager(self, plugin_manager):
        """Inject plugin manager for widget registration."""
        self._plugin_manager = plugin_manager

    async def initialize(self) -> bool:
        """Initialize I2C connection to UPS."""
        success = await self._original_initialize()

        # Register widget if hardware not available
        if not success and self._plugin_manager:
            widget = HeaderWidget(
                id="ups.hardware_missing",
                source="ups_manager",
                severity=WidgetSeverity.WARNING,
                icon="⚠️",
                message="UPS hardware not detected. Battery monitoring unavailable.",
                dismissible=True,
                action_label="Learn More",
                action_url="/settings#ups",
                created_at=datetime.now().isoformat()
            )
            self._plugin_manager.register_widget(widget)

        return success
```

#### 3. API Endpoints

```python
# app/backend/api/system.py (add to existing router)

class WidgetResponse(BaseModel):
    """Header widget response model."""
    id: str
    source: str
    severity: str
    message: str
    dismissible: bool
    icon: Optional[str] = None
    action_label: Optional[str] = None
    action_url: Optional[str] = None
    created_at: str
    expires_at: Optional[str] = None

@router.get("/widgets", response_model=List[WidgetResponse])
async def get_header_widgets():
    """Get all active header widgets.

    Returns:
        List of active widgets
    """
    try:
        plugin_manager = get_plugin_manager()
        widgets = plugin_manager.get_active_widgets()

        return [
            WidgetResponse(
                id=w.id,
                source=w.source,
                severity=w.severity.value,
                message=w.message,
                dismissible=w.dismissible,
                icon=w.icon,
                action_label=w.action_label,
                action_url=w.action_url,
                created_at=w.created_at or datetime.now().isoformat(),
                expires_at=w.expires_at
            )
            for w in widgets
        ]

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@router.post("/widgets/{widget_id}/dismiss")
async def dismiss_widget(widget_id: str):
    """Dismiss a header widget.

    Args:
        widget_id: ID of widget to dismiss

    Returns:
        Success status
    """
    try:
        plugin_manager = get_plugin_manager()
        plugin_manager.dismiss_widget(widget_id)

        return {"success": True, "widget_id": widget_id}

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
```

### Frontend Components

#### 1. Header Widget Component

```typescript
// app/frontend/app/components/HeaderWidgets.tsx

import { useState, useEffect } from "react";
import { Link } from "@remix-run/react";

interface HeaderWidget {
  id: string;
  source: string;
  severity: "info" | "warning" | "error" | "success";
  message: string;
  dismissible: boolean;
  icon?: string;
  action_label?: string;
  action_url?: string;
}

export function HeaderWidgets() {
  const [widgets, setWidgets] = useState<HeaderWidget[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadWidgets();
    // Refresh every 30 seconds
    const interval = setInterval(loadWidgets, 30000);
    return () => clearInterval(interval);
  }, []);

  const loadWidgets = async () => {
    try {
      const response = await fetch("/api/system/widgets");
      if (response.ok) {
        const data = await response.json();
        setWidgets(data);
      }
    } catch (error) {
      console.error("Failed to load widgets:", error);
    } finally {
      setLoading(false);
    }
  };

  const dismissWidget = async (widgetId: string) => {
    try {
      const response = await fetch(`/api/system/widgets/${widgetId}/dismiss`, {
        method: "POST",
      });

      if (response.ok) {
        setWidgets(widgets.filter(w => w.id !== widgetId));
      }
    } catch (error) {
      console.error("Failed to dismiss widget:", error);
    }
  };

  if (loading || widgets.length === 0) {
    return null;
  }

  return (
    <div className="header-widgets">
      {widgets.map((widget) => (
        <div
          key={widget.id}
          className={`widget widget-${widget.severity}`}
          role="alert"
        >
          {widget.icon && <span className="widget-icon">{widget.icon}</span>}

          <span className="widget-message">{widget.message}</span>

          {widget.action_url && widget.action_label && (
            <Link to={widget.action_url} className="widget-action">
              {widget.action_label}
            </Link>
          )}

          {widget.dismissible && (
            <button
              onClick={() => dismissWidget(widget.id)}
              className="widget-dismiss"
              aria-label="Dismiss"
              title="Dismiss"
            >
              ×
            </button>
          )}
        </div>
      ))}
    </div>
  );
}
```

#### 2. CSS Styles

```css
/* app/frontend/app/styles.css - Add to existing file */

/* Header Widgets */
.header-widgets {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  background: var(--color-bg-secondary);
  border-bottom: 1px solid var(--color-border);
}

.widget {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem;
  border-radius: var(--border-radius);
  font-size: 0.9rem;
  animation: slideDown 0.3s ease-out;
}

@keyframes slideDown {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.widget-info {
  background: rgba(0, 200, 255, 0.1);
  border-left: 3px solid var(--color-primary);
  color: var(--color-primary);
}

.widget-warning {
  background: rgba(255, 200, 0, 0.1);
  border-left: 3px solid #ffc800;
  color: #ffc800;
}

.widget-error {
  background: rgba(255, 0, 100, 0.1);
  border-left: 3px solid #ff0064;
  color: #ff0064;
}

.widget-success {
  background: rgba(0, 255, 136, 0.1);
  border-left: 3px solid var(--color-accent);
  color: var(--color-accent);
}

.widget-icon {
  font-size: 1.2rem;
  flex-shrink: 0;
}

.widget-message {
  flex: 1;
  line-height: 1.4;
}

.widget-action {
  padding: 0.4rem 0.8rem;
  background: rgba(255, 255, 255, 0.1);
  border-radius: var(--border-radius);
  text-decoration: none;
  color: inherit;
  font-size: 0.85rem;
  font-weight: 500;
  transition: background 0.2s;
  white-space: nowrap;
}

.widget-action:hover {
  background: rgba(255, 255, 255, 0.2);
}

.widget-dismiss {
  background: none;
  border: none;
  color: inherit;
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
  padding: 0.25rem 0.5rem;
  opacity: 0.6;
  transition: opacity 0.2s;
  flex-shrink: 0;
}

.widget-dismiss:hover {
  opacity: 1;
}

/* Mobile optimizations */
@media (max-width: 768px) {
  .header-widgets {
    padding: 0.5rem;
  }

  .widget {
    font-size: 0.85rem;
    padding: 0.6rem;
  }

  .widget-action {
    padding: 0.3rem 0.6rem;
    font-size: 0.8rem;
  }
}
```

#### 3. Integration into root.tsx

```tsx
// app/frontend/app/root.tsx - Update App component

import { HeaderWidgets } from "./components/HeaderWidgets";

export default function App() {
  // ... existing code ...

  return (
    <>
      <header className="header">
        <div className="header-content">
          {/* Existing header content */}
        </div>
      </header>

      {/* NEW: Header widgets display area */}
      <HeaderWidgets />

      {/* Desktop Navigation */}
      <nav className="nav">
        {/* ... existing nav ... */}
      </nav>

      {/* Main content */}
      <main className="main">
        <Outlet />
      </main>

      {/* ... rest of app ... */}
    </>
  );
}
```

---

## Plugin Integration

### Example: Hello World Plugin with Widget

```python
# app/plugins/hello_world/main.py

from datetime import datetime, timedelta
from app.backend.managers.plugin_manager import (
    PluginBase,
    HeaderWidget,
    WidgetSeverity
)

class HelloWorldPlugin(PluginBase):
    async def on_enable(self):
        """Called when plugin is enabled."""
        # Get plugin manager instance
        from app.backend.managers.plugin_manager import get_plugin_manager
        plugin_manager = get_plugin_manager()

        # Register a demo widget
        widget = HeaderWidget(
            id="plugin.hello_world.demo",
            source="plugin:hello_world",
            severity=WidgetSeverity.INFO,
            icon="👋",
            message="Hello World plugin is active!",
            dismissible=True,
            action_label="Settings",
            action_url="/settings#plugins",
            created_at=datetime.now().isoformat(),
            expires_at=(datetime.now() + timedelta(hours=1)).isoformat()
        )

        plugin_manager.register_widget(widget)

    async def on_disable(self):
        """Called when plugin is disabled."""
        from app.backend.managers.plugin_manager import get_plugin_manager
        plugin_manager = get_plugin_manager()

        # Clean up widget
        plugin_manager.unregister_widget("plugin.hello_world.demo")
```

---

## Common Widget Use Cases

### 1. UPS Hardware Missing

```python
HeaderWidget(
    id="ups.hardware_missing",
    source="ups_manager",
    severity=WidgetSeverity.WARNING,
    icon="⚠️",
    message="UPS hardware not detected. Battery monitoring unavailable.",
    dismissible=True,
    action_label="Learn More",
    action_url="/settings#ups"
)
```

### 2. Low Battery Warning

```python
HeaderWidget(
    id="ups.battery_low",
    source="ups_manager",
    severity=WidgetSeverity.ERROR,
    icon="🔋",
    message="Battery critically low (5%). Connect to power immediately.",
    dismissible=False,  # Critical - don't allow dismissal
    action_label="Details",
    action_url="/settings#ups"
)
```

### 3. Update Available

```python
HeaderWidget(
    id="updates.available",
    source="update_manager",
    severity=WidgetSeverity.INFO,
    icon="🔄",
    message="Dangerous Pi v1.2.0 is available. You have v1.1.0.",
    dismissible=True,
    action_label="Update Now",
    action_url="/updates"
)
```

### 4. No PM3 Devices

```python
HeaderWidget(
    id="pm3.no_devices",
    source="pm3_device_manager",
    severity=WidgetSeverity.WARNING,
    icon="📡",
    message="No Proxmark3 devices detected. Please connect a device.",
    dismissible=False,
    action_label="Help",
    action_url="/help#pm3-connection"
)
```

### 5. Plugin Update Available

```python
HeaderWidget(
    id="plugin.custom_plugin.update",
    source="plugin:custom_plugin",
    severity=WidgetSeverity.INFO,
    icon="🔌",
    message="Custom Plugin v2.0 is available.",
    dismissible=True,
    action_label="View",
    action_url="/settings#plugins",
    expires_at=(datetime.now() + timedelta(days=7)).isoformat()
)
```

---

## Implementation Checklist

### Phase 1: Backend Infrastructure
- [ ] Add `HeaderWidget` dataclass to plugin_manager.py
- [ ] Add `WidgetSeverity` enum
- [ ] Implement `register_widget()` method
- [ ] Implement `unregister_widget()` method
- [ ] Implement `get_active_widgets()` method
- [ ] Implement `dismiss_widget()` method
- [ ] Add dismissed widgets persistence (optional)

### Phase 2: API Endpoints
- [ ] Add `GET /api/system/widgets` endpoint
- [ ] Add `POST /api/system/widgets/{id}/dismiss` endpoint
- [ ] Add `WidgetResponse` Pydantic model
- [ ] Test endpoints with curl

### Phase 3: UPS Manager Integration
- [ ] Inject plugin_manager into UPSManager
- [ ] Register widget on initialization failure
- [ ] Register widget on critical battery
- [ ] Unregister widget when hardware becomes available

### Phase 4: Frontend Component
- [ ] Create `HeaderWidgets.tsx` component
- [ ] Add CSS styles for widget display
- [ ] Implement auto-refresh (30s interval)
- [ ] Implement dismiss functionality
- [ ] Add to root.tsx layout

### Phase 5: Additional Integrations
- [ ] Update Manager: Register widget for updates
- [ ] PM3 Device Manager: Register widget when no devices
- [ ] Plugin Manager: Allow plugins to register widgets
- [ ] BLE Manager: Register widget when BLE unavailable (optional)

### Phase 6: Testing & Polish
- [ ] Test with UPS hardware disconnected
- [ ] Test dismissal persistence across sessions (optional)
- [ ] Test multiple widgets display
- [ ] Test mobile responsiveness
- [ ] Test widget expiry
- [ ] Add unit tests

---

## Future Enhancements

### Priority 1: Persistence
- Store dismissed widgets in database
- Restore dismissed state across sessions
- Add "Restore dismissed widgets" button in settings

### Priority 2: Advanced Features
- Widget priority/ordering
- Collapsible widget groups
- Widget categories (system, plugins, updates, etc.)
- Toast notifications for transient widgets
- Click-to-expand for long messages

### Priority 3: Plugin Capabilities
- Allow plugins to update widgets dynamically
- Widget templates for common patterns
- Widget analytics (how often dismissed, clicked)

---

## Performance Considerations

1. **Widget Limit**: Cap at 10 active widgets maximum
2. **Refresh Rate**: Poll every 30 seconds (not real-time)
3. **Dismissed Storage**: Store in localStorage (client-side) or database (server-side)
4. **SSE Integration**: Consider using existing SSE for real-time widget updates (optional)

---

## Accessibility

- All widgets have `role="alert"` for screen readers
- Dismiss buttons have `aria-label` attributes
- Action links are keyboard navigable
- Color is not the only indicator (icons + text)
- 44x44px minimum touch targets for mobile

---

## Security Considerations

1. **Widget Content**: Sanitize all widget messages (XSS prevention)
2. **Plugin Widgets**: Validate plugin-registered widgets
3. **Dismissal**: Store dismissed widget IDs, not widget content
4. **Rate Limiting**: Limit widget registration frequency from plugins

---

**Status**: Ready for implementation
**Estimated Effort**: 4-6 hours for Phase 1-4 (core functionality)
**Dependencies**: None (extends existing plugin system)
