# Dashboard Hybrid Redesign — Design + Architecture Spec

Audience: AI coding agents first. Implementers code AGAINST the signatures below — they are frozen contracts. 4 implementers work in parallel on DISJOINT files (ownership table at bottom). Do not edit files you do not own.

Approved design source: `.superpowers/brainstorm/419295-1784213428/content/hybrid.html` (approved hybrid mockup — binding). Vocabulary: `CONTEXT.md` (use `tool`, `slug`, `alias`, `account` exactly; never `profile`).

## 1. Product contract (binding)

- Popup = tab strip: `⚠ Alerts` tab + one tab per provider (Codex / Claude / Grok). Tab badge = count of that tab's accounts needing attention.
- Popup opens on Alerts tab when it has alerts, else last-used provider tab. Tab never switches under the user on data refresh — active tab set only at popup open.
- Alerts tab = attention inbox. One banner card per problem:
  - limit breach → suggested action `Switch to <best>` — best = healthy (`HealthStatus.OK`) account of SAME tool with lowest 5h usage (fallback: lowest secondary when 5h absent, e.g. Grok), excluding the breached account. No candidate → banner renders without action button.
  - broken auth → `Repair…`.
  - Healthy accounts collapse to compact summary lines below banners.
  - Empty state: `All good ✓` heading + quota summary line.
- Provider tab = quota-first white account cards: alias, `DEFAULT` badge, health badge, 5h + 7d (Grok: billing-period) progress bars with `%` and reset countdown. ONE contextual primary button per card: `Set Default` on non-default healthy, `Repair…` on broken, none on default healthy. ALL secondary actions (Reload, Re-authenticate…, Rename…, Remove…) live in a `⋮` popover menu. Remove requires confirm (confirm dialog stays in `indicator.py` — existing `_remove_account` flow).
- Footer: single row, MUST NOT wrap: `＋ Add account` (flat) · `⟳ Ns ago` timestamp · `Reload All` · `⚙`.
- `⚙` opens Settings dialog: auto-close toggle, refresh interval, warning threshold. Persisted (§7).
- Button language: GTK `suggested-action` = green filled primary (Switch / Set Default / Repair-on-broken banner-primary), `destructive-action` = Remove menu item, `flat` = Add. One app-scoped `GtkCssProvider` at `STYLE_PROVIDER_PRIORITY_APPLICATION` — accent only, inherit Cinnamon/Mint-Y theme. NEVER replace theme.
- Switching default = explicit button ONLY. Card click does nothing (mockup question resolved: option B).
- Tray menu untouched. `PopupWindow` shell kept (positioning / auto-close); its CONTENT is fully new.

## 2. Package layout

New package `ui/` at repo root (pytest.ini `pythonpath = .` → import as `ui.x`). Exact modules:

```
ui/__init__.py        # re-exports: Dashboard, DashboardCallbacks, build_dashboard_vm, Settings, SettingsStore
ui/style.py           # apply_app_css, add_css_class
ui/style.css          # app accent CSS (data file, shipped next to style.py)
ui/view_model.py      # pure python — NO gi import, ever
ui/tab_bar.py
ui/alerts_tab.py
ui/provider_tab.py
ui/account_card.py    # new card; top-level account_card.py deleted at integration (§6)
ui/footer.py
ui/settings_dialog.py
ui/settings_store.py  # pure python — NO gi import, ever
ui/dashboard.py       # composition root, single public entry
```

Every widget module MUST import headless (§5). Every module MUST pass `ruff check .` and `mypy .` with NO new ignore entries — write typed code; do not add `[mypy-ui.*] ignore_errors`.

## 3. View-model (`ui/view_model.py`) — Foundation-owned, frozen

Pure python. Imports allowed: stdlib, `health_client` (dataclasses/enum only), `limit_warning`. NO gi, NO widget modules. All dataclasses `@dataclass(frozen=True)`. Widgets stay dumb: every displayed string is pre-formatted here.

### Inputs

```python
@dataclass(frozen=True)
class AccountInput:
    tool: str                       # "codex" | "claude" | "grok"
    slug: str
    alias: str
    plan: str | None
    is_default: bool
    snapshot: AccountSnapshot | None
    checked_at: float | None        # from Indicator.last_fetched
    busy_actions: frozenset[str]    # subset of {"set_default","reload","reauthenticate","rename","remove"}

@dataclass(frozen=True)
class ProviderInput:
    tool: str
    label: str                      # "Codex" | "Claude Code" | "Grok"
    accounts: tuple[AccountInput, ...]
```

