0.9.7
What's Changed
add: notification-area icon, with minimize to tray (Phase 5)
The window is not what the player looks at while playing — the overlay is — so
the shell spends most of a session out of the way. A NotifyIcon in MainWindow.xaml
carries a three-item menu: open the window, toggle the in-game overlay (the check
mark reads the HUD's own state), quit. Minimize to tray is off by default and read
at the moment of the minimize, so the Settings switch needs no restart.
ViewModels/TrayViewModel holds the menu and the minimize rule over the
Services/ITrayHost seam that MainWindow implements and hands to it, which is what
makes E2E/TrayScenarios provable without a window. Same window-seam idea as
OverlayService/IOverlayWindow, but the coordinator is a view model because every
decision here is a menu item; the day the tray grows one that is not, split a
TrayService out.
Exit closes the shell rather than calling Application.Shutdown(). MainWindow.OnClosed
stays the single exit trigger and the only path that reaches StopAsync, which flushes
the inventory store — a menu item that shut down directly would drop the counts edited
in the last seconds in game.
Two failures this feature can produce that no other can, both guarded:
- Hide() removes the window from the taskbar AND from Alt+Tab, so hiding into a
notification area with no icon of ours would leave Task Manager as the only way
back. Hiding is gated on ITrayHost.IsTrayAvailable, read fresh each time; with no
icon the window just minimizes normally. Same guard OverlayService.Initialize makes
for a click-through HUD whose unlock hotkey never registered. - An Explorer restart rebuilds the notification area and takes every icon with it.
Wpf.Ui.Tray 4.3.0 does not handle the shell's TaskbarCreated broadcast anywhere, so
the icon would be gone for the rest of the session while IsRegistered still claimed
otherwise. MainWindow hooks the broadcast and re-registers; TrayManager.Register
repopulates the whole NOTIFYICONDATA, icon and tooltip included. Verified by
broadcasting the message at a running app rather than by restarting Explorer.
Restoring from the tray is Views/WindowRestore: Show() first, then WindowState. The
intuitive order fails silently — WPF defers a state written to a hidden window, so
the property reports Normal and IsVisible reports true while the HWND stays iconic,
which is a taskbar button and no window. Reported from the field during this work and
measured with IsIconic; E2E/WindowRestoreTests pins it against a real Window and
asserts on the HWND, because trusting the managed properties is what let it through.
NotifyIcon reports a registration failure nowhere, and a missing icon looks exactly
like one in the overflow flyout, so MainWindow logs the outcome — the same diagnostic
role the hotkey backend line has. AppLog's "Warn" spelling is normalised to "Warning"
so one grep finds every warning.
Also drops Phase 6 (Supabase + Discord OAuth) from the roadmap: a hosted backend is a
recurring cost for a single-machine companion app, and Discord identity buys nothing
when there is nothing shared to identify against. The %AppData% JSON files stay the
only persistence.
Tests: 389 green.
fix: a failed refresh was erased by the next catalog navigation
Settings said "offline", the user opened the Catalog, and the green cloud came
back over data nothing had re-validated. The load in between made no API call at
all — the 12 h version-check timer had not elapsed — and that path published
CatalogStatus.Online unconditionally.
So Online meant two different things: "the API answered just now" and "we did not
ask". The second one silently overwrote the first, which is the whole defect — a
badge a page change resets tells the user nothing they can act on.
CatalogStatus now reports what the last contact taught us rather than what the
current load did, decided in one place, ContractCatalogService.CachedStatus:
- the rate-limit window is read live, because it expires on its own;
- _apiUnreachable is sticky, because unreachability does not expire. Only an
answer from the server clears it, and a 429 counts as one: the server replied,
so a stale offline verdict has to go, and the window alone represents the 429.
The flag is in memory only, deliberately. A failed attempt is positive evidence
and must survive navigation while the app runs; a fresh launch holds no such
evidence, and absence of evidence is not evidence of a problem. Answering that at
startup would mean a version check on every launch, which is exactly what the 12 h
timer exists to avoid.
Covered by E2E/CatalogAvailabilityScenarios for the journey (forced refresh fails,
then the catalog is opened) and two ContractCatalogServiceTests for the carry
forward and the 429 interaction. Verified the first two fail with the old
unconditional Online; the 429 one guards the clearing rule going forward rather
than this fix.
Also drops "Start with Windows" from Phase 5: a companion app for one game does not
belong in every boot, and the tray already keeps it out of the way for the rest of
the session.
Tests: 392 green.
add: Favorites tabs, a completion filter, and a gathering grid (Phase 2.5)
The gathering plan sat in an Expander above the contract list, so opening it
pushed the list — the reason the page exists — down to roughly one visible card.
That is why the panel had to ship collapsed: both halves were competing for the
same vertical space and the loser was whichever one you were not looking at.
FavoritesPage is now a TabControl, and each half gets the whole page.
The count badge moves from the Expander header to the gathering tab's header,
which is the job that header was really doing — keeping the number glanceable
from the other tab. Both TabItems are always present and their states live
inside them: binding TabItem.Visibility would hide a selected tab, and WPF does
not move the selection off one that disappears, leaving a blank content area.
The filter bar moves inside the contracts tab, which makes "the plan ignores the
page's filters" structural rather than a rule stated in docs/ui-notes.md.
ContractFilter gains a completion axis so both list pages can hide finished
contracts without un-starring them. Completion is a parameter of Matches rather
than a field read from ICompletionService: it is service state keyed by UUID,
and taking that service would cost the record the purity that lets it be tested
without a WPF Application.
One consequence needed its own care. OnCompletionChanged fanned out to the cards
and stopped there, so a contract completed while "not completed" was selected
stayed on screen until something else re-filtered. It now refreshes the
collection view too, unconditionally — completion changes at click rate. That
refresh is deliberately NOT in OnInventoryChanged, which is the ~30x/s
held-hotkey path.
With a tab to work with, the plan renders as a card grid whose column count
follows the window width (WidthToColumnsConverter over a UniformGrid, minimum
column width as ConverterParameter). Not WrapPanel + a fixed ItemWidth, which
flows at one width and leaves a ragged right gutter. Each card states what is
gathered against what the starred contracts ask for, a coverage bar, and its own
overlay pin — so an item can be pinned from the list where the decision is
actually made instead of finding the name again on the Inventory page.
The card does not spell the shortfall out separately. "12 / 36" already says it,
the tab is titled "What to still gather", and a third number was the one that
wanted a status colour of its own.
GatheringItem gains CoveredFraction, beside Outstanding, because it is the same
derived arithmetic and belongs with the rest of it rather than in a row view
model. SyncGathering reconciles the displayed rows in place against the plan's
name ordering: rebuilt rows would discard every PinToggle to move one number,
thirty times a second while a hotkey is held. GatheringRowViewModel.Update
returns early on record equality for the same reason — the plan is recomputed
whole, but one edit moves one row.
The pin affordance and the "Overlay 3/10" budget are extracted into PinToggle
and OverlayPinsViewModel, shared by the inventory row and the gathering card.
The budget VM is a singleton: there is one set of pins, and a second counter
would only be a second thing to keep in step.
Availability colour stops going through an IValueConverter, and this fixes a
real defect rather than tidying one. A converter runs when its binding evaluates
and returns an already-resolved Brush; nothing re-runs it when a resource
dictionary is swapped, because the binding's source has not changed. So after a
runtime light/dark flip every converter-supplied colour stayed on the old
palette while DynamicResource chrome moved with it. Chips.xaml now does it with
Style.Triggers + DynamicResource in AvailabilityChipStyle and the new
AvailabilityValueStyle, and AvailabilityToBrushConverter is deleted. Chips had
hidden the fault because fill, border and text went stale together and stayed
internally legible; the pin badge next to a stale number is what exposed it.
Verified by rendering the page, flipping the theme at runtime, and re-shooting —
a cold start proves nothing here, since everything resolves against the palette
already merged.
WPF-UI themes stock TabControl, but three of its tokens are unusable over a Mica
page: the selected header's background is SolidBackgroundFillColorTertiary
(#282828 on a #202020 page) and both border keys are 10% black. They are
re-pointed inside TabControl.Resources, where the templates' DynamicResource
lookups find them and nothing else does, using Colors + DynamicResource rather
than a StaticResource alias to a brush key, which would freeze one theme's value
into the other. The rule under the tab strip is the content Border's top edge, so
BorderBrush on the TabControl is all it takes.
Tests: 426 green.