# ACE Permissions (https://timmygstudios.com/docs/faq-ace-permissions) How to use FiveM's ACE permissions system to grant players and groups access to script commands. FiveM's ACE system is how scripts decide who can run which commands. TIMMYG Studios scripts use it for admin-only features — for example, opening the tELS editor requires the `tlib.admin` permission. Permission checks happen **server-side**, so changes require either an `ensure` of the resource or a server restart to take effect. --- ## Principals A _principal_ is an identifier that holds permissions (which FiveM calls **ACEs**). Principals are either: - **Player identifiers** — a specific player's Discord ID, Steam ID, License, etc. - **Groups** — made-up identifiers prefixed with `group.` (e.g., `group.admin`, `group.moderator`). Groups don't need to be declared — they exist the moment you reference one. Groups are the recommended approach since they let you assign the same permissions to many players at once. ### Adding a player to a group ```ini title="server.cfg" add_principal identifier.discord:394446211341615104 group.admin ``` This adds the player with Discord ID `394446211341615104` to `group.admin`. Any ACEs granted to `group.admin` now apply to that player. ### Nesting groups ```ini title="server.cfg" add_principal group.admin group.moderator ``` `group.admin` now inherits every permission that `group.moderator` has. Lines starting with `#` in `server.cfg` are comments and are ignored by the parser. --- ## Granting permissions Use `add_ace` to grant a permission node, `remove_ace` to revoke it. Permissions can be attached to a group or directly to a player identifier. ```ini title="server.cfg" # Grant the tlib.admin permission to the admin group add_ace group.admin "tlib.admin" allow # Add the player to the group — they inherit all of its permissions add_principal identifier.discord:394446211341615104 group.admin ``` Any player added to `group.admin` now has `tlib.admin`. Add or remove players from the group without touching the ACE itself. ```ini title="server.cfg" # Grant the permission directly to one player add_ace identifier.discord:394446211341615104 "tlib.admin" allow ``` Faster for a single player, but doesn't scale. Prefer groups if more than one person will ever need the permission. ### Removing permissions ```ini title="server.cfg" # Revoke from the group remove_ace group.admin "tlib.admin" allow # Revoke from a specific player remove_ace identifier.discord:394446211341615104 "tlib.admin" allow ``` The `allow` at the end must match the original grant. --- ## Example: giving someone tELS editor access The tELS editor is gated behind `tlib.admin`. To grant a player access: ### Open server.cfg Locate `server.cfg` in your server's root directory. ### Grant the ACE to a group ```ini title="server.cfg" add_ace group.admin "tlib.admin" allow ``` ### Add the player to the group ```ini title="server.cfg" add_principal identifier.discord:394446211341615104 group.admin ``` Replace the Discord ID with your admin's identifier. You can also use `identifier.steam:...`, `identifier.license:...`, or `identifier.fivem:...`. ### Restart the server ACE changes are loaded at startup. Restart the server or run `refresh` + `restart` on the resources that check the permission. ### Verify The admin runs `/telsedit` in-game. If the permission is correct, the editor opens. If it still says permission denied, check the identifier matches exactly — Discord IDs must be numeric with no prefix beyond `identifier.discord:`. --- ## Related Where `tlib.admin` is used — gates the editor and server-wide defaults. Provides the admin ACE and permission helpers used across TIMMYG Studios scripts. If commands fail for reasons other than permissions, start here. # tRadio API Reference (https://timmygstudios.com/docs/tRadio/api-reference) Lua exports, REST API, Socket.IO events, and the dispatch panel theming SDK for Tommy's Radio. ## Server exports Call these from any server-side script as `exports['tRadio']:name(...)`. The `provide 'tRadio'` alias means the folder name does not matter. ### Channel information | Export | Parameters | Returns | | --------------------------- | --------------------------- | ------------------------------------------------------------------------- | | `getSpeakersInChannel` | `frequency: string\|number` | `table` — server IDs connected as speakers | | `getListenersInChannel` | `frequency` | `table` — server IDs scanning (listen only) | | `getAllUsersInChannel` | `frequency` | `table` — speakers and listeners combined | | `getActiveTalkersInChannel` | `frequency` | `table` — server IDs holding PTT right now | | `getActiveChannels` | — | `string[]` — frequencies with at least one active user | | `getChannelInfo` | `frequency` | `{ speakers, listeners, activeTalkers }` or `nil` | | `getAllChannels` | — | `table[]` — the full configured channel list with zones, names, and types | ```lua local speakers = exports['tRadio']:getSpeakersInChannel("154.755") local info = exports['tRadio']:getChannelInfo("154.755") -- info = { speakers = {...}, listeners = {...}, activeTalkers = {...} } ``` ### Alerts and panic | Export | Parameters | Returns | | ------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `getChannelAlert` | `frequency` | The active alert config, or `nil` | | `setAlertOnChannel` | `frequency`, `enabled: boolean\|nil`, `alertIndexOrName: number\|string\|nil` | — | | `setChannelSignal` | `frequency`, `enabled: boolean` | `true` — fires the **first** configured alert. The export equivalent of the in-game SGN button | | `getChannelPanic` | `frequency` | `{ [serverId] = serverId, ... }` | | `setChannelPanic` | `frequency`, `serverId`, `enabled: boolean` | `boolean` | | `getUserPanicState` | `serverId`, `frequency` | `boolean` | `setAlertOnChannel` resolves its third argument like this: - omitted or `nil` → the first alert in the list - a number → 1-based index into the alerts array - a string → case-insensitive name match - `enabled = nil` → toggle the current state ```lua exports['tRadio']:setAlertOnChannel("154.755", true) -- first alert, activate exports['tRadio']:setAlertOnChannel("154.755", true, "SIGNAL 100") -- by name exports['tRadio']:setAlertOnChannel("154.755", nil, 2) -- toggle the second alert exports['tRadio']:setChannelPanic("154.755", 5, true) ``` ### User management | Export | Parameters | Returns | | ---------------- | ------------------------------- | ------------------------------------------------- | | `setUserChannel` | `serverId: number`, `frequency` | `boolean` — handles trunked routing automatically | | `disconnectUser` | `serverId: number` | `boolean` | | `isUserTalking` | `serverId: number`, `frequency` | `boolean` | ### User information | Export | Parameters | Returns | | ------------------- | --------------------------- | ----------------------------------------------------------------------------------- | | `getPlayerName` | `serverId` | `string` | | `getPlayerNacId` | `serverId` | `string` or `nil` | | `getUserInfo` | `serverId` | `{ name, nacId }` | | `hasRadioAccess` | `serverId` | `boolean` | | `setUserNacId` | `serverId`, `nacId: string` | `boolean` — display override until the next `refreshNacId` | | `refreshNacId` | `serverId` | `boolean` — re-resolves access and pushes it to the client. Call after a job change | | `refreshPlayerInfo` | — | `number` — count of players whose NAC ID and name were refreshed | | `getAcePermissions` | `serverId` | `{ connect, scan, gps, zones, dispatch }` | ```lua local perms = exports['tRadio']:getAcePermissions(5) -- connect/scan/gps are frequency arrays, zones is a 1-based index array, -- dispatch is a boolean (supervisor privileges) ``` ### Codeplugs and subscribers | Export | Parameters | Returns | | -------------------- | ---------- | ----------------------------------------------------------------------------------- | | `getSubscriberInfo` | `serverId` | The subscriber record, or `nil` | | `getSubscriberZones` | `serverId` | `table[]` of zone objects, or `nil` | | `refreshSubscriber` | `serverId` | `boolean` — rebuilds the whole subscriber record. More thorough than `refreshNacId` | `getSubscriberInfo` returns: ```lua { codeplugId = "leo", unitId = "1041", nac = "141", allowedModels = { "QRX-720", "HLX-120" }, defaultLayouts = { Handheld = "QRX-720", Vehicle = "HLX-120" }, features = { gps = true, scan = true, emergencyButton = true, manDown = false }, tones = { ... }, emergency = { emergencyChannelId = nil, autoTransmitDuration = 5 }, announcementVolume = 0.8, } ``` Each subscriber gets a P25 unit ID derived from their codeplug ID and license hash, so the same player on the same codeplug keeps the same unit ID across sessions and restarts. ### Callsigns Requires **Enable Callsigns** in the admin panel. | Export | Parameters | Returns | | ------------- | ------------------------------------------------------------- | ----------------- | | `setCallsign` | `serverId`, `callsign: string\|nil` — `""` or `nil` clears it | — | | `getCallsign` | `serverId` | `string` or `nil` | ### Audio | Export | Parameters | Returns | | ------------------- | --------------------------- | ------- | | `playToneOnChannel` | `frequency`, `tone: string` | — | | `playToneOnSource` | `serverId`, `tone: string` | — | ```lua exports['tRadio']:playToneOnChannel("154.755", "ALERT_A") exports['tRadio']:playToneOnSource(5, "BEEP") ``` Tone keys come from the [tone catalog](/docs/tRadio/configuration#tone-catalog). --- ## Client exports All volume exports use **0.0–1.0**. The admin and dispatch panels use **0–100** — divide by 100 before passing a panel value to an export. ### Radio control | Export | Parameters | Returns | | ------------------------ | --------------------------------- | ------------------------------------- | | `openRadio` | `focus: boolean` (default `true`) | — | | `closeRadio` | — | — | | `connectToFrequency` | `frequency` | `boolean` | | `setCurrentChannel` | `frequency` | — | | `getCurrentFrequency` | — | frequency, or `-1` when not connected | | `getCurrentChannel` | — | channel object | | `addListeningChannel` | `frequency` | — | | `removeListeningChannel` | `frequency` | — | | `getListeningChannels` | — | `string[]` — active scan frequencies | ```lua exports['tRadio']:openRadio() -- open with NUI focus exports['tRadio']:openRadio(false) -- open without focus exports['tRadio']:connectToFrequency("154.755") ``` `radio:client:openRadio` is also fired as a client event when a player uses the radio item from their inventory — handle it yourself only if `Config.inventory.system = 'custom'`. ### Transmission | Export | Parameters | Returns | | ------------------------ | ------------------ | ---------------------------------------- | | `startTransmitting` | — | — | | `stopTransmitting` | — | — | | `setTalking` | `talking: boolean` | — — same as start/stop | | `isTransmitting` | — | `boolean` | | `getActiveTalker` | — | `{ serverId, name, frequency }` or `nil` | | `activateProximityVoice` | — | — — triggers in-game proximity voice | ### Radio state | Export | Returns | | ----------------- | ------------------------------- | | `isConnected` | `boolean` | | `isPowerOn` | `boolean` | | `isRadioOpen` | `boolean` | | `isRadioFocused` | `boolean` | | `setPower(state)` | — — omit the argument to toggle | ### Volume and audio | Export | Parameters | Returns | | --------------------------------- | -------------- | ------------------------------------------------ | | `setVolume` / `getVolume` | `0.0–1.0` | — / `number` | | `setToneVolume` / `getToneVolume` | `0.0–1.0` | — / `number` | | `set3DVolume` / `get3DVolume` | `0.0–1.0` | — / `number` | | `playTone` | `tone: string` | — — plays locally only | | `NotifySirenChanged` | — | — — forces an immediate re-poll of `bgModeCheck` | ### Appearance and settings | Export | Parameters | Returns | | ----------------------------------------- | ------------------------- | ------------- | | `setRadioLayout` / `getRadioLayout` | `model: string` | — / `string` | | `setRadioTheme` / `getRadioTheme` | `"Auto"\|"Light"\|"Dark"` | — / `string` | | `setAnimationId` / `getAnimationId` | `id: string` | — / `string` | | `setEarbudsEnabled` / `getEarbudsEnabled` | `boolean` | — / `boolean` | | `setGPSEnabled` / `getGPSEnabled` | `boolean` | — / `boolean` | ### Alerts and panic | Export | Parameters | | ----------------------- | ----------------------------------------------- | | `triggerAlertOnChannel` | `frequency`, `enabled: boolean`, `alertConfig?` | | `panicButton` | `frequency`, `enabled: boolean` | ### Player and system information | Export | Returns | | --------------------------------- | ------------------------------- | | `getCurrentName` | `string` | | `getCurrentNacId` | `string` | | `refreshMyNacId` | — — re-fetches from the server | | `getMyCallsign` / `setMyCallsign` | `string\|nil` / — (`""` clears) | | `getZone` | `string` — current zone name | | `getBatteryLevel` | `number` (0–100) | | `getSignalStrength` | `number` (0–5) | | `getConnectionDiagnostics` | diagnostic snapshot table | --- ## Web API ### Authenticating Protected endpoints need a per-session token obtained at runtime. This is **not** `Config.authToken`. ```bash curl -X POST http://203.0.113.10:7777/radio/dispatch/auth \ -H "Content-Type: application/json" \ -d '{"nacId": "4417", "callsign": "Dispatcher-01"}' ``` ```python res = requests.post( "http://203.0.113.10:7777/radio/dispatch/auth", json={"nacId": "4417", "callsign": "Dispatcher-01"}, ).json() auth_token, session_id = res["authToken"], res["sessionId"] ``` ```javascript const res = await fetch("http://203.0.113.10:7777/radio/dispatch/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nacId: "4417", callsign: "Dispatcher-01" }), }).then((r) => r.json()); const { authToken, sessionId } = res; ``` ```json { "success": true, "sessionId": "dispatch_abc123", "authToken": "550e8400-e29b-41d4-a716-446655440000" } ``` Then send both headers on every protected request: ``` Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000 x-session-id: dispatch_abc123 ``` - `nacId` must match `dispatchNacId` in `config.lua` - Sessions last **24 hours**; at most 100 exist at once, oldest evicted first - Authentication is rate-limited to 15 attempts per 15 minutes per IP - With Discord auth enabled the same endpoint returns `202` and a `redirectUri` instead - After a server restart, re-issue a session with `POST /radio/dispatch/reauth` sending only the `Authorization` header ### Endpoint prefixes | Prefix | Purpose | | ------------------ | -------------------------------------------------------- | | `/api/` | General integrations — bots, CAD, external monitoring | | `/radio/dispatch/` | Full dispatcher presence and real-time state | | `/dispatch/` | Unit management — controlling individual in-game players | Prefer `GET /radio/dispatch/status` over `GET /api/status` for integrations. It is a superset and the actively maintained one. ### Public endpoints | Method | Path | Description | | ------ | ----------------------------- | -------------------------------------------------------------------------------- | | `GET` | `/api/health` | `{ status, service, serviceName, authMethod }`. Used by the startup health check | | `GET` | `/api/locale` | The string table from `config/locales.json` | | `GET` | `/radio/dispatch/installer` | Windows desktop app installer. Rate-limited to 5/min | | `GET` | `/radio/dispatch/update.json` | Desktop app auto-updater manifest | ### Status and config | Method | Path | Description | | ------ | ------------------------ | ----------------------------------------------------- | | `GET` | `/radio/dispatch/status` | Users, channels, panic, alerts, patches — preferred | | `GET` | `/api/status` | A subset of the above | | `GET` | `/radio/dispatch/config` | Zones, alerts, volumes, FX settings, installed themes | | `GET` | `/radio/dispatch/tones` | Available tone list | ### Broadcasts and tones | Method | Path | Body | Description | | ------ | ------------------------------ | --------------------------------------- | ----------------------------------------------------------- | | `POST` | `/radio/dispatch/broadcast` | `{ message, frequency?, type?, tone? }` | Broadcast to a channel or all | | `POST` | `/api/trigger-broadcast` | `{ message, frequency?, type? }` | Broadcast, no tone field | | `POST` | `/radio/dispatch/tone` | `{ frequency, tone }` | Play a tone on a channel | | `POST` | `/api/play-tone` | `{ frequency, tone }` | Alias of the above | | `POST` | `/radio/dispatch/speaker-tone` | `{ targetId, tone }` | Play a tone at a world speaker, or every speaker in a group | ### Alerts | Method | Path | Body | | ------ | ------------------------------- | ----------------------------------------------------------- | | `POST` | `/radio/dispatch/alert/trigger` | `{ frequency, alertType, alertConfig }` | | `POST` | `/radio/dispatch/alert/clear` | `{ frequency }` | | `POST` | `/radio/dispatch/alert/oneshot` | `{ frequency, alertConfig }` — fire once without persisting | ### Channels and units | Method | Path | Body | Description | | ------ | ------------------------------------ | --------------------------------------- | ---------------------------------------------------------- | | `POST` | `/radio/dispatch/switchChannel` | `{ serverId, frequency, oldFrequency }` | Move an in-game player | | `POST` | `/dispatch/user/alert` | `{ userId, message, frequency }` | Send an alert to one player | | `POST` | `/dispatch/user/disconnect` | `{ userId }` | Disconnect a player from the radio | | `POST` | `/dispatch/user/set-player-callsign` | `{ userId, callsign }` | Set an in-game player's callsign | | `POST` | `/dispatch/user/update-callsign` | `{ callsign, userId }` | Update a dispatcher's callsign — `userId` must be negative | ### Patches | Method | Path | Body | | ------ | ------------------- | ----------------------------------------------- | | `GET` | `/api/patches` | — | | `POST` | `/api/patch/create` | `{ label, frequencies: ["154.755", "460.25"] }` | | `POST` | `/api/patch/remove` | `{ id }` | ```bash SESSION_TOKEN="550e8400-e29b-41d4-a716-446655440000" SESSION_ID="dispatch_abc123" curl http://203.0.113.10:7777/radio/dispatch/status \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "x-session-id: $SESSION_ID" curl -X POST http://203.0.113.10:7777/api/patch/create \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "x-session-id: $SESSION_ID" \ -H "Content-Type: application/json" \ -d '{"label": "TAC1-FIRE", "frequencies": ["154.755", "460.25"]}' curl -X POST http://203.0.113.10:7777/radio/dispatch/tone \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "x-session-id: $SESSION_ID" \ -H "Content-Type: application/json" \ -d '{"frequency": "154.755", "tone": "ALERT_A"}' ``` ```python headers = { "Authorization": f"Bearer {auth_token}", "x-session-id": session_id, } status = requests.get( "http://203.0.113.10:7777/radio/dispatch/status", headers=headers ).json() requests.post( "http://203.0.113.10:7777/api/patch/create", headers=headers, json={"label": "TAC1-FIRE", "frequencies": ["154.755", "460.25"]}, ) requests.post( "http://203.0.113.10:7777/radio/dispatch/tone", headers=headers, json={"frequency": "154.755", "tone": "ALERT_A"}, ) ``` ### Error responses All errors are `application/json`. | Status | Body | Cause | | ------ | ---------------------------------------- | ------------------------------- | | `400` | `{ "error": "..." }` | Missing or invalid parameters | | `401` | `{ "error": "Authentication required" }` | Missing or invalid auth headers | | `404` | `{ "error": "Channel not found" }` | Unknown frequency | | `429` | `{ "error": "Too many..." }` | Rate limited | | `500` | `{ "error": "..." }` | Server error | **Type notes.** Frequencies accept strings (`"154.755"`) or numbers (`154.755`) everywhere. Dispatcher `serverId` values must be **negative**; in-game player IDs are positive. CORS is enabled for all origins. --- ## Socket.IO Connect to appear as a dispatcher and receive real-time events. ```javascript const res = await fetch("http://203.0.113.10:7777/radio/dispatch/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nacId: "4417", callsign: "Bot-01" }), }).then((r) => r.json()); const socket = io("http://203.0.113.10:7777", { auth: { authToken: res.authToken, // per-session UUID — not Config.authToken sessionId: res.sessionId, serverId: -(1000 + Math.floor(Math.random() * 10000)), // must be negative }, }); ``` ### Emit — client to server | Event | Payload | Description | | ------------------------ | ---------------------------------- | ----------------------------------------------------------------------- | | `setDispatchSession` | `sessionId: string` | Link the socket to the auth session | | `updateUserInfo` | `{ name, nacId }` | Set dispatcher identity | | `setSpeakerChannel` | `frequency: string` | Join a channel to speak | | `addListeningChannel` | `frequency: string` | Start scanning | | `removeListeningChannel` | `frequency: string` | Stop scanning | | `listenToUser` | `targetServerId: number` | Follow a user across any frequency | | `stopListeningToUser` | `targetServerId: number` | Stop following | | `setTalking` | `{ state, frequency?, override? }` | PTT. `override: true` bypasses half-duplex blocking and is audit-logged | | `voice` | `{ data, encoding?, frequency? }` | Base64 Opus packet; `encoding: "imbe"` for P25 | | `heartbeat` | `Date.now()` | Keep-alive | ### Listen — server to client | Event | Payload | Fires when | | --------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------- | | `voice` | `{ serverId, frequency, data, encoding?, receiveType, patchId? }` | A voice packet is relayed | | `talkingState` | `{ serverId, frequency, state }` | PTT starts or stops | | `speakerJoined` / `speakerLeft` | `{ serverId, frequency, name?, nacId? }` | A speaker joins or leaves | | `listenerJoined` / `listenerLeft` | `{ serverId, frequency }` | A listener joins or leaves | | `channelState` | `{ frequency, speakers, listeners, activeTalkers, empty }` | Full channel snapshot | | `patchStatus` | `{ patchId, active, label, frequencies }` | A patch is created or removed | | `serverTone` | `{ tone, frequency }` | A tone plays on a channel | | `dispatchNotification` | `{ type, ...payload }` | Cross-dispatcher notification | | `playerInfoUpdate` | `{ serverId, name?, nacId?, unitId? }` | Player identity changes | | `configUpdate` | `{ ... }` | Slim config re-broadcast, no zones or alerts inline | | `configRefresh` | `{}` | Re-fetch `/radio/dispatch/config` | | `ptt:denied` | `{ frequency, reason, currentTalker? }` | PTT denied — channel busy | --- ## Dispatch panel theming The dispatch panel is themeable in **four tiers**, from a colour swap to a fully custom console. Each tier is more capable than the last and carries more of the work — pick the lowest one that meets your goal. | Tier | You write | Effort | Host keeps feature-gating, a11y, i18n, keyboard? | Auto-publishes? | | --------------------- | -------------------------- | ------- | -------------------------------------------------------------------------------- | ----------------------------------- | | **1 — Design tokens** | `cssVars` | Lowest | Yes — stock panel | Yes | | **2 — CSS** | `cssText` | Low | Yes — stock panel | Yes | | **3 — Slot override** | `slots` | Medium | **Yes** — the host keeps every gate and guard; you swap only the markup you name | Yes | | **4 — Full takeover** | `template` and/or `jsText` | Highest | **No** — you own parity for everything you render | `template`-only: yes · `jsText`: no | Layers combine — `cssVars` + a couple of `slots`, or `cssVars` + `cssText` + a full `template`. The rule that governs publishing is simple: **a theme with no `jsText` is declarative and installs without the JavaScript source-review gate; adding `jsText` reintroduces it** (see [Security Model](#security-model)). Themes are installed from **Admin Panel → Marketplace → Themes**. Building and publishing one is covered in [Publishing a theme](#publishing-a-theme) below. **Which tier?** Recolouring the stock panel → Tier 1 / 2. Restyling one kind of component (channel cards, unit rows, 911 cards) while everything else stays stock → Tier 3. A genuinely different console layout → Tier 4. Reach for Tier 4 last: it is the only tier where a new panel feature does **not** appear automatically, because you have replaced the component that would have rendered it. ### Tier 1 — Design tokens The "change some colours" surface. Every dispatch component is styled with CSS custom properties; set them via a theme's `cssVars`, a `:root { … }` block in `cssText`, or `theme.setVar()` at runtime — no markup, no JavaScript. The table below is the complete, authoritative registry, generated from `server/web/src/theme-sdk/tokens.ts` (the same source the panel and the admin Theme Studio read): {/* GENERATED:tokens START — auto-generated by scripts/gen-theme-docs.ts (`bun run docs:tables`); do not edit by hand. */} | 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.5rem` | 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. | {/* GENERATED:tokens END */} `--font-sans` and `--font-size` are also settable through a theme's dedicated `font` / `fontSize` fields. The table above is the **dispatch-panel** token contract (`tokens.ts`). The shared UI kit — every dialog, button, input and select — is styled by a _second_ set of tokens on the shadcn/UnoCSS convention: `--background`, `--foreground`, `--card` / `--card-foreground`, `--primary` / `--primary-foreground`, `--secondary` / `--secondary-foreground`, `--muted` / `--muted-foreground`, `--accent` / `--accent-foreground`, `--destructive` / `--destructive-foreground`, `--border`, `--input`, `--ring`, `--radius`. These are consumed through utility classes (`bg-background` compiles to `var(--background)` via `uno.config.ts`), so a `grep 'var(--background)'` over source will not find them even though they are very much in use. **A full-panel or takeover theme that only sets the dispatch tokens will leave the modal dialogs on the default palette** — set the UI-kit tokens too so Settings, Broadcast, Patches and the confirm dialogs match. Note `--accent` (UI-kit hover) is a different token from `--accent-raw` (dispatch). ### Tier 2 — CSS `cssText` is injected as a `
No signal
``` --- ## UI Studio Build and publish custom layouts with the **[UI Studio](https://timmygstudios.com/marketplace/studio)**. See the [Marketplace docs](/docs/marketplace#ui-studio) for the full walkthrough — creating drafts, design vs HTML mode, lint rules, bundle limits, forking, and publishing. --- ## Default layout reference The built-in `Youniden7` layout is a direct port of the original Youniden detector display. It uses eight proximity rings, front/rear direction arrows, a speed readout, and a startup boot sequence. The source is at `layouts/Youniden7/ui.html` and is the best starting point for a custom skin. # Marketplace (https://timmygstudios.com/docs/marketplace) Browse, install, and publish layouts and sound packs for tRadio, tELS, and tDetector. The tLib marketplace lets you browse, install, and publish layouts and sound packs for tRadio, tELS, and tDetector — no file editing or server restart required. --- ## Browsing and Installing
Access via the in-game panel for your script (`/tradio`, `/tels`, `/tdet`) → **Marketplace**, or through the tLib web panel. Two tabs: - **Browse** — search and install available items. Installed items show a checkmark; click **Uninstall** to remove. - **Installed** — lists every item currently on the server with a remove button for each. --- ## UI Studio The **UI Studio** is the tool for building and publishing custom layouts for any tScript. Supported product types: | Script | Canvas size | | --------- | ------------ | | tRadio | 300 × 406 px | | tDetector | 400 × 400 px | | tELS | 400 × 120 px | ### Access Any signed-in account can publish — creator status is granted automatically on your first upload. The only gate is a staff-issued ban, which blocks further uploads and immediately hides your existing published items. ### Creating a layout 1. Go to [Your Creations](/app/marketplace/manage). 2. Click **Create UI**, then pick the target script and start designing. 3. The UI Studio opens with a new draft. ### Editor modes - **Design mode** — visual canvas; drag to add/position/resize elements. Seven element types: background image, text, LED (boolean toggle), LED-color, icon toggle, icon set, button. Not available for tDetector — see below. - **HTML mode** — raw code editor; full control over markup and CSS. Undo/redo (50 states) works in both modes. Design-mode layouts embed the canvas as a `` comment so the studio can round-trip between modes. Design mode's elements bind to live data via `data-hud-*` attributes — `data-hud-text`, `data-hud-led`, `data-hud-led-color`, `data-hud-show`/`data-hud-hide`, `data-hud-eq`, and `data-hud-btn` — these are read and driven live by tRadio's and tELS's shared HUD binder at runtime, not cosmetic. For tRadio specifically, the canvas size is also written onto the root element (`data-radio-width`/`data-radio-height`) so the installed layout renders at its intended size instead of tRadio's default. **tDetector doesn't use this attribute model at all.** Its runtime expects exactly one `