### Outputs

```python
@dataclass(frozen=True)
class QuotaBarVM:
    window_label: str        # "5h" | "7d" | "mo"
    fraction: float          # 0.0..1.0 (used, clamped)
    percent_text: str        # "42%"
    reset_text: str          # "↺ 2h10m" | "" when reset_at is None
    level: str               # "ok" | "hot" (used>=80) | "crit" (breached per threshold)

@dataclass(frozen=True)
class AccountVM:
    tool: str
    slug: str
    alias: str
    plan_text: str                   # plan or "unknown"
    is_default: bool
    health: str                      # "ok" | "broken" | "unknown"
    health_text: str                 # "Healthy" | "Needs repair" | "Checking"
    attention: bool                  # broken OR any bar level != "ok"... see rule below
    primary: QuotaBarVM | None       # None when no 5h window (Grok)
    secondary: QuotaBarVM | None
    checked_text: str                # "⟳ 12s ago" | "⟳ 5m ago (stale)" | "never refreshed"
    primary_action: str | None       # "set_default" | "repair" | None
    primary_action_label: str        # "Set Default" | "Repair…" | ""
    busy_actions: frozenset[str]

@dataclass(frozen=True)
class AlertVM:
    kind: str                # "breach" | "broken"
    tool: str
    slug: str
    title_text: str          # "codex/personal at 92% of 5h window" | "grok/roy-grok auth expired"
    detail_text: str         # "resets in 44m · work is at 42%" | "health checks paused"
    action: str | None       # "switch" | "repair" | None
    action_label: str        # "Switch to work" | "Repair…" | ""
    action_slug: str | None  # switch target slug; None for repair/no-action

@dataclass(frozen=True)
class SummaryLineVM:
    text_left: str           # "✓ codex/work ★"  (★ only when default)
    text_right: str          # "5h 42% · 7d 67%" (omit missing windows)

@dataclass(frozen=True)
class AlertsTabVM:
    alerts: tuple[AlertVM, ...]
    healthy_lines: tuple[SummaryLineVM, ...]
    empty_heading: str       # "All good ✓" when alerts empty, else ""
    empty_detail: str        # "5 accounts · highest 5h 42% · highest 7d 67%" when empty, else ""

@dataclass(frozen=True)
class TabVM:
    id: str                  # "alerts" | tool value
    label: str               # "⚠ Alerts" | provider label
    badge_count: int         # 0 → no badge rendered
    badge_level: str         # "err" (any broken on tab) | "warn" | ""

@dataclass(frozen=True)
class ProviderTabVM:
    tool: str
    accounts: tuple[AccountVM, ...]
    add_label: str           # "＋ Add Codex account"

@dataclass(frozen=True)
class DashboardVM:
    tabs: tuple[TabVM, ...]          # alerts tab first, then providers in input order
    alerts: AlertsTabVM
    providers: tuple[ProviderTabVM, ...]
    active_tab: str                  # "alerts" if alerts non-empty else last_used_tab (validated; fallback first provider)
    add_tools: tuple[tuple[str, str], ...]   # (tool, label) pairs for footer Add on Alerts tab
    refresh_text: str                # "⟳ 12s ago" | "⟳ never"
```

### Builder + rules

```python
def build_dashboard_vm(
    providers: Sequence[ProviderInput],
    *,
    now: float,
    last_used_tab: str | None,
    warning_threshold_pct: int,
) -> DashboardVM: ...
```

