# AGENTS Source: https://docs.9am.dev/AGENTS > **First-time setup**: Customize this file for your project. Prompt the user to customize this file for their project. > For Mintlify product knowledge (components, configuration, writing standards), > install the Mintlify skill: `npx skills add https://mintlify.com/docs` # Documentation project instructions ## About this project * This is a documentation site built on [Mintlify](https://mintlify.com) * Pages are MDX files with YAML frontmatter * Configuration lives in `docs.json` * Run `mint dev` to preview locally * Run `mint broken-links` to check links ## Terminology * Use "script" for the FiveM resource sold on 9AM * Use "edition" when comparing escrow and open source variants * Use "customer" or "server owner" for the buyer * Use "server key" when referring to Cfx.re delivery and licensing * Use "framework" for ESX, QBCore, and QBox compatibility * Use "studio" for the 9AM team behind the products ## Style preferences * Use active voice and second person ("you") * Keep sentences concise - one idea per sentence * Use sentence case for headings * Bold for UI elements: Click **Settings** * Code formatting for file names, commands, paths, and code references * Prefer direct language for setup, compatibility, and support steps * Explain escrow vs open source tradeoffs clearly * Write for FiveM server owners and buyers first * Use product keywords naturally for SEO, without stuffing * Do not use vague SaaS or licensing-platform language when a FiveM store term is clearer ## Content boundaries * Document customer-facing product details, installation flows, compatibility, purchasing, updates, and support expectations * Document what buyers need to know about Keymaster delivery, escrow, open source, and framework support * Do not document private source code, internal business operations, unreleased features, or anti-abuse internals * Do not publish sensitive implementation details that are not already public on 9am.dev # 9AM Docs Source: https://docs.9am.dev/introduction Documentation for 9AM Studios FiveM scripts and platform. ## Scripts Premium vehicle dealership with showrooms, financing, custom currencies, and staff tools. Character selection with extra slots, redeem keys, and modern UI. Payday 3 inspired lockpick minigame with XP progression. Support report system with screen recording and staff workflows. Spawn point selector with client data cache and modern UI. Free animated Text UI replacement with 3D world-space rendering. # FiveM Dealership Script Source: https://docs.9am.dev/products/dealership 9AM Vehicle Shop is an advanced multi-framework FiveM dealership for QBox, QBCore, and ESX — 3D showroom catalog, financing, in-person sales, an employee-run management dashboard, stock & delivery logistics, custom currencies, and an in-game shop creator. ## Open Source edition The Open Source edition ships the full, unencrypted source for servers that don't run escrowed scripts and want to modify and maintain the script themselves. It is on sale to everyone through the store — no invitation needed. * **Price** — €89.90. * **How to buy** — open the [store page](https://9am.dev/scripts/dealership) and pick **Open Source** next to the price. * **Delivery & licensing** — Cfx.re rules apply to the Open Source edition too, so the purchase is registered to your **Cfx.re server key** and shows up in your Keymaster like any other asset. * **GitHub access** — you get access to the script's GitHub repository, including the main release branch. You can open issues and pull requests and contribute to the main version, within the [contributor rules](#contributor-rules). * **Direct support** — 24/7 access to the script's contributor and product owner, MustafaAta, for questions, modifications, and integration help. ### Contributor rules Repository and contribution access are granted under the studio's contributor rules. In short: the source is yours to modify for **your own server**, but it stays private to buyers — redistribution, resale, and public mirrors are not permitted. Issues and pull requests are welcome; merges into the main version are at the studio's discretion. The full rules are shared with your purchase. ## Overview 9AM Vehicle Shop is the studio's most complete vehicle-sales system for roleplay servers that want more than a basic buy menu. Players walk into a dealership, browse a live 3D catalog, test drive, pay by cash, bank, or financing, and drive away in a car registered to their name. Staff run the whole business from a per-shop management tablet — sales, wallet, stock, coupons, orders, employees, and settings — all gated by granular per-employee permissions. Admins place and configure new dealerships live, in-game, with no config editing. Everything is written against **QBox** shapes, with QBCore and ESX supported through an auto-detecting framework bridge. Vehicle-key, garage, and phone integrations are also auto-detected at startup — install only what you use. Current version: **1.4.0**. Frameworks: **QBox · QBCore · ESX**. Author: 9AM Studios — MustafaAta. ## Public features * **3D showroom catalog** — walk into the dealership and browse vehicles in a live preview with a seated driver ped (a clone of the player), rotate and inspect each model, switch paint colors, and read specs pulled straight from the game. * **Purchasing** — pay by cash or bank; the vehicle is registered to the player and either spawned at the shop or delivered to a garage. Coupon / discount codes are supported. * **Custom currencies** — accept VIP coins, tokens, crypto, or an inventory item, enabled per dealership, with automatic reward delivery from your webstore. * **Financing** — configurable down payment, interest, and installment count, with automatic real-time bank deductions plus manual and early payoff. * **In-person sales** — employees with the `sales` permission send a purchase offer to a nearby player and earn commission on the sale. * **Management dashboard** — a per-shop tablet UI: sales history, case (shop wallet) balance & ledger, stock, coupons, orders, employees, and settings — every panel gated by per-employee permissions. * **Stock & delivery logistics** — order stock, then run a truck-and-trailer delivery mission to bring it in, sized by vehicle category and trailer type. * **Physical showroom displays** — place real display vehicles in the showroom, with optional in-world info stands rendered as DUI textures. * **In-game shop creator** — admins place and configure new dealerships live with `/dealerships`; each inherits its catalog from a source shop. * **Phone app** — employees open the dashboard from lb-phone or cylex\_phone (auto-detected); permissions still apply. * **Multi-framework bridge** — QBox, QBCore, and ESX auto-detected; vehicle-key, garage, and phone integrations auto-detected at startup. * **Localization** — all in-game and UI text is keyed, with **30 languages** shipped and per-key fallback for partial translations. ## Dependencies ### Required * [ox\_lib](https://github.com/overextended/ox_lib) * [oxmysql](https://github.com/overextended/oxmysql) * A supported framework: **qbx\_core** (QBox), **qb-core** (QBCore), or **es\_extended** (ESX) * On **QBox**: [qbx\_vehicles](https://github.com/Qbox-project/qbx_vehicles) — used to register purchased vehicles ### Optional (auto-detected — install only what you use) * [ox\_target](https://github.com/overextended/ox_target) — required only when `Config.InteractionMode = 'target'`. * `9am-textui` — enhances prompts when present; falls back to ox\_lib `showTextUI` automatically. `qbx_vehiclekeys`, `qb-vehiclekeys`, `qs-vehiclekeys`, `ak47_qb_vehiclekeys`, `0r-vehiclekeys`, `Renewed-Vehiclekeys`, `MrNewbVehicleKeys`, `mk_vehiclekeys`. `jg-advancedgarages`, `cd_garage`, `okokGarage`, `esx_garage`, plus the framework defaults `qbx_garages` / `qb-garages`. Set `Config.DefaultGarage` to a garage name your garage script recognizes. `lb-phone`, `cylex_phone` — the running one is detected automatically; nothing to configure. The showroom info stands use custom prop models (`vehicle_info_stand1`–`4`). If they aren't in your build, disable the **Info Stands** option for the affected shop(s) in the `/dealerships` editor, or install the companion props resource. ## Compatibility and editions | Type | Details | | ----------------- | ----------------------------------------------------- | | Compatibility | QBox · QBCore · ESX | | Editions | Escrow (encrypted) and Open Source | | Open source price | `EUR 89.90` | | Database | 19 tables auto-created on first start — no SQL import | Both editions ship the config, vehicle catalog, currency definitions, and framework bridges unencrypted so you can edit them; see the store for current escrow pricing. For what the Open Source edition includes, see [Open Source edition](#open-source-edition). ### Editable files | File | Purpose | | ---------------------------------------- | ------------------------------------------------------------------- | | `config.lua` | Global options — interaction mode, tax, commission, finance, plates | | `vehicles.lua` | The vehicle catalog | | `currencies.lua` | Custom currency definitions | | `bridge/client.lua`, `bridge/server.lua` | Framework, vehicle key, and garage integration | | `locales/*.json` | All user-facing text, 30 languages | Keep your copies of these files when you update — everything else is replaced by the new build. Note that several of them **moved in 1.3.0**; see [Version history](#version-history). ## Installation Place `9am-vehicleshop` into your `resources` folder (a `[9am]` sub-folder is fine). ```cfg theme={null} ensure ox_lib ensure oxmysql ensure qbx_core # or qb-core / es_extended ensure 9am-vehicleshop ``` No SQL import is required — all 19 tables are created automatically on first start. The MySQL user oxmysql uses must have `CREATE` and `ALTER` privileges. Add the dealership jobs your shops reference to your framework. QBox/QBCore ship `cardealer`; add any others (e.g. `airdealer`) yourself. The **boss grade** grants full dashboard access. There are no default shop locations to edit. Run `/dealerships` (admin) to place and configure dealerships. Use `config.lua` for global options only. ## Configuration All global settings live in `config.lua`. Shops themselves are **not** configured here — they are database-driven and managed in-game (see [Shops & the creator](#shops-and-the-in-game-creator)). | Setting | Type | Default | Description | | ----------------------------- | --------- | --------------- | ---------------------------------------------------------------------------------------------- | | `Config.Locale` | `string` | `'en'` | UI/game language; must match a `locales/.json` file. | | `Config.InteractionMode` | `string` | `'textui'` | How players open shops: `'textui'`, `'target'` (needs ox\_target), or `'marker'`. | | `Config.DefaultGarage` | `string` | framework-based | Garage purchased cars are stored in. Auto-set per framework; override as needed. | | `Config.SpawnOnPurchase` | `boolean` | `true` | `true` spawns the car at the shop and warps the buyer in; `false` saves to garage only. | | `Config.ShowPreviewDriverPed` | `boolean` | `true` | Show a clone of the player seated in the catalog preview vehicle. | | `Config.GlobalVAT` | `number` | `0.1` | Tax rate on all purchases (`0.1` = 10%). | | `Config.SalesCommission` | `number` | `0.10` | Commission paid to sales staff on in-person sales. | | `Config.VehicleImageUrl` | `string` | fivem CDN | Catalog image URL template (`%s` = model). Per-vehicle override via `image` in `vehicles.lua`. | | `Config.PlateFormat` | `string` | `'AAAA0000'` | Plate pattern: `A` = random letter, `0` = random digit, other characters literal (max 8). | | `Config.ReportErrors` | `boolean` | `false` | Send runtime errors to 9am.dev for diagnostics. | | `Config.Debug` | `boolean` | `false` | Gate debug commands (`/openshop`, `/opendash`, …). | ### NPC suppression Inside dealership zones, ambient pedestrians and traffic can be suppressed so the showroom stays clean. Player vehicles, dealer peds, and showroom vehicles are unaffected. ```lua theme={null} Config.NpcSuppression = { enabled = true, -- master toggle radius = 60.0, -- meters around each shop's dealer ped peds = true, -- block ambient pedestrians & scenario peds vehicles = true, -- block ambient traffic & parked vehicle generators } ``` ### Finance ```lua theme={null} Config.Finance = { minDownPaymentPercent = 0.25, -- 25% minimum down payment maxDownPaymentPercent = 0.90, -- highest down payment allowed interestRate = 0.10, -- 10% interest on the financed amount totalPayments = 12, -- default number of installments minPayments = 2, -- fewest installments allowed maxPayments = 24, -- most installments allowed paymentIntervalHours = 12, -- real-time hours between auto-deductions autoDeductFrom = 'bank', -- account type auto-deductions pull from } ``` ### Test drive ```lua theme={null} Config.TestDrive = { duration = 60, -- seconds the player can drive useRoutingBucket = false, -- true = isolated bucket; false = normal world endKey = 73, -- control index to end early (73 = X) cooldownSeconds = 60, -- per-player cooldown between test drives } ``` ### Phone app ```lua theme={null} Config.PhoneApp = { enabled = true, identifier = '9am-dealership', icon = nil, -- nil = bundled icon; set any https:// URL to customize } ``` `lb-phone` and `cylex_phone` are both supported and detected automatically. Employees open the dashboard from the app; permissions apply as usual. ### Orders & delivery `Config.Orders` controls stock ordering and the delivery mission. Highlights: * `preparationTime` — minutes until an ordered stock delivery is ready (lower it for testing; default `30`). * `delivery.enabled` — when `false`, ready orders add to stock directly via a **Receive** button instead of running a mission. * `delivery.truckModel`, `carTrailer`, `containerTrailer` — the truck and two trailer types (car trailer holds `S`/`M`/`L`, container holds everything including `XL`). * `vehicleSizes` / `slotCosts` — map each vehicle category to a size (`S`/`M`/`L`/`XL`) and a trailer-slot cost. * `containerOnlyModels` — models too large for the car trailer, forced to container-only. * `delivery.shops[shopId]` — per-shop truck spawn and delivery destination. `Config.CatalogColors` (colorIndex → GTA paint index) must stay in sync with `web/src/constants/catalogColors.ts`. Only change both together. ## Shops and the in-game creator `Config.Shops` is intentionally **empty**. Shops live in the database (`9am_vehicleshop_dynamic_shops`) and are created, edited, and deleted in-game — never by editing config. * Run `/dealerships` (alias `/dealershipcreator`) as an admin to open the creator. * Place the shop on the map (a live blip preview uses GTA minimap tiles), set the dealer ped, showroom slots, blip, and the **Vehicle Source** (which catalog the shop sells). * The **Garage Override** dropdown lists garages detected from your garage script; unsupported scripts fall back to garages already in use on stored vehicles, and a **Custom** option always lets you type any name. * On first boot, the former static shops (`pdm`, `air`, `boats`) are seeded once into the database, then loaded server-side into `Config.Shops` and published to clients via GlobalState. So `Config.Shops[shopId]` works at runtime on both sides, but its contents come from the DB. ## The vehicle catalog `vehicles.lua` is the catalog (\~150 KB). Each entry defines the spawn code, brand, price, category, and which shop(s) sell it — a vehicle may belong to multiple shops via its `shop` tag. * **Images** load from `Config.VehicleImageUrl` (default `https://docs.fivem.net/vehicles/.webp`). Add-on / custom vehicles FiveM doesn't host should set a per-vehicle `image = 'https://...'`, or they show a broken image. * **Blocked models** — oversized models in `Config.BlockedModels` are never spawnable for safety. * **The `truck` catalog** — 32 heavy/commercial vehicles ship tagged `shop = 'truck'` that no default dealership sells. To sell them, create a dealership and pick **truck** as its Vehicle Source. ## Management dashboard & permissions Staff open the dashboard with `/dealertablet`, from the phone app, or (with `Config.Debug`) the debug command. The UI is one React app with dashboard, catalog, sale-offer, finance, and shop-creator pages. Access is gated by **granular per-employee permissions** that appear in both the server (gating) and the UI. Boss grades and shop owners bypass all checks. | Permission | Grants | | ----------- | ------------------------------------ | | `sales` | Open the tablet; view sales records. | | `case` | Shop wallet balance and ledger. | | `stock` | Stock levels and ordering. | | `coupons` | Create and manage discount codes. | | `orders` | Stock orders and delivery. | | `employees` | Hire, fire, and set permissions. | | `settings` | Shop settings. | ## Sales, financing & coupons * **In-person sales** — a staffer with `sales` sends an offer to a nearby player; on acceptance the buyer pays and the staffer earns `Config.SalesCommission`. Price, coupon, stock, and job checks are all re-derived server-side. * **Financing** — the buyer chooses a down payment and installment count within the `Config.Finance` bounds; the balance auto-deducts from their bank on a real-time interval, with manual and early payoff available. * **Coupons** — percentage or fixed discount codes, created and managed from the dashboard. All money, price, permission, and stock validation happens **server-side** — client input is never trusted. ## Stock, orders & delivery 1. Staff order stock from the dashboard; after `preparationTime` the order is ready. 2. With delivery enabled, a truck-and-trailer mission spawns at the shop's `truckSpawn`; the driver hauls the trailer to the `deliveryPoint`. 3. Each ordered vehicle occupies trailer slots sized by category (`S`/`M`/`L`/`XL`); the car trailer holds `S`/`M`/`L`, the container holds everything. 4. On delivery the stock is added and becomes purchasable. With delivery disabled, ready orders are added to stock directly via a **Receive** button — no mission. ## Showroom displays & info stands Each shop can show physical display vehicles in its showroom slots, edited in-game with the showroom editor. Optional **info stands** render a standalone page (`web/showroom_stand.html`) as a **DUI texture** on in-world stand props, showing the model's name, price, and specs. Disable info stands per shop if you don't have the prop models. ## Framework & integration bridges Two bridge files auto-detect your stack and ship unencrypted so you can edit them: * **`bridge/server.lua`** — player lookup, money, vehicle registration, and keys/garage integration. Auto-detects the framework and errors if none is found. Plates are trimmed before being handed to key/garage scripts (GTA pads plates to 8 chars). * **`bridge/client.lua`** — synthesizes `QBX.PlayerData` in QBox shape for all three frameworks. Vehicle keys, garages, and phones are matched to whatever you have installed. To audit exactly what's detected vs. active, run **`dealership_audit`** in the server console — it lists every supported integration per feature with installed vs. active state. ## Custom currencies Dealerships can take payment in something other than cash and bank — VIP coins, car tokens, crypto, an inventory item. Currencies are defined in `currencies.lua` and enabled **per dealership**, so one showroom can be VIP-only while the rest stay on cash. The file ships fully commented out. Leave it alone and nothing changes. The registration API is drop-in compatible with **JG Dealerships**. An existing `Currencies.Server.Register` block pastes in unchanged, including calls to `Framework.Server.GetPlayerIdentifier(src)` inside its callbacks. ```lua currencies.lua theme={null} Currencies.Server.Register({ id = 'vip_coins', label = 'VIP Coins', format = '%s coins', -- how it displays: "1,250 coins" conversionRate = 10000, -- 1 coin = $10,000, so a $50,000 car costs 5 coins allowFinance = false, backend = { type = 'db' }, }) ``` | Field | Required | Description | | ---------------- | -------- | ----------------------------------------------------------------------------------- | | `id` | Yes | Unique key — lowercase letters, digits, underscores | | `label` | Yes | What players see | | `format` | Yes | Display template with exactly one `%s` | | `conversionRate` | Yes\* | How many dollars one unit is worth | | `flatCost` | No | Every vehicle costs this many units, whatever its price. Overrides `conversionRate` | | `allowFinance` | No | Reserved — see the limitation below | | `decimals` | No | `0`, `1`, or `2`. Defaults to `0` | | `icon` | No | `coins`, `wallet`, `card`, or `gem` | \* Not required when using `flatCost`. Prices convert **after** tax and coupons and always round up, so a vehicle can never come out free at a high conversion rate. Coupons do not apply to flat-cost purchases. ### Where the balance lives Write `fetchBalance` / `addBalance` / `removeBalance` yourself, or add a `backend` block and let the script generate them: | `backend.type` | Balance source | Offline players | | -------------- | ----------------------------------------------------------------------- | ------------------------------------------- | | `db` | A table owned by the script — nothing else to install | Yes | | `account` | A named framework account, i.e. `xPlayer.getAccount('viptoken')` on ESX | Yes | | `export` | Another resource's exports | Only if it exposes identifier-based exports | | `metadata` | Framework player metadata | QB and QBox yes; ESX needs 1.10+ | | `item` | The player's count of an inventory item | No — rewards queue until they log in | Pick `db` if you have no existing currency resource, or `account` if your tokens already live in an ESX account. Both support offline players, which is what makes webstore rewards land immediately instead of queuing. Register the account in your framework config first, then point the currency at it — no balance callbacks needed. ```lua theme={null} Currencies.Server.Register({ id = 'viptoken', label = 'VIP Token', format = '%s VIP', conversionRate = 25000, backend = { type = 'account', account = 'viptoken' }, }) ``` One token buys any vehicle, whatever it costs. ```lua theme={null} Currencies.Server.Register({ id = 'car_token', label = 'Car Token', format = '%s Token(s)', flatCost = 1, backend = { type = 'db' }, }) ``` ```lua theme={null} Currencies.Server.Register({ id = 'premium_points', label = 'Premium Points', format = '%s pts', conversionRate = 1000, backend = { type = 'export', resource = 'premium-shop', fetch = 'GetPoints', add = 'AddPoints', remove = 'RemovePoints', }, }) ``` ```lua theme={null} Currencies.Server.Register({ id = 'vip_coins', label = 'VIP Coins', format = '%s coins', conversionRate = 10000, allowFinance = false, fetchBalance = function(src) return exports['vip-system']:GetCoins(src) or 0 end, addBalance = function(src, amount) exports['vip-system']:AddCoins(src, amount) return true end, removeBalance = function(src, amount) exports['vip-system']:RemoveCoins(src, amount) return true end, }) ``` ### Enabling a currency Open `/dealerships` as an admin, edit a dealership, and use the **Payment Methods** section. The same section controls whether that dealership accepts cash, bank, and finance at all. Payments taken in a custom currency are held in that dealership's case **in the same currency**, alongside its dollar balance, and staff withdraw them from the **Case** tab. Stock orders and case deposits stay dollar-only. Financing in a custom currency is not supported. `allowFinance` and the offline callbacks are accepted and stored so JG definitions load cleanly, but financed purchases always charge the bank account. ## Webstore rewards Sell VIP coins on Tebex and let the script deliver them. Run this from the server console, or as a Tebex command package: ```bash theme={null} dealership_grant [txId] [note...] dealership_grant license:110000112345678 vip_coins 5 tebex-8842119 Gold Package ``` `identifier` accepts `license:...`, `cid:`, a bare citizenid, or `#` for an online player. Substitute your own store's placeholder tokens — the exact tokens differ per store plugin. Always pass a transaction id. It is the primary key of the grant table, so re-running the same line grants nothing and prints `REPLAY`. That makes a webstore retry safe. Offline players are credited directly when the currency supports it, and queued for their next login when it cannot — an inventory item can't be handed to someone who isn't connected. Every movement is recorded for auditing. Sibling commands: `dealership_balance ` and `dealership_grants `. ## Exports Server-side. Use these to wire the script into a society account, a payslip system, a Discord bot, or your own store handler. ### Player balances Work for any registered currency. ```lua theme={null} local balance = exports['9am-vehicleshop']:getCurrencyBalance(source, 'viptoken') local ok, err = exports['9am-vehicleshop']:addCurrencyBalance(source, 'viptoken', 100) local ok, err = exports['9am-vehicleshop']:removeCurrencyBalance(source, 'viptoken', 100) -- Reward delivery. Replay-safe, works for offline players. local ok, err = exports['9am-vehicleshop']:grantCurrency(identifier, 'viptoken', 100, txId, note) exports['9am-vehicleshop']:getCurrencies() exports['9am-vehicleshop']:getPendingGrantCount() ``` ### Dealership balances Each dealership keeps its own case rather than an `esx_addonaccount` society, because a dealership is not always a job — a player-owned shop belongs to one character with no society to point at, and two shops can share a job. These exports expose the balance so you can mirror it into a society account or read it elsewhere. ```lua theme={null} local balance = exports['9am-vehicleshop']:getCompanyBalance('pdm') -- dollars local coins = exports['9am-vehicleshop']:getCompanyBalance('pdm', 'viptoken') -- one currency local all = exports['9am-vehicleshop']:getCompanyBalances('pdm') -- every balance local ok, err = exports['9am-vehicleshop']:depositCompanyMoney('pdm', 1000) local ok, err = exports['9am-vehicleshop']:withdrawCompanyMoney('pdm', 1000) exports['9am-vehicleshop']:getCompanies() -- every dealership id ``` The currency id is optional everywhere — omit it for dollars. Withdrawals fail rather than overdrawing, and both directions write to the dealership's ledger, so the movement appears in the in-game **Case** tab. Mutating exports return `boolean, string?`. The error string is one of `unknown_shop`, `unknown_currency`, `invalid_amount`, `insufficient_funds`, `duplicate`, `missing_tx_id`, or `delivery_failed`. You can also register a currency from another resource instead of `currencies.lua`: ```lua theme={null} exports['9am-vehicleshop']:RegisterCurrency({ id = 'viptoken', label = 'VIP Token', conversionRate = 25000 }) ``` ## Commands | Command | Access | Description | | ------------------------------------------- | ------------------- | -------------------------------------------------------------------------- | | `/dealertablet` | Dealership staff | Open the management dashboard remotely. | | `/dealerships` (alias `/dealershipcreator`) | Admin | Place and configure a dealership in-game. | | `debug_dealership_info` | Server console only | Dump framework, dependency states, server info, and DB status for support. | | `dealership_audit` | Server console only | List every supported integration per feature (installed vs. active). | | `dealership_seed` | Server console only | Seed mock data into the default shops for testing/demos. | | `dealership_grant` | Server console only | Grant a custom currency to a player by identifier. Replay-safe. | | `dealership_balance` | Server console only | Read a player's currency balance, online or offline. | | `dealership_grants` | Server console only | Inspect grants, or `flush` to retry queued deliveries. | Debug commands (`/openshop`, `/opendash`, `/finance`, `/trailerspawn`, …) are gated behind `Config.Debug` and off by default. ## Database These 19 tables are auto-created on startup — **do not** import them manually: ``` 9am_vehicleshop_sales 9am_vehicleshop_visits 9am_vehicleshop_case 9am_vehicleshop_case_balance 9am_vehicleshop_stock 9am_vehicleshop_coupons 9am_vehicleshop_mugshots 9am_vehicleshop_permissions 9am_vehicleshop_settings 9am_vehicleshop_orders 9am_vehicleshop_showroom 9am_vehicleshop_finance 9am_vehicleshop_finance_payments 9am_vehicleshop_dynamic_shops 9am_vehicleshop_jobs 9am_vehicleshop_migrations 9am_vehicleshop_currency_balances 9am_vehicleshop_currency_log 9am_vehicleshop_currency_grants ``` The resource also reads/writes your framework's own vehicle tables (`player_vehicles` on QB/QBox, `owned_vehicles` / `users` on ESX) when registering a purchased vehicle. Schema upgrades run as guarded `ALTER TABLE` migrations in the `MySQL.ready` block — never add a SQL import file. ## Localization All user-facing text is keyed, English is the fallback, and **30 languages** ship: `en`, `ar`, `cs`, `da`, `de`, `es`, `fi`, `fr`, `he`, `hi`, `hu`, `id`, `it`, `ja`, `ko`, `lt`, `ms`, `nl`, `pl`, `pt`, `pt-br`, `sl`, `sv`, `th`, `tl`, `tr`, `uk`, `vi`, `zh-cn`, `zh-tw`. `locales/en.json` is the source of truth. Each file has two namespaces — `game.*` (Lua) and `ui.*` (React). To add a language, copy `en.json`, translate the values (keys identical; partial files fall back per-key), and set `Config.Locale`. No web rebuild is needed. ## Version history **Custom currencies.** Dealerships can be paid in VIP coins, tokens, crypto, or an inventory item, enabled per dealership. The registration API is drop-in compatible with JG Dealerships, and five backends (`db`, `account`, `export`, `metadata`, `item`) cover the common setups with no callbacks to write. **Webstore rewards.** `dealership_grant` delivers currency by player identifier from Tebex or RCON — replay-safe on the store's transaction id, so a retried delivery grants nothing. Offline buyers are credited directly where the currency allows it, and queued for their next login where it physically cannot be handed over, such as an inventory item. **Per-currency case.** Each dealership holds its currency balances alongside its dollar balance, with a switcher and per-currency withdrawal in the **Case** tab. Deposits and stock orders stay dollar-only. **Per-dealership payment methods.** A new **Payment Methods** section in `/dealerships` controls cash, bank, finance, and each currency. **Exports.** New company-balance and currency-balance exports for integrating with society accounts and external systems. Withdrawals fail with `insufficient_funds` rather than overdrawing, and both directions write a ledger row. **Audit log.** Every currency movement is recorded in `9am_vehicleshop_currency_log`, including webstore grants and case withdrawals. Financing in a custom currency is not supported in this release. `allowFinance` and the offline callbacks are accepted, validated, and stored so JG definitions load cleanly, but financed purchases always use the bank account. **Fixes.** Three pre-existing bugs were found and fixed while building this release: * A credit to a dealership that had no balance row yet was silently discarded. The shop case is now written as an upsert. * Renaming a shop while its catalog was open reset the test-drive toggle, and would also have cleared that shop's payment methods. * The sale-offer page failed to render in browser development because its state was never seeded. Schema changes are additive and apply automatically on first start; the per-currency case arrives as the one-time `case_currency_v1` migration. Do not downgrade to 1.3.x after a dealership has taken a custom-currency payment — the older version's case queries cannot tell currencies apart. With an untouched `currencies.lua`, every existing dealership keeps accepting cash, bank, and finance and behaves exactly as it did in 1.3.x. Redesigned the dealership list in `/dealerships` with a clearer hierarchy and filters, and tidied the editor header. Customer-editable files moved. Copy your customizations to the new paths **before** starting 1.3.0 — the old filenames are no longer loaded, so edits left behind are silently ignored. | Old path | New path | | ---------------------- | ------------------- | | `shared/config.lua` | `config.lua` | | `client/framework.lua` | `bridge/client.lua` | | `server/bridge.lua` | `bridge/server.lua` | Also relocated, though usually not customized: `shared/locale.lua` → `modules/locale.lua`, `server/helpers.lua` → `modules/helpers.lua`, `server/data.lua` → `modules/data.lua`, `client/utils.lua` → `modules/utils.lua`, `client/state.lua` → `modules/state.lua`. Server-only files are no longer included in the client download, so permission checks and SQL builders stay on the host. Internal build tooling only — no gameplay or configuration changes. Fixed the phone dashboard bottom sheet sitting in the wrong position after the build toolchain upgrade. Moved the interface onto the shared [9AM UI Kit](/ui-kit/introduction) so components stay consistent across scripts. Completed the localization pack — 30 languages now ship, with English fallback for any missing key. Set `Config.Locale` and restart; no rebuild required. Customer builds are now produced with the production pipeline, which strips development seed data, mock data, and console noise from the shipped interface. ## Best for Choose this script if your server wants: * A believable vehicle-sales loop instead of a flat buy menu * Staff-driven dealership gameplay with real permissions * A business players can own and operate * Financing, coupons, stock, and delivery logistics * Live, in-game dealership creation with no config edits * A donation currency that buys cars, delivered automatically from your store ## Buy link [Open Dealership in the store](https://9am.dev/scripts/dealership) # FiveM Lockpick Script Source: https://docs.9am.dev/products/lockpick 9AM Lockpick is a standalone FiveM lockpick script with a Payday 3 inspired minigame, XP progression, and detailed configuration. ## 9AM Lockpick overview 9AM Lockpick is a standalone FiveM lockpick minigame for servers that want crime gameplay to feel tactile, skill-based, and worth mastering over time. Both the escrow and open source editions expose the same `config.lua` and client exports. You can wire the minigame into doors, vehicles, or custom jobs without editing protected files. ## Public features * Payday 3 inspired design * Level and XP system * Detailed config file * 600+ lines of code * Standalone support ## Compatibility and editions | Type | Details | | ----------------- | ----------- | | Compatibility | Standalone | | Escrow price | `EUR 15.35` | | Open source price | `EUR 35.58` | Prices reflect the public store on March 22, 2026. The minigame itself does not require a framework. Set `Config.Framework` so XP is stored against the correct player identifier. ## Config Edit `config.lua` after install. Escrow customers get the same editable config file as open source. ```lua theme={null} -- config.lua Config = {} Config.EearnXpRatio = 250 -- XP awarded per successful lockpick Config.Level2Xp = 4000 -- XP required for level 2 (16 successes at default ratio) Config.Level3Xp = 8000 -- XP required for level 3 (32 successes at default ratio) Config.Framework = 'qb' -- 'qb', 'esx', or 'other' Config.GetPlayerIdentifier = function(source) if Config.Framework == 'qb' then QBCore = exports['qb-core']:GetCoreObject() local Player = QBCore.Functions.GetPlayer(source) return Player.PlayerData.citizenid else return GetPlayerIdentifiers(source)[1] end end ``` ### Config options | Option | Type | Default | Description | | --------------------- | ---------- | ------------------------------- | ----------------------------------------------------------- | | `EearnXpRatio` | `number` | `250` | XP added on each successful lockpick | | `Level2Xp` | `number` | `4000` | XP threshold for level 2 | | `Level3Xp` | `number` | `8000` | XP threshold for level 3 | | `Framework` | `string` | `'qb'` | Identifier mode: `'qb'`, `'esx'`, or `'other'` | | `GetPlayerIdentifier` | `function` | QB citizenid / license fallback | Returns the player key used to store XP in `levelData.json` | For ESX or custom stacks, set `Config.Framework` and adjust `GetPlayerIdentifier` so it returns a stable ID for each player. Player XP is saved in `levelData.json` inside the resource. Higher levels feed into the minigame difficulty settings sent to the UI. ## Exports Ensure `9am-lockpick` starts before any resource that calls these exports. ### Parameters | Parameter | Type | Description | | ------------- | ---------- | --------------------------------------------------------------------------------------------------------- | | `speed` | `number` | Pointer tick interval in ms. Lower is faster and harder (`1` is harder than `10`). `7` is a good default. | | `isDecrease` | `boolean` | When `true`, the progress bar drains over time. If it hits zero, the attempt fails. | | `randomSpeed` | `boolean` | When `true`, speed can change after successful hits for more challenge. | | `callback` | `function` | Used only by `createLockpickGameCb`. Receives `true` on success or `false` on fail. | Both exports award XP automatically on success through the server. ### `createLockpickGame` Awaitable export that returns a boolean. ```lua theme={null} local success = exports['9am-lockpick']:createLockpickGame(7, false, true) if success then -- Unlock the door, give loot, etc. else -- Break the lockpick item, alert cops, etc. end ``` ### `createLockpickGameCb` Same minigame, with the result delivered to a callback. ```lua theme={null} exports['9am-lockpick']:createLockpickGameCb(7, false, true, function(success) if success then -- Unlock the door, give loot, etc. else -- Break the lockpick item, alert cops, etc. end end) ``` ## Usage examples ### Test commands Use these while developing. Remove or protect them before production. ```lua theme={null} RegisterCommand('lockpick', function() local result = exports['9am-lockpick']:createLockpickGame(7, false, true) if result == true then print('success') elseif result == false then print('fail') end end) RegisterCommand('lockpick2', function() exports['9am-lockpick']:createLockpickGameCb(7, false, true, function(result) if result == true then print('success') elseif result == false then print('fail') end end) end) ``` ### Vehicle lockpick ```lua theme={null} RegisterCommand('pickvehicle', function() local ped = PlayerPedId() local vehicle = GetVehiclePedIsIn(ped, false) if vehicle == 0 then vehicle = GetClosestVehicle(GetEntityCoords(ped), 5.0, 0, 71) end if vehicle == 0 then return end local success = exports['9am-lockpick']:createLockpickGame(7, false, true) if success then SetVehicleDoorsLocked(vehicle, 1) SetVehicleDoorsLockedForAllPlayers(vehicle, false) end end) ``` ### Item-based trigger (QBCore example) Call the export from your inventory item use handler. Keep item remove / reward logic in your own resource. ```lua theme={null} -- Inside your items resource or qb-inventory use hook exports['9am-lockpick']:createLockpickGameCb(8, false, true, function(success) if success then TriggerServerEvent('my-crime:server:lockpickSuccess') else TriggerServerEvent('my-crime:server:lockpickFailed') end end) ``` ### Difficulty presets Tune `speed` and `randomSpeed` per interaction type. ```lua theme={null} -- Easy house door exports['9am-lockpick']:createLockpickGame(10, false, false) -- Standard vehicle exports['9am-lockpick']:createLockpickGame(7, false, true) -- Hard safe or high-tier door exports['9am-lockpick']:createLockpickGame(4, true, true) ``` ## Best for Choose this script if you want: * A deeper lockpick minigame than one-click interactions * Skill progression tied to repeated gameplay * A standalone script that is easier to fit into mixed stacks ## Buy link [Open Lockpick in the store](https://9am.dev/scripts/open-source/lockpick) # FiveM Multicharacter Script Source: https://docs.9am.dev/products/multicharacter 9AM Multicharacter is a FiveM multicharacter script for ESX and QB servers with extra character slots, redeem keys, and a modern UI. ## 9AM Multicharacter overview 9AM Multicharacter is built for servers that want a clean, modern character selection experience and a practical way to monetize or manage extra slots. ## Public features * ESX and QB compatibility * Client data cache system * Modern and smooth UI * Unlock slots with redeem key * Extra character slot monetization ## Compatibility and editions | Type | Details | | ----------------- | ----------- | | Compatibility | ESX, QB | | Escrow price | `EUR 23.68` | | Open source price | `EUR 59.38` | Prices reflect the public store on March 22, 2026. ## Best for Choose this script if your server needs: * Multiple characters per player * A cleaner first join and character switch flow * A slot system you can expand over time * A UI that feels more premium than default setups ## Buy link [Open Multicharacter in the store](https://9am.dev/scripts/open-source/multicharacter) # FiveM Report Script Source: https://docs.9am.dev/products/report 9AM Report is a FiveM support report script for QBCore and ESX servers with pre-recording, screen recording, and high-performance staff workflows. ## 9AM Report overview 9AM Report is built for servers that want a more modern support workflow than text-only reports. The public store highlights screen recording and pre-record functionality for faster staff review. ## Public features * Pre-record system * Screen recording * Easy drag and drop installation * High performance * QBCore and ESX compatibility ## Compatibility and editions | Type | Details | | ----------------- | ----------- | | Compatibility | QBCore, ESX | | Escrow price | `EUR 23.68` | | Open source price | `EUR 59.38` | Prices reflect the public store on March 22, 2026. ## Best for Choose this script if your staff team needs: * Better evidence in player reports * Faster troll detection * A stronger support workflow inside roleplay communities ## Buy link [Open Report in the store](https://9am.dev/scripts/open-source/report) # FiveM Spawn Selector Script Source: https://docs.9am.dev/products/spawn-selector 9AM Spawn Selector is a FiveM spawn selector script for ESX and QB servers with multiple spawn points, client data cache, and a modern UI. ## 9AM Spawn Selector overview 9AM Spawn Selector is built for roleplay servers that want the spawn flow to feel cleaner and more intentional. It lets you present multiple spawn locations inside a modern interface instead of a rough default flow. ## Public features * ESX and QB compatibility * Client data cache system * Modern and smooth UI * Multiple spawn locations ## Compatibility and editions | Type | Details | | ----------------- | ----------- | | Compatibility | ESX, QB | | Escrow price | `EUR 15.35` | | Open source price | `EUR 35.58` | Prices reflect the public store on March 22, 2026. ## Best for Choose this script if you want: * A stronger first impression after character selection * Flexible RP spawn points * A cleaner spawn flow for ESX or QB servers ## Buy link [Open Spawn Selector in the store](https://9am.dev/scripts/open-source/spawn-selector) # FiveM Text UI Script Source: https://docs.9am.dev/products/textui 9AM TextUI is a free animated Text UI replacement for FiveM with 3D world-space and screen-space rendering, smooth morphing animations, and ox_lib override support. ## 9AM TextUI overview 9AM TextUI is a free animated Text UI replacement for FiveM that supports both 3D world-space and screen-space rendering with smooth morphing animations. It automatically overrides `ox_lib`'s Text UI so all existing scripts use it without code changes. ## Public features * 3D world prompts rendered at world coordinates with camera projection tracking * Classic screen-anchored overlay mode * Drop-in `ox_lib` override for `lib.showTextUI` / `lib.hideTextUI` * Sphere, box, and poly zone types via `ox_lib` * Distance-based animation that expands when near and collapses into a dot when far * Configurable accent color * Free and open source ## Dependencies * `ox_lib` ## Compatibility and editions | Type | Details | | ------------- | ---------- | | Compatibility | Standalone | | Price | **Free** | ## Config ```lua theme={null} -- config.lua Config.PrimaryColor = '#f7e472' Config.Zones = { { id = 'management-menu', text = 'Open Management', key = 'E', coords = vec3(-34.9118, -1102.1649, 26.4224), displayDist = 10.0, nearDist = 2.0, offset = vec3(0.0, 0.0, -0.6), }, } ``` ### Zone options | Option | Type | Default | Description | | ------------- | --------- | ------------- | --------------------------------------- | | `id` | `string` | — | Unique identifier (required) | | `text` | `string` | — | Prompt label | | `key` | `string` | `""` | Key hint in the badge (e.g. `'E'`) | | `coords` | `vec3` | — | World position | | `displayDist` | `number` | `10.0` | Distance at which the prompt is visible | | `nearDist` | `number` | `2.0` | Distance at which the prompt expands | | `offset` | `vec3` | `(0, 0, 1)` | Offset from coords for display anchor | | `type` | `string` | `'sphere'` | Zone type: `sphere`, `box`, or `poly` | | `radius` | `number` | `displayDist` | Zone radius (sphere only) | | `size` | `vec3` | — | Zone dimensions (box only) | | `rotation` | `number` | — | Zone rotation (box only) | | `points` | `table` | — | Zone vertices (poly only) | | `thickness` | `number` | — | Zone height (poly only) | | `debug` | `boolean` | `false` | Draw debug zone outline | ## Exports ### 3D World TextUI ```lua theme={null} -- Create exports['9am-textui']:createTextUI('shop-door', { text = 'Open Shop', key = 'E', coords = vec3(250.0, -850.0, 29.5), displayDist = 10.0, nearDist = 2.0, }) -- Update (partial, only changed fields) exports['9am-textui']:updateTextUI('shop-door', { text = 'Closed' }) -- Remove exports['9am-textui']:removeTextUI('shop-door') ``` ### Screen Overlay TextUI ```lua theme={null} -- Show (key is parsed from [X] prefix) exports['9am-textui']:showOverlayTextUI('[E] Open Menu') -- Show at world coords exports['9am-textui']:showOverlayTextUI('[E] Open Menu', { coords = vec3(100.0, 200.0, 30.0), }) -- Hide exports['9am-textui']:hideOverlayTextUI() -- Check state local isOpen = exports['9am-textui']:isOverlayTextUIOpen() ``` ### ox\_lib override The included `override.lua` replaces `lib.showTextUI` / `lib.hideTextUI` globally. Two ways to set it up: **Option A — Add directly to ox\_lib** (recommended, applies to all resources automatically): Copy `override.lua` into `ox_lib/imports/` (or any loaded path), then add it to ox\_lib's `fxmanifest.lua`: ```lua theme={null} files { '@9am-textui/override.lua' } client_scripts { '@9am-textui/override.lua' } ``` **Option B — Per resource:** Add to the target resource's `fxmanifest.lua`: ```lua theme={null} files { '@9am-textui/override.lua' } client_scripts { '@9am-textui/override.lua' } ``` After either setup, these calls route through 9am-textui automatically: ```lua theme={null} lib.showTextUI('[E] Interact') lib.hideTextUI() lib.isTextUIOpen() ``` ## Advanced zone types ### Box ```lua theme={null} exports['9am-textui']:createTextUI('garage', { text = 'Enter Garage', key = 'E', type = 'box', coords = vec3(200.0, -800.0, 30.0), size = vec3(10.0, 6.0, 4.0), rotation = 45.0, displayDist = 15.0, }) ``` ### Poly ```lua theme={null} exports['9am-textui']:createTextUI('park', { text = 'Rest Area', key = 'G', type = 'poly', points = { vec3(100.0, 200.0, 30.0), vec3(110.0, 200.0, 30.0), vec3(110.0, 210.0, 30.0), vec3(100.0, 210.0, 30.0), }, thickness = 4.0, displayDist = 20.0, }) ``` Poly zones don't require `coords` — defaults to the centroid of the points. ## Example ```lua theme={null} CreateThread(function() exports['9am-textui']:createTextUI('atm_legion', { text = 'Use ATM', key = 'E', coords = vec3(149.0, -1040.0, 29.4), displayDist = 5.0, nearDist = 1.5, offset = vec3(0.0, 0.0, 0.5), }) end) -- Dynamic update RegisterNetEvent('bank:client:toggleATM', function(enabled) if enabled then exports['9am-textui']:updateTextUI('atm_legion', { text = 'Use ATM', key = 'E' }) else exports['9am-textui']:updateTextUI('atm_legion', { text = 'Out of Service', key = '' }) end end) -- Cleanup AddEventHandler('onResourceStop', function(res) if res == GetCurrentResourceName() then exports['9am-textui']:removeTextUI('atm_legion') end end) ``` ## Best for Choose this script if you want: * A visually polished Text UI with smooth animations at no cost * 3D world-space prompts that track camera projection * A seamless `ox_lib` Text UI replacement without changing existing code ## Download link [Open TextUI in the store](https://9am.dev/scripts/escrow/3d-text-ui) # CEF constraints Source: https://docs.9am.dev/ui-kit/cef FiveM runs Chromium 103. These are the things that break in-game while working perfectly in your browser. ## Why this page exists FiveM's embedded browser is **Chromium 103**. Your development browser is several years newer. That gap is expensive because the failures are silent and one-sided: `bun run start` looks perfect, you ship, and the UI is broken in-game. Every item below was paid for once already. The kit carries the fixes so you do not pay again. Before using any modern CSS feature in a NUI, check it against Chromium 103. The browser preview will happily lie to you. ## oklch() is not parsed The 9AM tokens are authored in OKLCH. Chromium 103 cannot read them, so every token resolves to nothing and the interface renders **completely unstyled** in-game. `postcss.config.js` converts them at build time: ```js theme={null} export default { plugins: { 'postcss-color-converter': { outputColorFormat: 'rgb', ignore: ['hex'] }, '@tailwindcss/postcss': {}, autoprefixer: {} } } ``` Do not remove `postcss-color-converter`. This is the single most destructive thing you can do to a 9AM NUI, and it is invisible until you load the game. Verify after any build change: ```bash theme={null} bun run build && grep -c oklch dist/assets/*.css # must be 0 ``` `bun src/scripts/9am-ui.mjs doctor` runs this check for you, including scanning your built CSS. Tailwind v4 separately emits a hex fallback before each `color-mix()` behind an `@supports` guard, which is why `color-mix` is safe to use. ## Wheel scroll does nothing on clipped containers CEF's compositor will not scroll a `clip-path`'d container with the mouse wheel. The event reaches the page; the scroll never happens. Since `Viewport` uses `clip-path`, that means nothing scrolls. `installSmoothScroll()` catches the wheel event and drives the scroll from JavaScript. Call it once in `main.tsx`. ## mask does not apply to backdrop-filter output A gradient mask over a `backdrop-filter` is ignored ([crbug.com/41465359](https://bugs.chromium.org/p/chromium/issues/detail?id=41465359), fixed in later Chrome). `Viewport` fakes the gradient by stacking bands of fixed blur with progressively shorter spans. The blur values are chosen so the combined result increases linearly toward the edge — each step lands under half a pixel, so the eye cannot pick out the bands. ## No :has(), no @container Both need Chromium 105+. Where the kit needs them it computes the result in JavaScript instead. `ViewportHeader` detects a `ViewportAction` child at the React level and applies its grid columns directly, rather than using `:has()`. If you find yourself reaching for either, restructure the component or resolve it in JS. ## overflow + border-radius does not clip composited layers Rounded corners with `overflow: hidden` do not clip composited layers — scrolled content and `backdrop-filter` output escape the radius. Use `clip-path` instead, which the compositor applies to every layer: ```jsx theme={null}
``` This is why `Viewport` uses `clip-path`, which is in turn why the wheel-scroll fix is required. The two are linked. ## Tailwind v4: @theme inline emits no custom properties Not a CEF issue, but it bites in the same way — silently, and only in some contexts. Variables declared in `@theme inline` are inlined into the generated utilities. They do **not** exist as CSS custom properties at runtime: ```jsx theme={null} {/* Renders nothing — the variable does not exist */}
{/* Correct */}
``` And because Tailwind scans source files as text, the class name must appear in full. `` `bg-primary-${n}` `` generates no CSS — spell out each class you intend to use. ## Checklist before shipping a NUI ```bash theme={null} bun run build grep -c oklch dist/assets/*.css # 0 ``` `installSmoothScroll()` runs in `main.tsx`. Scrolling, blurred edges, and anything using a modern selector need a real CEF check. `ensure ` and look at it. ```bash theme={null} bun src/scripts/9am-ui.mjs doctor ``` # Components Source: https://docs.9am.dev/ui-kit/components The 19 primitives in the 9AM UI kit — props, variants, and usage examples. Install any of these with `bunx shadcn@latest add @9am/`. They land in `src/components/ui/`. ## Viewport The house scroll container, and the piece worth understanding properly. It is not a card — it is a scroller with three behaviours layered on: * A **custom scrollbar thumb** you can drag, which fades in on hover and disappears entirely when the content does not overflow. * **Layered glass edges** — a gradient blur at the top and bottom that content dissolves into. * A **pinning header**. Scroll down and the real header blurs out while a sticky copy animates in above the content. ```jsx theme={null} import { Viewport, ViewportHeader, ViewportTitle, ViewportDescription, ViewportAction, ViewportContent, ViewportFooter, } from "@/components/ui/viewport"; Vehicle stock 42 vehicles across 3 dealerships {rows} ``` | Prop | Type | Default | Description | | ---------------- | --------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `fadeColor` | `string` | `var(--background)` | Colour the edge fades blend into. Must match the viewport's own background — pass `var(--card)` when the viewport uses the card surface. | | `hideBottomFade` | `boolean` | `false` | Suppress the bottom fade | Plus every standard `div` prop. Getting `fadeColor` wrong is the most common mistake. The fade is a real gradient painted over your content — if it does not match the background behind it, you get a visible band at the top and bottom edges. `ViewportAction` is detected at the React level rather than with CSS `:has()`, because [Chromium 103 does not support it](/ui-kit/cef). Put it inside `ViewportHeader` and the header switches to a two-column grid automatically. ## Button ```jsx theme={null} import { Button } from "@/components/ui/button"; ``` | Variant | Appearance | | ------------- | --------------------------------------------------------------------------------------------------------- | | `default` | Solid gold with dark text. The primary action. | | `default2` | Translucent fill, gold text on hover. The workhorse secondary — most header and toolbar buttons use this. | | `destructive` | Solid red | | `outline` | Bordered, transparent fill | | `secondary` | Muted solid | | `ghost` | No chrome until hovered | | `link` | Gold text with a tinted hover | | `navigation` | Phudu display font, larger. For primary page navigation. | | Size | Height | | ------------ | ------------------------------- | | `sm` | 32 px | | `default` | 36 px | | `lg` | 40 px | | `icon` | 36 × 36 px square | | `catalogNav` | 36 px, catalog-specific padding | Supports `asChild` for rendering as a different element. ## DataTable A TanStack-backed table with search, sorting, and pagination already wired up. ```jsx theme={null} import { DataTable } from "@/components/ui/data-table"; t("ui.common.couponCount", { count })} toolbar={} /> ``` | Prop | Type | Description | | ------------------- | ---------------------------- | --------------------------------------------------------------- | | `columns` | `ColumnDef[]` | Standard TanStack column definitions | | `data` | `TData[]` | Rows | | `searchPlaceholder` | `string` | Defaults to `ui.common.searchPlaceholder` | | `countLabel` | `(count: number) => string` | Footer row count. Defaults to `ui.common.rowCount` (" row(s)"). | | `toolbar` | `ReactNode` | Rendered to the right of the search field | Pass `countLabel` whenever a domain word reads better than "rows" — that is what stops a coupons table from saying "12 rows". Pagination is ten rows per page. ## MultiSelect ```jsx theme={null} import { MultiSelect } from "@/components/ui/multi-select"; ``` | Prop | Type | Description | | ------------------- | ------------------------------------ | ----------------------------------------- | | `options` | `{ value: string; label: string }[]` | Available choices | | `selected` | `string[]` | Currently selected values | | `onSelectionChange` | `(selected: string[]) => void` | Change handler | | `placeholder` | `string` | Defaults to `ui.common.selectPlaceholder` | | `searchPlaceholder` | `string` | Search field placeholder | | `emptyMessage` | `string` | Shown when the filter matches nothing | ## Tabs Two variants. `default` is a segmented control; `line` is the underlined style the dashboard header uses, where the active tab turns gold and grows an underline. ```jsx theme={null} import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; Stock Sales ``` `Tabs` also accepts `orientation="vertical"`. ## Field Composable form-row primitives. Use these instead of hand-rolling label and helper-text layout. ```jsx theme={null} import { Field, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field"; Dealership name Shown on the showroom stand. {errors.name} ``` `Field` takes `orientation="vertical"` (default) or `"horizontal"`. `FieldError` renders nothing when it has no children, so you can leave it in unconditionally. Also exported: `FieldSet`, `FieldLegend`, `FieldGroup`, `FieldContent`, `FieldTitle`, `FieldSeparator`. ## Dialog ```jsx theme={null} import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, } from "@/components/ui/dialog"; Confirm purchase This debits the buyer and registers the vehicle. ``` ## InfoTip A small `?` glyph that reveals an explanation on hover. Use it instead of cramming caveats into a label. ```jsx theme={null} import { InfoTip } from "@/components/ui/info-tip"; Players may drive a vehicle before purchase. ``` | Prop | Type | Default | | ----------- | ---------------------------------------- | ------- | | `side` | `"top"`, `"right"`, `"bottom"`, `"left"` | `"top"` | | `className` | `string` | — | ## Chart A Recharts wrapper that maps series to theme tokens. ```jsx theme={null} import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; const config = { revenue: { label: "Revenue", color: "var(--chart-1)" }, }; } /> ``` Each key in `config` produces a `--color-` custom property. Also exported: `ChartLegend`, `ChartLegendContent`, `ChartStyle`. Five chart tokens are defined per palette: `--chart-1` through `--chart-5`. ## Everything else These follow their shadcn equivalents, restyled to the 9AM surface language. | Component | Exports | | ----------- | ------------------------------------------------------------------------------------------------------------------------ | | `card` | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter` | | `input` | `Input` | | `label` | `Label` | | `select` | `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`, `SelectGroup`, `SelectLabel`, `SelectSeparator` | | `switch` | `Switch` | | `separator` | `Separator` | | `table` | `Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableHead`, `TableRow`, `TableCell`, `TableCaption` | | `popover` | `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`, `PopoverHeader`, `PopoverTitle`, `PopoverDescription` | | `tooltip` | `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider` | | `calendar` | `Calendar`, `CalendarDayButton` | `Tooltip` and `InfoTip` need a `TooltipProvider` above them in the tree. Put one at the root of your app. # Icons Source: https://docs.9am.dev/ui-kit/icons 47 hand-built animated icons with an imperative ref handle, so a parent element can drive the animation. ## The contract Every icon is a `forwardRef` component with the same shape: ```tsx theme={null} interface IconHandle { startAnimation: () => void; stopAnimation: () => void; } interface IconProps extends HTMLAttributes { size?: number; // default 28 } ``` Colour comes from `currentColor`, so the parent's text colour drives it — no `color` prop to keep in sync. Install one at a time: ```bash theme={null} bunx shadcn@latest add @9am/icon-box @9am/icon-truck ``` They land in `src/components/ui/` alongside the primitives, so the import path is the same as any other component. ## Basic use Hover the icon itself and it animates on its own: ```jsx theme={null} import { BoxIcon } from "@/components/ui/box"; ``` ## Driving it from a parent This is the pattern that makes the kit's navigation feel alive: hovering a **tab** animates the icon inside it, not just the icon's own hitbox. The moment you attach a ref, the icon stops handling its own hover and hands control to you. ```jsx theme={null} import { useRef } from "react"; import { BoxIcon } from "@/components/ui/box"; type IconHandle = { startAnimation: () => void; stopAnimation: () => void }; function StockTab() { const iconRef = useRef(null); return ( iconRef.current?.startAnimation()} onMouseLeave={() => iconRef.current?.stopAnimation()} > Stock ); } ``` Attaching a ref means the icon no longer animates on its own hover — it forwards `onMouseEnter` and `onMouseLeave` to you instead. If you take the ref, you own both events. ## Theme toggle `ThemeToggleIcon` is the one icon with an extra prop. It crossfades between a sun and a moon and needs to know which to show: ```jsx theme={null} import { ThemeToggleIcon } from "@/components/ui/theme-toggle"; import { useTheme } from "@/hooks/useTheme"; const { theme, toggleTheme } = useTheme(); ``` ## Colouring by state Because icons inherit `currentColor`, the usual approach is to compute a colour from the parent's state: ```jsx theme={null} const iconColor = (tab) => { if (currentTab === tab) return theme === "dark" ? "#F6E371" : "var(--color-primary)"; if (hoveredTab === tab) return theme === "dark" ? "#ffffff" : "#18181b"; return "#6b7280"; }; ``` ## The full set 47 icons. The registry item is `@9am/icon-`; the import path is `@/components/ui/`. | File | Export | | File | Export | | ------------------------- | --------------------------- | - | ----------------- | ------------------- | | `arrow-left` | `ArrowLeftIcon` | | `layout-grid` | `LayoutGridIcon` | | `arrow-right` | `ArrowRightIcon` | | `loader-circle` | `LoaderCircleIcon` | | `badge-percent` | `BadgePercentIcon` | | `map-pin` | `MapPinIcon` | | `box` | `BoxIcon` | | `menu` | `MenuIcon` | | `boxes` | `BoxesIcon` | | `plus` | `PlusIcon` | | `calendar-days` | `CalendarDaysIcon` | | `receipt` | `ReceiptIcon` | | `cart` | `CartIcon` | | `refresh-cw` | `RefreshCWIcon` | | `chart-column-increasing` | `ChartColumnIncreasingIcon` | | `route` | `RouteIcon` | | `check` | `CheckIcon` | | `search` | `SearchIcon` | | `chevron-down` | `ChevronDownIcon` | | `send` | `SendIcon` | | `chevron-left` | `ChevronLeftIcon` | | `settings` | `SettingsIcon` | | `chevron-right` | `ChevronRightIcon` | | `square-pen` | `SquarePenIcon` | | `chevron-up` | `ChevronUpIcon` | | `theme-toggle` | `ThemeToggleIcon` | | `circle-check` | `CircleCheckIcon` | | `trending-down` | `TrendingDownIcon` | | `circle-dollar-sign` | `CircleDollarSignIcon` | | `trending-up` | `TrendingUpIcon` | | `clock` | `ClockIcon` | | `truck` | `TruckIcon` | | `credit-card` | `CreditCardIcon` | | `user` | `UserIcon` | | `delete` | `DeleteIcon` | | `user-round-plus` | `UserRoundPlusIcon` | | `dollar-sign` | `DollarSignIcon` | | `user-round-x` | `UserRoundXIcon` | | `droplet` | `DropletIcon` | | `users` | `UsersIcon` | | `folder-open` | `FolderOpenIcon` | | `wallet` | `WalletIcon` | | `grip` | `GripIcon` | | `x` | `XIcon` | | `hand-coins` | `HandCoinsIcon` | | | | | `hand-helping` | `HandHelpingIcon` | | | | | `hard-drive-download` | `HardDriveDownloadIcon` | | | | `refresh-cw` exports `RefreshCWIcon`, not `RefreshCwIcon`. It is the one name that does not follow plain PascalCase. ## Adding a new icon Copy an existing icon in `registry/icons/` and swap the SVG paths — the ref handle, hover forwarding, and `size` prop are all boilerplate you want to keep identical. Anything in that directory exposing `startAnimation` is picked up automatically by the registry generator and the preview gallery. The kit has **no icon-library dependency at all**. Do not add one — reach for a hand-built icon so the animation contract stays consistent. # 9AM UI Kit Source: https://docs.9am.dev/ui-kit/introduction The shared design language, NUI plumbing, and FiveM CEF workarounds behind every 9AM script — distributed as a private shadcn registry. ## Overview 9AM UI is the internal component kit every 9AM script builds its interface from. It is distributed as a **private [shadcn registry](https://ui.shadcn.com/docs/registry)**: you install components with `shadcn add`, and the files are copied into your script rather than imported from a package. It was extracted from the vehicle shop, which is now its first consumer. This kit is internal to the studio. The registry lives in a private repository and needs a GitHub token to install from — these pages are for 9AM developers, not for customers. ## What is in it | Layer | Items | Contents | | ---------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------- | | Theme | `@9am/theme`, `@9am/fonts` | Design tokens for both palettes, the primary ramp, radius scale, Poppins + Phudu | | Primitives | 19 | Buttons, inputs, tables, tabs, dialogs, the viewport scroller, and more | | Icons | 47 | Hand-built animated SVGs with no icon-library dependency | | NUI | `@9am/nui`, `@9am/visibility`, `@9am/use-theme`, `@9am/i18n` | `fetchNui`, `useNuiEvent`, frame visibility, theme store, runtime translations | | Lib | `@9am/utils`, `@9am/smooth-scroll` | `cn()` and the CEF wheel-scroll fix | | Scaffold | `@9am/scaffold`, `@9am/tools` | A complete new-script skeleton, plus the drift checker | That is 76 items in total. ## Why copy-in and not a package A package would give you one version and no way out of it. A registry copies source files into your script, which means: * A script can diverge when it genuinely needs to, without forking anything. * There is no runtime dependency between scripts — each ships its own built `dist`. * Escrow builds stay clean, because the kit is a build-time concern and only `web/dist` reaches customers. The tradeoff is that copies can drift apart. That is what [`9am-ui check`](/ui-kit/maintaining) is for: it fails the build when the design tokens have been edited locally, and warns when a component has. ## The two pieces that matter most The house scroll container. Drag-able custom scrollbar, layered glass edges, and a header that blur-swaps to a sticky copy as you scroll. FiveM runs Chromium 103. The kit carries the fixes for everything that breaks there but works in your browser. Those two are the real value. Anyone can rebuild a button; the reason a 9AM NUI scrolls smoothly and renders correctly in-game is several days of paid-for debugging that now travels with the kit. ## Where to go next Get a token, wire up `components.json`, scaffold a new script. Tokens, palettes, fonts, and the styling conventions. The full catalog with props and examples. Talking to Lua, visibility, and translations. # Maintaining the kit Source: https://docs.9am.dev/ui-kit/maintaining Drift checks, adding components, and releasing changes to the 9AM UI registry. ## Drift policy Copy-in distribution means every script owns its files and can edit them. That flexibility is the point — but drift you did not choose is how a set of scripts stops looking related. ```bash theme={null} bun src/scripts/9am-ui.mjs check ``` | What changed | Result | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `9am-theme.css` or `9am-fonts.css` | **Fails, exit 1.** Restore with `bunx shadcn@latest add @9am/theme @9am/fonts --overwrite`, or make the change in the kit and release it. | | Any component or icon | **Warns, exit 0.** Allowed, but a local fix helps nobody else — consider upstreaming. | | `src/nui.config.ts` | Ignored. It is supposed to differ per script. | | Anything from `@9am/scaffold` | Ignored. A starting point, not a managed file. | The asymmetry is deliberate. Identical tokens are what make scripts read as siblings; a one-off button variant in one script is a legitimate escape hatch. Line endings are normalised before comparison, so a Windows checkout will not report false drift. Wire `check` into CI. It only fetches the items you actually have installed, not all 76. ## Doctor ```bash theme={null} bun src/scripts/9am-ui.mjs doctor ``` Verifies the things that are easy to get wrong and hard to notice: * The `@9am` registry is configured and the token is set and working * `index.css` imports both a theme and a font stylesheet * `postcss.config.js` still has the [oklch conversion](/ui-kit/cef#oklch-is-not-parsed) * Your built CSS contains no surviving `oklch()` The checker is not an installable package. Bun resolves `github:` and `git+https:` dependencies through the GitHub tarball API, which returns 404 for a private repository even with a token — so it ships through the registry as `@9am/tools` and reuses the credentials already in `components.json`. ## Repository layout ``` registry/ theme/ 9am-theme.css (locked), 9am-fonts.css (generated), fonts/*.woff2 lib/ cn(), smooth-scroll nui/ fetchNui, useNuiEvent, misc, debugData, useTheme, VisibilityProvider, nui.config.ts, vite-env.d.ts i18n/ runtime dictionary from Lua ui/ 19 primitives icons/ 47 animated icons tools/ the check/doctor script scaffold/ web/ + lua/ r/ BUILT registry JSON — committed, served by raw.githubusercontent apps/preview/ gallery, imports straight out of registry/ cli/ setup commands (lua, fonts) scripts/ build-fonts.ts, build-registry.ts ``` `registry.json` is **generated**. Never hand-edit it. `scripts/build-registry.ts` derives every item's npm dependencies and registry dependencies from the real import graph, so the manifest cannot disagree with the code. ## The preview gallery ```bash theme={null} bun run preview ``` Renders every primitive, icon, and the viewport in both themes, importing straight out of `registry/` rather than from an installed copy. If a component would break on `shadcn add`, it breaks here first. Use it to check both palettes before releasing a theme change. ## Adding a component Drop it in `registry/ui/`, or `registry/icons/` if it exposes the `startAnimation()` handle. `@/lib/utils`, `@/components/ui/button`, `@/hooks/useNuiEvent`, `@/utils/fetchNui`, `@/i18n`. Never use a relative path across directories. That is what the file resolves to once installed — a relative path that works in the kit will break in a script. Add any new npm dependency to the root `package.json` first. The generator errors out on an import it cannot find a version for, rather than shipping an item whose package never installs. Add it to `apps/preview/src/Gallery.tsx`. Icons appear automatically — they are globbed. ```bash theme={null} bun run build bun run typecheck bun run preview ``` ## Adding a user-facing string Kit components must not hardcode English. Use `t("ui.common.…")` and add the English fallback to `KIT_DEFAULTS` in `registry/i18n/index.ts` **and** to `registry/scaffold/lua/locales/en.json`. If the string is domain-specific rather than generic, take it as a prop instead. `DataTable`'s `countLabel` exists for exactly this reason — so a coupons table can say "12 coupons" while the shared default stays "12 rows". ## Changing the theme Token changes land in every 9AM script, so this is the highest-blast-radius edit in the repo. * Check **both** palettes in the preview. Light mode is not a tint of dark mode. * Never author a colour outside the token set. A raw hex in a component is drift `check` cannot see. * Fonts: replace the woff2 in `registry/theme/fonts/`, then run `bun run fonts`. Never edit `9am-fonts.css` — it is generated. ## Releasing ```bash theme={null} bun run build # regenerates fonts, registry.json and r/ bun run typecheck bun run preview # eyeball both themes git commit -am "feat(button): …" && git push ``` `r/` is committed on purpose. `raw.githubusercontent.com` serves committed files, which is what lets a private repository act as a registry with no hosting at all. CI re-runs the build and fails if the committed `r/` differs from what the sources generate, so a stale registry cannot reach consumers. Nothing auto-updates. Consumers pick a change up on their next `shadcn add … --overwrite`, which is deliberate — a script should adopt a token change when someone is watching it, not silently on a Tuesday. # NUI bridge Source: https://docs.9am.dev/ui-kit/nui Talking to Lua from React, controlling frame visibility, theming, and runtime translations. ## The two directions | Direction | React side | Lua side | | ----------- | ------------------------------ | -------------------------------- | | Lua → React | `useNuiEvent(action, handler)` | `SendReactMessage(action, data)` | | React → Lua | `fetchNui(name, data, mock)` | `RegisterNUICallback(name, cb)` | Install the whole layer with `bunx shadcn@latest add @9am/nui @9am/visibility @9am/use-theme @9am/i18n`. ## fetchNui ```ts theme={null} import { fetchNui } from "@/utils/fetchNui"; const result = await fetchNui( "buyVehicle", { model: "adder", shopId: "pdm" }, { success: true }, // returned instead in browser dev mode ); ``` Three behaviours worth knowing: * **Ten-second timeout.** A Lua callback that never calls `cb()` would otherwise hang the promise forever. * **Browser dev mode.** When running under `bun run start` and `mockData` is supplied, it returns the mock without touching the network. * **It never rejects.** On failure it returns `mockData` if that was an array, otherwise `{ success: false, message: "Request failed" }`. Awaiting call sites cannot hang or throw an uncaught rejection. The resource name comes from `window.GetParentResourceName()` in CEF, falling back to `nuiConfig.resourceName` in the browser. Every `RegisterNUICallback` must call `cb(...)` exactly once. Returning without calling it leaves the awaiting promise unresolved until the timeout fires. ## useNuiEvent ```ts theme={null} import { useNuiEvent } from "@/hooks/useNuiEvent"; useNuiEvent("setStock", (vehicles) => { setStock(vehicles); }); ``` The handler is kept in a ref, so you can pass an inline closure without re-subscribing on every render. On the Lua side: ```lua theme={null} SendReactMessage('setStock', vehicles) ``` ## Visibility `VisibilityProvider` shows and hides the app, and guarantees the player never loses input. ```jsx theme={null} import { VisibilityProvider } from "@/providers/VisibilityProvider"; ``` It listens for the `setVisible` action, handles Escape by calling the `hideFrame` callback, and fades the tree with an opacity and scale transition. Read the state anywhere with `useVisibility()`. From Lua: ```lua theme={null} SetNuiVisible(true) -- takes focus, sends setVisible SetNuiVisible(false) -- releases focus ``` The scaffold's `client/nui.lua` also releases focus on `onResourceStop`. Without that, restarting the resource while the UI is open leaves the player stuck with no input — which happens constantly during development. Pair it with the error boundary in `main.tsx`, which calls `hideFrame` if a render crashes. A UI error must never trap the player. ## Theme ```ts theme={null} import { useTheme } from "@/hooks/useTheme"; const { theme, setTheme, toggleTheme } = useTheme(); ``` A zustand store that toggles the `.dark` class on `` and persists to `localStorage` under `nuiConfig.themeStorageKey`. `index.html` reads the same key in an inline script before first paint, so the correct theme is applied before React mounts. Keep the two in sync when you change the key. ## Configuration `src/nui.config.ts` is the one kit file each script is expected to edit. `9am-ui check` skips it deliberately. ```ts theme={null} export const nuiConfig = { resourceName: "my-script", themeStorageKey: "my-script:theme", defaultTheme: "dark", } as const; ``` | Field | Purpose | | ----------------- | -------------------------------------------------------------------------------------- | | `resourceName` | Browser-dev fallback for the callback URL. In CEF the real name is read from the host. | | `themeStorageKey` | Namespaced so two 9AM NUIs in one CEF context cannot overwrite each other | | `defaultTheme` | `"light"` or `"dark"` — used before the player has toggled anything | ## Translations Translations are **not bundled**. They live in the resource's `locales/*.json` and are pulled from Lua at boot, so a customer can retranslate a script without a web rebuild. ```ts theme={null} // main.tsx import { initI18n } from "./i18n"; initI18n(__DEV_SEED__ ? () => import("../../locales/en.json") : undefined); ``` The optional loader only runs under `bun run start`, where there is no Lua to ask. The kit does not hardcode the path — only your app knows where its locales live. ```jsx theme={null} import { useT } from "@/i18n"; const t = useT(); {t("ui.dashboard.stock.title")} {t("ui.catalog.priceFrom", { price: "50,000" })} ``` ```ts theme={null} import { t } from "@/i18n"; ``` Placeholders use `{name}` syntax and are substituted from the vars object. Never call `t` at module scope. The dictionary loads asynchronously, so a translated string captured in a top-level constant will be the raw key forever. Store keys in your definition arrays and translate at render time. ### Resolution order 1. The dictionary from Lua 2. `KIT_DEFAULTS` — English fallbacks for the kit's own strings 3. The key itself Step two means a freshly scaffolded script renders "No results." rather than the raw string `ui.common.noResults`. Your own keys still belong in `locales/`. Keys the kit uses itself, all under `ui.common`: `close`, `searchPlaceholder`, `noResults`, `noResultsFound`, `selectPlaceholder`, `nSelected`, `rowCount`, `pageOf`. ### The Lua side `shared/locale.lua` loads English first, deep-merges the active language over it, and exposes both a Lua helper and the NUI callback: ```lua theme={null} locale('game.purchase.success', { vehicle = label, price = 50000 }) ``` A key missing from the active language falls back to English; missing from both, it returns the key, so a typo is visible in-game rather than a crash. Load it **after** `shared/config.lua` — it reads `Config.Locale`. ## Browser development ```ts theme={null} import { isEnvBrowser } from "@/utils/misc"; import { debugData } from "@/utils/debugData"; // Simulate a message from Lua while developing in the browser debugData([{ action: "setStock", data: mockVehicles }]); ``` `debugData` only fires in development mode and outside CEF, so it is safe to leave in place. Gate dev-only code behind `__DEV_SEED__` — the compile-time flag is `false` for `bun run production-build`, which removes those branches and their chunks from the customer build entirely. ## Smooth scroll ```ts theme={null} // main.tsx import { installSmoothScroll } from "./lib/smooth-scroll"; installSmoothScroll(); ``` Call this once. It is not optional: [CEF will not wheel-scroll a `clip-path`'d container](/ui-kit/cef), which includes every `Viewport`. It also gives every scroller the same eased feel in the browser and in-game. # Setup Source: https://docs.9am.dev/ui-kit/setup Authenticate against the private 9AM UI registry, scaffold a new script, or add the kit to an existing one. ## Get a token The registry is private, so `shadcn` needs credentials. Create a personal access token with **read access to `ilovehugetits/9am-ui`**. Add it to your shell profile so it survives a reboot. ```bash theme={null} export NINEAM_UI_TOKEN=github_pat_xxxxxxxx ``` Never commit the token. `components.json` references it as `${NINEAM_UI_TOKEN}` and `shadcn` expands it at install time, so the file itself stays safe to check in. ## Point a script at the registry Add a `registries` block to your script's `web/components.json`: ```json theme={null} { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "registries": { "@9am": { "url": "https://raw.githubusercontent.com/ilovehugetits/9am-ui/main/r/{name}.json", "headers": { "Authorization": "Bearer ${NINEAM_UI_TOKEN}" } } }, "iconLibrary": "lucide" } ``` Items are served straight off `raw.githubusercontent.com`, so there is nothing to host and no npm registry to authenticate against. ## Start a new script ```bash theme={null} mkdir -p my-script/web && cd my-script/web bun init -y ``` Then add the `components.json` shown above. ```bash theme={null} bunx shadcn@latest add @9am/scaffold ``` This writes `vite.config.ts`, `tsconfig.json`, `eslint.config.js`, `postcss.config.js`, `index.html`, `src/main.tsx`, and a starter `src/components/App.tsx` — and pulls in the theme, fonts, NUI bridge, i18n, viewport, and the icons the starter uses. ```bash theme={null} cd .. && bun /cli/index.ts lua ``` Writes `fxmanifest.lua`, `client/nui.lua`, `shared/locale.lua`, and `locales/en.json` into the resource root. Existing files are skipped unless you pass `--force`. This step is not a registry item because `shadcn` rejects any install target containing `..`, and the Lua files belong one level above the web project. Edit `web/src/nui.config.ts` — this is the one kit file you are meant to change. ```ts theme={null} export const nuiConfig = { resourceName: "my-script", themeStorageKey: "my-script:theme", defaultTheme: "dark", } as const; ``` Keep the `localStorage` key in `index.html` in sync with `themeStorageKey`. It runs before first paint to stop the wrong theme flashing. ```bash theme={null} bun install bun run build bun src/scripts/9am-ui.mjs doctor ``` ## Add the kit to an existing script Add the `registries` block, then take only what you want: ```bash theme={null} bunx shadcn@latest add @9am/theme @9am/fonts bunx shadcn@latest add @9am/viewport @9am/data-table ``` Dependencies resolve transitively. Asking for `@9am/data-table` also installs `button`, `input`, `table`, `i18n`, and its three icons — and nothing else, so a script that only wants a button does not pull in charts. After adding the theme, import it from your entry stylesheet. Order matters: ```css theme={null} @import "tailwindcss"; @import "tw-animate-css"; @import "./styles/9am-fonts.css"; @import "./styles/9am-theme.css"; /* your script's own styles go below */ ``` ## Where files land | Item type | Destination | | ---------------------------------- | ------------------------ | | Primitives and icons | `src/components/ui/` | | `@9am/utils`, `@9am/smooth-scroll` | `src/lib/` | | `useNuiEvent`, `useTheme` | `src/hooks/` | | `fetchNui`, `misc`, `debugData` | `src/utils/` | | `VisibilityProvider` | `src/providers/` | | `@9am/i18n` | `src/i18n/` | | `@9am/theme`, `@9am/fonts` | `src/styles/` | | `@9am/nui` config | `src/nui.config.ts` | | `@9am/tools` | `src/scripts/9am-ui.mjs` | ## Recommended package scripts ```json theme={null} { "scripts": { "start": "vite", "start:game": "vite build --watch", "build": "tsc && vite build", "production-build": "tsc && vite build --mode prod", "lint": "eslint src", "ui:check": "bun src/scripts/9am-ui.mjs check", "ui:doctor": "bun src/scripts/9am-ui.mjs doctor" } } ``` | Command | Use it for | | -------------------------- | ------------------------------------------------------------------ | | `bun run start` | Browser-only development with mock data. Fastest loop for UI work. | | `bun run start:game` | Rebuilds `dist` on save. Restart the resource to reload the NUI. | | `bun run build` | Required before testing in-game — `web/dist` is gitignored. | | `bun run production-build` | Customer-ready build with dev seeds and console noise stripped. | # Theme Source: https://docs.9am.dev/ui-kit/theme Design tokens, the light and dark palettes, fonts, and the styling conventions that make a script look like a 9AM script. ## How the theme is structured The theme installs as two stylesheets in `src/styles/`: | File | Contents | | --------------- | ----------------------------------------------------------------------------------------------------- | | `9am-theme.css` | Tokens for both palettes, the primary ramp, radius scale, the `dark:` variant, and the NUI base rules | | `9am-fonts.css` | Poppins and Phudu as `@font-face` declarations | Both are **locked**. `9am-ui check` fails the build if either is edited locally — identical tokens across scripts is the whole point. Put your own styles in `index.css`, below the imports. ## Dark mode is class-driven ```css theme={null} @custom-variant dark (&:where(.dark, .dark *)); ``` Without this, every `dark:` utility keys off the player's operating system theme, which is neither what you want nor controllable from the UI. Instead the `.dark` class on `` drives everything, and [`useTheme`](/ui-kit/nui#theme) toggles it. Dark is the shipped default. `index.html` sets the class before first paint so the wrong theme never flashes. ## Palettes Tokens are authored in OKLCH. Dark ships by default; light is a real second palette, not a tint. ```css theme={null} --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); --primary: oklch(0.9089 0.1361 99.99); /* bright gold */ --primary-foreground: oklch(0.28 0.07 46); --muted-foreground: oklch(0.705 0.015 286.067); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); ``` ```css theme={null} --background: oklch(0.985 0.002 285.9); --foreground: oklch(0.21 0.006 285.885); --card: oklch(1 0 0); --primary: oklch(0.58 0.135 80); /* deeper amber */ --primary-foreground: oklch(0.985 0.015 95); --muted-foreground: oklch(0.505 0.014 285.9); --destructive: oklch(0.583 0.222 27.2); --border: oklch(0 0 0 / 10%); ``` Light mode's `--primary` is deliberately a **deeper** amber than dark mode's bright gold. Bright gold is unreadable as text on white and washes out as a fill. Choosing one deeper gold that works both as `bg-primary` with dark text *and* as `text-primary` on white is why roughly forty `text-primary` usages needed no per-file changes when light mode was added. ## The primary ramp Eleven fixed gold steps, independent of the active palette: ``` primary-50 primary-100 primary-200 primary-300 primary-400 primary-500 primary-600 primary-700 primary-800 primary-900 primary-950 ``` Use them as normal Tailwind utilities — `bg-primary-500`, `text-primary-200`, `border-primary-700`. The ramp lives in `@theme inline`, which means Tailwind inlines the values into utilities instead of emitting CSS custom properties. `var(--color-primary-500)` resolves to **nothing**. Always use the generated utility, and spell the class out in full so Tailwind's scanner can see it — a template literal like `` `bg-primary-${n}` `` produces no CSS. ## Radius ```css theme={null} --radius: 0.625rem; ``` `radius-sm` is `--radius - 4px`, `radius-md` is `- 2px`, `radius-lg` equals it, `radius-xl` is `+ 4px`. In practice: `rounded-2xl` for containers and cards, `rounded-lg` for controls, `rounded-xl` for tiles and avatars. ## Type | Family | Weights | Used for | | ------- | ------------------ | ---------------------------------------------------------------- | | Poppins | 400, 500, 600, 700 | All body copy. Applied globally to `html, body`. | | Phudu | 600, 700 | Display — headings, page titles, the `navigation` button variant | Apply Phudu with `!font-[phudu]`: ```jsx theme={null}

Vehicle stock

``` ### Choosing a font stylesheet `9am-theme.css` does not import fonts, because which stylesheet you want depends on your app. | Option | Use when | How | | --------------------- | ------------------------------------------ | --------------------------------------------------------------------- | | `@9am/fonts` (base64) | Single HTML entry — the common case | `bunx shadcn@latest add @9am/fonts` | | Linked woff2 | Several HTML entries, especially DUI pages | `bun <9am-ui>/cli/index.ts fonts`, then import `9am-fonts-linked.css` | Embedding is the better default: one command, nothing else to fetch, and in a NUI everything loads off local disk so there is no network round-trip to save. It becomes the wrong choice the moment you have multiple entry points. **Every DUI is its own CEF instance**, so each one would decode the entire 322 KB payload again with nothing shared between them. The vehicle shop is the worked example — it has three entries and creates one DUI per showroom slot, so it uses the linked variant. ## Styling conventions These are what make a screen read as 9AM rather than generic shadcn. ### Translucent fills, not solid greys ```jsx theme={null}
``` This is *the* raised-control surface — buttons, inputs, avatar tiles, list rows. Hairline borders are `border-black/10 dark:border-white/10`. ### Gold tint for accent states ```jsx theme={null}
``` `#F6E3711a` is gold at 10% opacity. It is the hover and active wash across links, tabs, and badges. ### Press feedback ```jsx theme={null}