diff --git a/CLAUDE.md b/CLAUDE.md
index c46edcc..4708dd0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,4 +65,5 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle
- **In-app update check (added 2026-09-02, on branch `feature/update-check-notify`, not yet merged to staging — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic.
**UX pass (started 2026-09-02, in progress)** — a full-codebase UX review turned up several friction points (Select tool overloaded with undiscoverable per-click-target behavior, no undo anywhere, no onboarding, inconsistent delete confirmation, silent AP-suggestion replacement, no survey-type context in Workspace, the Pivot-clipping bug). Being fixed incrementally, each as its own branch:
+- **Workspace onboarding + tool legend, and a warmer Home (added 2026-09-02, on branch `feature/workspace-onboarding`, not yet merged to staging):** a "Tool Help" button (flyout, always available) now explains all four canvas tools including Select's three click-target-dependent behaviors. A skippable first-run coach-mark tour (`TeachingTip`, one per tool button, Next/Skip actions) walks a new user through them once — tracked via a new `AppSettings.HasSeenWorkspaceOnboarding` (same `IAppSettingsService` store the update-check feature added), `WorkspaceViewModel.ShouldShowOnboardingAsync`/`MarkOnboardingSeenAsync`. Separately, at the user's request, Home's static "Home" title is now a time-of-day greeting (`HomeViewModel.GreetingDisplay`, morning/afternoon/evening/late-night buckets on `DateTime.Now.Hour`) plus a small, non-interactive `WellbeingMessageDisplay` line picked at random from 4 options each visit — no reply affordance, just a quiet touch. The now-unused static "HomeTitle" resource was removed from both languages since this change made it dead.
- **Undo last delete (added 2026-09-02, on branch `feature/undo-last-delete`, not yet merged to staging):** the Delete tool removed a wall/test point/AP permanently with zero confirmation or recovery — the single highest-risk unconfirmed action in the app. Deliberately not a full undo/redo stack (that would touch nearly every mutation in `WorkspaceViewModel`) — just single-level: `TryFindNearestElement` now also reports *what* it's about to remove, `DeleteNearestElementAsync` stashes `(Floor, object Item)` before removing, and a new `UndoDeleteCommand` re-adds it. An `InfoBar` with an Undo action button appears after a delete (`CanUndoDelete`/`LastDeletedItemDisplay`), overwritten by the next delete and cleared on floor switch so it never fires somewhere other than where the deletion happened. 5 new tests covering restore-after-delete for both a wall and a test point, overwrite-on-second-delete, and floor-switch clearing.
diff --git a/src/Pulsemap.App.Core/Settings/AppSettings.cs b/src/Pulsemap.App.Core/Settings/AppSettings.cs
index 95cdddb..be9fb68 100644
--- a/src/Pulsemap.App.Core/Settings/AppSettings.cs
+++ b/src/Pulsemap.App.Core/Settings/AppSettings.cs
@@ -8,4 +8,8 @@ public sealed class AppSettings
/// API with no identifying data attached, not telemetry — but it's still an outbound network
/// call, so it stays user-visible and disableable in Settings.
public bool CheckForUpdatesEnabled { get; set; } = true;
+
+ /// Whether the Workspace first-run coach-mark tour has been shown or skipped —
+ /// suppresses it on every later visit regardless of which survey is open.
+ public bool HasSeenWorkspaceOnboarding { get; set; }
}
diff --git a/src/Pulsemap.App/Services/LocalizationService.cs b/src/Pulsemap.App/Services/LocalizationService.cs
index 5b26903..981ecea 100644
--- a/src/Pulsemap.App/Services/LocalizationService.cs
+++ b/src/Pulsemap.App/Services/LocalizationService.cs
@@ -120,6 +120,14 @@ public string GetString(string key)
["WorkspaceUndoDeleteAccessPointMessage"] = "Access point deleted.",
["HomeUpdateAvailableFormat"] = "Pulsemap {0} is available.",
+ ["HomeGreetingMorning"] = "Good morning",
+ ["HomeGreetingAfternoon"] = "Good afternoon",
+ ["HomeGreetingEvening"] = "Good evening",
+ ["HomeGreetingNight"] = "Hello",
+ ["HomeWellbeingMessage1"] = "Hope you're doing well today.",
+ ["HomeWellbeingMessage2"] = "Take a moment for yourself if you need one.",
+ ["HomeWellbeingMessage3"] = "However today's going, we hope it gets a little easier.",
+ ["HomeWellbeingMessage4"] = "Glad you're here. Take care of yourself out there.",
},
["pl-PL"] = new Dictionary
{
@@ -198,6 +206,14 @@ public string GetString(string key)
["WorkspaceUndoDeleteAccessPointMessage"] = "Usunięto punkt dostępowy.",
["HomeUpdateAvailableFormat"] = "Dostępny jest Pulsemap {0}.",
+ ["HomeGreetingMorning"] = "Dzień dobry",
+ ["HomeGreetingAfternoon"] = "Dzień dobry",
+ ["HomeGreetingEvening"] = "Dobry wieczór",
+ ["HomeGreetingNight"] = "Witaj",
+ ["HomeWellbeingMessage1"] = "Mamy nadzieję, że wszystko u Ciebie dobrze.",
+ ["HomeWellbeingMessage2"] = "Jeśli potrzebujesz chwili dla siebie, zrób sobie przerwę.",
+ ["HomeWellbeingMessage3"] = "Niezależnie od tego, jak mija dzisiejszy dzień, mamy nadzieję, że będzie choć trochę lżej.",
+ ["HomeWellbeingMessage4"] = "Miło, że tu jesteś. Zadbaj o siebie.",
},
};
}
diff --git a/src/Pulsemap.App/Strings/en-US/Resources.resw b/src/Pulsemap.App/Strings/en-US/Resources.resw
index 71acba6..497316e 100644
--- a/src/Pulsemap.App/Strings/en-US/Resources.resw
+++ b/src/Pulsemap.App/Strings/en-US/Resources.resw
@@ -25,9 +25,6 @@
-
- Home
-
Your surveys, all in one place.
@@ -278,6 +275,84 @@
Delete
+
+ Tool Help
+
+
+ What each tool does
+
+
+ Select
+
+
+ Click a wall to add it to a multi-select for batch material/thickness editing. Click a test point to rescan and replace its reading. On an outdoor area, drag its edge to resize or drag inside it to move the whole area.
+
+
+ Add Test Point
+
+
+ Click anywhere on the canvas to place a manual test point without doing a full guided walk.
+
+
+ Draw Wall
+
+
+ Click one endpoint, then click the other to draw a wall between them.
+
+
+ Delete
+
+
+ Click near a wall, test point, or access point to remove it. An Undo option appears right after, until your next delete.
+
+
+ Welcome to the Workspace
+
+
+ Select does different things depending on what you click: a wall, a test point, or an outdoor area's edge. The Tool Help button always explains this if you forget.
+
+
+ Next
+
+
+ Skip tour
+
+
+ Add Test Point
+
+
+ Click anywhere to drop a manual test point — or use the Adapter tab's guided walk to be led through suggested points instead.
+
+
+ Next
+
+
+ Skip tour
+
+
+ Draw Wall
+
+
+ Click one end, then the other, to draw a wall. Give it a material afterward with Select for a more accurate coverage prediction.
+
+
+ Next
+
+
+ Skip tour
+
+
+ Delete
+
+
+ Click near anything to remove it — an Undo option appears right after, so a misclick isn't permanent.
+
+
+ Got it
+
+
+ Skip tour
+
Back
diff --git a/src/Pulsemap.App/Strings/pl-PL/Resources.resw b/src/Pulsemap.App/Strings/pl-PL/Resources.resw
index 0742093..db3438d 100644
--- a/src/Pulsemap.App/Strings/pl-PL/Resources.resw
+++ b/src/Pulsemap.App/Strings/pl-PL/Resources.resw
@@ -25,9 +25,6 @@
-
- Strona główna
-
Wszystkie Twoje badania w jednym miejscu.
@@ -278,6 +275,84 @@
Usuń
+
+ Pomoc dot. narzędzi
+
+
+ Co robi każde narzędzie
+
+
+ Zaznacz
+
+
+ Kliknij ścianę, aby dodać ją do zaznaczenia wielokrotnego w celu zbiorczej edycji materiału/grubości. Kliknij punkt pomiarowy, aby ponownie zeskanować i zastąpić jego odczyt. W obszarze zewnętrznym przeciągnij krawędź, aby zmienić rozmiar, lub przeciągnij wnętrze, aby przesunąć cały obszar.
+
+
+ Dodaj punkt pomiarowy
+
+
+ Kliknij dowolne miejsce na płótnie, aby ręcznie umieścić punkt pomiarowy bez pełnego pomiaru terenowego.
+
+
+ Rysuj ścianę
+
+
+ Kliknij jeden koniec, a następnie drugi, aby narysować między nimi ścianę.
+
+
+ Usuń
+
+
+ Kliknij w pobliżu ściany, punktu pomiarowego lub punktu dostępowego, aby go usunąć. Zaraz potem pojawi się opcja cofnięcia, ważna do kolejnego usunięcia.
+
+
+ Witaj w Obszarze roboczym
+
+
+ Narzędzie Zaznacz robi różne rzeczy w zależności od tego, co klikniesz: ścianę, punkt pomiarowy lub krawędź obszaru zewnętrznego. Przycisk Pomoc dot. narzędzi zawsze to wyjaśni, gdybyś zapomniał.
+
+
+ Dalej
+
+
+ Pomiń
+
+
+ Dodaj punkt pomiarowy
+
+
+ Kliknij dowolne miejsce, aby umieścić ręczny punkt pomiarowy — albo skorzystaj z pomiaru terenowego w zakładce Adapter, aby zostać poprowadzonym przez sugerowane punkty.
+
+
+ Dalej
+
+
+ Pomiń
+
+
+ Rysuj ścianę
+
+
+ Kliknij jeden koniec, a potem drugi, aby narysować ścianę. Nadaj jej materiał później za pomocą Zaznacz, aby uzyskać dokładniejszą prognozę pokrycia.
+
+
+ Dalej
+
+
+ Pomiń
+
+
+ Usuń
+
+
+ Kliknij w pobliżu dowolnego elementu, aby go usunąć — zaraz potem pojawi się opcja cofnięcia, więc pomyłka nie jest trwała.
+
+
+ Rozumiem
+
+
+ Pomiń
+
Wstecz
diff --git a/src/Pulsemap.App/ViewModels/HomeViewModel.cs b/src/Pulsemap.App/ViewModels/HomeViewModel.cs
index 7d27ede..6bf3cdd 100644
--- a/src/Pulsemap.App/ViewModels/HomeViewModel.cs
+++ b/src/Pulsemap.App/ViewModels/HomeViewModel.cs
@@ -18,10 +18,31 @@ public partial class HomeViewModel(
// Home is a dashboard, not the library — the Surveys nav tab shows the full, unlimited list.
private const int RecentSurveysLimit = 3;
+ // A quiet, non-interactive touch — no reply expected or possible, just a small sign the app
+ // isn't purely transactional. Picked once per Home visit (HomeViewModel is transient, so a
+ // fresh navigation gets a fresh pick) rather than rotated on a timer or tracked across visits.
+ private static readonly string[] WellbeingMessageKeys =
+ [
+ "HomeWellbeingMessage1",
+ "HomeWellbeingMessage2",
+ "HomeWellbeingMessage3",
+ "HomeWellbeingMessage4",
+ ];
+
private string? _releaseUrl;
public ObservableCollection Surveys { get; } = [];
+ public string GreetingDisplay => DateTime.Now.Hour switch
+ {
+ >= 5 and < 12 => localizationService.GetString("HomeGreetingMorning"),
+ >= 12 and < 17 => localizationService.GetString("HomeGreetingAfternoon"),
+ >= 17 and < 22 => localizationService.GetString("HomeGreetingEvening"),
+ _ => localizationService.GetString("HomeGreetingNight"),
+ };
+
+ public string WellbeingMessageDisplay { get; } = localizationService.GetString(WellbeingMessageKeys[Random.Shared.Next(WellbeingMessageKeys.Length)]);
+
[ObservableProperty]
public partial bool IsLoading { get; set; }
diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs
index 27e2c08..e795cc9 100644
--- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs
+++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs
@@ -11,6 +11,7 @@
using Pulsemap.App.Core.Persistence;
using Pulsemap.App.Core.Placement;
using Pulsemap.App.Core.Propagation;
+using Pulsemap.App.Core.Settings;
using Pulsemap.App.Services;
using Windows.System;
@@ -42,6 +43,7 @@ public partial class WorkspaceViewModel : ObservableObject
private readonly IReportExporter _reportExporter;
private readonly ISurveyExportFilePickerService _exportFilePickerService;
private readonly IKrigingInterpolator _krigingInterpolator;
+ private readonly IAppSettingsService _appSettingsService;
private string? _filePath;
@@ -55,7 +57,8 @@ public WorkspaceViewModel(
ISurveyDataExporter surveyDataExporter,
IReportExporter reportExporter,
ISurveyExportFilePickerService exportFilePickerService,
- IKrigingInterpolator krigingInterpolator)
+ IKrigingInterpolator krigingInterpolator,
+ IAppSettingsService appSettingsService)
{
_surveyFileService = surveyFileService;
_propagationModel = propagationModel;
@@ -67,6 +70,7 @@ public WorkspaceViewModel(
_reportExporter = reportExporter;
_exportFilePickerService = exportFilePickerService;
_krigingInterpolator = krigingInterpolator;
+ _appSettingsService = appSettingsService;
}
public event EventHandler? FloorChanged;
@@ -226,6 +230,19 @@ public string AccessPointSummaryDisplay
public string WallSelectionCountDisplay =>
string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceWallSelectionCountFormat"), SelectedWallCount);
+ public async Task ShouldShowOnboardingAsync()
+ {
+ var settings = await _appSettingsService.LoadAsync();
+ return !settings.HasSeenWorkspaceOnboarding;
+ }
+
+ public async Task MarkOnboardingSeenAsync()
+ {
+ var settings = await _appSettingsService.LoadAsync();
+ settings.HasSeenWorkspaceOnboarding = true;
+ await _appSettingsService.SaveAsync(settings);
+ }
+
public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default)
{
_filePath = filePath;
diff --git a/src/Pulsemap.App/Views/HomePage.xaml b/src/Pulsemap.App/Views/HomePage.xaml
index 882686b..fb92493 100644
--- a/src/Pulsemap.App/Views/HomePage.xaml
+++ b/src/Pulsemap.App/Views/HomePage.xaml
@@ -35,8 +35,9 @@
-
+
+