# FEEDBACK — mgf-brand-qt

> Consumer papers for the Qt brand kit, per federation feedback
> discipline (DOC-13 + FB-01..FB-04). `PAPER-NN` is a monotonic ID,
> per this repo. Brand-VALUE issues (token colours, mark geometry,
> icon sources) belong in `mgf-brand/FEEDBACK.md` — this file is for
> the kit's Qt surface (theme engine, QSS, widgets, packaging).

Severity: 🔴 Blocker · 🟡 High · 🟢 Nice-to-have · 💭 Aspirational.
Template: mgf-standard `FEEDBACK.md` header.

---

## Open

### PAPER-01 — `bind_empty_state` leaks its connections and never sees `dataChanged`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted |
| Decided on | 2026-08-07 |
| Scheduled for | 0.1.2 |
| Shipped in | 0.1.2 — the binding object: dataChanged watched, live-model sync, unbind() |

- **What hurts:** `bind_empty_state(view, empty)` connects four model signals to a closure and returns `None`. There is no unbind, no returned handle, and nothing tied to a lifetime — so a view whose model is later replaced (`view.setModel(new)`) keeps four live connections to the **old** model, whose `sync` closure still holds and toggles the *current* view/empty pair. It also does not watch `dataChanged`, so a model that mutates **in place** — same row count, rows filled or emptied by an update — never re-evaluates emptiness, and the widget keeps showing whichever state it last computed.
- **Why it matters:** model swapping is the normal shape for a device console: one view, a model rebuilt or replaced when the broker reconnects or the scope changes. The leak is silent (Qt does not complain about connections to a still-alive model) and the in-place case shows up as the worst kind of UI bug — a stale empty state over a populated table, or an empty table with no explanation, with the kit's own honest-empty-state promise inverted.
- **Evidence:** `src/mgf/brand_qt/widgets/empty.py:113-133` — signature returns `None`; `model.modelReset.connect(sync)`, `rowsInserted`, `rowsRemoved`, `layoutChanged` (four connects, no `disconnect` anywhere in the module); the docstring at `:116-117` states the contract as "Tracks reset/insert/remove signals" — accurate, and that is the gap. `sync` closes over `model`, the local captured at bind time (`:119`), not over `view.model()`.
- **Suggested shape:** return a disposable binding — `bind_empty_state(view, empty) -> EmptyStateBinding` with `.unbind()`, and/or parent the connections to a `QObject` scoped to the pair so Qt tears them down; connect `dataChanged` alongside the other four (cheap: `sync` is a row-count check); and re-read `view.model()` inside `sync` rather than capturing it, so a swap degrades to "no-op" instead of "wrong pair". If a returned handle is too much surface, connecting with `sync` as a slot on a `QObject` child of `empty` closes the leak with no API change.
- **Cost if deferred:** the leak is invisible until a consumer swaps a model under a bound view, and then it presents as an unrelated bug (a widget toggling itself). Fixing the signature later is source-compatible (nobody uses a `None` return), so the API risk of waiting is low — the risk is consumer-side workarounds that hand-roll the binding and never adopt the kit's version.
- **Status:** OPEN

### PAPER-02 — `StatusChip`'s busy pulse runs forever when the chip is hidden (and the kit ships no motion policy for it to consult)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted |
| Decided on | 2026-08-07 |
| Scheduled for | 0.1.2 |
| Shipped in | 0.1.2 — visibility-gated pulse + set_animate + the kit motion policy |