Decision rules (implement exactly):
1. Evaluated snapshot: mirror `Indicator._breach_evaluation_snapshot` — `UNKNOWN` + both pcts present → treat as `OK`.
2. Breach: `limit_warning.breached_windows(evaluated, five_hour_threshold_pct=warning_threshold_pct)` non-empty (§below: `limit_warning` gains keyword thresholds). One `AlertVM(kind="breach")` per breached ACCOUNT (worst window in title), not per window.
3. Broken: `status == BROKEN` → `AlertVM(kind="broken")`. Broken alerts sort before breach alerts; within kind, input order.
4. Attention (drives tab badges, `AccountVM.attention`, card accent): broken OR breached. NOT plain `hot`.
5. Bar level: `crit` when that window breached, else `hot` when used ≥ 80, else `ok`.
6. Switch target: healthy accounts of same tool minus breached one; min by `primary_used_pct` (None → use `secondary_used_pct`; both None → excluded); tie → lower secondary. None left → `action=None`.
7. `reset_text`: `""` when reset_at None; else `"↺ "` + delta with units `44m` / `2h10m` / `3d4h` (same unit ladder as `tray_model._reset_delta`; reimplement here — do NOT import a private).
8. `checked_text` / `refresh_text` ago format: `<60s → "Ns ago"`, `<60m → "Nm ago"`, `<24h → "Nh ago"`, else `"Nd ago"`; stale (age > 180s) appends `" (stale)"` on `checked_text` only. `refresh_text` uses newest `checked_at` across ALL accounts.
9. `active_tab`: alerts non-empty → `"alerts"`; else `last_used_tab` if it matches a provider tab id; else first provider tool; no providers → `"alerts"`.

`limit_warning.py` change (Foundation): add keyword-only params, defaults = current constants — backward compatible:

```python
def breached_windows(
    snapshot: AccountSnapshot,
    *,
    five_hour_threshold_pct: int = FIVE_HOUR_WARNING_THRESHOLD_PCT,
    seven_day_threshold_pct: int = SEVEN_DAY_WARNING_THRESHOLD_PCT,
) -> frozenset[str]: ...
```

## 4. Callback protocol — frozen

`ui/view_model.py` also defines (so every widget module imports contracts from ONE pure module):

```python
@dataclass(frozen=True)
class DashboardCallbacks:
    on_set_default: Callable[[str, str], None]         # (tool, slug)
    on_reload: Callable[[str | None, str | None], None] # (None,None)=all; (tool,None)=provider; (tool,slug)=one
    on_repair: Callable[[str, str], None]              # (tool, slug)
    on_rename: Callable[[str, str], None]              # (tool, slug) — indicator opens rename dialog
    on_remove: Callable[[str, str], None]              # (tool, slug) — indicator opens confirm dialog
    on_add: Callable[[str], None]                      # (tool)
    on_open_settings: Callable[[], None]               # indicator opens SettingsDialog
    on_tab_changed: Callable[[str], None]              # (tab_id) — indicator persists last_tab
```

Identity = `(tool, slug)` — slug is the immutable key (CONTEXT.md). NEVER key callbacks on alias. Widgets NEVER call registries/services directly — callbacks only, synchronously on the GTK main thread; indicator owns threading.

## 5. Widget module contracts — frozen

Common shape, follow existing `account_card.py` / `popup_window.py` idiom exactly:
- Module-level `_load_gtk_modules()` → `try: import gi ... except Exception: return _FallbackGtk` (gi import ONLY inside this function, never at module top).
- Module-level `_FallbackGtk` + `_Fallback*` widget stubs exposing the attrs its tests assert (`.text`, `.label`, `.children`, `.sensitive`, `.visible`, `.fraction`, `.connect`/`.emit`, `.css_classes` list). Each module owns its own fallbacks — no shared fallback module (keeps files disjoint; matches repo idiom).
- Constructor takes `gtk_module: Any | None = None`; tests pass the module's `_FallbackGtk`.
- Widget construction via defensive `_new_box/_new_label/_new_button` helpers (copy pattern from `account_card.py`).
- Every class exposes `.widget` (root container) and an `update(vm)` that mutates in place — no rebuild-from-scratch on data refresh. Rebuild children only when the set of accounts/alerts/tabs changes.
- CSS classes applied via `ui.style.add_css_class(widget, name)` (safe no-op on fallbacks).

```python
# ui/style.py  (Foundation)
def apply_app_css(gtk_module: Any | None = None, gdk_module: Any | None = None) -> bool:
    """Load ui/style.css into one app-scoped CssProvider at APPLICATION priority.
    Idempotent (module-level flag). Returns False (no-op) headless/on failure."""

def add_css_class(widget: Any, name: str) -> None:
    """widget.get_style_context().add_class(name); fallback: append to widget.css_classes."""
```

```python
# ui/tab_bar.py  (A)
class TabBar:
    def __init__(self, tabs: tuple[TabVM, ...], active: str,
                 on_select: Callable[[str], None], gtk_module: Any | None = None) -> None: ...
    widget: Any
    def update(self, tabs: tuple[TabVM, ...]) -> None: ...   # badges/labels only; keeps active
    def set_active(self, tab_id: str) -> None: ...           # visual state only; does NOT fire on_select
```

