diff --git a/src/PostIt/PostIt/Services/UiDispatcher.cs b/src/PostIt/PostIt/Services/UiDispatcher.cs
new file mode 100644
index 00000000..e935ac1a
--- /dev/null
+++ b/src/PostIt/PostIt/Services/UiDispatcher.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Threading;
+
+namespace PostIt.Services;
+
+///
+/// Tiny marshalling helper around so
+/// the rest of the codebase does not have to import Avalonia.Threading
+/// directly. We want exactly one place that decides "is the current
+/// thread the Avalonia UI thread, and if not, post there" so that
+/// -derived types (Settings, the various
+/// ViewModels) can fire PropertyChanged safely from background
+/// work — which is exactly the cross-thread case that previously blew
+/// up inside DataValidationErrors.SetErrors on Avalonia 11.
+///
+/// The helper is intentionally tiny: a sync post when we are off the
+/// UI thread, a no-op when we are already on it, and an async fire-
+/// and-forget variant for places where awaiting would deadlock the
+/// caller (e.g. Settings.Load continuation paths).
+///
+public static class UiDispatcher
+{
+ ///
+ /// True when the calling thread is the Avalonia UI thread. Property
+ /// setters that touch bindings should check this before mutating
+ /// state; the safe path is .
+ ///
+ public static bool IsOnUiThread => Dispatcher.UIThread.CheckAccess();
+
+ ///
+ /// Run on the UI thread. If the caller is
+ /// already on the UI thread, run synchronously to preserve stack
+ /// traces and ordering; otherwise post to the dispatcher and wait.
+ /// Never throws on shutdown — a missing dispatcher is treated as
+ /// "best-effort skipped", matching Avalonia's own behaviour when
+ /// the application lifetime has been torn down.
+ ///
+ public static void InvokeIfNeeded(Action action)
+ {
+ if (action is null) return;
+ if (IsOnUiThread) { action(); return; }
+ try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
+ catch (InvalidOperationException) { /* dispatcher gone, nothing to do */ }
+ }
+
+ ///
+ /// Fire-and-forget variant: schedules on
+ /// the UI thread but does not block the caller. Use this from
+ /// background workers (OIDC discovery, HTTP callbacks, file I/O)
+ /// where awaiting the dispatcher would deadlock the calling sync
+ /// context.
+ ///
+ public static void Post(Action action)
+ {
+ if (action is null) return;
+ try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
+ catch (InvalidOperationException) { /* dispatcher gone */ }
+ }
+
+ ///
+ /// Awaitable variant. Useful inside async ViewModel methods
+ /// that must touch bindings only after the dispatcher has processed
+ /// a queued update (e.g. "load file then refresh observable state").
+ ///
+ public static Task InvokeAsync(Action action)
+ {
+ if (action is null) return Task.CompletedTask;
+ if (IsOnUiThread) { action(); return Task.CompletedTask; }
+ return Dispatcher.UIThread.InvokeAsync(action, DispatcherPriority.Normal).GetTask();
+ }
+}
diff --git a/src/Yavsc.Org/Views/Blogspot/Index.cshtml b/src/Yavsc.Org/Views/Blogspot/Index.cshtml
index 1582041c..52cf3b88 100644
--- a/src/Yavsc.Org/Views/Blogspot/Index.cshtml
+++ b/src/Yavsc.Org/Views/Blogspot/Index.cshtml
@@ -70,7 +70,7 @@
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
{
-
Details
+
Details
}
else
{