- **What hurts:** a `StatusChip` in the `busy` state starts a `QVariantAnimation` with `setLoopCount(-1)` and stops it only when the state or the `animate` flag changes. Visibility is never consulted — so a chip on a tab the operator switched away from, or in a collapsed panel, or on a window that is minimised, keeps running its animation and driving repaints of an invisible widget for as long as the operation lasts. A hardware console typically has several of these, and "busy" can mean minutes.
- **Why it matters:** this is the shape of a background CPU drain nobody attributes to the brand kit: an idle-looking app that never idles. It also breaks the kit's own motion law (`motion.py`: "motion is instrumentation, not theatre") in the letter it explicitly carves out — the busy pulse is *the* sanctioned looping animation, so it is the one that most needs a bound. **Related (🟢) ask, same root:** `motion.py`'s docstring says everything "respects the `gui_reduce_motion` setting", but the kit ships no `MotionPolicy` / `set_reduce_motion` — the flag is an interface it describes and does not provide. `fade_in()` at least takes `enabled=`; `StatusChip` has an `animate` constructor flag and no way to consult a global policy, so every consumer re-derives the gate and must thread it into every chip it constructs.
- **Evidence:** `src/mgf/brand_qt/widgets/chips.py:101-115` — `_sync_pulse()` builds the animation with `setLoopCount(-1)` (`:110`) and `start()`s it; `:116-121` stops it only on `not wants_pulse`. No `hideEvent` / `showEvent` in the module (grep: zero hits). Motion contract: `src/mgf/brand_qt/motion.py:1-11` (the docstring's "never looping (busy pulses excepted)" plus the `gui_reduce_motion` claim) versus `motion.py:__all__ = ["fade_in"]` — no policy object anywhere in the package.
- **Suggested shape:** gate the pulse on visibility — override `hideEvent` / `showEvent` to `stop()` / restart via the existing `_sync_pulse()`, which already owns both transitions (the change is two small overrides and no new state). For the related ask: a `MotionPolicy` in `motion.py` (`set_reduce_motion(bool)` + `reduce_motion()`), consulted by `StatusChip._sync_pulse` and defaulted by `fade_in(enabled=…)`, so a consumer sets the operator's preference once instead of at every construction site.
- **Cost if deferred:** additive, but every consumer that ships chips before it lands either burns cycles invisibly or writes its own show/hide plumbing around a kit widget — and a consumer-side reduced-motion gate cannot reach inside `StatusChip` at all today, so the accessibility half is not merely inconvenient, it is unreachable.
- **Follow-up (2026-07-30, hrb-app commit `5c60339`):** hrb-app's final GUI round confirms the constructor-flag pain above and adds two per-widget gaps the MotionPolicy work should sweep in. (1) `Badge` hard-codes `animate=False` (`chips.py:123-132` — the subclass's entire behaviour) with no constructor escape, so a badge that must show live work cannot, and the consumer's only move is swapping widget class. (2) `StatusChip._animate` is set once in `__init__` (`chips.py:64,68`) with no `set_animate` — so even a consumer that builds its own reduce-motion gate cannot apply it to chips that already exist without rebuilding them. A runtime `set_animate(bool)` that re-runs the existing `_sync_pulse()` is the two-line enabler that makes any policy — the kit's future MotionPolicy or an app-local one — retrofittable; `Badge` then either grows the constructor flag or stays the static marker by documented choice rather than by lock.
- **Status:** OPEN

### PAPER-03 — `icon()` bakes the app-level device-pixel-ratio into its cache and never invalidates

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted |
| Decided on | 2026-08-07 |
| Scheduled for | 0.1.2 |
| Shipped in | 0.1.2 — per-call DPR read + the ratio= per-screen override |

- **What hurts:** `icon()` renders the SVG to a single `QPixmap` sized by `QGuiApplication.devicePixelRatio()` — the **application**-level ratio — and caches the resulting `QIcon` under a key containing that ratio. The per-screen ratio is never consulted and there is no invalidation hook, so a window dragged from a 1× monitor to a 2× monitor (or a session where scaling changes) keeps rendering icons rasterised for the ratio that was current when the process asked, and they go soft or crunchy. Because the ratio is in the cache key, the cache is not *wrong* — it simply can never contain an entry for the screen the widget is actually on.
- **Why it matters:** mixed-DPI is the ordinary desktop case (laptop plus external display), and icons are the kit's most repeated visual element — a blurry icon set is the single most legible signal that an app is not native-quality, which is precisely what the kit exists to prevent. It is also invisible in CI and on a single-monitor developer machine, so it will be found by operators, not by tests.
- **Evidence:** `src/mgf/brand_qt/icons.py:75` — `_CACHE: dict[tuple[str, str, int, float], QIcon]`; `:88-90` — `app = QGuiApplication.instance()`; `dpr = app.devicePixelRatio() …`; `key = (name, resolved, size, dpr)`; `:101-104` — the pixmap is baked at `px = max(1, round(size * dpr))`. Nothing in the module connects `QWindow.screenChanged` or `QScreen.physicalDotsPerInchChanged`, and there is no cache-clear entry point.
- **Suggested shape:** build a **multi-resolution `QIcon`** — add pixmaps for the ratios in play (1×, 1.5×, 2×, or every `QGuiApplication.screens()` ratio) so Qt picks per-window and the DPR leaves the cache key entirely. That is the fix that needs no consumer cooperation. Failing that, expose `invalidate_icon_cache()` and document the `screenChanged` connection consumers must make — workable, but it puts a Qt-internals detail on every consumer. A `QIcon` built from the `QSvgRenderer` via a `QIconEngine` would be the most correct answer if the kit wants icons to be resolution-independent by construction.
- **Cost if deferred:** the public signature does not change under any of these shapes, so this is deferrable without contract risk — the cost is that every consumer's screenshots and every operator's second monitor carry the defect in the meantime, and consumers who notice will bypass `icon()` and rasterise the SVGs themselves.
- **Status:** OPEN