```python
# ui/alerts_tab.py  (B)
class AlertsTab:
    def __init__(self, vm: AlertsTabVM, callbacks: DashboardCallbacks,
                 gtk_module: Any | None = None) -> None: ...
    widget: Any
    def update(self, vm: AlertsTabVM) -> None: ...
# action dispatch: kind "breach"+action "switch" → callbacks.on_set_default(tool, action_slug)
#                  kind "broken" → callbacks.on_repair(tool, slug)
```

```python
# ui/provider_tab.py  (C)
class ProviderTab:
    def __init__(self, vm: ProviderTabVM, callbacks: DashboardCallbacks,
                 gtk_module: Any | None = None) -> None: ...
    widget: Any
    cards: dict[str, AccountCard]          # keyed by slug — tests introspect
    def update(self, vm: ProviderTabVM) -> None: ...
```

```python
# ui/account_card.py  (C)
class AccountCard:
    def __init__(self, vm: AccountVM, callbacks: DashboardCallbacks,
                 gtk_module: Any | None = None) -> None: ...
    widget: Any
    def update(self, vm: AccountVM) -> None: ...
# primary button: rendered only when vm.primary_action is not None; suggested-action class;
#   "set_default" → on_set_default, "repair" → on_repair; insensitive while action in busy_actions.
# ⋮ menu: Gtk.MenuButton+Gtk.Popover when available, else Gtk.Menu; fallback records items as
#   list[tuple[label, action]] on `menu_items`. Items (exact order/labels):
#   "Reload" → on_reload(tool, slug); "Re-authenticate…" → on_repair;
#   "Rename…" → on_rename; "Remove…" → on_remove (destructive-action class; confirm lives in indicator).
```

```python
# ui/footer.py  (D)
class Footer:
    def __init__(self, add_label: str, add_tools: tuple[tuple[str, str], ...], refresh_text: str,
                 callbacks: DashboardCallbacks, gtk_module: Any | None = None) -> None: ...
    widget: Any
    def update(self, add_label: str, add_tools: tuple[tuple[str, str], ...], refresh_text: str) -> None: ...
# add button: len(add_tools)==1 → on_add(tool) direct; >1 (Alerts tab) → popover listing labels → on_add(tool).
# "Reload All" → callbacks.on_reload(None, None). "⚙" → callbacks.on_open_settings().
# single horizontal Box, hexpand on timestamp label — row MUST NOT wrap.
```

```python
# ui/settings_store.py  (D) — pure python, NO gi
@dataclass(frozen=True)
class Settings:
    auto_close: bool = False
    refresh_interval_seconds: int = 60          # clamp read to 30..3600
    warning_threshold_pct: int = 15             # clamp read to 1..50; drives 5h breach threshold
    last_tab: str = ""                          # "" = never set

class SettingsStore:
    def __init__(self, path: Path) -> None: ...
    def read(self) -> Settings: ...             # missing/corrupt/partial → field defaults, never raise
    def write(self, settings: Settings) -> None # atomic: NamedTemporaryFile + fsync + replace
                                                # (copy HealthSnapshotStore.write pattern)
```

```python
# ui/settings_dialog.py  (D)
class SettingsDialog:
    def __init__(self, settings: Settings, on_save: Callable[[Settings], None],
                 transient_for: Any | None = None, gtk_module: Any | None = None) -> None: ...
    def open(self) -> None: ...                 # modal Gtk.Dialog; Save → on_save(new Settings), Cancel → nothing
# controls: Switch (auto-close) · SpinButton 30..3600 step 30 (refresh interval, seconds)
#           · SpinButton 1..50 (warning threshold, % remaining on 5h window)
# fallback: records controls + `saved: Settings | None`; `simulate_save(settings)` helper for tests.
# last_tab is NOT shown in the dialog; on_save preserves incoming settings.last_tab.
```

