Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions .interface-design/system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# Hassette Design System

## Intent

**Who:** A developer running Home Assistant automations — technical, comfortable with code, checking dashboards between tasks or debugging at midnight.

**Task:** Monitor app health, inspect event flow, review logs, manage scheduled jobs. Rapid scanning of status, not prolonged reading.

**Feel:** Like a well-lit control room. Cool and composed, with a single warm indicator light that draws the eye to what matters. Dense but never cluttered. The interface should feel like a tool you trust — quiet when things are fine, unmistakable when they're not.

## Direction

**Domain:** Home automation control plane — dashboards, status indicators, event streams, entity state, scheduled tasks.

**Color world:** Cool slate walls of a server room, warm amber of an indicator LED, green/red status lights on equipment racks.

**Signature:** The breathing pulse dot (`ht-pulse-dot`) — a slow amber inhale/exhale animation on live-connected panels. Static red when disconnected. It could only exist for a system that maintains a persistent WebSocket to a home automation hub.

**Rejecting:**
- Generic blue primary (every SaaS app) -> warm amber accent (`#D4915C`) for identity
- Drop shadows for depth (Bulma default) -> borders-only depth strategy
- System fonts (invisible) -> Space Grotesk headings (geometric, technical character)
- Standard body copy font -> JetBrains Mono for data/code (this is a developer tool)

---

## Foundation

**Palette:** Cool slate (`--ht-slate-50` through `--ht-slate-900`)
**Accent:** Warm amber `#D4915C`, hover `#c07e4a`, dim `rgba(212, 145, 92, 0.35)`
**Depth strategy:** Borders-only — `rgba(0, 0, 0, 0.08)` default, `0.15` strong, `0.04` subtle. No box-shadows.

### Surfaces

| Level | Token | Value | Use |
| ----- | ----------------------- | ------------------------------ | -------------------------- |
| L0 | `--ht-surface-canvas` | slate-50 (`#f8fafc`) | Page background |
| L1 | `--ht-surface-card` | `#ffffff` | Cards, panels |
| L2 | `--ht-surface-dropdown` | `#ffffff` + strong border | Dropdowns, popovers |
| — | `--ht-surface-sidebar` | slate-900 (`#0f172a`) | Dark sidebar |
| — | `--ht-surface-sticky` | `white` | Sticky table headers |
| — | `--ht-surface-inset` | `rgba(0, 0, 0, 0.05)` | Alert items, nested panels |
| — | `--ht-surface-code` | `#1e1e2e` | Code blocks, tracebacks |

### Semantic Colors

Each semantic color has three tokens: base, `-light` (background tint), `-text` (high-contrast label).

| Semantic | Base | Light | Text |
| -------- | --------- | --------- | --------- |
| Success | `#16a34a` | `#f0fdf4` | `#166534` |
| Danger | `#dc2626` | `#fef2f2` | `#991b1b` |
| Warning | `#ca8a04` | `#fefce8` | `#854d0e` |
| Info | `#2563eb` | `#eff6ff` | `#1e40af` |
| Link | `#7c3aed` | `#f5f3ff` | `#5b21b6` |
| Critical | `#991b1b` | — | — |

### Alert Tints

Translucent overlays for alert banners (warning/danger variants):

| Token | Value |
| ---------------------- | ---------------------------- |
| `--ht-warning-bg` | `rgba(234, 179, 8, 0.1)` |
| `--ht-warning-border` | `rgba(234, 179, 8, 0.3)` |
| `--ht-danger-bg` | `rgba(239, 68, 68, 0.1)` |
| `--ht-danger-border` | `rgba(239, 68, 68, 0.3)` |

---

## Typography

| Role | Font | Token |
| ----------- | ----------------- | ------------------- |
| Headings | Space Grotesk 600 | `--ht-font-heading` |
| Body | system-ui stack | `--ht-font-body` |
| Data / code | JetBrains Mono | `--ht-font-mono` |

### Type Scale

| Token | Size | Use |
| ---------------- | ---- | ---------------------------------------- |
| `--ht-text-xs` | 12px | Table data, timestamps, secondary labels |
| `--ht-text-sm` | 13px | Compact UI, badge text |
| `--ht-text-base` | 14px | Body text, form inputs |
| `--ht-text-lg` | 16px | Section headings, emphasis |
| `--ht-text-xl` | 20px | Page headings |
| `--ht-text-2xl` | 24px | Dashboard hero numbers |

---

## Spacing

4px grid. Tokens: `--ht-sp-{1,2,3,4,6,8,12}` = 4, 8, 12, 16, 24, 32, 48px.

---

## Radius