### PAPER-04 — `confirm_action` belongs in the kit: the second consumer has landed, and the kit already ships the QSS it needs

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted |
| Decided on | 2026-08-07 |
| Scheduled for | 0.1.2 |
| Shipped in | 0.1.2 — widgets.confirm_action — verb-named, Cancel-default, danger variant |

- **What hurts:** the confirmation dialog is brand surface — button naming, which choice is default, how a destructive affirmative is styled — and the kit does not provide one. vm-vmanager-app built it app-local (`confirm_action(parent, *, title, text, action_text, destructive=False)`: the affirmative names the action, Cancel is the default button so Enter never destroys anything, destructive affirmatives are danger-styled), and it reaches **into the kit** to do it — it imports `set_mgf` from `mgf.brand_qt.widgets` and sets `Variant="danger"`. hrb-app, meanwhile, still asks raw `QMessageBox.question(...)` with `StandardButton.Yes | Cancel`, i.e. a "Yes" button and no danger styling, on operations that authorise or unbind hardware. Same brand, two behaviours, one of them by accident.
- **Why it matters:** the promotion bar (SCOPE §3, two consumers) is met, and the evidence is unusually clean: the kit **already ships** the `QPushButton[mgfVariant="danger"]` QSS and nothing in the kit uses it — the only consumer of that rule today is an app-local helper in another repo. That is the exact signature of a widget that belongs one layer down. Leaving it out means every desktop consumer re-decides "does Enter destroy the thing?" independently, which is a safety property, not a style preference.
- **Evidence:** the existing implementation and its kit dependency — `vm-vmanager-app/src/vmanager/gui/confirm.py:11` (`from mgf.brand_qt.widgets import set_mgf`), `:15-21` (the signature), `:31-35` (`addButton(action_text, AcceptRole)`, `setDefaultButton(cancel)`, `set_mgf(action, Variant="danger")`). The unused kit QSS: `src/mgf/brand_qt/qss.py:175-183` (`QPushButton[mgfVariant="danger"]` + `:hover`). The second consumer's current state: `hrb-app/src/hrb_app/ui/qt/main_window.py:732-739` and `:797-804` — two `QMessageBox.question` confirms with `StandardButton.Yes | Cancel` (8 `QMessageBox.*` call sites in the app in total, of which these 2 are confirmations).
- **Suggested shape:** promote `confirm_action` into `mgf.brand_qt.widgets` essentially as written (it is already kit-shaped and Qt-only), exported from `mgf.brand_qt.widgets.__all__` alongside `set_mgf`. Keep the `destructive` flag as the single brand decision it encodes, and document the two invariants in the docstring so they survive: the affirmative names the action, and the safe choice is the default. vm-vmanager-app's module then becomes a re-export (its monkeypatchable `_confirm_*` seams are unaffected), and hrb-app's two raw dialogs become one call each.
- **Cost if deferred:** each consumer keeps its own answer to "which button is default on a destructive action" — and the divergence already exists in the fleet, so deferring means shipping two different safety behaviours under one brand.
- **Status:** OPEN

### PAPER-05 — Missing public API: `to_qcolor` / `mix` / a mark accessor / a lattice accessor / an `extra_qss` hook (five small promotions)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted |
| Decided on | 2026-08-07 |
| Scheduled for | 0.1.2 |
| Shipped in | 0.1.2 — to_qcolor / mix / logo_pixmap / lattice_tile / set_extra_qss |

