Skip to content

Commit fb6cd92

Browse files
committed
feat(settings): add local-first account foundation
1 parent de12be8 commit fb6cd92

25 files changed

Lines changed: 623 additions & 58 deletions

Directory.Build.props

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>28.0.0-alpha.16</Version>
3+
<Version>28.0.0-alpha.17</Version>
44
<VersionPrefix>28.0.0</VersionPrefix>
5-
<VersionSuffix>alpha.16</VersionSuffix>
5+
<VersionSuffix>alpha.17</VersionSuffix>
66
<AssemblyVersion>28.0.0.0</AssemblyVersion>
77
<FileVersion>28.0.0.0</FileVersion>
8-
<InformationalVersion>28.0.0-alpha.16</InformationalVersion>
9-
<LifeOSDisplayVersion>v28.0.0-alpha.16</LifeOSDisplayVersion>
10-
<LifeOSReleaseName>v28 Relationships and Contact Context</LifeOSReleaseName>
8+
<InformationalVersion>28.0.0-alpha.17</InformationalVersion>
9+
<LifeOSDisplayVersion>v28.0.0-alpha.17</LifeOSDisplayVersion>
10+
<LifeOSReleaseName>v28 Accounts and Local-First Sync Foundation</LifeOSReleaseName>
1111
</PropertyGroup>
1212
</Project>

LifeOS.Desktop/LifeOS.Desktop.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
</PropertyGroup>
1616

