Dispatch Panel Theming
Theme the Tommy's Radio dispatch panel in four tiers — design tokens, CSS, slot overrides and full takeover — plus state, events, actions, templates, live authoring and publishing.
The panel is themeable in four tiers. Pick the lowest that meets your goal.
| Tier | You write | Host keeps gating, a11y, i18n, keyboard? | Auto-publishes? |
|---|---|---|---|
| 1 — Design tokens | cssVars | Yes — stock panel | Yes |
| 2 — CSS | cssText | Yes — stock panel | Yes |
| 3 — Slot override | slots | Yes — you swap only the markup you name | Yes |
| 4 — Full takeover | template, jsText | No — you own parity for what you render | template only |
Layers combine. A theme with no jsText skips the source-review gate (see Security model). Reach for Tier 4 last: it is the only tier where new panel features do not appear automatically.
Tier 1 — Design tokens
Set these via cssVars, a :root { … } block in cssText, or theme.setVar(). Generated from theme-sdk/tokens.ts:
| Token | Category | Default (dark) | Controls |
|---|---|---|---|
--bg-primary | Surfaces | #030303 | App background behind everything. |
--bg-secondary | Surfaces | #141414 | Header, bars, cards, panels. |
--bg-hover | Surfaces | #262626 | Hover/pressed state for interactive rows and buttons. |
--card | Surfaces | hsl(0 0% 8%) | Raised card/dialog surface (shadcn-derived components). |
--text-primary | Text | #fafafa | Default foreground text colour. |
--text-secondary | Text | #a3a3a3 | Muted labels, metadata, secondary rows. |
--foreground | Text | hsl(0 0% 98%) | Base foreground for shadcn-derived components. |
--accent-raw | Accent | #3b82f6 | Primary accent — active channel, PTT, selected states. |
--primary | Accent | hsl(204 77% 47%) | Primary colour for shadcn-derived buttons and rings. |
--ring | Accent | hsl(0 0% 83%) | Keyboard focus outline colour. |
--danger | Status | #ef4444 | Panic, destructive actions, error states. |
--success | Status | #22c55e | Connected/healthy indicators. |
--destructive | Status | hsl(0 84% 60%) | Destructive colour for shadcn-derived components; drives panic flash. |
--my-dispatch-bg | Dispatch | hsl(220 100% 13%) | Background tint for units controlled by this dispatcher. |
--my-dispatch-border | Dispatch | hsl(220 100% 28%) | Border for units controlled by this dispatcher. |
--other-dispatch-bg | Dispatch | hsl(270 100% 13%) | Background tint for units controlled by another dispatcher. |
--other-dispatch-border | Dispatch | hsl(270 100% 28%) | Border for units controlled by another dispatcher. |
--border | Structure | hsl(0 0% 15%) | Default border/divider colour. |
--input | Structure | hsl(0 0% 18%) | Form control border colour. |
--radius | Structure | 0.75rem | Base border-radius for cards and controls. |
--font-sans | Typography | "JetBrains Mono", monospace | UI font stack. Also settable via the theme font field. |
--font-size | Typography | 100% | Root font size as a percentage (100% = 16px). Also settable via the theme fontSize field. |
The shared UI kit uses a second set, on the shadcn/UnoCSS convention: --background, --foreground, --card, --primary, --secondary, --muted, --accent, --destructive (each with a -foreground partner), --border, --input, --ring, --radius. Set only the dispatch tokens and the modals stay on the default palette. --accent (UI-kit hover) is not --accent-raw (dispatch).
Derived -rgb companions — do not set these
The panel reads a triplet per tinted color — --accent-raw-rgb: 77, 166, 255 — and builds tints as rgba(var(--accent-raw-rgb), 0.15). They recompute on every theme change, so setting one does nothing. Use the same form for your own tints.
Tier 2 — CSS
cssText is injected as a <style> block, so it can restyle any part of the stock panel. Target the panel's class names or the Tier 1 tokens. It is sanitized at install: @import, expression(…) and every url(…), relative paths and data: URIs included, are removed. Use gradients or Unicode glyphs instead.
extends inherits a built-in theme's tokens: dark-minimal (default), dark, dark-blue, basic, basic-dark.
Tier 3 — Slot overrides
A slot replaces one kind of component while the host keeps the shell: feature gates, keyboard handling, ARIA, localization and every guard. What you leave alone stays stock.
A fragment is ordinary data-rd-* markup, bound by the same binder and action allow-list as a template. Only scope differs: an item slot gets the item as a scope variable as well as the root view model, so ch.name and counts.panics both resolve in a channel fragment.
{ "id": "mcc5500-slots", "slots": {
"channel": "<div class=\"mx-tile\" data-rd-class=\"cur:ch.isCurrent\">…</div>"
} }| Slot | Scope var | Replaces | Host gates (owned by the panel) |
|---|---|---|---|
header | — (root) | Top bar: connection, callsign, counts, voice status, global PTT. | — |
announcement-bar | g | One announcement-group button. | announcementGroups.length |
zone | zone | A zone column, including its header and collapsed strip. | — |
channel | ch | A channel card: name, badges, roster, and its controls. | — |
unit | u | One unit row inside a channel roster. | — |
tx-entry | tx | One transmission-log row. | settings.transmissionLogEnabled |
call-card | c | One 911 call card. | call911Enabled |
patch-row | p | One active-patch row. | — |
speaker-row | sp | One speaker or speaker-group row on the tone board. | counts.speakers |
Host gates are checked before the slot renders, so a theme never sees them: an overridden call-card cannot appear where 911 is off. See mcc5500-slots-demo.js.
Compatibility — requires
The view model and slot registry are versioned separately. A theme declares its floor with { "requires": { "viewModel": 2, "slots": 1 } }. These are floors, not pins: a theme needing v2 runs on v2 and later. No requires means v1. On a mismatch install is rejected, and a stored incompatible theme fails safe at mount.
Tier 4 — Full takeover
Hides the stock panel and renders your own, two combinable ways:
- Declarative templates — binder-driven
data-rd-*markup. No JavaScript, so it auto-publishes. Recommended - Theme JavaScript —
jsText, imperative, full DOM access. Trips the review gate
Either way you take on full parity. mcc5500-theme.js rebuilds a console from scratch, delegating the rest to a small jsText layer.
Theme JavaScript
Three globals are injected: dispatch (state, events, actions), theme (CSS and DOM) and onUnmount(fn).
Reading state
dispatch.getViewModel() returns the typed view model — the versioned contract templates bind to, and a superset of getState(). Prefer it. dispatch-theme.d.ts carries autocomplete and the nested shapes.
It carries version (currently 2), channels flattened across zones, counts.panics and calls911, which getState() lacks.
Each view-model user also carries emergencyMode ("alarm", "call", "alarmCall" or null), emergencySilent and emergencyBadge; each transmission carries emergency. Neither call subscribes. getState() returns:
channel: string, scannedChannels: string[], zones: Zone[], pttActive: boolean, channelPttActive: boolean, channelPttFrequency: string, voiceStatus: string, micReady: boolean | null, activeAlerts: Record<string, string>, alertTypes: AlertType[], tones: { id, name }[], patches: Patch[], transmissions: TransmissionEntry[] (last 50), announcementGroups: AnnouncementGroup[], activeAnnouncementGroup: string | null, settings: DispatchSettings, callsign: string, serverName: string, connected: boolean, health: string, authenticated: boolean.
voiceStatus is MUTED, LISTENING, TRANSMITTING, TX (IMBE) or TX (Opus); health is connected, stale or disconnected.
Events
Subscribe with dispatch.on(event, callback). Each fires immediately with current state, so there is no initialization call.
const handler = ({ zones }) => render(zones);
dispatch.on("dispatch:zones", handler);
onUnmount(() => dispatch.off("dispatch:zones", handler));| Event | Payload | Fires when |
|---|---|---|
dispatch:zones | { zones } | Channel, user, panic or listener change |
dispatch:ptt | { active } | Global PTT |
dispatch:channel | { freq } | Monitored channel |
dispatch:channel-ptt | { active, freq } | Per-channel PTT |
dispatch:voice-status | { status } | Voice status |
dispatch:connection | { connected, health } | Connection |
dispatch:alerts | { alerts: [{id, name}] } | Active alerts |
dispatch:alert-types | { alertTypes } | Alert config reload |
dispatch:scans | { scanned: string[] } | Scan list |
dispatch:patches | { patches } | Patches |
dispatch:transmissions | { transmissions } | TX log entry |
dispatch:callsign | { callsign } | Callsign |
dispatch:announcement-group | { groupId | null } | Armed group |
dispatch:settings | { settings } | PTT bindings or volume |
Actions
dispatch.joinChannel("460.25"); dispatch.leaveChannel();
dispatch.addScan("154.755"); dispatch.removeScan("154.755");
dispatch.startPTT(); dispatch.stopPTT();
dispatch.startChannelPTT("460.25"); dispatch.stopChannelPTT();
dispatch.activateAnnouncementGroup("group-id"); // null disarms
dispatch.triggerAlert("460.25", "SIGNAL 100");
dispatch.acknowledgePanic(serverId, unitId); // unitId optional
dispatch.updateSettings({ voiceVolume: 80 });
dispatch.openSettings(); dispatch.closeSettings();
// type: General | Information | Priority | Emergency Alert
dispatch.sendBroadcastAlert({ frequency: "460.25", type: "Priority Alert",
message: "All units respond", tone: "PRIORITY" });
await dispatch.createPatch("Tac 1", [460.25, 154.755]); // numbers; -> { ok, error? }
await dispatch.removePatch(dispatch.getViewModel().patches[0].id);
// These four return Promise<boolean>
dispatch.kickUser(serverId);
dispatch.alertUser(serverId, "Message text", frequency);
dispatch.setUserCallsign(serverId, "UNIT-5");
dispatch.switchUserChannel(serverId, "462.45", "460.25"); // (id, newFreq, oldFreq)PTT no-ops with no active channel, on a canTransmit:false channel, or while another PTT runs. Acknowledgment is per unit: the radio shows Ack received and its alarm stops, but the emergency ends only on an Alarm-mode profile.
DOM control
All of this reverses when the theme changes or the panel unmounts.
theme.setVar("--accent-raw", "#ff6600"); // CSS variable on <html>
theme.removeVar("--accent-raw");
theme.addClass("ptt-active"); // becomes "tj-ptt-active"
theme.removeClass("ptt-active");
theme.hideDefaultUI(); // hides Header, ZoneList, announcement bar
theme.showDefaultUI();
theme.getContainer(); // persistent <div> inside .dispatch-appVariable names: --[a-zA-Z][a-zA-Z0-9-]*, max 60 characters. Class names: [a-zA-Z][a-zA-Z0-9-_]*, max 40, without the tj- prefix. hideDefaultUI() spares the Settings, broadcast and patch modals — overlay portals.
Declarative templates
Annotate HTML with data-rd-* attributes and pass it to theme.mountTemplate(). The binder pushes the view model in, keeps it in sync and reconciles lists by key. No arbitrary JavaScript runs, so template-only themes are safe.
theme.hideDefaultUI();
theme.mountTemplate(`
<header data-rd-text="serverName"></header>
<div data-rd-repeat="ch in channels" data-rd-key="ch.id"
data-rd-class="active:ch.isCurrent"
data-rd-on="click:join(ch.frequency)">
<span data-rd-text="ch.name"></span>
<span data-rd-show="ch.hasPanic">⚠ PANIC</span>
</div>
`);mountTemplate handles updates and teardown. reference-panel.html is a styled starting point.
A theme can also ship a template field with no JavaScript: the panel hides its default UI and auto-mounts it. The markup is sanitized at install — <script>, inline on* handlers and javascript: URIs stripped — so it skips review.
Binding attributes
| Attribute | Effect |
|---|---|
data-rd-text="ch.name" | Sets textContent |
data-rd-html="ch.note" | Sets innerHTML, trusted |
data-rd-show="ch.transmitting" | Shows when truthy; ! negates |
data-rd-hide="ch.userCount" | Hides when truthy |
data-rd-attr="title:ch.name" | Sets/removes attributes |
data-rd-class="active:ch.isCurrent" | Toggles classes |
data-rd-style="color:accent" | Sets inline styles |
data-rd-repeat="ch in zone.channels" | Repeats per item |
data-rd-key="ch.id" | Reconciliation key (default: index) |
data-rd-on="click:join(ch.frequency)" | Binds an event to an action |
Expressions are dotted paths and literals with a fixed operator set: negation, strict comparison, ordering, && / ||, ternary, grouping and filters. Repeat variables shadow the view model, nested repeats see their parent's scope, and "ch, i in channels" binds an index.
Filters chain left to right as expression | name or | name:arg. The list is fixed; an unknown name passes the value through. Arguments are expressions, so field names need quoting (where:'hasAudio'), and a | inside one ends it.
| Filter | Args | Result |
|---|---|---|
default | 1 | the argument when the value is null, undefined or "" |
elapsed | none | epoch ms → time since, "3:07" past a minute else "42s" (uses vm.now) |
join | 0–1 | array → string joined by the separator (default ", ") |
limit | 1 | array → at most N leading items |
lower | none | lowercase text |
pct | none | number 0–100 → "50%" |
plural | 2 | number → first argument when exactly 1, else the second |
round | 0–1 | number → rounded to N decimal places (default 0) |
sortBy | 1 | array → stably sorted ascending by the named field |
time | none | epoch ms → local wall clock "14:32:07" |
upper | none | uppercase text |
where | 1 | array → only items whose named field is truthy |
whereNot | 1 | array → only items whose named field is falsy |
Template actions (data-rd-on) are a fixed allow-list — the same registry the JS API exposes, so each is also callable as dispatch.<name>(…).
| Action | Args |
|---|---|
acceptCall911() | 1 |
acknowledgePanic() | 2 |
activateGroup() | 1 |
addScan() | 1 |
checkUpdate() | none |
clearLog() | none |
closeBroadcast() | none |
closePatchPanel() | none |
closeSettings() | none |
endCall911() | 1 |
expandSidebar() | none |
focusCall911() | 1 |
join() | 1 |
kickUser() | 1 |
leave() | none |
logout() | none |
openBroadcast() | 1 |
openPatchPanel() | none |
openSettings() | none |
playTransmission() | 1 |
removePatch() | 1 |
removeScan() | 1 |
setAutoCollapse() | 1 |
setLogFilter() | 1 |
setSfxVolume() | 1 |
setTransmissionLogEnabled() | 1 |
setVoiceVolume() | 1 |
speakerTone() | 2 |
startChannelPtt() | 1 |
startPtt() | none |
stopChannelPtt() | none |
stopPtt() | none |
stopTransmission() | 1 |
toggleCallQueue() | none |
toggleChannel() | 2 |
toggleTxLog() | none |
toggleZone() | 1 |
triggerAlert() | 2 |
unfocusCall911() | none |
The table lists only arity:
activateGroup(null)disarms.focusCall911andopenBroadcasttake a frequency,acceptCall911/endCall911a call id,removePatchavm.patches[].id- Creating a patch needs free text, so it is JS-only
setLogFiltertakes'all','monitored'or'current'. Volume actions take 0–100, and the grammar has no arithmetic, so render discrete stepsplayTransmission/stopTransmissionreplay buffered audio. Gate ontx.hasAudio, readtx.playing
Reaching what a template cannot express
The binder passes only paths and literals to an action, so it can never read an <input>. Free text, key capture, drag-and-drop and popouts must be delegated.
1. Open a built-in modal. SettingsModal, BroadcastModal, SpeakerBoard and PatchPanel render outside .dispatch-default-ui, so they survive hideDefaultUI().
2. Go hybrid. theme.mountTemplate() works inside jsText, so templates still cover the lists. Mark intent with a plain data-* attribute — unknown ones are ignored — then delegate:
// <button data-rd-attr="data-alert-user:u.id">Alert…</button>
theme.getContainer().addEventListener("click", (e) => {
const el = e.target.closest?.("[data-alert-user]");
if (el) myDialog((msg) => dispatch.alertUser(el.dataset.alertUser, msg, freq));
}, true); // capture phase — see belowThe themes folder
A theme is a folder in layouts/themes/ with a module.json declaring what it is and a theme.json of colors. You need both: theme.json alone is not found, module.json alone is skipped.
{ "schema": 1, "kind": "theme", "id": "my-theme", "name": "My Theme",
"version": "1.0.0", "author": "You", "license": "CC0-1.0" }
{ "id": "my-theme", "cssVars": { "--accent-raw": "#22d3ee" } }cssVars is the only required field; cssText, jsText, template, slots, requires, extends, font and fontSize are optional. The folder name is the theme's id, and module.json's id must match it exactly.
Radio skins use the same shape with "kind": "layout". Sounds do not: a sound is the audio file alone in layouts/sounds/ (see Adding a sound). sounds and themes are reserved names.
Shipping your own font
Setting --font-sans picks a family; it does not supply one. The panel bundles Poppins and JetBrains Mono and assumes Courier New and Courier. Name anything else and the browser paints the first family it can match — silently, so check its console.
To supply one, put the .woff2 in a fonts/ directory inside your theme and declare it in module.json. --font-sans then resolves, because the panel emits an @font-face per entry.
"theme": { "fonts": [
{ "family": "Inter", "file": "inter-400.woff2", "weight": "400", "license": "OFL-Inter.txt" }
] }| Field | ||
|---|---|---|
family | required | The CSS family name, exactly. Not guessed from the filename |
file | required | A bare filename inside fonts/. .woff2 only |
weight | optional, "400" | Or a range like "100 800". A 600 face at 400 gets a fake bold |
style | optional, "normal" | "italic", else the browser slants the upright face |
license | required | A license file beside the font — themes are zipped onto other servers |
Live authoring
Any dispatch panel exposes window.__tjDev = { dispatch, theme } while you are logged in — the same objects a jsText theme gets. Logging in is the only gate.
window.__tjDev.theme.hideDefaultUI();
window.__tjDev.theme.mountTemplate(`<div data-rd-repeat="zone in zones" data-rd-key="zone.id">…</div>`);
window.__tjDev.dispatch.getViewModel();Keep the template in DevTools Snippets (Sources → Snippets) rather than retyping it: a multi-line editor that survives reloads, with Ctrl+Enter to re-run. Editing the live DOM in Elements does not work for anything the binder controls — it re-asserts state and overwrites your edits.
Install from the same console, with an active admin session. It appears in Settings → Theme at once; pass the same id later to edit in place.
fetch("/admin/api/themes", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "My Theme", cssVars: { "--accent-raw": "#22d3ee" } }),
});Publishing a theme
Submit in the theme editor — tgstudios → Marketplace → Themes → Create Theme. Build against your live panel first (see Live authoring). Every layer composes, so one theme can carry colors, a slot override and JavaScript.
| Section | What it takes |
|---|---|
| Name, Description, Resource, Screenshot | Metadata. Resource locks once it exists |
| Colors & variables | Key/value rows with a swatch, synced with a :root { … } block |
| Custom CSS | Free-form, applied to the stock panel and any slot overrides |
| Component overrides | Pick a slot, write its data-rd-* fragment |
| Advanced | Disclosure: Full-panel template and JavaScript |
A live preview runs your template or slots against mock data. JavaScript cannot be previewed. Validation errors block submission; warnings do not.
| Your theme contains | Result |
|---|---|
Only cssVars, cssText, slots and/or template | Published immediately. No staff review |
Any jsText | Pending staff review before it goes live |
An edit that introduces JavaScript drops the theme back to pending even if already approved. Once live, updates reach installed servers on their next panel refresh. Themes cap at 10 MB and 256 variables.
Security model
| What | How it's enforced |
|---|---|
| CSS injection | In cssText, @import, expression(…) and every url(…) are stripped at install. A <style> inside a template is sanitized as HTML, so url() lives |
| Templates & slots | Bindings resolve only paths, literals, operators and the fixed filters; events reach only the action allow-list. No eval, no indexing, no theme functions |
| JS execution | Runs in the panel — no iframe sandbox. Full DOM access by design |
| Install review | jsText forces a source-review dialog. No bypass |
| JS badge | Marketplace cards show a JS badge before you open them |
| Auto-cleanup | CSS vars, classes, canvas and hideDefaultUI reverse on theme change |
Next steps
- Exports, Web API and Socket.IO: API Reference
- Installing a theme someone else published: Themes
Tommy's Radio
Still stuck? Support can answer it directly.
Was this page helpful?
TIMMYG Studios