- **What hurts:** five capabilities exist inside the kit and are unreachable from outside it, so consumers either import privates or break the kit's own laws. (1) **Token parsing** — the `#rrggbb` / `rgba(...)` reader is implemented twice inside the kit and is private both times, so a consumer that needs a `QColor` from a token parses it again. (2) **`mix()`** lives in `_mix`, so a consumer needing a token-derived tint must import a private module or hardcode hex — which violates the kit's zero-raw-hex law to satisfy it. (3) **Logo marks** ship as assets with no accessor (vmanager reaches into package paths by hand). (4) **The lattice tile** likewise: the loader exists but is `_lattice_tile`, private to the empty-state module, so a consumer wanting the brand ground anywhere else re-implements the `importlib.resources` walk. (5) **`ThemeManager` hardcodes `build_qss(...)`**, so an app that appends its own QSS loses it on the next theme flip — the manager overwrites the whole application stylesheet.
- **Why it matters:** each of these has the same shape: the kit *has* the knowledge, keeps it private, and the consumer's only conformant path is a non-conformant one. (2) and (5) are the sharpest — a rule that cannot be obeyed with public API is a rule the fleet will break, and app QSS that silently disappears on a theme toggle is a bug consumers will diagnose as a kit bug anyway.
- **Evidence:** the duplicate parsers — `src/mgf/brand_qt/theme.py:40-41` ("Parse a token value — `#rrggbb` or `rgba(r,g,b,a)` — into a QColor", private) and `src/mgf/brand_qt/qss.py:80-83` (`_rgba`, private); `mix` — `src/mgf/brand_qt/_mix.py:32`, absent from `mgf.brand_qt.__all__` (`__init__.py:58-81`, which exports tokens plus `build_qss` only); assets with no accessor — `src/mgf/brand_qt/assets/logo/` and `assets/pattern/` exist, and the only reader is `src/mgf/brand_qt/widgets/empty.py:39-44` (`_lattice_tile`); the QSS overwrite — `src/mgf/brand_qt/theme.py:143` — `self._app.setStyleSheet(build_qss(sem, chrome_ink=RAW.ink, chrome_text=RAW.mist))` with no hook for consumer QSS.
- **Suggested shape:** promote the five, smallest first — `to_qcolor(token: str) -> QColor` (one implementation, both internal call sites switched to it), `mix(a, pct, b)` re-exported from `mgf.brand_qt`, `mark(name, *, variant=...) -> QPixmap | QIcon` and `lattice_tile(variant) -> QPixmap` as the asset accessors (same shape as `icon()`, so the same caching and the same unknown-name discipline apply), and `ThemeManager(..., extra_qss: str | Callable[[SemanticColors], str] | None = None)` appended after `build_qss` on every apply so consumer QSS survives theme flips. All five are additive; none changes existing behaviour.
- **Cost if deferred:** N/A (roadmap-shaped), except that (2) and (5) keep producing consumer code the kit would reject in review — hardcoded hex and app stylesheets that vanish — which is harder to walk back than to prevent.
- **Status:** OPEN

### PAPER-06 — Widget gaps toward an industry-leading console, plus `mgf.brand_qt.testing` (one roadmap paper)

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

- **What hurts:** the widget layer ships nine names, and a hardware console repeatedly needs the same seven things that are not among them. **(a) A loading/busy counterpart to `EmptyState`**, plus more `EmptyState` kinds (error, no-permission) and a monospace `detail` slot — hrb-app currently smuggles markup into the `reason` string (`<br><code>…</code>`, hand-escaped) because there is nowhere else to put a socket path. **(b) A toast / timed banner** and notification-duration tokens for transient outcomes that must not be a modal. **(c) A `Ledger` widget** — the kit already ships `mgfClass="ledger"` / `"ledgerKey"` / `"ledgerAccent"` QSS that no kit widget uses, so every consumer hand-builds the widget the styling was written for. **(d) A mono-column / eliding-URN delegate** — every consumer of a URN-keyed table writes one. **(e) A `StatRow`** that enforces one-gold-per-view (the brand rule the kit states and leaves to discipline). **(f) A log / mono panel.** **(g) `mgf.brand_qt.testing`** — a `themed_app` fixture plus `assert_contrast` / `assert_uses_tokens`; hrb-app hand-rolls global-state save/restore around theme application in its own tests today.
- **Why it matters:** grouped deliberately as one paper so the maintainer can triage a roadmap rather than six tickets — but the SCOPE second-consumer gate is the reason to have the conversation now: vm-vmanager-app has **two** implementations of the log/mono panel and hrb-app needs one, so (f) has already crossed the bar, and (a), (c) and (d) are on the same trajectory. Item (g) is the meta-cost: without a testing surface, every consumer's brand tests poke `QApplication` global state by hand, which is both fragile and the reason a consumer's green suite can ship a visibly-wrong widget.
- **Evidence:** the current widget surface — `src/mgf/brand_qt/widgets/__init__.py:11-21` (`STATUS_ROLE`, `Badge`, `EmptyState`, `StatTile`, `StatusChip`, `StatusChipDelegate`, `apply_label_class`, `bind_empty_state`, `set_mgf`). Styling shipped without a widget — `src/mgf/brand_qt/qss.py:486-507` (the four `ledger*` classes). The markup-in-`reason` workaround — `hrb-app/src/hrb_app/ui/qt/main_window.py:306-319` (`EmptyState("warning", "Broker unreachable", "…<br><code>{escape(socket_path)}</code>…")`, with an in-code comment explaining the escaping obligation the API imposes). No `testing` module exists in the package (`src/mgf/brand_qt/` has no `testing.py`).
- **Suggested shape:** triage as a roadmap, not a batch. Suggested order by evidence: (f) and (c) first (styling or duplicate implementations already exist, so the shape is known), then (a) — an `EmptyState` `detail` slot plus a `LoadingState` sibling, which removes the markup-smuggling API hazard, then (d), (e), (b). (g) can land small and early: a `themed_app` pytest fixture that saves and restores application state, plus `assert_contrast(fg, bg, ratio)` and `assert_uses_tokens(widget)`, is useful the day it exists and unblocks consumers writing honest brand tests.
- **Cost if deferred:** N/A (roadmap). The compounding cost is drift: each item deferred is re-implemented per consumer, and the ones with kit QSS already shipped (c) will be re-implemented *differently* while the kit's own styling sits unused.

