Skip to content

feat(ui): Phase 3 Track A — app shell + profile management - #27

Merged
ModifAmorphic merged 10 commits into
mainfrom
feat/phase3-tracka
Jul 3, 2026
Merged

feat(ui): Phase 3 Track A — app shell + profile management#27
ModifAmorphic merged 10 commits into
mainfrom
feat/phase3-tracka

Conversation

@ModifAmorphic

Copy link
Copy Markdown
Owner

Track A of Phase 3: replaces the bare Phase-0 window with the real app shell and wires up profile management. The app is now user-usable for profile CRUD; the mod-list (Track B) and launch behavior (Track C) land next.

What this delivers

The shell (ShellViewModel + MainWindow): a top bar (app title, a compact profile dropdown, a Manage-profiles gear, the Launch Darktide button), a dominant mod-list area (empty-state placeholder for Track B), and a status strip showing the live Darktide running-state. Profiles are demoted to a compact control (infrequent UX); the mod list owns the window.

Profile management: dropdown switching of the active profile, plus a Manage-profiles dialog (editable-list pattern: per-row inline rename via pencil, delete-with-confirm via trash, and a "+ New profile" add row). CRUD flows through the existing IProfileService.

IProfileSession (ui/Session/) — the single authority for the active profile, the switch-block gate, and the live running-state. Both the shell and the Manage dialog consume it; neither decides the gate or injects ISteamService. A DispatcherTimer polls IsGameRunning about every 3s (live, not a startup snapshot), with a testable Refresh seam.

Persistence: the active profile persists across restarts via IAppStateStore (a new small general/ component writing app-state.json, deliberately separate from MagosConfig so the settings schema stays pure).

Behaviors pinned (each iterated on after manual review caught gaps):

  • switch-blocked-while-running (dropdown disabled with a tooltip);
  • create-while-running creates the profile but does not change the active (the session gate holds);
  • delete-of-active clears the active to null (no implicit switch to another), and is blocked entirely while the game runs (CanDeleteProfile gates the trash UI plus a command-level defense-in-depth check);
  • Launch blocked when no profile is active (the existing CanLaunch guard: SelectedProfile is not null && !IsGameRunning).

Conventions applied

  • UI icons and markers are drawn geometry (<Path> for the gear/pencil/trash, <Ellipse> for the status/active dots), never Unicode glyphs.
  • No em-dashes in prose (the new Track A files are swept clean).

Validation

  • Operator visually verified across iterations (shell layout, the dialog, the live running-state, the switch/delete behavior).
  • QA: PASS (single-authority architecture, behavior correctness, conventions, public surfaces, doc currency).
  • Code-review: APPROVE after two doc-only fixes (folded in: general.md now credits IProfileSession as the IAppStateStore consumer; em-dashes removed from that section).
  • dotnet build 0 warnings; dotnet test 261/261, with about 30 tests pinning the active-profile / running-state / switch / delete / launch-guard behavior.
  • Public backend surfaces unchanged (IProfileService, ISteamService, IAppStateStore, IEnginseerLaunchService); IProfileSession and the simplified IDialogService.ShowManageProfilesAsync are ui/-internal.

Tests

New Magos.Modificus.UI.Tests project (61 tests): ProfileSessionTests (the gate, persistence, live refresh, delete fallback, CanDeleteProfile), ShellViewModelTests (switch-block, session mirroring, launch-guard, dialog coordination), ManageProfilesViewModelTests (editable-list CRUD, create/delete-of-active under the gate, defense-in-depth).

…tone)

Replace the bare Phase-0 window (config-summary display + the stale "libraries
are stubs" text) with a real shell: sidebar (profile-list region) + header
(Launch button) + content (mod-list region) + status strip (game-running
indicator), bound to a new ShellViewModel.

Milestone 1 — shell-first, for visual review before profile management:
- ShellViewModel resolves IProfileService / ISteamService / IEnginseerLaunchService
  from DI. Live: Profiles = ListProfiles() (empty pre-release), IsGameRunning = the
  real check (status strip). Stubbed/disabled: NewProfile + Launch (milestone 2 /
  Track C).
- Layout: ~260px sidebar, header with Launch, content with empty-state
  placeholders, status strip with a running/not-running dot. Window 960x640.
- App.axaml.cs Phase-0 service-counting diagnostic removed.

No new backend; read-only consumption of existing services. 193 tests green, 0
warnings. This is the empty shell — profile management (milestone 2) + Tracks B/C
drop into the regions it defines.
…ual review)

