field-guide / building-ui
05/10
05task lane

Building UI

Razor HUD that actually re-renders. The BuildHash trap.

26.07.15a
"[GameResource] is obsolete: declare custom resources with [AssetType]"

The [GameResource("Name","ext","desc")] attribute on a GameResource subclass now raises CS0618 obsolete. It is a warning, so a green dotnet build hides it unless the project treats warnings as errors. Switch to [AssetType] with named properties. The type still derives from GameResource, still writes an .ext file, and is still found by ResourceLibrary.GetAll<T>(). Only the declaring attribute changes, and the old Icon argument has no replacement.

26.07.08e
"@ref on a bare private field silently never assigns"

@ref on a private field compiles with only a CS0649 warning: the ref stays null at runtime. Bind to a property instead.

26.08.05
"#if DEBUG in a .razor file is always false: dev panels leak into release"

A #if DEBUG guard inside a .razor component is always false in every configuration, so it strips nothing, and the razor source plus any sibling .scss ship into the uploaded package as readable text regardless. Build anything that must be absent from a release as a plain .cs class.

26.07.22
"A code-behind Panel has no Style.Rotate shortcut: rotate it with a PanelTransform"

A pure C# Panel subclass has no settable Style.Rotate shortcut reachable from code the way razor markup can write transform:rotate(Xdeg). Rotate it by building a PanelTransform, calling AddRotation, and assigning it to Style.Transform each frame the angle changes.

26.08.05
"A flex: 1 spacer in a clipping column hides every control after it"

A flex: 1 spacer takes all remaining height in a column, so every sibling after it starts at the container's bottom edge. Under overflow: hidden those trailing controls are laid out but never drawn: hover and onclick stay intact while zero pixels reach the screen. Put a spacer between two blocks that both fit, or bottom-anchor a group with margin-top: auto.

26.07.22
"A literal glued to the front of a Razor expression parses as an email address"

In a .razor file, `word@Expr.Member` (e.g. `P@Score.Value`) is parsed as a literal string, not an expression. Razor's email-address heuristic sees the `foo@bar.baz` shape and treats the whole thing as text. The element renders the raw source, with no compile error and no warning. Put the literal inside the expression instead: `@($"P{Score.Value}")`.

26.07.08e
"A loose PNG in razor CSS renders in the editor but goes blank in the published build"

A razor background-image referencing a loose PNG/JPG renders in-editor (loose Assets are readable off the mounted filesystem) but goes blank in the published package, because loose non-compiled images are not auto-shipped. Add the disk glob to the sbproj Resources list; the wildcard matches the loose disk path, not the normalized asset path.

26.07.22
"A panel's width and height are the outer size (border-box by default)"

In s&box razor UI the declared width/height of a panel is the OUTER number: padding and borders sit inside it, not added on top. The default already behaves like box-sizing: border-box, with no such declaration anywhere. Author every width/height as the outer number the mockup shows; adding padding/border by hand under-sizes every box by exactly its own padding.

26.07.22
"A percent-sized absolute fill floods the panel when its track has no position"

An absolutely positioned child with a percent size resolves against the nearest ancestor that is a positioning context, not against the track you can see. A track div does not become a positioning context by carrying flex, padding, a background, or a fixed size. It needs an explicit position declaration. Without one, height: 100% resolves against the panel root, so a stat-bar fill sized width N percent by height 100 percent floods the whole panel and buries the cards under it. Set position: relative on the track, then sweep every sibling with the same pattern, because small absolute children hide the same defect by landing in the wrong corner instead of flooding.

26.07.22
"A percent-width flex child renders as a floating pill, not a left-anchored progress fill"

A progress fill built as a plain flex child sized by width:N% renders as a small detached pill floating partway along the track, because the engine's flex layout does not left-pin an under-width percent child the way a browser does. Fill must be position:absolute in a position:relative track.

26.07.22
"A property initializer that reads another property compiles headless but breaks the editor (CS0182)"

public int X { get; init; } = SomeClass.SomeProperty; passes headless dotnet build but fails the editor compile with CS0182: the editor's code generator embeds initializers where only constants are legal. Default to a constant sentinel and resolve later.

26.07.15a
"A runtime-written Data-filesystem image needs a code Texture, not a CSS background-image URL"

An image you write to FileSystem.Data at runtime (a per-save thumbnail, a screenshot) is not addressable from a CSS background-image URL -- those resolve against mounted content, not the user Data FS. Decode it into a Texture in code and assign Style.BackgroundImage instead.

26.07.22
"A ScreenPanel lays out against a height-fixed logical canvas, not a fixed 1920x1080"

