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
1. Expose state as a topic (2 minutes)
Section titled “1. Expose state as a topic (2 minutes)”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).
2. Build the bridge (1 minute)
Section titled “2. Build the bridge (1 minute)”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 declarationui_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.
3. Embed the page (2 minutes)
Section titled “3. Embed the page (2 minutes)”juce_adapter::JuceTaskQueue tasks;juce_adapter::JuceWebUiHost view; // a juce::Component: put it in a windowui_bridge::WebUiPage page (bridge, view, tasks); // one bridge connection per page loadpage.load (content); // ui_bridge::PackagedContentcontent 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_webuishuffle_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.
Or serve it to a browser
Section titled “Or serve it to a browser”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 choosesettings.content = content; // the same PackagedContentsettings.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).
4. Write the page (3 minutes)
Section titled “4. Write the page (3 minutes)”<script type="module" src="app.js"></script><ol id="songs" aria-label="Songs"></ol><button id="go" type="button">GO</button>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 / welcomebindTheme (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).
5. Generate the client (1 minute)
Section titled “5. Generate the client (1 minute)”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 timeThe 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.
6. Check it without a window (1 minute)
Section titled “6. Check it without a window (1 minute)”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.
Accessibility checklist
Section titled “Accessibility checklist”- Use native elements (
button,input type="range",input type="checkbox") before ARIA roles (UB-R92). - Take accessible names from the generated
commandstable withaccessibleName(), and value texts withvalueText()(“−6.0 decibels”). - Announce with
LiveRegions(one polite and one assertive region, at most one announcement every 2 s each); subscribe to theannouncementevents. - Show a pending confirmation (the
sessiontopic) as analertdialogthat takes focus, returns it when it closes and cancels on Escape.
Real-time data
Section titled “Real-time data”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.
Further reading
Section titled “Further reading”- specs/ui_bridge.md: the protocol, message by message.
- core/ui_bridge/README.md: the API, benchmarks and coverage.
- adapters/net/shuffle_net_websocket/README.md: the WebSocket server, who may connect, the local network.
- adapters/juce/README.md in shuffle_juce: the webview host and its limits.
© 2026 Umberto Sorbo. The software is licensed under the Apache License, Version 2.0, and provided “as is”, without warranties or conditions of any kind; see the disclaimer.
SHUFFLE Framework and the SHUFFLE Framework logo are trademarks of Umberto Sorbo; the licence grants no rights to them (trademarks). Other names belong to their owners. Fonts: IBM Plex Sans and JetBrains Mono, SIL Open Font License 1.1.
Contact: shuffleframework@gmail.com · Instagram @shuffleframework. Security problems: write to the same address with “Security” in the subject, not in a public issue.