| Token | Value | Use |
| ------------------ | ------ | ---------------------- |
| `--ht-radius-sm` | 3px | Badges, small tags |
| `--ht-radius-md` | 5px | Cards, buttons, inputs |
| `--ht-radius-lg` | 8px | Large panels, modals |
| `--ht-radius-full` | 9999px | Pills, dots |

---

## Component Patterns

### Card (`ht-card`)

```
border: 1px solid var(--ht-border)
border-radius: var(--ht-radius-md) /* 5px */
padding: var(--ht-sp-4) var(--ht-sp-6) /* 16px 24px */
background: var(--ht-surface-card)
```

### Button (`ht-btn`)

```
height: auto (padding-driven)
padding: 0.4em 0.85em
border-radius: var(--ht-radius-md) /* 5px */
font-size: var(--ht-text-sm) /* 13px */
font-weight: 500
border: 1px solid
```

Small variant (`ht-btn--sm`): `padding: 0.25em 0.6em`, `font-size: var(--ht-text-xs)`.

Semantic variants: `--success`, `--danger`, `--warning`, `--info`, `--link`, `--primary` (amber).

### Badge (`ht-badge`)

```
padding: 0.3em 0.5em
border-radius: var(--ht-radius-full) /* pill */
font-size: var(--ht-text-xs) /* 12px */
font-weight: 500
```

Small variant (`ht-badge--sm`): `0.1em 0.45em`, `font-size: 0.6875rem (11px)`.
Medium variant (`ht-badge--md`): `0.2em 0.65em`.

Semantic variants match button patterns. Status-specific: `ht-status-stopped`, `ht-status-disabled`, `ht-status-blocked`.

### Table (`ht-table`)

```
width: 100%
border-bottom: 1px solid var(--ht-border)
th padding: 0.4em 0.85em
td padding: 0.4em 0.85em
```

Dense variant (`ht-table--dense`): `0.25em 0.5em` cell padding.
Striped variant (`ht-table--striped`): alternating `var(--ht-surface-canvas)` rows.

### Input / Select (`ht-input`, `ht-select`)

```
padding: 0.35em 0.6em
border: 1px solid var(--ht-border-strong)
border-radius: var(--ht-radius-md)
font-size: var(--ht-text-base)
```

Small variants: `font-size: var(--ht-text-xs)`, `padding: 0.25em 0.5em`.

### Sidebar

```
width: 220px (expanded), 56px (collapsed icon rail)
background: var(--ht-surface-sidebar) /* slate-900 */
transition: width 0.2s ease
```

Nav link: `padding: 0.6rem 1.25rem`, active state uses `var(--ht-sidebar-active-bg)` + `var(--ht-sidebar-active-color)`.

Mobile: collapses to 56px icon rail by default, expands to 260px overlay with backdrop.

### Pulse Dot (Signature)

```css
.ht-pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ht-amber);
animation: ht-breathe 2s ease-in-out infinite; /* slow inhale/exhale */
}
.ht-pulse-dot--disconnected {
background: var(--ht-danger);
animation: none;
}
```

---

## Layout

### Grid (`ht-grid`)

CSS Grid, 12-column, `gap: var(--ht-sp-4)` (16px). Column classes: `ht-grid-col-{1..12}`.

### Level (`ht-level`)

Flexbox row, `justify-content: space-between`, `align-items: center`. Children: `ht-level-start`, `ht-level-end`, `ht-level-item`.

### Tabs (`ht-tabs`)

Flex row with bottom border. Active tab: amber bottom border + bold text.

---

## Breakpoints

| Breakpoint | Behavior |
| ---------- | ---------------------------------------------------------------------------------------------- |
| > 768px | Desktop: sidebar open, 12-col grid |
| <= 768px | Tablet/mobile: sidebar collapses to icon rail, grid becomes single column, status bar compacts |
| <= 480px | Small phone: reduced padding |

---

## Theming

All tokens live in `static/css/tokens.css` under `:root, [data-theme="default"]`. Components in `style.css` reference only token variables — no hardcoded colors.

To create a new theme: copy `tokens.css`, change the selector to `[data-theme="your-theme"]`, override values. Set `data-theme` attribute on `<html>` to activate.

---

## CSS Class Prefix

All classes use `ht-` prefix. The only non-prefixed class is `is-active` (retained for Alpine.js toggle compatibility on nav links and tabs).
4 changes: 1 addition & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,9 @@ repos:
- id: djlint
name: Lint Jinja2 templates
language: system
entry: uv run djlint --check src/hassette/web/templates/
entry: uv run djlint --reformat src/hassette/web/templates/
pass_filenames: false
files: ^src/hassette/web/templates/
stages:
- pre-push