```python
# ui/dashboard.py  (A) — composition root, single public entry
class Dashboard:
    def __init__(self, callbacks: DashboardCallbacks, gtk_module: Any | None = None,
                 alerts_tab_factory: Callable[..., Any] | None = None,
                 provider_tab_factory: Callable[..., Any] | None = None,
                 footer_factory: Callable[..., Any] | None = None) -> None: ...
    # factories default to AlertsTab/ProviderTab/Footer via imports DEFERRED into __init__
    # (repo idiom: Indicator's popup_window_factory). A's tests inject stubs → A never blocks on B/C/D.
    widget: Any                                  # root vertical Box; indicator adds it to PopupWindow content once
    def update(self, vm: DashboardVM) -> None: ...  # NEVER changes active tab
    def show_tab(self, tab_id: str) -> None: ...    # indicator calls on popup open with vm.active_tab
# composes: TabBar + Gtk.Stack (fallback: dict of boxes + visible flag) holding AlertsTab and one
#   ProviderTab per tool + Footer. Calls ui.style.apply_app_css once in __init__.
# tab select: set stack page, footer.update(add label/tools for that tab), callbacks.on_tab_changed(tab_id).
# footer add label: provider tab → its ProviderTabVM.add_label + [(tool,label)];
#   alerts tab → "＋ Add account" + vm.add_tools.
```

## 6. Integration contract (`indicator.py`) — Integration wave, AFTER A–D land

`indicator.py` KEEPS: tray icon + warning icon, tray menu (untouched), `RefreshScheduler` orchestration, health stores/cache, `_check_limit_warning` notifications, ALL dialogs (device-auth, code-entry, rename, remove-confirm, settings instantiation), `_notify`, busy-action bookkeeping, `PopupWindow` lifecycle.

`indicator.py` GAINS:
- `self._settings_store = SettingsStore(Path(base_dir or "~/.systray-ai") / "settings.json")`; `self._settings = store.read()` at init.
- `self._dashboard: Dashboard | None`. `_ensure_popup`: create `PopupWindow(auto_close=self._settings.auto_close, ...)`, create `Dashboard(self._dashboard_callbacks())`, add `dashboard.widget` to `popup.get_content_area()` ONCE.
- `_build_dashboard_vm() -> DashboardVM`: assemble `ProviderInput`s from `self._providers()`, `self.snapshots`, `self.last_fetched`, `self._busy_actions`; call `build_dashboard_vm(..., now=self._time(), last_used_tab=self._settings.last_tab or None, warning_threshold_pct=self._settings.warning_threshold_pct)`.
- `_dashboard_callbacks()`: map to existing methods, resolving `(tool, slug)` → `Account` via provider registry lookup; `on_open_settings` → `SettingsDialog(self._settings, on_save=self._apply_settings, ...).open()`; `on_tab_changed` → replace `self._settings` `last_tab` + `store.write` (skip write when unchanged).
- `_apply_settings(settings)`: write store; `popup.set_auto_close(settings.auto_close)`; reschedule health timer (`GLib.source_remove(self._health_timer_id)` + `timeout_add_seconds(settings.refresh_interval_seconds, ...)` — store timer id at `build()`); `self._rebuild_popup_if_open()` so thresholds re-evaluate.
- `_check_limit_warning`: pass `five_hour_threshold_pct=self._settings.warning_threshold_pct` to `breached_windows`.

REPLACE: `_rebuild_popup` body → `self._dashboard.update(self._build_dashboard_vm())`. `_open_popup` additionally calls `self._dashboard.show_tab(vm.active_tab)` before show. `_update_dashboard_account(account)` body → `self._rebuild_popup_if_open()` (whole-VM update; widgets diff internally) — keep the method name, callers unchanged.

DELETE (dead after cutover — remove, do not comment out):
- `indicator.py`: `_FallbackPopupButton`, `_FallbackPopupLabel`, `_FallbackPopupBox`; fields `_dashboard_cards`, `_dashboard_reload_buttons`, `_dashboard_add_buttons`, `_dashboard_refresh_button`; methods `_new_box`, `_new_button`, `_new_label`, `_add_child`, `_clear_container`, `_vertical_orientation`, `_dashboard_summary_lines`, `_account_needs_attention`; `account_card_factory` ctor param + `self._account_card_factory` + `from account_card import AccountCard`.
- Top-level `account_card.py` + `tests/test_account_card.py` (only consumer was `indicator.py`).
- `ruff.toml`: drop `"account_card.py" = ["F811"]` per-file-ignore. `mypy.ini`: drop `[mypy-account_card]` section.
- Update `tests/test_indicator.py` popup assertions to drive `Dashboard` via its fallback gtk.

## 7. Settings persistence

