# GUI Consumers (PySide6 / Qt)

> **Conformance:** L1 / L2 (see [`README.md`](README.md) §"Conformance levels for projects").
> **Audience:** Human engineers AND AI coding agents building a desktop / mobile GUI consumer of the Magogi Foundation federation.
> **Reference consumers:** [`vm-vmanager-app`](https://codeberg.org/magogi-admin/vm-vmanager-app) (the canonical vm-vmanager-app GUI; production-scale) and [`hrb-app`](https://codeberg.org/magogi-admin/hrb-app) (smaller surface; easier to read end-to-end).
> **Closes:** **PAPER-59** (federation lacked a written GUI standard until this doc).
> **Propagation (DOC-18):** changing a rule here ripples — walk DOCUMENTATION.md §10.4 and update its dependents in the same commit (README *Index of MUSTs*, the `mgf-fed standards` / `agents` CLI, and any parallel copies / pins the matrix names).

---

## Intent of this standard

This document is the **single source of truth for how a GUI-shaped consumer joins the Magogi Foundation federation**. It exists for three reasons:

1. **Coherence.** Every GUI in the federation faces the same set of Qt-specific decisions (where Qt may live in the import graph, how to keep the event loop responsive while a blocking SDK call runs, how to bridge Qt's message handler to Python's `sys.excepthook`, how to test a GUI without spawning a real engine). Without a written standard each project re-invents these — sometimes badly. This doc collects the answers in one place so the federation's GUI projects converge to the same shape.
2. **Bar.** The other standards docs (`DESIGN_PRINCIPLES.md`, `ERROR_HANDLING.md`, `LOGGING.md`, `CONFIGURATION.md`, …) define the cross-cutting bar. This doc defines the **GUI-specific application** of those rules — what DP-01 layering means when Qt is in the picture, what EH-04 top-level handlers mean when Qt installs its own message handler, what TS-02 isolation means when `pytest-qt` is in the loop. It does not invent new bars; it specialises existing ones.
3. **Drop-in templates.** Every numbered rule below ships a paste-able code skeleton — a worker class, a `main()`, a layering contract, a test fixture. New GUI consumers lift these directly rather than rediscovering the shape.

**Scope.** This standard covers GUI consumers in any Qt-based framework supported by Python — PySide6 today, PyQt6/PySide7 in principle. The rules are framework-neutral where possible; PySide6 is the worked example because every GUI consumer in the federation uses it. Non-Qt desktop frameworks (Textual, Tauri-hybrid, native iOS / Android) are out of scope until a federation consumer uses them — at which point an analogous standard sibling lands.

**Non-scope.** This standard does NOT cover:
- Visual design / UX patterns (icons, layouts, colour choices, accessibility checklists). Those are per-project.
- Specific widget recipes (a "how to build a tab" cookbook). Those live in the per-project repository.
- The federation's underlying contracts (the `mgf-vm-sdk` surface, the libvirt provider, etc.). Those have their own per-project docs.

If you want patterns for any of those, check the reference consumers' source first.

---

## 📦 What ships in `mgf.common`

The base classes a GUI consumer composes against — all in `mgf.common`:

- `mgf.common.bootstrap()` — installs `sys.excepthook`, `threading.excepthook`, asyncio handler, and configures the logging stack. Call this FIRST in `main()`, before any Qt import. See [ERROR_HANDLING.md](ERROR_HANDLING.md) EH-04.
- `mgf.common.observability.Redactor` — applied by the formatter on every log sink. GUI consumers don't configure it; just don't bypass `logging` (no `print()` to stderr with user data).
- `mgf.common.config.BaseAppSettings` — typed Settings for window geometry, recent items, theme preference, OTLP endpoint, etc. See [CONFIGURATION.md](CONFIGURATION.md) CF-01/CF-08.
- `mgf.common.exceptions.AppError` — the root every typed exception lives under. GUI workers catch `AppError`; per-type handling matches on subclasses. See [ERROR_HANDLING.md](ERROR_HANDLING.md) EH-01.
- `mgf.common.vault.VaultProtocol` — for credentials the GUI prompts for and persists. See [SECURITY_STANDARD.md](SECURITY_STANDARD.md) SC-01.

The Qt-side bridge (`qInstallMessageHandler` integration, the worker base classes if you want a project-local one) is NOT in `mgf.common` — it lives in each consumer's `presentation/` or `gui/` layer. The reference is `vm-vmanager-app/src/vmanager/gui/qt_excepthook.py`.

---

## Rules box (read this first)

| ID | Rule | Level |
|---|---|---|
| <a id="GUI-01"></a>**GUI-01** | Federation libraries (anything under `mgf.*` except the GUI-consumer's own `mgf.<consumer>.gui`) MUST NOT import `PySide6` / `PyQt6` at module level. Qt is permitted only in the consumer's presentation layer. The layering MUST be enforced mechanically via `import-linter`. | L1 |
| <a id="GUI-02"></a>**GUI-02** | Blocking or streaming SDK / federation-library calls MUST run off the Qt main thread, and the off-thread worker MUST satisfy the worker invariant: (a) the call runs off the Qt main thread; (b) **exactly one terminal signal fires per operation** — success, failure, or cancellation; (c) **cancellation is a distinct outcome, never reported as failure**. This is the federation's single canonical worker contract — `mgf.common` §8 (COMMON_COMPONENTS), DESIGN_PRINCIPLES §2.1, and ERROR_HANDLING §5 all point here, not to a second vocabulary. The **canonical reference mechanism** is a `QObject` worker moved to a `QThread` emitting typed signals `completed(result)` / `failed(exc)` / `cancelled()` (streaming variant adds `update(item)` + `finished()`, per GUI-07). An **equivalent mechanism that meets the invariant is conformant** — e.g. qasync / `PySide6.QtAsyncio` / `asyncio.to_thread` (the hrb-app async path; see [DESIGN_PRINCIPLES.md](DESIGN_PRINCIPLES.md) §11.4) — when the project records the choice per [`non-adoption-is-valid`](principles/non-adoption-is-valid.md). Synchronous SDK calls on the main thread are a defect. | L1 |
| <a id="GUI-03"></a>**GUI-03** | Every long-running worker operation MUST accept an explicit `CancellationToken` (per [DESIGN_PRINCIPLES.md](DESIGN_PRINCIPLES.md) DP-09). The UI's Cancel button calls `token.cancel()`. The worker honors cancellation at every yield point and emits a cancellation signal (NOT a failure signal) when caught. | L1 |
| <a id="GUI-04"></a>**GUI-04** | GUI workers MUST catch every exception (typed AND untyped), report via typed signals (`completed(result)` / `failed(exc)` / `cancelled()`), and never let an exception cross the worker-thread boundary uncaught. Untyped exceptions MUST flow through the crash reporter before being surfaced. Specialisation of [ERROR_HANDLING.md](ERROR_HANDLING.md) EH-06. | L1 |
| <a id="GUI-05"></a>**GUI-05** | `mgf.common.bootstrap()` MUST run before any Qt import in `main()`. After Qt is loaded but BEFORE `QApplication.exec()`, the consumer MUST install a Qt-side bridge that catches Qt's native messages (`qInstallMessageHandler`) and routes them through Python's `logging`. Reference: `vm-vmanager-app/src/vmanager/gui/qt_excepthook.py`. Specialisation of [ERROR_HANDLING.md](ERROR_HANDLING.md) EH-04. | L1 |
| <a id="GUI-06"></a>**GUI-06** | Presentation widgets (windows, dialogs, panels, models) MUST NOT contain business logic. They render state and emit user-action signals; controllers / workers own the business operations. Imports go one direction: presentation → controllers → SDK. Enforced by `import-linter` as part of GUI-01. | L1 |
| <a id="GUI-07"></a>**GUI-07** | Streaming SDK calls (e.g., `client.watch_vm(vm_id)`) MUST map to a streaming worker that emits one signal per yielded item, plus terminal signals (`finished()` on normal exhaustion, `failed(exc)` on error, `cancelled()` on token cancel). The worker MUST be closable from the main thread; closing it MUST stop the stream and release the underlying SDK resource deterministically (DP-13). | L1 |
| <a id="GUI-08"></a>**GUI-08** | GUI-state persistence (window geometry, last-opened item, recent-history, theme preference, etc.) MUST go through a `BaseAppSettings`-derived class — NOT directly through `QSettings`. The Settings object becomes the single source of truth; `QSettings`, if used, is one of its persistence backends (an internal implementation detail). | L2 |
| <a id="GUI-09"></a>**GUI-09** | GUI unit tests MUST run with `pytest-qt` AND a `Fake*Backend` from the consumed federation library (e.g., `mgf.vm.core.fakes.FakeHypervisorBackend`). Real daemons, real Qt main loops driving real backends, and real DBus / pipe calls are forbidden at unit tier; they belong in the integration tier. Specialisation of [TESTING.md](TESTING.md) TS-02. | L1 |
| <a id="GUI-10"></a>**GUI-10** | GUI features that depend on backend capabilities MUST query `.capabilities()` (or the equivalent on the consumed surface) and show / hide / disable controls accordingly. Hard-coding "this feature is available" — when an adapter that doesn't support it might be the active one — is a defect. Examples: hide "Memory snapshot" when `supports_memory_snapshots=False`; disable "Live migrate" when `supports_live_migration=False`. | L2 |

**Mechanical enforcement matrix** is at the bottom of this doc (§7).

---

## 1. Layering — Qt stays in presentation (rule GUI-01, GUI-06)

The single biggest cause of GUI rot in long-lived projects is **business logic accumulating in widgets** — a button's `clicked` handler that knows how to talk to libvirt, a window that decides retry policy, a dialog that hand-rolls cancellation. Once that happens, the GUI is the application, and the SDK / domain library is just a thin RPC layer that nobody can use from a CLI or a test.

The federation's discipline:

```
  ┌────────────────────────────────────────────┐
  │  presentation/    (Qt widgets, windows,     │  PySide6 imports allowed here only.
  │                   dialogs, models)           │  Knows: layout, signals, user input.
  │                                              │  Knows nothing about: SDK methods,
  │                                              │  business semantics, retry policy.
  └─────────────┬──────────────────────────────┘
                │  imports
                ▼
  ┌────────────────────────────────────────────┐
  │  controllers/, workers/, viewmodels/         │  Bridges presentation to the SDK.
  │                                              │  Knows: which SDK method to call for
  │                                              │  which user action, error categorisation,
  │                                              │  retry / cancel policy, threading.
  │                                              │  Pure Python — testable without Qt.
  └─────────────┬──────────────────────────────┘
                │  imports
                ▼
  ┌────────────────────────────────────────────┐
  │  Federation libraries (mgf-common,           │  No Qt. Ever.
  │                        mgf-vm-sdk, ...)      │
  └────────────────────────────────────────────┘
```

Enforce this with `import-linter`. The canonical contract for a Qt consumer:

```ini
# .importlinter at repo root
[importlinter]
root_packages =
    your_app

[importlinter:contract:no-qt-in-core]
name = Qt may only be imported by presentation/
type = forbidden
source_modules =
    your_app.controllers
    your_app.workers
    your_app.viewmodels
forbidden_modules =
    PySide6
    PyQt6

[importlinter:contract:no-sdk-in-presentation]
name = Presentation imports controllers, not the SDK directly
type = forbidden
source_modules =
    your_app.presentation
forbidden_modules =
    mgf.vm.sdk
    mgf.vm.core.contracts
```

The second contract is the one that bites later — presentation widgets reaching across the controller layer to call the SDK directly is the most common form of regression. Catch it in CI, not in code review months later.

### What about `viewmodels/` vs `controllers/`?

The federation does not mandate either name; the discipline is "one layer between presentation and the SDK." Pick one convention per project and stick with it. The vm-vmanager-app reference uses `controllers/` + `workers/`. hrb-app uses `viewmodels/`. Both are fine.

---

## 2. Off-main-thread blocking calls (rule GUI-02, GUI-03)

The Qt event loop is single-threaded. A synchronous SDK call on the main thread freezes the window — Qt cannot repaint, cannot dispatch events, cannot respond to the user clicking Cancel. The canonical worker pattern:

```python
# your_app/workers/create_vm_worker.py
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from PySide6.QtCore import QObject, Signal

from mgf.common.exceptions import AppError

if TYPE_CHECKING:
    from mgf.vm.core.cancellation import CancellationToken
    from mgf.vm.core.contracts import VmSpec
    from mgf.vm.sdk import VmHandle, VmManagerClient

LOG = logging.getLogger(__name__)


class CreateVmWorker(QObject):
    """Run ``client.create_vm(spec)`` off the Qt main thread.

    Emits one of three terminal signals exactly once:
      * ``completed(VmHandle)``  — success
      * ``failed(AppError)``     — typed failure (worker stays the source of typed exceptions)
      * ``cancelled()``           — caller called token.cancel() before completion
    """

    completed = Signal(object)   # VmHandle
    failed = Signal(AppError)    # typed exception (kept as object on the wire)
    cancelled = Signal()

    def __init__(
        self,
        client: VmManagerClient,
        spec: VmSpec,
        token: CancellationToken,
    ) -> None:
        super().__init__()
        self._client = client
        self._spec = spec
        self._token = token

    def run(self) -> None:
        """Entry point — connect this to QThread.started."""
        try:
            handle = self._client.create_vm(self._spec, token=self._token)
        except Exception as exc:  # GUI-04: catch everything at the worker boundary
            if self._token.is_cancelled():  # canonical CancellationToken API (COMMON_COMPONENTS §7)
                self.cancelled.emit()
                return
            if not isinstance(exc, AppError):
                # GUI-04: untyped exceptions flow through the crash reporter.
                LOG.exception("untyped exception in CreateVmWorker")
                # bootstrap()'s excepthook installs the crash reporter; the
                # log + sys.excepthook call records it. Wrap into a typed
                # error so callers don't have to special-case.
                from mgf.common.exceptions import AppError as _AppError
                exc = _AppError(f"unexpected error during create_vm: {exc!r}")
            self.failed.emit(exc)
            return
        self.completed.emit(handle)
```

And the wiring from the window:

```python
# your_app/windows/main_window.py
from PySide6.QtCore import QThread

from your_app.workers.create_vm_worker import CreateVmWorker
from mgf.vm.core.cancellation import CancellationEvent


class MainWindow(QMainWindow):
    def on_create_clicked(self) -> None:
        spec = self._build_spec_from_form()           # presentation only
        token = CancellationEvent()                    # GUI-03
        worker = CreateVmWorker(self._client, spec, token)
        thread = QThread(self)                          # parented to the window
        worker.moveToThread(thread)
        # Wire the signals BEFORE start()
        thread.started.connect(worker.run)
        worker.completed.connect(self._on_create_completed)
        worker.failed.connect(self._on_create_failed)
        worker.cancelled.connect(self._on_create_cancelled)
        # Cleanup: terminate the thread when the worker is done
        worker.completed.connect(thread.quit)
        worker.failed.connect(thread.quit)
        worker.cancelled.connect(thread.quit)
        thread.finished.connect(worker.deleteLater)
        thread.finished.connect(thread.deleteLater)
        # Cancel button wires to token.cancel()
        self._cancel_button.clicked.connect(token.cancel)
        # Go
        self._thread = thread          # retain so it isn't GC'd mid-run
        self._token = token
        thread.start()
```

Critical details:

- **Wire signals before start.** Otherwise the worker can complete + emit before the slots are connected.
- **Retain the `QThread` instance.** A `QThread` that goes out of scope mid-run is a crash; keep a reference on the window.
- **Cancel is bound to the token, not to the thread.** Quitting the thread doesn't cancel the in-flight SDK call; cancelling the token does.
- **`deleteLater`** on `finished` cleans up both objects deterministically.

For projects that build a lot of workers, factor out a `WorkerRunner` helper. The vm-vmanager-app reference has `src/vmanager/gui/workers/_runner.py` for this.

### Streaming workers (rule GUI-07)

The same pattern, with one extra signal:

```python
class WatchVmWorker(QObject):
    """Stream lifecycle events for one VM."""

    update = Signal(object)      # one signal per yielded VmStatus
    finished = Signal()           # stream closed cleanly
    failed = Signal(AppError)
    cancelled = Signal()

    def run(self) -> None:
        try:
            for status in self._client.watch_vm(self._vm_id, token=self._token):
                self.update.emit(status)
        except Exception as exc:
            # same GUI-04 pattern as above
            ...
            return
        self.finished.emit()

    def stop(self) -> None:
        """Main-thread-callable. Cancels the token; the stream loop sees
        it on its next iteration and the worker terminates cleanly."""
        self._token.cancel()
```

The `update` signal fires once per yielded item — UI slots typically debounce or coalesce if the stream is high-frequency.

---

## 3. The Qt excepthook bridge (rule GUI-05)

Qt installs its OWN message handler for native errors (`QtWarningMsg`, `QtFatalMsg`, …) that BYPASS Python's `sys.excepthook`. If you only install Python's excepthook, you'll lose Qt-side warnings and assertions. The discipline:

```python
# your_app/__main__.py
from __future__ import annotations

import sys


def main() -> int:
    # 1. Bootstrap mgf-common BEFORE any Qt import.
    import mgf.common
    with mgf.common.bootstrap(app_name="your-app", app_version=__version__):
        # 2. Now Qt may load — its message handler will land on top of ours.
        from PySide6.QtWidgets import QApplication

        # 3. Bridge Qt's message handler back to Python logging.
        from your_app.presentation.qt_excepthook import install_qt_handlers
        install_qt_handlers()

        # 4. Run the app.
        qapp = QApplication(sys.argv)
        window = MainWindow()
        window.show()
        return qapp.exec()


if __name__ == "__main__":
    raise SystemExit(main())
```

The `install_qt_handlers()` function is short:

```python
# your_app/presentation/qt_excepthook.py
import logging
import sys

from PySide6.QtCore import (
    QtCriticalMsg,
    QtDebugMsg,
    QtFatalMsg,
    QtInfoMsg,
    QtWarningMsg,
    qInstallMessageHandler,
)

LOG = logging.getLogger("qt")

_LEVEL_MAP = {
    QtDebugMsg: logging.DEBUG,
    QtInfoMsg: logging.INFO,
    QtWarningMsg: logging.WARNING,
    QtCriticalMsg: logging.ERROR,
    QtFatalMsg: logging.CRITICAL,
}


def _qt_message_handler(msg_type, context, message):  # type: ignore[no-untyped-def]
    level = _LEVEL_MAP.get(msg_type, logging.WARNING)
    LOG.log(level, "%s (%s:%d, %s)", message, context.file, context.line, context.function)
    if msg_type == QtFatalMsg:
        # Match Qt's intent: fatal really is fatal. Let Python's
        # excepthook see the abort via sys.exit, not the native
        # abort() that Qt's default handler issues.
        sys.exit(2)


def install_qt_handlers() -> None:
    """Route Qt-native messages through Python logging.

    Must be called AFTER QApplication is constructed and BEFORE
    QApplication.exec() — the message handler is process-global but
    routing depends on the logging stack being live first.
    """
    qInstallMessageHandler(_qt_message_handler)
```

The reference implementation in `vm-vmanager-app/src/vmanager/gui/qt_excepthook.py` adds rate-limiting + redaction-friendly formatting; copy it if you want production hardening on day 1.

### Why this matters

Without the bridge:
- A `QObject` accessed from the wrong thread → Qt warning → swallowed silently → user reports "the app got slow" with no log line you can correlate.
- A Qt-side assertion failure → fatal handler called → process aborts → no crash report.
- An exception inside a `paintEvent()` slot → Qt's default handler prints to stderr → never reaches Sentry / your OTLP collector.

With the bridge: every Qt warning is a logged record passing through the same `Redactor` + handler stack as everything else. Operationally consistent.

---

## 4. Error categorisation in the UI (rule GUI-04 + EH-09)

Typed exceptions are how the UI decides what dialog to show. The federation surface gives you precise types; use them:

```python
def _on_create_failed(self, exc: AppError) -> None:
    """Slot connected to CreateVmWorker.failed."""
    from mgf.vm.core.exceptions import (
        AuthorizationDenied,
        DeviceContended,
        InsufficientCapacity,
        SpecConflict,
        TransportConnectionError,
    )

    if isinstance(exc, SpecConflict):
        # User-actionable — show the field that conflicts.
        self._show_validation_error(str(exc))
    elif isinstance(exc, DeviceContended):
        # Real conflict — offer to wait or pick another device.
        self._offer_alternative_device(exc)
    elif isinstance(exc, InsufficientCapacity):
        self._show_capacity_dialog(str(exc))
    elif isinstance(exc, AuthorizationDenied):
        # Not user-recoverable — show admin contact info.
        self._show_permission_dialog()
    elif isinstance(exc, TransportConnectionError):
        # Show "reconnecting" banner; the SDK's retry policy will
        # re-attempt. This is also the surface that exposes
        # exc.RETRYABLE → True. (See EH-09 for retry UI patterns.)
        self._show_reconnect_banner()
    else:
        # Catch-all — the crash reporter has already recorded this.
        self._show_generic_error(str(exc))
```

Two anti-patterns to avoid:

- **String-matching error messages.** Every typed exception's `RETRYABLE: bool` attribute is the canonical retry signal; never `if "timeout" in str(exc)`.
- **Showing the raw `repr(exc)` in a dialog.** It's full of internals nobody cares about. Render `str(exc)` plus, when present, structured fields (`exc.metadata` if the exception type carries it). The crash reporter has the full `repr` if you need it later.

For the canonical retry-UI shape (banner that retries automatically with backoff, "Cancel retry" button), see `vm-vmanager-app/src/vmanager/gui/widgets/_retry_banner.py`.

---

## 5. Settings persistence (rule GUI-08)

Every GUI persists state — window geometry, last-opened directory, theme, log-level slider. The temptation is to use `QSettings` directly because it's right there in Qt. Don't.

The federation's discipline: **one typed Settings object per process** ([CONFIGURATION.md](CONFIGURATION.md) CF-01). For a GUI consumer:

```python
# your_app/config.py
from pydantic import Field

from mgf.common.config import BaseAppSettings


class AppSettings(BaseAppSettings):
    """Typed application settings."""

    # Operational settings (env-overridable)
    sdk_endpoint: str = Field(default="local", description="SDK endpoint URI")
    log_level: str = "INFO"

    # GUI-state preferences (persisted to ~/.config/your-app/state.yaml)
    main_window_geometry: bytes | None = None
    main_window_state: bytes | None = None
    recent_vms: list[str] = Field(default_factory=list, max_length=20)
    theme: str = "system"
```

And the GUI reads/writes through it:

```python
# In the main window
def closeEvent(self, event) -> None:
    self._settings.main_window_geometry = self.saveGeometry().data()
    self._settings.main_window_state = self.saveState().data()
    self._settings.persist()  # CF-04 atomic file write
    super().closeEvent(event)
```

Why this matters:
- **One representation.** `QSettings` is opaque, untyped, platform-specific (Registry on Windows, plist on macOS, INI on Linux). `BaseAppSettings` is typed, declarative, portable, testable.
- **Env-overridable.** CF-02 says CLI flags > env vars > file > defaults. `QSettings` skips the env-var layer; `BaseAppSettings` honors it.
- **Tests can construct the settings explicitly.** CF-10 says tests pass values, never inherit from the user's actual `QSettings` (which has caused real flakes in real projects).

If you must integrate with platform-native settings storage (e.g., a Windows GPO-managed configuration), make `QSettings` an internal *backend* of your Settings class — not a peer to it. The Settings class is the typed surface; `QSettings` is one of its persistence implementations.

---

## 6. Testing GUI consumers (rule GUI-09)

Two test tiers per the federation's [TESTING.md](TESTING.md):

### Tier 1 — Unit (no real engines, no daemons)

```python
# tests/unit/windows/test_main_window.py
import pytest
from pytestqt.qtbot import QtBot

from mgf.vm.core.fakes import FakeHypervisorBackend
from mgf.vm.sdk import VmManagerClient
from mgf.vm.sdk.transports import InProcessTransport

from your_app.windows.main_window import MainWindow


@pytest.fixture
def client() -> VmManagerClient:
    """A working client backed by the federation's shipped Fake."""
    return VmManagerClient(
        InProcessTransport.from_adapters(
            hypervisor=FakeHypervisorBackend(),
            # storage, device, ...
        ),
    )


def test_create_vm_button_creates_a_vm(qtbot: QtBot, client: VmManagerClient) -> None:
    window = MainWindow(client)
    qtbot.addWidget(window)
    window._name_field.setText("test-vm")
    qtbot.mouseClick(window._create_button, Qt.LeftButton)
    # The worker runs async — wait for completion signal.
    with qtbot.waitSignal(window.vm_created, timeout=2000):
        pass
    assert window.vm_list.count() == 1
```

Rules:
- **No `time.sleep`.** Use `qtbot.waitSignal` or `qtbot.waitUntil`.
- **No real `QApplication`.** `pytest-qt` provides one per session.
- **No real SDK transport.** The `InProcessTransport` + `Fake*Backend` runs in microseconds.
- **No real `QSettings`.** Construct your `AppSettings` explicitly; the fixture's `tmp_path` is the persistence root.

### Tier 2 — Integration (real engines in containers)

For flows that genuinely need libvirt (or whatever your domain engine is), the integration tier spins up a containerised engine and exercises the GUI against the real adapter. These tests are slow; mark them `@pytest.mark.integration` and exclude from the unit suite. See `vm-vmanager-app/tests/integration/` for the reference.

### What NOT to test

- **Pixel-perfect rendering.** Screenshot diffs are brittle; don't.
- **Qt's own behavior.** Don't test that `QPushButton.clicked` fires when you click it.
- **The Fake's internals.** Test your interaction with the Fake's interface, not its bookkeeping.

---

## 7. Mechanical-enforcement matrix

How each rule is mechanically caught:

| Rule | Caught by | Where |
|---|---|---|
| GUI-01 (Qt in core libraries) | `import-linter` | `.importlinter` `no-qt-in-core` contract |
| GUI-02 (off-main-thread blocking) | Code review + `pytest-qt` timing assertions | Tests fail if a worker is missing |
| GUI-03 (CancellationToken plumb-through) | Code review + mypy (the SDK methods declare `token` parameter) | Type errors at signature site |
| GUI-04 (worker catches everything) | Custom ruff rule (project-local) + code review | A worker's `run()` lacking a try/except is a defect |
| GUI-05 (excepthook bridge) | Smoke test that fires a `QtWarningMsg` and asserts it lands in `caplog` | `tests/smoke/test_qt_logging_bridge.py` |
| GUI-06 (presentation has no business logic) | `import-linter` | `.importlinter` `no-sdk-in-presentation` contract |
| GUI-07 (streaming → signals) | Code review | Pattern, not mechanically checkable |
| GUI-08 (BaseAppSettings, not QSettings) | grep for `QSettings(` outside an explicit backend module | `tests/unit/test_no_qsettings_anywhere.py` |
| GUI-09 (pytest-qt + Fakes) | CI matrix: unit tier runs without Docker; fails fast if a test imports a real engine | `pytest.ini` markers |
| GUI-10 (capability-driven UI) | Code review | Pattern, not mechanically checkable |

The two rules NOT mechanically enforceable (GUI-07, GUI-10) are caught in PR review. The §"Self-review checklist" below is the PR author's contract.

---

## 8. Self-review checklist

Before opening a PR that touches GUI code:

- [ ] No `from PySide6 ...` or `from PyQt6 ...` outside the consumer's presentation layer (GUI-01).
- [ ] Every new SDK call site that can block is wrapped in a worker; no synchronous SDK calls on the main thread (GUI-02).
- [ ] Every long-running worker accepts and honors a `CancellationToken` (GUI-03).
- [ ] Every worker's `run()` method is wrapped in a try/except that catches `Exception` and emits the appropriate terminal signal (GUI-04).
- [ ] `mgf.common.bootstrap()` runs before Qt loads in `main()`; `install_qt_handlers()` runs before `QApplication.exec()` (GUI-05).
- [ ] No business logic in widgets — only rendering + signal emission (GUI-06).
- [ ] Streaming SDK calls map to one signal per yielded item; the worker is closable from the main thread (GUI-07).
- [ ] GUI-state persistence goes through `BaseAppSettings`, not `QSettings` directly (GUI-08).
- [ ] New tests use `pytest-qt` + a `Fake*Backend`; no real daemons in the unit tier (GUI-09).
- [ ] Capability-dependent UI controls query `.capabilities()` and show/hide accordingly (GUI-10).

---

## 9. Quick-start checklist for a new GUI consumer

```bash
# 1. Install the foundation + scaffold the federation files
pip install mgf-common
mgf-fed standards gui                              # this doc
mgf-fed init-project --name <your-app> --kind service   # PAPER-59 workaround
# (Generates docs/CLAUDE.md, .claude/settings.json, pyproject snippet.)

# 2. Pin mgf-common with a tight one-minor window
# pyproject.toml [project.dependencies]:
#   mgf-common >= 0.38,<0.39
# Tells you the current window:
mgf-fed federation-sync

# 3. Add Qt + the federation GUI deps
# pyproject.toml [project.optional-dependencies.gui]:
#   PySide6 >= 6.6, <7
# [project.optional-dependencies.gui-dev]:
#   pytest-qt >= 4.4, <5

# 4. Add the import-linter contracts
# (see §1 for the canonical .importlinter file)

# 5. Wire mgf.common.bootstrap() into your main()
# (see §3 for the canonical main())

# 6. File the federation discipline docs
touch DEPS.md STANDARDS_NOTE.md docs/inprogress/MGF_STANDARDS_CONFORMANCE.md
# Templates: mgf-common/DEPS.md, mgf-vm-core/STANDARDS_NOTE.md,
# mgf-common/docs/recipes/mgf-standards-conformance-md-template.md

# 7. Verify the four gates pass on the skeleton
ruff check src tests
mypy --strict src/
pytest --cov --cov-fail-under=80
lint-imports
```

You're set.

---

## 10. Known gotchas (federation-wide; carry forward)

| Paper | What hits you | Workaround |
|---|---|---|
| **PAPER-59** | `mgf-fed init-project` has no `--kind gui`. Generated `docs/CLAUDE.md` mentions `serve()` instead of a Qt event loop. | Use `--kind service`. Add a "Scaffolding gotchas" section to your `docs/CLAUDE.md` noting the kind-stand-in. Close PAPER-59 by adding a real `gui` template. |
| **PAPER-60** | `mgf.common.observability.excepthook` brittle during interpreter / pytest shutdown on Python 3.13 (an unraisable warning during teardown can crash itself trying to log). | Stay on Python 3.12 for the GUI's primary `.venv` until PAPER-60 ships. Workaround: wrap your own `excepthook` body in a `try/except (ValueError, OSError)` defensive layer. |

When you file your own gotcha, see [§"Feedback channels"](../../FEEDBACK.md) at the project root.

---

## 11. Reference consumers

| Consumer | What you can crib | Notes |
|---|---|---|
| [`vm-vmanager-app`](https://codeberg.org/magogi-admin/vm-vmanager-app) | Full PySide6 worker pattern; `qt_excepthook.py`; wizard infrastructure; capability-driven UI in `gui/widgets/`. | Currently mid-port to consume `mgf-vm-sdk` — see `vm-vmanager-docs/ROADMAP.md` Stream E. |
| [`hrb-app`](https://codeberg.org/magogi-admin/hrb-app) | The smaller GUI — easier to read end-to-end; PAPER-59 workaround docs. | First GUI consumer of `mgf-hrb`. |

Clone either, grep for patterns matching your needs, lift them in.

---

## 12. Cross-references

- [DESIGN_PRINCIPLES.md](DESIGN_PRINCIPLES.md) — DP-01 (layering), DP-09 (cancellation), DP-13 (deterministic cleanup); GUI-* extends these.
- [ERROR_HANDLING.md](ERROR_HANDLING.md) — EH-04 (top-level handlers), EH-06 (GUI workers must catch); GUI-04/GUI-05 specialize.
- [LOGGING.md](LOGGING.md) — LG-04 (redaction); applies uniformly. GUI-side Qt warnings flow through Python `logging` per GUI-05.
- [CONFIGURATION.md](CONFIGURATION.md) — CF-01/CF-08 (one typed Settings); GUI-08 mandates `BaseAppSettings` over `QSettings`.
- [TESTING.md](TESTING.md) — TS-02 (no daemons in unit tier); GUI-09 specializes (uses `pytest-qt` + Fakes).
- [OPERABILITY.md](OPERABILITY.md) — applies uniformly; GUIs should expose a "doctor" view that surfaces what `mgf-fed app-doctor` reports.

---

## 13. How this doc evolves

- New GUI-* rules land via PR against this file plus an updated mechanical-enforcement matrix.
- When a federation consumer hits friction with a GUI rule (or notices a missing one), file a paper in [`FEEDBACK.md`](../../FEEDBACK.md). The maintainer triages and either updates this doc or marks the friction as a known limitation.
- When PAPER-59 lands (the `--kind gui` template), §"Quick-start checklist" updates and the workaround disappears.
- When a non-Qt federation GUI consumer arrives (Textual, Tauri, native mobile), this doc either gets framework-neutral language or splits into framework-specific siblings; the rules carry forward.

---

*End of `GUI_CONSUMERS.md`.*
