forge/arma/client/addons/common/WEB_UI_FRAMEWORK.md
Jacob Schmidt e15d4b3066 Introduce shared web UI runtime and migrate org/store bridges
- add common ForgeWebUI runtime, site loader, and SQF WebUI bridge base declarations
- migrate org and store web UIs to src-driven bundles and new bridge/bootstrap flow
- update addon configs/prep hooks and document the shared CT_WEBBROWSER framework
2026-03-14 00:40:34 -05:00

21 KiB

Web UI Framework Proposal

Goal

Create a shared web UI framework inside forge_client_common that provides one browser runtime for all CT_WEBBROWSER interfaces:

  • store
  • bank
  • garage
  • org
  • actor
  • notifications

The framework should standardize:

  • browser bootstrapping
  • Arma to JS messaging
  • JS to Arma messaging
  • reactive state updates
  • shared UI primitives
  • asset loading
  • teardown and remount behavior

Why This Should Live In common

The current client web UIs already share the same underlying concerns:

  • A3API.RequestFile for loading scripts and styles
  • A3API.SendAlert for outbound events
  • ctrlWebBrowserAction ["ExecJS", ...] for inbound events
  • full-page rerender on every signal update
  • duplicated runtime and bridge code across addons

That makes forge_client_common the right owner for:

  • the browser runtime
  • the bridge contract
  • reusable DOM helpers
  • shared components and styles

Each addon should keep only:

  • app-specific state
  • app-specific event names
  • app-specific SQF handlers
  • app-specific views and theme assets

Constraints From CT_WEBBROWSER

This framework should be built for the actual browser host, not for a generic modern frontend stack.

  • Browser engine should be treated as conservative Chromium/CEF.
  • HTML is hosted inside the Arma browser control, not a normal web server app.
  • Asset loading must work through A3API.RequestFile.
  • Game integration must work through A3API.SendAlert and SQF ExecJS.
  • Browser controls are opened and destroyed by UI displays, so mount/unmount must be explicit.
  • Startup latency matters because players open these UIs interactively in-game.

Design Principles

  1. Keep the runtime small.
  2. Avoid framework dependencies like React or Vue.
  3. Prefer one shared bundle plus one app bundle per UI.
  4. Support coarse-grained reactivity first, then targeted DOM patching where it matters.
  5. Make the Arma bridge a first-class host adapter, not an afterthought.
  6. Keep app logic plain JavaScript so views are easy to reason about.
  7. Make every UI follow the same bootstrap contract.

Proposed Ownership

Common addon

forge_client_common should own:

  • browser host adapter
  • reactive runtime
  • DOM renderer
  • shared event bus
  • base CSS tokens and utility classes
  • shared components
  • generic bootstrap helper
  • SQF bridge base class

Feature addons

Each feature addon should own:

  • one app entrypoint
  • feature store/state
  • feature bridge schema
  • feature views/components
  • feature-specific CSS layer
  • feature SQF bridge subclass/instance

Proposed Folder Layout

arma/client/addons/common/
  ui/
    src/
      runtime.js
      host.js
      bridge.js
      app.js
      index.js
    _site/
      forge-webui.js
  functions/
    fnc_initWebUIBridge.sqf
    fnc_openWebUI.sqf
    fnc_sendWebUIEvent.sqf
  README.md
  WEB_UI_FRAMEWORK.md

Feature addon structure would then look like:

arma/client/addons/org/
  ui/
    _site/
      index.html
      app.js
      views/
      components/
      theme.css
  functions/
    fnc_initOrgUIBridge.sqf
    fnc_openUI.sqf
    fnc_handleUIEvents.sqf

Runtime API Sketch

The shared runtime should expose a small API on window.ForgeWebUI.

Core API

ForgeWebUI = {
  h,
  text,
  fragment,
  signal,
  computed,
  effect,
  batch,
  mount,
  unmount,
  createApp,
  createBridge,
  createAssetLoader,
  createNoticeCenter,
};

Reactive primitives

const count = signal(0);
const doubled = computed(() => count() * 2);

effect(() => {
  console.log("count", count());
});

count.set(5);

Design notes:

  • signal() returns a getter function with .set() and .update().
  • computed() caches until one of its dependencies changes.
  • effect() is for bridge sync, timers, DOM subscriptions, and cleanup.
  • batch() groups several writes into one render pass.

DOM/rendering

function CounterView() {
  return h("button", {
    onClick() {
      count.update((value) => value + 1);
    }
  }, `Count: ${count()}`);
}

mount(document.getElementById("app"), CounterView);

The renderer should support:

  • keyed child reconciliation
  • event binding
  • text node updates
  • conditional sections
  • list rendering
  • SVG nodes
  • mount cleanup

It should not rebuild the whole root on every write.

App Bootstrap Contract

Every app should use the same bootstrap shape:

const app = ForgeWebUI.createApp({
  name: "org",
  root: "#app",
  setup({ host, bridge, assets, notices }) {
    const store = createOrgStore();

    bridge.on("org::sync", (payload) => {
      store.hydrate(payload);
    });

    bridge.ready();

    return () => OrgApp({ store, host, notices });
  }
});

app.start();

Responsibilities:

  • createApp() locates the root node
  • waits for DOM readiness
  • sets up host services
  • mounts the view
  • wires bridge event listeners
  • exposes teardown hooks

Host Adapter API

The Arma host layer should hide A3API details behind one consistent service.

const host = {
  isArma: true,
  requestFile(path),
  requestTexture(path, size),
  send(event, data),
  exec(name, data),
  on(event, handler),
  off(event, handler),
  ready(data),
  close(data),
};

Behavior:

  • send() wraps A3API.SendAlert(JSON.stringify(...))
  • on() and off() subscribe to messages injected from SQF
  • ready() announces page readiness to SQF
  • close() sends a standard close event
  • if A3API is unavailable, fallback behavior supports local browser testing

JS Bridge Contract

Each page should expose one stable bridge object to SQF:

window.ForgeBridge.receive({
  event: "org::sync",
  data: { ... }
});

This replaces app-specific globals like:

  • StoreUIBridge
  • OrgUIBridge

Recommended interface:

window.ForgeBridge = {
  receive(payload),
  receiveMany(events),
  reset(),
  ping(),
};

Feature apps should register handlers with the shared bridge:

bridge.on("store::hydrate", handleHydrate);
bridge.on("store::checkout::success", handleCheckoutSuccess);

That removes duplicated payload parsing from each app bridge file.

SQF Bridge Base Class

The SQF side should also be normalized in common.

Shared base responsibilities

  • find active browser control
  • execute JS safely
  • send { event, data } payloads
  • queue payloads until page ready
  • flush pending payloads on ready
  • standardize close handling
  • standardize logging and diagnostics

SQF API sketch

GVAR(WebUIBridgeBaseClass) = compileFinal createHashMapFromArray [
    ["#type", "WebUIBridgeBaseClass"],
    ["#create", compileFinal {
        _self set ["pendingEvents", []];
        _self set ["isReady", false];
    }],
    ["getActiveBrowserControl", compileFinal { ... }],
    ["execJS", compileFinal { ... }],
    ["sendEvent", compileFinal { ... }],
    ["queueEvent", compileFinal { ... }],
    ["flushPendingEvents", compileFinal { ... }],
    ["handleReady", compileFinal { ... }],
    ["handleClose", compileFinal { ... }]
];

Feature bridges like org or store would then extend only the behavior they need:

  • payload building
  • server RPC dispatch
  • feature response mapping

SQF Type Model With createHashMapObject

The SQF side should lean into createHashMapObject instead of using plain hash maps for everything.

This gives us:

  • inheritance through #base
  • explicit type tagging through #type
  • constructors through #create
  • cleanup through #delete

That is a strong fit for browser UI infrastructure because the UI layer already has clear object roles.

At minimum, define these object families in forge_client_common:

  • IWebUIBridge
  • IWebUIScreen
  • IWebUIRequest
  • IWebUISubscription

Feature addons can then define their own types on top:

  • OrgUIBridge
  • StoreUIBridge
  • BankUIBridge
  • GarageUIBridge

Example hierarchy

private _webUIBridgeDeclaration = [
    ["#type", "IWebUIBridge"],
    ["#create", { ... }],
    ["getActiveBrowserControl", { ... }],
    ["sendEvent", { ... }],
    ["handleReady", { ... }],
    ["dispose", { ... }]
];

private _orgUIBridgeDeclaration = [
    ["#base", _webUIBridgeDeclaration],
    ["#type", "OrgUIBridge"],
    ["buildHydratePayload", { ... }],
    ["handleCreditResponse", { ... }]
];

Type checks then become straightforward:

if ("IWebUIBridge" in (_bridge get "#type")) then {
    _bridge call ["sendEvent", ["ui::ping", createHashMap]];
};

Why Example 4 matters

Example 4 on the wiki shows the important lifecycle property:

  • constructor creates a resource
  • object holds that resource
  • destructor deletes that resource when the object is released

That pattern maps directly to UI/session resources.

Good uses of #delete in this framework

  • clear pending request queues
  • unregister display event handlers
  • null out active browser control references
  • stop polling/update loops
  • remove temporary mission event handlers
  • release temporary response trackers

Example use: request/response object

private _requestDeclaration = [
    ["#type", "IWebUIRequest"],
    ["#create", {
        params ["_requestId", "_onTimeout"];
        _self set ["requestId", _requestId];
        _self set ["onTimeout", _onTimeout];
        _self set ["isResolved", false];
    }],
    ["resolve", {
        _self set ["isResolved", true];
    }],
    ["#delete", {
        if !(_self getOrDefault ["isResolved", false]) then {
            private _onTimeout = _self getOrDefault ["onTimeout", {}];
            call _onTimeout;
        };
    }]
];

This is the same concept as Example 4:

  • object owns a resource or responsibility
  • when the object is released, cleanup happens automatically

Lifecycle Guidance

Use destructors as a cleanup safety net, not as the only control path.

Reason:

  • #delete runs when the last reference is removed
  • that is useful, but not always the best moment for gameplay/UI logic

Recommended pattern:

  1. expose an explicit dispose or close method
  2. perform normal cleanup there
  3. let #delete catch anything missed

That keeps UI shutdown deterministic while still benefiting from automatic cleanup.

Typed Screen Objects

We can also model each open browser UI as a typed screen object instead of just storing a control reference.

Example:

private _screenDeclaration = [
    ["#type", "IWebUIScreen"],
    ["#create", {
        params ["_displayName", "_control"];
        _self set ["displayName", _displayName];
        _self set ["control", _control];
        _self set ["isReady", false];
        _self set ["pendingEvents", []];
    }],
    ["markReady", {
        _self set ["isReady", true];
    }],
    ["queueEvent", { ... }],
    ["flushPendingEvents", { ... }],
    ["dispose", {
        _self set ["pendingEvents", []];
        _self set ["control", controlNull];
    }]
];

That gives us a cleaner split:

  • bridge object owns app-level behavior
  • screen object owns one live browser control/session
  • request objects own transient async work

The current org and store bridge objects already use createHashMapObject.

This should evolve into:

  • one shared IWebUIBridge base declaration in common
  • one shared IWebUIScreen declaration in common
  • feature bridge types inheriting from IWebUIBridge
  • optional transient request/session helper types where async cleanup matters

That will make the SQF side more explicit, easier to test, and safer around UI teardown.

Event Naming

Keep namespaced events. The current event style is good.

Examples:

  • org::ready
  • org::sync
  • org::create::request
  • store::checkout::request
  • notifications::ready

Standardize a small set of host-level events:

  • ui::ready
  • ui::close
  • ui::error
  • ui::ping

And keep feature events under their own namespace.

State Model

The framework should support two store patterns:

Local signal store

Good for:

  • form state
  • modal state
  • selection state
  • optimistic UI flags

Domain store wrapper

Good for:

  • hydrated server payloads
  • catalog data
  • actor action lists
  • organization portal data

Recommended store API:

function createStore(initialState) {
  const state = signal(initialState);

  return {
    get state() {
      return state();
    },
    patch(partial) {
      state.set({ ...state(), ...partial });
    },
    replace(next) {
      state.set(next);
    }
  };
}

Component Update Model

The framework should update component subtrees, not the full UI root.

That means:

  • no browser page reload
  • no innerHTML = "" on the app root for every state change
  • only components that read changed state should rerender

Practical expectation

Examples:

  • adding a member updates MembersCard and any member count badge
  • granting a credit line updates TreasuryCard and the specific member row
  • updating funds updates treasury summary components only
  • showing a modal or notice updates only the overlay layer

Store Contract

Each app store should expose three layers:

  1. domain state signals
  2. derived selectors/computed values
  3. mutation methods

Recommended shape:

function createOrgStore() {
  const org = signal({
    id: "",
    name: "",
    ownerUid: "",
  });

  const session = signal({
    actorUid: "",
    actorName: "",
    role: "",
    ceo: false,
  });

  const treasury = signal({
    funds: 0,
    reputation: 0,
    creditLines: [],
  });

  const roster = signal({
    members: [],
  });

  const ui = signal({
    modal: null,
    notices: [],
    treasuryTab: "overview",
  });

  const memberCount = computed(() => roster().members.length);
  const activeCreditCount = computed(() => treasury().creditLines.length);

  return {
    org,
    session,
    treasury,
    roster,
    ui,
    memberCount,
    activeCreditCount,
    hydrate(payload) { ... },
    addMember(member) { ... },
    removeMember(memberUid) { ... },
    upsertCreditLine(line) { ... },
    setFunds(amount) { ... },
    openModal(type, data) { ... },
    closeModal() { ... },
  };
}

Rules

  • component code reads signals directly from the store
  • mutation methods are the only place that update domain state
  • derived values use computed() instead of recalculating in every component
  • UI state stays separate from domain state

Component Contract

Components should be plain functions that subscribe only to the signals they read.

Example:

function MembersCard({ store, actions }) {
  const members = store.roster().members;
  const canManageMembers = store.canManageMembers();

  return Card({
    title: "Members",
    body: List({
      items: members,
      key: (member) => member.uid,
      renderItem: (member) =>
        MemberRow({
          member,
          canRemove: canManageMembers && !store.isProtectedMember(member),
          onRemove: () => actions.removeMember(member.uid),
        }),
    }),
  });
}

In this model:

  • MembersCard rerenders when roster().members changes
  • it does not rerender when treasury funds change
  • TreasuryCard rerenders when treasury() changes
  • modal components rerender when ui().modal changes

Patch-Oriented Mutations

Interactive actions should prefer small patch events over full app hydration.

Recommended event examples:

  • org::member::added
  • org::member::removed
  • org::member::creditUpdated
  • org::treasury::fundsUpdated
  • org::notice::show

Initial load can still use a hydrate event:

  • org::hydrate

But actions like assigning credit lines should not require rebuilding the full portal payload.

Example:

bridge.on("org::member::creditUpdated", ({ memberUid, memberName, amount }) => {
  store.upsertCreditLine({
    uid: memberUid,
    member: memberName,
    amount,
  });
});

List Reconciliation

To make targeted updates real, list rendering must be keyed.

Requirement:

  • every repeated domain item must have a stable key

Examples:

  • members use uid
  • credit lines use uid
  • assets use className or inventory id
  • fleet entries use vehicle id

Without keyed reconciliation, a list change still forces the entire list DOM to be replaced.

Org UI Example

Using the current organization portal as the model:

MembersCard

Depends on:

  • store.roster().members
  • membership permission selectors

Should update when:

  • a member is added
  • a member is removed
  • a member name or role changes

Should not update when:

  • treasury funds change
  • a modal opens
  • a fleet item changes

TreasuryCard

Depends on:

  • store.treasury().funds
  • store.treasury().creditLines
  • treasury permissions
  • store.ui().treasuryTab

Should update when:

  • funds change
  • a credit line is added or updated
  • the user changes treasury tab

Should not update when:

  • member roster changes unrelated to treasury display
  • fleet changes

ModalLayer

Depends on:

  • store.ui().modal

Should update when:

  • a modal opens
  • a modal closes
  • modal payload changes

Should not update when unrelated domain state changes.

Mutation Examples

Add member

addMember(member) {
  this.roster.update((state) => ({
    ...state,
    members: [...state.members, member],
  }));
}

Only subscribers to roster rerender.

Update credit line

upsertCreditLine(nextLine) {
  this.treasury.update((state) => {
    const exists = state.creditLines.some((line) => line.uid === nextLine.uid);

    return {
      ...state,
      creditLines: exists
        ? state.creditLines.map((line) =>
            line.uid === nextLine.uid ? nextLine : line
          )
        : [...state.creditLines, nextLine],
    };
  });
}

Only subscribers to treasury rerender.

Bridge Response Strategy

For responsive UIs, each server-backed action should define:

  • request event
  • success patch event
  • failure notice event or payload

Example credit line flow:

  1. JS sends org::credit::request
  2. SQF/server validates and persists
  3. SQF sends:
    • org::member::creditUpdated on success
    • org::credit::failure on failure
  4. JS store applies a targeted patch
  5. TreasuryCard and any dependent member row update

This is preferable to sending a full org::sync after every action.

Shared Components

The common addon should provide plain, themeable primitives only.

Recommended first set:

  • app shell
  • title bar
  • navbar
  • modal
  • notice/toast
  • stat card
  • empty state
  • action row
  • form field
  • spinner
  • error banner

These should accept data and callbacks, not own business logic.

Styling Model

Use layered CSS:

  1. common tokens
  2. common primitives
  3. feature theme
  4. feature view styles

The common layer should define:

  • spacing scale
  • type scale
  • colors
  • elevation/shadows
  • radius
  • focus states
  • motion durations

Feature UIs should override tokens rather than rewriting primitive CSS.

Asset Loading

The loader should support:

  • A3API.RequestFile
  • A3API.RequestTexture
  • local fetch() fallback for browser testing

Recommended change:

  • stop loading many small scripts individually in production
  • build one common runtime file and one feature app file
  • keep source files split in repo, but ship bundled outputs into _site

That reduces browser startup cost and simplifies ordering problems.

Error Handling

The framework should standardize:

  • bridge unavailable errors
  • malformed payload errors
  • timeout handling for requests that expect responses
  • visible in-UI notices for recoverable failures
  • console.error plus diag_log friendly payloads

Recommended bridge helper:

bridge.request("store::checkout::request", payload, {
  pending: "Submitting order...",
  timeoutMs: 15000,
  onTimeout() {
    notices.error("The checkout request timed out.");
  }
});

Migration Plan

Phase 1

Extract common pieces without changing app behavior:

  • shared JS host adapter
  • shared JS bridge
  • shared signal/runtime
  • shared SQF bridge base class

Phase 2

Migrate org and store first because they already use the same custom runtime pattern.

Phase 3

Migrate bank, garage, and notifications.

Phase 4

Migrate actor, which may need more event-heavy interaction handling.

Phase 5

Bundle all _site apps into production-ready outputs.

First Implementation Targets

The first concrete files to build should be:

  1. arma/client/addons/common/ui/src/host.js
  2. arma/client/addons/common/ui/src/runtime.js
  3. arma/client/addons/common/ui/src/bridge.js
  4. arma/client/addons/common/ui/src/app.js
  5. arma/client/addons/common/functions/fnc_initWebUIBridge.sqf

Those five pieces establish the core contract. After that, org and store can be migrated with low risk.

Non-Goals

At least initially, this framework should not try to provide:

  • client-side routing between pages
  • SSR or pre-rendering
  • JSX compilation
  • TypeScript-only tooling assumptions
  • a giant component system
  • generalized diffing for every possible DOM edge case

This should stay focused on Arma in-browser application UIs.

Use forge_client_common as the host for a small custom reactive framework, not as a dumping ground for copied app utilities.

The correct abstraction boundary is:

  • common owns the browser platform
  • each addon owns the application

That gives one UI system across the repo without forcing all screens into one monolithic app.