nix-bundle-lgx 6fb5d5b ("feat: add requirement for icons with manifest
0.4.0") validates the packaged icon straight out of the PNG IHDR: exactly
256x256, required for `type == "ui_qml"`. The tutorials embed a base64
placeholder that is 64x64, so `nix build '.#lgx'` now fails for calc_ui:
ERROR: icon does not match the Logos icon standard.
expected: PNG, exactly 256x256
actual: PNG, 64x64
That one rejection is the whole of the red run (208 passed, 3 failed):
`result-lgx/` is never created, so `Install the UI plugin` fails on a
literal `result-lgx/*.lgx`, and basecamp then launches without the plugin
("No clickable element found with text 'calc_ui'").
Regenerate the placeholder at 256x256, keeping the same solid #2ECC71. It
is now a 1-bit palette PNG rather than RGBA, which makes the embedded blob
shorter than the one it replaces (280 -> 144 base64 chars, 209 -> 106
bytes) despite the 16x pixel count.
The C++ UI tutorial carried the same 64x64 blob and was passing only
because its chain never reaches an `.#lgx` step — fixed here too, before
it surfaces.
Verified by extracting bundle.sh's validator verbatim and running the
tutorials' own `- run:` command through it: the old icon is rejected with
the exact CI error text, the new one passes.
outputs/*.md are hand-maintained (they carry deliberate /0.2.0 flake pins
that the generator does not emit), so the mirrored edits there are by hand
rather than regenerated.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
35 KiB
Tutorial Part 2: Building a QML UI for Your Logos Module
This is Part 2 of the Logos module tutorial series. In Part 1 you wrapped a C library as a Logos core module. Now you'll build a QML user interface that calls that module — first isolated with nix run, then packaged and loaded into logos-basecamp.
What you'll build: A calc_ui QML plugin with input fields and buttons that call calc_module methods (add, multiply, factorial, fibonacci) through the Logos bridge.
What you'll learn:
- How QML UI plugins work in the Logos platform
- The
logos.callModule()bridge that connects QML to core modules - The project structure and metadata for a QML plugin
- How to package and install your UI into
logos-basecamp
Prerequisites
- Completed Part 1 — you have a working
calc_modulewith the shared library built (.soon Linux,.dylibon macOS inlogos-calc-module/lib/) - Nix with flakes enabled (same as Part 1)
- Basic familiarity with QML (Qt's declarative UI language)
How QML UI Plugins Work
Before writing code, let's understand the architecture:
+-------------------+ logos.callModule() +-------------------+
| calc_ui | --------------------------> | calc_module |
| Main.qml (QML) | IPC (Qt Remote Objects) | C++ plugin |
+-------------------+ +-------------------+
^ ^
└──────────────── loaded by ───────────────────────┘
logos-basecamp / logos-standalone-app
Key points:
- No compilation. A QML plugin is just
.qmlfiles and ametadata.json. - Sandboxed. No network access, no filesystem access outside the module directory.
- The
logosbridge is injected by the host. Call core modules withlogos.callModule("module", "method", [args]). - Entry point is defined by the required
"view"field inmetadata.json(for this tutorial it isMain.qml).
Step 1: Scaffold
Create a new directory and initialise it from the QML module template:
mkdir logos-calc-ui && cd logos-calc-ui
nix flake init -t github:logos-co/logos-module-builder/0.2.0#ui-qml
Note: The generated
flake.nixuses an unpinnedlogos-module-builderURL. Replace it with the pinned version shown in Step 4 to ensure reproducible builds.
git init
git add -A
This gives you:
logos-calc-ui/
├── flake.nix # Nix build + nix run support
├── metadata.json # Plugin metadata
└── Main.qml # Your UI (starter template)
Step 2: Update metadata.json
Replace the template contents with your plugin's details. The template may generate an extra nix section — keep it as-is, it's used by the builder:
{
"name": "calc_ui",
"version": "1.0.0",
"description": "Calculator UI - QML frontend for the calc_module",
"type": "ui_qml",
"view": "Main.qml",
"dependencies": ["calc_module"],
"category": "tools",
"icon": "icons/calc.png",
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": [],
"extra_link_libraries": []
}
}
}
Create the icon directory and add a placeholder icon. It must be a PNG that is exactly 256×256 — LGX packaging rejects any other size. The icon is displayed in the logos-basecamp sidebar when the module is loaded:
mkdir -p icons
# Copy any PNG here — or generate a 256×256 placeholder:
echo "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAABlBMVEUuzHEuzHEVOa2oAAAAH0lEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABYOSdlwAAAABJRU5ErkJggg==" | base64 -d > icons/calc.png
The view field tells the host which QML file to load for the UI. The dependencies field tells the host to load calc_module before showing your UI.
Naming convention: Each entry in
dependenciesmust match thenamefield in that module's ownmetadata.json. When adding a dependency as a flake input, the input attribute name must also match the dependency name — e.g., the input must be calledcalc_module. The URL can point anywhere (a localpath:or a remotegithub:repo); the attribute name is how the builder resolves dependencies.
Step 3: Write Main.qml
Replace the starter file with the calculator UI. This demonstrates two communication patterns:
- Direct calls —
logos.callModule()sends a request and returns the result immediately - Event-based —
logos.callModule()fires-and-forgets, the module emits an event, and QML receives it vialogos.onModuleEvent()
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property string result: ""
property string errorText: ""
property string versionFromEvent: ""
// ── Event subscription ────────────────────────────────────
// Subscribe to "versionReady" events pushed from calc_module.
Component.onCompleted: {
if (typeof logos !== "undefined" && logos.onModuleEvent)
logos.onModuleEvent("calc_module", "versionReady")
}
Connections {
target: typeof logos !== "undefined" ? logos : null
function onModuleEventReceived(moduleName, eventName, data) {
if (eventName === "versionReady")
root.versionFromEvent = data[0]
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
// ── Title ──────────────────────────────────────────────
Text {
text: "Logos Calculator"
font.pixelSize: 20
font.weight: Font.DemiBold
color: "#ffffff"
Layout.alignment: Qt.AlignHCenter
}
// ── Pattern 1: Direct call (request -> response) ──────
Text {
text: "Direct calls (logos.callModule -> returns result)"
color: "#8b949e"
font.pixelSize: 12
}
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"
onClicked: callTwoOp("add", inputA.text, inputB.text)
}
Button {
text: "Multiply"
onClicked: callTwoOp("multiply", inputA.text, inputB.text)
}
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputN
placeholderText: "n"
Layout.preferredWidth: 80
validator: IntValidator { bottom: 0 }
}
Button {
text: "Factorial"
onClicked: callOneOp("factorial", inputN.text)
}
Button {
text: "Fibonacci"
onClicked: callOneOp("fibonacci", inputN.text)
}
Button {
text: "libcalc version"
onClicked: callModule("libVersion", [])
}
}
// Direct call result
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
}
}
// ── Pattern 2: Event-based (fire-and-forget -> event) ─
Text {
text: "Event-based (fire-and-forget call -> result via event)"
color: "#8b949e"
font.pixelSize: 12
}
RowLayout {
spacing: 12
Layout.fillWidth: true
Button {
text: "libcalc version (event)"
onClicked: {
if (typeof logos !== "undefined" && logos.callModule)
logos.callModule("calc_module", "libVersionNotify", [])
}
}
}
// Event result
Rectangle {
Layout.fillWidth: true
height: 56
color: "#1a1a2d"
radius: 8
Text {
anchors.centerIn: parent
text: root.versionFromEvent.length > 0
? ("Version (via event): " + root.versionFromEvent)
: "Press the event button — result arrives via event"
color: "#7ab8ff"
font.pixelSize: 15
}
}
Item { Layout.fillHeight: true }
}
// ── Direct call helpers ───────────────────────────────────
function callModule(method, args) {
root.errorText = ""
root.result = ""
if (typeof logos === "undefined" || !logos.callModule) {
root.errorText = "Logos bridge not available"
return
}
root.result = String(logos.callModule("calc_module", method, args))
}
function callTwoOp(method, a, b) {
if (a === "" || b === "") { root.errorText = "Enter values for a and b"; return }
callModule(method, [parseInt(a), parseInt(b)])
}
function callOneOp(method, n) {
if (n === "") { root.errorText = "Enter a value for n"; return }
callModule(method, [parseInt(n)])
}
}
The UI demonstrates two communication patterns:
-
Green section (direct calls):
logos.callModule("calc_module", "libVersion", [])sends a request tocalc_moduleand returns the result synchronously. Simple request/response. -
Blue section (event-based):
logos.callModule("calc_module", "libVersionNotify", [])calls the module but ignores the return value. Instead, the module emits a"versionReady"event, and the QML receives it through thelogos.onModuleEvent()subscription set up inComponent.onCompleted.
On the calc_module side (Part 1), that event is just the versionReady(...) method declared in its logos_events: block — the module's libVersionNotify() calls it. Nothing about the QML changes regardless of how the backend module is written; the bridge only sees the event name and its arguments.
The logos object is injected by the host at runtime.
Step 4: Update flake.nix
The template already has everything wired up. Update the description and add calc_module as a dependency input:
{
description = "Calculator QML UI Plugin for Logos - frontend for calc_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder/0.2.0";
# 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 "Test with nix run" below).
calc_module.url = "path:/path/to/your/calc_module";
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
The input attribute name (calc_module) 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 (the flake is 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 uses the locked path with no override needed.
path:(used here) — a local directory on disk. Best for developingcalc_moduleand its UI side by side, no network.github:— fetchescalc_modulefrom 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_modulemust be built with its shared library (.soon Linux,.dylibon macOS) present inlib/. If the library is missing, the nix build will fail with linker errors. See Part 1, Step 1.5 for build instructions.
mkLogosQmlModule handles everything — it stages QML files, metadata, and icons into a plugin directory, bundles all module dependencies (direct and transitive) from their LGX packages, and automatically wires up apps.default so nix run . launches the UI in a standalone window with all required backend modules self-contained. flakeInputs = inputs passes all inputs so that dependencies declared in metadata.json are resolved automatically.
Step 5: Test with nix run
5.1 UI only (layout preview)
git add -A
nix flake update --override-input calc_module path:../logos-calc-module
git add flake.lock
nix run .
The app opens immediately. No modules are loaded, so clicking buttons shows "Logos bridge not available" — but you can verify the layout and styling look correct.
Step 6: Full functionality (with modules)
The standalone app automatically bundles and loads all module dependencies declared in metadata.json. To test with your local calc_module from Part 1, you first need to make sure it has been built and its shared library (.so on Linux, .dylib on macOS) is present.
6.1 Ensure calc_module is built
Go back to your logos-calc-module directory and verify the shared library exists:
ls ../logos-calc-module/lib/libcalc.so # Linux
ls ../logos-calc-module/lib/libcalc.dylib # macOS
If the file is missing, build it first (as covered in Part 1, Step 1.5):
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
Also make sure the module itself builds successfully:
cd ../logos-calc-module
git add -A
nix build
cd ../logos-calc-ui
The nix build produces result/lib/calc_module_plugin.so (or .dylib), which is the compiled Qt plugin. The lib/libcalc.so (or .dylib) inside the source tree is the underlying C library that gets linked in during the build.
6.2 Option A: Use --override-input (quick, no flake.nix edits)
You can point calc_module at your local checkout for a single command, without touching flake.nix or its lock — handy for a one-off run or when the input is set to a github: URL:
nix run . --override-input calc_module path:../logos-calc-module
This tells nix to resolve the calc_module flake input from your local directory instead of from the remote URL. Any changes you've made to calc_module locally (including the built .so/.dylib in lib/) are picked up immediately — no need to push to GitHub first.
6.3 Option B: Lock path: once, then run normally
If you're iterating on both repos side by side, lock calc_module to your local checkout once. You can't write path:../logos-calc-module directly into flake.nix — Nix evaluates the flake from a sandboxed copy, so a relative .. escapes it and is rejected. Instead, lock it with an --override-input (which resolves to an absolute path and stores it in flake.lock):
nix flake update --override-input calc_module path:../logos-calc-module
git add flake.lock
After that, plain nix run . uses the locked local path — no override needed on each command:
nix run .
Re-run the nix flake update --override-input … line whenever you want to re-point or refresh the lock. Switch to a github: URL in flake.nix when you're ready to pin to a published version.
6.4 Option C: Pin to the remote repo
Once calc_module is published to its own repo (with the .so/.dylib committed in lib/), point the input at it with a github: URL instead of the local path: — e.g. calc_module.url = "github:your-org/your-calc-module";. Then a plain nix run . fetches and builds calc_module from the remote:
nix run .
Important: The remote repo must contain the built
.so/.dylibinlib/(or the nix build must produce it). If the shared library is missing, thecalc_modulebuild will fail with linker errors.
Whichever option you choose, clicking Add, Multiply, Factorial, or Fibonacci now calls the real module.
Step 7: Using the Logos Design System
logos-basecamp (and logos-standalone-app) has logos-design-system on its QML import path. Use its themed components directly — no extra setup in your module.
import Logos.Theme
import Logos.Controls
import Logos.Icons // optional: shared icon assets (LogosIcons.search, .install, .refresh, …)
Why use it
Hardcoding colors, font sizes, or rolling your own button means your module looks subtly different from every other module in basecamp, drifts as the design evolves, and re-implements work the design system already does. Using Logos.Controls + Theme tokens means your module gets the polished look automatically as the design system is updated — no churn on your side.
What's available
Run the storybook to browse every component interactively with live property editors:
cd repos/logos-design-system
nix run # or: ws run logos-design-system
The sidebar splits components into two sections:
- Controls — designed per Figma, production-ready. Use these directly. Examples:
LogosButton,LogosBadge,LogosCheckbox,LogosComboBox,LogosIconButton,LogosPaginator,LogosSearchBar,LogosTabBar/LogosTabButton,LogosTable/LogosTableColumn,LogosText,LogosTextField,LogosToolTip. - Controls (not designed) — placeholders with stable APIs but unstyled visuals. Functional, you can ship with them, and you'll inherit the polished look automatically when each gets its design pass — no QML changes on your side. Examples:
LogosDialog,LogosDrawer,LogosFrame,LogosGroupBox,LogosItemDelegate,LogosMenu,LogosProgressBar,LogosRadioButton,LogosScrollBar/LogosScrollView,LogosSlider,LogosSpinBox,LogosSpinner,LogosStackView,LogosSwitch,LogosTextArea,LogosToolBar.
Each storybook page exposes a designed: true/false flag if you want to see at a glance which it is.
Replace raw Qt controls with Logos equivalents
// Instead of Button:
LogosButton {
text: qsTr("Add")
onClicked: callTwoOp("add", inputA.text, inputB.text)
}
// Instead of TextField:
LogosTextField {
id: inputA
placeholderText: qsTr("a")
}
// Use theme colors instead of hardcoded hex values:
Rectangle {
color: Theme.palette.backgroundSecondary
Text { color: Theme.palette.text }
}
Theme tokens — avoid hardcoding magic numbers
// Palette — Theme.palette.*
// background, backgroundSecondary, backgroundMuted, surface,
// text, textSecondary, textMuted, textTertiary,
// border, borderSubtle, primary, success, warning, error, info, hover, pressed, …
// Spacing — Theme.spacing.*
// tiny, small, medium, large, xlarge, xxlarge,
// radiusSmall, radiusMedium, radiusLarge
// Typography — Theme.typography.*
// pageTitleText (36), titleText (30), panelTitleText (24),
// subtitleText (16), primaryText (14), secondaryText (12),
// weightRegular (400), weightMedium (500), weightBold (700),
// publicSans (font family)
// Icons — Logos.Icons.LogosIcons.*
// arrowLeft, arrowRight, refresh, install, trash, more, search, …
If a token you need is missing, file a feature issue — don't inline a hex literal or a magic number; that just stores up drift.
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: Load in logos-basecamp
8.1 Bundle as LGX packages
Create .lgx packages for both dev and portable variants. Use --out-link to avoid overwriting the result symlink:
# Package calc_module (from Part 1)
cd ../logos-calc-module
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
# Package the QML UI plugin
cd ../logos-calc-ui
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
For more bundling options (standalone bundler syntax, cross-platform packaging), see the Developer Guide — Bundling with nix-bundle-lgx.
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
Re-locking note: earlier steps rebuilt
calc_moduleand droppedresult-lgxlinks inside its directory, so its on-disk contents changed since you first locked it. Becausecalc_moduleis a localpath:input, re-runnix flake update --override-input calc_module path:../logos-calc-modulebefore building so the lock matches the current contents (a stricter Nix otherwise rejects the stale hash).
8.2 Build logos-basecamp
Build the basecamp desktop shell:
nix build 'github:logos-co/logos-basecamp/0.2.0' -o basecamp-result
Basecamp manages its own per-user data directory and preinstalls its bundled modules (main_ui, package_manager, …) from the build. It does not accept --modules-dir / --ui-plugins-dir flags; instead you point it at a data directory with --user-dir (or the LOGOS_USER_DIR env var), and it reads installed core modules from <dir>/modules and UI plugins from <dir>/plugins — exactly the directories lgpm writes to.
For this tutorial we use an explicit data directory, basecamp-data, so the install location is deterministic (no ~/Library/Application Support/Logos/LogosBasecampDev or ~/.local/share/Logos/LogosBasecampDev platform-path guessing).
8.3 Build the lgpm package manager CLI
lgpm installs .lgx packages into a modules/plugins directory:
nix build 'github:logos-co/logos-package-manager/0.2.0#cli' --out-link ./pm
8.4 Create the data directory
Make a fresh, isolated data directory with the modules/ and plugins/ subdirectories basecamp expects:
rm -rf basecamp-data && mkdir -p basecamp-data/modules basecamp-data/plugins
8.5 Install the core module
Install calc_module (the LGX you built above) into the data directory's modules/:
./pm/bin/lgpm --modules-dir basecamp-data/modules install --file ../logos-calc-module/result-lgx/*.lgx
8.6 Install the UI plugin
Install calc_ui into the data directory's plugins/:
./pm/bin/lgpm --ui-plugins-dir basecamp-data/plugins install --file result-lgx/*.lgx
8.7 Launch basecamp and use the calculator
Launch basecamp pointed at that data directory. The calc_ui plugin appears in the sidebar alongside the built-in modules — open it, enter two numbers, and press Add to call calc_module through basecamp:
./basecamp-result/bin/LogosBasecamp --user-dir $PWD/basecamp-data
The doc comments you wrote on calc_module's methods and events in
Part 1 also surface in basecamp. Open Settings → Modules → Core
Modules, then open calc_module's Interface — each method and
event shows its description.
The result 8 comes back from calc_module: pressing Add calls logos.callModule("calc_module", "add", [3, 5]), which basecamp routes to your core module and back to the QML view. Both modules — the calc_module core plugin and the calc_ui view plugin — are loaded from the basecamp-data directory you installed them into.
The Interface screen (Settings → Modules → Core Modules → Interface) lists every method and event with the description from its doc comment — the same docs lm and logoscore module-info showed in Part 1, here in the GUI. Multi-line /// comments render as multiple lines, exactly as written.
The sidebar labels each UI plugin by its name from metadata.json, which is why the tab reads calc_ui.
8.8 Portable basecamp build (optional)
The dev build above depends on nix store paths at runtime. For a self-contained portable build that works without nix:
nix build 'github:logos-co/logos-basecamp/0.2.0#bin-bundle-dir' -o basecamp-portable
# Launch once to preinstall bundled modules
./basecamp-portable/bin/LogosBasecamp
The portable build uses a different data directory (LogosBasecamp instead of LogosBasecampDev). Set BASECAMP_DIR to your platform's path:
# macOS:
BASECAMP_DIR="$HOME/Library/Application Support/Logos/LogosBasecamp"
# Linux:
BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecamp"
Install your modules using the portable .lgx variants:
# Install core module (use portable variant)
./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \
install --file ../logos-calc-module/result-lgx-portable/*.lgx
# Install UI plugin (use portable variant)
./pm/bin/lgpm --ui-plugins-dir "$BASECAMP_DIR/plugins" \
install --file result-lgx-portable/*.lgx
# Launch
./basecamp-portable/bin/LogosBasecamp
Important: Portable basecamp requires portable
.lgxvariants (result-lgx-portable), and the dev build requires dev variants (result-lgx). Mixing them will cause loading failures.
8.9 Install via logos-basecamp UI
Instead of using lgpm on the command line, you can install modules through the basecamp UI:
- Launch
logos-basecamp - Go to Package Manager
- Click Install from file
- Select
../logos-calc-module/result-lgx/*.lgx— installscalc_module - Repeat for
result-lgx/*.lgx— installscalc_ui
A calc_ui tab appears in the sidebar (UI plugins are labelled by their name from metadata.json). Clicking it loads your Main.qml.
8.10 Live reloading with logos-standalone-app
For QML iteration, set DEV_QML_PATH to the directory that contains your view entry file (the basename from metadata.json view must exist under that directory). For this tutorial's layout (view: Main.qml at repo root):
DEV_QML_PATH=$PWD nix run .
When DEV_QML_PATH is set, logos-standalone-app loads QML from your source tree at runtime instead of the installed copy — so edits in Main.qml are picked up on the next relaunch without you having to manually 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_PATHonly kicks in after the build is done; it doesn't shortcut the rebuild itself. - C++ /
.rep/metadata.json/ CMake changes rebuild as normal. - The flake-evaluation overhead on each
nix runis fixed and unavoidable while invoking through nix.
For the absolute fastest loop (no nix involvement after the first build), do the build once and run the resulting binary directly:
# Build once — populates result/ in the nix store
nix build .
# Subsequent runs: invoke the bundled standalone wrapper directly,
# skipping nix entirely. DEV_QML_PATH still redirects QML loading.
DEV_QML_PATH=$PWD ./result/bin/run-logos-standalone-ui
(Adjust the binary name to whatever ls result/bin/ shows on your build.)
Naming: Only
DEV_QML_PATHis honored. Seerepos/logos-standalone-app/README.md.
This does not work with
logos-basecamp. Basecamp loads QML plugins from its own data directory, so changes to your source files are not reflected until you rebuild and reinstall the.lgxpackage.
8.11 Testing without any runtime
You can open Main.qml in any QML viewer (e.g., qml from Qt) to test the layout.
Install
You'll need to have QML and any included modules (QtQuick and submodules Controls, and Layout).
Eg, to simply install on linux (apt package manager):
sudo apt install qml-qt6 qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts
Viewing the QML
The logos bridge won't be available, so clicking buttons will show "Logos bridge not available" -- but you can verify the layout and styling work correctly.
# If you have Qt and included modules installed
# macOS:
qml Main.qml
# Linux:
qml6 Main.qml
Step 9: UI Integration Tests
You can add automated UI tests that verify your QML plugin renders correctly. The test infrastructure is built into logos-module-builder — just add .mjs test files to a tests/ directory and you get nix build .#integration-test for free.
Tests use the logos-qt-mcp test framework, which connects to the QML inspector inside logos-standalone-app and can find elements, click buttons, verify text, and take screenshots.
9.1 Create a test file
Create tests/ui-tests.mjs:
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: loads and shows title", async (app) => {
await app.waitFor(
async () => {
await app.expectTexts(["Logos Calculator"]);
},
{ timeout: 15000, interval: 500, description: "calc_ui to load" },
);
});
test("calc_ui: add button visible", async (app) => {
await app.expectTexts(["Add"]);
});
test("calc_ui: click add shows validation", async (app) => {
await app.click("Add");
await app.waitFor(
async () => {
await app.expectTexts(["Enter values for a and b"]);
},
{ timeout: 5000, interval: 500, description: "validation message to appear" },
);
});
run();
9.2 Run the tests
git add tests/
nix build .#integration-test -L
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/.
You can have multiple test files (e.g., tests/smoke.mjs, tests/interactions.mjs) — they are all discovered and run automatically.
To run tests interactively (against an already-running app):
nix build .#test-framework -o result-mcp
nix run . # start the app with inspector on :3768
node tests/ui-tests.mjs # in another terminal
Known Limitations
QML-to-C++ type coercion
When calling C++ module methods from QML via logos.callModule(), arguments are passed through IPC as QVariant values. The runtime automatically coerces mismatched types to match the target method signature — for example, a double sent from QML will be converted to int if the method expects an integer, and numeric strings will be converted to their numeric types.
This means you can define your module methods with their natural parameter types and calls from QML will work without manual conversion. In the pure-C++ (universal) module from Part 1 that looks like:
// In calc_module_impl.h — plain C++, no Qt. The generator maps
// int64_t onto the wire as int; the runtime coerces QML args to match.
int64_t add(int64_t a, int64_t b);
Note: Type coercion uses
QVariant::convert(), which rounds (not truncates) when convertingdoubletoint— e.g.,3.7becomes4.
QML changes not appearing after rebuild
Qt caches compiled QML on disk. If you update your Main.qml, rebuild and reinstall the .lgx, but the old UI still appears, the cache is stale. Fix by disabling the cache before launching:
QML_DISABLE_DISK_CACHE=1 ./basecamp-result/bin/LogosBasecamp
UI module not loading or basecamp behaving unexpectedly
When switching between portable and dev builds of basecamp, or running multiple basecamp instances, the data directory can get into a bad state (stale modules, mixed variants, corrupted preinstall). Clear it and let basecamp re-preinstall on next launch:
# Remove basecamp's data directory
# macOS:
rm -rf ~/Library/Application\ Support/Logos/LogosBasecampDev
# Linux:
rm -rf ~/.local/share/Logos/LogosBasecampDev
# Relaunch — basecamp will re-preinstall its bundled modules
./basecamp-result/bin/LogosBasecamp
Then reinstall your custom modules.
Recap
| Core Module (Part 1) | QML UI Plugin (Part 2) | |
|---|---|---|
| Language | C++ (plain *_impl class) |
QML / JavaScript |
| Files | *_impl.h/.cpp, CMakeLists.txt, metadata.json |
Main.qml, metadata.json |
| Compilation | Yes (CMake → .so) |
No (file copy) |
metadata.type |
"core" (interface: "universal") |
"ui_qml" |
| Test command | logoscore -D -m ./modules + call |
nix run . |
| Calls other modules | Via LogosModuleContext modules() (C++) |
Via logos.callModule() (JS) |
What's Next
- Add more methods to
calc_moduleand call them from QML - Use Logos Design System styled components for consistent look and feel
- Build a C++ UI module for cases where QML sandboxing is too restrictive — see Developer Guide, Section 7.2




