# Default components

`hudkit` is the shipped library. It drives `s2script_lib.xml` in addon `3790153369`. Plugins pass **data**, not panel ids. Modal and badge claims are shared across every plugin on the host — six center sheets and four badges on one layout entity. Two plugins cannot both own `s2_m0`; unload frees that plugin's slots. Import `hudkit` from `@s2script/cs2` and claim panels from `OnPluginStart` or a later callback — the kit binds to this plugin's context, so a kit touched at module eval would paint panels that never deliver clicks. Methods and `layout`/`hud` throw before the plugin context exists (`s2script: hudkit.<name> requires plugin context; use it in OnPluginStart or a later callback`); claimed handles stay usable after initialization settles. Modal/badge claims return `null` only when their pool is exhausted (`tryModal` / `tryBadge` report `PoolExhausted` instead). Toast, badge, modal, callout, banner, motd, and dashboard hang off `hudkit` itself (same object as `CustomHudLayout.kit`). Prefer `forSlot` on the kit, a modal, and a badge so drive calls do not re-thread the slot. `forSlot` is per-slot storage on that shared entity, not private delivery. Shared hudkit is **non-observable** (`observable: false`): a spectator keeps their own UI. Observation is rendering policy, not confidentiality — do not put secrets in HUD state ([HUD hazards](https://s2script.com/docs/concepts/hud#hazards)). Use a separate custom resource if you need an observable layout.

```ts
import { hudkit } from '@s2script/cs2';

export function OnPluginStart(): void {
	// claim modal / badge here
}
```

Use `hudkit` from `OnPluginStart` for claims, renderers, and click hooks that must register while the load window is open. Click routes for pooled sheets register once at kit init; a later claim only swaps which handle receives them. `hudkit.spec` / `hudkit.descriptor` stay readable at module top-level, so `CustomHudLayout.components(hudkit.spec)` does not depend on resolution order. Component kits are cached by layout `resource` — that call and equivalent descriptor copies reuse the existing kit instead of installing duplicate click handlers.

| Kind      | Pool                             | Claim                                                                 |
| --------- | -------------------------------- | --------------------------------------------------------------------- |
| Toast     | 4, rotating                      | `hudkit.forSlot(slot).toast(spec)` — no claim, next free line         |
| Badge     | 4                                | `hudkit.badge(spec)` — `null` when exhausted; `tryBadge` → `UiResult` |
| Modal     | 6 (`s2_m0`–`s2_m5`), 8 rows each | `hudkit.modal(spec)` — `null` when exhausted; `tryModal` → `UiResult` |
| Callout   | 1                                | `hudkit.forSlot(slot).callout(spec)` — no claim, no cursor            |
| Banner    | 1                                | `hudkit.forSlot(slot).banner(spec)` — no claim, no cursor             |
| MOTD      | 1                                | `hudkit.forSlot(slot).motd(spec)` — scrim + OK, not a Menu            |
| Dashboard | 1, dedicated                     | `hudkit.dashboard(spec)` — last spec wins; not a modal-pool slot      |

The modal pool is six `s2_m*` trees in `s2script_lib.xml`, shared across every plugin. That number belongs to the **markup** — a server can only address panels the client's layout already contains, so raising the runtime pool alone hands out sheets that paint nothing (Panorama ignores unknown ids silently). Exhaustion still returns `null` and logs `modal pool exhausted`. `tryModal` / `tryBadge` keep that claim timing and report `PoolExhausted`. Built-in menus do not reserve a sheet at load — they claim on first open; a busy pool or a failed HUD `tryOpen` falls that session to chat (later opens retry). Vote rail (`s2_vote`), callout, banner, MOTD, and the TopMenu hub (`s2_dash`) are dedicated roots — they are not `s2_mN` sheets and do not spend from this pool.

`Variant` is `"primary" | "good" | "warn" | "bad" | "ghost"`. `ghost` is the low-emphasis default for footer buttons.

Need a kill feed or scoreboard the library does not cover? [Mount your own layout](https://s2script.com/docs/concepts/hud/custom).

## Structured results

Additive counterparts to the nullable / throwing / `HudResult` APIs. Existing methods keep their published behavior. Branch on `error.code`, never parse `message`.

```ts
type UiErrorCode =
	| 'NotReady'
	| 'StaleClient'
	| 'Released'
	| 'Unavailable'
	| 'PoolExhausted'
	| 'Busy'
	| 'InvalidArgument'
	| 'PaintFailed';

type UiResult<T> =
	{ ok: true; value: T } | { ok: false; error: { code: UiErrorCode; message: string } };
```

`hudkit.tryModal(spec)` / `tryBadge(spec)` claim like `modal()` / `badge()` but report `PoolExhausted` instead of returning `null`. `tryOpenResult` on modal/dashboard (owners and views) is the structured twin of `open` / `tryOpen`. Owner `tryRefresh(slot)` requires a slot so a mixed bulk result cannot hide which player failed; bound views use `tryRefresh()`. `HudLayout` / `HudPlayer.tryShow` and `BadgeView.tryShow` report the drive result.

`HudLayout.status()` is server readiness (`ready` | `not-ready` | `unavailable`). `clientContent` is always `"unknown"` — this API cannot prove a client mounted the addon or rendered the paint. A MAM cvar or successful engine submission is not a render acknowledgement.

Failed structured ops roll back claims/capture. Incomplete paints disable interaction until an explicit successful retry. There is no automatic retry queue.

## Exclusive focus

Modal and dashboard opens accept `focus`, and `MotdSpec` accepts the same option. All three participate in one host-owned stack per layout entity and client connection, including across plugins. This does not control engine z-order and is not a client render acknowledgement (`clientContent` stays `"unknown"`).

```ts
import { hudkit, type UiFocusOptions } from '@s2script/cs2';

const focus: UiFocusOptions = { mode: 'exclusive', priority: 10 };
modal?.tryOpenResult(slot, { focus, cursor: true });
hudkit.motd(slot, { title: 'Rules', focus });
```

`UiFocusOptions` is `{ mode: "exclusive"; priority?: number }`. Higher priority wins; the latest successful reservation wins ties. Default `0`. Priority must be an integer in `-2147483648`…`2147483647`; fractions, nonfinite numbers, strings, and out-of-range values fail with `InvalidArgument` before painting. Reopening and replacing a dashboard spec make fresh reservations. Ordinary refresh and restoration keep their reservation order.

Reservation happens before providers, engine writes, or capture. A covered open succeeds as a logical open and keeps desired navigation and cursor state, but does not evaluate providers, paint, capture input, or dispatch actions. Taking focus hides the outgoing root and releases that panel's capture before the incoming component paints, even when the incoming open requests `cursor: false`. Independent manual capture holders remain independent.

Closing, forgetting, releasing, disconnecting, or unloading a winner retires its exact reservation. A surviving contender waits a later host frame, then clears its paint cache, evaluates fresh providers, completely repaints, restores desired capture, and activates. Restoration also clears the cache when the component never observed that it was covered.

Component actions check host focus immediately before dispatch. A newly activated component cannot consume the input delivery that opened it, including nested delivery. A failed initial paint leaves no open focused presentation; a failed refresh or restoration disables actions and releases focus. Desired state may remain available for an explicit `refresh()` / `tryRefresh()`. Failure does not retry each frame. Modal `setCursor` remembers changes while covered and applies them only when the component owns focus again.

Use `tryOpenResult` to inspect reservation errors, including `Busy`, without parsing text. MOTD keeps its existing return: a failed focused open logs a diagnostic and returns an invalid no-op `MotdHandle` — no new structured MOTD-open method.

Calls without `focus` keep their existing behavior. `CustomHudLayout.onClicked` still observes; `HudInput` state/arming is unchanged. Focus is not global click suppression: legacy menus, low-level layouts, and manual drives stay outside arbitration. Dispose your own component handles for focused presentation cleanup; owned-surface helpers preserve explicit claims.

## Explicit invalidation

Modal and dashboard `invalidate()` schedule open views for one repaint on the next server frame. Owner calls may name a slot or omit it to invalidate every open view. Repeated invalidations of the same live component/client pair coalesce into one repaint.

Use `invalidate()` when many updates may land in one frame. Use `refresh()` / `tryRefresh()` when you need a synchronous repaint now. Synchronous `refresh()` is preserved: a successful sync repaint fulfills invalidation intent that was already pending when it started. An invalidation raised from inside a provider is newer intent and runs on the following frame (no recursion into the active repaint).

Each repaint evaluates its row provider once. Paging, selection, details, footer placement, and the submitted click snapshot all derive from that same candidate. Covered exclusive-focus views retain dirty intent without evaluating providers or accepting input. When focus becomes ready, restoration and a pending invalidation share one successful repaint.

`lastUpdateResult()` on `ModalView` / `DashboardView` is the last completed initial open, synchronous repaint, or deferred repaint for that live bound view. It is `null` before the first completed update. Scheduling an invalidation does not erase the previous result. A deferred failure disables interaction, records the source `UiResult`, logs once on entry into failure, and does not retry automatically; a later successful explicit update rearms that diagnostic.

Ordinary close/reopen preserves the view's component/client lifetime and result history. After release, forget, reconnect, or layout-entity replacement the retained view is stale and `lastUpdateResult()` returns `null`; a replacement view has an independent history.

## Owned shared surfaces

Toast, banner, callout, MOTD, and dashboard roots are shared by every plugin using the shipped hudkit layout. Legacy presentation methods reserve host occupancy while they are visible; an explicit claim reports `Busy` instead of overwriting them. An explicit claim never evicts another explicit claim.

Player-bound claims return `UiSurfaceHandle` (`isValid()` / idempotent `dispose()`):

```ts
const banner = hudkit.forSlot(slot).tryOwnBanner({ text: 'Match starts soon' });
if (banner.ok) banner.value.dispose();
```

`tryOwnToast` / `tryOwnBanner` / `tryOwnCallout` / `tryOwnMotd` hang off `hudkit.forSlot(slot)`. Toast has four lanes per player (host-selected allocation). Timers and stale disposers carry the exact host token and cannot fade or hide a replacement presentation.

Dashboard construction is player-independent: `hudkit.tryOwnDashboard(spec)` returns `UiResult<OwnedDashboard>`. The per-player claim happens when `tryOpenResult(slot)` runs — one controller can serve several players; contention is isolated per connection.

```ts
const owned = hudkit.tryOwnDashboard(spec);
if (owned.ok) {
	const opened = owned.value.tryOpenResult(slot, { focus: { mode: 'exclusive' } });
	if (!opened.ok && opened.error.code === 'Busy') {
		// Another live presentation owns this player's dashboard root.
	}
}
```

Focused MOTD and dashboard claims keep ownership while covered or waiting. Their physical focus lease is linked to the claim, so replacement, disconnect, unload, and disposal retire capture and visibility together (no hide fallback after a stale token release). Dispose transient handles and owned dashboards when their work is done. Retained views report `Released` after their owner is disposed.

`hideAll(slot)` atomically clears legacy and free owned-surface lanes while preserving every explicit claim, including claims created by the caller. Dispose explicit handles directly. For modal and badge pools, `hideAll` closes only this plugin context's live claims. Raw `HudLayout` writes stay outside ownership arbitration.

## Toast

Transient notice. Four lanes per player (host-selected). Drive from any slot-bearing callback (`OnClientActive`, `player_death`, a command).

```ts
hudkit.forSlot(slot).toast({
  title?: string;
  message?: string;
  variant?: Variant;     // default unstyled
  holdSeconds?: number;  // default 6; 0 keeps it until replaced
});
```

Returns `HudResult` (`null` on success). Legacy `toast()` reserves host occupancy while visible; `tryOwnToast(spec)` claims a lane and reports `Busy` instead of overwriting another live presentation. Four lanes per player (host-selected). A generation stamp per line so a replacement is not yanked by the previous hold timer. `hudkit.toast(slot, spec)` stays for bulk loops.

## Badge

Persistent corner element. Claim once, then `show` / `hide` per player.

```ts
const badge = hudkit.badge({
  corner?: "tl" | "tr" | "bl" | "br";  // default "tr"
  title?: string;
  accent?: "accent" | "good" | "warn" | "bad";
});

const bv = badge?.forSlot(slot);
bv?.isValid();
bv?.show({ title?: string; text?: string });
bv?.tryShow({ title?: string; text?: string });  // UiResult<void>
bv?.hide();
badge?.release();  // hide for remaining viewers and return the pool slot
```

`show` overlays `title` / `text` on the spec defaults for that player. Slot-first `badge.show(slot, data)` / `badge.hide(slot)` adopt the current occupant. A retained `BadgeView` stays valid across ordinary hide/show; it goes stale on reconnect, `release` / `forget`, or layout replacement. `badge.release()` returns the pool slot **and** hides the badge for every player still seeing it. Unload/reload also hide it: a shown badge holds an owned, hide-only surface lease so the host's owner sweep can clear it.

## Modal

Paged list. `onPick`, `cursor()`, and `select()` use **absolute** indices into the full row list, not the current page. Clicks do not re-read live row providers. After a complete successful paint, the library snapshots submitted rows, absolute indices, footer actions, and callbacks, then dispatches against that snapshot. A partial or failed paint disables interaction until a complete successful repaint. `onPick` receives the copied row and absolute index from that last complete page — revalidate current domain state by `row.id` instead of indexing a freshly fetched collection with `index`. Engine click delivery has no render revision, so a delayed click after the same panel is reused cannot be distinguished.

```ts
const modal = hudkit.modal({
  title: string | ((slot) => string);
  subtitle?: string | ((slot) => string);  // default: page indicator when paged
  rows: Row[] | ((slot) => Row[]);
  onPick?: (slot, index, row, view) => void;
  detail?: (slot, row, cursor) => string[];  // up to 4 lines; last is clamped
  buttons?: FooterButton[] | ((slot) => FooterButton[]);  // up to 5
  pageSize?: number;                         // default 8
  width?: "sm" | "md" | "lg" | "xl";         // default "md" (560px)
});
```

`Row` is `{ id?: string; a: string; b?: string; c?: string; disabled?: boolean; tone?: "good" | "warn" | "bad" }`. Optional `id` is a stable domain identity; supplied IDs must be unique within the row collection (duplicates throw / `tryOpen` fails). `a` flexes; `b` and `c` are right-aligned. `disabled` greys the row — `onPick` still fires so you can say why. `tone` tints the primary cell (`.s2-cell-a`) green / amber / red via `.s2-li-good` / `.s2-li-warn` / `.s2-li-bad` on the row button — the same descendant-rule shape as `.s2-btn-good .s2-btn-label`. It composes with `disabled` and the selection highlight: a row can be the cursor, unaffordable, and bad at once. Only `a` is tinted; `b` and `c` stay muted.

`tone` needs a workshop addon carrying those CSS rules — republish `s2script_lib.css` on item `3790153369`. On an older addon the class is set but the client has no rule, so the row renders untinted. Never make a tone the only way a row communicates something; keep a text marker like `[BAD]` when the meaning matters.

`FooterButton` is `{ text: string; variant?: Variant; onClick: (slot, view) => void }`. `buttons` may be a per-slot function. Prev/Next take the trailing two footer slots automatically when the list pages. The handler's `view` is that player's `ModalView`. Footer clicks dispatch through the handler table from that player's last complete paint.

```ts
const v = modal?.forSlot(slot);
v?.isValid(); // false after reconnect / release / layout replacement
v?.open(); // default grabs the cursor; throws if paint/show fails
v?.tryOpen(); // { ok: true, view } | { ok: false, error: string }
v?.tryOpenResult(); // UiResult<ModalView> — branch on error.code
v?.open({ cursor: false }); // paint without stealing the mouse (Tab menus)
v?.open({ focus: { mode: 'exclusive' } });
v?.close();
v?.isOpen();
v?.refresh(); // synchronous repaint now
v?.tryRefresh(); // UiResult<void> for this bound player
v?.invalidate(); // coalesce a next-frame repaint
v?.lastUpdateResult(); // last completed open/repaint; null before first
v?.page(delta);
v?.select(index); // absolute; pages to it
v?.cursor(); // absolute index, same space as onPick
v?.forget();
modal?.setCursor(slot, true); // grab or release the mouse without closing
modal?.release();
```

`forSlot(slot)` / `open(slot)` adopt the **current** occupant and return a fresh valid view. A retained `ModalView` stays reusable across ordinary same-client close/open. It goes stale on same-slot reconnect (even the same SteamID), `release` / `forget`, or layout replacement. Stale `open` throws (`/stale/`); other stale ops no-op or fail closed. Ordinary close/open does not invalidate the view.

`open(slot, opts?)` still exists and returns the bound view. Default `{ cursor: true }`. `opts.focus` opts into the shared exclusive-focus stack. It throws a descriptive error when paint or show fails (`hudkit: modal.open failed: …`). `tryOpen` returns `ModalOpenResult` — `{ ok: true, view }` or `{ ok: false, error }` — and leaves the modal closed on failure (retryable; `isOpen` stays false until paint and show succeed). `tryOpenResult` is the structured twin (`UiResult<ModalView>`). `refresh(slot?)` on the modal — omit `slot` to repaint every player who has it open. Owner `tryRefresh(slot)` requires a slot. `invalidate(slot?)` schedules a coalesced next-frame repaint (omit `slot` for every open view). Bound views expose `invalidate()` and `lastUpdateResult()`. Use `invalidate()` when many updates may land in one frame; use `refresh()` when you need a synchronous repaint now.

`release()` closes every open viewer, revokes this handle's click routes, and returns the pool slot. Idempotent. A released handle cannot repaint or steal clicks; `open` after `release` throws (`tryOpen` reports `{ ok: false }`). Reclaiming a panel invalidates that tree's paint cache so a prior owner's writes do not suppress the new paint. A panel released during an event fan-out (including nested callbacks / unload) is not handed to another plugin until that dispatch returns — a click that opens another plugin's menu does not also select its first item.

A non-focused open modal holds an owned, hide-only surface lease while shown, so plugin unload/reload hides the sheet (hot reload used to leave it on screen with nothing able to close it). Focused presentation still retires with its claim as before.

Put attacker-controlled text on the last `detail` line and escape it before it reaches the call.

## Callout

Hint-style overlay. Bottom-center, no cursor. No claim — one at a time.

```ts
hudkit.forSlot(slot).callout({
  title?: string;
  message?: string;
  variant?: 'good' | 'warn' | 'bad';
  holdSeconds?: number;  // default 4; 0 keeps it until replaced
});
```

Returns `HudResult`. Legacy `callout()` reserves occupancy while visible; `tryOwnCallout(spec)` claims the root and reports `Busy` instead of overwriting. `hudkit.callout(slot, spec)` stays for bulk loops.

## Banner

Center-top broadcast strip. One at a time, no cursor.

```ts
hudkit.forSlot(slot).banner({
  text: string;
  holdSeconds?: number;  // default 5; 0 keeps it until replaced
});
```

Returns `HudResult`. Legacy `banner()` reserves occupancy while visible; `tryOwnBanner(spec)` claims the root and reports `Busy` instead of overwriting. `hudkit.banner(slot, spec)` stays for bulk loops.

## MOTD

First-join / rules overlay. Scrim + OK — not a Menu and not a pooled `s2_mN` sheet (the pool is `s2_m0`–`s2_m5`). Stays until the player clicks OK or you call `MotdHandle.close`. `onClose` fires on the OK click only, not on `close()`.

```ts
const h = hudkit.forSlot(slot).motd({
  title: string;
  subtitle?: string;
  sections?: { heading?: string; body?: string }[];  // up to 3; unused slots hidden
  note?: string;
  ok?: string;           // default "OK"
  cursor?: boolean;      // default true
  focus?: UiFocusOptions;
  onClose?: (slot) => void;
});
h.isValid();             // false after close / replacement / client lifetime end
h.close();
```

`hudkit.motd(slot, spec)` stays for bulk loops. A failed focused open logs a diagnostic and returns an invalid no-op `MotdHandle` — no structured MOTD-open method. `tryOwnMotd(spec)` claims the root (`UiResult<UiSurfaceHandle>`); `Busy` means another live presentation owns it. Focused MOTD ownership stays linked to the physical focus lease.

## Dashboard

Tabbed hub. Dedicated chrome `s2_dash` on `s2script_lib.xml` — not a pooled `s2_mN` sheet and not a modal-pool slot. Last spec wins. Always returns a handle (the panel is a single root, not a pool). Throws before plugin context exists — that is not pool exhaustion.

```ts
const dash = hudkit.dashboard({
  title: string | ((slot) => string);
  subtitle?: string | ((slot, tabId) => string);
  closeText?: string;                        // default "Close"
  tabs: DashTab[] | ((slot) => DashTab[]);
  rows: (slot, tabId) => DashRow[];
  onPick?: (slot, tabId, row, view) => void;
  onClose?: (slot) => void;                  // Close click only, not close()
});
```

`DashTab` is `{ id: string; title: string }`. `DashRow` is `{ id: string; a: string; b?: string; disabled?: boolean }`. `a` is the label; `b` is optional right text. Tab `id`s must be unique within the submitted collection; row `id`s must be unique within the active tab's submitted rows — duplicates throw on open/paint. `disabled` greys the row and swallows the click — unlike a modal, `onPick` does not fire.

`onPick` receives the tab ID and a copied row from the last completely submitted page. Revalidate by `row.id` before acting. Same snapshot-dispatch rules as a modal: a partial or failed paint disables interaction until a complete successful repaint; engine clicks have no render revision.

```ts
const v = dash.forSlot(slot);
v.isValid(); // false after reconnect / spec replacement / forget
v.open(); // default grabs the cursor
v.tryOpenResult({ tab: 'players' });
v.open({ tab: 'players', focus: { mode: 'exclusive' } });
v.close();
v.isOpen();
v.setTab('bans');
v.refresh();
v.tryRefresh();
v.invalidate();
v.lastUpdateResult();
```

`dashboard.open(slot)` / `forSlot(slot)` adopt the current occupant. A retained `DashboardView` stays valid across ordinary same-client close/open. It goes stale on reconnect, dashboard spec replacement, `forget`, or layout replacement.

`open(slot, opts?)` still exists and returns the bound view. Default `{ cursor: true }`. `opts.focus` opts into exclusive focus. `tryOpenResult` is the structured twin. `refresh(slot?)` — omit `slot` to repaint every player who has it open. Owner `tryRefresh(slot)` requires a slot. `invalidate(slot?)` / view `invalidate()` / `lastUpdateResult()` match the modal contract.

The legacy `hudkit.dashboard(spec)` controller uses one spec at a time (last configuration wins); each open reserves host occupancy for that player. `hudkit.tryOwnDashboard(spec)` constructs an independent `OwnedDashboard`; the per-player claim happens on `tryOpenResult(slot)`. `dispose()` is idempotent.

[adminmenu](https://s2script.com/plugins/adminmenu) paints `sm_admin` / `sm_menu` through this API. Plugins declare tabs with [`topmenu.addTab`](https://s2script.com/docs/authoring#topmenu); empty tabs are omitted by the caller.

## Library panels

Production `s2script_lib.xml` (source in [`examples/hud-lab/workshop/`](https://github.com/s2script/s2script/tree/main/examples/hud-lab/workshop)) declares:

- `s2_t0`–`s2_t3` toasts, `s2_b0`–`s2_b3` badges, `s2_m0`–`s2_m5` center sheets (count must match the runtime modal pool)
- `s2_callout`, `s2_banner`, `s2_motd`
- `s2_vote` right-side rail (starts `s2-hide`); `s2_vote_q` / `s2_vote_sub`; `s2_vote_o0`–`o8` (`_t` label, `_c` count). `.s2-vote-dock` places the rail; older `.s2-vote` card rules stay for `s2script_hud_live.xml`'s yes/no tally.
- `s2_dash` TopMenu hub: tabs `s2_dash_t0`–`t7`, rows `s2_dash_r0`–`r7`, Close, Prev/Next, status. Not a pooled `s2_mN`.

Row tone (`.s2-li-good` / `.s2-li-warn` / `.s2-li-bad`) tints `.s2-cell-a` through a descendant rule, driven by `Row.tone`. A client on an addon without those rules renders the row untinted, so a tone is always decoration on top of text that already stands alone.

Votes drive `s2_vote*` through `hudkit.layout` — not a second `CustomHudLayout.create`, not a separate `s2_vote.xml`. The stylesheet include must stay `file://{resources}/styles/custom_game/s2script_lib.css`. An `s2r://` include compiles clean and ships unstyled. Republish the same workshop item (`3790153369`) after compiling on Windows Workshop Tools so `s2_m0`–`s2_m5`, `s2_dash`, the `.s2-li-*` row-tone rules, and buy-menu / scoreboard yield reach clients. Do not add a second layout.

Passive overlays yield to the client buy menu and scoreboard. CS2 applies `HUD_BUYMENU_VISIBLE` / `HUD_SCOREBOARD_VISIBLE` on an ancestor; the library CSS hides toasts, badges, banners, and callouts with descendant selectors (`.HUD_SCOREBOARD_VISIBLE .s2-toast-stack`, …). Interactive panels stay visible because CSS cannot release server-owned cursor capture. Custom layouts draw above the base HUD. **Compile and republish the workshop stylesheet** — updating the server bundle alone does not update clients.

**Publish the six-sheet layout before deploying a runtime whose modal pool is 6.** Extra unused trees are harmless (interning is lazy). A runtime expecting six sheets against a two-tree client addresses ids that client does not have, and Panorama ignores unknown ids silently — sheets 2–5 paint nothing. That order is the opposite of a CSS-only tint (`Row.tone`), where publishing after the runtime is safe. Old clients with only `s2_m0`/`s2_m1` keep the common case (claims lowest-first); a third concurrent sheet paints nothing rather than breaking. Each sheet costs ~51 panel ids; the layout now interns **432** of the **1024** intern cap (was 228).

## Shared

- `hudkit.forSlot(slot).hideAll()` / `hideAll(slot)` — atomically clear this context's pooled panels plus legacy and free owned-surface lanes (toast, callout, banner, motd, dashboard). Preserves every explicit `tryOwn*` / `tryOwnDashboard` claim, including the caller's — dispose those handles directly. Modal/badge pools close only this plugin context's live claims. Raw `HudLayout` writes stay outside ownership arbitration.
- `hudkit.forSlot(slot).forget()` / `forget(slot)` — drop per-player state (disconnect); also closes callout, banner, motd, and the dashboard.
- Retained views (`HudPlayer`, `HudKitPlayer`, `ModalView`, `DashboardView`, `BadgeView`, `MotdHandle`) expose `isValid()`. Slot-first APIs adopt the current occupant. Ordinary same-client close/open (or hide/show) keeps the view valid. Reconnect — even the same SteamID — `release` / `forget`, layout replacement, dashboard spec replacement, and MOTD close stale a saved view. Stale ops no-op or fail closed (`open` throws `/stale/`; hudkit methods return `"stale client"`). Delayed fades and framework timers are fenced by client + component lifetime.
- `spec` / `descriptor` — static library descriptor. Always readable at module top-level; pass to `CustomHudLayout.components(hudkit.spec)`.
- `ensure()` — force-spawn the library layout. Optional once `hudkit` has been used and a client is active.
- `layout` — the underlying `custom_hud_layout` (vote rail; anything the library does not cover). Throws before plugin context exists.
- `budget()` — intern counts for `panelIds`, `classNames`, and `variables`. Each vector is its own **1024** cap (not one shared 3072). The shipped lib interns 432 panel ids (six sheets × ~51). Interning is idempotent; repaints are free. Past the cap a name is refused and its value never arrives.

`CustomHudLayout.components(spec?)` is the same kit for a descriptor (`ui.components()` stays as a deprecated alias). Kits are cached by layout `resource`.

## See also

- [HUD](https://s2script.com/docs/concepts/hud) — setup, first draw, screen effects
- [Mounting your own](https://s2script.com/docs/concepts/hud/custom)
- [Creating a layout](https://s2script.com/docs/concepts/hud/custom/create) — buy-menu / scoreboard yield
- [HUD module](https://s2script.com/docs/api/modules/ui)