Path: `<registry base dir>/settings.json` (default `~/.systray-ai/settings.json`) — same dir + same atomic-write convention as `default_slug` pointer and `health_cache.json`. Schema (flat JSON, unknown keys ignored on read):

```json
{"auto_close": false, "refresh_interval_seconds": 60, "warning_threshold_pct": 15, "last_tab": "codex"}
```

Corrupt/missing file → defaults. Out-of-range numbers → clamp (§5 SettingsStore). Classification per CONTEXT.md checks: shared tier (UI preference, not credential state).

## 8. CSS — class names + `ui/style.css`

Class names (exact — tests assert them via `css_classes` on fallbacks):

| Element | Classes |
|---|---|
| Dashboard root | `dashboard` |
| Tab strip / tab / active | `tab-strip` / `tab` / `tab` + `active` |
| Tab badge | `tab-badge` + (`warn` \| `err`) |
| Alert banner | `alert-banner` + (`warn` breach \| `error` broken); children `alert-title`, `alert-detail` |
| Healthy summary block / line | `healthy-summary` / `summary-line` |
| Empty state | `empty-state` |
| Account card | `account-card` + `attention` when `vm.attention` |
| Badges on card | `default-badge`; `health-badge` + (`ok` \| `warn` \| `err`) |
| Quota row/label/bar/value | `quota-row` / `quota-label` / `quota-bar` + (`hot` \| `crit`) / `quota-value` |
| Footer / timestamp | `footer` / `footer-timestamp` |
| Buttons | GTK stock: `suggested-action`, `destructive-action`, `flat` — do NOT restyle these in style.css |

`ui/style.css` content guidance — accent ONLY, inherit theme (colors from approved mockup):

```css
/* cards + banners */
.account-card { background: #ffffff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 12px; }
.account-card.attention { border-color: #e8c9a0; }
.alert-banner.warn  { background: #fdecd3; border: 1px solid #e8c9a0; border-radius: 6px; padding: 10px 12px; }
.alert-banner.error { background: #fadddd; border: 1px solid #e5b8b8; border-radius: 6px; padding: 10px 12px; }
.healthy-summary { background: #ffffff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 8px 12px; }
/* badges */
.default-badge { background: #8fa876; color: #ffffff; border-radius: 9px; padding: 1px 7px; font-size: 10px; font-weight: 600; }
.health-badge.ok   { background: #e2efd9; color: #4e7a3a; border-radius: 9px; padding: 1px 7px; font-size: 10px; }
.health-badge.warn { background: #fdecd3; color: #9a6a1e; border-radius: 9px; padding: 1px 7px; font-size: 10px; }
.health-badge.err  { background: #fadddd; color: #a33333; border-radius: 9px; padding: 1px 7px; font-size: 10px; }
.tab-badge.warn { background: #fdecd3; color: #9a6a1e; border-radius: 9px; padding: 1px 7px; font-size: 10px; }
.tab-badge.err  { background: #fadddd; color: #a33333; border-radius: 9px; padding: 1px 7px; font-size: 10px; }
/* tabs */
.tab-strip .tab.active { border-bottom: 2px solid #8fa876; font-weight: 600; }
/* quota bars — GTK3 ProgressBar node selectors */
progressbar.quota-bar progress { background-color: #8fa876; min-height: 6px; border-radius: 3px; }
progressbar.quota-bar trough   { min-height: 6px; border-radius: 3px; }
progressbar.quota-bar.hot  progress { background-color: #d9930d; }
progressbar.quota-bar.crit progress { background-color: #c0392b; }
/* footer */
.footer-timestamp { font-size: 11px; opacity: 0.55; }
```

DO NOT: set fonts, window backgrounds, or button base styles; add `*` selectors; ship any selector not in the table above.

## 9. Headless / fallback strategy

- `ui/view_model.py`, `ui/settings_store.py`: zero GTK — plain import in tests.
- Every other `ui/*` module: lazy gi via `_load_gtk_modules()` + own `_FallbackGtk` (§5 common shape). `import ui.<module>` MUST succeed with no `gi` installed — CI-testable via existing pattern; add one test per module asserting construction with `_FallbackGtk`.
- `ui/style.py`: `apply_app_css` wraps everything in try/except → returns `False` headless. `add_css_class` falls back to appending to `widget.css_classes` (create list attr if absent) — this is what widget tests assert.

## 10. Test plan