Operator visual review of the v1 shell: profiles were over-elevated. Profiles are
set-once / rarely-switched config (most users create one and run), not an everyday
interaction — the mod list + launch are. Demote profiles to a compact top-bar
control; let the mod list own the window.

v2 layout:
- Top bar: app title · a compact profile dropdown (empty/disabled, placeholder
  "No profile") · "Manage profiles…" (disabled, milestone 2) · Launch Darktide
  (disabled).
- Main area: the mod list — dominant, full width (empty-state placeholder).
- Status strip: game-running indicator (kept).

ShellViewModel: NewProfileCommand -> ManageProfilesCommand (stub). DI wiring, live
Profiles/IsGameRunning, status strip unchanged. Still milestone 1 — no new backend,
no CRUD/switch/launch logic; read-only existing services. 193 tests green.
…log + persisted active profile)

Wire the shell's profile controls to IProfileService CRUD + persist the active
profile. CRUD already existed on IProfileService — this milestone is UI wiring
plus one new backend bit (active-profile persistence, decision b).

- IAppStateStore/AppStateStore (general/) — active profile id persisted to
  <app-data>/Magos Modificus/app-state.json (runtime state, kept out of
  MagosConfig). First-run safe; TryAdd-registered in AddGeneral().
- Dropdown switch — selecting a profile sets it active + persists; disabled with a
  tooltip while IsGameRunning (switch-blocked-while-running).
- "Manage profiles…" (gear) -> modal dialog (ManageProfilesViewModel +
  ManageProfilesWindow): create / rename (inline) / delete (with confirmation —
  deleting a profile is real data loss). New profile becomes active; deleting the
  active profile falls back to the first remaining.
- IDialogService seam — the dialog + confirm flow go through an injectable
  interface so the view models are unit-testable without real Avalonia windows.
- New Magos.Modificus.UI.Tests project (27 tests) + AppStateStoreTests. 227 total
  tests green, 0 warnings.

IProfileService + MagosConfig public surfaces unchanged. Docs updated (general.md
for AppStateStore; AGENTS + READMEs for status/layout). The Manage dialog is left
enabled while the game runs (only the active-profile switch is gated) — flagged
for review.
…e/delete + add row)

Operator UX review: the create/rename/delete panel (rename textbox + create/delete
buttons) was confusing — rename over-prominent, the two textboxes coupled the
renamed profile to the deleted one, and create/delete side-by-side read as one
muddled panel. Switch to a modern editable-list pattern.

- Each profile row: name + active (●) marker + ✏ (inline rename in place: Enter
  commits, Esc cancels, blur commits, empty reverts) + 🗑 (delete with confirm).
- "+ New profile" add row at the bottom → click → inline name entry → Enter creates
  (new profile becomes active).
- Per-row ProfileItemViewModel (Id/Name + IsActive/IsEditing/EditText); parent VM
  owns CRUD via IProfileService; delete-confirm via the IDialogService seam (VM
  stays unit-testable). FocusOnVisible behavior focuses + selects-all on edit.
- Rewritten ManageProfilesViewModelTests (23 tests) for the new mechanics.

IProfileService surface unchanged. 236 tests green, 0 warnings. Leaves the space
below free for future profile settings.
…ot-running, conventions

Operator review of the Manage Profiles dialog:

- The Unicode pencil/trash glyphs rendered poorly on Linux (trash showed as a
  box without a color-emoji font; pencil was unrecognizable and off-center).
  Replace with drawn Avalonia Path icons (Material pencil + trash path data,
  themed via foreground, fixed size, centered).
- Retro-fix the other UI glyphs to drawn geometry per the new convention: the
  gear button is now a Path gear; the status-strip dots and the active-profile
  marker are Ellipse shapes (were TextBlock black-circle glyphs). No Unicode
  icon or marker glyphs remain in the UI.
- Creating a profile now sets it active only when the game is NOT running. Both
  active-change paths (dropdown switch and create-in-dialog) route through a
  single !IsGameRunning predicate in the shell, so create-while-running leaves
  the active profile unchanged (it was bypassing the switch-block guard).
  Delete-of-active fallback is unchanged.

Conventions recorded in AGENTS.md: UI icons and markers are drawn geometry
(Path / Ellipse), never Unicode glyphs; and no em-dashes in prose (an AI tell),
use a comma, colon, parentheses, or period instead.

