Files
logos-tutorial/tests/tutorial-cpp-ui-app.test.yaml

1040 lines
63 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: "Tutorial Part 3: Building a C++ UI Module (Process-Isolated)"
output: tutorial-cpp-ui-app.md
project_name: logos-calc-ui-cpp
requires:
- tutorial-qml-ui-app.test.yaml
release: ""
intro: |
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.
what_you_build: |
A `calc_ui_cpp` module with:
- A `.rep` file exercising the **full QtRO surface**, not just slots: value slots and a void slot, `PROP`s of different types (`QString`, `int`) and both modes (`READONLY` and `READWRITE`), and a `SIGNAL` — the one Qt-typed contract you author
- A C++ `*Backend` class that derives the generated `SimpleSource` (implements the `.rep`) and `LogosUiPluginContext` (gives `modules()` Qt-typed callers, event subscriptions, and `onContextReady()`)
- A QML view that drives each surface: `logos.watch()` for slot replies, plain property reads for auto-synced PROPs, a property *write* for the READWRITE memory register, a `Connections` block for the signal, and a label fed by a typed `calc_module` **event subscription**
- Process isolation: backend crashes can't bring down the host app
You write only the `.rep` and the `Backend`. The `*Plugin`/`*Interface` classes, the `initLogos`/`setBackend` wiring, and the typed SDK are generated.
comparison: |
**Why C++ backend over QML-only?**
| | QML-only (Part 2) | C++ backend (Part 3) |
| ----------------- | ----------------------------------------------------------------- | ----------------------------------------------------- |
| Compilation | None | CMake + Qt |
| Process isolation | No (QML runs in-process) | Yes (C++ in separate `ui-host` process) |
| Backend calls | `logos.callModule()` / `logos.callModuleAsync()` to other modules | `modules()` typed SDK in C++ (type-safe, no QVariant) |
| Type safety | Args travel as `QVariant` | C++ types preserved |
| QML ↔ backend | Direct bridge | Qt Remote Objects (typed replica) |
| `.rep` file | Not needed | Required — your view contract, the one file you author |
| C++ you write | None | One `*Backend` class — no hand-written plugin/interface |
prerequisites:
- "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/`)"
- Nix with flakes enabled
sections:
# ── Architecture (prose only) ───────────────────────────────────────────────
- title: "Architecture"
text: |
```
logos-basecamp / logos-standalone-app
┌─────────────────────────────────────────────┐
│ │
│ QML View (Main.qml) │
│ readonly property var backend: │
│ logos.module("calc_ui_cpp") │
│ logos.watch(backend.add(1,2))│
│ │ │
│ │ Qt Remote Objects (socket) │
└──────────┼──────────────────────────────────┘
ui-host process (separate)
┌──────────┼──────────────────────────────────┐
│ ▼ │
│ CalcUiCppBackend (you write this) │
│ : CalcUiCppSimpleSource (impl .rep) │
│ : LogosUiPluginContext (modules()) │
│ int add(int a, int b) override { │
│ return modules().calc_module.add(a,b);│
│ } │
│ │ │
│ │ modules() typed SDK │
│ ▼ │
│ calc_module (loaded in ui-host) │
└─────────────────────────────────────────────┘
```
You author two files: the `.rep` (your view contract) and the `*Backend` class. Everything else is generated.
The `.rep` file declares the interface. At build time, Qt's `repc` compiler generates:
- **`CalcUiCppSimpleSource`** — base class the backend implements
- **`CalcUiCppReplica`** — typed replica the QML view uses
- **`calc_ui_cpp_replica_factory`** — separate plugin that the host loads to create typed replicas
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 `LogosUiPluginContext`. A UI plugin is a view, not a module, so the context carries only the dependency surface: `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). The dep wrappers are **Qt-typed** (`QString`, `int`, ...) to match the `.rep` slots — no std<->Qt conversions in the view.
The `.rep` is more than a list of slots, and this tutorial exercises the whole of it: value slots and a void slot, `PROP`s of different types and both modes (a slot-driven `READONLY` counter, a `READWRITE` memory register QML writes back, an event-fed `READONLY` string), and a `SIGNAL` the backend pushes to the view. The calculator slots *call* `calc_module`; `record()` feeds the PROP + SIGNAL after each call; and `onContextReady()` *subscribes* to `calc_module`'s `versionReady` event to feed the auto-syncing event PROP.
# ── Step 1: Scaffold ────────────────────────────────────────────────────────
- title: "Scaffold"
step: true
text: |
Create a new directory and initialise it from the C++ backend UI template:
`mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp`
steps:
- run: "nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml-backend"
code_block: |
nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml-backend
post_text: |
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.
- run: "rm -f src/ui_example.rep src/ui_example_backend.h src/ui_example_backend.cpp"
post_text: |
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.)
- run: "git init && git add -A"
# ── Step 2: metadata.json ───────────────────────────────────────────────────
- title: "`metadata.json`"
step: true
text: |
Replace the template contents with your plugin's details:
steps:
- file:
path: metadata.json
language: json
content: |
{
"name": "calc_ui_cpp",
"version": "1.0.0",
"type": "ui_qml",
"interface": "universal",
"category": "tools",
"description": "Calculator C++ UI — QML view with process-isolated backend for calc_module",
"main": "calc_ui_cpp_plugin",
"view": "qml/Main.qml",
"icon": "icons/calc.png",
"dependencies": ["calc_module"],
"codegen": { "rep": "src/calc_ui_cpp.rep" },
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": [],
"extra_link_libraries": []
}
}
}
post_text: |
Create the icon directory and add a placeholder icon — a PNG that is exactly 256×256, the only size LGX packaging accepts (displayed in the `logos-basecamp` sidebar when the module is loaded):
- run: "mkdir -p icons && echo 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAABlBMVEUuzHEuzHEVOa2oAAAAH0lEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABYOSdlwAAAABJRU5ErkJggg==' | base64 -d > icons/calc.png"
code_block: |
mkdir -p icons
# Copy any PNG here — or generate a 256×256 placeholder:
echo "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAABlBMVEUuzHEuzHEVOa2oAAAAH0lEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABYOSdlwAAAABJRU5ErkJggg==" | base64 -d > icons/calc.png
post_text: |
Key fields:
- `"type": "ui_qml"` — tells the builder this is a QML view module
- `"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`.)
- `"main": "calc_ui_cpp_plugin"` — the generated backend Qt plugin library (without extension)
- `"view": "qml/Main.qml"` — the QML entry point
- `"dependencies": ["calc_module"]` — core modules the backend calls via `modules()`
# ── Step 3: The .rep File ─────────────────────────────────────────────────────
- title: "The `.rep` File"
step: true
text: |
Create `src/calc_ui_cpp.rep`:
steps:
- file:
path: src/calc_ui_cpp.rep
language: rep
content: |
class CalcUiCpp
{
// ── SLOTs — call-and-return; each reply reaches QML via logos.watch() ──
SLOT(int add(int a, int b))
SLOT(int multiply(int a, int b))
SLOT(int factorial(int n))
SLOT(int fibonacci(int n))
SLOT(QString libVersion())
// Void slot — fire-and-forget. Asks calc_module to (re-)announce
// its version as a `versionReady` event; there's no return value
// to await, the answer comes back through the PROP below.
SLOT(void announceVersion())
// ── PROPs — auto-synced backend → every QML replica, no polling ──
// QString, event-fed: the typed `versionReady` subscription the
// backend arms in onContextReady() writes it. Starts empty.
PROP(QString versionEvent="" READONLY)
// int, slot-driven: the backend bumps it after each calculation,
// so the view shows a live tally without ever polling.
PROP(int computeCount=0 READONLY)
// int, READWRITE: a memory register the QML view both *reads* and
// *writes* (Store / Clear buttons), and the backend may set too.
// A write round-trips QML → replica → source → back to every replica.
PROP(int memory=0 READWRITE)
// ── SIGNAL — backend → view push, distinct from a return value ──
// Emitted after each calculation. QML catches it with a
// Connections block (not logos.watch(), not a PROP read).
SIGNAL(computed(QString op, int result))
}
post_text: |
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`, `int`) because that's the Qt Remote Objects wire contract. `repc` generates:
- `rep_calc_ui_cpp_source.h` — `CalcUiCppSimpleSource` with virtual slots your `*Backend` overrides, a `set<Prop>(...)` setter + change signal for each PROP (`setVersionEvent`, `setComputeCount`, `setMemory`), and the `computed(...)` signal you `emit`
- `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods, the auto-synced `versionEvent` / `computeCount` / `memory` properties, and the `computed` signal the QML view reads, writes, and connects to
One contract, four kinds of surface — slots are only the first:
- **SLOT** return values arrive as `QRemoteObjectPendingReply` — `logos.watch()` turns them into JS Promises in QML. `add`/`multiply`/… return values; `announceVersion()` is a **void** slot (fire-and-forget, no reply to watch).
- **PROP** auto-syncs from the backend to every QML replica with no polling. We use three, of two types and two modes: `versionEvent` (QString, READONLY, fed by a module-**event subscription** in Step 5), `computeCount` (int, READONLY, bumped by each slot), and `memory` (int, **READWRITE** — QML assigns `backend.memory = …` and the new value round-trips through the backend source and back to the view).
- **SIGNAL** is a backend → view push that is neither a return value nor a synced property: `computed(op, result)` fires after each calculation and the QML view catches it with a `Connections` block.
# ── Step 4: CMakeLists.txt ────────────────────────────────────────────────────
- title: "`CMakeLists.txt`"
step: true
steps:
- file:
path: CMakeLists.txt
language: cmake
content: |
cmake_minimum_required(VERSION 3.14)
project(CalcUiCppPlugin LANGUAGES CXX)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
# Derive the module name from metadata.json — single source of truth.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/metadata.json" METADATA_JSON)
string(JSON MODULE_NAME GET ${METADATA_JSON} name)
logos_module(
NAME ${MODULE_NAME}
REP_FILE src/calc_ui_cpp.rep
SOURCES
src/calc_ui_cpp_backend.h
src/calc_ui_cpp_backend.cpp
INCLUDE_DIRS
src
)
post_text: |
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:
1. Run `repc` to generate the source/replica headers
2. Generate the `*Plugin` / `*Interface` wrapper around your `*Backend` (because `metadata.json` sets `"interface": "universal"`)
3. Build a separate `calc_ui_cpp_replica_factory` shared library
# ── Step 5: C++ Backend ───────────────────────────────────────────────────────
- title: "C++ Backend"
step: true
text: |
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.
- **`LogosUiPluginContext`** — gives `modules()` (Qt-typed callers + event subscriptions for your `dependencies`) and `onContextReady()`. A UI plugin is a view, not a module, so that is all the context carries.
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.
steps:
- title: "`src/calc_ui_cpp_backend.h`"
file:
path: src/calc_ui_cpp_backend.h
language: cpp
content: |
#pragma once
#include "rep_calc_ui_cpp_source.h"
#include "logos_ui_plugin_context.h"
// The whole hand-written backend. Derives:
// - CalcUiCppSimpleSource — generated from calc_ui_cpp.rep; override its
// slots (the QML replica gets each return value via Qt Remote Objects).
// - LogosUiPluginContext — supplies modules() (Qt-typed callers + typed event
// subscriptions for "dependencies") and onContextReady(). A UI plugin is a
// view, not a module, so that is all the context carries.
// The *Plugin / *Interface classes (Q_PLUGIN_METADATA, initLogos wiring,
// QtRO registration) are generated around it.
class CalcUiCppBackend : public CalcUiCppSimpleSource,
public LogosUiPluginContext
{
public:
// Slots from calc_ui_cpp.rep — each delegates to calc_module.
int add(int a, int b) override;
int multiply(int a, int b) override;
int factorial(int n) override;
int fibonacci(int n) override;
QString libVersion() override;
// Tells calc_module to emit its `versionReady` event.
void announceVersion() override;
// Fires once when ui-host hands the plugin its LogosAPI — the
// typed dependency surface is live, so we arm the event
// subscription here (before the view's first call).
void onContextReady() override;
private:
// Feeds the non-slot surfaces of the .rep after each calculation:
// bumps the computeCount PROP (setComputeCount, generated) and
// emits the `computed` SIGNAL. The READWRITE `memory` PROP is
// driven from QML, so the backend doesn't have to touch it.
void record(const QString& op, int result);
};
post_text: |
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 (plus any private helpers of your own, like `record`).
Beyond the call-and-return slots, the backend drives the rest of the `.rep` surface:
- **PROPs** — each gets a generated `set<Prop>()` setter on the `SimpleSource`. The backend calls `setComputeCount(...)` (and could call `setMemory(...)`); `memory` is READWRITE, so QML can write it too.
- **SIGNAL** — `computed(...)` is declared on the `SimpleSource`, so the backend just `emit`s it.
- **Event subscriptions** — `LogosUiPluginContext` gives typed `modules().dep.on<Event>(...)` and the `onContextReady()` hook. `onContextReady()` subscribes to `calc_module`'s `versionReady` event and pipes the payload into the `versionEvent` PROP, which Qt Remote Objects auto-syncs to the view. Arm subscriptions in `onContextReady()` (not the constructor) so they're live the moment the backend is wired.
- title: "`src/calc_ui_cpp_backend.cpp`"
file:
path: src/calc_ui_cpp_backend.cpp
language: cpp
content: |
#include "calc_ui_cpp_backend.h"
// Generated umbrella: LogosModules (behind modules()) from
// metadata.json#dependencies — typed wrappers + typed event accessors.
#include "logos_sdk.h"
int CalcUiCppBackend::add(int a, int b)
{
int result = modules().calc_module.add(a, b);
record("add", result);
return result;
}
int CalcUiCppBackend::multiply(int a, int b)
{
int result = modules().calc_module.multiply(a, b);
record("multiply", result);
return result;
}
int CalcUiCppBackend::factorial(int n)
{
int result = modules().calc_module.factorial(n);
record("factorial", result);
return result;
}
int CalcUiCppBackend::fibonacci(int n)
{
int result = modules().calc_module.fibonacci(n);
record("fibonacci", result);
return result;
}
QString CalcUiCppBackend::libVersion()
{
// A UI plugin is Qt-typed: modules().calc_module's wrapper returns QString
// (api-style qt), matching the .rep slot — no conversion needed.
return modules().calc_module.libVersion();
}
void CalcUiCppBackend::record(const QString& op, int result)
{
// PROP: bump the slot-driven counter. setComputeCount() is the
// generated setter; Qt Remote Objects syncs the new value to every
// replica, so the view's "Computations" label updates with no polling.
setComputeCount(computeCount() + 1);
// SIGNAL: a backend → view push, distinct from the return value the
// QML side gets via logos.watch(). `computed` is declared on the
// generated SimpleSource, so we just emit it; the typed replica
// re-emits it and the view's Connections block catches it.
emit computed(op, result);
}
void CalcUiCppBackend::announceVersion()
{
// Fire-and-forget call into calc_module: it looks up the library
// version and emits it as a `versionReady` event. We don't read a
// return value here — the event comes back through the subscription
// armed in onContextReady() below.
modules().calc_module.libVersionNotify();
}
void CalcUiCppBackend::onContextReady()
{
// Typed module-event subscription. `versionReady` is calc_module's
// event (Part 1's `logos_events:` block); the generated wrapper
// exposes it as on<Event> + a Qt-typed callback (QString, because a
// UI plugin is api-style qt). Push each payload into the versionEvent
// PROP — Qt Remote Objects then auto-syncs it to the QML replica.
modules().calc_module.onVersionReady([this](const QString& version) {
setVersionEvent(version);
});
}
post_text: |
Key points:
- Each value slot delegates straight to `calc_module` via `modules().calc_module.<method>(...)` — the generated typed SDK, type-safe with no `QVariant` — then calls `record()` to drive the PROP + SIGNAL surfaces before returning. Slot return values travel back to the QML replica via Qt Remote Objects.
- `modules().calc_module.libVersion()` returns `QString` even though Part 1's `calc_module` declares it `std::string`. A UI plugin is Qt-typed (api-style `qt`), so the generated `modules().<dep>` wrapper exposes Qt types (`QString`, `int`, ...) that match the `.rep` slots directly — the std<->Qt conversion happens inside the generated wrapper, not in your view code.
- **PROP vs. SIGNAL vs. return value — three ways to get data to the view.** `record()` shows two of them side by side: `setComputeCount(...)` writes a **PROP** that Qt Remote Objects auto-syncs (the view reads `backend.computeCount` with no polling), while `emit computed(...)` fires a **SIGNAL** the view catches in a `Connections` block. Both are independent of the slot's `logos.watch()` return value. The READWRITE `memory` PROP is the fourth path — there the *view* writes and the value syncs back.
- **Event subscription vs. method call.** `announceVersion()` makes `calc_module` *emit*; `onContextReady()` *subscribes* to that emission. The callback is Qt-typed (`const QString&`) and `setVersionEvent(...)` is the generated PROP setter — the value reaches QML with no polling and no return value to await. Arm the subscription in `onContextReady()`, never the constructor: `modules()` isn't wired until the framework calls it.
- No `initLogos`, no manual `LogosModules` construction — `modules()` is wired by the generated plugin before any slot runs or any event arrives.
# ── Step 6: QML View ──────────────────────────────────────────────────────────
- title: "QML View"
step: true
text: |
Create `src/qml/Main.qml`:
steps:
- file:
path: src/qml/Main.qml
language: qml
content: |
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property string result: ""
property string errorText: ""
// Last payload from the backend's `computed` SIGNAL (see Connections below).
property string lastSignal: "(none)"
// Typed replica of the backend running in ui-host (generated from calc_ui_cpp.rep).
readonly property var backend: logos.module("calc_ui_cpp")
// The ui-host backend connects asynchronously, so the replica isn't
// immediately usable. Track readiness reactively: isViewModuleReady()
// is a Q_INVOKABLE (not a property), so we re-check it on the
// onViewModuleReadyChanged signal and once at startup — never via a
// plain property binding, which would not re-evaluate.
property bool ready: false
Connections {
target: logos
function onViewModuleReadyChanged(moduleName, isReady) {
if (moduleName === "calc_ui_cpp")
root.ready = isReady && root.backend !== null
}
}
Component.onCompleted: {
root.ready = root.backend !== null && logos.isViewModuleReady("calc_ui_cpp")
}
// SIGNAL from the .rep: the backend emits `computed(op, result)` after
// each calculation. The typed replica re-emits it, so we catch it with
// a Connections block — no logos.watch(), no property read. This is the
// backend → view push path, distinct from the slot return value above.
Connections {
target: root.backend
function onComputed(op, result) {
root.lastSignal = op + " = " + result
}
}
// logos.watch() delivers the result of a replica slot call via callbacks.
// No QtRemoteObjects import needed — the bridge handles it.
function callCalc(method, args) {
if (!root.ready) {
root.errorText = "Backend not ready"
return
}
root.errorText = ""
root.result = "..."
logos.watch(backend[method].apply(backend, args),
function(value) { root.result = String(value) },
function(error) { root.errorText = String(error) }
)
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
Text {
text: "Logos Calculator (C++ backend)"
font.pixelSize: 20
color: "#ffffff"
Layout.alignment: Qt.AlignHCenter
}
// Reactive backend-connection indicator.
Text {
text: root.ready ? "Connected" : "Connecting to backend..."
color: root.ready ? "#56d364" : "#f0883e"
font.pixelSize: 12
Layout.alignment: Qt.AlignHCenter
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputA
placeholderText: "a"
Layout.preferredWidth: 80
validator: IntValidator {}
}
TextField {
id: inputB
placeholderText: "b"
Layout.preferredWidth: 80
validator: IntValidator {}
}
Button {
text: "Add"
enabled: root.ready
onClicked: root.callCalc("add", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0])
}
Button {
text: "Multiply"
enabled: root.ready
onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0])
}
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputN
placeholderText: "n"
Layout.preferredWidth: 80
validator: IntValidator { bottom: 0 }
}
Button {
text: "Factorial"
enabled: root.ready
onClicked: root.callCalc("factorial", [parseInt(inputN.text) || 0])
}
Button {
text: "Fibonacci"
enabled: root.ready
onClicked: root.callCalc("fibonacci", [parseInt(inputN.text) || 0])
}
Button {
text: "libcalc version"
enabled: root.ready
onClicked: root.callCalc("libVersion", [])
}
Button {
// Fires the event path: asks calc_module to emit
// versionReady. No logos.watch() — the result comes
// back through the versionEvent PROP, not a return value.
text: "Announce version (event)"
enabled: root.ready
onClicked: root.backend.announceVersion()
}
}
Rectangle {
Layout.fillWidth: true
height: 56
color: root.errorText.length > 0 ? "#3d1a1a" : "#1a2d1a"
radius: 8
Text {
anchors.centerIn: parent
text: root.errorText.length > 0 ? root.errorText
: (root.result.length > 0 ? root.result : "Enter values and press a button")
color: root.errorText.length > 0 ? "#f85149" : "#56d364"
font.pixelSize: 15
}
}
// Slot-driven PROP: bumped by the backend's record() after each
// calculation. A plain property read — auto-syncs, no polling.
Text {
text: "Computations: " + ((root.ready && root.backend) ? root.backend.computeCount : 0)
color: "#cdd6f4"
font.pixelSize: 14
Layout.alignment: Qt.AlignHCenter
}
// SIGNAL payload, captured by the Connections block above.
Text {
text: "Last op (signal): " + root.lastSignal
color: "#94e2d5"
font.pixelSize: 14
Layout.alignment: Qt.AlignHCenter
}
// READWRITE PROP: the memory register. The label *reads*
// backend.memory; the buttons *write* it. A write round-trips
// QML → replica → backend source → back to every replica, so the
// label updates once the new value syncs home.
RowLayout {
spacing: 12
Layout.alignment: Qt.AlignHCenter
Text {
text: "Memory: " + ((root.ready && root.backend) ? root.backend.memory : 0)
color: "#cdd6f4"
font.pixelSize: 14
}
Button {
text: "Store (MS)"
enabled: root.ready
onClicked: root.backend.memory = parseInt(root.result) || 0
}
Button {
text: "Clear (MC)"
enabled: root.ready
onClicked: root.backend.memory = 0
}
}
// Event-fed label: the versionEvent PROP auto-syncs from the
// backend's typed versionReady subscription. No polling — it
// updates the moment calc_module emits.
Text {
readonly property string ev: (root.ready && root.backend) ? root.backend.versionEvent : ""
text: "Version event: " + (ev.length > 0 ? ev : "(none yet)")
color: "#f9e2af"
font.pixelSize: 15
Layout.alignment: Qt.AlignHCenter
}
Item { Layout.fillHeight: true }
}
}
post_text: |
Key patterns — one for each surface the `.rep` exposes:
- `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties, callable slots, connectable signals)
- **SLOT return value:** `logos.watch(backend.add(1, 2), ...)` — the reply as a JS Promise.
- **READONLY PROP (slot-driven):** `backend.computeCount` is read directly in a binding. The backend bumps it via `setComputeCount(...)` after each calculation, and Qt Remote Objects auto-syncs it — the label re-evaluates with no polling.
- **READWRITE PROP:** `backend.memory` is both read (the "Memory:" label) and **written** (`backend.memory = ...` in the Store/Clear buttons). Assigning the property on the replica pushes the value to the backend source; it syncs back to every replica, so the label updates once the write lands.
- **SIGNAL:** `Connections { target: root.backend; function onComputed(op, result) { ... } }` catches the backend's `computed` push. No return value, no property — a one-shot event the view reacts to.
- **Event-fed PROP:** `backend.versionEvent` is read directly — no `logos.watch()`, no polling. The "Announce version" button calls the void `announceVersion()` slot (which makes `calc_module` emit `versionReady`); the backend's subscription catches the event and writes the PROP, and Qt Remote Objects pushes the new value straight into this label. This is the event path, distinct from the call-and-return slots above.
- **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 `logos` object is injected by the host at runtime — no `QtRemoteObjects` import needed
# ── Step 7: Use the Logos Design System (prose only) ──────────────────────────
- title: "Use the Logos Design System in your QML"
step: true
text: |
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.
```qml
import Logos.Theme
import Logos.Controls
import Logos.Icons // optional shared icon assets
LogosButton {
text: qsTr("Add")
onClicked: root.callCalc("add", [parseInt(inputA.text) || 0,
parseInt(inputB.text) || 0])
}
LogosTextField {
id: inputA
placeholderText: qsTr("a")
}
Rectangle {
color: Theme.palette.backgroundSecondary
radius: Theme.spacing.radiusSmall
LogosText { text: qsTr("Result"); color: Theme.palette.text }
}
```
**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):
- `Theme.palette.*` — `background`, `backgroundSecondary`, `surface`, `text`, `textSecondary`, `border`, `primary`, `success`, `warning`, `error`, `info`, `hover`, `pressed`, …
- `Theme.spacing.*` — `tiny`, `small`, `medium`, `large`, `xlarge`, `xxlarge`, `radiusSmall`, `radiusMedium`, `radiusLarge`
- `Theme.typography.*` — `pageTitleText` (36), `titleText` (30), `panelTitleText` (24), `subtitleText` (16), `primaryText` (14), `secondaryText` (12); `weightRegular` / `weightMedium` / `weightBold`; `publicSans`
- `Logos.Icons.LogosIcons.*` — `arrowLeft`, `arrowRight`, `refresh`, `install`, `trash`, `more`, `search`, …
**Feedback and contributions**
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.
# ── Step 8: flake.nix ─────────────────────────────────────────────────────────
- title: "`flake.nix`"
step: true
text: |
The template already wires everything up. Update the description and point `calc_module` at your dependency:
steps:
- file:
path: flake.nix
language: nix
content: |
{
description = "Calculator C++ UI plugin for Logos - QML view with process-isolated backend for calc_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder{release}";
# Points at your local calc_module checkout. This is a placeholder —
# you lock it to your actual path in the next step with
# `nix flake update --override-input` (see "Lock and build" below).
calc_module.url = "path:/path/to/your/calc_module";
};
outputs = inputs@{ logos-module-builder, calc_module, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
post_text: |
The `calc_module` input attribute name must match the dependency name in `metadata.json`.
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`.
# ── Step 9: Build and Run ─────────────────────────────────────────────────────
- title: "Build and Run"
step: true
text: |
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)):
steps:
- title: "Ensure `calc_module` is built"
run: "ls ../logos-calc-module/lib/libcalc.{ext}"
code_block: |
ls ../logos-calc-module/lib/libcalc.so # Linux
ls ../logos-calc-module/lib/libcalc.dylib # macOS
post_text: |
If the file is missing, build it first (as covered in [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)):
extra_run:
run: "cd ../logos-calc-module/lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd ../../logos-calc-ui-cpp"
code_block: |
cd ../logos-calc-module/lib
gcc -shared -fPIC -o libcalc.so libcalc.c # Linux
# gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS
cd ../../logos-calc-ui-cpp
- title: "Lock and build"
text: |
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`:
run: "git add -A"
- run: "nix flake update --override-input calc_module path:../logos-calc-module"
code_block: |
nix flake update --override-input calc_module path:../logos-calc-module
- run: "git add flake.lock"
post_text: |
Now that the lock pins the real path, plain `nix run` works — no override needed on subsequent commands:
```bash
nix run
```
- title: "Launch and verify the UI"
text: |
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()`.
ui_test:
launch: "nix run ."
setup:
- "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp"
qt_mcp: "result-mcp"
# `nix build .` (the runner's warm-up) builds the UI plugin, not
# `apps.default`'s standalone-app closure, so that still compiles
# during launch and can blow past the default 120s inspector wait.
launch_timeout: 900
tests:
- name: "App window opens with title"
action: wait_for
texts: ["Logos Calculator (C++ backend)"]
timeout: 30000
- name: "Operation buttons visible"
action: wait_for
texts: ["Add", "Multiply", "Factorial", "Fibonacci"]
timeout: 5000
screenshot: "calc-cpp-buttons.png"
- name: "Enter operands"
action: set_text
find_by: "placeholderText"
find_value: "a"
value: "3"
- name: "Set second operand"
action: set_text
find_by: "placeholderText"
find_value: "b"
value: "5"
- name: "Click Add"
action: click
target: "Add"
- name: "Result of 3 + 5 shows 8"
action: wait_for
texts: ["8"]
timeout: 10000
screenshot: "calc-cpp-result.png"
- name: "Slot-driven READONLY PROP: compute count incremented"
action: wait_for
texts: ["Computations: 1"]
timeout: 5000
- name: "SIGNAL delivered: computed(op, result) reaches the view"
action: wait_for
texts: ["Last op (signal): add = 8"]
timeout: 5000
- name: "Memory register starts at zero"
action: wait_for
texts: ["Memory: 0"]
timeout: 5000
- name: "Write the READWRITE PROP from QML"
action: click
target: "Store (MS)"
- name: "READWRITE PROP round-trips backend → view"
action: wait_for
texts: ["Memory: 8"]
timeout: 10000
- name: "Event label starts empty"
action: wait_for
texts: ["Version event: (none yet)"]
timeout: 5000
- name: "Trigger the versionReady event"
action: click
target: "Announce version (event)"
- name: "Subscribed event payload reaches the view"
action: wait_for
texts: ["Version event: 1.0.0"]
timeout: 10000
screenshot: "calc-cpp-event.png"
post_text: |
Every surface the `.rep` declares is now proven end to end from one click:
- **SLOT return value** — the result `8` comes from `calc_module.add(3, 5)`, the call-and-return path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → `calc_module`), delivered via `logos.watch()`.
- **READONLY PROP (slot-driven)** — `Computations: 1` is the `computeCount` PROP: the same `add` call ran `setComputeCount(...)` in the backend's `record()`, and Qt Remote Objects synced it to the view with no polling.
- **SIGNAL** — `Last op (signal): add = 8` is the `computed` signal the backend `emit`ted; the QML `Connections` block caught it. It carried the op name *and* the result, neither of which is a property or a return value.
- **READWRITE PROP** — `Memory: 0` → `Memory: 8` proves the *write* direction: the Store button assigned `backend.memory = 8` in QML, which pushed to the backend source and synced back to the label. Properties aren't read-only mirrors; QML can drive them too.
- **Event-fed PROP** — `Version event: 1.0.0` is the event path: clicking *Announce version* called `calc_module.libVersionNotify()`, which emitted `versionReady("1.0.0")`; the backend's `modules().calc_module.onVersionReady(...)` subscription — armed in `onContextReady()` — caught it and wrote the `versionEvent` PROP, which Qt Remote Objects synced into the view. The label was `(none yet)` until the event fired, so seeing the version proves the typed subscription delivered.
# ── Step 10: Hot reload (prose only) ─────────────────────────────────────────
- title: "Hot-reloading QML with `nix build .#ui-dev`"
step: true
text: |
For QML iteration, build the dev launcher once. After that, QML edits need no rebuild at all:
```bash
nix build .#ui-dev
./result/bin/run-logos-standalone-ui
```
Run from the repo root and the launcher finds your QML source automatically, then watches it. Edit a `.qml` file, save, and the view re-renders in about 200 ms. It reports what it picked up on startup:
```
run-logos-standalone-ui: hot-reloading QML from /path/to/logos-calc-ui-cpp/src/qml
(export DEV_QML_PATH to override, or LOGOS_QML_HOT_RELOAD=0 to disable)
```
`ui-dev` is the same wrapper `nix run .` uses — dependency modules bundled and loaded identically — exposed as a package so it lands in `./result/bin`. It is a development target and is never bundled into `.lgx` packages.
**What reloads, and what doesn't.**
- **Any `.qml`/`.js` under your view directory**, including files and folders created after launching.
- **The backend keeps running.** A module's C++ backend lives in a separate `ui-host` process, so its state and connections survive a reload.
- **QML-side state resets** — scroll position, text fields, current tab.
- **A syntax error is recoverable.** It's logged with a line number and the view blanks; the next save that compiles restores it.
- **C++, `.rep`, `metadata.json` and CMake changes still need a rebuild.** Re-run `nix build .#ui-dev` and relaunch.
**Why not `nix run .`?** It re-evaluates the flake and rehashes the source tree on every invocation. Since `src = ./.` covers every tracked file including `*.qml`, even a one-character QML edit rebuilds the plugin before the app starts. Building `ui-dev` once avoids that entirely.
> **Custom layouts:** the launcher looks for the `view` entry from `metadata.json` under `src/<viewDir>/`, then `<viewDir>/`. If your tree differs, set `DEV_QML_PATH` to the directory holding the entry file and it takes precedence.
> This does not work with `logos-basecamp`. Basecamp loads QML plugins from its own data directory, so source edits are not reflected until you rebuild and reinstall the `.lgx` package.
- title: "How the Pieces Connect"
step: true
text: |
1. `nix build` → generates the `*Plugin`/`*Interface` glue around your `CalcUiCppBackend`, compiles the C++ plugin + replica factory, bundles QML view
2. `nix run` → launches `logos-standalone-app` which:
- Loads `calc_module` (dependency)
- Spawns a `ui-host` child process with `calc_ui_cpp_plugin.so`
- The generated plugin calls `initLogos()` → wires `modules()` and `onContextReady()` → `setBackend(<your CalcUiCppBackend>)` → `enableRemoting(host)`
- Backend is now accessible over a local socket
3. Host app loads `calc_ui_cpp_replica_factory.dylib` → creates a typed replica
4. QML gets the replica via `logos.module("calc_ui_cpp")`
5. `backend.add(1, 2)` → Qt Remote Objects sends call to ui-host → your backend's `add()` runs `modules().calc_module.add(1, 2)` → returns result
# ── Step 12: UI Integration Tests ─────────────────────────────────────────────
- title: "UI Integration Tests"
step: true
text: |
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`.
Tests connect to the QML inspector inside `logos-standalone-app` and can find elements, click buttons, verify text, and take screenshots.
steps:
- title: "Create a test file"
text: |
Create `tests/ui-tests.mjs`:
file:
path: tests/ui-tests.mjs
language: javascript
content: |
import { resolve } from "node:path";
// CI sets LOGOS_QT_MCP automatically; for interactive use: nix build .#test-framework -o result-mcp
const root =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(
resolve(root, "test-framework/framework.mjs")
);
test("calc_ui_cpp: loads and shows title", async (app) => {
await app.waitFor(
async () => {
await app.expectTexts(["Logos Calculator (C++ backend)"]);
},
{ timeout: 15000, interval: 500, description: "UI to load" },
);
});
test("calc_ui_cpp: operation buttons visible", async (app) => {
await app.expectTexts(["Add", "Multiply", "Factorial", "Fibonacci"]);
});
run();
- title: "Run the tests"
run: "git add tests/"
- run: "nix flake update --override-input calc_module path:../logos-calc-module && nix build .#integration-test -L"
code_block: |
# Hermetic CI test
nix build .#integration-test -L
post_text: |
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):
```bash
nix build .#test-framework -o result-mcp
nix run . # app with inspector on :3768
node tests/ui-tests.mjs # in another terminal
```
# ── Comparison: .rep Interface Patterns (prose only) ──────────────────────────
- title: "Comparison: .rep Interface Patterns"
text: |
You declare each pattern in the `.rep` and implement it in your `*Backend` (which derives the generated `SimpleSource` + `LogosUiPluginContext`). There is no hand-written plugin — the `*Plugin`/`*Interface` glue is generated. Every row but **Model** is live in this tutorial's `.rep` (✓), and the UI test in Step 9 drives each one:
| Pattern | .rep declaration | Backend C++ (`CalcUiCppBackend`) | QML usage |
| -------------------- | ----------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| **Return value** ✓ | `SLOT(int add(int a, int b))` | `int add(...) override { return ...; }` | `logos.watch(backend.add(1,2), cb)` |
| **Void slot** ✓ | `SLOT(void announceVersion())` | `void announceVersion() override { ... }` | `backend.announceVersion()` (fire-and-forget, no `watch`) |
| **READONLY PROP** ✓ | `PROP(int computeCount=0 READONLY)` | `setComputeCount(computeCount()+1)` (inherited setter) | `backend.computeCount` (auto-syncs, read-only) |
| **READWRITE PROP** ✓ | `PROP(int memory=0 READWRITE)` | `setMemory(...)` — or let QML write it | `backend.memory` (read) / `backend.memory = 8` (write, round-trips) |
| **Signal** ✓ | `SIGNAL(computed(QString op, int result))`| `emit computed("add", 8)` | `Connections { target: backend; function onComputed(op, result) {...} }` |
| **Event-fed PROP** ✓ | `PROP(QString versionEvent="" READONLY)` | `onContextReady()`: `modules().dep.on<Event>([this](...){ setVersionEvent(...); })` | `backend.versionEvent` (auto-syncs, no polling) |
| **Model** | (use Q_PROPERTY on backend) | `Q_PROPERTY(QAbstractItemModel* items ...)` | `logos.model("calc_ui_cpp", "items")` |
The last live row uses the `LogosUiPluginContext` surface — Qt-typed `modules()` callers and event subscriptions armed in `onContextReady()`: `versionEvent` is a PROP fed by the `modules().calc_module.onVersionReady(...)` subscription, and the *Announce version* button drives it. **Model** is the one pattern shown but not built here — for a `QAbstractItemModel*` Q_PROPERTY remoted via `logos.model()`, see [Next Steps](#next-steps).
# ── Next Steps (prose only) ───────────────────────────────────────────────────
# ── Intents (prose only) ───────────────────────────────────────────────────
#
# Prose, not executable steps: an intent needs a SECOND app to be worth
# running, and this tutorial is about calc_ui_cpp. The executable two-app
# version lives in logos-basecamp's doctests, where shell behaviour belongs.
- title: "Offering a Capability to Other Apps"
text: |
A `.rep` interface is how *your* QML talks to *your* backend. It is
private — no other app can see it, and that is deliberate.
When you want another app to be able to use something you do, declare an
**intent** instead. Unlike a `.rep` method, an intent is addressed by
capability rather than by app name, so a caller never has to know you
exist.
In `metadata.json` — entries are **objects**, not strings:
```json
"provides": [ { "intent": "calc.evaluate" } ]
```
Handle it in your QML view, exactly like any other signal:
```qml
Connections {
target: logos
function onIntentRequested(requestId, intent, params, requesterName) {
// Your backend is still reached the normal way — via the replica.
// The intent is just how the request arrived.
var result = backend.evaluate(params.expression)
logos.respond(requestId, true, ({ value: result }), "")
}
}
```
Answering later is fine and usually right: show whatever UI you need,
let the user decide, then call `logos.respond`. What you must not do is
declare `provides` and never connect `intentRequested` — the requester
then waits out the deadline and gets `timeout`.
Because `provides` is copied into the signed `.lgx` manifest, a catalog
can see what your package offers before it is installed. `uses` is not
copied: what you can *do* is public, what you want to *call* is not.
Full API and error codes: [Developer Guide](logos-developer-guide.md)
§8.5. App-author walkthrough:
[Intents for App Developers](guide-intents-for-app-developers.md).
- title: "Next Steps"
text: |
- This tutorial already exercises slots, PROPs of different types and modes, and a signal — extend them with more state for your own UI
- Add an **enum** to the `.rep` (`ENUM Status { Idle, Busy }` — no parentheses, unlike `SLOT`/`PROP`/`SIGNAL`) — the factory registers it under the `Logos.<ModuleName>` QML URI, so `import Logos.CalcUiCpp` then `CalcUiCpp.Busy` works in bindings
- Use `logos.model()` for list views backed by a `QAbstractItemModel*` Q_PROPERTY — the **Model** row above, the one `.rep` pattern this tutorial doesn't build
- Package as `.lgx` for distribution: `nix build .#lgx`
- **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`.
- See [logos-package-manager-ui](https://github.com/logos-co/logos-package-manager-ui) for a production example