diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs
index 3f2a8d5..bbeb0be 100644
--- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs
+++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs
@@ -55,6 +55,19 @@ public async Task SaveThenLoad_RoundTrips()
reloaded.Should().Be(original);
}
+ [Fact]
+ public async Task SaveThenLoad_DistinctFixture_PreservesEveryMappedField()
+ {
+ using var tempDir = new TempDirectory();
+ var facade = new GridStyleEditorFacade();
+
+ facade.Save(tempDir.Path, GridStyleOptionsTestData.Distinct()).IsSuccess.Should().BeTrue();
+
+ var reloaded = (await facade.Load(tempDir.Path)).Value;
+
+ reloaded.Should().Be(GridStyleOptionsTestData.Distinct());
+ }
+
[Fact]
public async Task Validate_ShippedRecord_ReturnsOk()
{
diff --git a/SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs b/SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs
new file mode 100644
index 0000000..f0897b2
--- /dev/null
+++ b/SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs
@@ -0,0 +1,100 @@
+using SemiStep.Core.Configuration;
+
+namespace SemiStep.Tests.Helpers;
+
+///
+/// Shared test fixture for the grid-style anti-regression guards. returns a
+/// in which every surfaced field holds a distinct, valid, and
+/// exactly-representable value, so that a dropped or cross-wired Seed/BuildRecord/mapper line makes
+/// exactly one field mismatch. Colors are 53 mutually-distinct UPPERCASE opaque #RRGGBB literals
+/// (equality is string equality; ToHex emits X2). Numerics are integers or clean halves so the
+/// int↔decimal and double↔decimal conversions are exact. All five italics are true — the one
+/// kind whose type default (false) can equal a fixture value — so a dropped italic Seed line
+/// surfaces as false ≠ true. is the non-default value, which
+/// proves BuildRecord preserves the unsurfaced field through with.
+///
+public static class GridStyleOptionsTestData
+{
+ public static GridStyleOptions Distinct()
+ {
+ return new GridStyleOptions(
+ FontFamily: "Distinct Test Font",
+ HeaderFontSize: 13,
+ HeaderFontWeight: 300,
+ HeaderItalic: true,
+ CellFontSize: 11,
+ CellFontWeight: 800,
+ CellItalic: true,
+ CellPaddingLeft: 6.5,
+ CellPaddingTop: 4.5,
+ CellPaddingRight: 7.5,
+ CellPaddingBottom: 3.5,
+ RowHeight: 28.5,
+ SelectionBackgroundColor: "#010101",
+ SelectionForegroundColor: "#020202",
+ CellChangedColor: "#030303",
+ CellChangedSelectedColor: "#040404",
+ DisabledCellDepth0Color: "#050505",
+ DisabledCellDepth1Color: "#060606",
+ DisabledCellDepth2Color: "#070707",
+ DisabledCellDepth3Color: "#080808",
+ DisabledCellDepth0PastColor: "#090909",
+ DisabledCellDepth1PastColor: "#0A0A0A",
+ DisabledCellDepth2PastColor: "#0B0B0B",
+ DisabledCellDepth3PastColor: "#0C0C0C",
+ DisabledCellSelectedColor: "#0D0D0D",
+ DisabledCellForegroundColor: "#0E0E0E",
+ ReadOnlyCellDepth0Color: "#0F0F0F",
+ ReadOnlyCellDepth1Color: "#101010",
+ ReadOnlyCellDepth2Color: "#111111",
+ ReadOnlyCellDepth3Color: "#121212",
+ ReadOnlyCellDepth0PastColor: "#131313",
+ ReadOnlyCellDepth1PastColor: "#141414",
+ ReadOnlyCellDepth2PastColor: "#151515",
+ ReadOnlyCellDepth3PastColor: "#161616",
+ ReadOnlyCellSelectedColor: "#171717",
+ ReadOnlyCellForegroundColor: "#181818",
+ GridLineColor: "#191919",
+ StatusBarBackgroundColor: "#1A1A1A",
+ StatusBarForegroundColor: "#1B1B1B",
+ StatusBarPadding: 5.5,
+ StatusBarItemSpacing: 10.5,
+ StatusBarFontSize: 17,
+ StatusBarFontWeight: 500,
+ StatusBarItalic: true,
+ StatusBarTimerLabelFontSize: 19,
+ StatusBarTimerLabelFontWeight: 600,
+ StatusBarTimerLabelItalic: true,
+ StatusBarTimerValueFontSize: 23,
+ StatusBarTimerValueFontWeight: 700,
+ StatusBarTimerValueItalic: true,
+ ValidationPanelBackgroundColor: "#1C1C1C",
+ ValidationPanelForegroundColor: "#1D1D1D",
+ ValidationPanelErrorColor: "#1E1E1E",
+ ValidationPanelWarningColor: "#1F1F1F",
+ ValidationPanelMaxHeight: 150.5,
+ ExecutionDepth0Color: "#202020",
+ ExecutionDepth1Color: "#212121",
+ ExecutionDepth2Color: "#222222",
+ ExecutionDepth3Color: "#232323",
+ ExecutionDepth0PastColor: "#242424",
+ ExecutionDepth1PastColor: "#252525",
+ ExecutionDepth2PastColor: "#262626",
+ ExecutionDepth3PastColor: "#272727",
+ ExecutionCurrentStepMarkerColor: "#282828",
+ InfoColor: "#292929",
+ ConnectedColor: "#2A2A2A",
+ DisconnectedColor: "#2B2B2B",
+ LocalModeColor: "#2C2C2C",
+ ConnectingColor: "#2D2D2D",
+ PanelBackgroundColor: "#2E2E2E",
+ PanelHeaderBackgroundColor: "#2F2F2F",
+ SubtleBorderColor: "#303030",
+ SeparatorColor: "#313131",
+ SecondaryForegroundColor: "#323232",
+ GridBorderColor: "#333333",
+ GridBackgroundColor: "#343434",
+ HeaderForegroundColor: "#353535",
+ Orientation: GridOrientation.ColumnsAsSteps);
+ }
+}
diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs
index 80c1d2f..22deb25 100644
--- a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs
+++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections.Generic;
using System.Reactive;
using System.Reactive.Linq;
+using System.Reflection;
using System.Text.RegularExpressions;
using Avalonia.Headless.XUnit;
@@ -31,36 +33,77 @@ namespace SemiStep.Tests.UI.StyleEditor;
public sealed class GridStyleEditorViewModelTests
{
private const string ConfigDir = @"C:\does-not-exist";
+ private const int SurfacedEditablePropertyCount = 77;
[AvaloniaFact]
- public void Seed_PopulatesColorAndNumericProps_FromRecord()
+ public void Seed_PopulatesEverySurfacedProperty_FromDistinctFixture()
{
- var source = GridStyleOptions.Default with
+ var fixture = GridStyleOptionsTestData.Distinct();
+ var viewModel = CreateViewModel(fixture);
+
+ var properties = EditableProperties();
+ properties.Should().HaveCount(
+ SurfacedEditablePropertyCount,
+ "the fixture must exercise every surfaced editable property");
+
+ foreach (var property in properties)
{
- StatusBarFontSize = 15,
- StatusBarTimerValueFontSize = 30
- };
- var viewModel = CreateViewModel(source);
+ var recordValue = RecordField(property).GetValue(fixture);
+ var actual = property.GetValue(viewModel);
+ var because = $"property {property.Name} must be seeded from the fixture";
+
+ if (property.PropertyType == typeof(Color))
+ {
+ actual.Should().Be(HexColor.Parse((string)recordValue!), because);
+ }
+ else if (property.PropertyType == typeof(decimal?))
+ {
+ // The record's int font sizes and double paddings box under reflection; a direct
+ // (decimal) cast on a boxed int throws, so normalize both sides via Convert.ToDecimal.
+ Convert.ToDecimal(actual).Should().Be(Convert.ToDecimal(recordValue), because);
+ }
+ else
+ {
+ actual.Should().Be(recordValue, because);
+ }
+ }
+ }
- viewModel.CellFontSize.Should().Be(source.CellFontSize);
- viewModel.RowHeight.Should().Be((decimal)source.RowHeight);
- viewModel.ValidationPanelMaxHeight.Should().Be((decimal)source.ValidationPanelMaxHeight);
- viewModel.StatusBarFontSize.Should().Be(15);
- viewModel.StatusBarTimerValueFontSize.Should().Be(30);
+ [AvaloniaFact]
+ public void Seed_ThenBuildRecord_PreservesEveryFieldDistinctly()
+ {
+ var viewModel = CreateViewModel(GridStyleOptionsTestData.Distinct());
- viewModel.SelectionBackground.Should().Be(Color.Parse(source.SelectionBackgroundColor));
- viewModel.HeaderForeground.Should().Be(Color.Parse(source.HeaderForegroundColor));
+ viewModel.BuildRecord().Should().Be(GridStyleOptionsTestData.Distinct());
}
[AvaloniaFact]
- public void Seed_RoundTripsShippedHexValues_Losslessly()
+ public void BuildRecord_PerturbingEachProperty_ChangesOnlyThatMappedField()
{
- var source = GridStyleOptions.Default;
- var viewModel = CreateViewModel(source);
+ var viewModel = CreateViewModel(GridStyleOptionsTestData.Distinct());
- var record = viewModel.BuildRecord();
+ var properties = EditableProperties();
+ properties.Should().HaveCount(
+ SurfacedEditablePropertyCount,
+ "the perturbation guard must cover every surfaced editable property");
- record.Should().Be(source);
+ foreach (var property in properties)
+ {
+ var seededValue = property.GetValue(viewModel);
+ var baseline = viewModel.BuildRecord();
+
+ property.SetValue(viewModel, Perturb(property, seededValue));
+ var built = viewModel.BuildRecord();
+
+ var mappedField = RecordField(property).Name;
+ var changedFields = ChangedRecordFields(baseline, built);
+ changedFields.Should().BeEquivalentTo(
+ new[] { mappedField },
+ $"editing {property.Name} must change exactly the {mappedField} record field");
+
+ // Restore the seeded value rather than reconstruct the VM: each ctor enumerates system fonts.
+ property.SetValue(viewModel, seededValue);
+ }
}
[AvaloniaFact]
@@ -385,6 +428,63 @@ private static GridStyleEditorViewModel CreateViewModel(GridStyleOptions source)
NullLogger.Instance);
}
+ private static IReadOnlyList EditableProperties()
+ {
+ return typeof(GridStyleEditorViewModel).GetProperties()
+ .Where(property => property.GetMethod?.IsPublic == true && property.SetMethod?.IsPublic == true)
+ .ToList();
+ }
+
+ private static PropertyInfo RecordField(PropertyInfo viewModelProperty)
+ {
+ var fieldName = viewModelProperty.PropertyType == typeof(Color)
+ ? viewModelProperty.Name + "Color"
+ : viewModelProperty.Name;
+
+ return typeof(GridStyleOptions).GetProperty(fieldName)
+ ?? throw new InvalidOperationException($"No GridStyleOptions field maps to {viewModelProperty.Name}");
+ }
+
+ // Color perturbation stays opaque so ToHex changes; weights are unvalidated ints so any value round-trips.
+ private static object? Perturb(PropertyInfo property, object? current)
+ {
+ if (property.PropertyType == typeof(Color))
+ {
+ var color = (Color)current!;
+ return Color.FromArgb(255, (byte)(color.R ^ 1), color.G, color.B);
+ }
+
+ if (property.PropertyType == typeof(decimal?))
+ {
+ return (decimal?)current + 1;
+ }
+
+ if (property.PropertyType == typeof(int))
+ {
+ return (int)current! + 100;
+ }
+
+ if (property.PropertyType == typeof(bool))
+ {
+ return !(bool)current!;
+ }
+
+ if (property.PropertyType == typeof(string))
+ {
+ return (string)current! + " Perturbed";
+ }
+
+ throw new InvalidOperationException($"No perturbation defined for {property.Name} ({property.PropertyType})");
+ }
+
+ private static IReadOnlyList ChangedRecordFields(GridStyleOptions baseline, GridStyleOptions candidate)
+ {
+ return typeof(GridStyleOptions).GetProperties()
+ .Where(property => !Equals(property.GetValue(baseline), property.GetValue(candidate)))
+ .Select(property => property.Name)
+ .ToList();
+ }
+
private static async Task ExecuteSwallowing(ReactiveCommand command)
{
try
diff --git a/docs/plans/completed/20260805-grid-style-guards.md b/docs/plans/completed/20260805-grid-style-guards.md
new file mode 100644
index 0000000..1d5f47c
--- /dev/null
+++ b/docs/plans/completed/20260805-grid-style-guards.md
@@ -0,0 +1,107 @@
+# Slice 1 — Grid-style editor: anti-regression guards before the debloat
+
+## Overview
+
+Issue #118's debloat of the grid-style pipeline (nest the flat `GridStyleOptions` record, type colors as a value type,
+split the editor view model into per-group drafts) is a multi-slice mechanical refactor that will rename ~120 member
+accesses and rewrite the editor's `Seed`/`BuildRecord` blocks. Those blocks carry a **silent-field-drop** bug with no
+compile or test guard: `GridStyleEditorViewModel.BuildRecord` rebuilds the record with `_source with { …surfaced fields… }`,
+so a field surfaced in `Seed` but **missing from `BuildRecord`** compiles fine and silently reverts that field to the
+loaded value on save. The three parallel blocks (property declarations, `Seed`, `BuildRecord`) sit 200+ lines apart in a
+523-line file, so review cannot see a mismatch either.
+
+**This slice lands the safety net first** — tests only, no production change — so every later structural slice is
+protected against a mechanical omission. It must ship before any nest/type/split slice.
+
+**The existing round-trip test is false confidence** on two counts, and a naive replacement inherits a third blind spot:
+1. `GridStyleEditorViewModelTests.Seed_RoundTripsShippedHexValues_Losslessly` seeds `GridStyleOptions.Default`, whose 32
+ colors are all `#000000` — duplicate values mask cross-wiring (a `Seed` line reading field A into property B passes as
+ long as A and B share a value).
+2. **A `BuildRecord(Seed(x)) == x` round-trip can NEVER fail on a dropped `BuildRecord` line** — `with` keeps the
+ `_source` value, and `_source` *is* the seed source `x`, so the dropped field still equals `x`.
+3. **It also can't fail on a dropped `Seed` line for the 14 nullable-fallback fields** (the 13 `decimal?` numerics +
+ `FontFamily`): a dropped `Seed` line leaves the property `null`, and `BuildRecord`'s `ToInt(null, _source.X)` /
+ `FontFamily ?? _source.FontFamily` restores the source value, so the round-trip stays green. (Color/`int`/`bool`
+ fields have no null fallback — a dropped `Seed` line there yields a type default that the distinct round-trip does
+ catch.)
+
+So the guards are split by **direction and field kind**, each catching what the others structurally cannot:
+
+| Guard | Catches | Structural blind spot (covered by another guard) |
+|---|---|---|
+| **Seed-populates** — after `Seed(distinct)`, assert **each** editable property equals the fixture's mapped value | every `Seed` omission (nullable fields → still null; italics → still `false` ≠ fixture `true`) and every non-bool `Seed` cross-wire | the `BuildRecord` direction; a bool↔bool `Seed` cross-wire (inherent to 5 two-valued fields) |
+| **Perturbation (exact-one-field)** — seed, then change one property *from its current value*, assert `BuildRecord()` equals the baseline in **every field except that property's mapped field** | every `BuildRecord` drop, mis-target (writes to the wrong field), and same-valued-bool swap | the `Seed` direction |
+| **Save→load mapping** — `GridStyleWriter.Save` → `GridStyleLoader.LoadAsync` → `GridStyleMapper.Map` == distinct | `GridStyleDtoMapper` / `GridStyleMapper` omissions (the flatten/unflatten layer the nest slice rewrites) | the editor layer |
+
+A distinct-value `BuildRecord(Seed(distinct)) == distinct` round-trip is kept as an integration sanity check, but it is
+**not** load-bearing — the two directional guards above are what actually bite. Together they make a silently-dropped or
+mis-wired field impossible across the whole config↔editor pipeline without a red test.
+
+**Counts (verified):** 77 surfaced editable properties — 13 `decimal?` numerics, 1 `FontFamily`, 5 `int` weights, 5
+`bool` italics, 53 `Color`s; the record has 78 positional fields (77 surfaced + the unsurfaced `Orientation`). The
+property setters are public (via the private `SetColor`/`SetNumber`/`SetValue` helpers); `ErrorMessage`/`CanSave`/
+`AvailableFontFamilies`/`AvailableFontWeights` are private-set; `SaveCommand` is get-only.
+
+**Scope:** tests + one shared test-data builder only. No production file changes. Async `Save`, the `HexColor.Parse`
+drop, the record nesting, the `StyleColor` typing, and the VM draft split are later slices.
+
+## Acceptance Evidence
+
+- `GridStyleOptionsTestData.Distinct()` returns a `GridStyleOptions` with every surfaced field distinct, valid, and
+ exactly-representable: distinct **UPPERCASE** opaque `#RRGGBB` colors (equality is string equality; `ToHex` emits
+ `X2`), distinct integer/clean-half numerics in-range, distinct weights, all five italics `true`, a non-empty `FontFamily`,
+ and `Orientation` = the non-default value.
+- The Seed-populates test fails red if any single `Seed` line is removed (incl. a nullable field).
+- The perturbation test fails red if any single `BuildRecord` line is removed — verified by a scratch check, reverted.
+- The save→load mapping round-trip fails red if a `GridStyleDtoMapper` line is dropped (a dropped field serializes absent → defaults substitute → differs) or a `GridStyleMapper` line mis-wires. (A dropped `GridStyleMapper` line is a compile error — its 78-arg positional `new GridStyleOptions(...)` won't build — an even stronger guard.)
+- `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format` clean.
+
+## Task 1: Distinct-value fixture + Seed-populates + value round-trips
+
+**Files:**
+- Create: `SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs` (`public static GridStyleOptions Distinct()`).
+- Modify: `SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs` (replace `Seed_RoundTripsShippedHexValues_Losslessly`; add the Seed-populates test).
+- Modify: `SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs` (add the distinct-value save→load mapping round-trip alongside the existing shipped-config `SaveThenLoad_RoundTrips`).
+
+- [x] `GridStyleOptionsTestData.Distinct()`: a hand-written `new GridStyleOptions(...)` with **every** field distinct and valid. Colors: 53 mutually-distinct **uppercase** opaque `#RRGGBB` literals (mutual distinctness is required so the round-trip/Seed-populates expose swaps; e.g. a sequential palette `#010101`, `#020202`, … kept uppercase). Numerics: distinct, in-range per the VM `Min*/Max*` consts, and exactly representable in both `double` and `decimal` (integers or clean halves — the `int`↔`decimal` via `ToInt`'s `Round` and the `double`↔`decimal` via `ToDouble`'s cast must be exact). Weights: distinct ints (e.g. 300/400/500/600/700). Italics: **all 5 `true`** — `bool` is the one kind whose type default (`false`) can equal a fixture value, so a fixture `false` would let a dropped `Seed` line for that italic stay green across every guard; setting all italics `true` makes each differ from the `false` default, so a dropped `Seed` line → `false ≠ true` → red. (Residual limitation to state honestly: a bool↔bool `Seed` cross-wire — a line reading the wrong italic — stays undetectable, inherent to a 2-valued type with 5 instances, and a lower-probability mechanical error than a dropped line.) `FontFamily`: a fixed non-empty string. `Orientation`: `GridOrientation.ColumnsAsSteps` (non-default — proves `BuildRecord` preserves the unsurfaced field via `with`).
+- [x] **Seed-populates test** — the `Seed`-direction guard (the one that catches dropped `Seed` lines for the nullable + bool fields). Seed the VM from `Distinct()`, then assert **each** surfaced property equals the fixture's corresponding value: colors as `HexColor.Parse(fixture.Color)`, weights/italics/`FontFamily` directly, numerics via `Convert.ToDecimal(...)` on both sides (the record's `int` font sizes and `double` paddings box under reflection — a direct `(decimal)` cast on a boxed `int` throws `InvalidCastException`; `Convert.ToDecimal` handles both). Drive it by the same public-get+public-set reflection enumeration Task 2 uses, with the VM-property→record-field name map (colors: property name + `"Color"`; everything else: identical name), so a dropped or cross-wired `Seed` line makes exactly that property mismatch → red. (A dropped nullable `Seed` line leaves the property `null` ≠ the fixture value → red; a dropped italic `Seed` line leaves it `false` ≠ the fixture `true` → red.)
+- [x] **Distinct-value BuildRecord round-trip** (integration sanity): construct the VM seeded from `Distinct()`, assert `viewModel.BuildRecord().Should().Be(GridStyleOptionsTestData.Distinct())`. Replace the `Default`-based `Seed_RoundTripsShippedHexValues_Losslessly` (all-`#000000` colors mask cross-wires). Name it for what it proves (e.g. `Seed_ThenBuildRecord_PreservesEveryFieldDistinctly`).
+- [x] **Save→load mapping round-trip** (Core config tests): drive it through the **facade** — `facade.Save(tempDir, Distinct())` then `facade.Load(tempDir)`, assert the loaded value `== Distinct()` (mirror the existing `SaveThenLoad_RoundTrips` harness; `GridStyleWriter.Save` is an instance method, and the facade path additionally runs `GridStyleValidator` in the real load pipeline — strictly more coverage, less internal reach). This exercises `GridStyleDtoMapper` (options→dto) + YAML serialize/deserialize + `GridStyleMapper` (dto→options) over distinct values — the layer the nest slice rewrites. Complements (does not duplicate) `SaveThenLoad_RoundTrips`, which round-trips the *shipped* config whose duplicated/default-equal values mask cross-wires and default-substitution. Keep both. (`TempDirectory` lives in `SemiStep.Tests.Config.Helpers` — add the `using`; decide the new fixture's namespace deliberately.)
+- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green.
+
+## Task 2: Perturbation completeness test (the BuildRecord guard)
+
+The `BuildRecord`-direction guard. Seed, then change each editable property **from its current (seeded) value** and
+assert the built record changed in **exactly** that property's mapped field.
+
+**Files:** `SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs` (may share the reflection helper + name map with Task 1's Seed-populates test).
+
+- [x] Enumerate the surfaced editable properties by reflection: `typeof(GridStyleEditorViewModel).GetProperties()` where `GetMethod?.IsPublic == true && SetMethod?.IsPublic == true`. This selects exactly the 77 style fields and excludes `ErrorMessage`/`CanSave`/`AvailableFontFamilies`/`AvailableFontWeights` (private set), `SaveCommand` (get-only), and inherited `ReactiveObject` members (get-only). **Assert the count == 77** (drift guard); if a non-style public-set property is ever added, exclude it by name with a comment. (Reused the `EditableProperties()`/`RecordField()` helpers from Task 1.)
+- [x] For each property: seed the VM from `Distinct()`, capture `baseline = BuildRecord()`, set the property to a value **derived from its current value** and distinct from it (`Color` → flip one channel `Color.FromArgb(255, (byte)(c.R ^ 1), c.G, c.B)`, opaque so `ToHex` changes; `decimal?` → `current + 1`; `int` weight → a different valid weight; `bool` → `!current`; `string` `FontFamily` → current + a suffix), then `built = BuildRecord()`. **Assert exactly the mapped record field changed:** the set of `GridStyleOptions` properties where `built` differs from `baseline` (compared by reflection over the record's properties) equals `{ mappedField }`, where `mappedField` is the property name + `"Color"` for colors, else the property name. This catches a dropped line (zero fields differ → red), a mis-target (a different field differs → red), and — because it pins the exact field — a same-valued-bool swap that a plain `NotBe(baseline)` would miss. Report the property name in the assertion message. **Restore the property to its seeded value between iterations** (cheaper than reconstructing the VM 77× — each ctor enumerates system fonts).
+- [x] **Non-vacuity check (do NOT commit the break):** temporarily delete one `BuildRecord` assignment line, confirm the perturbation test goes red for exactly that property; delete one nullable (`decimal?`/`FontFamily`) `Seed` line AND one italic (`bool`) `Seed` line, confirm the Seed-populates test goes red for each. Revert all. State the results in the progress log. (All three verified red — dropped `InfoColor` BuildRecord line → perturbation red naming Info; dropped `ValidationPanelMaxHeight` Seed line → Seed-populates red; dropped `StatusBarTimerValueItalic` Seed line → Seed-populates red. All reverted; tree clean of production edits.)
+- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`.
+
+## Post-Completion
+
+**Next slices of #118 (each its own PR):** (2) async `Save` — `IGridStyleEditorFacade.Save` → `Task`,
+`ReactiveCommand.CreateFromTask`; (3) nest `GridStyleOptions` to mirror the DTO groups (+ a shared `DepthPalette` for the
+identical ReadOnly/Disabled blocks), rewiring the two mappers and the ~10 runtime consumers under these guards; (4) type
+colors as a `StyleColor` value type, fold the color-validation into the load mapper, delete `HexColor`; (5) split the VM
+into per-group drafts (property-initializer = seed, positional `Build()` = compile guard), grouped compiled-binding AXAML
+paths, reduce `CanSave` to numeric ranges. This slice is the anti-regression net all of those lean on.
+
+**Executed by exec:**
+- branch: grid-style-guards
+
+## Verify it yourself
+
+This is a tests-only safety net — there is no behavior to click through. Verify the guards exist and genuinely bite:
+
+1. **The guards run green against the current (correct) code:**
+ `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~GridStyleEditorViewModelTests|FullyQualifiedName~GridStyleEditorFacadeTests"` — `Seed_PopulatesEverySurfacedProperty_FromDistinctFixture`, `BuildRecord_PerturbingEachProperty_ChangesOnlyThatMappedField` (count asserts 77), `Seed_ThenBuildRecord_PreservesEveryFieldDistinctly`, and `SaveThenLoad_DistinctFixture_PreservesEveryMappedField` all pass.
+2. **The guards bite (each catches its target silent-drop)** — proven during exec by scratch checks (delete a line, confirm red, revert), recorded in the progress log:
+ - delete a `BuildRecord` assignment (e.g. `InfoColor`) → the perturbation test goes red, naming the field.
+ - delete a nullable `Seed` line (e.g. `ValidationPanelMaxHeight`) → the Seed-populates test goes red (found 0 vs 150.5).
+ - delete an italic `Seed` line → the Seed-populates test goes red (false vs true).
+ - delete a `GridStyleDtoMapper` line (e.g. `CellWeight`) → the facade save→load test goes red (loaded 400 vs fixture 800). Revert each.
+3. **Whole suite:** `dotnet build SemiStep.slnx` (0 warnings) and `dotnet test` (1687 passed, 0 failed). No production file changed — `git diff master...HEAD --name-only` lists only `SemiStep.Tests/**` and the plan.