diff --git a/Docs/architecture/grid-style-configuration.md b/Docs/architecture/grid-style-configuration.md index 9e2bde1c..fb1acd67 100644 --- a/Docs/architecture/grid-style-configuration.md +++ b/Docs/architecture/grid-style-configuration.md @@ -63,34 +63,32 @@ column factories, `ColumnWidthCalculator`) injects the same typed record. `GridStyleOptions` is an immutable record with a `Default` fallback used only by tests; the `#000000` cell-palette placeholders in `Default` are never rendered in production. -**Interim (slice 4).** Colors are now the typed `StyleColor` value (channels `A`/`R`/`G`/`B`) end to -end in the record; hex is a string only at the two I/O edges. Validation lives inside the load mapper — -`GridStyleMapper.Map` returns a `Result` and accumulates every per-key error in one -pass. The standalone `GridStyleValidator` and the editor's `HexColor` helper are gone. The full doc -rewrite (and removal of the now-obsolete "Known gap" section) lands in slice 5. - -## Record shape: nested per group (interim, slice 3) - -`GridStyleOptions` is no longer a flat 78-field record. It is a one-level nested root that composes ~10 -per-group records (`Fonts`, `Layout`, `Selection`, `ChangedCells`, `ReadOnlyCells`, `DisabledCells`, -`Execution`, `StatusBar`, `ValidationPanel`, `Chrome`) plus the root-level `Orientation`. `ReadOnlyCells` -and `DisabledCells` share one `DepthPalette` type. Consumers read short nested paths — `gridStyle.Fonts.CellFontSize`, -`gridStyle.ReadOnlyCells.Depth1`, `gridStyle.Chrome.GridLine` — instead of the old flat fields. Colors were -`string` at slice 3; slice 4 typed them as `StyleColor` (see the interim note above). - -**The YAML file, the DTOs, and `GridStyleValidator` are unchanged** by this slice: the file on disk is -byte-identical, the DTO layer keeps its nested snake_case shape and per-key error reporting, and the load -mapper owns the small DTO-tree-to-group walk. `SaveThenLoad_DistinctFixture` proves the nested record still -round-trips losslessly through the unchanged file format. Property references elsewhere in this doc that still -show a flat path (for example `GridStyleOptions.FontFamily`) now live under their group -(`GridStyleOptions.Fonts.FontFamily`); those spellings are corrected in the slice-5 doc rewrite. - -**Note for slice 4.** The error-path key `colors.grid_line` and the record path `Chrome.GridLine` -deliberately diverge: `grid_line` is a loose field in the `colors:` DTO section (beside `grid_border` / -`grid_background`), but the one-level record folds it into `Chrome` so the root carries no lone `string`. -Slice 4 moved those per-key checks into the load mapper (`GridStyleMapper.Map`); that mapper-resident -validation keeps emitting the `colors.grid_line` key, not `chrome.grid_line`. The key names the -YAML path the operator edits, not the record path. +Colors are the typed `StyleColor` value (channels `A`/`R`/`G`/`B`) end to end in the record; hex is a +string only at the two I/O edges. Validation lives inside the load mapper: `GridStyleMapper.Map` returns +a `Result` and accumulates every per-key error in one pass, so a single load reports +every missing or invalid key at once instead of failing on the first. There is no standalone color +validator — the parse and its format check sit at the one place the hex string enters the record. + +## Record shape: nested per group + +`GridStyleOptions` is a one-level nested root record. It composes ten per-group records (`Fonts`, +`Layout`, `Selection`, `ChangedCells`, `ReadOnlyCells`, `DisabledCells`, `Execution`, `StatusBar`, +`ValidationPanel`, `Chrome`) plus the root-level `Orientation`. `ReadOnlyCells` and `DisabledCells` share +one `DepthPalette` type. Consumers read short nested paths — `gridStyle.Fonts.CellFontSize`, +`gridStyle.ReadOnlyCells.Depth1`, `gridStyle.Chrome.GridLine`. Every group record is positional, so +construction is compile-checked: an omitted component is a CS7036 build error. + +The nesting mirrors how the record is consumed — each reader takes one group (fonts, a palette, the +chrome block), never the whole record as a flat field list (see the resources projection and font-model +sections). The YAML file and the DTO layer keep their own nested snake_case shape with per-key error +reporting, and the load mapper owns the DTO-tree-to-group walk. `SaveThenLoad_DistinctFixture` proves the +nested record round-trips losslessly through the file format. + +The error-path key `colors.grid_line` and the record path `Chrome.GridLine` deliberately diverge: +`grid_line` is a loose field in the `colors:` DTO section (beside `grid_border` / `grid_background`), but +the one-level record folds it into `Chrome` so the root carries no lone color field. The mapper-resident +validation emits the `colors.grid_line` key, not `chrome.grid_line`: the key names the YAML path the +operator edits, not the record path. ## Resources projection @@ -146,9 +144,9 @@ orientation: rows_as_steps # canonical (rows = steps); or columns_as_steps (tr - **Writer round-trip.** `GridStyleDtoMapper` serializes from the enum, so it always emits a valid value: a style-editor save preserves the field, and a save over a file that never had it writes the explicit `orientation: rows_as_steps`. The editor does not surface orientation - as a control; `BuildRecord`'s `with`-rebuild over the seeded record carries it through. The - DTO property is declared last so serialized output keeps `fonts:` as the first key (pinned - by test). + as a control; the editor's `BuildRecord` constructs the record positionally and carries the field + straight from `_source.Orientation` (the seeded copy). The DTO property is declared last so + serialized output keeps `fonts:` as the first key (pinned by test). - **Shipped configs.** RIE ships `orientation: columns_as_steps` (transposed by default); MOCVD and MBE omit the key and start canonical. @@ -169,7 +167,7 @@ The earlier single `StatusBarTimerFontSize` (one size for both label and value) two timer roles, so the caption and the countdown can carry independent fonts. With the shipped config the label renders at 14 and the value at 24 — they no longer share one size. -- **Family** is a single `string` on the record (`GridStyleOptions.FontFamily`). The default is `""`, +- **Family** is a single `string` on the record (`GridStyleOptions.Fonts.FontFamily`). The default is `""`, meaning "grid default": consumers fall back to `GridFonts.DefaultFamily` (`"Segoe UI Variable Text, Segoe UI"`, the same proportional Segoe as the app chrome) and render with `GridFonts.TabularFigures` (the `tnum` feature) so digit columns stay aligned. A non-empty value sets @@ -235,35 +233,39 @@ The in-app editor is the write path back to the same file: ``` GridStyleEditorWindow - → GridStyleEditorViewModel (mutable draft: Color per color, decimal? per size) + → GridStyleEditorViewModel (per-group ReactiveObject drafts: Color per color, decimal? per size) → GridStyleEditorFacade (the single public Core seam) → GridStyleWriter (record → DTO, serialize, atomic write) ``` -- **ViewModel.** Seeds a mutable draft from the loaded record (a separate copy, never the DI - singleton). Colors are exposed as `Color` for the `ColorPicker`; sizes as `decimal?` for - `NumericUpDown`. Channel↔`Color` conversion goes through `StyleColorConversions` - (`ToMediaColor` / `ToStyleColor`, in `SemiStep.UI/Styles`), not `Color.ToString()`. `CanSave` is gated - by VM-side numeric range checks alone (font/padding/row-height/spacing/panel-height bounds); an invalid - color is unrepresentable in the typed record, so the facade no longer color-validates on save. The editor surfaces - effectively the whole record: all ~54 colors, 13 numerics, plus the font controls — one global - font-family `ComboBox`, and a per-role weight `ComboBox` (curated 300–900 list) and italic - `CheckBox`. The window groups these into two cards: **Fonts** (the family row plus a size / weight / - italic row per role) and **Layout** (paddings, row height, status padding/spacing, panel height). - The family/weight pickers always include the seeded value even when it is outside the offered list, - so a hand-edited family or weight round-trips losslessly. `BuildRecord` rebuilds the record - with `with` over the seeded source, so the mechanism still preserves any field that happened not to - be surfaced. +- **ViewModel.** A thin parent exposes each of the ten record groups as a per-group `ReactiveObject` + draft (`Fonts`, `Layout`, `Selection`, …, `Chrome`), seeded from a mutable copy of the loaded record + (a separate copy, never the DI singleton). A draft's property initializers are the seed — each leaf + reads its same-named source component — and its `Build()` calls the group record's positional + constructor, so a dropped field is a compile error (CS7036), not a silent revert. Colors are exposed + as `Color` for the `ColorPicker`; sizes as `decimal?` for `NumericUpDown`. Channel↔`Color` conversion + goes through `StyleColorConversions` (`ToMediaColor` / `ToStyleColor`, in `SemiStep.UI/Styles`), not + `Color.ToString()`. The AXAML binds grouped `Group.Leaf` paths against the drafts, each resolved and + compile-checked by the window-level `x:DataType` (a stale or mistyped path fails the build with + AVLN2000). `CanSave` is gated by VM-side numeric range checks alone (font/padding/row-height/spacing/ + panel-height bounds); an invalid color is unrepresentable in the typed record, so there is nothing + else to validate. The editor surfaces effectively the whole record: all ~54 colors, 13 numerics, plus + the font controls — one global font-family `ComboBox`, and a per-role weight `ComboBox` (curated + 300–900 list) and italic `CheckBox`. The window groups these into two cards: **Fonts** (the family row + plus a size / weight / italic row per role) and **Layout** (paddings, row height, status padding/ + spacing, panel height). The family/weight pickers always include the seeded value even when it is + outside the offered list, so a hand-edited family or weight round-trips losslessly. `BuildRecord` + constructs a new record positionally from the ten drafts' `Build()` calls, carrying the one unsurfaced + field `Orientation` straight from the seeded source. ![In-app grid style editor](../img/visual_style_window.png) - **Facade.** `GridStyleEditorFacade` is the only public Core seam for the editor: - `Load(configDir)` → `Result`, `Validate(GridStyleOptions)` → `Result`, - `Save(configDir, GridStyleOptions)` → `Task`. `Save` is async and runs the file write off the - UI thread (mirroring `Load`); the editor's `SaveCommand` awaits it. `Validate` is a vacuous - `Result.Ok()` pass-through — color validation now lives in the load mapper, so it validates nothing; - `RecomputeCanSave` still calls it per keystroke but `CanSave` turns on the numeric-range checks alone. - The method stays on the interface until slice 5 trims it. The loader, writer, and the ~12 DTOs stay + `Load(configDir)` → `Task>` and `Save(configDir, GridStyleOptions)` → + `Task`. Both are async and run their file I/O off the UI thread; the editor's `SaveCommand` + awaits `Save`. The seam carries no validate step: color validation lives in the load mapper and + numeric ranges are checked VM-side, so there is nothing left for the editor to ask Core to validate + before a save. The loader, writer, and the ~12 DTOs stay `internal`. The UI never touches Core internals directly. (Layering: the config stays in `SemiStep.Core`, settled by review; the editor reaches it only through this facade.) @@ -296,14 +298,3 @@ restart is the simplest correct behavior for v1. - **Removed fields.** Grid-line thickness, alternating-row background, and normal-row background were dropped from the model, DTOs, mapper, and shipped configs. Semi's `DataGrid` exposes no inner-gridline-thickness or alternating-row-background property, so they had no wire target. - -## Known gap - -`GridStyleValidator` currently format-checks only the execution / readonly / disabled cell palettes and -the optional chrome section. These colors are **not** Core-validated: selection (background / -foreground), changed-cell (`changed` / `changed_selected`), the grid line, the status-bar -(background / foreground), and the validation-panel (background / foreground / error / warning). In -practice the editor's `ColorPicker` constrains those to valid `Color` structs, so a malformed value -cannot reach `Save` through the editor — but a hand-edited file could carry an invalid hex in those -keys without the validator catching it. Noted honestly; closing the gap would mean extending -`GridStyleValidator` to cover the remaining sections. diff --git a/Docs/plans/20260805-grid-style-debloat-roadmap.md b/Docs/plans/20260805-grid-style-debloat-roadmap.md new file mode 100644 index 00000000..19e9452c --- /dev/null +++ b/Docs/plans/20260805-grid-style-debloat-roadmap.md @@ -0,0 +1,329 @@ +# Grid-Style Debloat Roadmap (#118) + +## Summary + +Issue #118: the grid-style configuration/editor stack maintains ~78 fields in parallel across a flat +record, two hand-written mappers, a color-enumerating validator, a three-way-mirrored editor view +model, and a 555-line editor window. Every touch point is untyped repetition: adding or renaming one +style field means editing up to 13 places across 9 files, and forgetting one of them fails silently +(an omitted field reverts to its previous value on save, or a color never reaches the screen). + +This roadmap restructures the stack in five independent slices so that a missed field becomes a +**compile error or a red test**, not a silent no-op. Slice 1 (the anti-regression guard net, PR #175) +is merged; slice 2 (async save) has its own plan; slices 3–5 (nest the record, type the colors, +split the view model) are designed here and get focused plans when picked up. #118 closes after +slice 5. + +Scope note: this is a roadmap, not an implementation plan. Each slice below gets its own plan/branch +when picked up (slice 2's already exists: `Docs/plans/20260805-grid-style-async-save.md`; slice 1's +completed plan is `Docs/plans/completed/20260805-grid-style-guards.md`). + +All counts and line numbers below were verified against the code on 2026-08-05 (post-#175). Prefer +the shapes over the line numbers if they have drifted. + +## Root-cause diagnosis + +### The flat pivot record + +`SemiStep/SemiStep.Core/Configuration/GridStyleOptions.cs` is a positional record of **arity 78**: +53 colors as raw `#RRGGBB` strings, 13 numerics, 5 font weights, 5 italics, the font family, and the +orientation enum — one flat parameter list, 163 lines with the `Default` instance. The YAML side is +already **nested** (`GridStyleOptionsDto` composes 11 per-section DTOs: fonts, layout, +colors.selection, colors.cells.{readonly,disabled,execution}, status_bar, validation_panel, chrome). +The flat record is the pivot everything else must flatten into and unflatten out of, and it forces: + +- **Two hand-written mappers.** `Mapping/GridStyleMapper.cs` (100 lines, DTO → record, one line per + field) and `Mapping/GridStyleDtoMapper.cs` (124 lines, record → DTO, one line per field). Both + exist only to bridge nested ↔ flat. +- **A color-enumerating validator.** `Validation/GridStyleValidator.cs` (243 lines) re-lists every + color key as a `(name, value)` tuple per section to run one regex over each — the section/key + structure restated a third time. +- **A ×3 view-model mirror.** `SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs` (523 lines) + declares all 77 surfaced fields three times: the property declarations (`Color` per color, + `decimal?` per numeric), the `Seed` method (77 assignment lines), and `BuildRecord` (77 + `with`-initializer lines). Orientation is the one unsurfaced field (78 − 1 = 77; pinned by the + `SurfacedEditablePropertyCount` constant in `GridStyleEditorViewModelTests`). + +### The per-field tax (verified counts) + +Places one field is declared, mapped, bound, validated, or consumed today: + +| Touch point | Color field (`ReadOnlyCellDepth1Color`) | Numeric field (`RowHeight`) | +|---|---|---| +| Record positional parameter | `GridStyleOptions.cs` | `GridStyleOptions.cs` | +| Record `Default` argument | yes | yes | +| DTO property | `GridStyleReadOnlyCellColorsDto` | `GridStyleLayoutDto` | +| Load-mapper line | `GridStyleMapper.cs` | `GridStyleMapper.cs` | +| Save-mapper line | `GridStyleDtoMapper.cs` | `GridStyleDtoMapper.cs` | +| Validator tuple entry | `GridStyleValidator.cs` | — | +| VM property declaration | yes | yes | +| VM `Seed` line | yes | yes | +| VM `BuildRecord` line | yes | yes | +| VM range check | — | `NumericsInRange` | +| AXAML binding | `GridStyleEditorWindow.axaml` | yes | +| Resource-key constant | `CellPaletteInstaller` | `RowHeightKey` | +| Installer projection line | `CellPaletteInstaller.Install` | yes | +| Direct runtime reads | — (consumed via resource) | `ColumnBuilder`, `TransposedRecipeGridView` | +| Test fixture value | `GridStyleOptionsTestData.Distinct()` | yes | + +**13 touch points across 9 files for a color; 13–15 for a numeric.** The target design does not +drive this to zero — DTOs, consumers, and the fixture legitimately remain — but it converts every +remaining bookkeeping point into something the compiler or a guard test checks, and merges the three +restatements (mappers' flatten layer, validator tuples, VM mirror) into structure. + +### The silent-revert mechanism + +`BuildRecord` rebuilds via `_source with { … }`. The `with` expression preserves any field not +listed — which is the intended mechanism for the unsurfaced `Orientation`, but it also means **a +field accidentally dropped from `BuildRecord` (or `Seed`) silently reverts to its loaded value on +save**. Before slice 1 nothing detected this; the compiler cannot, because `with` has no +completeness check. The same failure mode exists in both mappers (a dropped line falls back to +`Default` or serializes nothing) and in `Seed` (a dropped line leaves the property at its type +default). This is the second structural cause, independent of the line count: the shape makes +omission *legal*. + +## Consumer survey (why the redesign is safe) + +Verified against every non-test reader of `GridStyleOptions` (13 UI files plus the config layer). +The consumers read the record **per group**, never as a flat whole: + +| Group | Consumers | How consumed | +|---|---|---| +| Fonts (family + per-role size/weight/italic) | `RecipeGrid/GridFontApplier.cs`, `RecipeGrid/ColumnBuilder.cs`, `RecipeGrid/ColumnWidthCalculator.cs` | code-assigned per role; the width calculator measures with the same typeface | +| Layout (cell paddings, row height) | `RecipeGrid/TextCellFactory.cs`, `RecipeGrid/Transposed/TransposedCellTemplateFactory.cs`, `ColumnBuilder`, `Transposed/TransposedRecipeGridView.axaml.cs` | `Thickness`/height construction | +| Cell-state + selection + status-bar + validation-panel + chrome palettes | `Styles/CellPaletteInstaller.cs` | wholesale projection into `Application.Resources` brush/value keys at startup (`App.axaml.cs`) | +| Execution palette | `Styles/ExecutionPaletteInstaller.cs` | same projection, execution depth brushes + marker | +| Orientation | `RecipeGrid/ActiveRecipeGridSurface.cs` | read once at construction | +| Whole record (carrier only) | `App.axaml.cs`, `ComboBoxCellFactory`, `TransposedRecipeGridSurface` (`GridStyle` property), `ConfigFacade`/`AppConfiguration` (DI wiring) | passed through, no field logic | + +Hex strings become Avalonia types in exactly **one runtime place**: +`Styles/PaletteBrushFactory.From(string)` → `Color.Parse`. (The editor has its own edge, +`StyleEditor/HexColor.cs`, for `Color` ↔ hex in the pickers.) No consumer wants the flat shape; the +grouping the DTO already has matches consumption exactly. The flat record is accidental structure, +and nesting it ripples only property paths, not logic. + +## Target design + +Four coordinated changes, delivered by slices 3–5: + +**1. Nest the record to mirror the consumption groups.** `GridStyleOptions` becomes a ~15-line root +record composing per-group records — fonts, layout, selection, cell-state, status-bar, +validation-panel, chrome, execution — plus `Orientation`. The identical ReadOnly and Disabled +10-field blocks (`Depth0..3`, `Depth0Past..3Past`, `Selected`, `Foreground`) collapse into one +reusable `DepthPalette` record used twice; Execution (8 depths + `CurrentStepMarker`, no +selected/foreground) gets its own small record. Group records stay positional so construction is +compile-checked (CS7036 on omission). + +**2. Type the colors.** A `StyleColor` value type in Core (readonly record struct, A/R/G/B channels, +`Parse`/`TryParse`/`ToString`) replaces the 53 `string` fields. `ToString` absorbs `HexColor.ToHex`'s +round-trip rule: opaque colors emit `#RRGGBB` (uppercase), translucent `#AARRGGBB` — never the +`#FF`-prefixed form `Avalonia.Media.Color.ToString()` would inject. Hex parsing then exists only at +the I/O edge (the load mapper) and hex formatting only at the save edge; everywhere else a color is +channels. Core stays Avalonia-free (a hard constraint — the record is consumed in Core and tests +without Avalonia), so `StyleColor` cannot be `Avalonia.Media.Color`; the UI converts channel-wise. + +**3. Fold the validator into the load mapper.** With typed colors, "is this hex valid" is asked +exactly where the string is parsed. The load mapper accumulates the same typed errors the validator +emits today (`GridStyleSectionMissingError`, `GridStyleKeyMissingError`, +`GridStyleHexColorInvalidError` with section path + key name, `GridStyleOrientationInvalidError`) — +**aggregate, not fail-fast**, preserving per-key error identity for `ReasonLocalizer` and the +existing error tests. The 243-line validator and its third restatement of the key structure are +deleted. + +**4. Split the view model into per-group drafts.** The monolithic VM becomes a thin parent (~150 +lines: facade wiring, `SaveCommand`, `CanSave`, font-picker sources) plus one small `ReactiveObject` +draft per group. A draft's property initializers *are* the seed (constructed from its group record — +a property without an initializer is visibly unseeded in one screenful), and its `Build()` calls the +group record's positional constructor — so an omitted field is a **compile error**, replacing the +`with`-rebuild and its silent revert. The AXAML binding paths become grouped +(`{Binding Fonts.HeaderSize}`); paths are already build-checked today via the window-level +`x:DataType` (verified empirically: a bogus path fails the build with AVLN2000), so every rename is +caught at compile time. + +### End-state shape + +| Artifact | Today | Target | +|---|---|---| +| `GridStyleOptions.cs` | 163 lines, arity-78 flat | ~15-line root + ~8 small group records (incl. `DepthPalette`, reused ×2) | +| `GridStyleMapper.cs` + `GridStyleDtoMapper.cs` | 100 + 124 lines, one line per field | per-group mapping, positional construction; load side absorbs validation | +| `GridStyleValidator.cs` | 243 lines | deleted (checks live in the load mapper) | +| `GridStyleEditorViewModel.cs` | 523 lines, ×3 mirror | ~150-line parent + per-group draft classes | +| `HexColor.cs` | 27 lines | deleted (absorbed by `StyleColor`) | +| `GridStyleEditorWindow.axaml` | 555 lines, flat paths | same size, grouped compiled-checked paths | +| Missed field on rename/add | silent revert / silent default | compile error in 3 places (group ctor, draft `Build()`, AXAML path) + red guard test | + +Net line count in the config/editor layer drops from ~1180 (record + mappers + validator + VM + +`HexColor`) to an estimated ~750–800. **The LOC reduction is not the point** — the structural win +is that field omission stops being representable, and the per-field tax that remains is +compile-checked or guard-tested. + +## The guard net (slice 1 — DONE, PR #175) + +Slices 3–5 are mechanical rewrites of exactly the layers where a dropped field is silent. The guard +net exists so that *any* such drop turns a later slice's PR red instead of shipping. Shipped in +PR #175 (merged 2026-08-05); design rationale in `Docs/plans/completed/20260805-grid-style-guards.md`. + +The fixture: `SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.Distinct()` — every field +holds a distinct, valid, exactly-representable value (53 mutually distinct uppercase opaque hex +colors; integers/clean halves so `int`↔`decimal`↔`double` conversions are exact; all five italics +`true` so a dropped bool line surfaces as `false ≠ true`; non-default `Orientation` proving the +unsurfaced field survives `with`). + +The guards, split by direction and field kind: + +| Guard (test) | Catches | +|---|---| +| `Seed_PopulatesEverySurfacedProperty_FromDistinctFixture` — asserts each of the 77 editable VM properties equals the fixture's mapped value | every `Seed` omission and every non-bool `Seed` cross-wire | +| `BuildRecord_PerturbingEachProperty_ChangesOnlyThatMappedField` — perturb one property, assert exactly one record field changed | every `BuildRecord` drop, mis-target, and same-valued-bool swap | +| `SaveThenLoad_DistinctFixture_PreservesEveryMappedField` — facade `Save` → `Load` == fixture | any omission in either mapper — the flatten/unflatten layer slice 3 rewrites | +| `Seed_ThenBuildRecord_PreservesEveryFieldDistinctly` | integration sanity over the whole editor round-trip | + +Every later slice leans on these: a rename or rewrite cannot silently drop or cross-wire a field +without one of them failing. **Documented residual:** a bool↔bool `Seed` cross-wire among the five +italics is structurally invisible to the Seed guard (all five are `true` in the fixture; two-valued +fields cannot be mutually distinct) — accepted, since the perturbation guard covers the +`BuildRecord` direction and slice 5's typed drafts eliminate the hand-written `Seed` entirely. + +## Migration slices + +Each slice is one independent PR, ordered smallest-safe-first. Ordering dependencies: 3 before 4 +(typing colors on the flat 78-arity record would mean re-touching all 53 in place); 4 before 5 is +preferred (drafts seed from typed group records without hex parsing); 2 is independent of 3–5. + +### Slice 1 — anti-regression guards. Status: DONE (PR #175, merged) + +See "The guard net" above. Also fixed en route: the exact-one-field guards flushed out the +distinct-value blind spots of the old `Default`-based round-trip test (all-`#000000` colors masked +cross-wires). + +### Slice 2 — async `Save` off the UI thread. Status: DONE (branch `grid-style-async-save`, PR #176) + +`GridStyleEditorFacade.Load` and `Save` now return `Task` and do file I/O off the UI thread. +`IGridStyleEditorFacade.Save` → `Task`, async `GridStyleWriter` +(`WriteAllTextAsync`; the atomic `File.Move` stays sync), VM command via +`ReactiveCommand.CreateFromTask`. `Validate` stays sync — `RecomputeCanSave` calls it per keystroke +and it is a pure in-memory pass. The window is unchanged. Behavior-preserving; slice 1's guards keep +holding. Touches: the facade interface + implementation, the writer, the VM save path, and every +test caller of `Save`. + +Blast radius: small. Risk: low (threading only, no mapping changes). + +### Slice 3 — nest the record (+ `DepthPalette`). Status: DONE (PR #177) + +The big line-count win and the **only slice that ripples the consumers**. + +Scope: replace the flat record with the nested root + group records; rewrite both mappers as +per-group positional construction; rewire every consumer's property path; retarget the fixture and +the VM's `Seed`/`BuildRecord` paths (the VM stays a monolith with a nested `with`-rebuild until +slice 5 — mechanical path change only). + +Touches: `GridStyleOptions.cs` + new group-record files (Core/Configuration); both mappers; the VM's +`Seed`/`BuildRecord`; `GridStyleOptionsTestData`; and the consumer files listed in the survey above +(`CellPaletteInstaller`, `ExecutionPaletteInstaller`, `GridFontApplier`, `ColumnBuilder`, +`TextCellFactory`, `ComboBoxCellFactory`, `ColumnWidthCalculator`, `ActiveRecipeGridSurface`, +`TransposedRecipeGridSurface`, `TransposedRecipeGridView.axaml.cs`, +`TransposedCellTemplateFactory`). `App.axaml.cs`, `ConfigFacade`, DI registration: carrier only, +likely untouched. **The YAML format, DTOs, and validator do not change** — the file on disk is +identical before and after. + +Blast radius: wide but shallow — every touch is a property-path rename, no logic changes. Risk: +medium by surface area, low by mechanism; the save→load and Seed/perturbation guards catch any +dropped or cross-wired field, and the compiler catches every path rename. Do not mix any behavior +change into this PR. + +### Slice 4 — type colors as `StyleColor`, fold validation into the load map. Status: DONE (branch `grid-style-color-typing`, PR #178) + +Scope: introduce `StyleColor` in Core; flip the 53 color fields (now spread over the group records) +from `string` to `StyleColor`; move hex parsing + per-key error accumulation into the load mapper; +delete `GridStyleValidator` (orientation and section-missing checks move with it); serialize via +`StyleColor.ToString` in the save mapper (the DTOs keep their double-quoted string properties, so +YAML output shape is unchanged); retarget `PaletteBrushFactory.From(StyleColor)` (channel copy, no +parse); replace `HexColor` in the VM with channel-wise `StyleColor` ↔ `Avalonia.Media.Color` +conversion and delete `HexColor.cs`; decide what remains of `IGridStyleEditorFacade.Validate` (with +typed colors it has almost nothing left to check — see open questions). + +Error-identity constraint (the hard part): the mapper must aggregate **all** errors per load, keyed +by the same section path + key name the validator emits today, so `ReasonLocalizer` output and +`GridStyleColorsValidationTests` semantics survive. Parse-accepting rule follows the current +validator: `#RRGGBB` / `#AARRGGBB` (6 or 8 hex digits) — note the shipped file header advertises +`#RGB`/`#ARGB` too, which the validator already rejects for validated keys; slice 4 should settle +that discrepancy explicitly rather than inherit it silently. + +Behavior note: hex **case** normalizes on save (typed equality is value equality; the writer emits +canonical uppercase). Acceptable — the editor already rewrites the whole file. + +Blast radius: Core config layer + `PaletteBrushFactory` + VM edges; consumers of brushes unchanged. +Risk: medium — concentrated in error-path fidelity, covered by the existing error tests plus the +save→load guard. + +### Slice 5 — split the VM into per-group drafts + grouped AXAML. Status: DONE (branch `grid-style-vm-split`). Closes #118 + +Scope: per-group `ReactiveObject` drafts (initializer = seed, positional `Build()` = compile guard); +parent VM shrinks to ~150 lines; `CanSave` reduces to the numeric range checks (color pickers cannot +produce an invalid `StyleColor`, so the per-keystroke facade `Validate` call goes away); +`IGridStyleEditorFacade` shrinks accordingly; `GridStyleEditorWindow.axaml` paths become grouped — +each rename is build-checked (`x:DataType` + AVLN2000, verified). The screenshot tests +(`SemiStep.Screenshots/GridStyleEditorScreenshotTests`) guard the visual layout across the rewrite. + +Documentation lands here: rewrite `Docs/architecture/grid-style-configuration.md` to describe the +nested record, `StyleColor`, the mapper-resident validation, and the draft-based editor as *current* +(slices 2–4 only add interim notes). While in there, fix the doc's already-stale "Known gap" +section — the validator has since gained optional-section coverage (selection, changed, grid line, +status-bar, validation-panel keys are format-checked when present), and after slice 4 the section is +obsolete entirely. + +Blast radius: `StyleEditor/` (VM, drafts, AXAML) + facade interface + VM tests. Risk: medium — the +AXAML is 555 lines of renames, but every path is compile-checked and the Seed/perturbation guards +re-verify the field wiring end to end. + +## Trade-offs and rejected alternatives + +Settled during design — do not relitigate without new facts: + +- **Serialize the nested record directly (custom YAML converter), dropping the DTOs.** Rejected. + Grid style would diverge from every sibling config section (actions/columns/groups/properties all + use nullable snake_case DTOs), and YamlDotNet-level type converters fail on the first bad scalar — + losing the aggregate per-key error report that names every invalid key in one pass. The DTO layer + is also where "absent key → default" is expressible; typed records deliberately cannot represent + absence. +- **Source-generate the VM mirror (or the mappers).** Rejected. A generator hides exactly the code a + maintainer must read to answer "where does this field go" — the readability-first goal of #118. + The positional-constructor discipline gets the same completeness guarantee from the plain + compiler, with no toolchain cost. +- **Reflection-based generic mapping/seeding.** Rejected for the same reason plus type-erasure: the + compile-time omission check is the entire point, and reflection trades it away to save lines. +- **Make the record mutable / bind AXAML straight to the record.** Rejected. The immutable record is + load-bearing (DI singleton, `Avalonia`-free Core, `with`-based tests), and Avalonia two-way + binding needs settable named properties — hence drafts remain a separate mutable layer; the design + makes them small and compile-guarded instead of eliminating them. +- **Honest accounting.** The refactor removes roughly 400 lines net (~1180 → ~750–800 in the layer), + not a dramatic collapse — the DTOs, the AXAML, and the consumers keep their size. What it buys is + categorical: after slice 5 there is no hand-maintained full-field list left whose omission + compiles. + +## Open questions for the operator + +Genuine forks needing a human call at slice 3 / slice 5 planning time; everything above stands +regardless of how these land. + +1. **Nesting depth (slice 3).** Mirror the DTO tree exactly (`Colors.Cells.ReadOnly.Depth1` — three + levels) or flatten to one level of ~8 groups (`ReadOnlyPalette.Depth1`)? One level matches + consumption and keeps paths short; exact mirroring makes the mappers near-trivial. Recommendation + pending taste: one level, letting the mapper own the small DTO-tree walk. +2. **`DepthPalette` for Execution (slice 3).** Execution is 8 depths + marker (no + selected/foreground). Reuse a shared 8-field depth block inside both `DepthPalette` and an + `ExecutionPalette`, or accept a 9-field standalone record and keep `DepthPalette` for exactly the + two identical 10-field blocks? Extra sharing saves ~8 lines and costs one more nesting hop. +3. **`StyleColor` struct vs class (slice 4).** `readonly record struct` is the natural fit (value + semantics, no allocation per color ×53); the alternative is a sealed record class if boxing in + the reactive layer or a future null-means-inherit semantic matters. Also settle whether + `Parse` accepts `#RGB`/`#ARGB` (file header says yes, validator says no — see slice 4). +4. **AXAML rename scope (slice 5).** Rename all 77 binding paths to grouped form in the same PR as + the VM split (one coherent build-checked change, bigger diff) or keep flat names on the drafts + via bridge properties and rename in a follow-up? The compile check makes the big-bang rename + safe; the bridge keeps the PR reviewable. Also decide whether the parent exposes drafts as + properties (`Fonts.HeaderSize`) or the window sets per-card `DataContext`. +5. **What remains of facade `Validate` (slices 4–5).** After colors are typed, `Validate(record)` + has nothing to check (numeric ranges are VM-side by design). Drop it from + `IGridStyleEditorFacade` at slice 4 already, or keep a vacuous pass until slice 5 trims the + interface? Affects only how many PRs touch the interface. diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs index 3b646385..c79c1a8f 100644 --- a/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs @@ -6,11 +6,12 @@ namespace SemiStep.Core.Configuration; /// -/// The single public Core seam for the in-app style editor. Color hex validation now lives in -/// , which parses and validates in one pass on ; an invalid -/// color is unrepresentable in , so writes without a -/// pre-write gate. Numeric-range validation (font sizes, paddings, row height, spacing, panel height) is the -/// caller's responsibility — the editor view model enforces those bounds before invoking . +/// The single public Core seam for the in-app style editor: and . +/// Color hex validation now lives in , which parses and validates in one pass +/// on ; an invalid color is unrepresentable in , so +/// writes without a pre-write gate. Numeric-range validation (font sizes, paddings, row +/// height, spacing, panel height) is the caller's responsibility — the editor view model enforces those +/// bounds before invoking . /// public sealed class GridStyleEditorFacade : IGridStyleEditorFacade { @@ -27,13 +28,6 @@ public async Task> Load(string configDir) return GridStyleMapper.Map(loadResult.Value); } - public Result Validate(GridStyleOptions options) - { - // Deliberate vacuous pass-through: a typed GridStyleOptions cannot hold an invalid color, so there is - // nothing to validate here. The method stays on IGridStyleEditorFacade until slice 5 trims the interface. - return Result.Ok(); - } - public async Task Save(string configDir, GridStyleOptions options) { return await _gridStyleWriter.SaveAsync(configDir, options); diff --git a/SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs b/SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs index f195e3b4..dc85677e 100644 --- a/SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs +++ b/SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs @@ -3,14 +3,13 @@ namespace SemiStep.Core.Configuration; /// -/// The style-editor seam consumed by the UI editor view model. Declared as an interface so the -/// view model depends on an abstraction it can mock, rather than the concrete Core facade. +/// The style-editor seam consumed by the UI editor view model: and . +/// Declared as an interface so the view model depends on an abstraction it can mock, rather than the +/// concrete Core facade. /// public interface IGridStyleEditorFacade { Task> Load(string configDir); - Result Validate(GridStyleOptions options); - Task Save(string configDir, GridStyleOptions options); } diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs index e01cb98a..e2133fbe 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs @@ -68,17 +68,6 @@ public async Task SaveThenLoad_DistinctFixture_PreservesEveryMappedField() reloaded.Should().Be(GridStyleOptionsTestData.Distinct()); } - [Fact] - public async Task Validate_ShippedRecord_ReturnsOk() - { - using var tempDir = CopyShippedConfig("MBE"); - var facade = new GridStyleEditorFacade(); - - var loaded = (await facade.Load(tempDir.Path)).Value; - - facade.Validate(loaded).IsSuccess.Should().BeTrue(); - } - [Fact] public async Task Load_UnparseableYaml_CarriesOriginalExceptionOnCausedBy() { diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleDraftTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleDraftTests.cs new file mode 100644 index 00000000..a6049646 --- /dev/null +++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleDraftTests.cs @@ -0,0 +1,118 @@ +using System; + +using FluentAssertions; + +using SemiStep.Tests.Helpers; +using SemiStep.UI.StyleEditor; + +using Xunit; + +namespace SemiStep.Tests.UI.StyleEditor; + +/// +/// Per-draft round-trip guards. Each draft seeds from a group of the all-distinct fixture and rebuilds +/// it; record value equality over mutually-distinct, exactly-representable fixture values makes the +/// assertion field-exhaustive — a dropped, unseeded, or cross-wired leaf mismatches exactly one field. +/// Plain : the drafts touch no Avalonia services, only the Color struct, +/// so they run outside the headless dispatcher. +/// +[Trait("Component", "UI")] +[Trait("Category", "Unit")] +public sealed class GridStyleDraftTests +{ + [Fact] + public void GridStyleFontsDraft_RoundTrips_FromDistinctFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().Fonts; + + new GridStyleFontsDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void GridStyleLayoutDraft_RoundTrips_FromDistinctFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().Layout; + + new GridStyleLayoutDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void SelectionColorsDraft_RoundTrips_FromDistinctFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().Selection; + + new SelectionColorsDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void ChangedCellColorsDraft_RoundTrips_FromDistinctFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().ChangedCells; + + new ChangedCellColorsDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void DepthPaletteDraft_RoundTrips_FromReadOnlyCellsFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().ReadOnlyCells; + + new DepthPaletteDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void DepthPaletteDraft_RoundTrips_FromDisabledCellsFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().DisabledCells; + + new DepthPaletteDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void ExecutionPaletteDraft_RoundTrips_FromDistinctFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().Execution; + + new ExecutionPaletteDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void StatusBarStyleDraft_RoundTrips_FromDistinctFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().StatusBar; + + new StatusBarStyleDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void ValidationPanelStyleDraft_RoundTrips_FromDistinctFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().ValidationPanel; + + new ValidationPanelStyleDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void ChromeColorsDraft_RoundTrips_FromDistinctFixture() + { + var fixture = GridStyleOptionsTestData.Distinct().Chrome; + + new ChromeColorsDraft(fixture).Build().Should().Be(fixture); + } + + [Fact] + public void DraftNumbers_ToInt_Throws_OnNull() + { + var toInt = () => DraftNumbers.ToInt(null); + + toInt.Should().Throw(); + } + + [Fact] + public void DraftNumbers_ToDouble_Throws_OnNull() + { + var toDouble = () => DraftNumbers.ToDouble(null); + + toDouble.Should().Throw(); + } +} diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs index b1ec69a2..34642b01 100644 --- a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs +++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs @@ -36,40 +36,6 @@ public sealed class GridStyleEditorViewModelTests private const string ConfigDir = @"C:\does-not-exist"; private const int SurfacedEditablePropertyCount = 77; - [AvaloniaFact] - public void Seed_PopulatesEverySurfacedProperty_FromDistinctFixture() - { - 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) - { - var recordValue = LeafValue(fixture, RecordPath(property)); - var actual = property.GetValue(viewModel); - var because = $"property {property.Name} must be seeded from the fixture"; - - if (property.PropertyType == typeof(Color)) - { - actual.Should().Be(((StyleColor)recordValue!).ToMediaColor(), 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); - } - } - } - [AvaloniaFact] public void Seed_ThenBuildRecord_PreservesEveryFieldDistinctly() { @@ -79,31 +45,33 @@ public void Seed_ThenBuildRecord_PreservesEveryFieldDistinctly() } [AvaloniaFact] - public void BuildRecord_PerturbingEachProperty_ChangesOnlyThatMappedField() + public void BuildRecord_PerturbingEachLeaf_ChangesOnlyThatMappedField() { var viewModel = CreateViewModel(GridStyleOptionsTestData.Distinct()); - var properties = EditableProperties(); - properties.Should().HaveCount( + var leaves = SurfacedLeaves(viewModel); + leaves.Should().HaveCount( SurfacedEditablePropertyCount, - "the perturbation guard must cover every surfaced editable property"); + "the perturbation guard must cover every surfaced editable leaf"); - foreach (var property in properties) + foreach (var (groupName, draft, leaf) in leaves) { - var seededValue = property.GetValue(viewModel); + var seededValue = leaf.GetValue(draft); var baseline = viewModel.BuildRecord(); - property.SetValue(viewModel, Perturb(property, seededValue)); + leaf.SetValue(draft, Perturb(leaf, seededValue)); var built = viewModel.BuildRecord(); - var mappedField = RecordPath(property); + // Derived, not mapped: the draft leaf name mirrors the record component name by design, so + // the record path is {GroupProperty}.{LeafProperty}. A name that diverges cannot hide — it + // makes this walk report a different path or the per-draft round-trip guard go red. + var mappedField = $"{groupName}.{leaf.Name}"; var changedFields = ChangedRecordFields(baseline, built); changedFields.Should().BeEquivalentTo( new[] { mappedField }, - $"editing {property.Name} must change exactly the {mappedField} record field"); + $"editing {mappedField} must change exactly that record field"); - // Restore the seeded value rather than reconstruct the VM: each ctor enumerates system fonts. - property.SetValue(viewModel, seededValue); + leaf.SetValue(draft, seededValue); } } @@ -113,8 +81,8 @@ public void BuildRecord_AfterEditingColorAndFontSize_ChangesOnlyThoseFields() var source = GridStyleOptions.Default; var viewModel = CreateViewModel(source); - viewModel.CellFontSize = 18; - viewModel.SelectionBackground = Color.Parse("#123456"); + viewModel.Fonts.CellFontSize = 18; + viewModel.Selection.Background = Color.Parse("#123456"); var record = viewModel.BuildRecord(); @@ -131,7 +99,7 @@ public void BuildRecord_RoundsFractionalStatusBarFontSize_ToNearestInt() { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.StatusBarFontSize = 14.6m; + viewModel.StatusBar.FontSize = 14.6m; viewModel.BuildRecord().StatusBar.FontSize.Should().Be(15); } @@ -143,7 +111,7 @@ public void CanSave_IsFalse_WhenStatusBarFontSizeOutOfRange(int fontSize) { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.StatusBarFontSize = fontSize; + viewModel.StatusBar.FontSize = fontSize; viewModel.CanSave.Should().BeFalse(); } @@ -155,7 +123,7 @@ public void CanSave_IsFalse_WhenStatusBarTimerValueFontSizeOutOfRange(int fontSi { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.StatusBarTimerValueFontSize = fontSize; + viewModel.StatusBar.TimerValueFontSize = fontSize; viewModel.CanSave.Should().BeFalse(); } @@ -167,7 +135,7 @@ public void CanSave_IsFalse_WhenStatusBarTimerLabelFontSizeOutOfRange(int fontSi { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.StatusBarTimerLabelFontSize = fontSize; + viewModel.StatusBar.TimerLabelFontSize = fontSize; viewModel.CanSave.Should().BeFalse(); } @@ -193,12 +161,12 @@ public void Seed_PopulatesFontFamilyWeightAndItalic_FromRecord() }; var viewModel = CreateViewModel(source); - viewModel.FontFamily.Should().Be("Cascadia Code"); - viewModel.HeaderFontWeight.Should().Be(600); - viewModel.HeaderItalic.Should().BeTrue(); - viewModel.CellFontWeight.Should().Be(500); - viewModel.StatusBarTimerLabelFontSize.Should().Be(18); - viewModel.StatusBarTimerLabelItalic.Should().BeTrue(); + viewModel.Fonts.FontFamily.Should().Be("Cascadia Code"); + viewModel.Fonts.HeaderFontWeight.Should().Be(600); + viewModel.Fonts.HeaderItalic.Should().BeTrue(); + viewModel.Fonts.CellFontWeight.Should().Be(500); + viewModel.StatusBar.TimerLabelFontSize.Should().Be(18); + viewModel.StatusBar.TimerLabelItalic.Should().BeTrue(); } [AvaloniaFact] @@ -207,10 +175,10 @@ public void BuildRecord_CarriesEditedFontFamilyWeightAndItalic() var source = GridStyleOptions.Default; var viewModel = CreateViewModel(source); - viewModel.FontFamily = "Consolas"; - viewModel.HeaderFontWeight = 900; - viewModel.CellItalic = true; - viewModel.StatusBarTimerValueFontWeight = 300; + viewModel.Fonts.FontFamily = "Consolas"; + viewModel.Fonts.HeaderFontWeight = 900; + viewModel.Fonts.CellItalic = true; + viewModel.StatusBar.TimerValueWeight = 300; var record = viewModel.BuildRecord(); @@ -258,7 +226,7 @@ public void CanSave_IsFalse_WhenFontSizeOutOfRange(int fontSize) { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.CellFontSize = fontSize; + viewModel.Fonts.CellFontSize = fontSize; viewModel.CanSave.Should().BeFalse(); } @@ -268,7 +236,7 @@ public void CanSave_IsFalse_WhenNumericIsNull() { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.RowHeight = null; + viewModel.Layout.RowHeight = null; viewModel.CanSave.Should().BeFalse(); } @@ -278,7 +246,7 @@ public void CanSave_IsFalse_WhenRowHeightOutOfRange() { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.RowHeight = GridStyleEditorViewModel.MaxRowHeight + 1; + viewModel.Layout.RowHeight = GridStyleEditorViewModel.MaxRowHeight + 1; viewModel.CanSave.Should().BeFalse(); } @@ -288,7 +256,7 @@ public void ToHex_PreservesSixDigitForm_ForOpaqueColors() { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.SelectionBackground = Color.Parse("#A1B2C3"); + viewModel.Selection.Background = Color.Parse("#A1B2C3"); viewModel.BuildRecord().Selection.Background.ToString().Should().Be("#A1B2C3"); } @@ -298,7 +266,7 @@ public void ToHex_EmitsEightDigitForm_ForNonOpaqueColors() { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.SelectionBackground = Color.FromArgb(0x80, 0x11, 0x22, 0x33); + viewModel.Selection.Background = Color.FromArgb(0x80, 0x11, 0x22, 0x33); viewModel.BuildRecord().Selection.Background.ToString().Should().Be("#80112233"); } @@ -307,12 +275,33 @@ public void ToHex_EmitsEightDigitForm_ForNonOpaqueColors() public async Task LoadAsync_MissingConfigDir_SetsErrorMessageAndDoesNotReseed() { var viewModel = CreateViewModel(GridStyleOptions.Default); - viewModel.CellFontSize = 18; + viewModel.Fonts.CellFontSize = 18; await viewModel.LoadAsync(); viewModel.ErrorMessage.Should().NotBeNullOrWhiteSpace(); - viewModel.CellFontSize.Should().Be(18); + viewModel.Fonts.CellFontSize.Should().Be(18); + } + + [AvaloniaFact] + public async Task LoadAsync_Success_ReplacesDraftsAndRewiresCanSave() + { + var loaded = GridStyleOptionsTestData.Distinct(); + var viewModel = new GridStyleEditorViewModel( + new CausedByFailingFacade(loadResult: Result.Ok(loaded)), + ConfigDir, + GridStyleOptions.Default, + NullLogger.Instance); + + await viewModel.LoadAsync(); + + viewModel.Fonts.HeaderFontSize.Should().Be(loaded.Fonts.HeaderFontSize); + + // The out-of-range edit lands on the NEW draft; CanSave flipping proves the re-seed rewired the + // subscriptions to it, not to the discarded default draft. + viewModel.Fonts.HeaderFontSize = 999; + + viewModel.CanSave.Should().BeFalse(); } [AvaloniaFact] @@ -438,118 +427,39 @@ private static GridStyleEditorViewModel CreateViewModel(GridStyleOptions source) NullLogger.Instance); } - private static IReadOnlyList EditableProperties() + // Every surfaced leaf as (group property name on the VM, the live draft instance, the leaf property on + // that draft). The ten group properties are exactly the public VM properties whose type derives from + // ReactiveObject; each draft's leaves are its own declared public get/set properties. DeclaredOnly keeps + // the base ReactiveObject members out, yet keeps ChangedCellColorsDraft.Changed — the get/set color leaf + // that hides the base observable with `new` — because it is declared on the draft type. + private static IReadOnlyList<(string GroupName, object Draft, PropertyInfo Leaf)> SurfacedLeaves( + GridStyleEditorViewModel viewModel) { - return typeof(GridStyleEditorViewModel).GetProperties() - .Where(property => property.GetMethod?.IsPublic == true && property.SetMethod?.IsPublic == true) + var groups = typeof(GridStyleEditorViewModel) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => typeof(ReactiveObject).IsAssignableFrom(property.PropertyType)) .ToList(); - } - // Hand-maintained VM-property -> nested record leaf path. NOT derived: several leaf names diverge - // from the VM property name (StatusBarFontWeight -> StatusBar.Weight, ValidationPanelError -> - // ValidationPanel.ErrorColor, every Color drops its trailing "Color"), so a mistyped entry surfaces - // as a red guard test rather than a silent pass. Every entry is cross-checked against the group records. - private static readonly IReadOnlyDictionary _viewModelToRecordPath = - new Dictionary + var leaves = new List<(string, object, PropertyInfo)>(); + foreach (var group in groups) { - ["FontFamily"] = "Fonts.FontFamily", - ["HeaderFontSize"] = "Fonts.HeaderFontSize", - ["HeaderFontWeight"] = "Fonts.HeaderFontWeight", - ["HeaderItalic"] = "Fonts.HeaderItalic", - ["CellFontSize"] = "Fonts.CellFontSize", - ["CellFontWeight"] = "Fonts.CellFontWeight", - ["CellItalic"] = "Fonts.CellItalic", - ["CellPaddingLeft"] = "Layout.CellPaddingLeft", - ["CellPaddingTop"] = "Layout.CellPaddingTop", - ["CellPaddingRight"] = "Layout.CellPaddingRight", - ["CellPaddingBottom"] = "Layout.CellPaddingBottom", - ["RowHeight"] = "Layout.RowHeight", - ["SelectionBackground"] = "Selection.Background", - ["SelectionForeground"] = "Selection.Foreground", - ["CellChanged"] = "ChangedCells.Changed", - ["CellChangedSelected"] = "ChangedCells.ChangedSelected", - ["ReadOnlyCellDepth0"] = "ReadOnlyCells.Depth0", - ["ReadOnlyCellDepth1"] = "ReadOnlyCells.Depth1", - ["ReadOnlyCellDepth2"] = "ReadOnlyCells.Depth2", - ["ReadOnlyCellDepth3"] = "ReadOnlyCells.Depth3", - ["ReadOnlyCellDepth0Past"] = "ReadOnlyCells.Depth0Past", - ["ReadOnlyCellDepth1Past"] = "ReadOnlyCells.Depth1Past", - ["ReadOnlyCellDepth2Past"] = "ReadOnlyCells.Depth2Past", - ["ReadOnlyCellDepth3Past"] = "ReadOnlyCells.Depth3Past", - ["ReadOnlyCellSelected"] = "ReadOnlyCells.Selected", - ["ReadOnlyCellForeground"] = "ReadOnlyCells.Foreground", - ["DisabledCellDepth0"] = "DisabledCells.Depth0", - ["DisabledCellDepth1"] = "DisabledCells.Depth1", - ["DisabledCellDepth2"] = "DisabledCells.Depth2", - ["DisabledCellDepth3"] = "DisabledCells.Depth3", - ["DisabledCellDepth0Past"] = "DisabledCells.Depth0Past", - ["DisabledCellDepth1Past"] = "DisabledCells.Depth1Past", - ["DisabledCellDepth2Past"] = "DisabledCells.Depth2Past", - ["DisabledCellDepth3Past"] = "DisabledCells.Depth3Past", - ["DisabledCellSelected"] = "DisabledCells.Selected", - ["DisabledCellForeground"] = "DisabledCells.Foreground", - ["ExecutionDepth0"] = "Execution.Depth0", - ["ExecutionDepth1"] = "Execution.Depth1", - ["ExecutionDepth2"] = "Execution.Depth2", - ["ExecutionDepth3"] = "Execution.Depth3", - ["ExecutionDepth0Past"] = "Execution.Depth0Past", - ["ExecutionDepth1Past"] = "Execution.Depth1Past", - ["ExecutionDepth2Past"] = "Execution.Depth2Past", - ["ExecutionDepth3Past"] = "Execution.Depth3Past", - ["ExecutionCurrentStepMarker"] = "Execution.CurrentStepMarker", - ["StatusBarBackground"] = "StatusBar.Background", - ["StatusBarForeground"] = "StatusBar.Foreground", - ["StatusBarPadding"] = "StatusBar.Padding", - ["StatusBarItemSpacing"] = "StatusBar.ItemSpacing", - ["StatusBarFontSize"] = "StatusBar.FontSize", - ["StatusBarFontWeight"] = "StatusBar.Weight", - ["StatusBarItalic"] = "StatusBar.Italic", - ["StatusBarTimerLabelFontSize"] = "StatusBar.TimerLabelFontSize", - ["StatusBarTimerLabelFontWeight"] = "StatusBar.TimerLabelWeight", - ["StatusBarTimerLabelItalic"] = "StatusBar.TimerLabelItalic", - ["StatusBarTimerValueFontSize"] = "StatusBar.TimerValueFontSize", - ["StatusBarTimerValueFontWeight"] = "StatusBar.TimerValueWeight", - ["StatusBarTimerValueItalic"] = "StatusBar.TimerValueItalic", - ["ValidationPanelBackground"] = "ValidationPanel.Background", - ["ValidationPanelForeground"] = "ValidationPanel.Foreground", - ["ValidationPanelError"] = "ValidationPanel.ErrorColor", - ["ValidationPanelWarning"] = "ValidationPanel.WarningColor", - ["ValidationPanelMaxHeight"] = "ValidationPanel.MaxHeight", - ["GridLine"] = "Chrome.GridLine", - ["Info"] = "Chrome.Info", - ["Connected"] = "Chrome.Connected", - ["Disconnected"] = "Chrome.Disconnected", - ["LocalMode"] = "Chrome.LocalMode", - ["Connecting"] = "Chrome.Connecting", - ["PanelBackground"] = "Chrome.PanelBackground", - ["PanelHeaderBackground"] = "Chrome.PanelHeaderBackground", - ["SubtleBorder"] = "Chrome.SubtleBorder", - ["Separator"] = "Chrome.Separator", - ["SecondaryForeground"] = "Chrome.SecondaryForeground", - ["GridBorder"] = "Chrome.GridBorder", - ["GridBackground"] = "Chrome.GridBackground", - ["HeaderForeground"] = "Chrome.HeaderForeground", - }; + var draft = group.GetValue(viewModel) + ?? throw new InvalidOperationException($"Draft property {group.Name} was null"); + foreach (var leaf in DraftLeaves(group.PropertyType)) + { + leaves.Add((group.Name, draft, leaf)); + } + } - private static string RecordPath(PropertyInfo viewModelProperty) - { - return _viewModelToRecordPath.TryGetValue(viewModelProperty.Name, out var path) - ? path - : throw new InvalidOperationException($"No GridStyleOptions path maps to {viewModelProperty.Name}"); + return leaves; } - private static object? LeafValue(GridStyleOptions record, string dottedPath) + private static IReadOnlyList DraftLeaves(Type draftType) { - object current = record; - foreach (var segment in dottedPath.Split('.')) - { - var property = current.GetType().GetProperty(segment) - ?? throw new InvalidOperationException($"No record property '{segment}' on {current.GetType().Name}"); - current = property.GetValue(current) - ?? throw new InvalidOperationException($"Record property '{segment}' was null"); - } - - return current; + return draftType + .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(property => property.GetMethod?.IsPublic == true && property.SetMethod?.IsPublic == true) + .ToList(); } // Color perturbation stays opaque so ToHex changes; weights are unvalidated ints so any value round-trips. @@ -641,11 +551,6 @@ public Task> Load(string configDir) return Task.FromResult(Result.Ok(GridStyleOptions.Default)); } - public Result Validate(GridStyleOptions options) - { - return Result.Ok(); - } - public Task Save(string configDir, GridStyleOptions options) { throw failure; @@ -661,11 +566,6 @@ public Task> Load(string configDir) return Task.FromResult(loadResult ?? Result.Ok(GridStyleOptions.Default)); } - public Result Validate(GridStyleOptions options) - { - return Result.Ok(); - } - public Task Save(string configDir, GridStyleOptions options) { return Task.FromResult(saveResult ?? Result.Ok()); diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowTests.cs index c3783cd7..aecdbd78 100644 --- a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowTests.cs +++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowTests.cs @@ -36,8 +36,8 @@ public async Task Save_AfterEditingColorAndNumeric_PersistsToYaml_AndReloads() loaded, NullLogger.Instance); - viewModel.SelectionBackground = Color.Parse("#123456"); - viewModel.CellFontSize = loaded.Fonts.CellFontSize + 1; + viewModel.Selection.Background = Color.Parse("#123456"); + viewModel.Fonts.CellFontSize = loaded.Fonts.CellFontSize + 1; viewModel.CanSave.Should().BeTrue(); var saved = await viewModel.SaveCommand.Execute(); diff --git a/SemiStep/SemiStep.UI/StyleEditor/ChangedCellColorsDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/ChangedCellColorsDraft.cs new file mode 100644 index 00000000..4f7da0d5 --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/ChangedCellColorsDraft.cs @@ -0,0 +1,37 @@ +using Avalonia.Media; + +using ReactiveUI; + +using SemiStep.Core.Configuration; +using SemiStep.UI.Styles; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of . Property initializers seed from ; +/// reconstructs the record positionally so an omitted component is a compile error. +/// +public sealed class ChangedCellColorsDraft(ChangedCellColors source) : ReactiveObject +{ + // Hides ReactiveObject.Changed (the get-only change observable). The leaf name mirrors the record + // component ChangedCellColors.Changed by design; the AXAML path ChangedCells.Changed and the guard + // reflection both resolve to this get/set property, not the base observable. + public new Color Changed + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Changed.ToMediaColor(); + + public Color ChangedSelected + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.ChangedSelected.ToMediaColor(); + + public ChangedCellColors Build() + { + return new ChangedCellColors( + Changed: Changed.ToStyleColor(), + ChangedSelected: ChangedSelected.ToStyleColor()); + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/ChromeColorsDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/ChromeColorsDraft.cs new file mode 100644 index 00000000..e4fc5be1 --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/ChromeColorsDraft.cs @@ -0,0 +1,118 @@ +using Avalonia.Media; + +using ReactiveUI; + +using SemiStep.Core.Configuration; +using SemiStep.UI.Styles; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of . Property initializers seed from ; +/// reconstructs the record positionally so an omitted component is a compile error. +/// +public sealed class ChromeColorsDraft(ChromeColors source) : ReactiveObject +{ + public Color Info + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Info.ToMediaColor(); + + public Color Connected + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Connected.ToMediaColor(); + + public Color Disconnected + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Disconnected.ToMediaColor(); + + public Color LocalMode + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.LocalMode.ToMediaColor(); + + public Color Connecting + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Connecting.ToMediaColor(); + + public Color PanelBackground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.PanelBackground.ToMediaColor(); + + public Color PanelHeaderBackground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.PanelHeaderBackground.ToMediaColor(); + + public Color SubtleBorder + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.SubtleBorder.ToMediaColor(); + + public Color Separator + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Separator.ToMediaColor(); + + public Color SecondaryForeground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.SecondaryForeground.ToMediaColor(); + + public Color GridBorder + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.GridBorder.ToMediaColor(); + + public Color GridBackground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.GridBackground.ToMediaColor(); + + public Color HeaderForeground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.HeaderForeground.ToMediaColor(); + + public Color GridLine + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.GridLine.ToMediaColor(); + + public ChromeColors Build() + { + return new ChromeColors( + Info: Info.ToStyleColor(), + Connected: Connected.ToStyleColor(), + Disconnected: Disconnected.ToStyleColor(), + LocalMode: LocalMode.ToStyleColor(), + Connecting: Connecting.ToStyleColor(), + PanelBackground: PanelBackground.ToStyleColor(), + PanelHeaderBackground: PanelHeaderBackground.ToStyleColor(), + SubtleBorder: SubtleBorder.ToStyleColor(), + Separator: Separator.ToStyleColor(), + SecondaryForeground: SecondaryForeground.ToStyleColor(), + GridBorder: GridBorder.ToStyleColor(), + GridBackground: GridBackground.ToStyleColor(), + HeaderForeground: HeaderForeground.ToStyleColor(), + GridLine: GridLine.ToStyleColor()); + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/DepthPaletteDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/DepthPaletteDraft.cs new file mode 100644 index 00000000..7b6e2322 --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/DepthPaletteDraft.cs @@ -0,0 +1,91 @@ +using Avalonia.Media; + +using ReactiveUI; + +using SemiStep.Core.Configuration; +using SemiStep.UI.Styles; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of , used for both the read-only-cell and disabled-cell +/// palettes. Property initializers seed from ; reconstructs +/// the record positionally so an omitted component is a compile error. +/// +public sealed class DepthPaletteDraft(DepthPalette source) : ReactiveObject +{ + public Color Depth0 + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth0.ToMediaColor(); + + public Color Depth1 + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth1.ToMediaColor(); + + public Color Depth2 + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth2.ToMediaColor(); + + public Color Depth3 + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth3.ToMediaColor(); + + public Color Depth0Past + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth0Past.ToMediaColor(); + + public Color Depth1Past + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth1Past.ToMediaColor(); + + public Color Depth2Past + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth2Past.ToMediaColor(); + + public Color Depth3Past + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth3Past.ToMediaColor(); + + public Color Selected + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Selected.ToMediaColor(); + + public Color Foreground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Foreground.ToMediaColor(); + + public DepthPalette Build() + { + return new DepthPalette( + Depth0: Depth0.ToStyleColor(), + Depth1: Depth1.ToStyleColor(), + Depth2: Depth2.ToStyleColor(), + Depth3: Depth3.ToStyleColor(), + Depth0Past: Depth0Past.ToStyleColor(), + Depth1Past: Depth1Past.ToStyleColor(), + Depth2Past: Depth2Past.ToStyleColor(), + Depth3Past: Depth3Past.ToStyleColor(), + Selected: Selected.ToStyleColor(), + Foreground: Foreground.ToStyleColor()); + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/DraftNumbers.cs b/SemiStep/SemiStep.UI/StyleEditor/DraftNumbers.cs new file mode 100644 index 00000000..2123b411 --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/DraftNumbers.cs @@ -0,0 +1,36 @@ +using System; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Strict numeric conversions shared by the per-group drafts' Build() methods. A draft exposes +/// numerics as ? for two-way binding against a NumericUpDown, which is transiently +/// null while the operator is typing. Build() runs only on save — gated by the VM's +/// CanSave, which requires every numeric non-null and in range — so a null here means +/// Build() was called on a draft the guard should have rejected, and that is a bug, not a value to +/// substitute. Both methods therefore throw rather than fall back. +/// +internal static class DraftNumbers +{ + internal static int ToInt(decimal? value) + { + return (int)Math.Round(Require(value), MidpointRounding.AwayFromZero); + } + + internal static double ToDouble(decimal? value) + { + return (double)Require(value); + } + + private static decimal Require(decimal? value) + { + if (value is null) + { + throw new InvalidOperationException( + "Build was called on a draft with a null numeric field; CanSave must reject a null or " + + "out-of-range numeric before Build runs."); + } + + return value.Value; + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/ExecutionPaletteDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/ExecutionPaletteDraft.cs new file mode 100644 index 00000000..5c1db1fc --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/ExecutionPaletteDraft.cs @@ -0,0 +1,83 @@ +using Avalonia.Media; + +using ReactiveUI; + +using SemiStep.Core.Configuration; +using SemiStep.UI.Styles; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of . Property initializers seed from ; +/// reconstructs the record positionally so an omitted component is a compile error. +/// +public sealed class ExecutionPaletteDraft(ExecutionPalette source) : ReactiveObject +{ + public Color Depth0 + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth0.ToMediaColor(); + + public Color Depth1 + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth1.ToMediaColor(); + + public Color Depth2 + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth2.ToMediaColor(); + + public Color Depth3 + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth3.ToMediaColor(); + + public Color Depth0Past + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth0Past.ToMediaColor(); + + public Color Depth1Past + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth1Past.ToMediaColor(); + + public Color Depth2Past + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth2Past.ToMediaColor(); + + public Color Depth3Past + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Depth3Past.ToMediaColor(); + + public Color CurrentStepMarker + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.CurrentStepMarker.ToMediaColor(); + + public ExecutionPalette Build() + { + return new ExecutionPalette( + Depth0: Depth0.ToStyleColor(), + Depth1: Depth1.ToStyleColor(), + Depth2: Depth2.ToStyleColor(), + Depth3: Depth3.ToStyleColor(), + Depth0Past: Depth0Past.ToStyleColor(), + Depth1Past: Depth1Past.ToStyleColor(), + Depth2Past: Depth2Past.ToStyleColor(), + Depth3Past: Depth3Past.ToStyleColor(), + CurrentStepMarker: CurrentStepMarker.ToStyleColor()); + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs index 207162f2..b2f839c0 100644 --- a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs +++ b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs @@ -1,6 +1,7 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Reactive; -using System.Runtime.CompilerServices; +using System.Reactive.Disposables; using Avalonia.Media; @@ -12,18 +13,17 @@ using SemiStep.Core.Configuration; using SemiStep.UI.Localization; -using SemiStep.UI.Styles; namespace SemiStep.UI.StyleEditor; /// -/// Editable draft of for the in-app style editor. Seeded from the -/// loaded record (a separate mutable copy — never the DI singleton), it exposes colors as -/// and sizes as ? for two-way binding. -/// gates on the Core color validator AND VM-side numeric range checks (the Core validator is -/// colors-only). The editor surfaces effectively the whole record; constructs -/// a new record positionally, carrying the one unsurfaced field -/// straight from _source.Orientation. +/// Thin parent for the in-app style editor. It exposes each group of as a +/// per-group draft (initializer-seeded from a mutable copy of the loaded +/// record, never the DI singleton); the AXAML binds grouped Group.Leaf paths against those drafts. +/// gates on the VM-side numeric range checks alone; a typed +/// cannot hold an invalid color, so there is nothing else to validate. constructs +/// a new record positionally from the ten drafts' Build() calls, carrying the one unsurfaced field +/// straight from _source.Orientation. /// public sealed class GridStyleEditorViewModel : ReactiveObject { @@ -43,7 +43,7 @@ public sealed class GridStyleEditorViewModel : ReactiveObject private readonly ILogger _logger; private GridStyleOptions _source; - private bool _seeding; + private CompositeDisposable _draftSubscriptions = new(); public GridStyleEditorViewModel( IGridStyleEditorFacade gridStyleEditorFacade, @@ -73,7 +73,7 @@ public GridStyleEditorViewModel( // transient dialog VM, which is neither IDisposable nor pooled. SaveCommand.ThrownExceptions.Subscribe(ReportSaveException); - Seed(source); + ReplaceDrafts(source); } public ReactiveCommand SaveCommand { get; } @@ -90,33 +90,67 @@ public bool CanSave private set => this.RaiseAndSetIfChanged(ref field, value); } - // Numeric draft (decimal? for NumericUpDown). - public decimal? HeaderFontSize { get => field; set => SetNumber(ref field, value); } - public decimal? CellFontSize { get => field; set => SetNumber(ref field, value); } - public decimal? CellPaddingLeft { get => field; set => SetNumber(ref field, value); } - public decimal? CellPaddingTop { get => field; set => SetNumber(ref field, value); } - public decimal? CellPaddingRight { get => field; set => SetNumber(ref field, value); } - public decimal? CellPaddingBottom { get => field; set => SetNumber(ref field, value); } - public decimal? RowHeight { get => field; set => SetNumber(ref field, value); } - public decimal? StatusBarPadding { get => field; set => SetNumber(ref field, value); } - public decimal? StatusBarItemSpacing { get => field; set => SetNumber(ref field, value); } - public decimal? StatusBarFontSize { get => field; set => SetNumber(ref field, value); } - public decimal? StatusBarTimerLabelFontSize { get => field; set => SetNumber(ref field, value); } - public decimal? StatusBarTimerValueFontSize { get => field; set => SetNumber(ref field, value); } - public decimal? ValidationPanelMaxHeight { get => field; set => SetNumber(ref field, value); } - - // Global font family ("" = theme default). Weight (int 100-900) and italic per role. - public string? FontFamily { get => field; set => SetValue(ref field, value); } - public int HeaderFontWeight { get => field; set => SetValue(ref field, value); } - public bool HeaderItalic { get => field; set => SetValue(ref field, value); } - public int CellFontWeight { get => field; set => SetValue(ref field, value); } - public bool CellItalic { get => field; set => SetValue(ref field, value); } - public int StatusBarFontWeight { get => field; set => SetValue(ref field, value); } - public bool StatusBarItalic { get => field; set => SetValue(ref field, value); } - public int StatusBarTimerLabelFontWeight { get => field; set => SetValue(ref field, value); } - public bool StatusBarTimerLabelItalic { get => field; set => SetValue(ref field, value); } - public int StatusBarTimerValueFontWeight { get => field; set => SetValue(ref field, value); } - public bool StatusBarTimerValueItalic { get => field; set => SetValue(ref field, value); } + // Raising change on a whole-draft swap re-evaluates every bound leaf path, so a successful + // re-seed refreshes the editor. + public GridStyleFontsDraft Fonts + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public GridStyleLayoutDraft Layout + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public SelectionColorsDraft Selection + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public ChangedCellColorsDraft ChangedCells + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public DepthPaletteDraft ReadOnlyCells + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public DepthPaletteDraft DisabledCells + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public ExecutionPaletteDraft Execution + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public StatusBarStyleDraft StatusBar + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public ValidationPanelStyleDraft ValidationPanel + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + public ChromeColorsDraft Chrome + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } // Picker sources. Always contain the seeded value so the bound string/int is never nulled // by a missing SelectedItem/SelectedValue (protects the lossless-seed contract). @@ -132,61 +166,6 @@ public IReadOnlyList AvailableFontWeights private set => this.RaiseAndSetIfChanged(ref field, value); } = []; - // Color draft (Color for ColorPicker). - public Color SelectionBackground { get => field; set => SetColor(ref field, value); } - public Color SelectionForeground { get => field; set => SetColor(ref field, value); } - public Color CellChanged { get => field; set => SetColor(ref field, value); } - public Color CellChangedSelected { get => field; set => SetColor(ref field, value); } - public Color DisabledCellDepth0 { get => field; set => SetColor(ref field, value); } - public Color DisabledCellDepth1 { get => field; set => SetColor(ref field, value); } - public Color DisabledCellDepth2 { get => field; set => SetColor(ref field, value); } - public Color DisabledCellDepth3 { get => field; set => SetColor(ref field, value); } - public Color DisabledCellDepth0Past { get => field; set => SetColor(ref field, value); } - public Color DisabledCellDepth1Past { get => field; set => SetColor(ref field, value); } - public Color DisabledCellDepth2Past { get => field; set => SetColor(ref field, value); } - public Color DisabledCellDepth3Past { get => field; set => SetColor(ref field, value); } - public Color DisabledCellSelected { get => field; set => SetColor(ref field, value); } - public Color DisabledCellForeground { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellDepth0 { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellDepth1 { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellDepth2 { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellDepth3 { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellDepth0Past { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellDepth1Past { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellDepth2Past { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellDepth3Past { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellSelected { get => field; set => SetColor(ref field, value); } - public Color ReadOnlyCellForeground { get => field; set => SetColor(ref field, value); } - public Color GridLine { get => field; set => SetColor(ref field, value); } - public Color StatusBarBackground { get => field; set => SetColor(ref field, value); } - public Color StatusBarForeground { get => field; set => SetColor(ref field, value); } - public Color ValidationPanelBackground { get => field; set => SetColor(ref field, value); } - public Color ValidationPanelForeground { get => field; set => SetColor(ref field, value); } - public Color ValidationPanelError { get => field; set => SetColor(ref field, value); } - public Color ValidationPanelWarning { get => field; set => SetColor(ref field, value); } - public Color ExecutionDepth0 { get => field; set => SetColor(ref field, value); } - public Color ExecutionDepth1 { get => field; set => SetColor(ref field, value); } - public Color ExecutionDepth2 { get => field; set => SetColor(ref field, value); } - public Color ExecutionDepth3 { get => field; set => SetColor(ref field, value); } - public Color ExecutionDepth0Past { get => field; set => SetColor(ref field, value); } - public Color ExecutionDepth1Past { get => field; set => SetColor(ref field, value); } - public Color ExecutionDepth2Past { get => field; set => SetColor(ref field, value); } - public Color ExecutionDepth3Past { get => field; set => SetColor(ref field, value); } - public Color ExecutionCurrentStepMarker { get => field; set => SetColor(ref field, value); } - public Color Info { get => field; set => SetColor(ref field, value); } - public Color Connected { get => field; set => SetColor(ref field, value); } - public Color Disconnected { get => field; set => SetColor(ref field, value); } - public Color LocalMode { get => field; set => SetColor(ref field, value); } - public Color Connecting { get => field; set => SetColor(ref field, value); } - public Color PanelBackground { get => field; set => SetColor(ref field, value); } - public Color PanelHeaderBackground { get => field; set => SetColor(ref field, value); } - public Color SubtleBorder { get => field; set => SetColor(ref field, value); } - public Color Separator { get => field; set => SetColor(ref field, value); } - public Color SecondaryForeground { get => field; set => SetColor(ref field, value); } - public Color GridBorder { get => field; set => SetColor(ref field, value); } - public Color GridBackground { get => field; set => SetColor(ref field, value); } - public Color HeaderForeground { get => field; set => SetColor(ref field, value); } - public async Task LoadAsync() { var result = await _gridStyleEditorFacade.Load(_configDir); @@ -199,99 +178,22 @@ public async Task LoadAsync() _source = result.Value; ErrorMessage = null; - Seed(result.Value); + ReplaceDrafts(result.Value); } public GridStyleOptions BuildRecord() { return new GridStyleOptions( - Fonts: new GridStyleFonts( - FontFamily: FontFamily ?? _source.Fonts.FontFamily, - HeaderFontSize: ToInt(HeaderFontSize, _source.Fonts.HeaderFontSize), - HeaderFontWeight: HeaderFontWeight, - HeaderItalic: HeaderItalic, - CellFontSize: ToInt(CellFontSize, _source.Fonts.CellFontSize), - CellFontWeight: CellFontWeight, - CellItalic: CellItalic), - Layout: new GridStyleLayout( - CellPaddingLeft: ToDouble(CellPaddingLeft, _source.Layout.CellPaddingLeft), - CellPaddingTop: ToDouble(CellPaddingTop, _source.Layout.CellPaddingTop), - CellPaddingRight: ToDouble(CellPaddingRight, _source.Layout.CellPaddingRight), - CellPaddingBottom: ToDouble(CellPaddingBottom, _source.Layout.CellPaddingBottom), - RowHeight: ToDouble(RowHeight, _source.Layout.RowHeight)), - Selection: new SelectionColors( - Background: SelectionBackground.ToStyleColor(), - Foreground: SelectionForeground.ToStyleColor()), - ChangedCells: new ChangedCellColors( - Changed: CellChanged.ToStyleColor(), - ChangedSelected: CellChangedSelected.ToStyleColor()), - ReadOnlyCells: new DepthPalette( - Depth0: ReadOnlyCellDepth0.ToStyleColor(), - Depth1: ReadOnlyCellDepth1.ToStyleColor(), - Depth2: ReadOnlyCellDepth2.ToStyleColor(), - Depth3: ReadOnlyCellDepth3.ToStyleColor(), - Depth0Past: ReadOnlyCellDepth0Past.ToStyleColor(), - Depth1Past: ReadOnlyCellDepth1Past.ToStyleColor(), - Depth2Past: ReadOnlyCellDepth2Past.ToStyleColor(), - Depth3Past: ReadOnlyCellDepth3Past.ToStyleColor(), - Selected: ReadOnlyCellSelected.ToStyleColor(), - Foreground: ReadOnlyCellForeground.ToStyleColor()), - DisabledCells: new DepthPalette( - Depth0: DisabledCellDepth0.ToStyleColor(), - Depth1: DisabledCellDepth1.ToStyleColor(), - Depth2: DisabledCellDepth2.ToStyleColor(), - Depth3: DisabledCellDepth3.ToStyleColor(), - Depth0Past: DisabledCellDepth0Past.ToStyleColor(), - Depth1Past: DisabledCellDepth1Past.ToStyleColor(), - Depth2Past: DisabledCellDepth2Past.ToStyleColor(), - Depth3Past: DisabledCellDepth3Past.ToStyleColor(), - Selected: DisabledCellSelected.ToStyleColor(), - Foreground: DisabledCellForeground.ToStyleColor()), - Execution: new ExecutionPalette( - Depth0: ExecutionDepth0.ToStyleColor(), - Depth1: ExecutionDepth1.ToStyleColor(), - Depth2: ExecutionDepth2.ToStyleColor(), - Depth3: ExecutionDepth3.ToStyleColor(), - Depth0Past: ExecutionDepth0Past.ToStyleColor(), - Depth1Past: ExecutionDepth1Past.ToStyleColor(), - Depth2Past: ExecutionDepth2Past.ToStyleColor(), - Depth3Past: ExecutionDepth3Past.ToStyleColor(), - CurrentStepMarker: ExecutionCurrentStepMarker.ToStyleColor()), - StatusBar: new StatusBarStyle( - Background: StatusBarBackground.ToStyleColor(), - Foreground: StatusBarForeground.ToStyleColor(), - Padding: ToDouble(StatusBarPadding, _source.StatusBar.Padding), - ItemSpacing: ToDouble(StatusBarItemSpacing, _source.StatusBar.ItemSpacing), - FontSize: ToInt(StatusBarFontSize, _source.StatusBar.FontSize), - Weight: StatusBarFontWeight, - Italic: StatusBarItalic, - TimerLabelFontSize: ToInt(StatusBarTimerLabelFontSize, _source.StatusBar.TimerLabelFontSize), - TimerLabelWeight: StatusBarTimerLabelFontWeight, - TimerLabelItalic: StatusBarTimerLabelItalic, - TimerValueFontSize: ToInt(StatusBarTimerValueFontSize, _source.StatusBar.TimerValueFontSize), - TimerValueWeight: StatusBarTimerValueFontWeight, - TimerValueItalic: StatusBarTimerValueItalic), - ValidationPanel: new ValidationPanelStyle( - Background: ValidationPanelBackground.ToStyleColor(), - Foreground: ValidationPanelForeground.ToStyleColor(), - ErrorColor: ValidationPanelError.ToStyleColor(), - WarningColor: ValidationPanelWarning.ToStyleColor(), - MaxHeight: ToDouble(ValidationPanelMaxHeight, _source.ValidationPanel.MaxHeight)), - Chrome: new ChromeColors( - Info: Info.ToStyleColor(), - Connected: Connected.ToStyleColor(), - Disconnected: Disconnected.ToStyleColor(), - LocalMode: LocalMode.ToStyleColor(), - Connecting: Connecting.ToStyleColor(), - PanelBackground: PanelBackground.ToStyleColor(), - PanelHeaderBackground: PanelHeaderBackground.ToStyleColor(), - SubtleBorder: SubtleBorder.ToStyleColor(), - Separator: Separator.ToStyleColor(), - SecondaryForeground: SecondaryForeground.ToStyleColor(), - GridBorder: GridBorder.ToStyleColor(), - GridBackground: GridBackground.ToStyleColor(), - HeaderForeground: HeaderForeground.ToStyleColor(), - GridLine: GridLine.ToStyleColor()), + Fonts: Fonts.Build(), + Layout: Layout.Build(), + Selection: Selection.Build(), + ChangedCells: ChangedCells.Build(), + ReadOnlyCells: ReadOnlyCells.Build(), + DisabledCells: DisabledCells.Build(), + Execution: Execution.Build(), + StatusBar: StatusBar.Build(), + ValidationPanel: ValidationPanel.Build(), + Chrome: Chrome.Build(), Orientation: _source.Orientation); } @@ -329,113 +231,55 @@ private void LogCausedByExceptions(IResultBase result, string message) } } - private void Seed(GridStyleOptions options) + // Swaps the CanSave subscriptions so a stale draft never keeps driving CanSave. + [MemberNotNull( + nameof(Fonts), + nameof(Layout), + nameof(Selection), + nameof(ChangedCells), + nameof(ReadOnlyCells), + nameof(DisabledCells), + nameof(Execution), + nameof(StatusBar), + nameof(ValidationPanel), + nameof(Chrome))] + private void ReplaceDrafts(GridStyleOptions options) { - _seeding = true; - - HeaderFontSize = options.Fonts.HeaderFontSize; - CellFontSize = options.Fonts.CellFontSize; - CellPaddingLeft = (decimal)options.Layout.CellPaddingLeft; - CellPaddingTop = (decimal)options.Layout.CellPaddingTop; - CellPaddingRight = (decimal)options.Layout.CellPaddingRight; - CellPaddingBottom = (decimal)options.Layout.CellPaddingBottom; - RowHeight = (decimal)options.Layout.RowHeight; - StatusBarPadding = (decimal)options.StatusBar.Padding; - StatusBarItemSpacing = (decimal)options.StatusBar.ItemSpacing; - StatusBarFontSize = options.StatusBar.FontSize; - StatusBarTimerLabelFontSize = options.StatusBar.TimerLabelFontSize; - StatusBarTimerValueFontSize = options.StatusBar.TimerValueFontSize; - ValidationPanelMaxHeight = (decimal)options.ValidationPanel.MaxHeight; - - FontFamily = options.Fonts.FontFamily; - HeaderFontWeight = options.Fonts.HeaderFontWeight; - HeaderItalic = options.Fonts.HeaderItalic; - CellFontWeight = options.Fonts.CellFontWeight; - CellItalic = options.Fonts.CellItalic; - StatusBarFontWeight = options.StatusBar.Weight; - StatusBarItalic = options.StatusBar.Italic; - StatusBarTimerLabelFontWeight = options.StatusBar.TimerLabelWeight; - StatusBarTimerLabelItalic = options.StatusBar.TimerLabelItalic; - StatusBarTimerValueFontWeight = options.StatusBar.TimerValueWeight; - StatusBarTimerValueItalic = options.StatusBar.TimerValueItalic; + Fonts = new GridStyleFontsDraft(options.Fonts); + Layout = new GridStyleLayoutDraft(options.Layout); + Selection = new SelectionColorsDraft(options.Selection); + ChangedCells = new ChangedCellColorsDraft(options.ChangedCells); + ReadOnlyCells = new DepthPaletteDraft(options.ReadOnlyCells); + DisabledCells = new DepthPaletteDraft(options.DisabledCells); + Execution = new ExecutionPaletteDraft(options.Execution); + StatusBar = new StatusBarStyleDraft(options.StatusBar); + ValidationPanel = new ValidationPanelStyleDraft(options.ValidationPanel); + Chrome = new ChromeColorsDraft(options.Chrome); AvailableFontFamilies = BuildFontFamilies(options.Fonts.FontFamily); AvailableFontWeights = BuildFontWeights(options); - SelectionBackground = options.Selection.Background.ToMediaColor(); - SelectionForeground = options.Selection.Foreground.ToMediaColor(); - CellChanged = options.ChangedCells.Changed.ToMediaColor(); - CellChangedSelected = options.ChangedCells.ChangedSelected.ToMediaColor(); - DisabledCellDepth0 = options.DisabledCells.Depth0.ToMediaColor(); - DisabledCellDepth1 = options.DisabledCells.Depth1.ToMediaColor(); - DisabledCellDepth2 = options.DisabledCells.Depth2.ToMediaColor(); - DisabledCellDepth3 = options.DisabledCells.Depth3.ToMediaColor(); - DisabledCellDepth0Past = options.DisabledCells.Depth0Past.ToMediaColor(); - DisabledCellDepth1Past = options.DisabledCells.Depth1Past.ToMediaColor(); - DisabledCellDepth2Past = options.DisabledCells.Depth2Past.ToMediaColor(); - DisabledCellDepth3Past = options.DisabledCells.Depth3Past.ToMediaColor(); - DisabledCellSelected = options.DisabledCells.Selected.ToMediaColor(); - DisabledCellForeground = options.DisabledCells.Foreground.ToMediaColor(); - ReadOnlyCellDepth0 = options.ReadOnlyCells.Depth0.ToMediaColor(); - ReadOnlyCellDepth1 = options.ReadOnlyCells.Depth1.ToMediaColor(); - ReadOnlyCellDepth2 = options.ReadOnlyCells.Depth2.ToMediaColor(); - ReadOnlyCellDepth3 = options.ReadOnlyCells.Depth3.ToMediaColor(); - ReadOnlyCellDepth0Past = options.ReadOnlyCells.Depth0Past.ToMediaColor(); - ReadOnlyCellDepth1Past = options.ReadOnlyCells.Depth1Past.ToMediaColor(); - ReadOnlyCellDepth2Past = options.ReadOnlyCells.Depth2Past.ToMediaColor(); - ReadOnlyCellDepth3Past = options.ReadOnlyCells.Depth3Past.ToMediaColor(); - ReadOnlyCellSelected = options.ReadOnlyCells.Selected.ToMediaColor(); - ReadOnlyCellForeground = options.ReadOnlyCells.Foreground.ToMediaColor(); - GridLine = options.Chrome.GridLine.ToMediaColor(); - StatusBarBackground = options.StatusBar.Background.ToMediaColor(); - StatusBarForeground = options.StatusBar.Foreground.ToMediaColor(); - ValidationPanelBackground = options.ValidationPanel.Background.ToMediaColor(); - ValidationPanelForeground = options.ValidationPanel.Foreground.ToMediaColor(); - ValidationPanelError = options.ValidationPanel.ErrorColor.ToMediaColor(); - ValidationPanelWarning = options.ValidationPanel.WarningColor.ToMediaColor(); - ExecutionDepth0 = options.Execution.Depth0.ToMediaColor(); - ExecutionDepth1 = options.Execution.Depth1.ToMediaColor(); - ExecutionDepth2 = options.Execution.Depth2.ToMediaColor(); - ExecutionDepth3 = options.Execution.Depth3.ToMediaColor(); - ExecutionDepth0Past = options.Execution.Depth0Past.ToMediaColor(); - ExecutionDepth1Past = options.Execution.Depth1Past.ToMediaColor(); - ExecutionDepth2Past = options.Execution.Depth2Past.ToMediaColor(); - ExecutionDepth3Past = options.Execution.Depth3Past.ToMediaColor(); - ExecutionCurrentStepMarker = options.Execution.CurrentStepMarker.ToMediaColor(); - Info = options.Chrome.Info.ToMediaColor(); - Connected = options.Chrome.Connected.ToMediaColor(); - Disconnected = options.Chrome.Disconnected.ToMediaColor(); - LocalMode = options.Chrome.LocalMode.ToMediaColor(); - Connecting = options.Chrome.Connecting.ToMediaColor(); - PanelBackground = options.Chrome.PanelBackground.ToMediaColor(); - PanelHeaderBackground = options.Chrome.PanelHeaderBackground.ToMediaColor(); - SubtleBorder = options.Chrome.SubtleBorder.ToMediaColor(); - Separator = options.Chrome.Separator.ToMediaColor(); - SecondaryForeground = options.Chrome.SecondaryForeground.ToMediaColor(); - GridBorder = options.Chrome.GridBorder.ToMediaColor(); - GridBackground = options.Chrome.GridBackground.ToMediaColor(); - HeaderForeground = options.Chrome.HeaderForeground.ToMediaColor(); - - _seeding = false; - RecomputeCanSave(); - } - - private void SetColor(ref Color field, Color value, [CallerMemberName] string? propertyName = null) - { - this.RaiseAndSetIfChanged(ref field, value, propertyName); - RecomputeCanSave(); - } + _draftSubscriptions.Dispose(); + _draftSubscriptions = new CompositeDisposable( + SubscribeCanSave(Fonts), + SubscribeCanSave(Layout), + SubscribeCanSave(Selection), + SubscribeCanSave(ChangedCells), + SubscribeCanSave(ReadOnlyCells), + SubscribeCanSave(DisabledCells), + SubscribeCanSave(Execution), + SubscribeCanSave(StatusBar), + SubscribeCanSave(ValidationPanel), + SubscribeCanSave(Chrome)); - private void SetNumber(ref decimal? field, decimal? value, [CallerMemberName] string? propertyName = null) - { - this.RaiseAndSetIfChanged(ref field, value, propertyName); RecomputeCanSave(); } - private void SetValue(ref TValue field, TValue value, [CallerMemberName] string? propertyName = null) + // Takes the base ReactiveObject so the ChangedCellColorsDraft.Changed leaf (which hides the base + // Changed observable with `new`) resolves to the observable here, not the color property. + private IDisposable SubscribeCanSave(ReactiveObject draft) { - this.RaiseAndSetIfChanged(ref field, value, propertyName); - RecomputeCanSave(); + return draft.Changed.Subscribe(_ => RecomputeCanSave()); } private static IReadOnlyList BuildFontFamilies(string seededFamily) @@ -492,43 +336,28 @@ private static IReadOnlyList BuildFontWeights(GridStyleOptions private void RecomputeCanSave() { - if (_seeding) - { - return; - } - - CanSave = NumericsInRange() && _gridStyleEditorFacade.Validate(BuildRecord()).IsSuccess; + CanSave = NumericsInRange(); } private bool NumericsInRange() { - return InRange(HeaderFontSize, MinFontSize, MaxFontSize) - && InRange(CellFontSize, MinFontSize, MaxFontSize) - && InRange(RowHeight, MinRowHeight, MaxRowHeight) - && InRange(CellPaddingLeft, MinPadding, MaxPadding) - && InRange(CellPaddingTop, MinPadding, MaxPadding) - && InRange(CellPaddingRight, MinPadding, MaxPadding) - && InRange(CellPaddingBottom, MinPadding, MaxPadding) - && InRange(StatusBarPadding, MinPadding, MaxPadding) - && InRange(StatusBarItemSpacing, MinSpacing, MaxSpacing) - && InRange(StatusBarFontSize, MinFontSize, MaxFontSize) - && InRange(StatusBarTimerLabelFontSize, MinFontSize, MaxFontSize) - && InRange(StatusBarTimerValueFontSize, MinFontSize, MaxFontSize) - && InRange(ValidationPanelMaxHeight, MinPanelMaxHeight, MaxPanelMaxHeight); + return InRange(Fonts.HeaderFontSize, MinFontSize, MaxFontSize) + && InRange(Fonts.CellFontSize, MinFontSize, MaxFontSize) + && InRange(Layout.RowHeight, MinRowHeight, MaxRowHeight) + && InRange(Layout.CellPaddingLeft, MinPadding, MaxPadding) + && InRange(Layout.CellPaddingTop, MinPadding, MaxPadding) + && InRange(Layout.CellPaddingRight, MinPadding, MaxPadding) + && InRange(Layout.CellPaddingBottom, MinPadding, MaxPadding) + && InRange(StatusBar.Padding, MinPadding, MaxPadding) + && InRange(StatusBar.ItemSpacing, MinSpacing, MaxSpacing) + && InRange(StatusBar.FontSize, MinFontSize, MaxFontSize) + && InRange(StatusBar.TimerLabelFontSize, MinFontSize, MaxFontSize) + && InRange(StatusBar.TimerValueFontSize, MinFontSize, MaxFontSize) + && InRange(ValidationPanel.MaxHeight, MinPanelMaxHeight, MaxPanelMaxHeight); } private static bool InRange(decimal? value, int min, int max) { return value is not null && value >= min && value <= max; } - - private static int ToInt(decimal? value, int fallback) - { - return value is null ? fallback : (int)Math.Round(value.Value, MidpointRounding.AwayFromZero); - } - - private static double ToDouble(decimal? value, double fallback) - { - return value is null ? fallback : (double)value.Value; - } } diff --git a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorWindow.axaml b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorWindow.axaml index 18f38a07..3d905fe7 100644 --- a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorWindow.axaml +++ b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorWindow.axaml @@ -115,19 +115,19 @@ + Value="{Binding Layout.RowHeight}" /> + Value="{Binding Layout.CellPaddingLeft}" /> + Value="{Binding Layout.CellPaddingTop}" /> + Value="{Binding Layout.CellPaddingRight}" /> + Value="{Binding Layout.CellPaddingBottom}" /> @@ -146,25 +146,25 @@ + Value="{Binding Fonts.HeaderFontSize}" /> + SelectedValue="{Binding Fonts.HeaderFontWeight}" /> + IsChecked="{Binding Fonts.HeaderItalic}" /> + Value="{Binding Fonts.CellFontSize}" /> + SelectedValue="{Binding Fonts.CellFontWeight}" /> + IsChecked="{Binding Fonts.CellItalic}" /> @@ -179,28 +179,28 @@ + Color="{Binding Chrome.GridBackground}" /> + Color="{Binding Chrome.GridBorder}" /> + Color="{Binding Chrome.GridLine}" /> + Color="{Binding Chrome.HeaderForeground}" /> + Color="{Binding Selection.Background}" /> + Color="{Binding Selection.Foreground}" /> + Color="{Binding ChangedCells.Changed}" /> + Color="{Binding ChangedCells.ChangedSelected}" /> @@ -217,30 +217,30 @@ + Color="{Binding ReadOnlyCells.Depth0}" /> + Color="{Binding ReadOnlyCells.Depth0Past}" /> + Color="{Binding ReadOnlyCells.Depth1}" /> + Color="{Binding ReadOnlyCells.Depth1Past}" /> + Color="{Binding ReadOnlyCells.Depth2}" /> + Color="{Binding ReadOnlyCells.Depth2Past}" /> + Color="{Binding ReadOnlyCells.Depth3}" /> + Color="{Binding ReadOnlyCells.Depth3Past}" /> + Color="{Binding ReadOnlyCells.Selected}" /> + Color="{Binding ReadOnlyCells.Foreground}" /> @@ -257,30 +257,30 @@ + Color="{Binding DisabledCells.Depth0}" /> + Color="{Binding DisabledCells.Depth0Past}" /> + Color="{Binding DisabledCells.Depth1}" /> + Color="{Binding DisabledCells.Depth1Past}" /> + Color="{Binding DisabledCells.Depth2}" /> + Color="{Binding DisabledCells.Depth2Past}" /> + Color="{Binding DisabledCells.Depth3}" /> + Color="{Binding DisabledCells.Depth3Past}" /> + Color="{Binding DisabledCells.Selected}" /> + Color="{Binding DisabledCells.Foreground}" /> @@ -297,27 +297,27 @@ + Color="{Binding Execution.Depth0}" /> + Color="{Binding Execution.Depth0Past}" /> + Color="{Binding Execution.Depth1}" /> + Color="{Binding Execution.Depth1Past}" /> + Color="{Binding Execution.Depth2}" /> + Color="{Binding Execution.Depth2Past}" /> + Color="{Binding Execution.Depth3}" /> + Color="{Binding Execution.Depth3Past}" /> + Color="{Binding Execution.CurrentStepMarker}" /> @@ -336,10 +336,10 @@ + Value="{Binding StatusBar.Padding}" /> + Value="{Binding StatusBar.ItemSpacing}" /> @@ -358,36 +358,36 @@ + Value="{Binding StatusBar.FontSize}" /> + SelectedValue="{Binding StatusBar.Weight}" /> + IsChecked="{Binding StatusBar.Italic}" /> + Value="{Binding StatusBar.TimerLabelFontSize}" /> + SelectedValue="{Binding StatusBar.TimerLabelWeight}" /> + IsChecked="{Binding StatusBar.TimerLabelItalic}" /> + Value="{Binding StatusBar.TimerValueFontSize}" /> + SelectedValue="{Binding StatusBar.TimerValueWeight}" /> + IsChecked="{Binding StatusBar.TimerValueItalic}" /> @@ -402,22 +402,22 @@ + Color="{Binding StatusBar.Background}" /> + Color="{Binding StatusBar.Foreground}" /> + Color="{Binding Chrome.Connected}" /> + Color="{Binding Chrome.Disconnected}" /> + Color="{Binding Chrome.LocalMode}" /> + Color="{Binding Chrome.Connecting}" /> @@ -436,7 +436,7 @@ + Value="{Binding ValidationPanel.MaxHeight}" /> @@ -451,19 +451,19 @@ + Color="{Binding ValidationPanel.Background}" /> + Color="{Binding ValidationPanel.Foreground}" /> + Color="{Binding Chrome.Info}" /> + Color="{Binding ValidationPanel.ErrorColor}" /> + Color="{Binding ValidationPanel.WarningColor}" /> @@ -488,7 +488,7 @@ ItemsSource="{Binding AvailableFontFamilies}" ItemTemplate="{StaticResource FontFamilyItemTemplate}" SelectedValueBinding="{Binding Value}" - SelectedValue="{Binding FontFamily}" /> + SelectedValue="{Binding Fonts.FontFamily}" /> @@ -503,19 +503,19 @@ + Color="{Binding Chrome.PanelBackground}" /> + Color="{Binding Chrome.PanelHeaderBackground}" /> + Color="{Binding Chrome.SubtleBorder}" /> + Color="{Binding Chrome.Separator}" /> + Color="{Binding Chrome.SecondaryForeground}" /> diff --git a/SemiStep/SemiStep.UI/StyleEditor/GridStyleFontsDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/GridStyleFontsDraft.cs new file mode 100644 index 00000000..12c133e4 --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/GridStyleFontsDraft.cs @@ -0,0 +1,66 @@ +using ReactiveUI; + +using SemiStep.Core.Configuration; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of . Property initializers seed from ; +/// reconstructs the record positionally so an omitted component is a compile error. +/// +public sealed class GridStyleFontsDraft(GridStyleFonts source) : ReactiveObject +{ + public string? FontFamily + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.FontFamily; + + public decimal? HeaderFontSize + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.HeaderFontSize; + + public int HeaderFontWeight + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.HeaderFontWeight; + + public bool HeaderItalic + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.HeaderItalic; + + public decimal? CellFontSize + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.CellFontSize; + + public int CellFontWeight + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.CellFontWeight; + + public bool CellItalic + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.CellItalic; + + public GridStyleFonts Build() + { + return new GridStyleFonts( + FontFamily: FontFamily ?? "", + HeaderFontSize: DraftNumbers.ToInt(HeaderFontSize), + HeaderFontWeight: HeaderFontWeight, + HeaderItalic: HeaderItalic, + CellFontSize: DraftNumbers.ToInt(CellFontSize), + CellFontWeight: CellFontWeight, + CellItalic: CellItalic); + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/GridStyleLayoutDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/GridStyleLayoutDraft.cs new file mode 100644 index 00000000..3bb839a5 --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/GridStyleLayoutDraft.cs @@ -0,0 +1,52 @@ +using ReactiveUI; + +using SemiStep.Core.Configuration; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of . Property initializers seed from ; +/// reconstructs the record positionally so an omitted component is a compile error. +/// +public sealed class GridStyleLayoutDraft(GridStyleLayout source) : ReactiveObject +{ + public decimal? CellPaddingLeft + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = (decimal)source.CellPaddingLeft; + + public decimal? CellPaddingTop + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = (decimal)source.CellPaddingTop; + + public decimal? CellPaddingRight + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = (decimal)source.CellPaddingRight; + + public decimal? CellPaddingBottom + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = (decimal)source.CellPaddingBottom; + + public decimal? RowHeight + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = (decimal)source.RowHeight; + + public GridStyleLayout Build() + { + return new GridStyleLayout( + CellPaddingLeft: DraftNumbers.ToDouble(CellPaddingLeft), + CellPaddingTop: DraftNumbers.ToDouble(CellPaddingTop), + CellPaddingRight: DraftNumbers.ToDouble(CellPaddingRight), + CellPaddingBottom: DraftNumbers.ToDouble(CellPaddingBottom), + RowHeight: DraftNumbers.ToDouble(RowHeight)); + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/SelectionColorsDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/SelectionColorsDraft.cs new file mode 100644 index 00000000..e0f61390 --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/SelectionColorsDraft.cs @@ -0,0 +1,34 @@ +using Avalonia.Media; + +using ReactiveUI; + +using SemiStep.Core.Configuration; +using SemiStep.UI.Styles; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of . Property initializers seed from ; +/// reconstructs the record positionally so an omitted component is a compile error. +/// +public sealed class SelectionColorsDraft(SelectionColors source) : ReactiveObject +{ + public Color Background + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Background.ToMediaColor(); + + public Color Foreground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Foreground.ToMediaColor(); + + public SelectionColors Build() + { + return new SelectionColors( + Background: Background.ToStyleColor(), + Foreground: Foreground.ToStyleColor()); + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/StatusBarStyleDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/StatusBarStyleDraft.cs new file mode 100644 index 00000000..24fbd749 --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/StatusBarStyleDraft.cs @@ -0,0 +1,111 @@ +using Avalonia.Media; + +using ReactiveUI; + +using SemiStep.Core.Configuration; +using SemiStep.UI.Styles; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of . Property initializers seed from ; +/// reconstructs the record positionally so an omitted component is a compile error. +/// +public sealed class StatusBarStyleDraft(StatusBarStyle source) : ReactiveObject +{ + public Color Background + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Background.ToMediaColor(); + + public Color Foreground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Foreground.ToMediaColor(); + + public decimal? Padding + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = (decimal)source.Padding; + + public decimal? ItemSpacing + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = (decimal)source.ItemSpacing; + + public decimal? FontSize + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.FontSize; + + public int Weight + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Weight; + + public bool Italic + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Italic; + + public decimal? TimerLabelFontSize + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.TimerLabelFontSize; + + public int TimerLabelWeight + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.TimerLabelWeight; + + public bool TimerLabelItalic + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.TimerLabelItalic; + + public decimal? TimerValueFontSize + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.TimerValueFontSize; + + public int TimerValueWeight + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.TimerValueWeight; + + public bool TimerValueItalic + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.TimerValueItalic; + + public StatusBarStyle Build() + { + return new StatusBarStyle( + Background: Background.ToStyleColor(), + Foreground: Foreground.ToStyleColor(), + Padding: DraftNumbers.ToDouble(Padding), + ItemSpacing: DraftNumbers.ToDouble(ItemSpacing), + FontSize: DraftNumbers.ToInt(FontSize), + Weight: Weight, + Italic: Italic, + TimerLabelFontSize: DraftNumbers.ToInt(TimerLabelFontSize), + TimerLabelWeight: TimerLabelWeight, + TimerLabelItalic: TimerLabelItalic, + TimerValueFontSize: DraftNumbers.ToInt(TimerValueFontSize), + TimerValueWeight: TimerValueWeight, + TimerValueItalic: TimerValueItalic); + } +} diff --git a/SemiStep/SemiStep.UI/StyleEditor/ValidationPanelStyleDraft.cs b/SemiStep/SemiStep.UI/StyleEditor/ValidationPanelStyleDraft.cs new file mode 100644 index 00000000..19f6adbf --- /dev/null +++ b/SemiStep/SemiStep.UI/StyleEditor/ValidationPanelStyleDraft.cs @@ -0,0 +1,55 @@ +using Avalonia.Media; + +using ReactiveUI; + +using SemiStep.Core.Configuration; +using SemiStep.UI.Styles; + +namespace SemiStep.UI.StyleEditor; + +/// +/// Editable draft of . Property initializers seed from ; +/// reconstructs the record positionally so an omitted component is a compile error. +/// +public sealed class ValidationPanelStyleDraft(ValidationPanelStyle source) : ReactiveObject +{ + public Color Background + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Background.ToMediaColor(); + + public Color Foreground + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.Foreground.ToMediaColor(); + + public Color ErrorColor + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.ErrorColor.ToMediaColor(); + + public Color WarningColor + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = source.WarningColor.ToMediaColor(); + + public decimal? MaxHeight + { + get; + set => this.RaiseAndSetIfChanged(ref field, value); + } = (decimal)source.MaxHeight; + + public ValidationPanelStyle Build() + { + return new ValidationPanelStyle( + Background: Background.ToStyleColor(), + Foreground: Foreground.ToStyleColor(), + ErrorColor: ErrorColor.ToStyleColor(), + WarningColor: WarningColor.ToStyleColor(), + MaxHeight: DraftNumbers.ToDouble(MaxHeight)); + } +} diff --git a/docs/plans/completed/20260806-grid-style-vm-split.md b/docs/plans/completed/20260806-grid-style-vm-split.md new file mode 100644 index 00000000..65a8a989 --- /dev/null +++ b/docs/plans/completed/20260806-grid-style-vm-split.md @@ -0,0 +1,475 @@ +# Slice 5 — Split the editor VM into per-group drafts, group the AXAML paths + +## Overview + +Slice 5 of the #118 debloat — the final slice; its PR closes #118. The monolithic 534-line +`GridStyleEditorViewModel` (77 leaf properties + `Seed` + `BuildRecord`, the last ×3 hand-maintained field +mirror in the stack) becomes a thin parent plus nine per-group `ReactiveObject` draft classes. A draft's +property initializers ARE the seed (each constructed from its group record), and its `Build()` calls the +group record's positional constructor — so an omitted field is a compile error (CS7036), replacing the +`Seed`-line/`with`-rebuild failure modes the slice-1 guards existed to catch. All 77 AXAML binding paths in +`GridStyleEditorWindow.axaml` rename to grouped `Group.Prop` form in the same PR, build-checked by the +window-level `x:DataType` (a bad path fails the build with AVLN2000 — verified empirically during roadmap +work). `IGridStyleEditorFacade` loses the vacuous `Validate` slice 4 deliberately parked; `CanSave` reduces +to the VM-side numeric range checks. The architecture doc is rewritten as current-state. + +Behavior-preserving for the operator: the editor looks and behaves identically — same window, same +controls, same save→restart-prompt→close flow, same error surfaces. Only the VM/AXAML internal structure +and the facade seam change. + +**Locked decisions — settled by the operator, do not re-open:** +- **Binding shape = grouped paths, one DataContext.** The parent VM keeps the window's single + `DataContext` and exposes each group draft as a property (`public GridStyleFontsDraft Fonts { get; }`). + All 77 paths rename to `{Binding Fonts.HeaderFontSize}` form in this PR — big-bang, compile-checked. NOT + per-card `DataContext`, NOT bridge properties. +- **`Validate` leaves `IGridStyleEditorFacade`** (interface + implementation + the per-keystroke call). + With typed `StyleColor` a color picker cannot produce an invalid color; nothing is left for a facade + `Validate` to check. `CanSave` = numeric ranges only. +- **Per-group `ReactiveObject` drafts**: initializer = seed, positional `Build()` = compile guard. +- **`Docs/architecture/grid-style-configuration.md` rewritten as current-state**, including deleting the + obsolete "Known gap" section (closed by slice 4's `OptionalColor` — every optional-section key IS + format-checked when present) and all slice-2/3/4 interim notes. + +**Decisions made in this plan (rationale inline, executor may not silently deviate):** +- **Draft leaf names mirror the group-record component names exactly** (`StatusBar.Weight`, not + `StatusBar.FontWeight`; `ValidationPanel.ErrorColor`, not `ValidationPanel.Error`). This makes the + record path derivable as `{ParentProperty}.{DraftProperty}` and kills the hand-maintained 77-entry + VM-property→record-path map in the guard tests (see the guard-net section). +- **`Build()` is strict — the numeric null-fallback dies.** Today `BuildRecord` falls back to + `_source` when a `decimal?` is null because `RecomputeCanSave` called `BuildRecord` per keystroke and a + NumericUpDown is transiently null while typing. After Task 1 removes the `Validate` call, `BuildRecord` + runs only on save (gated by `CanSave`, which requires all numerics non-null-in-range) and in tests. So + draft `Build()` converts strictly: `ToInt`/`ToDouble` throw `InvalidOperationException` on null. This + removes the drafts' need to retain their source record, which also sidesteps CS9124 (a primary-ctor + parameter both captured into a member AND used in initializers is a warning — and warnings are errors). + If an unexpected caller surfaces, the fallback alternative is an explicit + `private readonly` source field per draft (no CS9124), not silent `default` substitution. +- **`FontFamily` builds as `FontFamily ?? ""`** (empty string = theme default). The old fallback to the + source family is unreachable: the picker sources always contain the seeded value, so the bound string is + never nulled (existing contract, kept). +- **CanSave wiring**: the parent subscribes each draft's `Changed` observable into a + `CompositeDisposable` that is replaced when `LoadAsync` re-seeds (new drafts, new subscriptions; stale + drafts must not keep driving `CanSave`). The `_seeding` flag dies — seeding is construction, and + subscriptions attach after the drafts exist. + +Branch: `grid-style-vm-split` off `origin/master` (slices 1–4 are merged; no stacking this time). + +## The target shape + +### One draft (sketch — `DepthPaletteDraft`, used twice) + +`SemiStep/SemiStep.UI/StyleEditor/DepthPaletteDraft.cs` (naming rule: `Draft`, namespace +`SemiStep.UI.StyleEditor`, one class per file): + +```csharp +public sealed class DepthPaletteDraft(DepthPalette source) : ReactiveObject +{ + public Color Depth0 { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } + = source.Depth0.ToMediaColor(); + public Color Depth1 { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } + = source.Depth1.ToMediaColor(); + // ... Depth2, Depth3, Depth0Past..Depth3Past, Selected, Foreground — 10 total. + // A property WITHOUT an initializer is visibly unseeded in one screenful; a wrong + // source field is caught by the per-draft round-trip guard (see guard-net section). + + public DepthPalette Build() + { + return new DepthPalette( // positional — an omitted argument is CS7036 + Depth0: Depth0.ToStyleColor(), + Depth1: Depth1.ToStyleColor(), + // ... all 10 + Foreground: Foreground.ToStyleColor()); + } +} +``` + +Numeric-bearing drafts expose `decimal?` (NumericUpDown) and build strictly via a shared helper +(`SemiStep/SemiStep.UI/StyleEditor/DraftNumbers.cs`): `ToInt(decimal?)` rounds away-from-zero, +`ToDouble(decimal?)` casts; both throw `InvalidOperationException` naming the draft state on null (only +reachable if `Build` is called on a draft `CanSave` rejects). `source` appears ONLY in property +initializers — never in `Build` — so no primary-ctor capture and no CS9124. + +### The nine draft classes (77 leaves total) + +| Draft class (new file) | Parent property | Builds group record | Leaves | +|---|---|---|---| +| `GridStyleFontsDraft` | `Fonts` | `GridStyleFonts` | FontFamily (`string?`), HeaderFontSize/CellFontSize (`decimal?`), HeaderFontWeight/CellFontWeight (`int`), HeaderItalic/CellItalic (`bool`) — 7 | +| `GridStyleLayoutDraft` | `Layout` | `GridStyleLayout` | CellPaddingLeft/Top/Right/Bottom, RowHeight (all `decimal?`) — 5 | +| `SelectionColorsDraft` | `Selection` | `SelectionColors` | Background, Foreground — 2 | +| `ChangedCellColorsDraft` | `ChangedCells` | `ChangedCellColors` | Changed, ChangedSelected — 2 | +| `DepthPaletteDraft` | `ReadOnlyCells` AND `DisabledCells` | `DepthPalette` | Depth0–3, Depth0Past–3Past, Selected, Foreground — 10 (×2 uses) | +| `ExecutionPaletteDraft` | `Execution` | `ExecutionPalette` | Depth0–3, Depth0Past–3Past, CurrentStepMarker — 9 | +| `StatusBarStyleDraft` | `StatusBar` | `StatusBarStyle` | Background, Foreground (`Color`), Padding, ItemSpacing, FontSize, TimerLabelFontSize, TimerValueFontSize (`decimal?`), Weight, TimerLabelWeight, TimerValueWeight (`int`), Italic, TimerLabelItalic, TimerValueItalic (`bool`) — 13 | +| `ValidationPanelStyleDraft` | `ValidationPanel` | `ValidationPanelStyle` | Background, Foreground, ErrorColor, WarningColor (`Color`), MaxHeight (`decimal?`) — 5 | +| `ChromeColorsDraft` | `Chrome` | `ChromeColors` | Info, Connected, Disconnected, LocalMode, Connecting, PanelBackground, PanelHeaderBackground, SubtleBorder, Separator, SecondaryForeground, GridBorder, GridBackground, HeaderForeground, GridLine — 14 | + +7+5+2+2+10+10+9+13+5+14 = 77 — the full surfaced set; `Orientation` stays unsurfaced, carried by the +parent from `_source.Orientation`. + +### The parent VM (sketch) + +`GridStyleEditorViewModel` keeps: both constructors (same signatures — DI, `UIFixture`, and the window +tests construct it today), `SaveCommand` + `ThrownExceptions` routing, `ErrorMessage`, `CanSave`, +`LoadAsync`, `SaveAsync`, `ReportSaveException`, `LogCausedByExceptions`, the range constants +(`MinFontSize`…`MaxPanelMaxHeight` — referenced by tests), `AvailableFontFamilies`/`AvailableFontWeights` +and their builders, `NumericsInRange`/`InRange`, and the ten draft properties: + +```csharp +public GridStyleFontsDraft Fonts { get; private set => this.RaiseAndSetIfChanged(ref field, value); } +// ... Layout, Selection, ChangedCells, ReadOnlyCells, DisabledCells, Execution, +// StatusBar, ValidationPanel, Chrome — names mirror the root record's components. + +private void ReplaceDrafts(GridStyleOptions options) // ctor + successful LoadAsync +{ + Fonts = new GridStyleFontsDraft(options.Fonts); + // ... one line per group; wrong-group wiring here is caught by the + // Seed_ThenBuildRecord whole-record guard (distinct fixture). + AvailableFontFamilies = BuildFontFamilies(options.Fonts.FontFamily); + AvailableFontWeights = BuildFontWeights(options); + _draftSubscriptions.Dispose(); + _draftSubscriptions = new CompositeDisposable(/* each draft.Changed → RecomputeCanSave() */); + RecomputeCanSave(); +} + +public GridStyleOptions BuildRecord() +{ + return new GridStyleOptions( // positional — an omitted group is CS7036 + Fonts: Fonts.Build(), + Layout: Layout.Build(), + // ... all ten groups + Orientation: _source.Orientation); +} + +private void RecomputeCanSave() => CanSave = NumericsInRange(); // no facade call, no BuildRecord +``` + +`NumericsInRange` reads the grouped paths (`Fonts.HeaderFontSize`, `Layout.RowHeight`, +`StatusBar.Padding`, …, `ValidationPanel.MaxHeight`) — same 13 checks, same bounds. Deleted from the +parent: the 77 leaf properties, `Seed`, `SetColor`/`SetNumber`/`SetValue`, `_seeding`, `ToInt`/`ToDouble` +(moved strict into `DraftNumbers`). Honest size accounting: the parent lands around 250 lines, not the +roadmap's ~150 — the picker-source builders (~55 lines) and the range checks stay parent-side; the ×3 +mirror (≈240 lines of Seed/BuildRecord/property declarations) is what dies. The structural claim is not +the line count; it is that no hand-maintained full-field list remains whose omission compiles. + +### The AXAML rename (77 paths, same file structure) + +`GridStyleEditorWindow.axaml` keeps `x:DataType="styleEditor:GridStyleEditorViewModel"` and every +control/layout element; only `{Binding …}` path strings change. Rule: new path = +`{ParentProperty}.{RecordComponentName}`. The renames, by group — divergent names in bold: + +| Group | Old flat path → new grouped path | +|---|---| +| Layout (5) | `RowHeight`→`Layout.RowHeight`; `CellPaddingLeft/Top/Right/Bottom`→`Layout.CellPadding*` | +| Fonts (7) | `HeaderFontSize/HeaderFontWeight/HeaderItalic/CellFontSize/CellFontWeight/CellItalic`→`Fonts.*`; `FontFamily`→`Fonts.FontFamily` | +| Selection (2) | **`SelectionBackground`→`Selection.Background`**, **`SelectionForeground`→`Selection.Foreground`** | +| ChangedCells (2) | **`CellChanged`→`ChangedCells.Changed`**, **`CellChangedSelected`→`ChangedCells.ChangedSelected`** | +| ReadOnlyCells (10) | **`ReadOnlyCellDepth0`→`ReadOnlyCells.Depth0`** … `Depth3Past`, **`ReadOnlyCellSelected`→`ReadOnlyCells.Selected`**, **`ReadOnlyCellForeground`→`ReadOnlyCells.Foreground`** | +| DisabledCells (10) | same pattern: `DisabledCell*`→`DisabledCells.*` | +| Execution (9) | `ExecutionDepth0..3(,Past)`→`Execution.Depth*`; `ExecutionCurrentStepMarker`→`Execution.CurrentStepMarker` | +| StatusBar (13) | `StatusBarBackground/Foreground/Padding/ItemSpacing/FontSize`→`StatusBar.*`; **`StatusBarFontWeight`→`StatusBar.Weight`**; **`StatusBarItalic`→`StatusBar.Italic`**; `StatusBarTimerLabelFontSize`→`StatusBar.TimerLabelFontSize`; **`StatusBarTimerLabelFontWeight`→`StatusBar.TimerLabelWeight`**; **`StatusBarTimerLabelItalic`→`StatusBar.TimerLabelItalic`**; same trio for TimerValue | +| ValidationPanel (5) | `ValidationPanelBackground/Foreground`→`ValidationPanel.*`; **`ValidationPanelError`→`ValidationPanel.ErrorColor`**; **`ValidationPanelWarning`→`ValidationPanel.WarningColor`**; `ValidationPanelMaxHeight`→`ValidationPanel.MaxHeight` | +| Chrome (14) | **`GridBackground/GridBorder/GridLine/HeaderForeground/PanelBackground/PanelHeaderBackground/SubtleBorder/Separator/SecondaryForeground/Info/Connected/Disconnected/LocalMode/Connecting` → `Chrome.`** | + +Caution — chrome colors do not all sit in one card: the `Info` picker lives in the notification-panel +card, and `Connected`/`Disconnected`/`LocalMode`/`Connecting` live in the status-bar Colors card (AXAML +~lines 409–420) — yet ALL group under `Chrome.*` because grouping follows the RECORD component, not the +visual card. An executor counting the status-bar card's pickers and expecting six `StatusBar.*` paths +would miswire four of them; the derive-by-record-component rule is authoritative. + +Unchanged paths: `SaveCommand`, `ErrorMessage` (×2), `AvailableFontWeights`/`AvailableFontFamilies` +(parent-level), the item-template bindings (`Name`, `Value` against `FontWeightOption`/ +`FontFamilyOption`), and the `$parent[ColorPicker]` template bindings. Compiled bindings resolve chained +INPC paths per segment, so replacing a whole draft on re-seed re-evaluates every `Group.Prop` binding when +the parent raises change for the group property. + +### Visual safety — screenshot project is NOT the guard + +`SemiStep.Screenshots` is excluded from `SemiStep.slnx` (verified: no `Screenshots` entry) and its +`GridStyleEditorScreenshotTests` is already broken at HEAD independent of this slice — it constructs the +VM via reflection with the removed 3-argument constructor +(`Activator.CreateInstance(viewModelType, [facade, Path.GetTempPath(), gridStyle])`; the logger parameter +added since makes that a runtime `MissingMethodException`). Do not rely on it and do not repair it in this +PR. The visual-safety claim rests on: (a) the AXAML diff touches only `{Binding …}` path strings — zero +layout/control changes; (b) every path is compile-checked (AVLN2000); (c) the headless tests that +construct and show the REAL `GridStyleEditorWindow` (`GridStyleEditorWindowOwnerRoutingTests`, including +`SaveThenExitNow_DrivesRealGlue…` with a real VM as DataContext) load the compiled AXAML and run the full +save→restart-prompt→close flow. + +## The guard-net transition + +The slice-1 guards were built around the monolith: reflection over 77 flat VM properties, a +hand-maintained property→nested-path map, `Seed`-populates + perturbation guards catching the +`with`-rebuild's silent revert. Slice 5 eliminates `Seed` and the `with`-rebuild, so part of the net is +superseded by the compiler and the rest must be retargeted — nothing is silently dropped. The whole #118 +thesis lands here: after this slice, a dropped or miswired field in any draft is a compile error or a red +test, enumerated per failure mode: + +| # | Failure mode | Before slice 5: caught by | After slice 5: caught by | +|---|---|---|---| +| 1 | Field omitted from a draft's `Build()` (or group omitted from `BuildRecord`) | perturbation guard at runtime (the `with`-rebuild silently reverted it) | **compile error CS7036** — positional constructors | +| 2 | Draft property has no initializer (unseeded → type default) | `Seed_PopulatesEverySurfacedProperty` | per-draft round-trip test red (`default(Color)`/null ≠ distinct fixture value) | +| 3 | Draft initializer reads the WRONG source field (seed cross-wire) | `Seed_PopulatesEverySurfacedProperty` | per-draft round-trip test red (built group ≠ source group, distinct fixture) | +| 4 | `Build()` passes the wrong draft property into a positional slot (build cross-wire, same type) | `BuildRecord_PerturbingEachProperty` | adapted perturbation guard red (perturbed leaf changes the wrong record leaf); round-trip also red unless the swap is symmetric | +| 5 | Symmetric double swap (initializers AND `Build` swap the same pair — round-trip is clean) | perturbation guard caught the Build side | adapted perturbation guard red on the Build side; a correct Build direction + clean round-trip then implies a correct seed direction | +| 6 | bool↔bool seed cross-wire between two same-valued italics | structurally invisible (documented slice-1 residual: all five fixture italics are `true`) | **still invisible to the round-trip — residual persists, unchanged.** The Build direction stays pinned by the perturbation guard; the seed side now sits one line from its source field in a ≤14-line class instead of a 77-line `Seed`, so review catches it. Accepted again, documented again | +| 7 | Parent wires the wrong group record into a draft (e.g. `source.DisabledCells` into the `ReadOnlyCells` slot — both `DepthPalette`) | n/a (`Seed` read leaf-by-leaf) | `Seed_ThenBuildRecord_PreservesEveryFieldDistinctly` red (whole-record equality; the two fixture palettes are distinct) | +| 8 | `Orientation` dropped or defaulted | `with`-carry + integration guard | CS7036 (root positional ctor) + the same integration guard (fixture uses non-default `ColumnsAsSteps`) | +| 9 | AXAML path stale, mistyped, or pointing at a removed property | AVLN2000 | AVLN2000 (unchanged — `x:DataType` stays) | +| 10 | AXAML binds a valid-but-wrong sibling path (e.g. `ReadOnlyCells.Depth2` on the Depth1 picker) | not caught (no UI-automation guard existed) | not caught — pre-existing residual, unchanged and out of scope | +| 11 | A leaf silently dropped from the editor surface | `SurfacedEditablePropertyCount = 77` pin | pin retargeted: sum of leaf properties across the ten draft instances == 77 (plus CS7036 in `Build` and AVLN2000 for the binding make a silent drop triply loud) | +| 12 | Mapper drop/cross-wire on the save or load side | `SaveThenLoad_DistinctFixture_PreservesEveryMappedField` | same test, untouched — it exercises the facade + mappers, not the VM | +| 13 | Re-seed leaves `CanSave` wired to discarded drafts | n/a (`Seed` mutated properties in place) | new test: after a successful `LoadAsync`, an out-of-range edit on the NEW draft flips `CanSave` false | + +**What each old guard becomes:** + +- `Seed_PopulatesEverySurfacedProperty_FromDistinctFixture` — **deleted, replaced** by the per-draft + round-trip guards (rows 2–3): for each draft class, `new (fixtureGroup).Build()` must equal + `fixtureGroup` (record value equality — exhaustive per field because every fixture value is distinct and + exactly representable through `decimal`). `DepthPaletteDraft` is asserted against BOTH fixture palettes. + One test per draft class, so a red names the broken draft. +- `BuildRecord_PerturbingEachProperty_ChangesOnlyThatMappedField` — **survives, adapted**: enumerate the + parent's draft properties by reflection (public instance properties whose type derives from + `ReactiveObject` — exactly the ten), then each draft's public get/set leaf properties; perturb one leaf + (same `Perturb` helper), `BuildRecord()`, assert the changed-leaf set == `{ "{Group}.{Leaf}" }` via the + existing typed leaf walk (`ChangedRecordFields` — kept as-is), restore. The **hand-maintained 77-entry + path map is deleted**: the record path is now derived as `{ParentProperty}.{DraftProperty}` because + draft leaf names mirror record component names by design. The derivation is sound because a mismatched + name cannot hide — it makes the round-trip guard's record equality fail (wrong/no field written) or the + leaf walk report a different path. The 77 count pin moves into this test (sum over all ten drafts). +- `Seed_ThenBuildRecord_PreservesEveryFieldDistinctly` — **survives verbatim** (row 7 + 8): still + `CreateViewModel(Distinct()).BuildRecord() == Distinct()`; now the guard for the parent's group wiring + and the `Orientation` carry. +- `SaveThenLoad_DistinctFixture_PreservesEveryMappedField` — **survives untouched** (lives in + `GridStyleEditorFacadeTests`, VM-free). + +**Non-vacuity is re-proven, not assumed** (Task 4): a deliberately miswired draft initializer must turn +the round-trip guard red, and a deliberately miswired `Build` slot must turn the perturbation guard red, +before the guards are trusted — same protocol as slices 3/4. + +## Acceptance Evidence + +- `IGridStyleEditorFacade` declares exactly `Load` and `Save`; `git grep -n "Validate" -- '*GridStyle*'` + finds no facade validate member, call, or fake override. +- The nine draft classes exist (initializer-seed, strict positional `Build()`, leaf names mirroring the + group records); the parent VM has zero leaf properties, no `Seed`, no `with`-rebuild, and `BuildRecord` + is ten `Build()` calls plus the `Orientation` carry. +- `GridStyleEditorWindow.axaml` contains no flat leaf path — every editable-field binding is + `Group.Prop`; the AXAML diff is binding-path-only (no element/layout changes); the build passes, which + per `x:DataType` + AVLN2000 proves all 77 paths resolve. +- Guard net green and re-proven: per-draft round-trips (one per draft class, `DepthPaletteDraft` ×2), + adapted perturbation guard with derived paths + 77-leaf count pin, `Seed_ThenBuildRecord…` verbatim, + `SaveThenLoad_DistinctFixture…` untouched, the new re-seed `CanSave` rewiring test; scratch checks + recorded showing a miswired initializer and a miswired `Build` slot each go red. +- The headless window tests (`GridStyleEditorWindowTests`, `GridStyleEditorWindowOwnerRoutingTests`, + `MainWindowStyleEditorInteractionTests`) are green — the real window loads the renamed AXAML and drives + the real save flow; the RU-culture load-error message test passes untouched. +- `Docs/architecture/grid-style-configuration.md` describes the current state only: nested record, + `StyleColor`, mapper-resident validation, draft-based editor; no "interim", no "slice N", no + "Known gap" section. +- `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format` clean. + +## Task 1: Drop `Validate` from the facade seam (standalone green commit) + +Small, self-contained, and it makes Task 3 safe: with the per-keystroke +`Validate(BuildRecord())` call gone, `BuildRecord` is no longer invoked on every edit, which is the +precondition for the drafts' strict `Build()` (no numeric null-fallback). + +**Files:** `SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs`, +`GridStyleEditorFacade.cs`, `SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs`, +`SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs`, +`SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs`. + +- [x] Remove `Validate` from `IGridStyleEditorFacade` and `GridStyleEditorFacade`; update both XML docs + (the facade seam is now `Load` + `Save`; the "stays until slice 5" comment dies with the method). +- [x] `GridStyleEditorViewModel.RecomputeCanSave` → `CanSave = NumericsInRange();` (drop the + `_gridStyleEditorFacade.Validate(BuildRecord())` conjunct). Update the class XML doc sentence about + `CanSave` gating. Nothing else in the VM changes in this task. +- [x] Delete `Validate_ShippedRecord_ReturnsOk` from `GridStyleEditorFacadeTests` (the guarantee it + tested is now structural: a loaded record is valid by construction). Remove the `Validate` + overrides from the `ThrowingFacade` and `CausedByFailingFacade` fakes in + `GridStyleEditorViewModelTests`. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`. One commit. + (The architecture doc still mentions `Validate` until Task 4 — acceptable inside one PR.) + +## Task 2: The nine draft classes + per-draft round-trip guards (standalone green commit) + +The drafts compile and are fully guard-tested before anything consumes them — the switchover commit then +contains no new field-mapping code, only wiring and renames. The drafts are production-unused for one +commit inside the PR; that is deliberate (no warning results — unused public classes are clean). + +**Files (create):** `SemiStep/SemiStep.UI/StyleEditor/GridStyleFontsDraft.cs`, `GridStyleLayoutDraft.cs`, +`SelectionColorsDraft.cs`, `ChangedCellColorsDraft.cs`, `DepthPaletteDraft.cs`, +`ExecutionPaletteDraft.cs`, `StatusBarStyleDraft.cs`, `ValidationPanelStyleDraft.cs`, +`ChromeColorsDraft.cs`, `DraftNumbers.cs`; +`SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleDraftTests.cs`. + +- [x] Author the nine drafts per the sketch and the class table: `public sealed class Draft( source) : ReactiveObject`; + every leaf is a `field`-backed property with `this.RaiseAndSetIfChanged` setter AND an initializer + reading its same-named source component (`Color` via `.ToMediaColor()`, `decimal?` via `(decimal)` + cast for `double` sources / implicit for `int`, `int`/`bool`/`string?` direct); `Build()` constructs + the group record positionally with argument names spelled out (`Depth0: …`), colors via + `.ToStyleColor()`, numerics via `DraftNumbers.ToInt`/`ToDouble` (strict, away-from-zero rounding as + today), `FontFamily ?? ""`. `source` must appear only in initializers (CS9124 discipline). +- [x] `DraftNumbers`: internal static, `ToInt(decimal? value)` / `ToDouble(decimal? value)` throwing + `InvalidOperationException` on null (message naming the guard: build called on an invalid draft). +- [x] `GridStyleDraftTests` (`[Trait("Component","UI")] [Trait("Category","Unit")]`; plain `[Fact]` — the + drafts touch no Avalonia services, only the `Color` struct. Note: this is the first time drafts run + outside the headless dispatcher; constructing a `ReactiveObject` and raising changes with no + subscribers needs no scheduler, and this task's first test run confirms it — if RxApp + initialization complains anyway, fall back to `[AvaloniaFact]`): one round-trip test per draft class, + `new (fixture.).Build().Should().Be(fixture.)` against + `GridStyleOptionsTestData.Distinct()`; `DepthPaletteDraft` asserted against BOTH `ReadOnlyCells` + and `DisabledCells` fixtures. Field-exhaustive by construction: record value equality over + all-distinct fixture values. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green (new guards included); + `dotnet format`. One commit. + +## Task 3: The switchover — parent VM + 77 AXAML paths + guard adaptation (one atomic commit) + +This is the ripple unit and it is indivisible: the AXAML paths reference the parent's draft properties, +the parent's draft properties replace the 77 leaves the old AXAML and tests bind, and the guard tests +reflect over whichever surface exists. A partial state does not compile (AVLN2000 or CS1061 either way), +exactly like the earlier slices' atomic type-flips. Production and tests reach green in the same commit. + +**Files (production):** rewrite `SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs` (per the +parent sketch); modify `SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorWindow.axaml` (the 77 renames per +the table — nothing else). `GridStyleEditorWindow.axaml.cs`, `UiDi.cs`, `MainWindowViewModel`: untouched +(VM constructor signatures unchanged). + +**Files (tests):** `GridStyleEditorViewModelTests.cs` (guard adaptation + every leaf-property access), +`GridStyleEditorWindowTests.cs` (leaf setters). `GridStyleEditorWindowOwnerRoutingTests`, +`MainWindowStyleEditorInteractionTests`, `UIFixture`: construct the VM only — verify untouched. Grep the +solution for any other leaf-property access; the build gate is the ground truth. + +- [x] Rewrite the parent VM: ten reactive draft properties (names = root record components); `ReplaceDrafts(options)` + called from the constructor and from successful `LoadAsync` (builds drafts + picker sources, swaps + the `CompositeDisposable` of `draft.Changed → RecomputeCanSave()` subscriptions, recomputes); + `BuildRecord` = positional root construction from ten `Build()` calls + `Orientation: _source.Orientation`; + `NumericsInRange` reads grouped paths; delete the 77 leaf properties, `Seed`, the three `Set*` + helpers, `_seeding`, `ToInt`/`ToDouble`. Constructors, `SaveCommand`/`SaveAsync`/`ErrorMessage`/ + `LoadAsync` failure path/logging: behavior-identical. +- [x] Rename all 77 AXAML binding paths to grouped form per the table. Touch only `{Binding …}` strings; + `x:DataType`, resources, styles, layout stay byte-identical. The build is the path check (AVLN2000). +- [x] Adapt `GridStyleEditorViewModelTests`: + - Perturbation guard: reflect the ten draft properties (type derives from `ReactiveObject`), walk + each draft's public get/set leaves, perturb → `BuildRecord` → changed-leaf set equals the derived + `{Group}.{Leaf}` path → restore. Assert total leaf count == `SurfacedEditablePropertyCount` (77). + Delete the `_viewModelToRecordPath` map and `RecordPath`; keep `Perturb`, `ChangedRecordFields`, + `CollectChangedLeaves`, `IsLeaf` as-is. + - Delete `Seed_PopulatesEverySurfacedProperty_FromDistinctFixture` and its `LeafValue` helper — + replaced by Task 2's per-draft round-trips (state this in the commit message). + - Keep `Seed_ThenBuildRecord_PreservesEveryFieldDistinctly` verbatim (it now guards group wiring + + orientation carry). + - Retarget ALL remaining `viewModel.` accesses to grouped paths (grep the solution; the + build gate is the ground truth — no named test is implied out of scope): + `CellFontSize`→`Fonts.CellFontSize`, `SelectionBackground`→`Selection.Background`, + `StatusBarFontSize`→`StatusBar.FontSize`, `RowHeight`→`Layout.RowHeight`, + `FontFamily`→`Fonts.FontFamily`, etc. Known affected (non-exhaustive): the three CanSave + font-size theories, `CanSave_IsFalse_WhenFontSizeOutOfRange`, `CanSave_IsFalse_WhenNumericIsNull`, + `CanSave_IsFalse_WhenRowHeightOutOfRange`, `BuildRecord_AfterEditingColorAndFontSize…`, + `BuildRecord_RoundsFractionalStatusBarFontSize_ToNearestInt`, + `Seed_PopulatesFontFamilyWeightAndItalic_FromRecord`, + `BuildRecord_CarriesEditedFontFamilyWeightAndItalic`, both `ToHex_…` tests, and + `LoadAsync_MissingConfigDir_SetsErrorMessageAndDoesNotReseed`. + - Add the re-seed rewiring test (guard row 13): facade fake whose `Load` succeeds with a distinct + record → `await LoadAsync()` → drafts replaced (e.g. `Fonts.HeaderFontSize` equals the loaded + value) → set `Fonts.HeaderFontSize = 999` on the NEW draft → `CanSave` false. +- [x] Adapt `GridStyleEditorWindowTests`: `viewModel.SelectionBackground`→`viewModel.Selection.Background`, + `viewModel.CellFontSize`→`viewModel.Fonts.CellFontSize` (both tests otherwise unchanged — they are + the real-file save/round-trip and byte-identity proofs). +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green — including the untouched + RU-culture error-message test, the owner-routing window tests over the renamed AXAML, and + `SaveThenLoad_DistinctFixture…`; `dotnet format`. One commit. + +## Task 4: Non-vacuity proofs + architecture-doc rewrite (closes the roadmap) + +- [x] **Guard non-vacuity scratch checks (do NOT commit any break; record each result in the progress + log):** + - Miswire a draft initializer (`Depth0 { … } = source.Depth1.ToMediaColor();` in + `DepthPaletteDraft`) → the per-draft round-trip test goes RED. Revert. + - Miswire a `Build` slot (`Depth0: Depth1.ToStyleColor()`) → the adapted perturbation guard goes + RED for that leaf (and the round-trip too). Revert. + - Delete one argument from a draft's `Build()` → CS7036 compile error (the omission-is-compile-error + claim, demonstrated). Revert. + - Mistype one AXAML path (`Fonts.HeaderSizeX`) → AVLN2000 build error. Revert. + If the initializer miswire does NOT go red, the fixture or the round-trip is broken — re-open + Task 2, do not paper over. +- [x] Rewrite `Docs/architecture/grid-style-configuration.md` as current-state — strip ALL interim + tenses; each item below is present in the doc today and must be individually resolved (verify none + is missed with `grep -n "slice\|interim\|Known gap\|with.*rebuild\|Validate" Docs/architecture/grid-style-configuration.md` + returning only intentional survivors): + - `**Interim (slice 4).**` paragraph (~lines 66–70): fold into the load-pipeline prose — typed + `StyleColor`, mapper-resident aggregated validation described as the design, no slice numbers. + - `## Record shape: nested per group (interim, slice 3)` (~lines 72–86): retitle and rewrite as + the permanent record-shape section; drop the "Colors were `string` at slice 3" history and the + "corrected in the slice-5 doc rewrite" promise (this IS that rewrite). + - `**Note for slice 4.**` (~lines 88–94): keep the substance (the `colors.grid_line` error key vs + `Chrome.GridLine` record path divergence is permanent and load-bearing) but rewrite it as a + current-state note, no slice framing. + - The orientation section's `BuildRecord`'s-`with`-rebuild sentence (~line 149): replace with the + positional construction carrying `_source.Orientation`. + - The editor write-back ViewModel bullet (~lines 242–256), including its `with`-rebuild closing + sentence: describe the draft-based editor — thin parent exposing per-group `ReactiveObject` + drafts, initializers seed from the group records, `Build()` constructs positionally (omission is + a compile error), AXAML binds grouped compiled-checked paths, `CanSave` is the VM-side numeric + range checks only. + - The facade bullet's `Validate` description (~lines 261–267): the seam is `Load`/`Save`; remove + every `Validate` mention including "stays on the interface until slice 5". + - Flat-path spellings the slice-3 note deferred (`GridStyleOptions.FontFamily` → + `GridStyleOptions.Fonts.FontFamily`, etc.). + - **Delete the `## Known gap` section** (~lines 300–309) — obsolete: since slice 4 the mapper's + `OptionalColor` format-checks every optional-section key (selection, changed cells, grid line, + status bar, validation panel, chrome) when present; a hand-edited invalid hex in any key now + fails the load with a per-key error. +- [x] Update `Docs/plans/20260805-grid-style-debloat-roadmap.md` slice statuses: slices 3, 4, 5 → DONE + (3: PR #177; 4: `grid-style-color-typing`; 5: this PR), and note #118 closes with slice 5. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`. One commit. + +## Post-Completion + +**This PR closes #118** — the last hand-maintained full-field list in the grid-style stack is gone. +End state across the five slices: nested positional record (compile-checked construction), typed +`StyleColor` (invalid colors unrepresentable), mapper-resident aggregated validation (per-key error +identity preserved), async save, and a draft-based editor where a dropped field is CS7036/AVLN2000 and a +miswired one is a red guard. + +- Branch `grid-style-vm-split` bases on `origin/master` (slices 1–4 merged) — no stacking. +- PR body carries `Closes #118`; commit scope `grid-style` per the repo grammar. +- Known accepted residuals, documented in the guard-net section: the bool↔bool seed cross-wire between + same-valued italics inside one draft (invisible to the round-trip, Build direction still pinned, seed + line now adjacent to its source in a small class), and a valid-but-wrong sibling AXAML path (never + guarded, unchanged). +- `SemiStep.Screenshots` remains excluded from the solution and its editor screenshot harness remains + stale (pre-existing: reflection against a removed VM constructor overload). If it is ever revived, its + `Activator.CreateInstance` call needs the logger argument — out of scope here. + +**Executed by exec:** +- branch: grid-style-vm-split + +## Verify it yourself + +This slice is a pure structural refactor of the editor: the window looks and behaves identically, the +on-disk YAML is unchanged, and no operator-visible behaviour moves. There is no manual repro that +distinguishes before from after — the evidence is the compiler, the guard tests, and the diff. + +1. **The editor still builds and every binding path resolves.** `dotnet build SemiStep.slnx` → 0 warnings, + 0 errors. The build IS the AXAML path check: Avalonia's compiled bindings (`x:DataType` + AVLN2000) + fail the build on any of the 77 grouped `{Binding Group.Leaf}` paths that does not resolve against the + parent's draft properties. +2. **Field omission is now a compile error, and miswiring is a red test** — the #118 thesis. Proven by the + Task-4 non-vacuity scratch checks (recorded in the progress log, all reverted): dropping a `Build()` + argument → CS7036; mistyping an AXAML path → AVLN2000; miswiring a draft initializer → the per-draft + round-trip test red; miswiring a `Build()` slot → the perturbation guard red. +3. **The guard net covers all 77 leaves.** `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~GridStyleDraft|FullyQualifiedName~GridStyleEditorViewModelTests"` + — the nine per-draft round-trips (`new (fixture.Group).Build() == fixture.Group`, DepthPaletteDraft + against both palettes) plus the perturbation guard that pins the walked leaf count at 77. +4. **Behaviour is preserved.** The untouched suites stay green: the Russian-culture malformed-hex end-to-end + message, the owner-routing window tests over the renamed AXAML, `SaveThenLoad_DistinctFixture`, and the + re-seed rewiring test (`LoadAsync_Success_ReplacesDraftsAndRewiresCanSave`). Facade seam is `Load`/`Save` + only — `git grep -n "Validate" -- SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs` is empty. +5. **Full gate:** full `dotnet test` → 1727 passed; `dotnet format` clean. (One unrelated GC-sensitive + allocation probe, `RecipeGridSurfaceContractTests.AppendStep_PerSurfaceTailAllocation…`, flaked on one + review run — outside this branch's surface; re-run if CI trips it.) +6. **Manual smoke (optional):** open the style editor, edit a value in each group (font size, a color, + orientation, a status-bar timer weight), save, reopen — every value persists and the window is visually + unchanged from before the split.