1717
<PropertyGroup Label="CurrentProductVersion">
18-
<Version>28.0.0-alpha.16</Version>
18+
<Version>28.0.0-alpha.17</Version>
1919
<AssemblyVersion>28.0.0.0</AssemblyVersion>
2020
<FileVersion>28.0.0.0</FileVersion>
21-
<InformationalVersion>28.0.0-alpha.16</InformationalVersion>
21+
<InformationalVersion>28.0.0-alpha.17</InformationalVersion>
2222
</PropertyGroup></Project>
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
using System.IO;
2+
using System.Text.Json;
3+
using System.Windows;
4+
using System.Windows.Automation;
5+
using System.Windows.Controls;
6+
using System.Windows.Media;
7+
using LifeOS.Core.Forms;
8+
using LifeOS.Core.LocalFirstSync;
9+
using LifeOS.Shared.LocalFirstSync;
10+
using LifeOS.Shared.Storage;
11+
12+
namespace LifeOS.Desktop;
13+
14+
public sealed class LocalFirstSyncWorkspaceView : UserControl
15+
{
16+
private readonly bool _demo;
17+
private List<LocalAccountSyncProfile> _profiles = [];
18+
private IReadOnlyList<FormFieldIssue> _issues = [];
19+
private UserFacingProblem? _problem;
20+
private string? _notice;
21+
private string _displayName = string.Empty;
22+
private string _deviceLabel = string.Empty;
23+
private LocalAccountMode _mode = LocalAccountMode.LocalOnly;
24+
25+
public LocalFirstSyncWorkspaceView(bool demo)
26+
{
27+
_demo = demo;
28+
Background = Brush("#0C1220");
29+
Foreground = Brushes.White;
30+
FontFamily = new FontFamily("Segoe UI");
31+
try { _profiles = LocalFirstSyncStorage.Load(); }
32+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
33+
{
34+
_problem = UserFacingProblemFactory.FromException(ex, "load the local account profile");
35+
}
36+
Render();
37+
}
38+
39+
private void Render()
40+
{
41+
LocalAccountSyncProfile? profile = _profiles.SingleOrDefault();
42+
StackPanel root = new() { Margin = new Thickness(24) };
43+
root.Children.Add(Text(_demo ? "PORTFOLIO DEMO • ISOLATED" : "ORDINARY MODE • LOCAL-FIRST", 11, "#AFA4FF", FontWeights.SemiBold));
44+
root.Children.Add(Text("Local Account & Sync Foundation", 30, "#FFFFFF", FontWeights.Bold));
45+
root.Children.Add(Text("Name this local workspace and choose a local-only or manual-transfer posture. No sign-in, cloud account, background sync, upload or provider write is available.", 14, "#B8C5D8"));
46+
if (_notice is not null) root.Children.Add(Text(_notice, 12, "#83D4B3", FontWeights.SemiBold));
47+
if (_problem is not null) root.Children.Add(Problem(_problem));
48+
49+
WrapPanel metrics = new() { Margin = new Thickness(0, 16, 0, 8) };
50+
metrics.Children.Add(Metric("Local profile", profile is null ? "Not set" : "Ready", "No authentication identity"));
51+
metrics.Children.Add(Metric("Pending local changes", (profile?.PendingLocalChanges ?? 0).ToString(), "Local counter only"));
52+
metrics.Children.Add(Metric("Cloud provider", "Not configured", "Credentials required later"));
53+
metrics.Children.Add(Metric("Background sync", "Off", "Cannot auto-resume"));
54+
root.Children.Add(metrics);
55+
56+
if (profile is null) root.Children.Add(Capture());
57+
else root.Children.Add(ProfileCard(profile));
58+
59+
root.Children.Add(Heading("Configuration boundary", 21, new Thickness(0, 20, 0, 6)));
60+
root.Children.Add(Card("Development can continue without credentials", "This foundation validates local identity labels, persists one recoverable profile and records local change pressure. Authentication, encryption keys, endpoint selection, remote conflict exchange and actual transfer remain unavailable until explicitly configured and tested.", "#152437"));
61+
LocalStoreHealth health = LocalFirstSyncStorage.Inspect();
62+
root.Children.Add(Card("Versioned local-account-sync store", $"State: {health.State}. Recovery is available in Local Data & Recovery. Reset moves the profile to recoverable Trash.", "#151F30"));
63+
Content = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, Content = root };
64+
}
65+
66+
private UIElement Capture()
67+
{
68+
StackPanel body = new();
69+
body.Children.Add(Heading("Create a local workspace profile", 20));
70+
body.Children.Add(Text("This is a device-local label, not a login or remote account.", 12, "#A9B6CA"));
71+
WrapPanel fields = new();
72+
fields.Children.Add(Input("Display name *", "local-account-name", "LocalSync.DisplayName", _displayName, value => _displayName = value, 480));
73+
fields.Children.Add(Input("Device label *", "local-device-label", "LocalSync.DeviceLabel", _deviceLabel, value => _deviceLabel = value, 480));
74+
body.Children.Add(fields);
75+
body.Children.Add(Choice("Data movement posture", "LocalSync.Mode", Enum.GetValues<LocalAccountMode>(), _mode, value => _mode = value, 480));
76+
Button create = Button("Create local profile", "LocalSync.Create", false);
77+
create.Click += (_, _) => Create();
78+
body.Children.Add(create);
79+
return Panel(body, new Thickness(0, 14, 0, 0));
80+
}
81+
82+
private UIElement ProfileCard(LocalAccountSyncProfile profile)
83+
{
84+
StackPanel body = new();
85+
DockPanel header = new();
86+
TextBlock state = Text(profile.State.ToString().ToUpperInvariant(), 11, "#AFA4FF", FontWeights.SemiBold);
87+
DockPanel.SetDock(state, Dock.Right);
88+
header.Children.Add(state);
89+
header.Children.Add(Heading(profile.DisplayName, 20));
90+
body.Children.Add(header);
91+
body.Children.Add(Text($"Device: {profile.DeviceLabel} • posture: {profile.Mode}", 12, "#A9B6CA"));
92+
body.Children.Add(Text($"Pending local changes: {profile.PendingLocalChanges}. Provider configured: no. Background sync: off.", 13, "#E7ECF4", FontWeights.SemiBold));
93+
if (profile.LastManualTransferUtc is not null) body.Children.Add(Text($"Last acknowledged manual transfer: {profile.LastManualTransferUtc:yyyy-MM-dd HH:mm} UTC", 11, "#A9B6CA"));
94+
WrapPanel actions = new();
95+
if (profile.State != LocalSyncState.Paused)
96+
{
97+
Button change = Button("Record local change", "LocalSync.RecordChange", true);
98+
change.Click += (_, _) => Change(profile, value => LocalFirstSyncService.RegisterLocalChange(value, DateTimeOffset.UtcNow), "Recorded one local change.");
99+
actions.Children.Add(change);
100+
if (profile.Mode == LocalAccountMode.ManualTransfer)
101+
{
102+
Button transfer = Button("Acknowledge manual transfer", "LocalSync.RecordTransfer", true);
103+
transfer.Click += (_, _) => Change(profile, value => LocalFirstSyncService.RecordManualTransfer(value, DateTimeOffset.UtcNow), "Recorded a manual-transfer checkpoint. LifeOS did not upload anything.");
104+
actions.Children.Add(transfer);
105+
}
106+
Button pause = Button("Pause local change tracking", "LocalSync.Pause", true);
107+
pause.Click += (_, _) => Change(profile, value => LocalFirstSyncService.SetPaused(value, true, DateTimeOffset.UtcNow), "Local change tracking paused.");
108+
actions.Children.Add(pause);
109+
}
110+
else
111+
{
112+
Button resume = Button("Resume local change tracking", "LocalSync.Resume", true);
113+
resume.Click += (_, _) => Change(profile, value => LocalFirstSyncService.SetPaused(value, false, DateTimeOffset.UtcNow), "Local change tracking resumed.");
114+
actions.Children.Add(resume);
115+
}
116+
body.Children.Add(actions);
117+
return Panel(body, new Thickness(0, 14, 0, 0));
118+
}
119+
120+
private void Create()
121+
{
122+
LocalAccountDraft draft = new(_displayName, _deviceLabel, _mode);
123+
FormValidationResult validation = LocalFirstSyncService.Validate(draft);
124+
_issues = validation.Issues;
125+
if (!validation.IsValid)
126+
{
127+
_problem = new UserFacingProblem("local-account-validation-failed", "Review the local profile fields", "No local account profile was added because one or more fields are invalid.", "Correct the highlighted fields, then try again.", true);
128+
Render();
129+
return;
130+
}
131+
try
132+
{
133+
LocalAccountSyncProfile profile = LocalFirstSyncService.Create(draft, DateTimeOffset.UtcNow);
134+
LocalFirstSyncStorage.Save([profile]);
135+
_profiles = [profile];
136+
_issues = [];
137+
_problem = null;
138+
_notice = "Created a local-only workspace profile. No remote account was created.";
139+
Render();
140+
}
141+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
142+
{
143+
_problem = UserFacingProblemFactory.FromException(ex, "save the local account profile");
144+
Render();
145+
}
146+
}
147+
148+
private void Change(LocalAccountSyncProfile profile, Func<LocalAccountSyncProfile, LocalAccountSyncProfile> change, string notice)
149+
{
150+
try
151+
{
152+
LocalAccountSyncProfile changed = change(profile);
153+
LocalFirstSyncStorage.Save([changed]);
154+
_profiles = [changed];
155+
_problem = null;
156+
_notice = notice;
157+
Render();
158+
}
159+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException or InvalidOperationException or OverflowException)
160+
{
161+
_problem = UserFacingProblemFactory.FromException(ex, "change the local sync state");
162+
Render();
163+
}
164+
}
165+
166+
private UIElement Input(string label, string fieldId, string automationId, string value, Action<string> changed, double width)
167+
{
168+
StackPanel field = new() { Width = width, Margin = new Thickness(0, 7, 10, 0) };
169+
field.Children.Add(Text(label, 12, "#C7D2E3", FontWeights.SemiBold));
170+
TextBox input = new() { Text = value, MinHeight = 38, Background = Brush("#101827"), Foreground = Brushes.White, BorderBrush = Brush("#3A4B66"), Padding = new Thickness(10, 7, 10, 7) };
171+
AutomationProperties.SetAutomationId(input, automationId);
172+
input.TextChanged += (_, _) => changed(input.Text);
173+
field.Children.Add(input);
174+
string error = string.Join(" ", _issues.Where(issue => issue.FieldId == fieldId).Select(issue => issue.Message));
175+
if (error.Length > 0) field.Children.Add(Text(error, 11, "#FF7788", FontWeights.SemiBold));
176+
return field;
177+
}
178+
179+
private static UIElement Choice<T>(string label, string automationId, IReadOnlyList<T> values, T selected, Action<T> changed, double width) where T : struct
180+
{
181+
StackPanel field = new() { Width = width, Margin = new Thickness(0, 7, 10, 0) };
182+
field.Children.Add(Text(label, 12, "#C7D2E3", FontWeights.SemiBold));
183+
ComboBox input = new() { ItemsSource = values, SelectedItem = selected, MinHeight = 38 };
184+
AutomationProperties.SetAutomationId(input, automationId);
185+
input.SelectionChanged += (_, _) => { if (input.SelectedItem is T value) changed(value); };
186+
field.Children.Add(input);
187+
return field;
188+
}
189+
190+
private static Border Problem(UserFacingProblem problem) { StackPanel body = new(); body.Children.Add(Heading($"{problem.Title} ({problem.Code})", 16)); body.Children.Add(Text(problem.Detail, 12, "#E1E7F0")); body.Children.Add(Text($"Next: {problem.RecoveryAction}", 12, "#C5AECF")); return new Border { Background = Brush("#251925"), BorderBrush = Brush("#C95F75"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(15), Margin = new Thickness(0, 12, 0, 0), Child = body }; }
191+
private static Button Button(string label, string id, bool secondary) { Button button = new() { Content = label, Background = Brush(secondary ? "#25334A" : "#315E91"), Foreground = Brushes.White, BorderBrush = Brush(secondary ? "#405472" : "#477DB4"), Padding = new Thickness(14, 7, 14, 7), Margin = new Thickness(0, 8, 8, 0), MinHeight = 36 }; AutomationProperties.SetAutomationId(button, id); return button; }
192+
private static Border Metric(string label, string value, string detail) { StackPanel body = new(); body.Children.Add(Text(label, 11, "#9EACC0")); body.Children.Add(Text(value, 22, "#FFFFFF", FontWeights.SemiBold)); body.Children.Add(Text(detail, 11, "#9EACC0")); return new Border { Width = 230, MinHeight = 100, Background = Brush("#151F30"), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(14), Margin = new Thickness(0, 0, 10, 10), Child = body }; }
193+
private static Border Card(string title, string body, string background) { StackPanel content = new(); content.Children.Add(Heading(title, 16)); content.Children.Add(Text(body, 12, "#C2CDDC")); return new Border { Background = Brush(background), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(16), Margin = new Thickness(0, 8, 0, 0), Child = content }; }
194+
private static Border Panel(UIElement child, Thickness margin) => new() { Background = Brush("#151F30"), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(16), Margin = margin, Child = child };
195+
private static TextBlock Heading(string text, double size, Thickness? margin = null) => new() { Text = text, FontSize = size, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, TextWrapping = TextWrapping.Wrap, Margin = margin ?? new Thickness(0, 0, 0, 4) };
196+
private static TextBlock Text(string text, double size, string color, FontWeight? weight = null) => new() { Text = text, FontSize = size, FontWeight = weight ?? FontWeights.Normal, Foreground = Brush(color), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 3, 0, 0) };
197+
private static SolidColorBrush Brush(string value) => new((Color)ColorConverter.ConvertFromString(value));
198+
}

