This file provides guidance for AI agents (GitHub Copilot, Cursor, Claude, etc.) and new contributors working on the LD-Lab project.
LD-Lab is a desktop Ladder Diagram (LD) simulator for learning, testing, and analyzing PLC programming logic — without requiring physical hardware.
- Organization: KN Algo @ Wrocław University of Science and Technology (PWr)
- License: GPLv3
- Status: Active development
LD-Lab uses a hybrid desktop architecture:
┌─────────────────────────────────────────────┐
│ Desktop Window │
│ ┌───────────────────────────────────────┐ │
│ │ WebView │ │
│ │ React 19 + TypeScript + Tailwind │ │
│ └──────────────┬──────────────┬─────────┘ │
│ │ Saucer RPC │ Binary │
│ │ (expose/call)│ Streaming │
│ ┌──────────────┴──────────────┴─────────┐ │
│ │ C++23 Backend │ │
│ │ VariableTable · ApiRegistry · ... │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Key components:
| Layer | Technology |
|---|---|
| Desktop shell | Saucer v8.0.4 (WebView2 on Windows) |
| Backend language | C++23, CMake ≥ 3.25 |
| Frontend language | TypeScript 5.9, React 19, Vite 8 |
| UI library | Tailwind CSS v4 + shadcn/ui (new-york) + Radix UI |
| Icons | Lucide React |
| Routing | React Router 7 |
| Dependency manager (C++) | CPM (CMake Package Manager) |
There are two communication channels:
- Saucer RPC – request/response calls from the frontend to C++ functions, registered with
webview.expose(...). Used for one-off operations (e.g., create variable, get value). - Binary Streaming – C++ pushes real-time variable updates to the frontend via Base64-encoded binary frames. The frontend receives them via
window.binaryUpdate(data). Used for real-time signal monitoring.
LD-Lab/
├── CMakeLists.txt # Main build config (Saucer, C++23, CPM)
├── AGENTS.md # This file
├── README.md
├── src/
│ ├── main.cpp # Entry point: window, webview, streaming setup
│ └── api/
│ ├── ApiRegistry.cpp # Registers all API modules with the webview
│ ├── VariableTable.cpp # Thread-safe variable store (singleton)
│ ├── VariableInitializer.cpp # Demo variable setup
│ ├── VariableUpdater.cpp # Background service: periodically updates variables
│ ├── examples/ # Example API modules (Greeter, Adder)
│ └── streaming/ # Binary streaming engine
│ ├── BatchQueue.cpp
│ ├── BinaryProtocol.cpp
│ ├── DeltaTracker.cpp
│ └── StreamingApi.cpp
├── include/
│ └── api/
│ ├── ApiRegistry.h
│ ├── VariableTable.h
│ ├── VariableInitializer.h
│ ├── VariableUpdater.h
│ ├── examples/
│ └── streaming/
│ ├── BatchQueue.h
│ ├── BinaryProtocol.h
│ ├── DeltaTracker.h
│ └── StreamingApi.h
├── frontend/
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ ├── context/
│ │ │ └── VariableContext.tsx # React context for variable state
│ │ ├── features/
│ │ │ └── cpp-api/
│ │ │ └── api/ # Hooks wrapping C++ API calls
│ │ │ ├── index.ts # Barrel export
│ │ │ ├── use-greeter.ts
│ │ │ ├── use-variable-control.ts
│ │ │ ├── use-variable-push.ts
│ │ │ └── use-variable-subscription.ts
│ │ └── lib/
│ │ └── api-client.ts # Singleton Saucer RPC client
│ ├── package.json
│ ├── vite.config.ts
│ └── components.json # shadcn/ui config
├── docs/
│ ├── Project-Setup/README.md # Dev environment setup guide
│ └── Creating-API/README.md # How to add new API modules
├── build/ # CMake output — DO NOT commit
└── embedded/ # Auto-generated by `saucer_embed` — DO NOT commit
Windows:
- Visual Studio 2022/2026 with Desktop development with C++ workload (MSVC v143)
- Node.js ≥ 24
- CMake ≥ 3.25
- VS Code with extensions: CMake Tools and C/C++
Linux/macOS:
- G++ ≥ 14 or Clang ≥ 17
- Node.js ≥ 24
- CMake ≥ 3.25
- VS Code with extensions: CMake Tools and C/C++
Frontend changes are reflected instantly. C++ changes require a rebuild.
Step 1 – start the frontend dev server:
cd frontend
npm install
npm run build # required first time only
npm run dev # starts Vite at http://localhost:5173Step 2 – build and run the C++ app:
- Open the root folder in VS Code.
Ctrl+Shift+P→ CMake: Select Variant → DebugCtrl+Shift+P→ CMake: Configure- Press F7 to build.
- Press Shift+F5 to run.
The app window loads http://localhost:5173. Press F12 for DevTools.
# Step 1: build the frontend
cd frontend
npm run build # outputs to frontend/dist/
# Step 2: rebuild C++ in Release mode
# In VS Code: CMake: Select Variant → Release
# → CMake: Configure → CMake: BuildThe output binary LD-Lab (or LD-Lab.exe) will be in build/. It is fully self-contained — no Node.js or separate server required.
A thread-safe singleton (include/api/VariableTable.h) that stores all simulation variables.
- Supports types:
BOOL,INT,FLOAT - Values are
std::variant<bool, int, float> - Observer pattern: register callbacks with
subscribe()— called on every value change - Use
VariableTable::getInstance()to access
src/api/ApiRegistry.cpp calls registerApi() on all modules. Any new API module must be registered here.
VariableTable change → DeltaTracker (detect changes) → BatchQueue (buffer)
→ flush every N ms → BinaryEncoder → Base64
→ webview.execute("window.binaryUpdate(...)")
→ frontend decodes binary frame → React state update
Binary frame format (BinaryProtocol.h):
- Header (8 bytes):
MessageType | reserved | count (u16) | timestamp (u32) - Entries (variable length):
[1-byte name length][name][type byte][8-byte double value]
Full guide: docs/Creating-API/README.md
- Create a header at
include/api/<module>/MyModule.h:
#pragma once
#include <saucer/smartview.hpp>
class MyModule {
public:
static void registerApi(saucer::smartview& webview);
};- Create an implementation at
src/api/<module>/MyModule.cpp:
#include "api/<module>/MyModule.h"
void MyModule::registerApi(saucer::smartview& webview) {
webview.expose("my_function", [](int value) {
return value * 2;
});
}- Register in
src/api/ApiRegistry.cpp:
#include "api/<module>/MyModule.h"
void api::registerAll(saucer::smartview& webview) {
// ...existing modules...
MyModule::registerApi(webview);
}- Create a hook at
frontend/src/features/cpp-api/api/use-my-function.ts:
import { useState, useCallback } from "react";
import { apiClient } from "@/lib/api-client";
export const useMyFunction = () => {
const [result, setResult] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const call = useCallback(async (value: number) => {
setLoading(true);
setError(null);
try {
const res = await apiClient.call<number>("my_function", [value]);
setResult(res);
return res;
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
throw err;
} finally {
setLoading(false);
}
}, []);
return { result, loading, error, call };
};- Export from the barrel
frontend/src/features/cpp-api/api/index.ts:
export * from "./use-my-function";- Standard: C++23 (
std::print,std::expected, coroutines, etc.) - Namespaces: snake_case lowercase (e.g.,
api::,streaming::) - Classes: PascalCase
- Methods/functions: camelCase
- Headers in
include/, implementations insrc/— mirror the same subfolder structure - All public APIs must have a
registerApi(saucer::smartview&)static method - Doxygen-style comments on public methods
- Frontend follows bulletproof-react conventions
- Feature code lives in
features/, shared utilities inlib/ - API hooks always expose
{ result, loading, error, call }(or similar) — never raw promises from components - Use the
apiClientsingleton from@/lib/api-client— never callwindow.__saucer__or similar directly - Component files: PascalCase (
MyComponent.tsx) - Hook files: kebab-case prefixed with
use-(use-my-function.ts) - Path alias
@/maps tofrontend/src/ - shadcn/ui components go in
frontend/src/components/ui/
- Branch naming:
feature/<description>orfix/<description>(kebab-case) - Never commit directly to
main— all changes go through a branch + PR - Commit messages: follow Conventional Commits:
feat:– new featurefix:– bug fixdocs:– documentation onlychore:– build, tooling, dependenciesrefactor:– code change that is neither a fix nor a featurestyle:– formatting, missing semicolons, etc.test:– adding or updating tests
| File / Area | Reason |
|---|---|
CMakeLists.txt |
Changing build configuration can break the entire build |
build/ |
Auto-generated CMake output — never edit manually |
embedded/ |
Auto-generated by saucer_embed — never edit manually |
frontend/dist/ |
Build output — generated by npm run build |
Dependency versions in package.json or FetchContent tags |
Version changes may break API compatibility |
| CI/CD configuration files | Changes affect automated pipelines |
- Match the existing code style and naming conventions in the file being edited.
- Place new API modules in the correct
include/api/+src/api/structure. - Export all new frontend hooks through
features/cpp-api/api/index.ts. - Respect the thread-safety model: always access
VariableTablethrough its public API, never directly touch internal members. - After modifying C++ headers included by multiple
.cppfiles, note that a CMake rebuild is required.
- Commit directly to
main— always work on afeature/orfix/branch. - Modify
CMakeLists.txt, build configs, or CI/CD files without an explicit request. - Change dependency versions (npm or C++ via FetchContent) without a clear reason and user confirmation.
- Introduce breaking changes (renamed/removed API endpoints, changed wire protocol) without providing a migration path and updating documentation.
- Add new libraries unless absolutely necessary — justify the addition and check for security surface area.
- Refactor unrelated code while implementing a feature — solve the stated problem only.
- Embed secrets, API keys, passwords, or tokens in source code.
- Ignore existing conventions (naming, folder structure, hook patterns).
- Make architectural decisions unilaterally — propose and wait for confirmation.
- Hide or obscure changes — every modification must be clearly described and reviewable in the diff.
If a change could affect the build, production environment, dependencies, security, or backward compatibility — stop and ask for confirmation before proceeding.
# Frontend
cd frontend
npm install # install dependencies
npm run dev # start dev server (http://localhost:5173)
npm run build # compile to frontend/dist/
npm run lint # run ESLint
npm run typecheck # run TypeScript compiler without emitting
# CMake (from repo root, or use VS Code CMake Tools)
cmake -B build . # configure
cmake --build build # build (Debug by default)