### PAPER-07 — Four chip recipes measure below WCAG-AA at strong-on-tint; a conformant consumer can only ledger the gap, not close it

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `5c60339`) |
| Submitted | 2026-07-30 |
| Decision | accepted |
| Decided on | 2026-08-07 |
| Scheduled for | 0.1.2 |
| Shipped in | 0.1.2 — four recipes strengthened to >=4.5:1; AA gate added kit-side |

- **What hurts:** four of the kit's chip recipes — `*_strong` text over `*_tint` composited over `bg_surface`, the pair behind every `StatusChip` — measure below the WCAG-AA 4.5:1 floor at mgf-brand-qt 0.1.1: dark `info` **3.63:1**, dark `busy` **4.13:1**, dark `derived` **3.68:1**, light `warning` **4.40:1** (measured by hrb-app's app-level G10 contrast sweep). All four sit at or above 3.0:1, the AA-Large / UI-component floor, so this is a tuning gap in the mix recipes, not a broken vocabulary.
- **Why it matters:** the consumer cannot fix it without breaking the kit's own law. Zero-raw-hex means the only conformant colours are the kit's tokens, so retuning app-side is exactly the raw hex the kit forbids. hrb-app's honest answer is a shrink-only exception ledger: each of the four entries asserts the pair still measures **below 4.5** — the day the kit retunes, the assertion fails and forces the entry's deletion, so the pin can only tighten — and **at or above 3.0**, so the gap cannot silently worsen. That is the best any consumer can do, and it is a per-app restatement of a fact the kit should own. Routing note: filed here rather than in mgf-brand because the failing values are this kit's `mix()` *recipes* over the base palette, not the base colours themselves.
- **Evidence:** the recipes — `src/mgf/brand_qt/tokens.py:117-118` (light `warning`: tint `mix(RAW.warning, 22, WHITE)` / strong `mix(RAW.warning, 62, BLACK)`), `:164-165` (dark `info`), `:171-172` (dark `busy`), `:174-175` (dark `derived`). The consumer ledger — `hrb-app/tests/unit/hrb_app/ui/qt/test_gui_standard.py:1189-1196` (`_KNOWN_BELOW_AA`, the four `(theme, pair)` entries) and `:1253-1261` (the shrink-only double assertion). Measurement shape: strong over tint alpha-composited over `bg_surface`, WCAG relative-luminance contrast.
- **Suggested shape:** either **(a)** retune the four recipes until strong-on-tint clears 4.5:1 in both themes — the mix percentages are the whole knob, and consumers' ledgers delete themselves on the next kit bump; or **(b)** declare chips a large-text / UI-component surface in the brand book with an explicit 3.0:1 floor (AA-Large), so a consumer's contrast gate can encode the sanctioned floor instead of inventing a per-pair exception list. Both are honest; the current state — an unstated sub-AA surface — makes every conscientious consumer's gate red until it writes its own policy.
- **Cost if deferred:** every consumer that runs an honest contrast gate rediscovers the same four pairs and grows its own divergent exception ledger; every consumer that does not ships sub-AA operator text unaware. And nothing kit-side notices if a fifth recipe slips under the bar.
- **Status:** OPEN

### PAPER-08 — `EmptyState` copy is constructor-only: an honestly-changing reason costs a full widget rebuild

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `5c60339`) |
| Submitted | 2026-07-30 |
| Decision | accepted |
| Decided on | 2026-08-07 |
| Scheduled for | 0.1.2 |
| Shipped in | 0.1.2 — label attributes + set_title / set_reason / set_icon |