LifeOS.Desktop/MainWindow.V8Routes.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public partial class MainWindow
3939
"intelligence",
4040
"item-state-engine",
4141
"lifeos-spine",
42+
"local-account-sync",
4243
"local-data-recovery",
4344
"memory",
4445
"money-pressure",
@@ -231,6 +232,9 @@ public void OpenV8Module(string routeId)
231232
case "lifeos-spine":
232233
ShowLifeOsSpinePage();
233234
break;
235+
case "local-account-sync":
236+
MainContentControl.Content = new LocalFirstSyncWorkspaceView(false);
237+
break;
234238
case "memory":
235239
ShowMemoryPage();
236240
break;

LifeOS.Desktop/V8ShellWindow.xaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,12 @@
369369
<Border Style="{StaticResource LifeOS.Card}" MaxWidth="1014" HorizontalAlignment="Left"><TextBlock Text="Preferences are stored locally under the user profile. Operational records stay in their authoritative module stores. Resetting shell preferences does not delete module data." Style="{StaticResource LifeOS.Text.Body}" /></Border>
370370

371371
<TextBlock Text="Sync / Companion" Margin="0,10,0,0" Style="{StaticResource LifeOS.Text.Heading}" />
372-
<Border Style="{StaticResource LifeOS.Card}" MaxWidth="1014" HorizontalAlignment="Left"><TextBlock Text="Companion and integration evidence remain review-first. No pending transfer mutates a workspace automatically." Style="{StaticResource LifeOS.Text.Body}" /></Border>
372+
<Border Style="{StaticResource LifeOS.Card}" MaxWidth="1014" HorizontalAlignment="Left">
373+
<StackPanel>
374+
<TextBlock Text="Companion and integration evidence remain review-first. No pending transfer mutates a workspace automatically." Style="{StaticResource LifeOS.Text.Body}" />
375+
<Button Content="Open Local Account &amp; Sync Foundation" Tag="local-account-sync" Click="OpenModule_Click" HorizontalAlignment="Left" Margin="0,12,0,0" Style="{StaticResource LifeOS.Button}" AutomationProperties.AutomationId="Workspace.Module.local-account-sync" />
376+
</StackPanel>
377+
</Border>
373378