239 tests green, 0 warnings.
ManageProfilesViewModel tracked its own ActiveProfileId and set it to a new
profile on create, driving the green-dot marker. The shell gates the actual
active change with !IsGameRunning, so creating a profile while the game ran left
the dialog marking the new profile active while the shell kept the current one
(the marker lied).

Fix: the dialog's create-sets-active now consults the same ISteamService
.IsGameRunning() the shell's gate uses (injected into ManageProfilesViewModel,
read live). Create while running creates the profile but leaves ActiveProfileId
on the current active (marker stays truthful); create while not running sets it
as before. Delete-of-active fallback stays ungated (forced recovery, not a
voluntary switch).

Adds the missing dialog-VM test (create while running leaves active unchanged).
240 tests green, 0 warnings. IProfileService surface unchanged.

Note: the shell's IsGameRunning is a startup snapshot (pre-existing); the dialog
now reads it live. Both consult the same ISteamService singleton and running
state is stable during a profile-management session, so they agree. Refreshing
the shell's running-state live is a separate (Track C / status-strip) concern.
…e (single authority)

The active-profile can-change decision was duplicated: the shell gated with a
startup-snapshot IsGameRunning, and the dialog (49addd4) checked IsGameRunning
itself on create-sets-active. Two forms deciding is an S violation. Separately,
IsGameRunning was a startup snapshot, so the status strip and Launch-availability
froze at whatever the state was when Magos opened.

Fix (option B): extract IProfileSession (ui/) as the single authority for the
active profile, the can-change gate, persistence (IAppStateStore), and the LIVE
running-state. A DispatcherTimer polls ISteamService.IsGameRunning about every 3s;
the shell binds its status strip, Launch-availability, and dropdown-enable to the
session's IsRunning (live, no longer a startup snapshot). The shell and the Manage
dialog consume the session; neither decides the gate. The dialog's marker binds to
session.ActiveProfileId (truthful by construction), and create/delete route through
RequestActive/ReconcileActive. The 49addd4 patch (dialog ISteamService injection +
IsGameRunning check) is removed; both forms drop their direct ISteamService
injection (the session is now the single ui/ consumer of IsGameRunning).

IDialogService.ShowManageProfilesAsync simplifies to Task (the session is the live
authority; no returned-active-id gating). Backend surfaces unchanged
(EnginseerLaunchService still injects ISteamService for discovery). 251 tests
green (14 new ProfileSession tests), 0 warnings. Also normalizes the
switch-blocked tooltip's em-dash per the writing convention.
Deleting the active profile triggered ReconcileActive to fall back to the first
remaining profile, an implicit switch the user did not choose (and a switch while
the game ran, violating switch-blocked-while-running).

Fix:
- ReconcileActive now clears the active to null on delete-of-active (not a switch
  to first-remaining). Delete-of-non-active leaves the active unchanged.
- Deleting the active profile while the game runs is blocked. IProfileSession
  .CanDeleteProfile(id) is the single authority (false when id is the active and
  IsRunning); the trash button disables on the active row while running (tooltip
  explains), and the delete command re-checks CanDeleteProfile so a programmatic
  call is a no-op too (defense-in-depth).
- Launch-blocking on the null active is already handled by the existing CanLaunch
  (requires a selected profile). Delete-of-active-not-running clears the active,
  so Launch disables automatically; the dropdown shows the placeholder and the
  mod-list area shows its empty state.

261 tests green (new: CanDeleteProfile, delete-of-active-while-running blocked,
programmatic-command no-op), 0 warnings. Public surfaces unchanged.
…n AGENTS

Per the writing convention (no em-dashes in prose), replace 20 em-dashes across
the Track A files (ui/ + AppStateStore + UI tests) with commas, colons, parens,
semicolons, or periods. Comments and one startup log string only; no logic.

AGENTS.md: the Phase 3 Track A bullet now records IProfileSession (ui/) as the
single authority for the active profile, the switch-block gate, and the live
running-state, plus the A -> D -> B -> C track sequencing.

261 tests green, 0 warnings.
…in its section

