Skip to content

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.

TierYou writeHost keeps gating, a11y, i18n, keyboard?Auto-publishes?
1 — Design tokenscssVarsYes — stock panelYes
2 — CSScssTextYes — stock panelYes
3 — Slot overrideslotsYes — you swap only the markup you nameYes
4 — Full takeovertemplate, jsTextNo — you own parity for what you rendertemplate 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:

TokenCategoryDefault (dark)Controls
--bg-primarySurfaces#030303App background behind everything.
--bg-secondarySurfaces#141414Header, bars, cards, panels.
--bg-hoverSurfaces#262626Hover/pressed state for interactive rows and buttons.
--cardSurfaceshsl(0 0% 8%)Raised card/dialog surface (shadcn-derived components).
--text-primaryText#fafafaDefault foreground text colour.
--text-secondaryText#a3a3a3Muted labels, metadata, secondary rows.
--foregroundTexthsl(0 0% 98%)Base foreground for shadcn-derived components.
--accent-rawAccent#3b82f6Primary accent — active channel, PTT, selected states.
--primaryAccenthsl(204 77% 47%)Primary colour for shadcn-derived buttons and rings.
--ringAccenthsl(0 0% 83%)Keyboard focus outline colour.
--dangerStatus#ef4444Panic, destructive actions, error states.
--successStatus#22c55eConnected/healthy indicators.
--destructiveStatushsl(0 84% 60%)Destructive colour for shadcn-derived components; drives panic flash.
--my-dispatch-bgDispatchhsl(220 100% 13%)Background tint for units controlled by this dispatcher.
--my-dispatch-borderDispatchhsl(220 100% 28%)Border for units controlled by this dispatcher.
--other-dispatch-bgDispatchhsl(270 100% 13%)Background tint for units controlled by another dispatcher.
--other-dispatch-borderDispatchhsl(270 100% 28%)Border for units controlled by another dispatcher.
--borderStructurehsl(0 0% 15%)Default border/divider colour.
--inputStructurehsl(0 0% 18%)Form control border colour.
--radiusStructure0.75remBase border-radius for cards and controls.
--font-sansTypography"JetBrains Mono", monospaceUI font stack. Also settable via the theme font field.
--font-sizeTypography100%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.

json
{ "id": "mcc5500-slots", "slots": {
  "channel": "<div class=\"mx-tile\" data-rd-class=\"cur:ch.isCurrent\">…</div>"
} }
SlotScope varReplacesHost gates (owned by the panel)
header— (root)Top bar: connection, callsign, counts, voice status, global PTT.—
announcement-bargOne announcement-group button.announcementGroups.length
zonezoneA zone column, including its header and collapsed strip.—
channelchA channel card: name, badges, roster, and its controls.—
unituOne unit row inside a channel roster.—
tx-entrytxOne transmission-log row.settings.transmissionLogEnabled
call-cardcOne 911 call card.call911Enabled
patch-rowpOne active-patch row.—
speaker-rowspOne 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.

javascript
const handler = ({ zones }) => render(zones);
dispatch.on("dispatch:zones", handler);
onUnmount(() => dispatch.off("dispatch:zones", handler));
EventPayloadFires 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

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

javascript
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-app

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

javascript
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

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

FilterArgsResult
default1the argument when the value is null, undefined or ""
elapsednoneepoch ms → time since, "3:07" past a minute else "42s" (uses vm.now)
join0–1array → string joined by the separator (default ", ")
limit1array → at most N leading items
lowernonelowercase text
pctnonenumber 0–100 → "50%"
plural2number → first argument when exactly 1, else the second
round0–1number → rounded to N decimal places (default 0)
sortBy1array → stably sorted ascending by the named field
timenoneepoch ms → local wall clock "14:32:07"
uppernoneuppercase text
where1array → only items whose named field is truthy
whereNot1array → 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>(…).

ActionArgs
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. focusCall911 and openBroadcast take a frequency, acceptCall911 / endCall911 a call id, removePatch a vm.patches[].id
  • Creating a patch needs free text, so it is JS-only
  • setLogFilter takes 'all', 'monitored' or 'current'. Volume actions take 0–100, and the grammar has no arithmetic, so render discrete steps
  • playTransmission / stopTransmission replay buffered audio. Gate on tx.hasAudio, read tx.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:

js
//   <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 below

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

json
{ "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.

json
"theme": { "fonts": [
  { "family": "Inter", "file": "inter-400.woff2", "weight": "400", "license": "OFL-Inter.txt" }
] }
Field
familyrequiredThe CSS family name, exactly. Not guessed from the filename
filerequiredA bare filename inside fonts/. .woff2 only
weightoptional, "400"Or a range like "100 800". A 600 face at 400 gets a fake bold
styleoptional, "normal""italic", else the browser slants the upright face
licenserequiredA 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.

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

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

SectionWhat it takes
Name, Description, Resource, ScreenshotMetadata. Resource locks once it exists
Colors & variablesKey/value rows with a swatch, synced with a :root { … } block
Custom CSSFree-form, applied to the stock panel and any slot overrides
Component overridesPick a slot, write its data-rd-* fragment
AdvancedDisclosure: 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 containsResult
Only cssVars, cssText, slots and/or templatePublished immediately. No staff review
Any jsTextPending 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

WhatHow it's enforced
CSS injectionIn cssText, @import, expression(…) and every url(…) are stripped at install. A <style> inside a template is sanitized as HTML, so url() lives
Templates & slotsBindings resolve only paths, literals, operators and the fixed filters; events reach only the action allow-list. No eval, no indexing, no theme functions
JS executionRuns in the panel — no iframe sandbox. Full DOM access by design
Install reviewjsText forces a source-review dialog. No bypass
JS badgeMarketplace cards show a JS badge before you open them
Auto-cleanupCSS 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

Still stuck? Support can answer it directly.

Was this page helpful?