Writing HTML Views for PrismaUI_F4

The Runtime

Views are rendered by Ultralight, an embeddable WebKit-based browser engine. The WebKit version shipped with this framework is roughly equivalent to Safari ~2020 / Chrome ~80. It supports modern CSS and ES2020 JavaScript, but it is not a full browser — certain APIs that exist in Chrome or Firefox are absent or behave differently.

When something doesn’t work, check this document before assuming your HTML is wrong.


File Location

Source (edit here)Deployed locationMO2 virtual path
YourPlugin/assets/views/page.htmlmods/YourPlugin/PrismaUI_F4/views/page.htmlData/PrismaUI_F4/views/page.html

The framework loads views via file:/// URIs resolved relative to Data/PrismaUI_F4/views/. Filenames must be unique across all PrismaUI plugins. You can reference images or other assets by relative path from the views folder.

Never edit files under mods/ directly. Always edit the source and redeploy with Copy-Item.


JavaScript Support

What Works

  • ES2020+: const, let, arrow functions, template literals, destructuring, async/await, Promises, class, optional chaining (?.), nullish coalescing (??)
  • DOM API: Full access to document, window, Element, event listeners, setTimeout/setInterval, requestAnimationFrame
  • Fetch API: Available for file:// resources. Not useful for network requests (game process has no internet access by design).
  • CSS: Flexbox, Grid, CSS variables (--var), animations (@keyframes), transitions, calc(), backdrop-filter, clip-path
  • Web Storage: localStorage and sessionStorage are available but data is scoped to the view’s URL. Not persistent across game launches.
  • JSON: JSON.parse / JSON.stringify work normally.
  • Canvas: 2D canvas API available.
  • SVG: Inline SVG in HTML renders correctly.

What Does NOT Work

APIStatusWorkaround
IntersectionObserverNot implementedGuard with typeof IntersectionObserver !== 'undefined'
ResizeObserverNot implementedUse fixed layout or window resize events
WebGL / WebGPUNot availableUse D3D11 from C++ side
Worker / SharedWorkerNot availableKeep processing on main JS thread
IndexedDBNot availableUse localStorage or pass data from C++
WebSockets / XMLHttpRequest to HTTPNo networkAll data comes from C++ via InteropCall/Invoke
dbg()Not a thingUse console.log() only
alert() / confirm() / prompt()Not implementedBuild your own modal in HTML
CSS @importMay not resolveInline all CSS in <style> tags
External fonts via @font-face url(http...)No networkUse system fonts or embed font as base64

Always Guard Optional APIs

// WRONG — will throw ReferenceError and abort the entire script
var observer = new IntersectionObserver(callback);

// CORRECT
var observer;
if (typeof IntersectionObserver !== 'undefined') {
  observer = new IntersectionObserver(callback);
} else {
  observer = { observe: function() {}, unobserve: function() {} };
  console.log('IntersectionObserver not available');
}

A ReferenceError at script load time aborts the rest of your JS. Always test features before using them.


JavaScript Console Logging

Use console.log() — never dbg(), print(), or custom globals.

console.log('info message');    // appears in C++ log as [JS LOG]
console.warn('warning');        // appears as [JS WARN]
console.error('error');         // appears as [JS ERR]

These only appear in your F4SE log if you registered a ConsoleMessageCallback from C++. See the API Reference.


The JS ↔ C++ Bridge

C++ → JS: Push data to the page

Use InteropCall for named function calls (best performance):

// C++
api->InteropCall(view, "onPlayerData", R"({"hp":210,"ap":75,"name":"Sole Survivor"})");
// JS
function onPlayerData(json) {
  var data = JSON.parse(json);
  document.getElementById('hp').textContent = data.hp;
}

Use Invoke for arbitrary expressions:

// C++
std::string script = "document.title = '" + escapedTitle + "'";
api->Invoke(view, script.c_str());

JS → C++: Events from the page to the plugin

Register a listener from C++ (do this inside your OnDomReady callback):

Threading: JSListenerCallback fires on the Ultralight render thread, not the game thread. Never access RE::* singletons directly inside a listener. Dispatch game thread work via F4SE::GetTaskInterface()->AddTask. See api-reference.md — Threading Warning.

// C++
api->RegisterJSListener(view, "requestClose", [](const char* /*arg*/) {
    // Safe — no RE:: access, PrismaUI calls marshal internally
    api->Unfocus(view);
    api->Hide(view);
});

api->RegisterJSListener(view, "onSettingChanged", [](const char* json) {
    // Dispatch to game thread if RE:: access is needed
    std::string j = json ? json : "";
    F4SE::GetTaskInterface()->AddTask([j]() {
        logger::info("Setting changed: {}", j);
        // RE:: access safe here
    });
});
// JS — these are now global functions on window
document.getElementById('closeBtn').addEventListener('click', function() {
  requestClose();
});

document.getElementById('volumeSlider').addEventListener('input', function() {
  onSettingChanged(JSON.stringify({ key: 'volume', value: this.value }));
});

Passing Complex Data

Always use JSON strings for structured data crossing the C++/JS boundary. Both sides parse and produce JSON:

// C++ — build JSON and push it
nlohmann::json j;
j["items"] = nlohmann::json::array();
for (auto& item : inventory) {
    j["items"].push_back({ {"name", item.name}, {"count", item.count} });
}
api->InteropCall(view, "loadInventory", j.dump().c_str());
// JS — receive and render
function loadInventory(jsonStr) {
  var data = JSON.parse(jsonStr);
  data.items.forEach(function(item) {
    // build DOM nodes...
  });
}

Viewport and Layout

Views are always the full screen size. 100vw and 100vh equal the game’s render resolution. Design your UI to work at 1920×1080 and test at 2560×1440 if possible. Use min-width, max-width, and centered containers rather than fixed pixel positions.

/* Good — centered container, responsive width */
.panel {
  width: min(600px, 90vw);
  margin: 0 auto;
}

/* Risky — will look wrong at 4K */
.panel {
  left: 660px;
  width: 600px;
}

Closing the View from JS

The standard pattern is to register a listener and call it:

// JS
function closeMyMenu() {
  requestClose(); // listener registered from C++
}

Alternatively you can call a JS function that the C++ side reads back via Invoke with a callback, but the listener pattern is simpler and lower latency.


Transparency and Backgrounds

The view composites over the 3D game using alpha blending. background:transparent on body will show the game world through your page. You can create floating panels with semi-transparent backgrounds:

body {
  background: transparent;
}
.panel {
  background: rgba(0, 0, 0, 0.8);
  backdrop-filter: blur(4px); /* blurs game world behind panel */
}

If you want to block mouse clicks from reaching the game (e.g., a full-screen overlay), set pointer-events: auto on a full-screen element. The focus system handles this at the engine level — when focused, all mouse events go to the view regardless.


Fonts

System fonts available in the game process: Courier New, Arial, Segoe UI, Consolas. For a terminal/retro look, Courier New or Consolas are reliable. You can embed a font as base64 in a @font-face rule if needed, but avoid loading fonts from external URLs.


Performance Guidelines

  • Avoid document.querySelectorAll in tight loops. Cache element references.
  • Batch DOM updates — build HTML strings and set innerHTML once rather than appending many nodes individually.
  • requestAnimationFrame is available and works correctly for smooth animations.
  • Heavy JS work (sorting large arrays, string manipulation) is fine. The Ultralight thread is dedicated and won’t block the game’s render thread.
  • Avoid creating and destroying many DOM nodes repeatedly. Reuse and update existing nodes.

Debugging

Register a ConsoleMessageCallback from C++ to see all console.* output in your F4SE log. This is the primary debugging tool.

For DOM inspection, use the inspector:

// C++ — create and show inspector (development only, never ship this)
api->CreateInspectorView(view);
api->SetInspectorBounds(view, 10.0f, 10.0f, 900, 600);
api->SetInspectorVisibility(view, true);

The inspector is the full WebKit DevTools. You can inspect the DOM, run JS in the console, check computed styles, and see network requests (all file://).


Template: Minimal Full-Screen Menu

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body {
    width:100vw; height:100vh;
    background:transparent;
    font-family:'Courier New',monospace;
    display:flex; align-items:center; justify-content:center;
  }
  .panel {
    background:rgba(8,8,6,0.92);
    border:1px solid #3d3208;
    padding:32px 40px;
    color:#f59e0b;
    min-width:400px;
  }
</style>
</head>
<body>
<div class="panel">
  <h1 id="title">MENU</h1>
</div>
<script>
  // Data pushed from C++ via InteropCall
  function setTitle(text) {
    document.getElementById('title').textContent = text;
  }
  console.log('page ready');
</script>
</body>
</html>

Build and Deployment Automation

The PrismaUI_F4 framework includes automated build-and-deploy.bat scripts that:

  1. Check GitHub for framework updates — Queries the latest release from https://github.com/NomadsReach/framework-F4-Conversion and alerts if a newer version is available
  2. Verify source freshness — Compares source file timestamps against the built DLL and prompts rebuild if source is newer
  3. Auto-extract SDK — Automatically copies the Ultralight SDK from the local cache if it’s missing from the build output
  4. Deploy all artifacts — Copies DLL, libraries, resources, and assets to the target MO2 mod folder

Framework build: E:\F4SE OG\Prisma\PrismaUI_F4 New Gen\build-and-deploy.bat

Example plugin build: E:\F4SE OG\Prisma\PrismaUI_F4 New Gen\example-f4se-plugin\build-and-deploy.bat

Both scripts run from their respective directories and prompt you for the deployment path. No manual SDK extraction or file copying is required.