- **What hurts:** `EmptyState(icon_name, title, reason, ...)` takes its copy in the constructor and offers no mutator — and the two `QLabel`s are constructor locals, not attributes, so even a subclass cannot reach them. A console whose empty-reason genuinely changes ("broker unreachable at `<socket>`" → "connected — no devices in scope") must tear the widget down and rebuild it to change two strings.
- **Why it matters:** the *reason* is precisely the part of an empty state that changes at runtime — the kit's own honest-empty-state doctrine demands copy that tracks the current cause. hrb-app wrote `TablePane.replace_empty_copy()` around the gap: it reconstructs the kit widget and re-splices it into the layout (re-crossing the PAPER-01 binding surface each time). Every live console will write the same helper.
- **Evidence:** `src/mgf/brand_qt/widgets/empty.py:56-65` (the signature), `:78-87` (`title_label` / `reason_label` are locals — never stored on `self`; contrast `self.action_button` at `:89`, which *is* kept). Consumer workaround: `hrb-app/src/hrb_app/ui/qt/table_pane.py:329` (`replace_empty_copy(icon_name, title, reason)`), driven from `main_window.py:653` on broker-state changes.
- **Suggested shape:** keep the labels as attributes and add `set_title(str)` / `set_reason(str)` (and plausibly `set_icon(name)`), mirroring the mutator precedent `StatusChip` already sets with `set_state` / `set_text`. Related but distinct from PAPER-06(a)'s ask (a `detail` slot + more kinds): that one grows the vocabulary, this one makes the existing vocabulary mutable.
- **Cost if deferred:** N/A (additive) — the standing cost is a rebuild helper per consumer around the widget the kit intends as its one honest empty state, with each rebuild re-crossing the PAPER-01 binding hazard.
- **Status:** OPEN

### PAPER-09 — `Mode` lives in the PySide6-importing `theme` module; a Qt-free settings model cannot name the kit's own theme vocabulary

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `5c60339`) |
| Submitted | 2026-07-30 |
| Decision | accepted |
| Decided on | 2026-08-07 |
| Scheduled for | 0.1.2 |
| Shipped in | 0.1.2 — Mode defined in the Qt-free tokens core; theme re-exports |

- **What hurts:** `Mode = Literal["dark", "light", "system"]` is defined in `mgf.brand_qt.theme` (`theme.py:34`), a module that imports PySide6 at import time (`:22-23`). A consumer's settings layer that must stay Qt-free — a pydantic model storing the operator's theme choice, loadable by headless config tooling and tests — cannot annotate with the kit's own type: pydantic resolves annotations at runtime, so a `TYPE_CHECKING` import cannot serve, and a real import drags PySide6 into a layer defined by not requiring it.
- **Why it matters:** hrb-app keeps a duplicated literal (`GuiThemeMode`) with a drift test pinning it to the kit's — a duplicate whose only reason to exist is the import graph. `Mode` is a pure string vocabulary; nothing about it is Qt. The kit's token core already demonstrates the right layering: `tokens.py` imports only stdlib plus `_mix` and carries no Qt.
- **Evidence:** `src/mgf/brand_qt/theme.py:16-34` (PySide6 imports at `:22-23`; `Mode` at `:34`; exported via `__all__` at `:180`). Qt-free precedent in the same package: `src/mgf/brand_qt/tokens.py` (stdlib + `mgf.brand_qt._mix` only). Consumer duplicate: `hrb-app/src/hrb_app/config.py:64` (`GuiThemeMode = Literal["dark", "light", "system"]`), drift-pinned by `hrb-app/tests/unit/hrb_app/ui/qt/test_branding.py:52-54` (`set(get_args(GuiThemeMode)) == set(get_args(Mode))` — a test that must import PySide6 to compare a string vocabulary).
- **Suggested shape:** move `Mode` into the Qt-free token core (`tokens.py`, or a tiny `mgf.brand_qt.modes`) and re-export it from `mgf.brand_qt.theme` unchanged, so every existing import keeps working and a Qt-free consumer can `from mgf.brand_qt.tokens import Mode`. Zero behaviour change; one hoisted alias.
- **Cost if deferred:** N/A (additive move) — the standing cost is a duplicated vocabulary plus a drift pin in every consumer whose settings layer is Qt-free, i.e. every consumer that follows the fleet's own layering discipline.
- **Status:** OPEN
- **Status:** OPEN