374379
<TextBlock Text="Automation / Emergency Stop" Margin="0,10,0,0" Style="{StaticResource LifeOS.Text.Heading}" />
375380
<Border Style="{StaticResource LifeOS.Card}" MaxWidth="1014" HorizontalAlignment="Left"><StackPanel><TextBlock x:Name="SettingsStopStatusText" Text="Emergency Stop: Idle" Style="{StaticResource LifeOS.Text.Body}" /><TextBlock Text="The state is always written in text and never communicated by colour alone." Margin="0,6,0,0" Style="{StaticResource LifeOS.Text.Caption}" /><Button Content="Open Automation Centre" Tag="automation-centre" Click="OpenModule_Click" HorizontalAlignment="Left" Margin="0,12,0,0" Style="{StaticResource LifeOS.Button}" AutomationProperties.AutomationId="Settings.Automation.OpenCentre" /></StackPanel></Border>

LifeOS.Desktop/V8ShellWindow.xaml.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,12 @@ private void OpenModule(string routeId)
11971197
_preferences.ExperienceMode == V8ExperienceMode.PortfolioDemo));
11981198
return;
11991199
}
1200+
if (string.Equals(routeId, "local-account-sync", StringComparison.OrdinalIgnoreCase))
1201+
{
1202+
ShowEmbeddedModule(routeId, title, subtitle, new LocalFirstSyncWorkspaceView(
1203+
_preferences.ExperienceMode == V8ExperienceMode.PortfolioDemo));
1204+
return;
1205+
}
12001206

12011207
MainWindow legacyModule = new()
12021208
{

LifeOS.Desktop/WorkspaceCatalog.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ private static WorkspaceModuleDefinition Navigate(
340340
new WorkspaceModuleDefinition[]
341341
{
342342
Route("settings-safety", "Settings / Safety", "Canonical settings", "Underlying safety profile and local settings evidence."),
343+
Route("local-account-sync", "Local Account & Sync", "SG-188 active", "Validated local workspace identity, manual-transfer posture and visible configuration gates without cloud authentication or background sync."),
343344
Route("local-data-recovery", "Local Data & Recovery", "SG-136 ready", "Versioned local stores, migration and backup health, recoverable Trash and explicit restore controls."),
344345
Route("control-plane", "Privacy, Backup & Emergency Control", "SG-111 ready", "Sensitive-category permissions, integrity-checked backup/restore, audit and global Emergency Stop."),
345346
Route("beta-readiness", "Private Beta Readiness", "SG-82 ready", "Setup now/later/decline choices and desktop/mobile/web release validation."),

0 commit comments

Comments
 (0)