Runner: pytest (repo has `pytest.ini`: `pythonpath = .`, `testpaths = tests`). Run: `python3 -m pytest` from repo root. Gates before done: `ruff check .` && `mypy .` && `python3 -m pytest` (CONTEXT.md audit #34).

Required new tests (each owner ships tests WITH the module, same wave; follow `tests/test_account_card.py` idiom — construct with module's `_FallbackGtk`, assert `.text`/`.label`/`.sensitive`/`.css_classes`, fire `button.emit("clicked")`, assert callback args):

| File | Owner | Must cover |
|---|---|---|
| `tests/test_ui_view_model.py` | F | breach/broken alert derivation; switch-target selection incl. Grok no-5h fallback + no-candidate; badge counts/levels; bar levels ok/hot/crit vs threshold; formatted strings (percent, reset `↺ 2h10m`, ago, stale suffix); active_tab rules; empty state |
| `tests/test_ui_settings_store.py` | D | round-trip; missing file → defaults; corrupt JSON → defaults; clamping; atomic write leaves valid file; unknown keys ignored |
| `tests/test_ui_tab_bar.py` | A | render tabs+badges; select fires `on_select`; `set_active` does NOT fire; `update` keeps active |
| `tests/test_ui_dashboard.py` | A | compose; `update` swaps nothing structurally when same shape; `show_tab` switches page + `on_tab_changed`; footer add label follows tab |
| `tests/test_ui_alerts_tab.py` | B | banner per alert, warn/error classes; switch button → `on_set_default(tool, target_slug)`; repair → `on_repair`; healthy lines; empty state text; no-action banner has no button |
| `tests/test_ui_account_card.py` | C | badges, bars, formatted texts from VM; contextual primary button presence per `primary_action`; busy → insensitive; ⋮ `menu_items` labels+dispatch; `update` mutates in place |
| `tests/test_ui_provider_tab.py` | C | card per account keyed by slug; update reuses cards; add/remove account rebuilds |
| `tests/test_ui_footer.py` | D | single-row structure; add single vs multi-tool popover dispatch; Reload All → `on_reload(None, None)`; ⚙ → `on_open_settings`; timestamp update |
| `tests/test_ui_settings_dialog.py` | D | controls reflect settings; save → `on_save` with new values, `last_tab` preserved; cancel → no call |
| `tests/test_indicator.py` (edit) | I | popup builds Dashboard once; `_rebuild_popup` → `dashboard.update`; settings wiring (auto-close, timer reschedule, threshold into `breached_windows`); `last_tab` persisted on tab change |
| `tests/test_limit_warning.py` (edit) | F | keyword thresholds override defaults; defaults unchanged |

## 11. File ownership — DISJOINT, no exceptions

| Owner | Files (create unless marked edit) |
|---|---|
| **F — Foundation** (lands FIRST; A–D depend on it) | `ui/__init__.py`, `ui/style.py`, `ui/style.css`, `ui/view_model.py`, `limit_warning.py` (edit), `tests/test_ui_view_model.py`, `tests/test_limit_warning.py` (edit) |
| **A** | `ui/tab_bar.py`, `ui/dashboard.py`, `tests/test_ui_tab_bar.py`, `tests/test_ui_dashboard.py` |
| **B** | `ui/alerts_tab.py`, `tests/test_ui_alerts_tab.py` |
| **C** | `ui/provider_tab.py`, `ui/account_card.py`, `tests/test_ui_provider_tab.py`, `tests/test_ui_account_card.py` |
| **D** | `ui/footer.py`, `ui/settings_dialog.py`, `ui/settings_store.py`, `tests/test_ui_footer.py`, `tests/test_ui_settings_dialog.py`, `tests/test_ui_settings_store.py` |
| **I — Integration** (lands LAST, after A–D) | `indicator.py` (edit), `tests/test_indicator.py` (edit), delete `account_card.py` + `tests/test_account_card.py`, `ruff.toml` (edit), `mypy.ini` (edit) |

A composes B/C/D by the frozen signatures in §5 through injected factories (imports deferred into `Dashboard.__init__`); A's tests inject stubs. Each of A–D runs its own tests green independently — runtime deps only `ui.view_model` + `ui.style` from F. Cross-file edits are FORBIDDEN: found a contract gap → flag it, do not patch a file you do not own.

`ui/__init__.py` (F) re-exports `AlertsTab`/`ProviderTab`/`Footer` lazily via `__getattr__` (PEP 562) so the package imports before B/C/D land.