<!-- Relayed from figrecovery's FB-05 outbox, 2026-07-31, raised during
     Phase-7 Track C (GUI chunks G3-G5) against mgf-brand-qt 0.1.1.
     Both papers were EXECUTED against the installed kit, not inferred:
     PAPER-11 carries a bisection table for a reproducible SIGSEGV. -->

### PAPER-10 — 🟡 `set_mgf(**props)` silently accepts any keyword, so a mis-keyed call is an invisible no-op

**Status: ✅ SHIPPED in 0.1.2** (decided 2026-08-07) — `set_mgf` now validates
both keys and values against `mgf.brand_qt.qss.MGF_PROPS`, with a targeted
hint for the prefixed-key mistake; the whole published vocabulary is
false-positive-pinned in `tests/test_props.py`.

**Severity: 🟡 High** — the failure is silent, survives every gate, and
the symptom is "the design system didn't apply", which reads as a
theming problem rather than a caller bug.

**What happened.** figrecovery called `set_mgf(bar, mgfChrome="ink")`
throughout its Phase-7 shell. `set_mgf` prefixes the key itself
(`props.py:31-32`: `widget.setProperty(f"mgf{key[0].upper()}{key[1:]}", value)`),
so that call sets **`mgfMgfChrome`** — a property no selector in the
kit's QSS matches. **The application shell was therefore never styled
at all**, through three build chunks and four consecutive green gate
runs, and nobody noticed until a fourth chunk inspected the live
property names at runtime.

The docstring is correct and does show the right form
(`set_mgf(button, Variant="accent")`). This is not a documentation
defect — it is that **nothing distinguishes a correct key from a
misspelled one**, at call time or at test time.

**Why the consumer's own gates could not catch it.** figrecovery has a
mechanical brand-conformance test that fails the build on any raw hex
colour, any `setStyleSheet(` call, and any inline `.ui` styleSheet
property. That test enforces *"the app authors no styling"* — and it
passes perfectly on a widget that received **no styling from anywhere**.
A missing style is not a raw hex. The guard was aimed at one direction
only, and the other direction is exactly where this lands.

**The same shape, one layer out — added after the draft.** The
*value* half of the vocabulary has the identical property. A correct
key with a misspelled value (`Class="strongLabl"` for
`Class="strongLabel"`) is also a silent no-op, and figrecovery's own
court round C-1 found that its first fix for this paper — an AST guard
on the keyword plus a widget-tree walk — could not see it on any surface
built on demand. Seeding that one-character typo into a safety banner
left a 1257-test suite green. Whatever validation the kit adds, **it
should cover the pair, not only the key**: an unknown value is as inert
as an unknown property and is harder to spot by eye.

**Scope check (executed, 2026-07-31).** Only figrecovery was affected:
`vm-vmanager-app` 0/21 call sites, `hrb-app` 0/3, `mgf-brand-qt`'s own
0/9. So this is a footgun that fired once, not a fleet-wide defect —
which is the argument for closing it cheaply now rather than after it
fires again.

**Suggested fix, cheapest first.**

1. **Validate the keys.** `set_mgf` knows the vocabulary its QSS
   selectors use (`Chrome`, `Class`, `Variant`, `Mono`, …). Raise on an
   unknown key, or at minimum on a key already starting with `mgf`
   (which is always a double-prefix and never intentional). A one-line
   guard converts a silent no-op into a loud `TypeError` at the call.
2. **Or expose the vocabulary** as a constant consumers can assert
   against, so a project can pin "every `set_mgf` key *and value* I use
   is real" in its own suite. figrecovery ended up deriving that set by
   substring-matching `build_qss(...)` output for `[mgfX="y"]`, which
   works and is obviously not what the kit wants consumers doing.

**The generalizable half**, offered rather than filed: a design system
whose adoption is verified only by *"the consumer authors no styling"*
cannot tell adoption from absence. Some positive signal — a rendered
pixel check, or an assertion that a widget carries at least one
recognised `mgf*` property — is what closes it. figrecovery has since
added a rendered-pixel guard on its chrome zones, and found that even
that is weaker than it looks: it samples a container background, not
whether the text on it is legible.

**Submitter:** figrecovery, 2026-07-31, during Phase-7 Track C and its
court round C-1.
**Evidence:** `mgf-brand-qt/src/mgf/brand_qt/widgets/props.py:24-35`
(0.1.1, installed); figrecovery commit `0516155` and its
seeded-sabotage proof; round C-1 finding C-F2 (seed S31: the value typo
that no test could see).

---

### PAPER-11 — 🔴 A second `apply_theme()` on a live application **segfaults**

