This is Part 3 of the Logos module tutorial series. In [Part 2](tutorial-qml-ui-app.md) you built a QML-only UI plugin. Now you'll build a **ui_qml module with a C++ backend** — the backend runs in a separate `ui-host` process while the QML view loads in the host app (basecamp / standalone).
You'll use the **universal authoring model**: set `"interface": "universal"` in `metadata.json` and write exactly two things — the `.rep` (your view contract) and a `*Backend` class that implements it. The `*Plugin` and `*Interface` classes, the `initLogos(LogosAPI*)` wiring, and the typed-SDK construction are all generated for you. This is the same model Part 1 used for the `calc_module` core module (`interface: universal`), now applied to a UI module.
- A `.rep` file defining the remote interface (slots) — the one Qt-typed contract you author
- A C++ `*Backend` class that derives the generated `SimpleSource` (implements the `.rep`) and `LogosModuleContext` (gives `modules()` typed callers, event subscriptions, and `onContextReady()`)
- Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module` with the shared library built (`.so` on Linux, `.dylib` on macOS in `logos-calc-module/lib/`)
And because `metadata.json` sets `"interface": "universal"`, the builder also generates the plumbing a classic plugin made you hand-write:
- **`CalcUiCppInterface`** — the Logos plugin interface (`name()`, `version()`)
- **`CalcUiCppPlugin`** — the `Q_OBJECT` plugin with `Q_PLUGIN_METADATA`, `initLogos(LogosAPI*)`, and the `setBackend()` / `enableRemoting()` wiring — built around your `*Backend`
Your `*Backend` derives `LogosModuleContext`, which gives it the same surface a universal **core** module impl gets: `modules()` typed method callers for your `dependencies`, typed **event subscriptions** (`modules().dep.on<Event>(...)`), and `onContextReady()` (fires when the backend is wired, so subscriptions are live before the view's first call). `calc_module` here is call-only, but for an event-driven PROP fed from a typed subscription see the worked [ui-typed-backend doc-test](https://github.com/logos-co/logos-module-builder/blob/main/doctests/ui-typed-backend.test.yaml).
This scaffolds the **universal** UI backend template: a `metadata.json` with `"interface": "universal"`, an example `.rep` (`src/ui_example.rep`), and a single `*Backend` class (`src/ui_example_backend.h` / `.cpp`) — no hand-written interface or plugin files. We'll replace the `ui_example` files with our calculator's `.rep` + backend.
Remove the example `.rep` and backend — we replace them with the `calc_ui_cpp` equivalents in the steps below. (There are no `*_interface.h` / `*_plugin.{h,cpp}` files to remove: in the universal model those are generated, not authored.)
-`"interface": "universal"` — selects the universal authoring model: you write the `.rep` + a `*Backend` class, and the `*Plugin`/`*Interface` glue is generated. Without this key, the builder expects the classic hand-written `initLogos(LogosAPI*)` plugin.
-`"codegen": { "rep": "src/calc_ui_cpp.rep" }` — names your view contract. (`backend_class` / `backend_header` are also overridable, defaulting to `CalcUiCppBackend` / `calc_ui_cpp_backend.h`.)
This is the **single source of truth** for the remote interface, and the one Qt-typed file you author — the `.rep` uses Qt types (`QString`) because that's the Qt Remote Objects wire contract. `repc` generates:
**SLOT** return values are delivered as `QRemoteObjectPendingReply` — use `logos.watch()` in QML to get them as JS Promises. You can also declare **PROP** entries (e.g. `PROP(QString status READWRITE)`) which auto-sync from the backend to the QML replica — see the [ui-typed-backend doc-test](https://github.com/logos-co/logos-module-builder/blob/main/doctests/ui-typed-backend.test.yaml) for a PROP fed from a typed module-event subscription.
You list only your two authored sources — the `*Backend` header and implementation. `REP_FILE` points at **your**`.rep`; the generated `*Plugin` glue in `generated_code/` is compiled automatically. `REP_FILE` tells `logos_module()` to:
Now write the backend — the **only** C++ you author. It's a single class that derives:
- **`CalcUiCppSimpleSource`** — generated by `repc` from your `.rep`; you override its slots. The QML replica receives each return value via Qt Remote Objects.
- **`LogosModuleContext`** — gives `modules()` (typed callers + event subscriptions for your `dependencies`) and `onContextReady()`, exactly like a universal core module impl.
There is no `*_interface.h` and no `*_plugin.{h,cpp}` to write — the builder generates the `*Plugin` (`Q_OBJECT`, `Q_PLUGIN_METADATA`, `initLogos`, `setBackend()`/`enableRemoting()`) and `*Interface` (`name()`, `version()`) around this class.
No `Q_OBJECT`, no `Q_PLUGIN_METADATA`, no `initLogos`, no `name()`/`version()` — the universal builder generates all of that. You only declare the `.rep` slot overrides.
`LogosModuleContext` also gives this backend typed **event subscriptions** (`modules().dep.on<Event>(...)`) and `onContextReady()` — arm subscriptions there so they're live before the view's first call. `calc_module` is call-only here, but the [ui-typed-backend doc-test](https://github.com/logos-co/logos-module-builder/blob/main/doctests/ui-typed-backend.test.yaml) shows a `.rep` PROP fed from a typed module-event subscription registered in `onContextReady()`.
- Each slot delegates straight to `calc_module` via `modules().calc_module.<method>(...)` — the generated typed SDK, type-safe with no `QVariant`.
- Slots return values directly; they travel back to the QML replica via Qt Remote Objects.
-`modules().calc_module.libVersion()` returns `std::string` because Part 1's `calc_module` is also a universal (std-typed) module — its `libVersion()` is declared `std::string`. The `.rep` slot is `QString libVersion()` (the QtRO wire type), so wrap with `QString::fromStdString(...)`. The `int` methods need no conversion: `int` ↔ `int64_t` on the wire.
- No `initLogos`, no manual `LogosModules` construction — `modules()` is wired by the generated plugin before any slot runs.
- **Readiness:** the backend lives in a separate `ui-host` process and connects asynchronously, so the replica isn't usable the instant the view loads. `logos.isViewModuleReady("calc_ui_cpp")` reports the current state and the `onViewModuleReadyChanged` signal fires when it changes. Because `isViewModuleReady()` is a `Q_INVOKABLE` method (not a property), don't bind it directly — a `readonly property bool ready: logos.isViewModuleReady(...)` would never re-evaluate. Use the `Connections` + `Component.onCompleted` pattern shown above, and gate the buttons with `enabled: root.ready`.
The QML you load above runs inside the host (`logos-basecamp` / `logos-standalone-app`), which already has `logos-design-system` on the QML import path. Use its themed components rather than rolling your own visuals — your module gets the polished look automatically as the design system evolves.
**Discover what's available** by running the storybook:
```bash
cd repos/logos-design-system && nix run
```
The sidebar splits components into:
- **Controls** — designed per Figma, production-ready (`LogosButton`, `LogosBadge`, `LogosCheckbox`, `LogosComboBox`, `LogosIconButton`, `LogosPaginator`, `LogosSearchBar`, `LogosTabBar`, `LogosTable`, `LogosText`, `LogosTextField`, `LogosToolTip`, …).
- **Controls (not designed)** — placeholders with stable APIs but unstyled visuals (`LogosDialog`, `LogosDrawer`, `LogosScrollView`, `LogosSpinner`, `LogosTextArea`, `LogosSwitch`, …). You can ship with them; they'll get the polished look applied later without you having to change your QML.
**Theme tokens** (use these instead of hex literals or magic font sizes):
Feel free to report bugs, file feature requests, or contribute components / theme tokens upstream — all welcome at `logos-co/logos-design-system`. The same fix lifts every consumer, so upstreaming is the most impactful path. If you can sketch the public API you'd like to use in a feature request, it makes review and implementation much faster.
The placeholder `path:/path/to/your/calc_module` is **not** meant to be edited by hand — Nix won't let a `flake.nix` input use a relative path like `../logos-calc-module` (it's evaluated from a sandboxed copy, so `..` escapes it). Instead you point it at your real checkout **once** via `--override-input` in the next step, which records the resolved absolute path in `flake.lock`. After that, plain `nix run` / `nix build` use the locked path with no override needed.
- **`path:`** (used here) — a local directory on disk. Best for developing `calc_module` and its UI side by side, no network.
- **`github:`** — fetches `calc_module` from a remote repo instead (for CI, or once it's published to its own repo), e.g. `calc_module.url = "github:your-org/your-calc-module";`.
> **Important:** Whichever URL scheme you use, `calc_module` must be built with its shared library (`.so` on Linux, `.dylib` on macOS) present in `lib/`. If it's missing, the nix build will fail with linker errors. See [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library).
`mkLogosQmlModule` handles everything: compiles the C++ backend (because `main` is set), bundles the QML view, generates LGX packages, and wires up `nix run`.
First, make sure your local `calc_module` is built and its shared library is present in `lib/` (see [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)):
Stage your files, then lock `calc_module` to your local Part 1 checkout. The `--override-input` resolves `../logos-calc-module` to an absolute path and records it in `flake.lock`, replacing the placeholder from `flake.nix`:
Launch the app and confirm the view loads with all of its controls. The backend runs in a separate `ui-host` process; clicking **Add** sends the call over Qt Remote Objects and the result comes back through `logos.watch()`.
The result `8` comes from `calc_module.add(3, 5)` executed in the C++ backend — proof the full path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → `calc_module`) works end to end.
For QML iteration, point `DEV_QML_PATH` at the directory that contains your view entry's **basename** (from `metadata.json``"view"`). This tutorial sets `"view": "qml/Main.qml"`, so the directory must contain `Main.qml` (here: `src/qml/`):
When `DEV_QML_PATH` is set, `logos-standalone-app` loads QML from your source tree at runtime instead of the installed copy — so edits to `Main.qml` (and any QML under that tree) are picked up on the next relaunch without you having to re-sync files.
**Important — what this does *not* skip.**`nix run` always re-evaluates the flake and rehashes the source tree before launching. By default `src = ./.` includes every tracked file, including `*.qml` — so:
- **Any source change, including QML edits, rebuilds the plugin** before the app starts. `DEV_QML_PATH` only kicks in *after* the build is done; it doesn't shortcut the rebuild itself.
> **Naming:** Only `DEV_QML_PATH` is honored by `logos-standalone-app`. See `repos/logos-standalone-app/README.md`.
> This does not work with `logos-basecamp` — Basecamp loads QML plugins from its own install tree, so source edits are not picked up until you rebuild and reinstall the `.lgx`.
Add automated UI tests using the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) test framework. Just create `.mjs` files in `tests/` and `logos-module-builder` auto-wires `nix build .#integration-test`.
The `integration-test` output launches `logos-standalone-app` with `QT_QPA_PLATFORM=offscreen` (no display needed), connects to the QML inspector, and runs all `.mjs` files in `tests/`.
To run tests interactively (against an already-running app):
You declare each pattern in the `.rep` and implement it in your `*Backend` (which derives the generated `SimpleSource` + `LogosModuleContext`). There is no hand-written plugin — the `*Plugin`/`*Interface` glue is generated.
| **Event-fed PROP** | `PROP(QString last READONLY)` | `onContextReady()`: `modules().dep.on<Event>([this](...){ setLast(...); })` | `backend.last` (auto-syncs, no polling) |
The last row uses the `LogosModuleContext` surface — typed `modules()` callers and event subscriptions armed in `onContextReady()`. `calc_module` is call-only here; see the [ui-typed-backend doc-test](https://github.com/logos-co/logos-module-builder/blob/main/doctests/ui-typed-backend.test.yaml) for a worked event-driven PROP.
- **Use the Logos Design System** in your QML — see [the design system step](#use-the-logos-design-system-in-your-qml). Browse components in the storybook (`cd repos/logos-design-system && nix run`); file issues at `logos-co/logos-design-system`.