Code-review follow-up: the IAppStateStore section credited ShellViewModel as the consumer, stale after the IProfileSession refactor (the session now owns the active profile + persistence; the shell and Manage dialog read it through the session). Also replaced the em-dashes in that new section (the Track A em-dash sweep covered code files, not this doc) per the no-em-dash convention. Doc-only.
@ModifAmorphic
ModifAmorphic merged commit f7f8250 into main Jul 3, 2026
2 checks passed
@ModifAmorphic
ModifAmorphic deleted the feat/phase3-tracka branch July 3, 2026 05:50
ModifAmorphic added a commit that referenced this pull request Jul 5, 2026
Updates all docs to reflect the current state after Phase 3 (#27-#32) +
the unified-mod-storage (#30) and config-live-read (#31) refactors.

- Phase 3 marked complete across AGENTS.md, README, architecture doc,
reference docs.
- Dropped stale shared-mods/diverged/AllocationResolver terminology;
replaced with the unified-repository model.
- Updated the composition-root description for the live-read
IConfigLoader pattern.
- Updated the staging/import model for base-folder preservation +
collision hard-block.
- Updated _local/v1-phase-plan.md (gitignored) + .agents/handoff.md
(gitignored) for the next context.

Docs-only; no code changes.
ModifAmorphic added a commit that referenced this pull request Jul 8, 2026
🤖 I have created a release *beep* *boop*
---


## 0.1.0 (2026-07-08)


### Features

* **component-a:** Hybrid Rust+C discovery + shell + launcher
([#1](#1))
([491e5d1](491e5d1))
* **magos-modificus:** implement Phase 1 Enginseer-client launch façade
([#20](#20))
([7950ed1](7950ed1))
* **magos-modificus:** implement Phase 1 Integrations (GitHub Releases
client)
([#19](#19))
([781d65c](781d65c))
* **magos-modificus:** implement Phase 1 Profiles library
([#17](#17))
([f355ceb](f355ceb))
* **magos-modificus:** implement Phase 1 Steam discovery library
([#18](#18))
([8f6ec00](8f6ec00))
* **magos-modificus:** implement Phase 2 shared-first mod storage
([#22](#22))
([cf2af80](cf2af80))
* **magos-modificus:** Phase 3 Track B mod-list, import, source model
([#29](#29))
([5075cee](5075cee))
* **magos-modificus:** Phase 3 Track C launch + Settings + escape-hatch
+ base-folder mod loading
([#32](#32))
([c595700](c595700))
* **magos-modificus:** Phase 4 Stage 1 nxm scheme handler + IPC
([#34](#34))
([0017529](0017529))
* **magos-modificus:** Phase 4 Stage 2 Nexus auth + Integrations dialog
([#35](#35))
([0790a8e](0790a8e))
* **magos-modificus:** Phase 4 Stage 3 Nexus mod acquisition
([#36](#36))
([d01105f](d01105f))
* **magos-modificus:** Phase 4 Stage 4 Nexus update-check service
([#39](#39))
([1749e76](1749e76))
* **magos-modificus:** Phase 4 Stage 5 mod-list update badges + per-mod
update
([#43](#43))
([423e146](423e146))
* **magos-modificus:** Phase 4 Stage 6 DMF new-profile/auth prompt
([#44](#44))
([994b4f8](994b4f8))
* **magos-modificus:** scaffold .NET 10 + Avalonia 12 app + libraries
([#16](#16))
([d1fac91](d1fac91))
* **mod-loader:** own the load-order contract (mods.lst), drop DMF
prepend
([#14](#14))
([1ccb891](1ccb891))
* **release:** add Curator release pipeline
([#49](#49))
([01517e4](01517e4))
* **runtime:** engine-context proven — trampoline, Enginseer v1,
launcher fail-fast
([#4](#4))
([4565ba8](4565ba8))
* **runtime:** Enginseer v2 — mod loader + launcher config + logging
([#5](#5))
([1e65b3f](1e65b3f))
* **runtime:** package Enginseer with the runtime; relocate build files
to runtime/
([#6](#6))
([7221bdb](7221bdb))
* **ui:** Phase 3 Track A — app shell + profile management
([#27](#27))
([f7f8250](f7f8250))
* **ui:** Phase 3 Track D — Preferences + i18n + custom title bars +
icon
([#28](#28))
([c921650](c921650))


### Bug Fixes

* **enginseer:** DMF integration fixes — IO re-root, load timing,
destroy
([#7](#7))
([401759c](401759c))
* **magos-modificus:** multi-format archive import (zip + 7z + rar)
([#41](#41))
([ee4f5c6](ee4f5c6))
* **magos-modificus:** search all Steam libraries for the compatdata
prefix
([#21](#21))
([895fa2b](895fa2b))
* **steam:** detect running Darktide via /proc argv[0] under Proton
([#23](#23))
([c5f38c4](c5f38c4))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: ModifAmorphic <86930443+ModifAmorphic@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant