- 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
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.RequestFilefor loading scripts and stylesA3API.SendAlertfor outbound eventsctrlWebBrowserAction ["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.SendAlertand SQFExecJS. - 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
- Keep the runtime small.
- Avoid framework dependencies like React or Vue.
- Prefer one shared bundle plus one app bundle per UI.
- Support coarse-grained reactivity first, then targeted DOM patching where it matters.
- Make the Arma bridge a first-class host adapter, not an afterthought.
- Keep app logic plain JavaScript so views are easy to reason about.
- 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()wrapsA3API.SendAlert(JSON.stringify(...))on()andoff()subscribe to messages injected from SQFready()announces page readiness to SQFclose()sends a standard close event- if
A3APIis 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:
StoreUIBridgeOrgUIBridge
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.
Recommended types
At minimum, define these object families in forge_client_common:
IWebUIBridgeIWebUIScreenIWebUIRequestIWebUISubscription
Feature addons can then define their own types on top:
OrgUIBridgeStoreUIBridgeBankUIBridgeGarageUIBridge
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:
#deleteruns when the last reference is removed- that is useful, but not always the best moment for gameplay/UI logic
Recommended pattern:
- expose an explicit
disposeorclosemethod - perform normal cleanup there
- let
#deletecatch 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
Recommended Application To Current Addons
The current org and store bridge objects already use createHashMapObject.
This should evolve into:
- one shared
IWebUIBridgebase declaration incommon - one shared
IWebUIScreendeclaration incommon - 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::readyorg::syncorg::create::requeststore::checkout::requestnotifications::ready
Standardize a small set of host-level events:
ui::readyui::closeui::errorui::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
MembersCardand any member count badge - granting a credit line updates
TreasuryCardand 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:
- domain state signals
- derived selectors/computed values
- 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:
MembersCardrerenders whenroster().memberschanges- it does not rerender when treasury funds change
TreasuryCardrerenders whentreasury()changes- modal components rerender when
ui().modalchanges
Patch-Oriented Mutations
Interactive actions should prefer small patch events over full app hydration.
Recommended event examples:
org::member::addedorg::member::removedorg::member::creditUpdatedorg::treasury::fundsUpdatedorg::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
classNameor 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().fundsstore.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:
- JS sends
org::credit::request - SQF/server validates and persists
- SQF sends:
org::member::creditUpdatedon successorg::credit::failureon failure
- JS store applies a targeted patch
TreasuryCardand 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:
- common tokens
- common primitives
- feature theme
- 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.RequestFileA3API.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.errorplusdiag_logfriendly 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:
arma/client/addons/common/ui/src/host.jsarma/client/addons/common/ui/src/runtime.jsarma/client/addons/common/ui/src/bridge.jsarma/client/addons/common/ui/src/app.jsarma/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.
Recommended Direction
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:
commonowns the browser platform- each addon owns the application
That gives one UI system across the repo without forcing all screens into one monolithic app.