Skip to content

fix: the ten views whose literal root dropped Style and Class (#1297, part 2) - #1327

Merged
rbuergi merged 6 commits into
mainfrom
fix/1297-literal-root-views
Aug 12, 2026
Merged

rbuergi merged 6 commits into
mainfrom
fix/1297-literal-root-views

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Part 2 of the #1297 sweep — 10 of the 17 views that drop both Style and Class: the ones whose
box is a single literal root element.

🔗 Stacked on #1323 (base is fix/1297-class-only-drops, not main) — it needs the ClassSuffix
helper that PR lands. GitHub will re-target this to main automatically once #1323 merges.
Merge order: #1323 → this → part 3. The diff shown here is this PR's own 10 views.

The judgement, made per view rather than mechanically

#1288 established that a blanket wrapper is wrong, with a case that would have shipped undetected
(TodoLayoutAreas.cs:117 puts flex: 1 on its fragment's own root because that root is the direct
flex child
— in-mesh source no build type-checks). So the question for each of the 17 is where does
the box go
, not what transformation do I apply.

For these ten the answer is the same and it is not a wrapper: each renders a panel / page / editor
with one root element of its own, carrying only hard-coded declarations and no author input at all.
That root IS the control's box. Nothing is created, nothing is nested, nothing moves.

View Root before Where Style / Class now go
NodeExportView class="node-export-view" style="max-width: 800px;" that root
NodeImportView class="node-import-view" style="max-width: 800px;" that root
ExportDocumentView class="export-document-view" style="max-width: 720px;" that root
AppearanceView style="max-width: 500px;", no class attribute that root (class="@Class")
CatalogView class="catalog-container" style="@ContainerStyle" — a code-behind literal ContainerStyle composition + class list
CommentableView class="commentable-wrapper", no style attribute the wrapper — it renders child areas and has no other element, so this is the only place its own Style can live
MarkdownEditorView class="collaborative-editor-container", no style that root
CollaborativeMarkdownView class="collab-md-container @ViewModeClass" that root, joined as a token list
MeshNodeContentEditorView class="mesh-node-content-editor" style="display:flex;…" that root
Monaco/DiffEditorView style="height: @(ViewModel.Height ?? "500px"); width:100%" that host box

Two placement decisions, both load-bearing

The author's Style goes LAST. BlazorView.StyleSuffix appends after the view's own declarations, so
a declared max-width: 400px overrides the built-in max-width: 800px. Dropping the declaration was
the bug; putting it where the default wins would be the same bug with extra steps. The tests assert the
order ("max-width: 800px; max-width: 400px"), not just presence.

DiffEditorView is the sharpest case: it read ViewModel.Height straight into its host box and ignored
Style entirely, so WithStyle("height: 200px") was a no-op. The control's own Height stays the
default and the declared style now follows it.

The author's Class joins the fixed list, never replaces it. .node-export-view, .catalog-container
etc. are the hooks their own stylesheets match on.

Backwards compatibility

Both suffixes are the empty string when nothing was declared, so every root is byte-identical. The
guards assert the exact attribute — class="node-export-view" style="max-width: 800px;" — not
Contain, so a trailing space or an empty class="" fails.

⚠️ One deliberate exception, stated because it is a real (if tiny) difference:
CollaborativeMarkdownView's container previously emitted class="collab-md-container " — a trailing
space — whenever its view-mode class was empty, which is the default mode. Preserving that byte-for-byte
would have produced "collab-md-container probe-class" (double space) once a class was declared, so
the tokens are now joined and the stray space is gone. A class attribute is a whitespace-separated token
list; that space never participated in any selector match.

Three views compose their attribute in code rather than markup, for a mechanical reason worth recording
so nobody "simplifies" it back: Razor cannot parse two adjacent explicit expressions in one attribute
value
(@(a)@(b) → CS1073: Unexpected token).

Tests — verified by falsification

Thirteen tests added to ControlStyleRenderingTest, rendering through the real HtmlRenderer
against a real monolith mesh. Reverting all ten views:

Failed  NodeExportControl_StyleAndClass_ReachTheLiteralRoot
Failed  NodeImportControl_StyleAndClass_ReachTheLiteralRoot
Failed  ExportDocumentControl_StyleAndClass_ReachTheLiteralRoot
Failed  AppearanceControl_StyleAndClass_ReachTheLiteralRoot
Failed  CatalogControl_StyleAndClass_ReachTheContainer
Failed  CommentableControl_StyleAndClass_ReachTheWrapper
Failed  MarkdownEditorControl_StyleAndClass_ReachTheContainer
Failed  CollaborativeMarkdownControl_StyleAndClass_ReachTheContainer
Failed  DiffEditorControl_StyleAndClass_ReachTheHostBox
Failed! - Failed: 9, Passed: 26, Total: 35
  • The nine positive tests fail without the fix — one per rendered view.
  • The four backwards-compatibility guards pass with and without it.

MeshNodeContentEditorView is the one not rendered, and the test class doc says why rather than
leaving it implicit: its first render is a progress ring, and the styled root appears only once the
node's editable fields have loaded from the mesh. Its change is the same one-line edit to a root it
already styles.

Verification

Check Result
dotnet build src/MeshWeaver.Blazor -c Release -warnaserror 0 Warning(s), 0 Error(s)
dotnet build test/MeshWeaver.Hosting.Blazor.Test -c Release -warnaserror 0 Warning(s), 0 Error(s)
ControlStyleRenderingTest (35) Passed
WhatsNewEntryIntegrityTest + DocumentationLinkIntegrityTest Passed

What's New entry added (Category: Fix).

Left for part 3

The remaining 7 of the 17, which are the ones with a genuine "which element" question rather than a
single obvious root: MeshNodeCardView (three branches, one of which delegates to another control),
MeshNodeThumbnailView and LayoutAreaDefinitionView (hard-coded Class/Style parameters shadowing
the bound values), NavLink (its Class is overwritten by the active-state token), DialogView (its
style slot composes CSS variables from Size), MeshNodePickerView (a FormComponentBase that ignores
ComputedStyle, hence also dropping Width/Height), and MeshNodeRoleEditorView (four branch roots).

🤖 Generated with Claude Code

rbuergi and others added 2 commits August 12, 2026 17:50
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>
@rbuergi

rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Re-targeted to main: CI (dotnet-test.yml) only triggers on pull_request: branches: [main], so with the stacked base this PR would have carried no checks at all — and a PR with no checks looks the same as one whose checks passed. The branch still contains #1323's commit (it needs the ClassSuffix helper), so the diff below shows both until #1323 merges, at which point it collapses to this PR's own 10 views. Merge order is still #1323 → this → #1332.

@rbuergi rbuergi closed this Aug 12, 2026
@rbuergi rbuergi reopened this Aug 12, 2026
rbuergi and others added 4 commits August 12, 2026 18:20
…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>
Copilot AI lite review requested due to automatic review settings August 12, 2026 16:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a set of Blazor control views that were silently ignoring the public UiControl.Style / UiControl.Class API by ensuring author-declared styling reaches the correct DOM “box” element (particularly for views with a single literal root element).

Changes:

  • Adds BlazorView.StyleSuffix / BlazorView.ClassSuffix helpers to append author styling to roots that already have fixed style/class tokens (preserving no-op output when unset).
  • Updates multiple component views to apply Style/Class on the intended root element (including literal-root panels/editors and previously class-only omissions).
  • Extends ControlStyleRenderingTest with render-based assertions and adds corresponding “What’s New” entries.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/MeshWeaver.Hosting.Blazor.Test/ControlStyleRenderingTest.cs Adds render-based tests covering Style/Class propagation for the affected views and registers missing Blazor services needed by the harness.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-styling-hooks-now-reach-eight-more-parts-of-the-page.md Documents the “class-only drop” fixes in release notes.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-panels-and-editors-now-accept-the-styling-you-give-them.md Documents the literal-root Style/Class fixes in release notes.
src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor.cs Introduces composed bubble class string that preserves fixed tokens while appending author class.
src/MeshWeaver.Blazor/Components/ThreadMessageBubbleView.razor Applies the composed bubble class to the root element.
src/MeshWeaver.Blazor/Components/TabsView.razor Forwards Class into the Fluent tabs component so authored class is not dropped.
src/MeshWeaver.Blazor/Components/NumberFieldView.razor Adds class="@Class" to the wrapper so authored class is not dropped.
src/MeshWeaver.Blazor/Components/NodeImportView.razor Appends ClassSuffix / StyleSuffix to the literal root element.
src/MeshWeaver.Blazor/Components/NodeExportView.razor Appends ClassSuffix / StyleSuffix to the literal root element.
src/MeshWeaver.Blazor/Components/Monaco/DiffEditorView.razor Moves host sizing into a composed style and appends authored style/class to the host box.
src/MeshWeaver.Blazor/Components/MeshNodeContentEditorView.razor Appends ClassSuffix / StyleSuffix to the editor’s styled root element.
src/MeshWeaver.Blazor/Components/MeshNodeCollectionView.razor Appends ClassSuffix so authored class is preserved alongside the fixed class.
src/MeshWeaver.Blazor/Components/MarkdownView.razor Appends ClassSuffix so authored class joins markdown-body.
src/MeshWeaver.Blazor/Components/MarkdownEditorView.razor Appends ClassSuffix and applies authored Style to the container element.
src/MeshWeaver.Blazor/Components/LayoutAreaView.razor Composes container class tokens into a single class string that can include authored class.
src/MeshWeaver.Blazor/Components/Label.razor Forwards Class into FluentLabel in both branches so authored class is not dropped.
src/MeshWeaver.Blazor/Components/ExportDocumentView.razor Appends ClassSuffix / StyleSuffix to the literal root element.
src/MeshWeaver.Blazor/Components/EditorView.razor Adds class="@Class" to the wrapper so authored class is not dropped.
src/MeshWeaver.Blazor/Components/CommentableView.razor Appends ClassSuffix and applies authored Style on the wrapper element.
src/MeshWeaver.Blazor/Components/CollaborativeMarkdownView.razor.cs Composes container class list (including authored class) as a token-joined string.
src/MeshWeaver.Blazor/Components/CollaborativeMarkdownView.razor Uses composed container class and applies authored Style to the container element.
src/MeshWeaver.Blazor/Components/CatalogView.razor.cs Composes container style to append authored style after defaults.
src/MeshWeaver.Blazor/Components/CatalogView.razor Appends ClassSuffix to catalog container class list.
src/MeshWeaver.Blazor/Components/AppearanceView.razor Applies authored class and appends authored style after the fixed max-width declaration.
src/MeshWeaver.Blazor/BlazorView.razor.cs Adds StyleSuffix / ClassSuffix helpers for safe concatenation onto fixed root attributes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@inject AccessService Access

<div style="max-width: 500px;">
<div class="@Class" style="max-width: 500px;@StyleSuffix">
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

722 tests  ±0   722 ✅ ±0   14m 5s ⏱️ -27s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit b04d23a. ± Comparison against base commit 408ed73.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   8m 8s ⏱️ +9s
1 814 tests ±0  1 711 ✅ ±0  103 💤 ±0  0 ❌ ±0 
2 073 runs  +2  1 970 ✅ +2  103 💤 ±0  0 ❌ ±0 

Results for commit b04d23a. ± Comparison against base commit 408ed73.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 885 tests  ±0   1 693 ✅ ±0   8m 27s ⏱️ -10s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit b04d23a. ± Comparison against base commit 408ed73.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 847 tests  ±0   1 837 ✅ ±0   10m 43s ⏱️ - 2m 14s
   11 suites ±0      10 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit b04d23a. ± Comparison against base commit 408ed73.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 372 tests  ±0   1 372 ✅ ±0   7m 6s ⏱️ +10s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit b04d23a. ± Comparison against base commit 408ed73.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 371 tests  +22   2 370 ✅ +22   8m 27s ⏱️ +7s
   12 suites ± 0       1 💤 ± 0 
   12 files   ± 0       0 ❌ ± 0 

Results for commit b04d23a. ± Comparison against base commit 408ed73.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

    66 files  ± 0     66 suites  ±0   56m 58s ⏱️ - 2m 25s
10 011 tests +22  9 705 ✅ +22  306 💤 ±0  0 ❌ ±0 
10 270 runs  +24  9 964 ✅ +24  306 💤 ±0  0 ❌ ±0 

Results for commit b04d23a. ± Comparison against base commit 408ed73.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants