# UI Construction Conventions

Underroot builds almost all of its runtime UI in code — no `.tscn` scene files, no visual editor layouts. Follow the conventions below and a new node behaves, sizes, and themes like the rest of the game.

## Procedural UI in `_ready()`

With two exceptions (`scenes/ui/TitleScreen.tscn` and the intro story scene), every UI element is constructed procedurally: the script's `_ready()` allocates nodes with `.new()`, wires their styles and signals, and attaches them with `add_child()`. There is no `.tscn` for the HUD, the bars, the drawers, or the modals.

The typical shape, seen across `scenes/ui/HUD.gd`, `scenes/ui/BottomBar.gd`, `scenes/ui/VillageBar.gd`, and `scenes/ui/BuildMenu.gd`:

```gdscript
func _ready() -> void:
	_build()                                    # allocate + style all nodes
	# connect EventBus / manager signals
	EventBus.food_changed.connect(func(v: float) -> void: _update_food(v))
	call_deferred("_initial_refresh")           # seed values after the tree settles
	get_viewport().size_changed.connect(_reposition)
```

Conventions that recur:

- A private `_build()` (or `_build_ui()` / `_build_left_panel()`) does all node allocation. `_ready()` stays a short assembly of build call, signal connections, and a deferred first refresh.
- Signal wiring goes through the `EventBus` autoload, never node-to-node. See [EventBus and the Signal Convention](/docs/underroot/eventbus-and-the-signal-convention).
- Initial data population is deferred with `call_deferred("_initial_refresh")` so it runs after the tree is fully built and after a continued run has restored `current_run`.
- Styling is applied per node with `add_theme_stylebox_override()`, `add_theme_color_override()`, and `add_theme_font_size_override()`. There is no global theme *resource*; each script builds its own `StyleBoxFlat` objects inline — but font sizes and shared colour ramps come from the `UITheme` tokens (next section), not magic numbers.

Because the tree is assembled in code, node references are held in typed member fields (`var _panel: PanelContainer`, `var _food_bar: ProgressBar`) rather than looked up by path.

## UITheme: the token file

`scripts/ui/UITheme.gd` (`class_name UITheme`, static) grew from a tooltip helper into the single source for the UI's type scale and semantic colour ramps (Talismans HUD work). New UI code MUST use the tokens; older panels migrate opportunistically when touched.

**Type scale** — names, not numbers, at call sites:

| Token | Value | Register |
|---|---|---|
| `T_CAPTION` | `11` | smallest legal persistent text |
| `T_BODY` | `13` | labels, gauge numbers |
| `T_TOAST` | `15` | toast text |
| `T_TITLE` | `16` | panel titles, section heads |
| `T_BANNER` | `22` | layer banners, storm toasts |
| `T_CARVED` | `32` | talisman face numerals (breach ETA, population, depth) |

**Semantic ramps** — one implementation instead of per-file copies:

```gdscript
UITheme.eta_color(seconds)   # supply/breach ETA urgency ramp
UITheme.timer_color(ratio)   # bar-fill ramp: green → amber → red as it empties
UITheme.legible(col)         # luminance floor for text on dark panels
```

Talisman visual constants (sizes, offsets, pulse rates) live at the top of their component script or in UITheme, so post-play tuning stays a one-line edit.

## HudShell: skin state and geometry

Two HUD skins exist (classic and talisman — see [The Talismans HUD](/docs/underroot/the-talismans-hud)). The conventions a UI node must respect:

- **Ask `HudShell.style()`**, never a sibling panel, when behaviour differs per skin; react to `EventBus.hud_style_changed` if the node outlives a flip.
- **Bottom chrome geometry** comes from the skin-aware `HUD.get_bottom_bar_y()` (classic: bottom bar top; talisman: the higher of crest/charm tops).
- **Threat panel geometry** comes from the `maw_bar` group: both MawBar and MawMedallion join it and expose `bottom_y()`, so consumers (BuildMenu, the tutorial ring) duck-type through the group instead of referencing a class.

## CanvasLayer sizing: set `.size` explicitly

Most top-level UI scripts `extends CanvasLayer` and position a child panel in screen space from a `_reposition()` method connected to `get_viewport().size_changed`. A CanvasLayer child does **not** resize to fit from `custom_minimum_size` alone — you must assign `.size` explicitly.

`scenes/ui/VillageBar.gd` `_reposition()` does both:

```gdscript
_panel.reset_size()
_panel.custom_minimum_size = Vector2(0.0, BAR_H)
_panel.size = Vector2(_panel.size.x, BAR_H)
_panel.position = Vector2(0.0, hud_bottom_y - BAR_H)
```

Setting only `custom_minimum_size` leaves the panel at its previous height. The explicit `.size` assignment is what actually applies the new dimensions. `scenes/ui/BuildMenu.gd` follows the same rule when it pins the drawer:

```gdscript
_panel.custom_minimum_size = Vector2(DRAWER_W, panel_h)
_panel.size                = Vector2(DRAWER_W, panel_h)
_panel.position            = Vector2(vp.x - maxf(_panel.size.x, DRAWER_W), top_y)
```

> Assigning `custom_minimum_size` without also assigning `.size` is the most common CanvasLayer sizing bug — the panel silently keeps its old size.

## Content-sized panels must be clamped to the viewport

A panel that sizes to its own content will happily grow past the bottom of the screen. Centring it is not a clamp: `maxf((vp.y - panel.size.y) * 0.5, MARGIN)` pins the *top* edge, and everything below the fold simply disappears. The LedgerOverlay shipped that way and a late-game factory pushed its last column off-screen.

The rule: **any panel whose height depends on run state must clamp to the live viewport on both axes and scroll its overflow.** Put the growing content in a `ScrollContainer`, leave the header outside it (a pinned header is worth more than three extra rows), then size the scroll region by measuring rather than by guessing at the chrome:

```gdscript
# Pass 1 — measure the panel around the content at its natural size.
var natural := _grid_wrap.get_combined_minimum_size()
_scroll.custom_minimum_size = natural
_panel.reset_size()
var chrome := _panel.size - natural        # borders + margins + header, exactly

# Pass 2 — hand the content whatever the screen actually leaves.
var avail := Vector2(minf(vp.x - MARGIN * 2.0, PANEL_MAX_W) - chrome.x,
                     vp.y - MARGIN * 2.0 - chrome.y)
_scroll.custom_minimum_size = Vector2(minf(natural.x, avail.x), minf(natural.y, avail.y))
_panel.reset_size()
```

Two pitfalls the two-pass form avoids:

- **Do not hardcode the chrome.** Deriving it from a measured pass keeps working when a header gains a row or a stylebox margin changes.
- **A `ScrollContainer` reports a near-zero minimum size** on any axis it can scroll, so a `PanelContainer` above it collapses unless you drive `custom_minimum_size` yourself.

Also: pay for the vertical scrollbar's width **up front** when the content is taller than the space (`want_x = natural.x + _scroll.get_v_scroll_bar().get_combined_minimum_size().x`), or the bar eats into the content width and induces a spurious *horizontal* scrollbar.

A "max width" constant must be a real cap. `PANEL_MAX_W` was originally used only inside the centring maths — a panel wider than it was centred *as if* it were that wide, and hung off the right edge.

### Scrollbar gutters

A scrollbar sits at the right edge of the scroll region, so a fat panel margin parks it in the middle of nowhere while the content still runs right up against it. Invert it: give the panel a **small** right content margin so the bar rides the border, and give the content its own gutter with a `MarginContainer` inside the scroll. Apply the same inset to the pinned header so its controls keep their distance from the edge.

Where the skin's scrollbars are deliberately quiet, say so in words — the ledger appends `· scroll for more` to its header hint when the content is clipped.

## PanelContainer shrink after hide: call `reset_size()`

A `PanelContainer` sizes to its visible content, but hiding a child (or zeroing its `custom_minimum_size`) does not shrink the container synchronously — Godot defers the recalculation. Call `reset_size()` on the container to force it that frame.

`scenes/ui/HUD.gd` calls it every time the sidebar swaps what it shows:

```gdscript
func _switch_tab(tab: String) -> void:
	_active_tab              = tab
	_left_scroll.visible     = tab == "items"
	_journal_scroll.visible  = tab == "journal"
	# ...
	_reposition()
	_left_panel.reset_size()
```

`_toggle_sidebar()` does the same after collapsing the panel to its compact view. Without the `reset_size()` call the panel would keep the height of whichever tab was previously larger.

`scenes/ui/MawBar.gd` combines this with immediate child removal. When the expandable column queue collapses, its rows are `free()`d (synchronous) rather than `queue_free()`d (deferred to frame-end), so that the following `reset_size()` measures the panel without the dead rows:

```gdscript
func _refresh_queue() -> void:
	for child in _queue_box.get_children():
		child.free()   # immediate — queue_free would leave rows in the tree at measure time
	# ... rebuild rows ...
```

Its `_reposition()` then calls `_panel.reset_size()` before reading `_panel.size.x`, so the right-edge pin is exact on the same frame.

## Centered modals: FULL_RECT CenterContainer

Centering a panel — especially one containing an autowrapping label — is done by nesting it in a `CenterContainer` anchored to `PRESET_FULL_RECT`. `PRESET_CENTER` and `reset_size()` both misplace the panel and must not be used for this.

`scenes/ui/ConfirmModal.gd` states the rule directly in its header comment: the panel is "Wrapped in a FULL_RECT CenterContainer — the pattern that actually centers a panel with an autowrapping label (PRESET_CENTER and reset_size both misplace it)."

`scenes/ui/SettingsPanel.gd` shows the full three-layer idiom:

```gdscript
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)

var dimmer := ColorRect.new()                       # visual dim, no input
dimmer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dimmer.mouse_filter = Control.MOUSE_FILTER_IGNORE

var click_catcher := Control.new()                  # closes on outside click
click_catcher.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
click_catcher.mouse_filter = Control.MOUSE_FILTER_STOP

var center := CenterContainer.new()                 # sizes + centers the panel
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_PASS      # miss-clicks fall through to click_catcher
```

The layering matters: a full-rect dimmer for the visual, a full-rect click-catcher that closes the modal on an outside click, and a `MOUSE_FILTER_PASS` `CenterContainer` so clicks that miss the panel reach the catcher behind it. The panel itself sets `mouse_filter = Control.MOUSE_FILTER_STOP` to swallow its own clicks.

Simpler transient popups (the songbook, the challenge-details panel in `scenes/ui/BottomBar.gd`) use Godot's `PopupPanel` with `popup_centered()` instead — appropriate when there is no custom dimmer or outside-click behaviour to manage.

Full-screen overlays size against the **live** `get_viewport_rect().size`, never a hardcoded 1280×720 — the stretch aspect is `expand` (the LedgerOverlay and storm overlays both follow this).

## Drawn markers instead of glyphs

Web builds have no OS fonts; the bundled emoji fallback is a **hand-subset** of about three dozen glyphs (see the cosmetics/fonts notes). Any dingbat outside that set renders as tofu. So decorative markers are **drawn** with `draw_circle` / `draw_rect` / `draw_colored_polygon` in a `_draw()` rather than typed as a character — SkillTreeOverlay's node diamonds, stars and crowns, and LineageOverlay's headstones and ritual diamonds all do this. Text inside a drawn control uses `draw_string(get_theme_default_font(), …)` with a `UITheme` size token.

## Tooltips: `_make_custom_tooltip` and the UITheme Tip subclasses

Godot draws tooltips in a separate popup whose theme does not inherit from the root `Window` at runtime, so a global theme cannot style them. The reliable mechanism is overriding `_make_custom_tooltip(for_text)` to return the literal `Control` to display. `scripts/ui/UITheme.gd` centralizes this.

`UITheme.build_tooltip(for_text)` returns a dark `PanelContainer` matching the game's panel style. It respects manual `\n` line breaks (the built-in tooltip label does not auto-wrap) and understands `[c=#rrggbb]…[/c]` spans for tinting segments such as material names.

Rather than override the method on every node, `UITheme` provides three ready subclasses:

| Class | Base | Use for |
|---|---|---|
| `UITheme.TipPanel` | `PanelContainer` | a panel whose `tooltip_text` should render styled |
| `UITheme.TipButton` | `Button` | any styled button with a tooltip |
| `UITheme.TipControl` | `Control` | a bare control (e.g. a hover region) with a tooltip |

Swap the plain node for its `Tip*` variant and set `tooltip_text`; the tooltip then renders in the game's dark panel instead of Godot's pale default. `scenes/ui/BuildMenu.gd` uses `UITheme.TipButton.new()` for its tab buttons and action buttons; `scenes/ui/MawBar.gd` uses `UITheme.TipPanel` for the threat panel and `UITheme.TipButton` for the chevron and surge-song buttons.

When a node needs a *dynamic* tooltip — different text per hover position — it overrides `_make_custom_tooltip` itself and delegates to `UITheme.build_tooltip`, setting `tooltip_text` from `_gui_input`. `scenes/ui/DepthRuler.gd` does exactly this to describe whichever layer band the cursor is over:

```gdscript
func _make_custom_tooltip(for_text: String) -> Object:
	return UITheme.build_tooltip(for_text)

func _gui_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion:
		tooltip_text = _tooltip_for_local((event as InputEventMouseMotion).position)
```

A separate hover tooltip pattern exists for cases that need live per-frame data (a status line recomputed at hover time): `scenes/ui/VillageBar.gd` builds its own `PanelContainer` with a `RichTextLabel`, shows it on `mouse_entered`, and positions it manually above the hovered cell. Use this only when `_make_custom_tooltip` (which receives a static string) cannot carry the live content.

## High-frequency signal rebuild gotcha

Some `EventBus` signals fire every frame. `storage_changed` fires continuously while a silo or water tower tops up its bar. A handler that does a full UI rebuild on such a signal will free and recreate its child buttons dozens of times per second, and any click landing between the free and the rebuild is lost.

The rule: on a high-frequency signal, update label text and bar values **in place**; never rebuild the node structure. `scenes/ui/BuildMenu.gd` is explicit about it:

```gdscript
# storage_changed fires every frame while a silo/tower tops up the bar — only the
# fill numbers change, never the card structure. Update labels in place; a full
# rebuild here would free+recreate the Build buttons 60×/s and eat every click.
EventBus.storage_changed.connect(func(_sf: float, _sw: float) -> void: _update_storage_status_labels())
```

`_update_storage_status_labels()` walks cached label references (`_storage_status_lbls`) and rewrites only their `.text`. Structural refreshes (`_refresh_storage_cards()`, which frees and rebuilds the cards) are reserved for low-frequency signals like `recipe_unlocked` or `tool_unlocked`. `scenes/ui/BottomBar.gd` applies the same split: `storage_changed` and `well_changed` update values and flip a row's `visible` flag, and only a *visibility change* (a row appearing or disappearing) emits `layout_changed` — never a value tick. The talisman LedgerOverlay follows the same discipline while open: values refresh in place, and its machines block structurally rebuilds only when grouping changes — never while the cursor is over it, and it re-runs the viewport clamp afterwards because the rebuild can change the column's height.

The same rule reaches dropdowns: the exchange `OptionButton`s carry live held-counts in their item labels, and both refuse to relabel while `get_popup().visible` — rewriting a list the player is mid-click on is the same footgun in a different shape.

## Key files

- `scripts/ui/UITheme.gd` — type-scale tokens (`T_CAPTION`…`T_CARVED`), semantic ramps (`eta_color`, `timer_color`, `legible`), tooltip styling and the `TipPanel` / `TipButton` / `TipControl` subclasses.
- `scripts/ui/HudShell.gd` — HUD skin state; ask it, not a sibling panel.
- `scenes/ui/SettingsPanel.gd` — canonical FULL_RECT `CenterContainer` modal (dimmer, click-catcher, centered panel).
- `scenes/ui/ConfirmModal.gd` — minimal centered-modal reference; documents why `PRESET_CENTER` fails.
- `scenes/ui/LedgerOverlay.gd` — two-pass viewport clamp, scrollbar gutter, in-place refresh.
- `scenes/ui/HUD.gd` — `reset_size()` after tab swaps; procedural sidebar build.
- `scenes/ui/VillageBar.gd` — explicit CanvasLayer `.size` assignment in `_reposition()`.
- `scenes/ui/BuildMenu.gd` — in-place label updates vs. structural rebuild on frequency-sensitive signals.

## Related

- [HUD and Bars Reference](/docs/underroot/hud-and-bars-reference)
- [The Talismans HUD](/docs/underroot/the-talismans-hud)
- [EventBus and the Signal Convention](/docs/underroot/eventbus-and-the-signal-convention)
- [The Scene Tree](/docs/underroot/the-scene-tree)
- [Performance Mode](/docs/underroot/performance-mode)