Repository navigation
fix: the seven views where the box was not the obvious root (#1297, part 3) - #1332
Conversation
Eight control views write Style="@Style" onto a real root element of their own and never wrote the author's Class beside it. Unlike the wrapper cases in #1288 there is no "where does the box go" question here: the element already carrying the Style is by definition the element that must carry the Class. Two shapes, chosen per view by what its root already has: * root with NO class of its own -> class="@Class" (Blazor omits a null attribute) * root with a FIXED class list -> "fixed@(ClassSuffix)" BlazorView.ClassSuffix renders " " + Class, or the empty string when nothing was declared, so an unstyled control's attribute stays byte-identical. Appending " @Class" literally would leave a trailing space in every such attribute on every page. LayoutAreaView and ThreadMessageBubbleView compose their class in code instead: Razor cannot parse two adjacent explicit expressions in one attribute value, and both roots end in a conditional expression. Nothing here is gated on the declaration (ViewModel.Class) as HtmlView is, because no element is created or removed — a JsonPointerReference that lands a frame later just updates an attribute; it cannot restructure the DOM mid-flight. Tests: five render through the real HtmlRenderer and were verified by falsification — reverting each view makes exactly its own test fail (5 failed / 17 passed). The four backwards-compatibility guards pass with AND without the fix, asserting the exact attribute (class="markdown-body", not Contain) so a stray trailing space or empty class="" fails. NumberFieldView, LayoutAreaView and ThreadMessageBubbleView are fixed the same way but need a live pointer/area/thread stream to render; that is stated in the test class doc rather than left implicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten control views render a panel / page / editor with ONE root element of their own, carrying only hard-coded declarations (`style="max-width: 720px;"`, `class="node-export-view"`) and no author input at all. That root IS the control's box, so it is where the author's Style and Class belong — no wrapper is introduced anywhere here, which is the point: #1288 showed a blanket wrapper breaks call sites that depend on a fragment's own root being the direct flex child. Placement rule, applied per view rather than mechanically: * the author's Style goes LAST (BlazorView.StyleSuffix) so a declared width/height OVERRIDES the view's default. Dropping it was the bug; losing the cascade would be the same bug with extra steps. * the author's Class joins the fixed class list (ClassSuffix), never replaces it. Three compose in code because Razor cannot parse two adjacent explicit expressions in one attribute value: CatalogView (its root style was already a code-behind literal), CollaborativeMarkdownView, and DiffEditorView — whose host box read ViewModel.Height directly and now keeps it as the default with the declared Style after it. Backwards compatibility: with nothing declared both suffixes are empty, so each root is byte-identical — asserted as exact attributes, not Contain. One deliberate exception: CollaborativeMarkdownView's container previously emitted a trailing space when its view-mode class was empty (the default mode); the tokens are now joined, so that space is gone. A class attribute is a whitespace-separated token list, so it never participated in a selector match. Tests: nine render through the real HtmlRenderer, verified by falsification — reverting the views fails exactly those nine (9 failed / 26 passed) while the four backwards-compatibility guards pass either way. MeshNodeContentEditorView is fixed the same way but not rendered: its first render is a progress ring and the styled root appears only after the node's fields load. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last of the both-drop bucket, and the only ones where the answer was NOT "the one root the view already has". Each decided on its own: * MeshNodeCardView renders the card inside an <a> that exists only to make it navigable (display:block; width:100% — a transparent pass-through). The CARD is the box the user sees and sizes, so the declaration goes there; putting it on the anchor as well would apply a declared margin twice. In the branch where the card delegates to an embedded LayoutArea it renders no element of its own, so the declaration is FORWARDED onto that control — the shape PropertyView/EditFormView already use — and only when actually declared, so an undeclared card yields the identical record. * MeshNodeThumbnailView and LayoutAreaDefinitionView had literals SHADOWING the bound values rather than merely missing them, which is why they read as already-correct. * NavLink's Class was not forgotten but OVERWRITTEN: Class="@(IsActive ? "active" : null)" made the active-state token the whole value. Both tokens now join, and the result stays null (not "") so an inactive unstyled link still emits no attribute. * DialogView's style slot composes --dialog-width/--dialog-height from Size; the author's declaration follows them so a declared width overrides the preset. * MeshNodePickerView is a FormComponentBase whose root ignored ComputedStyle, so it dropped Width and Height along with Style. Emitting ComputedStyle is the same one-line fix, not a separate one. * MeshNodeRoleEditorView applies to its two steady-state branch roots (editable and read-only). The progress ring and the deliberately-empty span are left alone. NamedAreaView — the audit's one "unclear" — is resolved as deliberately N/A, with the reasoning recorded in the view: a NamedAreaControl is a REFERENCE to an area, not a box, and every layout area, tab and dialog body reaches the DOM through it, so a wrapper here is the blanket-wrapper regression #1288 refused, applied to the busiest path in the UI. If a call site ever needs it, the fix is to forward onto RootControl. Tests: five render through the real HtmlRenderer, verified by falsification (5 failed / 39 passed with the views reverted). MeshNodePickerView needs a live pointer stream and MeshNodeRoleEditorView renders a progress ring until its role loads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Re-targeted to |
The NotContain needle was missing the space StyleSuffix prefixes, so the assertion matched nothing whether or not the anchor double-applied the style — a guard that cannot fail is not a guard. Anchored on "color: inherit", which appears only in the anchor's style (the card's own literal also ends in "box-sizing: border-box;", so a shorter needle would match the CARD and pass regardless). Verified by temporarily appending StyleSuffix to the anchor: this assertion fails, the two positive assertions in the same test still pass. 44/44 with the change reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oc example Two of Copilot's three findings on #1323, both real: * LayoutAreaView produced a DOUBLE space (and an empty class token) when the area is neither Top nor Fill and a class was declared — the interpolated literal already ended in a space before the empty conditional, and ClassSuffix adds its own. Joined as a token list instead, which also drops the pre-existing trailing space in that case. Same treatment CollaborativeMarkdownView gets in part 2, same justification: a class attribute is a whitespace-separated token list, so neither the old space nor its removal can change a selector match. * The ClassSuffix XML doc used 'markdown-body@ClassSuffix' as its example — the exact bare form that Razor's email-address heuristic emits as LITERAL TEXT with no error. Copying the doc would have reintroduced the bug; it now shows @(ClassSuffix) and says why. The third finding (that Contain("class=\"markdown-body\"") would still pass on a trailing space) does not hold: the needle includes the CLOSING QUOTE, so class="markdown-body " and class="markdown-body x" both fail to match. Answered on the thread rather than changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR continues the #1297 sweep to ensure UiControl.Style / UiControl.Class are consistently reflected in emitted Blazor markup (or deliberately documented as N/A), backed by renderer-based regression tests and “What’s New” entries.
Changes:
- Introduces
StyleSuffix/ClassSuffixhelpers onBlazorViewto safely append author styling without changing unstyled markup. - Updates a broad set of control views to forward/join
StyleandClassto the correct rendered element (including “which element?” cases, shadowed literals, and active-state class joining). - Expands
ControlStyleRenderingTestto render and assert emitted HTML for the updated views, plus adds “What’s New” documentation entries.
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Blazor.Test/ControlStyleRenderingTest.cs | Extends renderer-based tests to assert Style/Class reach the correct DOM element and preserve unstyled markup byte-for-byte. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-styling-hooks-now-reach-eight-more-parts-of-the-page.md | “What’s New” entry describing the class-hook fixes for additional controls. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-panels-and-editors-now-accept-the-styling-you-give-them.md | “What’s New” entry describing fixes for literal-root panels/editors that previously ignored styling. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-cards-links-and-dialogs-honour-their-styling-too.md | “What’s New” entry describing fixes for the “which element?” styling cases and form width/height pickup. |
| src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs | Composes a bubble class string that preserves role classes and appends author Class. |
| src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor | Applies composed class so author Class reaches the message bubble container. |
| src/MeshWeaver.Blazor/Components/TabsView.razor | Forwards Class into FluentTabs. |
| src/MeshWeaver.Blazor/Components/NumberFieldView.razor | Applies author Class on the wrapper that already owns the computed style/size. |
| src/MeshWeaver.Blazor/Components/NodeImportView.razor | Appends Style/Class to the literal root without changing unstyled output. |
| src/MeshWeaver.Blazor/Components/NodeExportView.razor | Appends Style/Class to the literal root without changing unstyled output. |
| src/MeshWeaver.Blazor/Components/NavLink.razor | Joins active-state token with author Class and forwards Style to link variants. |
| src/MeshWeaver.Blazor/Components/NamedAreaView.razor | Documents why NamedAreaView is deliberately N/A for applying its own Style/Class. |
| src/MeshWeaver.Blazor/Components/Monaco/DiffEditorView.razor | Moves host sizing to a composed style string and applies author Class/Style to host box. |
| src/MeshWeaver.Blazor/Components/MeshNodeThumbnailView.razor.cs | Avoids shadowing by composing FluentCard class/style to include author tokens. |
| src/MeshWeaver.Blazor/Components/MeshNodeThumbnailView.razor | Uses composed card class/style so author styling is honored. |
| src/MeshWeaver.Blazor/Components/MeshNodeRoleEditorView.razor | Applies author Style/Class to the steady-state editable and read-only roots. |
| src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.cs | Composes picker class to include layout state plus author Class. |
| src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor | Emits ComputedStyle on the root so Style/Width/Height are honored; applies composed class. |
| src/MeshWeaver.Blazor/Components/MeshNodeContentEditorView.razor | Appends author Style/Class to the editor root when it renders. |
| src/MeshWeaver.Blazor/Components/MeshNodeCollectionView.razor | Appends author Class to the collection container’s fixed class list. |
| src/MeshWeaver.Blazor/Components/MeshNodeCardView.razor | Applies author styling to the visible card (not the navigation anchor) and forwards styling onto embedded-area control when used. |
| src/MeshWeaver.Blazor/Components/MarkdownView.razor | Appends author Class to the fixed markdown-body class list. |
| src/MeshWeaver.Blazor/Components/MarkdownEditorView.razor | Adds Style/Class support to the editor container. |
| src/MeshWeaver.Blazor/Components/LayoutAreaView.razor | Composes container class to preserve existing state tokens and append author Class. |
| src/MeshWeaver.Blazor/Components/LayoutAreaDefinitionView.razor | Applies author Style/Class to the card anchor root. |
| src/MeshWeaver.Blazor/Components/Label.razor | Forwards Class into FluentLabel for both clickable and non-clickable branches. |
| src/MeshWeaver.Blazor/Components/ExportDocumentView.razor | Appends author Style/Class to the literal root without changing unstyled output. |
| src/MeshWeaver.Blazor/Components/EditorView.razor | Applies author Class to the wrapper that already owns Style. |
| src/MeshWeaver.Blazor/Components/DialogView.razor.cs | Ensures author Style is appended after size-derived CSS variables. |
| src/MeshWeaver.Blazor/Components/DialogView.razor | Forwards Class into FluentDialog and uses the composed style. |
| src/MeshWeaver.Blazor/Components/CommentableView.razor | Applies author Style/Class to the wrapper positioning context element. |
| src/MeshWeaver.Blazor/Components/CollaborativeMarkdownView.razor.cs | Composes container class token list to avoid shadowing/trailing-space artifacts and include author Class. |
| src/MeshWeaver.Blazor/Components/CollaborativeMarkdownView.razor | Applies composed class and forwards Style to the collaborative markdown container. |
| src/MeshWeaver.Blazor/Components/CatalogView.razor.cs | Appends author Style after container defaults so author values win the cascade. |
| src/MeshWeaver.Blazor/Components/CatalogView.razor | Appends author Class on the catalog container. |
| src/MeshWeaver.Blazor/Components/AppearanceView.razor | Appends author Style after the default max-width and forwards Class. |
| src/MeshWeaver.Blazor/BlazorView.razor.cs | Adds StyleSuffix and ClassSuffix helpers to preserve unstyled markup and ensure author styling wins the cascade when appended. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ads" (#1314 probes) Unblocks this PR's CI. The failing test — `LogIncidentIngestAccessContextTest.FirstSighting_IsAttributedToSystem_NotTheEmissionThreadsAmbient` (shard 4) — is NOT caused by the disposal-join change here. It is a pre-existing flake on main: the identical failure is recorded on main commit `c39288499` (merge of #1332, run 31634660324), which predates this PR's CI run, and the test file (`cb40296af`, #1314) is not touched by this diff. ## What actually failed Did not expect value to be 4 because the absent-node read completes off the subscribing thread … i.e. `hop.SelectorThread.Should().NotBe(hop.SubscribeThread)`, on a run whose own probe output shows the pipeline behaved perfectly: [create/cold-absent-node] subscribeThread=4@0.2ms selectorThread=4@6.2ms The stage deferred by six milliseconds and the thread pool handed the continuation back to the same thread it was subscribed from. Nothing was wrong; the assertion measures the wrong thing. ## The assertion was wrong twice over, so this is not merely "make it green" 1. **Wrong property.** A deferred continuation routinely lands on the subscribing thread, so thread identity cannot distinguish "ran inline" from "ran later". The file's own negative control already records the other half of this — *"an `AsyncLocal` DOES reach a pool hop scheduled from inside the scope … 'different thread' alone never proved a lost identity"* — so a thread change was never evidence about the identity either. 2. **Wrong probe.** It guarded `hop`, which makes no identity assertion at all. The identity assertion is on `hopNoFlow`, and that one had no non-vacuity guard: a stage running inside the still-open scope would see `system-security` for free, and the assertion would have proved nothing about `CarryAccessContext` carrying it. ## The replacement is strictly stronger `InlineDetector` measures the property the probes actually need — *the stage was not run synchronously nested inside the Subscribe call* — and measures it EXACTLY rather than by proxy. `Enter`/`Exit` bracket the Subscribe call on the subscribing thread, and `Observe` reports true only for a stage on that same thread while the call is still on the stack. Same thread ⇒ ordered by program order; any other thread ⇒ not nested by construction. No interleaving gives a wrong answer, so it cannot flake the way the thread comparison did. - The guard now sits on **`hopNoFlow`** in the create path, where the identity assertion is, and a matching guard is added to the fold path's `hop`, which had none. - **`ProbeInlineDetectorSeesInline` is a POSITIVE CONTROL** for the detector, and is the reason this is a pin rather than an assertion that cannot fail: a "did not happen" assertion is worthless unless the detector demonstrably reports the thing when it does happen. It runs the same measurement path with nothing between Subscribe and the selector, and must report inline. - The negative control's own "it must actually defer" check moves off thread identity too — same latent flake, one line from firing. ## Mutation-tested Replacing the framework read in `ProbeScopeReach` with an inline `Observable.Return` turns BOTH new guards red with their intended messages (`ranInline=True` on the real probes, positive control still `True`). Reverted; green again. Suites: `MeshWeaver.Observability.Test` 124/124, `MeshWeaver.Messaging.Hub.Test` 214/214 (this PR's own change area, including `HostedHubDisposalJoinTest` and `InflightHubCreationDrainTest`). Clean under `-c Release -warnaserror --no-incremental`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Part 3 — the last 7 of the 17 both-drop views, plus a resolution for the audit's one "unclear"
entry. With this the 25 views in #1297 are done.
Why these were held back for their own PR
These are the ones where "where does the box go" had a real answer that was not the one root the view
already has. Four of them are not even plain omissions — the value was actively overwritten or
shadowed, which is why the audit flagged them as easy to misread as already-correct.
MeshNodeCardView<a>that exists only to make it navigable (display:block; width:100%)MeshNodeThumbnailViewClass/Styleparameters present but hard-coded — the bound values were shadowed, not missedLayoutAreaDefinitionViewNavLinkClass="@(IsActive ? "active" : null)"— the active-state token was the whole value, so a declared class was overwrittennull(not"") when there is nothing to sayDialogView--dialog-width/--dialog-heightfromSizeMeshNodePickerViewFormComponentBasewhose root ignoredComputedStyleComputedStyle— see belowMeshNodeRoleEditorView<div>and the read-only labelMeshNodePickerView— same fix, not a different oneThe issue asks whether its extra
Width/Heightdrop is the same fix. It is, and it is one line.FormComponentBase.ComputedStylealready foldsStyle+Width+Heightinto a single string, andevery other form view emits it. This one emitted nothing. Switching its root to
style="@ComputedStyle"recovers all three at once; there is no separate Width/Height mechanism to build.
MeshNodeRoleEditorView— two of four branches, deliberatelyStyle/Class go on the two steady-state roots (the editable select+checkbox row, and the read-only
label). The loading
FluentProgressRingand the<span></span>no-role branch are left alone: thefirst is a transient placeholder — styling it and then dropping the style when the real content
arrives is a restructure mid-flight — and the second renders deliberately nothing.
NamedAreaView: resolved as deliberately N/A (bucket ⚪)The audit could not settle this one from the code. Resolved here, with the reasoning recorded in the
view so it does not have to be re-derived:
A
NamedAreaControlis a reference to an area, not a box. In the steady state the view rendersexactly one thing —
<DispatchView>on the resolvedRootControl— and that control applies its ownStyle/Class to its own element. It belongs with the other pure-indirection views (
DispatchView,EditFormView,PropertyView,ContainerView).Two reasons a wrapper would be actively wrong rather than merely unnecessary:
LayoutAreaView), as does everytab body and dialog body. One extra block box would land around all of them — the blanket-wrapper
regression fix: HtmlView honours Style/Class instead of silently dropping them #1288 refused, applied to the busiest path in the UI.
would apply the declaration to a spinner and remove it when content arrives — the visible mid-flight
restructure that fix: HtmlView honours Style/Class instead of silently dropping them #1288's "gate on the declaration, not the value" rule exists to prevent.
If a real call site ever needs a styled named area, the fix is to forward the declaration onto
RootControl— exactly whatMeshNodeCardViewnow does for its embedded area — not to introduce anelement here.
Tests — verified by falsification
Eight tests added. Reverting the five rendered views:
Two assertions worth pointing at:
MeshNodeCardControl_…_ReachTheCardNotTheAnchorasserts the anchor does not also carry thedeclaration — the double-margin trap, which a
Contain-only test would have passed.NavLinkControl_WithoutClass_EmitsNoClassAttributepins that the joined value staysnullratherthan
"", so an inactive unstyled nav link emits no class attribute exactly as before.Two of the seven are not rendered, stated in the test class doc:
MeshNodePickerViewneeds a livepointer stream (as every form view does), and
MeshNodeRoleEditorViewrenders a progress ring untilits role loads.
Verification
dotnet build src/MeshWeaver.Blazor -c Release -warnaserrordotnet build test/MeshWeaver.Hosting.Blazor.Test -c Release -warnaserrorControlStyleRenderingTest(44)WhatsNewEntryIntegrityTest+DocumentationLinkIntegrityTestWhat's New entry added (
Category: Fix).What remains of #1297 after this
Only the three views that were outside the audit's buckets:
SearchBoxView,MeshSearchViewandMonaco/CodeEditorVieware registered as control views but derive from plainComponentBase, soStyle/Classare never bound — there is nothing being dropped in markup, because nothing arrives.That is a different defect (a view-base question, not a markup one) and wants its own issue rather than
a hurried
[Parameter]pair here.🤖 Generated with Claude Code