**Status: ✅ SHIPPED in 0.1.2** (decided 2026-08-07) — `apply_theme` is now
get-or-create per QApplication: the second call updates mode/callback on the
live manager and never re-runs `setStyle`, so the crash path is
unconstructable; consumer memoization workarounds can be deleted.

**Severity: 🔴 Critical for the failure mode, low for the blast radius
today** — the process dies with `SIGSEGV`, there is no exception to
catch, and the only thing standing between a consumer and it is a
docstring sentence.

**What happens.** `apply_theme(app, mode)` calls
`app.setStyle("Fusion")` and then, via `ThemeManager._apply`,
`app.setStyleSheet(build_qss(...))`. Performing **both**, **twice**,
while a `QWizard` widget tree is alive kills the process. Minimised on
this bench (PySide6 6.x, `QT_QPA_PLATFORM=offscreen`, Python 3.13):

```
1. build a QWizard-derived dialog with real pages          (alive)
2. app.setStyle("Fusion"); app.setStyleSheet(build_qss(LIGHT, ...))
3. app.setStyle("Fusion"); app.setStyleSheet(build_qss(DARK, ...))
   -> Fatal Python error: Segmentation fault
      Current thread ... mgf/brand_qt/theme.py:143 in _apply
                         mgf/brand_qt/theme.py:110 in __init__
                         mgf/brand_qt/theme.py:177 in apply_theme
```

Isolated by bisection, and the isolation is the interesting part:

| repeated call | result |
|---|---|
| `setStyleSheet(build_qss(...))` ×2 | fine |
| `setStyle("Fusion")` ×2 | fine |
| **`setStyle` + `setStyleSheet`, ×2** | **`SIGSEGV`** |
| `apply_theme` ×1 | fine |
| `apply_theme` ×2 | `SIGSEGV` |
| `ThemeManager.set_mode(...)` ×N | fine |

Keeping a strong reference to each returned `ThemeManager` does **not**
help, so it is not the manager being garbage-collected — it is that
`setStyle` destroys the live `QStyle` and the subsequent stylesheet
re-polish walks `QWizard`'s internal buttons through the pointer that
just died. No dangling Python-side widget is observable before the
crash (`allWidgets()` is clean, 54 entries, none raising `RuntimeError`).

**Why this is worth a paper rather than a note.** The kit already knows
the right answer — `ThemeManager.set_mode` deliberately does *not*
re-call `setStyle`, and `apply_theme`'s docstring says *"Call once from
the GUI entry point"*. But:

- `apply_theme` is the **only public entry point**; a consumer who has
  not kept the returned manager has no other way to change theme, and
  calling it again is the obvious move;
- nothing enforces the once-per-process rule, and the penalty for
  breaking it is a `SIGSEGV`, not a `RuntimeError`;
- it is **ordering-dependent**, which is the worst signature there is.
  figrecovery's suite carried this latent for the whole of Phase 7 and
  the default collection order happened to hide it; it surfaced only
  when a seeded-sabotage harness selected a different subset. The
  application itself boots once and was never exposed — the View →
  Theme menu the kit's own docstring describes would have been.

**Suggested fix, cheapest first.**

1. **Make `apply_theme` idempotent.** If the application already has a
   `ThemeManager` installed (stash it on the app as a property, or in a
   module-level `WeakKeyDictionary` keyed by `QApplication`), call
   `set_mode(mode)` on it and return it instead of building a second
   one. One branch, no API change, and the footgun stops existing.
2. **Or skip the restyle when it is a no-op** — `if app.style().objectName()
   != "fusion": app.setStyle("Fusion")`. Narrower, and it leaves the
   double-`ThemeManager`/double-`colorSchemeChanged`-connection issue.
3. **At minimum, say what happens.** The docstring says "call once"; it
   should say *"calling it twice on a live application can crash the
   process — use the returned `ThemeManager.set_mode`"*. A consumer who
   knows the cost will keep the reference.

**What figrecovery did meanwhile.** `gui/app.py::_apply_brand_theme`
now memoises one `ThemeManager` per `QApplication` and switches its mode
on any later call — i.e. suggestion 1, implemented in the consumer
because the consumer is where the crash landed. Pinned by
`test_the_theme_is_applied_once_per_application`, which asserts manager
*identity* rather than the crash, since a `SIGSEGV` is not an assertion
a test can make.

**Submitter:** figrecovery, 2026-07-31, during the round C-1 fold.
**Evidence:** `mgf/brand_qt/theme.py:110/143/177` (0.1.1, installed);
`src/figrecovery/gui/app.py::_apply_brand_theme` and
`tests/unit/gui/conftest.py::theme_manager`.