A ScreenPanel lays out against a logical canvas that is fixed in height at 1080 and takes its width from the window aspect ratio. At a 1920x1080 window the canvas measures 2060.7 x 1080, not 1920 x 1080. Vertical and left-anchored values from a 1920-wide mockup transfer 1:1, but anything hard-centred with arithmetic against an assumed 1920 width sits off-centre. Centre with flex, never with math.

26.08.05
"A scroll container steals press-and-drag from every control inside it"

A Panel with overflow: scroll and real overflow content answers WantsDrag = true, and FindDragTarget walks up from the pointer to the first ancestor that claims the drag. That ancestor steals every press-and-drag from any slider, scrub bar, or knob inside it. Set CanDragScroll = false on the scroll container in OnTreeBuilt.

26.07.22
"A struct's Default => new() silently skips your all-optional constructor"

public static Config Default => new(); on a struct binds to the runtime's implicit parameterless constructor, not your declared all-optional-parameter constructor: every intended default is silently discarded and the value comes back default(T).

26.07.22
"A text line box is the taller of the declared line-height and the font's natural box"

A text element's line box measures max(declared line-height, the font's own natural box), so a column of rows built to a browser mockup's arithmetic runs long and overflows its fixed parent. Padding, borders, margins and gaps all measure exactly as declared: only the line box inflates. Size rows off the font size, not off an assumed 1.5×, and verify on a scale-1 capture.

26.07.15a
"A uniform font-size declaration still triggers glyph corruption"

SUPERSEDED: the font-size-declaration theory below was owner-settled (26.07.22) to be a CAPTURE-PATH artifact, not a live-render bug, and the mechanism was later traced to the root panel's fractional scale. Never strip font-size or letter-spacing from a shipping stylesheet to 'fix' captures. See camera-screenshot-cannot-verify-fonts.

26.07.08e
"Absolutely-positioned flex column with auto height silently drops trailing children"

Trailing children of a tall absolutely-positioned flex column silently vanish (no overflow, no compression) because Yoga's auto-height under-measures the content. Fix: give the column an explicit height.

26.08.05
"An absolute child of an unpositioned panel escapes to the screen, not the panel"

An absolutely positioned child clips to the nearest ancestor that is a positioning context, not to its visual parent. A frame with flex, a fixed size, and a border radius but no position declaration is not a positioning context, so the absolute child skips past it and renders full-bleed against the screen root. A sibling screen using the same pattern renders correctly only because its own box already was the root, which makes the pattern look safe to copy. For one rectangle, drop the absolute child and paint the texture on the frame's own background.

26.07.15a
"An MCP camera_screenshot cannot verify font faces or text legibility"

Glyph-block corruption in an MCP camera_screenshot is a capture-path artifact, not a live-render bug. The same UI renders text perfectly on the real screen. You cannot screenshot your way out of it: the requested width/height does not change the UI render scale, and sub-native requests crop the top-left. Verify geometry from a capture, never text fidelity; font-face and legibility are owner-eyeball checks.

26.07.15
"An undefined scss variable silently kills the whole panel -- no compile gate catches it"

Referencing an scss variable a sheet never defines passes both dotnet build and the in-editor compile_status clean, but at runtime the style parser logs a 'malformed rule' warning and the panel using that sheet can stop rendering entirely. Variables don't cross sheets, so copying a rule between sheets silently drops its variables.

26.07.22
"An XML doc comment on the field's own line deletes the field"

A single-line XML doc comment that shares a line with the field it documents pulls the declaration into the comment. The compiler then sees no field at all. Nothing fails at the comment site. The break shows up later as a 'does not contain a definition' error wherever the field is used, which reads like a typo or a missing using. Put every XML doc comment on its own line, directly above the member.

26.07.15a
"border-style: solid is a parse error that kills the whole stylesheet"

border-style: solid is a parse error in s&box scss. The invalid property aborts the whole stylesheet, collapsing the panel to zero size -- invisible, not just unbordered.

26.07.22
"box-shadow: inset paints outside the element in Razor panels"

The inset keyword on box-shadow is effectively ignored in s&box Razor panels: an inner vignette renders as an outer halo, and a full-screen inset vignette renders nothing at all. Build inner glows and vignettes from linear-gradient bands instead.

26.07.15a
"BuildHash() overrides only compile on PanelComponent, not on a plain Component"

protected override int BuildHash() is a PanelComponent member; a logic-only Component has no such method, so the override fails headless with CS0115. Easy to hit when you split a UI feature into a logic Component beside its PanelComponent view and copy the 'keep the tree stable' BuildHash idiom onto the logic component too.

26.07.08e
"Building a draggable slider with click-to-jump and drag-to-scrub"