- id: eslint
name: ESLint JS files
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Changed
- Replaced Bulma CSS framework with a custom `ht-` prefixed design system featuring cool slate surfaces, warm amber accent, and Space Grotesk + JetBrains Mono typography (#262)
- Extracted all design tokens into `tokens.css` with `[data-theme]` selector support for future theming (#262)
- Redesigned dashboard with app status chip grid, activity timeline, and streamlined layout (#262)
- App detail pages now use a flat single-page layout with collapsible metadata, inline tracebacks, and instance switcher dropdown (#262)
- Bus listener and scheduler job tables show expanded detail rows with predicate, rate-limiting, and trigger information (#262)
- Replaced hardcoded CSS fallback colors in alerts and detail panels with proper design tokens (`--ht-surface-inset`, `--ht-surface-code`, `--ht-warning-*`, `--ht-danger-*`)
- Toggle buttons now show fallback text before Alpine.js initializes and expose `aria-expanded` for accessibility (#262)

### Added
- Global alert banner showing HA disconnect warnings and failed app errors with expandable tracebacks (#262)
- `ht-btn--ghost` and `ht-btn--xs` button modifier classes (#262)

### Removed
- Bulma CSS CDN dependency (#262)
- Entity Browser page and related partials (#262)

## Previous Unreleased

### Changed
- E2E tests now run by default with `uv run pytest` instead of requiring `-m e2e`; added `nox -s e2e` session for CI
- `HassetteHarness` now uses a fluent builder API (`with_bus()`, `with_state_proxy()`, etc.) with automatic dependency resolution instead of boolean flags (#253)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ test = [
"pytest-cov>=7.0.0",
"pytest-playwright>=0.6.2",
"pytest-randomly>=3.16.0",
"pytest-repeat>=0.9.4",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"pytest>=8.4.0",
Expand Down
12 changes: 12 additions & 0 deletions src/hassette/bus/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ class ListenerMetrics:
min_duration_ms: float = 0.0
max_duration_ms: float = 0.0

# Listener configuration
predicate_description: str | None = None
debounce: float | None = None
throttle: float | None = None
once: bool = False
priority: int = 0

# Recency
last_invoked_at: float | None = None
last_error_message: str | None = None
Expand Down Expand Up @@ -92,6 +99,11 @@ def to_dict(self) -> dict[str, Any]:
"min_duration_ms": self.min_duration_ms,
"max_duration_ms": self.max_duration_ms,
"total_duration_ms": self.total_duration_ms,
"predicate_description": self.predicate_description,
"debounce": self.debounce,
"throttle": self.throttle,
"once": self.once,
"priority": self.priority,
"last_invoked_at": self.last_invoked_at,
"last_error_message": self.last_error_message,
"last_error_type": self.last_error_type,
Expand Down
10 changes: 10 additions & 0 deletions src/hassette/core/app_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import TYPE_CHECKING

from hassette.types.enums import BlockReason, ResourceStatus
from hassette.utils.exception_utils import get_traceback_string

if TYPE_CHECKING:
from hassette import AppConfig
Expand All @@ -24,6 +25,7 @@ class AppInstanceInfo:
status: ResourceStatus
error: Exception | None = None
error_message: str | None = None
error_traceback: str | None = None
owner_id: str | None = None


Expand Down Expand Up @@ -83,6 +85,7 @@ class AppManifestInfo:
instance_count: int = 0
instances: list[AppInstanceInfo] = field(default_factory=list)
error_message: str | None = None
error_traceback: str | None = None


@dataclass
Expand Down Expand Up @@ -239,6 +242,7 @@ def get_snapshot(self) -> AppStatusSnapshot:
status=ResourceStatus.FAILED,
error=error,
error_message=str(error),
error_traceback=get_traceback_string(error) if error.__traceback__ else None,
)
failed.append(info)

Expand Down Expand Up @@ -285,8 +289,11 @@ def get_full_snapshot(self) -> AppFullSnapshot:
)
)

error_traceback: str | None = None

if app_key in self._failed_apps:
for index, error in self._failed_apps[app_key]:
tb = get_traceback_string(error) if error.__traceback__ else None
instances.append(
AppInstanceInfo(
app_key=app_key,
Expand All @@ -296,10 +303,12 @@ def get_full_snapshot(self) -> AppFullSnapshot:
status=ResourceStatus.FAILED,
error=error,
error_message=str(error),
error_traceback=tb,
)
)
if error_message is None:
error_message = str(error)
error_traceback = tb

block_reason = self._blocked_apps.get(app_key)

Expand All @@ -316,6 +325,7 @@ def get_full_snapshot(self) -> AppFullSnapshot:
instance_count=len(instances),
instances=instances,
error_message=error_message,
error_traceback=error_traceback,
)
)

Expand Down
Loading
Loading