Skip to content

Build a web UI in 10 minutes

A shuffle_engine application can draw its interface with HTML, CSS and JavaScript (or TypeScript, with any framework) while the engine stays in C++. The page talks to the engine only through the UI bridge: it sends commands from the catalogue and reads state from topics. Undo, the recorder, access levels, show mode and confirmations apply to the page exactly as to any other source; there is no back door (specs/ui_bridge.md).

This guide builds the program of examples/juce/webui: a window with an embedded page that shows a set list and has a GO button. The bridge, the page connection and the JavaScript runtime are in shuffle_engine (core/ui_bridge); the embedded webview, JuceWebUiHost, and the example are in the shuffle_juce satellite, which the application adds after shuffle_engine.

flowchart LR
    page["page (HTML, CSS, JS)<br/>shuffle_bridge.js + generated client"]
    view["JuceWebUiHost<br/>(WebView2, WKWebView, WebKitGTK)"]
    wp["WebUiPage<br/>(in-process transport)"]
    bridge["Bridge"]
    session["CommandSession"]
    domain["domain<br/>CommandTarget + TopicSource"]
    page -- "call / subscribe" --> view --> wp --> bridge --> session --> domain
    domain -- "topicChanged" --> bridge -- "snapshot / delta" --> wp --> view --> page

A domain already has commands (a CommandTarget). To show its state to a page it also implements TopicSource: constant declarations with a JSON Schema, and a read() that returns the current value as a JSON object. Lists are keyed collections (an object keyed by identifier plus an order array) so a change costs one small delta:

constexpr ui_bridge::TopicDescriptor kSetlistTopics[] {
{ .name = "setlist.songs", .summary = "The songs of the set list, in order.", .schema = kSongsSchema } };
class SetlistTopics final : public ui_bridge::TopicSource
{
std::span<const ui_bridge::TopicDescriptor> topics() const noexcept override { return kSetlistTopics; }
time::Result<Json> bind (const ui_bridge::TopicDescriptor& t, const Json& params, const commands::Context& c) const override
{ return ui_bridge::bindParameters (t, params, show_, c); } // names resolve to identifiers
std::optional<Json> read (const ui_bridge::TopicDescriptor&, const Json&, std::string&) const override
{ /* {"songs": {"s1": {"id": "s1", "name": "...", ...}}, "order": ["s1", ...], "current": "s1"} */ }
void setListener (ui_bridge::TopicListener* l) override { listener_ = l; } // call l->topicChanged() after a change
};

The bridge also republishes every topic when the session’s revision changes, so a domain only has to notify changes that do not come from commands (playback progress, a device going away).

In the composition root, next to the command session:

ui_bridge::ThemeSource theme (stageDark); // the ui.theme topic (CSS custom properties)
ui_bridge::TopicSource* sources[] { &setlistTopics, &theme };
auto registry = ui_bridge::buildTopicRegistry (sources); // fails at start-up, naming every broken declaration
ui_bridge::Bridge bridge (session, *registry.value, clock);

Call bridge.tick() from the control timer (once per display frame is plenty): it publishes throttled topics, notices changes from other surfaces and samples streams.

juce_adapter::JuceTaskQueue tasks;
juce_adapter::JuceWebUiHost view; // a juce::Component: put it in a window
ui_bridge::WebUiPage page (bridge, view, tasks); // one bridge connection per page load
page.load (content); // ui_bridge::PackagedContent

content holds the page’s files. Package them into the binary (the example uses juce_add_binary_data) and add the generated client (step 5). The view serves them at its own origin, puts a strict Content Security Policy in every HTML document (no inline scripts, no eval, nothing from the network) and cancels navigation to anything else. So: no inline <script> or style= attributes; put scripts and styles in files.

In CMake, with shuffle_juce added after shuffle_engine, USES juce_webui is all an application needs:

add_subdirectory (../shuffle_juce shuffle_juce) # provides juce_platform, juce_webui
shuffle_engine_add_app (MyShow BACKEND juce GUI SOURCES main.cpp USES juce_platform juce_webui ui_bridge setlist)
juce_add_binary_data (MyShowPage SOURCES page/index.html page/app.js page/style.css
path/to/shuffle_engine/core/ui_bridge/web/shuffle_bridge.js)
target_link_libraries (MyShow PRIVATE MyShowPage)

On Windows the WebView2 SDK must be found at build time (JUCE_WEBVIEW2_PACKAGE_LOCATION, see shuffle_juce’s adapters/juce/README.md); without it the application builds, and the view says it is unavailable.

Without a webview (a server, a phone on the same network, a second screen), the same page talks to the bridge over a WebSocket. The server serves the files over HTTP too, the runtime included (ui_bridge::webRuntimeFiles()), and listens on loopback unless the machine configuration says otherwise:

net_sockets::OsStreamNetwork network;
net_websocket::WebSocketServerSettings settings; // loopback; settings.port = 0 lets the OS choose
settings.content = content; // the same PackagedContent
settings.anonymous = ui_bridge::ConnectionOptions { .profile = commands::remoteProfile() };
net_websocket::WebSocketServer server (bridge, tasks, network, clock, settings);
server.start(); // open http://127.0.0.1:<port>/

In the page, use webSocketTransport (`ws://${location.host}/`) instead of inProcessTransport (). Who may connect (origins, credentials in hello, other machines in clear text) is in shuffle_net_websocket; the example ui_bridge/websocket runs it end to end (--serve keeps it open for a browser).

index.html
<script type="module" src="app.js"></script>
<ol id="songs" aria-label="Songs"></ol>
<button id="go" type="button">GO</button>
app.js
import { BridgeClient, inProcessTransport, bindTheme, accessibleName } from "./shuffle_bridge.js";
import { createApi, subscribeTopic, commands, catalogueHash, catalogueEntries } from "./show_api.js";
const client = new BridgeClient (inProcessTransport (), { name: "Stage page", catalogueHash, catalogueEntries });
const api = createApi (client);
await client.connect (); // hello / welcome
bindTheme (client); // --shuffle-* CSS custom properties, live
subscribeTopic (client, "setlist.songs", {}, (value) => { // a new immutable value on every change
document.getElementById ("songs").replaceChildren (...value.order.map ((id) => {
const li = document.createElement ("li");
li.textContent = value.songs[id].name;
if (id === value.current) li.setAttribute ("aria-current", "true");
return li;
}));
});
const go = document.getElementById ("go");
go.setAttribute ("aria-label", accessibleName (commands["transport.start_next_song"]));
go.addEventListener ("click", async () => {
const result = await api.transport.start_next_song ({}); // the command's own name
if (result.outcome.status !== "applied") console.log (result.outcome.problems);
});

Things the runtime does for you: it matches answers to calls, applies the deltas (replacing every object a delta touches, so any framework sees the change by identity), asks for a fresh snapshot when it misses a delta, and keeps the theme’s CSS properties up to date without a reload. Use the theme’s variables in your CSS: background: var(--shuffle-surface-canvas).

Arguments name things by name: api.mixer.set_track_gain ({ track: "click", gain_db: -6 }). An error answers with a message that says what to fix; a refused command is a result with outcome rejected and the session’s own reason (show mode, access level, a value out of range).

The client module and its TypeScript declarations are generated from the catalogue, the topics and the streams:

auto client = ui_bridge::generateTypeScriptClient (catalogue.commands(),
registry.value->topicDescriptors(),
registry.value->streamDescriptors());
content.files.push_back ({ "show_api.js", {}, client.value->javascript }); // or write .js and .d.ts at build time

The example generates it at start-up, so the page always matches the application. To work in TypeScript, write client.value->declarations next to the module as show_api.d.ts and copy core/ui_bridge/web/shuffle_bridge.d.ts: every command, its parameters (with ranges, units, every example phrase, risk, access and show-mode availability in the documentation comments) and every topic value are typed. The files depend on nothing, no package and no framework.

If the page was generated from another catalogue, the welcome lists the commands, topics and streams that were added, removed or changed.

The core has fakes for every port, so a test or a CI job drives the same bridge through the same in-process transport with no webview:

ui_bridge::testing::FakeWebUiHost view;
ui_bridge::WebUiPage page (bridge, view, tasks);
page.load (content);
view.finishLoading();
view.pageSends (R"({"type":"hello","protocols":[1]})");
tasks.runPending(); // the welcome is in view.posted()

examples/juce/webui --headless (in shuffle_juce) does exactly that in CI; examples/juce/webui --smoke opens the real window, presses GO in the page and exits when the engine plays.

  • Use native elements (button, input type="range", input type="checkbox") before ARIA roles (UB-R92).
  • Take accessible names from the generated commands table with accessibleName(), and value texts with valueText() (“−6.0 decibels”).
  • Announce with LiveRegions (one polite and one assertive region, at most one announcement every 2 s each); subscribe to the announcement events.
  • Show a pending confirmation (the session topic) as an alertdialog that takes focus, returns it when it closes and cancels on Escape.

Meters and playheads are streams, not topics: a domain declares the element layout (StreamDescriptor) and implements StreamSource::fill from values the audio side already published. The page calls client.streamSubscribe ("mixer.meters", 30, onFrame) and decodes frames with decodeElements. Frames are latest-value-wins: a slow page skips frames, it never queues them.