No native drag helper exists for PanelComponent sliders. Use the engine's SliderControl pattern: MousePanelEvent.LocalPosition over track width, with pointer-events routing.

26.07.08e
"Building an s&box HUD"

One ScreenPanel host, static UIState for modals, self-closing panels, toast stack: BuildHash every flag the markup reads.

26.07.15a
"Child-combinator universal selector (`.parent > *`) never matches in s&box SCSS"

A child-combinator rule targeting the universal selector (`.parent > *`) is silently dead in s&box razor SCSS: no error, no warning, the rule simply never applies.

26.07.15a
"CSS @keyframes and animation shorthand work in razor SCSS"

CSS @keyframes plus the animation: shorthand are supported in s&box razor SCSS, so a spinner or looping effect is pure CSS -- do not drive it from a per-frame BuildHash re-render.

26.07.15a
"Defer a synchronous blocking call a few frames so the loading overlay actually paints"

Showing a loading overlay and running a synchronous main-thread job in the same handler never paints the overlay -- the blocking work runs before the render pass. Show the overlay, count down a couple of frames, then run the blocker in OnUpdate.

26.07.15a
"flex-basis: 0 overrides width and collapses a fixed column in Yoga"

In s&box Yoga, an explicit flex-basis (notably flex-basis: 0 inherited from a base rule) takes precedence over width for a flex item's main-axis size: a fixed column collapses to zero.

26.07.08e
"Flex-grow track with a percentage-width child causes a layout feedback loop"

A flex-grow slider track holding a normal-flow fill child sized by width:N% balloons wider as the value increases: take the fill out of flow with position:absolute, and make sure the track carries no overflow:hidden.

26.07.08e
"Glyph corruption (text renders as solid blocks) is also text-count dependent"

SUPERSEDED: the text-run-count theory below was owner-settled (26.07.22) to be a CAPTURE-PATH artifact, not a live-render bug, and the mechanism was later traced to the root panel's fractional scale. Do not trim text runs to 'fix' glyph blocks in a capture.

26.08.05
"Inside one panel tree, z-index paints the popup but does not order the click"

A dropdown paints cleanly over the block below it, and that block still eats every press. Paint order and hit order come from two different sums: render depth accumulates z-index down the tree, but hit-testing ranks siblings by SiblingIndex + ZIndex within each parent. An absolute menu that overhangs a later sibling looks perfect and is unclickable. Fix the ancestors, not the popup.

26.07.08e
"My s&box UI won't update / is frozen"

BuildHash() is the only re-render trigger: hash everything the markup reads, including collection contents and flags.

26.07.08e
"PanelComponent uses OnTreeBuilt, not OnAfterTreeRender"

OnAfterTreeRender(bool) is a Panel hook: on a PanelComponent it fails with CS0115; use parameterless OnTreeBuilt() or OnTreeFirstBuilt().

26.07.15a
"radial-gradient with a CSS shape keyword errors as a color and drops the rule"

s&box's radial-gradient parser accepts a color-stop list only. A standard CSS shape/size/position prelude (circle, ellipse, at 50% 50%) is read as the first color stop and aborts with a red 'Cannot read a color from ...' Code Error, silently dropping the rule. Write color stops only.

26.07.08e
"Razor panel mounted before a sync call never paints: budget frames, not ticks"

A loading screen mounted right before a synchronous multi-second call never paints: one consumed tick isn't enough; gate the block on a frame counter (~6 frames) so the Razor pipeline finishes mount → style → layout → paint.

26.07.08e
"Razor RenderFragment flex rows with gap collapse on top of each other"

Thin flex rows with a gap inside a Razor RenderFragment expression under-measure their height and pile on top of each other. The same markup renders fine in the component's main root.

26.07.18
"Razor tag resolution ignores global usings"

Razor component tag resolution does not consult global using directives: a library component renders as an inert HTML element while its class resolves fine in code-behind.

26.07.15a
"Render a player's Steam avatar with the avatar: texture-URL scheme"

s&box loads Steam avatars through an avatar:<steamid64> texture-URL scheme usable from both SCSS background-image and C# Texture.Load. It returns a transparent placeholder immediately and swaps in the real avatar with no panel re-render.

26.07.08e
"Roboto Mono is the engine's monospace font"

s&box ships Roboto Mono (all weights): JetBrains Mono and Consolas only resolve on dev machines with those system fonts. Only five families are reachable from UI CSS.

26.07.08e
"Runtime textures for UI panels must be assigned from C#"

SCSS background-image only resolves asset paths. Runtime Texture objects must be assigned via Panel.Style.BackgroundImage in OnTreeBuilt.

26.07.08e
"RZ1010 error from nested @{ } inside a Razor code block"

