Search docs
Search guides, modules and symbols
Default components

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). Use a separate custom resource if you need an observable layout.

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.

KindPoolClaim
Toast4, rotatinghudkit.forSlot(slot).toast(spec) — no claim, next free line
Badge4hudkit.badge(spec) — null when exhausted; tryBadge → UiResult
Modal6 (s2_m0–s2_m5), 8 rows eachhudkit.modal(spec) — null when exhausted; tryModal → UiResult
Callout1hudkit.forSlot(slot).callout(spec) — no claim, no cursor
Banner1hudkit.forSlot(slot).banner(spec) — no claim, no cursor
MOTD1hudkit.forSlot(slot).motd(spec) — scrim + OK, not a Menu
Dashboard1, dedicatedhudkit.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.

Structured results#

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

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").

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()):

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.

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).

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.

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.

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.

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.

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.

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.

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

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().

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.

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 ids must be unique within the submitted collection; row ids 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.

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 paints sm_admin / sm_menu through this API. Plugins declare tabs with topmenu.addTab; empty tabs are omitted by the caller.

Library panels#

Production s2script_lib.xml (source in 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#