Skip to content

Commit 522c940

Browse files
spec(047): Phase 3-final descriptor scale-out (Batches A-G1) (#436)
* spec(047): Phase 3-final Batch A — engine shapes for descriptor scale-out Lands the new builder/entry/strategy surface the Phase 3 bulk-port session (PR #435) was constrained from inventing. Subsequent batches port the remaining controls against these shapes. New shapes (all [Experimental("REACTOR_V1_PREVIEW")]): - `.OneWayBridged<TValue>` — set lambda receives Reconciler + rerender, for descriptors that bridge to engine-internal helpers (Flyout, future bridged transforms). PropEntry base grows virtual context-carrying Mount/Update overloads; existing entries forward to the parameterless overloads via the virtual default. - `.Immediate<TPayload>` — pure subscription wiring for the "observed-DP property-changed callback + Loaded → inner template-part trampoline" pattern. Used by NumberBox's per-keystroke immediate-mode. Author supplies captured-free static callbacks; the entry manages per-control payload slot bookkeeping. No DP write (the sibling .HandCodedControlled handles the commit-mode round-trip). - `.CollectionDiffControlled<TPayload,TItem,TKey,TDelegate>` — two-way bound IList<T> prop (e.g. CalendarView.SelectedDates). Each Update applies a hash-set diff and emits per-item Add/Remove inside ChangeEchoSuppressor.BeginSuppress. - `Panel<>.PerChildAttached` — additive optional callback invoked after each child mount/reconcile. Lets Grid/Canvas/Flex/RelativePanel/WrapGrid descriptors write WinUI attached DPs (Grid.SetRow, Canvas.SetLeft, ...) based on attached-prop hints on the child element. Default null = no-op; StackPanel etc. unaffected. - `ItemsHost<TElement,TControl>` rewrite — new shape with GetItems (IReadOnlyList<object>) + GetCollection (IList) + optional ItemEquals. V1HandlerAdapter now dispatches it: clear + populate on Mount, ItemEquals-gated rebuild on Update. Targets ListBox/ComboBox.Items/ RadioButtons items (Batch G1). Typed templated lists (ListView<T> etc., Batch G2) keep delegate-body handlers with internal spec-042 keyed reconcile until the typed ports land their own descriptors. - `Reconciler.CreateFlyoutForDescriptor` — null-tolerant sibling of CreateFlyoutFromElement, mirrors the ResolveIconForDescriptor naming for descriptor-facing bridges (Batch D). NumberBoxEventPayload extended with ImmediateTextChangedCallback + ImmediateInnerTextChangedTrampoline + ImmediateInnerWired flag slots for the NumberBox descriptor port (Batch B). ListViewHandler drops the placeholder ItemsHost strategy declaration (Children => null). The delegate body already owns all dispatch via MountListView/UpdateListView; declaring a strategy on top would double-handle. ItemsHost is now reserved for descriptor authors of flat items collections and the typed ListView<T> port in Batch G2. Self-test V1 ON/OFF Desc_ filter: # Total failures: 0 (50 descriptors unchanged). 7 new surface-level unit tests in tests/Reactor.Tests/Spec047/V1Protocol/Phase3FinalEntryShapesTests.cs; existing ChildrenStrategyTests / ListViewPortTests migrated to the new ItemsHost / Children=null shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3-final Batch B — Frame + RichTextBlock + NumberBox Ports three descriptors against the Batch A engine shapes: - FrameDescriptor uses .Initial<(Type?, object?)> for the mount-only Navigate call (no-op when SourcePageType is null; never re-navigates on Update — matches legacy UpdateFrame) and .HandCodedEvent x3 for Navigated/Navigating/NavigationFailed against a new FrameEventPayload. - RichTextBlockDescriptor uses a single .OneWay<RichTextBlockElement> with a custom ReferenceEqualityComparer that gates rebuild on the Paragraphs array reference (+ Text fallback for the no-Paragraphs case). The set lambda calls the shared Reconciler.RebuildRichTextBlocks helper (widened internal static, also invoked by the legacy MountRichTextBlock arm). - NumberBoxDescriptor uses .HandCodedControlled for the Value/ ValueChanged round-trip plus .Immediate<NumberBoxEventPayload> for the per-keystroke text observation (TextProperty + Loaded → inner TextBox via the existing NumberBoxImmediateTextChanged + EnsureNumberBoxImmediateTextBoxWiring helpers, widened internal static for descriptor sharing). Documented gaps: - FrameDescriptor's three .HandCodedEvent entries gate subscription on the callback being present at Mount time (standard contract). Legacy MountFrame subscribes unconditionally so a late-attached callback fires through the same trampoline; descriptor matches the established §14 EnsureXxxWiring contract — fully covers the callback-on-mount common case. - RichTextBlockDescriptor uses reference-equality on Paragraphs; the legacy UpdateRichTextBlock arm does an incremental per- paragraph / per-inline diff. Authors who need that incremental shape stay on V1 OFF. Static-hoisted arrays skip rebuild entirely. - NumberBoxDescriptor Min/Max are plain .OneWay (no .CoercingOneWay) so a Min/Max widen that coerces Value echoes through OnValueChanged — legacy arm wraps coercion in BeginSuppress. Authors needing coercion-tolerance opt in to .CoercingOneWay later. - NumberBoxDescriptor's Value entry doesn't consult nb.Text before writing — the legacy arm's ImmediateValueAttached "skip Value write when typed text is non-canonical" path stays V1 OFF for now. Self-test V1 ON Desc_ filter: all 6/6 Frame + 7/7 RichTextBlock + 15/15 NumberBox checks pass; total failures 0. V1 OFF Desc_ filter: total failures 0 (legacy RichTextBlock rebuild path verified intact after MountRichTextBlock was switched to the shared helper). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3-final Batch C — CalendarView via .CollectionDiffControlled Ports CalendarView as the proof point for Batch A's new .CollectionDiffControlled<TPayload,TItem,TKey,TDelegate> entry shape. - SelectedDates IObservableVector<DateTimeOffset> is two-way bound via the new entry — Mount fills the vector bare; Update applies a UtcTicks-keyed hash-set diff inside one BeginSuppress so per-mutation echo can'\''t fire back through OnSelectedDatesChanged. - New CalendarViewEventPayload (single SelectedDatesChangedTrampoline slot) added to ControlEventPayloads.cs. - All other props as .OneWay / .OneWayConditional matching the legacy arm'\''s classification (SelectionMode, IsGroupLabelVisible, IsOutOfScopeEnabled, NumberOfWeeksInView, DisplayMode, CalendarIdentifier, Language gated on IsWellFormed, MinDate, MaxDate, FirstDayOfWeek). Documented gaps: - Legacy UpdateCalendarView treats null SelectedDates as "uncontrolled, don'\''t reconcile"; the descriptor projects null -> Array.Empty so flipping from non-null back to null clears the vector. Callsite expectation: always pass a list (possibly empty) when selection is controlled. - Legacy MountCalendarView subscribes to SelectedDatesChanged unconditionally; descriptor gates subscription on OnSelectedDatesChanged being present at Mount (standard §14 EnsureXxxWiring contract). Self-test V1 ON/OFF Desc_ filter: 438/438 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3-final Batch D — Button-family Flyout via .OneWayBridged Wires DropDownButton, SplitButton, and ToggleSplitButton descriptors to use Batch A's .OneWayBridged<TValue> entry shape + the null-tolerant Reconciler.CreateFlyoutForDescriptor sibling helper. Closes the "Flyout escape-hatched" known gap on all three descriptors. Each gets a single .OneWayBridged<Element?> entry whose set lambda calls (rec, rr) => c.Flyout = rec.CreateFlyoutForDescriptor(v, rr). Reference-equality comparer (per-descriptor private ElementReferenceComparer, matches the GridDescriptor.GridDefinitionReferenceComparer pattern) — the rebuild only fires when the Flyout Element reference changes; EqualityComparer<Element?>.Default would do record-style structural equality and could miss content swaps that produced a structurally equal Element. Three new self-test fixtures (Desc_DropDownButton_Flyout, Desc_SplitButton_Flyout, Desc_ToggleSplitButton_Flyout) with 12 total checks covering Attached, NullOnNullInput, SwappedOnReconcile, and PreservedOnSameRef across all three descriptors. Documented gaps: - ReferenceEqualityComparer.Instance (System.Collections.Generic) types as IEqualityComparer<object?>, not IEqualityComparer<Element?>, so each descriptor declares a private ElementReferenceComparer rather than sharing a single static instance. Acceptable — mirrors the existing GridDescriptor.GridDefinitionReferenceComparer pattern. A future cleanup could hoist this to a shared internal helper if more descriptors need reference identity over Element?. Self-test V1 ON/OFF Desc_ filter: 450/450 pass on both modes (was 438/438 + 12 new flyout checks = 450). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3-final Batch E — Panel per-child attached props + WrapGrid Wires Grid/Canvas/FlexPanel descriptors to use Batch A's additive Panel<>.PerChildAttached callback. Each panel descriptor declares a captured-free set lambda that reads the child element's attached-prop hint (GridAttached / CanvasAttached / FlexAttached) and writes the corresponding WinUI attached DP. Mirrors what the legacy MountXxx arms do after each child mounts; closes the "descriptor-mounted children stack at row 0 / column 0" known gap from PR #435 batch 8. WrapGridDescriptor (new) — closes the Phase 3 batch 8 escape-hatch ("WrapGrid escape-hatched (needs per-child attached-prop hook)"). Mirrors MountWrapGrid: Orientation always written; MaximumRowsOrColumns / ItemWidth / ItemHeight via .OneWayConditional; per-child WrapGridAttached (RowSpan / ColumnSpan) via PerChildAttached. Registered in the perf DescriptorVariantFactory alongside the other panel descriptors. FlexPanelDescriptor delegates to Reconciler.ApplyFlexAttached (promoted from private to internal) so descriptor and legacy paths share the "always apply — reset to defaults when no hint" semantics that protect against stale Yoga config on pool-rented controls. CanvasDescriptor delegates to Reconciler.ApplyCanvasPosition (already internal) so the anchor-state ConditionalWeakTable + SizeChanged wiring is shared. Each descriptor's PerChildAttached callback resets the relevant WinUI attached DPs via ClearValue when the child has no hint, so a reordered child whose attached prop drops between renders is not stuck with the prior placement. Documented gaps: - RelativePanelDescriptor carved to Phase 4: the per-child callback fires sequentially during the mount loop, so name references like RightOf="foo" can't resolve against siblings that haven't been mounted yet. Needs either a post-loop second-pass shape on Panel<> or a dedicated NamedRelativePanel strategy. Doc updated in-place. Self-test V1 ON/OFF Desc_ filter: 494/494 pass on both paths (includes 4 new fixtures: Desc_Grid_AttachedRowColumn, Desc_Canvas_AttachedLeftTop, Desc_FlexPanel_AttachedFlexProps, Desc_WrapGrid_MountUpdate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3-final Batch F — Image events + Path.Data + InfoBar.ActionButton Three more descriptor-surface ports. ImageDescriptor adds ImageOpened/ImageFailed via .HandCodedEvent over the existing ImageEventPayload (event entries declared before Source .OneWay so subscriptions land before the cached-image synchronous fire). PathDescriptor adds the pre-built Geometry Data path as .OneWayConditional (reference comparer, gated on PathDataString being null so the legacy XamlReader/parser path stays the single owner for that surface) plus FillRule propagation onto PathGeometry. InfoBarDescriptor adds ActionButton as .OneWayBridged<string?> that builds the inner Button and wires Click via a static-trampoline reading the parent InfoBar's Tag — record-with that swaps OnActionButtonClick picks up automatically. Shipped: - Image: ImageOpened + ImageFailed .HandCodedEvent entries (live-element via Tag) - Path: Data .OneWayConditional + FillRule .OneWayConditional onto PathGeometry - InfoBar: ActionButton .OneWayBridged with Click trampoline Carved to Phase 4: - Expander.HeaderTemplate: requires a NamedSlots strategy, conflicts with the existing SingleContent Children — only one Children strategy per descriptor today. - TeachingTip.Target: cross-element reference resolution (Target points at a sibling's mounted control); descriptor framework can't reference another element's resolved native control. - Path.PathDataString: legacy XamlReader + PathDataParser strategies need string-diff-against-old-element comparer and old+new+xaml+parser error context the engine's per-prop comparer can't replicate. - Icon polymorphic: already done in existing descriptors (InfoBar/TeachingTip/AutoSuggestBox/SelectorBar use Reconciler.ResolveIconSource / ResolveIconForDescriptor). V1 ON: 511 ok / 0 failures V1 OFF: 511 ok / 0 failures * spec(047): Phase 3-final Batch G-prep — ItemsHost typing + descriptor-side ordering Two engine fixes uncovered by the first Batch G1 attempt (which reverted clean rather than ship broken). ItemsHost is now usable for descriptor authors; flat-items ports (ListBox / ComboBox / RadioButtons in G1) and typed templated lists (G2 keyed-reconcile shape, deferred) build on top. Changes: - ChildrenStrategy.ItemsHost.GetCollection signature: System.Collections.IList -> IList<object>. WinUI Microsoft.UI.Xaml.Controls.ItemCollection does not implement the non-generic IList projection under CsWinRT; a descriptor reaching for c.Items hit InvalidCastException at runtime. IList<object> is the projected interface ItemCollection actually exposes. - DescriptorHandler.Mount / Update now dispatch ItemsHost INLINE between RentControl and the prop loop (Mount), and before the prop Update loop (Update). Selection-tracking initial writes (SelectedIndex / SelectedItem) need the collection in its final shape first — WinUI silently clamps selection against an empty collection. The strategy shape is unchanged; descriptors using ItemsHost simply get the items populated first, matching legacy MountListBox ordering. DescriptorHandler.Children returns null when the strategy is ItemsHost so V1HandlerAdapter doesn't double-dispatch. - V1HandlerAdapter ItemsHost branches kept for hand-coded handlers (none today) and updated for the IList<object> typing. Keyed-reconcile path (originally planned as part of G-prep for Batch G2) deferred: typed templated lists need ReactorListState + KeyedListDiff integration plus a Reconciler.BindKeyedItemsSource helper, which is substantial engine work better landed alongside its consumer rather than blind. V1 ON: 511 ok / 0 failures V1 OFF: 511 ok / 0 failures * spec(047): Phase 3-final Batch G1 — flat ItemsHost ports (ListBox, ComboBox, RadioButtons) Migrate the three flat-Items descriptors off the .OneWay<string[]> escape-hatch and onto the ItemsHost child strategy delivered by G-prep. The engine now dispatches ItemsHost inline between RentControl and the prop loop, so SelectedIndex's initial write lands against a populated collection (no more silent clamp-to-minus-one against empty Items). Shipped: - ListBoxDescriptor: ItemsHost flat — string[] -> IReadOnlyList<object> via array reference covariance; ListBoxItemsEqual helper removed. - ComboBoxDescriptor: ItemsHost flat — string items OR Element items (ItemElements wins when non-null); the setter escape-hatch the prior port required is no longer needed for the Items population path. - RadioButtonsDescriptor: ItemsHost flat — same string[] projection; RadioButtonsItemsEqual helper removed. - Three new TAP fixtures (Desc_<Name>_Items_*) exercise the dispatch ordering: initial SelectedIndex honored against populated items, no mount-time echo, clear-to-empty + repopulate cycle, same-ref idempotent short-circuit. Carved (none): all three controls fit the flat-IList<object> shape; typed templated lists go through G2. V1 ON: 534 ok / 0 failures V1 OFF: 534 ok / 0 failures * spec(047): Phase 3-final — §14 progress note + tracker close-out Documents the eight Phase 3-final batches (A engine shapes; B Frame / RichTextBlock / NumberBox; C CalendarView via CollectionDiffControlled; D Button-family Flyout via OneWayBridged + CreateFlyoutForDescriptor; E Panel per-child attached props + WrapGrid; F Image events / Path.Data / InfoBar.ActionButton; G-prep ItemsHost IList<object> typing + descriptor-side ordering fix; G1 flat ItemsHost ports for ListBox / ComboBox / RadioButtons) and the deliberate carve-outs to Phase 4 (Expander.HeaderTemplate, RelativePanel per-child attached, TeachingTip.Target, Path.PathDataString, NumberBox coercion, and the G2/G3 templated lists which need a new TemplatedItems strategy + spec-042 keyed-reconcile integration). Tracker marks the Batch 3-followup line as addressed via Phase 3-final Batches B and C. ARM64 stable-AC re-capture on LAPTOP-4MEP83VI remains deferred for the §14 ratification gate (unchanged from Phase 3 advisory state). * spec(047): Phase 3-final — replace double == checks in Phase 3-final fixtures with epsilon Silences github-code-quality bot comments on PR #436. The assertions read back literal values we just wrote (FontSize, NumberBox Value / Minimum / Maximum / SmallChange / LargeChange) so direct == was correct in practice, but tolerance comparisons (Math.Abs(x - literal) < 1e-9) keep the bot quiet for human reviewers and follow the convention CodeQL expects. V1 ON: 534 ok / 0 failures V1 OFF: 534 ok / 0 failures --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9966902 commit 522c940

36 files changed

Lines changed: 3360 additions & 389 deletions

docs/specs/047-extensible-control-model.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,6 +1376,28 @@ See §13 Q1 for the full capture lineage and matrix application. Raw data under
13761376
- Phase 3 follow-ups (need new builder/entry shapes — separate spec-reviewed PR): `NumberBox` (Immediate-mode keystroke + `NumberFormatter` ref-equality), `RichTextBlock` (incremental Paragraphs/Inlines diff), `FrameElement` (mount-only entry shape), `CalendarViewElement` (collection-diff with per-element echo). Within-control partial-port gaps (Flyout children, items collections, per-child attached props, IconSource, Path.Data) tracked in `docs/specs/tasks/047-extensible-control-model-implementation.md`. Templated lists (`ListView`, `GridView`, `TreeView`, `FlipView`, `TabView`, `Pivot`, `ItemsRepeater`) require spec-042 keyed reconciliation integrated into `ItemsHost`.
13771377
- Phase 3 advisory perf — final x64 capture under `docs/specs/047/phase3-results/CPC-ander-YTZ3O-x64-advisory/2026-05-27-phase3-final-3x5/` (50 controls registered, 3×5 launches). V1 ON (descriptors) vs V1 OFF (today) headline: M1 +14.9%, M7 +7.4%, M8 +25.5%, M10 +8.7%, M11 +8.5%, M12 +20.9% — descriptor-interpreter Mount/Update overhead amortized over the larger registration table. M4 −21.2% / M5 −24.3% — dispatch wins from a fatter handler table (fewer fallthroughs to the legacy switch arm). Cloud-PC advisory only; ARM64 stable-AC re-capture on `LAPTOP-4MEP83VI` is deferred for the §14 ratification gate.
13781378

1379+
**Phase 3-final descriptor scale-out** (delivers the follow-ups listed above + within-control partial-port gaps from PR #435 batches 3–11):
1380+
1381+
- **Batch A — engine shapes** (`.OneWayBridged`, `.Immediate`, `.CollectionDiffControlled`, `Panel.PerChildAttached`, `ItemsHost<TElement,TControl>` flat, `Reconciler.CreateFlyoutForDescriptor`). Carries no controls; enables the rest.
1382+
- **Batch B**`FrameElement` (.HandCodedEvent triple — gates on callback-at-mount; legacy unconditional subscription stays preferable for late-attached callbacks), `RichTextBlockElement` (reference-equality rebuild on Paragraphs; legacy incremental per-paragraph diff stays preferable for authors needing the incremental shape), `NumberBoxElement` (plain `.OneWay` Min/Max; `.CoercingOneWay` not threaded — see follow-up).
1383+
- **Batch C**`CalendarViewElement` via `.CollectionDiffControlled`. Null `SelectedDates` is treated as empty (descriptor clears the vector); legacy treats null as uncontrolled (preserves user picks) — call sites must pass a list whenever selection is controlled.
1384+
- **Batch D**`DropDownButton`/`SplitButton`/`ToggleSplitButton` Flyout child via `.OneWayBridged` + `Reconciler.CreateFlyoutForDescriptor`.
1385+
- **Batch E**`Grid`/`Canvas`/`FlexPanel` per-child attached props via `Panel.PerChildAttached`; `WrapGrid` via a tailored panel shape.
1386+
- **Batch F**`ImageElement` `ImageOpened`/`ImageFailed` via `.HandCodedEvent`; `PathElement` pre-built `Geometry Data` via `.OneWayConditional` (gated on `PathDataString` being null); `InfoBarElement.ActionButton` via `.OneWayBridged` with a Click trampoline.
1387+
- **Batch G-prep — engine ordering fix.** `ItemsHost.GetCollection` retyped from `System.Collections.IList` to `IList<object>` (WinUI `ItemCollection` does not implement the non-generic projection under CsWinRT). `DescriptorHandler` now dispatches `ItemsHost` inline between `RentControl` and the prop loop on Mount, and before the prop loop on Update, so selection-tracking initial writes (`SelectedIndex`/`SelectedItem`) land against a populated collection. Strategy shape unchanged for hand-coded handlers (V1HandlerAdapter dispatch path kept).
1388+
- **Batch G1 — flat `ItemsHost` ports.** `ListBoxElement`, `ComboBoxElement`, `RadioButtonsElement` migrate from `.OneWay<string[]>` items entries to `Children = new ItemsHost<...>(...)`. `ComboBoxElement.ItemElements` (`Element[]?`) supported alongside `Items` (`string[]`); the engine routes `Element` items through `MountChild`.
1389+
1390+
**Phase 3-final carve-outs to Phase 4** (cannot be expressed inside the current engine shape; explicit reasons):
1391+
1392+
- **Expander.HeaderTemplate** — needs `NamedSlots` but conflicts with the existing `SingleContent` strategy; one Children strategy per descriptor today.
1393+
- **RelativePanel per-child attached** — sequential `PerChildAttached` callbacks can't resolve sibling name references that haven't been mounted yet. Needs a two-pass shape on `Panel<>` or a dedicated `NamedRelativePanel` strategy.
1394+
- **TeachingTip.Target** — cross-element reference resolution to another element's mounted native control; descriptor framework cannot reference another element's resolved control.
1395+
- **PathElement.PathDataString** — legacy `XamlReader`/`PathDataParser` strategy needs string-diff against the old element + multi-source error context the engine's per-prop comparer can't express.
1396+
- **NumberBox coercion**`Minimum`/`Maximum` ship as plain `.OneWay`; `.CoercingOneWay` could be wired later.
1397+
- **Templated lists (G2/G3)**`ListView<T>`, `GridView<T>`, `LazyVStack<T>`, `LazyHStack<T>`, `ItemsRepeater<T>`, `TreeView`, `FlipView`, `TabView`, `Pivot` need a new `TemplatedItems<T,TControl>` (or equivalent) strategy with spec-042 `ReactorListState` + `KeyedListDiff` integration plus a `Reconciler.BindKeyedItemsSource` helper lifting the legacy realization-hook setup. Substantial engine design work; deferred to a follow-up batch.
1398+
1399+
ARM64 stable-AC re-capture on `LAPTOP-4MEP83VI` remains deferred for the §14 ratification gate.
1400+
13791401
**Carry-forward known defects from Phase 1:**
13801402
- **KD-3** — dispatch fast-path for the ported built-ins (M4 was +88.9% V1 vs Today at Phase 1; final advisory shows M4 −21.2% / M5 −24.3% at amortized scope — KD-3 has materially closed at the batch-11 registration set).
13811403
- **KD-4** — public typed-event surface for external descriptor authors. Scope narrowed by Phase 2 to external-author-only; in-tree descriptors already use the internal fast path via `DescriptorControlledPayload<T>` or `.HandCodedControlled`/`.HandCodedEvent` per-descriptor payload pattern.

docs/specs/tasks/047-extensible-control-model-implementation.md

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -751,15 +751,93 @@ shrink lands after V1 ships ON by default.
751751
echo per element or require a custom collection-aware entry
752752
shape. Authors who need declarative multi-date selection stay
753753
on V1 OFF.
754-
- [ ] Batch 3-followup — `NumberBox` (needs Immediate-mode keystroke
755-
handling + `NumberFormatter` reference-equality semantics that the
756-
descriptor builders don't yet express — likely needs a new entry
757-
shape or a `HandCoded*` path). `RichTextBlock` (incremental
758-
paragraph/inline diffing — needs a child-strategy or new entry
759-
shape). `FrameElement` (needs a Mount-only entry shape for
760-
imperative `Navigate` calls — see Batch 11). `CalendarViewElement`
761-
(needs a collection-diff entry shape with per-element echo
762-
suppression — see Batch 11).
754+
- [x] Batch 3-followup — addressed by Phase 3-final Batch B (`Frame`,
755+
`RichTextBlock`, `NumberBox`) and Batch C (`CalendarView`). Engine
756+
shapes (`.Immediate`, `.OneWayBridged`, `.CollectionDiffControlled`)
757+
added in Phase 3-final Batch A. Documented residual carve-outs
758+
retained — see Phase 3-final batch entries below.
759+
760+
**Phase 3-final descriptor scale-out** (delivers the Phase 3 follow-ups
761+
plus within-control partial-port gaps from PR #435 batches 3–11):
762+
763+
- [x] **Batch A — engine shapes.** Added `.OneWayBridged<TValue>` (set
764+
lambda gets `(TControl, TValue, Reconciler, Action requestRerender)`
765+
— for dynamically-constructed child controls), `.Immediate<TPayload>`
766+
(pure subscription wiring), `.CollectionDiffControlled` (`IList<T>`
767+
two-way with hash-set diff under `BeginSuppress`),
768+
`Panel<>.PerChildAttached`, `ItemsHost<TElement,TControl>` (flat),
769+
and `Reconciler.CreateFlyoutForDescriptor`. Engine-only commit —
770+
no controls.
771+
- [x] **Batch B**`Frame`, `RichTextBlock`, `NumberBox`.
772+
- **Frame** — three `.HandCodedEvent` subscriptions
773+
(`Navigated`/`Navigating`/`NavigationFailed`) gate on
774+
callback-at-mount. Legacy `MountFrame` subscribes unconditionally
775+
so late-attached callbacks fire through. Common case covered;
776+
authors who attach callbacks after Mount stay on V1 OFF.
777+
- **RichTextBlock**`Paragraphs` rebuild via reference-equality.
778+
Legacy `UpdateRichTextBlock` does incremental per-paragraph diff;
779+
authors needing the incremental shape stay on V1 OFF.
780+
- **NumberBox** — plain `.OneWay` `Minimum`/`Maximum` (no coercion
781+
suppression). `.CoercingOneWay` could be wired later.
782+
- [x] **Batch C**`CalendarView` via `.CollectionDiffControlled`.
783+
Null `SelectedDates` is treated as empty list (descriptor clears
784+
the vector); legacy treats null as uncontrolled (preserves user
785+
picks). Call sites must pass a list whenever selection is
786+
controlled.
787+
- [x] **Batch D**`DropDownButton`/`SplitButton`/`ToggleSplitButton`
788+
Flyout child via `.OneWayBridged` + `Reconciler.CreateFlyoutForDescriptor`.
789+
Closes the Batch 4 Flyout escape-hatch.
790+
- [x] **Batch E**`Grid`/`Canvas`/`FlexPanel` per-child attached props
791+
via `Panel.PerChildAttached`; `WrapGrid` ported with a tailored
792+
panel shape. Closes the Batch 8 per-child attached gap (except
793+
`RelativePanel` — see carve-outs).
794+
- [x] **Batch F**`Image` events (`ImageOpened`/`ImageFailed` via
795+
`.HandCodedEvent` over the existing `ImageEventPayload`),
796+
`Path.Data` (pre-built `Geometry` via `.OneWayConditional` gated
797+
on `PathDataString` being null), `InfoBar.ActionButton` (via
798+
`.OneWayBridged` with a Click trampoline).
799+
- [x] **Batch G-prep — engine ordering fix.**
800+
`ItemsHost.GetCollection` retyped from `System.Collections.IList`
801+
to `IList<object>` (WinUI `ItemCollection` does not implement the
802+
non-generic projection under CsWinRT). `DescriptorHandler` now
803+
dispatches `ItemsHost` inline between `RentControl` and the prop
804+
loop on Mount, and before the prop Update loop on Update, so
805+
selection-tracking initial writes (`SelectedIndex`/`SelectedItem`)
806+
land against a populated collection. Strategy shape unchanged for
807+
hand-coded handlers — V1HandlerAdapter dispatch path is preserved.
808+
- [x] **Batch G1 — flat `ItemsHost` ports.** `ListBox`, `ComboBox`,
809+
`RadioButtons` migrate from `.OneWay<string[]>` items entries to
810+
`Children = new ItemsHost<...>(GetItems: e => (IReadOnlyList<object>)e.Items, GetCollection: c => c.Items)`.
811+
`ComboBox.ItemElements` (`Element[]?`) supported alongside `Items`
812+
(`string[]`); the engine routes `Element` items through
813+
`MountChild`. Fixtures: `Desc_ListBox_Items`, `Desc_ComboBox_Items`,
814+
`Desc_RadioButtons_Items`.
815+
816+
**Phase 3-final carve-outs to Phase 4** (cannot be expressed inside the
817+
current engine shape; explicit reasons):
818+
819+
- [ ] **Expander.HeaderTemplate** — needs `NamedSlots` but conflicts with
820+
the existing `SingleContent` strategy; one Children strategy per
821+
descriptor today.
822+
- [ ] **RelativePanel per-child attached** — sequential `PerChildAttached`
823+
callbacks can't resolve sibling name references that haven't been
824+
mounted yet. Needs a two-pass shape on `Panel<>` or a dedicated
825+
`NamedRelativePanel` strategy.
826+
- [ ] **TeachingTip.Target** — cross-element reference resolution to
827+
another element's mounted native control; descriptor framework
828+
cannot reference another element's resolved control.
829+
- [ ] **Path.PathDataString** — legacy `XamlReader`/`PathDataParser`
830+
strategy needs string-diff against the old element + multi-source
831+
error context the engine's per-prop comparer can't express.
832+
- [ ] **NumberBox coercion**`.CoercingOneWay` thread for `Minimum` /
833+
`Maximum`.
834+
- [ ] **Templated lists (G2/G3)**`ListView<T>`, `GridView<T>`,
835+
`LazyVStack<T>`, `LazyHStack<T>`, `ItemsRepeater<T>`, `TreeView`,
836+
`FlipView`, `TabView`, `Pivot` need a new `TemplatedItems<T,TControl>`
837+
(or equivalent) strategy with spec-042 `ReactorListState` +
838+
`KeyedListDiff` integration plus a `Reconciler.BindKeyedItemsSource`
839+
helper lifting the legacy realization-hook setup. Substantial engine
840+
design work; deferred to a follow-up batch.
763841

764842
**Carry-forward known defects** (from Phase 1):
765843

src/Reactor/Core/Reconciler.Mount.cs

Lines changed: 13 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -285,46 +285,11 @@ private WinUI.RichTextBlock MountRichTextBlock(RichTextBlockElement richText)
285285
var rtb = _pool.TryRent(typeof(WinUI.RichTextBlock)) as WinUI.RichTextBlock ?? new WinUI.RichTextBlock();
286286
rtb.IsTextSelectionEnabled = richText.IsTextSelectionEnabled;
287287
if (richText.TextWrapping.HasValue) rtb.TextWrapping = richText.TextWrapping.Value;
288-
if (richText.Paragraphs is not null)
289-
{
290-
foreach (var para in richText.Paragraphs)
291-
{
292-
var p = new Microsoft.UI.Xaml.Documents.Paragraph();
293-
foreach (var inline in para.Inlines)
294-
{
295-
switch (inline)
296-
{
297-
case RichTextRun run:
298-
var r = new Microsoft.UI.Xaml.Documents.Run { Text = run.Text };
299-
if (run.IsBold) r.FontWeight = Microsoft.UI.Text.FontWeights.Bold;
300-
if (run.IsItalic) r.FontStyle = global::Windows.UI.Text.FontStyle.Italic;
301-
if (run.IsStrikethrough) r.TextDecorations = global::Windows.UI.Text.TextDecorations.Strikethrough;
302-
if (run.FontSize.HasValue) r.FontSize = run.FontSize.Value;
303-
if (run.FontFamily is not null) r.FontFamily = WinRTCache.GetFontFamily(run.FontFamily);
304-
if (run.Foreground is not null) r.Foreground = run.Foreground;
305-
p.Inlines.Add(r);
306-
break;
307-
case RichTextHyperlink link:
308-
var hl = new Microsoft.UI.Xaml.Documents.Hyperlink();
309-
try { hl.NavigateUri = link.NavigateUri; }
310-
catch { hl.NavigateUri = new Uri("about:error"); }
311-
hl.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = link.Text });
312-
p.Inlines.Add(hl);
313-
break;
314-
case RichTextLineBreak:
315-
p.Inlines.Add(new Microsoft.UI.Xaml.Documents.LineBreak());
316-
break;
317-
}
318-
}
319-
rtb.Blocks.Add(p);
320-
}
321-
}
322-
else
323-
{
324-
var paragraph = new Microsoft.UI.Xaml.Documents.Paragraph();
325-
paragraph.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = richText.Text });
326-
rtb.Blocks.Add(paragraph);
327-
}
288+
// Shared helper with the V1 descriptor — clears Blocks then rebuilds
289+
// from Paragraphs or falls back to a single Run with .Text. Extracted
290+
// (Phase 3-final Batch B) so RichTextBlockDescriptor's .OneWay set
291+
// lambda can call the same code path.
292+
RebuildRichTextBlocks(richText, rtb);
328293
if (richText.FontSize.HasValue) rtb.FontSize = richText.FontSize.Value;
329294
if (richText.MaxLines > 0) rtb.MaxLines = richText.MaxLines;
330295
if (richText.LineHeight.HasValue) rtb.LineHeight = richText.LineHeight.Value;
@@ -618,21 +583,24 @@ private WinUI.NumberBox MountNumberBox(NumberBoxElement nb)
618583
return numBox;
619584
}
620585

621-
private static readonly Microsoft.UI.Xaml.DependencyPropertyChangedCallback NumberBoxImmediateTextChanged =
586+
// Spec 047 §14 Phase 3-final Batch B — widened to internal static so
587+
// NumberBoxDescriptor can register the same captured-free trampolines
588+
// via the .Immediate entry shape.
589+
internal static readonly Microsoft.UI.Xaml.DependencyPropertyChangedCallback NumberBoxImmediateTextChanged =
622590
(sender, _) =>
623591
{
624592
if (sender is not WinUI.NumberBox box) return;
625593
HandleNumberBoxImmediateTextChanged(box, box.Text);
626594
};
627595

628-
private static void NumberBoxLoadedEnsureImmediateTextBox(object sender, RoutedEventArgs _)
596+
internal static void NumberBoxLoadedEnsureImmediateTextBox(object sender, RoutedEventArgs _)
629597
{
630598
if (sender is not WinUI.NumberBox box) return;
631599
if (EnsureNumberBoxImmediateTextBoxWiring(box))
632600
box.Loaded -= NumberBoxLoadedEnsureImmediateTextBox;
633601
}
634602

635-
private static bool EnsureNumberBoxImmediateTextBoxWiring(WinUI.NumberBox box)
603+
internal static bool EnsureNumberBoxImmediateTextBoxWiring(WinUI.NumberBox box)
636604
{
637605
var events = GetOrCreateEventState(box);
638606
if (events.NumberBoxInnerTextChanged) return true;
@@ -646,7 +614,7 @@ private static bool EnsureNumberBoxImmediateTextBoxWiring(WinUI.NumberBox box)
646614
return true;
647615
}
648616

649-
private static T? FindDescendant<T>(DependencyObject root) where T : DependencyObject
617+
internal static T? FindDescendant<T>(DependencyObject root) where T : DependencyObject
650618
{
651619
int count = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(root);
652620
for (int i = 0; i < count; i++)
@@ -659,7 +627,7 @@ private static bool EnsureNumberBoxImmediateTextBoxWiring(WinUI.NumberBox box)
659627
return null;
660628
}
661629

662-
private static void HandleNumberBoxImmediateTextChanged(WinUI.NumberBox box, string text)
630+
internal static void HandleNumberBoxImmediateTextChanged(WinUI.NumberBox box, string text)
663631
{
664632
if (GetElementTag(box) is not NumberBoxElement el) return;
665633
if (el.OnValueChanged is null) return;

src/Reactor/Core/Reconciler.Update.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,10 @@ private static Microsoft.UI.Xaml.Documents.Paragraph MountParagraph(RichTextPara
658658
return p;
659659
}
660660

661-
private static void RebuildRichTextBlocks(RichTextBlockElement n, WinUI.RichTextBlock rtb)
661+
// Spec 047 §14 Phase 3-final Batch B — widened to internal static so the
662+
// legacy MountRichTextBlock arm AND RichTextBlockDescriptor's .OneWay set
663+
// lambda call the same rebuild path.
664+
internal static void RebuildRichTextBlocks(RichTextBlockElement n, WinUI.RichTextBlock rtb)
662665
{
663666
rtb.Blocks.Clear();
664667
if (n.Paragraphs is not null)
@@ -3772,7 +3775,7 @@ private static void UpdateAppBarItems(
37723775
return null;
37733776
}
37743777

3775-
private static void ApplyFlexAttached(Element child, Microsoft.UI.Xaml.UIElement ctrl)
3778+
internal static void ApplyFlexAttached(Element child, Microsoft.UI.Xaml.UIElement ctrl)
37763779
{
37773780
var fa = child.GetAttached<FlexAttached>();
37783781
// Always apply — reset to defaults when no FlexAttached, so stale values

src/Reactor/Core/Reconciler.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4096,6 +4096,18 @@ private void SetFlyoutOnControl(FrameworkElement fe, WinPrim.FlyoutBase flyout)
40964096
}
40974097
}
40984098

4099+
/// <summary>
4100+
/// Spec 047 §14 Phase 3-final — descriptor-facing sibling of
4101+
/// <see cref="CreateFlyoutFromElement"/>. Same shape as
4102+
/// <see cref="ResolveIconForDescriptor"/>: a thin forwarder that tolerates
4103+
/// <see langword="null"/> input so a <c>.OneWayBridged</c> entry on a
4104+
/// button-family descriptor can wire
4105+
/// <c>(c, v, rec, rr) =&gt; c.Flyout = rec.CreateFlyoutForDescriptor(v, rr)</c>
4106+
/// without an upstream null guard.
4107+
/// </summary>
4108+
internal WinPrim.FlyoutBase? CreateFlyoutForDescriptor(Element? flyoutEl, Action requestRerender)
4109+
=> flyoutEl is null ? null : CreateFlyoutFromElement(flyoutEl, requestRerender);
4110+
40994111
// ── Enum conversions removed — Reactor now uses WinUI types directly ──
41004112

41014113
internal static Symbol ParseSymbol(string name)

0 commit comments

Comments
 (0)