Nesting @{ } inside an already-open Razor code block (like @if or @for) causes RZ1010: you're already in C#, so drop the @.

26.07.22
"s&box composites UI alpha in linear space, so low-alpha whites render too light"

s&box blends UI alpha in linear color space while browser-authored mockups blend in sRGB, so every low-alpha white/light surface renders substantially lighter in-engine than the same rgba() value looks in a browser. Opaque colors match bit-for-bit: the divergence is isolated to the alpha compositing step and worst at low alpha. Match target rendered pixels, never authored alpha numbers.

26.07.22
"Sandbox.Json.Serialize always writes indented JSON"

Sandbox.Json.Serialize always emits pretty-printed JSON and exposes no public option to disable it: every network payload pays a whitespace tax. Re-drive the same serializer through a compact writer for a 41–56% size cut.

26.07.08e
"Scaffolded project missing global using System in Assembly.cs"

A freshly-scaffolded project's Assembly.cs can be missing global using System. Transplanted code using Math/MathF fails with CS0103.

26.07.08e
"ScenePanel Camera is read-only: configure it in place for 3D previews"

ScenePanel's Camera property is GET-ONLY. Assigning a new SceneCamera fails with CS0200. Configure the existing camera in place and build the preview world manually.

26.07.22
"ScreenPanel.ZIndex defaults to 100, and tied roots have no paint order"

ScreenPanel.ZIndex defaults to 100 (not 0), so any ScreenPanel that never sets a tier collides with the tier most projects author their modals at, and two roots with equal ZIndex have no defined paint order at all. Cross-root compositing is a stable OrderBy over an unordered HashSet, so tied roots keep an enumeration order that matches creation on a fresh boot but re-rolls on any enable/disable interleave or editor hotload. A full-screen surface can paint a perfectly healthy tree and still be invisible under a tied root.

26.07.22
"ScreenPanel.ZIndex orders paint only: pointer input falls through"

ScreenPanel.ZIndex orders paint only: pointer input is NOT routed by cross-root stacking, so a lower-ZIndex root keeps swallowing clicks even when a higher-ZIndex modal draws over it.

26.07.15a
"SliderControl near-min values render scientific notation in CSS style"

The engine SliderControl renders its thumb position as a raw float in a CSS style attribute: near-min values with snap residue produce scientific notation (4.9E-06%), causing a style parse error.

26.07.08e
"TextEntry is the house control, not HTML input"

Use TextEntry with onsubmit and OnTextEdited: standard Blazor input bindings compile but are not how the engine UI works.

26.07.08e
"The Razor @namespace trap"

Razor classes get a RootNamespace/folder-derived namespace: declare @namespace and global using or C# can't find your panels.

26.07.08e
"Three ways Razor text is in the DOM but invisible on screen"

s&box Razor panels have three distinct 'text is there but not on screen' failure modes (long-line clipping, narrow-segment overflow, and inline expression resolution), each with a different structural fix.

26.07.08e
"Too many font-size declarations (or too much text) corrupt UI glyph rendering"

SUPERSEDED: the font-size-declaration-count theory below was owner-settled (26.07.22) to be a CAPTURE-PATH artifact, not a real render bug. Panels with these exact font-size declarations render pin-sharp on the physical screen. Never strip font-size from a shipping stylesheet to 'fix' captures. See camera-screenshot-cannot-verify-fonts.

26.07.22
"transition-delay, :intro and :outro parse cleanly in razor-scss"

transition-delay, :intro and :outro all parse in the engine's razor-scss pipeline, tested isolated on engine build 26.07.22. An earlier caution that treated these selectors as unsupported can relax. A novel selector is worth one isolated test before you write it off, because an unparsed selector aborts the whole stylesheet to a 0x0 render.

26.07.22
"Undefined SCSS variable silently kills panels at runtime"

An undefined SCSS variable in a .razor.scss fails at runtime with only a console warning (no compile error, no toast) and the affected panels can render fully invisible. A trailing // comment on a value line fails the same way.

26.07.22
"Writing focus from onmouseover rebuilds the panel and swallows the click"

A button that never responds, with a clean console and a correct onclick handler, can be a BuildHash rebuild eating the click. If you write a focus-cursor field from onmouseover and that field participates in the panel's BuildHash, the mouse arriving over a row rebuilds the panel tree on the spot. The rebuild destroys and recreates the element under the cursor, and a press and release that straddles the rebuild is swallowed by the engine, so no onclick fires. Focus is a keyboard and gamepad concept, so never write it from a hover handler. Give the mouse its own affordance from CSS :hover, which needs no rebuild, and keep the keyboard focus index as a separate field if the panel needs both.

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord