Skip to content

Latest commit

 

History

History
559 lines (437 loc) · 23.2 KB

UI.md

File metadata and controls

559 lines (437 loc) · 23.2 KB

Taki_UI

The widget layer that sits on top of Taki.NUI.

Notifications, progress, context menus, dialogs, input forms, text overlays, status panels, keybind hints, and loading/error states - with theming, localisation and accessibility already handled.


Table of contents

  1. The one rule
  2. How it fits together
  3. The API
  4. Notifications
  5. Progress
  6. Context menus
  7. Dialogs
  8. Input forms
  9. Text, status and keybinds
  10. Theming
  11. Localisation
  12. Accessibility
  13. The wire protocol
  14. Writing your own renderer
  15. Development
  16. Troubleshooting

The one rule

Taki_Kit Core (src/) contains no UI framework.

Not React. Not Vue. Not Svelte. Not jQuery. This is checked by a build gate that runs first, before a single byte is bundled:

[1/8] Framework-independence gate
      ok   Taki_Kit Core is framework-independent (64 source files scanned, 0 UI framework imports).

It fails the build if any file under src/ imports one, and it checks both package.json dependencies and the actual import statements in the source.

Why bother

Because a FiveM resource is not a website. It is a script that runs inside another program's process, on a machine you do not control, with a CEF browser version that lags by years. Every kilobyte and every abstraction you add to the client is a liability on every player's machine.

The split this buys:

Client bundle NUI page
Contains React no yes
Size 54.5 KiB 470 KiB

The client - the part that runs in the game's own Lua VM, where a GC pause is a visible stutter - carries no UI framework at all. React is loaded once, by the page, and only because the page is a real browser.

If you decide you do not want React, delete ui/src/react/, write 200 lines against UiStore, and nothing in src/ changes. That is the whole point.

How it fits together

  client script                the wire                 the page
  ---------------          --------------          --------------------------
  Taki.UI.notify()  ──▶  UiCommand (JSON)  ──▶  UiStore  ──▶  React components
                                 ▲                            │
                                 └──────  UiResult  ───────────┘

Three rules make this work:

  1. Commands go one way, results come back the other. The client never mutates state; it sends a UiCommand. The page never invents state; it emits a UiResult when the user acts. "Who may change the state" is never ambiguous.

  2. The store is pure. src/client/ui/ui-store.ts has no DOM, no React and no FiveM. Every behaviour - auto-dismiss, field validation, keyboard navigation, plural forms - is unit-tested under bun test with an injected clock.

  3. The bridge is the only impure file. ui/src/bridge.ts is the single place that touches window.postMessage and GetParentResourceName().

Where the files are

src/shared/types/ui.ts        UiCommand, UiState, UiResult, themes, protocol version
src/client/ui/ui-store.ts     the store: all behaviour
src/client/ui/index.ts        Taki.UI - the client-side API
ui/src/bridge.ts              postMessage transport, validation
ui/src/theme.ts               UiTheme -> CSS custom properties
ui/src/react/                 the reference renderer
ui/public/                    index.html, style.css (hand-written, committed)
web/                          generated page (git-ignored)

The API

Everything is on Taki.UI.

Method Returns Notes
notify(options) string (id) Auto-dismisses unless duration: 0
success(title, message?, duration?) string Shorthand for notify
warn(title, message?, duration?) string Shorthand for notify
error(title, message?, duration?) string Shorthand for notify
info(title, message?, duration?) string Shorthand for notify
loading(title, message?) string duration: 0, so it persists
dismiss(id) void Removes one notification
clearNotifications() void Removes all, and cancels their timers
progress(options) string (id) Animates and closes itself if given duration
updateProgress(id, value, total?, label?) void For work you drive yourself
closeProgress(id) void
context.open(options) Promise<UiResult | null> The full result, including action
context.close(id?) void
dialog.open(options) Promise<boolean | null> null on timeout
input.open(options) Promise<Record<string,string> | null> Values are always strings
text(options) string (id) A centred overlay
closeText(id) void
status(payload) string (id) Persistent HUD panel
clearStatus(id?) void One panel, or all of them
keybinds(entries, options?) Disposer Fires onPress
setTheme(name | theme) void Live
getTheme() UiTheme The resolved theme object
setLocale(code) void Live
getLocale() string
getI18n() UiI18n The localizer, for your own strings
setAccessibility(prefs) void
open(options?) void Focus, show/hide the page
close() void
isOpen / isReady boolean Read-only state, for a HUD that mirrors it
fatal(options) string (id) Full-screen error state
receive(command) boolean Feed one command in; used by the bridge
onCommand(handler) Disposer Observe every command that lands
dispose() Disposer Releases pending requests. Registered automatically

receive and onCommand are the seam for driving the UI without Taki.UI's own helpers - a custom transport, a replay, or a test. Most code never calls them.

Every promise resolves. None of them reject, because a UI the player dismissed is not an exceptional condition - it is a normal outcome, and forcing every call site into a try/catch would be noise. Check for null, which is how a dismissal or a timeout is reported.

Notifications

Taki.UI.notify({
    type: "success", // info | success | warning | error | loading
    title: "Success",
    message: "Vehicle spawned", // optional
    duration: 4000, // ms. 0 = stays until dismissed
});

The store owns auto-dismiss. That decision is not the renderer's, because duration is a statement about the notification, not about how it is drawn - and because a renderer that forgot would leave cards on screen forever.

Replacing, not stacking

Sending the same id again updates the existing card in place. This is how a loading state becomes a success without a flash:

Taki.UI.loading({ id: "save", title: "Saving…" });
// … later …
Taki.UI.notify({ id: "save", type: "success", title: "Saved", duration: 3000 });

The old timer is cancelled when the replacement arrives, so the success message lasts its own duration rather than inheriting the loading state's deadline.

Bounds

At most 6 notifications are kept. Older ones are dropped, and their timers are cancelled with them - otherwise a script that spams notify would leak a timer per notification.

Progress

// Fire and forget: Taki_UI animates it and closes it when the duration elapses.
Taki.UI.progress({ label: "Repairing vehicle", duration: 2500 });

// Or drive it yourself.
const id = Taki.UI.progress({ label: "Downloading", variant: "modal" });
Taki.UI.updateProgress(id, 40, 100);
Taki.UI.closeProgress(id);

progress() returns the bar's id, not a promise - the bar is a HUD element, not a question. With a duration the client drives it to 100% and closes it, on a timer that is cancelled if the resource stops.

variant: "inline" | "modal" | "bar". A determinate bar reports aria-valuenow; an indeterminate one omits it rather than reporting a fake zero. cancellable: true adds a cancel button.

Context menus

const choice = await Taki.UI.context.open({
    id: "vehicle_menu",
    title: "Vehicle",
    subtitle: "Blista, 2-door",
    options: [
        { label: "Repair", description: "Free", action: "repair", icon: "🔧" },
        { label: "Lock", action: "lock" },
        { label: "Delete", action: "delete", tone: "danger", separatorBefore: true },
        { label: "Unavailable", disabled: true },
    ],
});

if (choice?.action === "repair") {
    // …
}

It resolves the whole UiResult, not just the action string, so optionId and action are both available. null means the player dismissed it, the page closed, or the request timed out - check for it.

Arrow keys, Home, End, type-ahead and Escape all work; focus is trapped while open and restored to whatever had it before. Disabled options are skipped by keyboard navigation, so Enter can never fire one.

searchThreshold: 5 adds a search box once there are at least that many options.

Dialogs

if (
    await Taki.UI.dialog.open({
        id: "confirm",
        title: "Delete vehicle?",
        body: "This cannot be undone.",
        confirmLabel: "Delete",
        tone: "danger", // default | danger
    })
) {
    // confirmed
}

Opens as role="alertdialog", which is what makes a screen reader announce it immediately instead of waiting for a pause. Escape resolves false; a timeout resolves null, so false unambiguously means "the player said no".

Input forms

const values = await Taki.UI.input.open({
    id: "deposit",
    title: "Deposit",
    fields: [
        { name: "amount", label: "Amount", type: "number", required: true, min: 1, max: 100_000 },
        { name: "note", label: "Note", maxLength: 16 },
        { name: "pin", label: "PIN", type: "password", pattern: "^[0-9]{4}$" },
    ],
});
if (values !== null) {
    // { amount: "500", note: "", pin: "1234" }
}

Validation is immediate and per-field. aria-invalid is set on the offending input, the message is linked with aria-describedby, and fixing a field clears its error on the next change rather than on submit.

Field types: text, number, password, textarea, select, checkbox.

This validation is UX, not security. It runs in the player's browser, which they fully control. The server must validate every value it receives, every time. docs/SECURITY.md covers the pattern.

Text, status and keybinds

// A centred overlay, e.g. for a checkpoint. The id is optional.
Taki.UI.text({ id: "cp", text: "Los Santos", subtext: "Waypoint reached", duration: 2500 });

// A persistent panel. Entries can be meters, plain values, or both.
Taki.UI.status({
    id: "vitals",
    position: "top-right", // top-left | top-right | bottom-left | bottom-right
    collapsible: true,
    entries: [
        { id: "fuel", label: "Fuel", value: 68, total: 100, tone: "warn" },
        { id: "clock", label: "Time", value: "21:40", align: "right" },
    ],
});

// Keybind hints. Returns a Disposer.
const stop = Taki.UI.keybinds(
    [
        { id: "trunk", keys: ["E"], label: "Trunk" },
        { id: "hood", keys: ["H"], label: "Hood" },
    ],
    { onPress: (id) => Taki.NUI.send("trunk:toggle", id) },
);

Meters use role="meter" with real aria-valuenow/aria-valuemin/ aria-valuemax, so the value is announced rather than only seen.

Theming

Taki.UI.setTheme("crimson"); // a built-in
Taki.UI.setTheme({
    name: "corp",
    mode: "dark",
    radius: "lg",
    density: "comfortable",
    fontScale: 1,
    colors: {/* 16 HSL channel triples */},
});

Built in: midnight, daylight, crimson.

Colours are bare HSL channel triples - "217 91% 60%", not #3b82f6 - so one declaration yields every translucent variant via hsl(var(--taki-accent) / 0.2). A theme declares ~16 colours instead of ~80.

radius is none | sm | md | lg | xl | full; density is compact | comfortable | spacious and drives gap, padding and row height together, so "make it tighter" is one word rather than a stylesheet edit.

Switching themes is instant and does not reload the page: it rewrites CSS custom properties on the root element.

Making it yours without forking

/* ui/public/style.css - after the theme is applied */
.taki-root {
    --taki-accent: 280 80% 60%;
    --taki-radius: 0.75rem;
    --taki-surface-alpha: 0.9;
}

Useful variables: --taki-accent, --taki-radius, --taki-gap, --taki-pad, --taki-row, --taki-font-scale, --taki-surface-alpha, --taki-blur, --taki-dur, --taki-ease.

Localisation

Taki.UI.setLocale("pt");

Built in: en, pt, fr. Every built-in string switches immediately, including plurals. Any string is accepted, so a locale with no messages falls back to en per key rather than rendering blanks.

For your own strings, build a localizer and use it from the client script. The Taki.UI widgets resolve their own strings through the same catalogue:

import { UiLocalizer } from "taki_kit/ui";

const t = new UiLocalizer({
    catalogues: {
        en: { "shop.buy": "Buy {name} for ${price}", "shop.empty": "Nothing here" },
        es: { "shop.buy": "Comprar {name} por ${price}", "shop.empty": "No hay nada" },
    },
    locale: "en",
    defaultLocale: "en",
});

Taki.UI.setLocale("es");
t.t("shop.buy", { name: "Lockpick", price: 250 }); // "Buy Lockpick for $250"
Member Does
t(key, context?, options?) Translate, interpolating {placeholders}
has(key) Whether the current locale (or the fallback) defines it
resolveAll(keys, context?) Many keys at once, for a render pass
setLocale(code) / locale Switch, and read the current one
available Locales with at least one message - for a picker

Placeholders use {name}. Plural forms use ICU-style #, where the bare key is the singular and #other is the plural:

{
    "vehicles.found": "Found {count} vehicle.",
    "vehicles.found#other": "Found {count} vehicles.",
}

A missing key renders as the key itself rather than an empty string, so a typo is visible instead of silent.

Accessibility

Not an afterthought - a NUI is frequently operated without a mouse, and often by someone for whom a 180ms animation is genuinely unpleasant.

  • Focus is visible. A 2px high-contrast ring on :focus-visible.
  • Focus is trapped in modals and restored on close.
  • Announcements. New notifications go through an aria-live="polite" region; errors use alertdialog. Screen-reader announcements are on by default.
  • Reduced motion is honoured from the OS and settable in-game: Taki.UI.setAccessibility({ reducedMotion: true }). Both collapse every transition.
  • High contrast strengthens borders, drops the blur, and makes surfaces opaque.
  • Progress bars are real progressbar/meter roles with real values.
  • Colour is never the only signal - every tone also carries an icon and a text label.

The wire protocol

Versioned. UI_PROTOCOL_VERSION is 1, and both ends check it, so a stale cached web/ folder fails loudly instead of misbehaving quietly.

type UiCommand =
    | { action: "ui:notify"; id: string; payload: NotificationPayload }
    | { action: "ui:context:open"; payload: ContextMenuPayload }
    | { action: "ui:progress:update"; id: string; value: number; total?: number }
    | { action: "ui:theme"; theme: UiTheme };
// … 20 more

The union is exhaustive: adding an action without handling it is a compile error in the store, not a silently ignored message at runtime.

Everything crossing the boundary is validated. A NUI page is untrusted input like any other - a browser extension, or a stray postMessage, must not be able to drive the store, so an unrecognised message is dropped and logged rather than thrown.

ui/src/bridge.ts also validates the resource name against /^[A-Za-z0-9._-]{1,64}$/ before interpolating it into a request URL.

Writing your own renderer

The store is the contract. Mount nothing from ui/src/react/ and you still get correct behaviour:

import { UiStore } from "../../src/client/ui/ui-store";

const store = new UiStore({ protocol: 1, theme: myTheme, locale: "en" });
store.subscribe((state) => render(state));
store.dispatch({ action: "ui:notify", id: "x", payload: { type: "info", title: "Hello" } });

The store gives you:

  • state - a frozen snapshot. Never mutate it.
  • dispatch(command) - client to store. Returns the action, or null if the command was rejected.
  • selectContextOption, confirmDialog, submitInput, dismissNotification, and friends - user actions, which emit a UiResult.
  • tick() - call periodically to decay the notification drain bars.
  • dispose() - cancels every timer. Call it.

Inject now and schedule to make it fully deterministic under test.

Development

bun install                  # once, at the repo root
bun run dev:ui               # http://127.0.0.1:5173, rebuilds on save
TAKI_UI_PORT=8080 bun run dev:ui

ui/ is a Bun workspace member, so the single root bun install also installs React for the NUI page. One lockfile, one install command - you never cd into ui/ to install anything.

React is a peerDependency of @taki-kit/ui rather than a hard dependency: if you write your own renderer, you should not inherit React.

| Command | Does | | bun run dev:ui | Serves web/ with a source map, watches ui/src and ui/public | | bun run build:ui | Minified page into web/ | | bun run build:ui --dev | Unminified, with a linked source map | | bun run build | Everything, including the 8 build gates | | bun run check | format, lint, typecheck, test, build |

Environment: TAKI_UI_PORT (default 5173), TAKI_UI_HOST (default 127.0.0.1).

Standalone mode

bun run dev:ui serves the page over HTTP, outside a game. GetParentResourceName() does not exist in a normal browser, so the bridge logs that once and carries on rendering. This is a feature: you get the real page, not a mock, so CSS and layout iterate in seconds instead of the usual copy-into-resources/, restart, join loop.

A dev control panel is injected into the served HTML to drive every widget. It is never bundled into a real resource - main.tsx does not import it, and the bundler cannot reach a file the entry point does not reference.

What standalone mode does not simulate: a real client script, net events, or anything behind a FiveM native. Use a real server for those.

In-game debugging

TakiUI is exposed on globalThis:

TakiUI.store.state; // the full state
TakiUI.store.dispatch({ action: "ui:notify", id: "x", payload: { type: "info", title: "Hello", duration: 0 } });
TakiUI.protocol; // the protocol version

Set DEBUG_RESULTS = true in ui/src/main.tsx to log every user action. It is off by default because a NUI page's console is the player's own, and an unattended chatty log is noise rather than a diagnostic.

Troubleshooting

Nothing appears. Check ui_page 'web/index.html' is in fxmanifest.lua and that web/ exists - run bun run build. Without ui_page the client script still runs and every UI command is a no-op.

The console says "no FiveM parent detected". Expected in a browser. It means standalone mode, which is fine for CSS work. In game it should not appear.

Cards never disappear. The page is a stale build predating auto-dismiss. bun run build and hard-reload.

A theme change does nothing. Confirm you passed a name ("crimson") or a full theme object. A partial object is merged over the current theme; an empty one is a no-op by design.

A menu opens behind another. Not possible - opening a context menu closes an open dialog or input form. If you see two surfaces, one of them was opened with raw Taki.NUI.send, which bypasses the store's bookkeeping.

React DevTools does not see it. Expected. The page is built in production mode with a minified bundle; the store, not the component tree, is the unit of behaviour.


Copyright (c) Taki. MIT licensed - see LICENSE.