# RadiantWave — Agent Instructions ## What This Is A kiosk app for RadiantWave wellness technology. Displays scrolling proclamation text (Fibonacci line-length pattern, per-frequency color palettes), plays solfeggio-frequency audio, and runs a first-run provisioning walkthrough on a headless Linux kiosk. Talks to `https://portal.radiantwavetech.com/api/v2`. **Language:** C++17 (no extensions). **Build:** CMake + Ninja. **Graphics:** SDL3 + SDL3_ttf/SDL3_image. **Audio:** SDL3_mixer. **Also links:** SDL3_net, SQLite3, libcurl, OpenSSL, GTest. **Linux-only:** libsystemd (sd-bus) via pkg-config, compiled only when `UNIX AND NOT APPLE` — guarded by `#ifdef __linux__` in the network manager. --- ## Build & Run ```sh cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug cmake --build build # builds the app AND every test binary ./build/radiantwave ``` The Home footer's top "Version" row is baked in at build time. Default it to the project version, or pass your own at configure time: ```sh cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3" ``` The Nix package bakes `-DAPP_VERSION=` via `cmakeFlags` (`version` defaults to `0.1.0` in `nix/package.nix`; the flake overrides it from the `APP_VERSION` env var on impure evals). `nix develop` sets `APP_VERSION=dev`. CMake seeds a *fresh* configure from the `APP_VERSION` env var; `-D` wins on reconfigure. - The app resolves `assets/...` relative to its working directory — launch `./build/radiantwave` from the repo root. (The Nix wrapper chdirs to `$out/share/radiantwave` before exec, which is why the installed app works.) - Development happens on macOS; WiFi/D-Bus code is Linux-only, but `NetworkManager::hasInternet()` (a raw TCP probe) works everywhere, so the provisioning walkthrough does not trap a Mac on the WiFi page. - The app creates a **1920x1080 fullscreen** window with VSync and a hidden cursor. `~/.local/radiantwave/config.yaml` (YAML) and `~/.local/radiantwave/radiantwave.db` (SQLite, WAL) are created at first run. - The root `flake.nix` is canonical: `packages.default`, `devShells.default`, `checks..default` (the package; its `checkPhase` runs the offline test suite), a `nixosModules.default`, and `nix build .#packages.x86_64-linux.test-vm`. `archive/` holds the previous Go implementation — reference only, do not modify. --- ## Tests There is **no ctest integration** and no single "run all" target. Each test file is its own `add_executable` in `CMakeLists.txt`; build them all with `cmake --build build`, then run each `./build/test_*` binary. Two frameworks coexist: - `tests`, `test_assets`, `test_provisioning` — the minimal built-in harness (`tests/test_harness.h`, `TEST(name)` + `RUN_ALL_TESTS()`). - Everything else — GoogleTest (`GTest::gtest_main`). GTest is a hard `find_package` requirement; the build fails without it. Test targets: `tests`, `test_assets`, `test_provisioning`, `test_network_unit`, `test_nm_device`, `test_config`, `test_db`, `test_proclamation_sync`, `test_api_client`, `test_license_service`, `test_license_status`, `test_validation`, `test_date_format`, `test_timezone`, `test_logger`, `test_audio`, `test_audio_downloader`, `test_fibonacci_pattern`, `test_scroller_streams`, `test_fibonacci_scrambler`, `test_color_palettes`. `test_nm_device` covers the pure `parseAccessPoint()` mapping; the sd-bus reads in `WifiDevice::getAccessPoints()` are Linux-only and verified live. Do not wrap the per-AP `ay` SSID property read in `sd_bus_message_enter_container()` before `sd_bus_message_read_array()` — `read_array` enters/exits the array itself, and the double-enter silently empties the scan list (see `src/network/nm_device.cpp`). New test files must be registered as a new `add_executable` target in `CMakeLists.txt` (there is no glob). `test_assets` must pass before committing — it checks the font and image files exist. `test_api_client` and `test_license_service` include "Live" tests that hit the portal unconditionally — they are excluded from the Nix `checkPhase` offline suite and need the `RWT_LIVE_*` secrets to pass. --- ## Runtime data & keys - `AppState` (in `app_state.h`) holds renderer, `PageManager`, `KeybindManager`, `config`, `db`, `network`, `audio`, `audioDownloader`, `licenseTracker`, and provisioning flags. No globals — pass `AppState&` around. - Device identity/state lives in the SQLite `system_kv` table. Canonical keys are in `src/db/db_keys.h`: `email_address`, `license_key`, `license_last_verified` (7-day offline grace window for a known-good license). A successful `validateStoredLicense` is the "subscribed" flag — the license is either active (valid) or inactive; there are no subscription levels. - `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires `onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read from it. - Proclamations carry a flat `category_id` plus a tag-like `group_id` (`proclamation_groups`). The Options page lists every category and, under it, only the groups that have at least one member in that category; ungrouped proclamations sit directly under the category. `runMigrations()` performs a one-time v1→v2 migration (`PRAGMA user_version`) that converts legacy `proclamation_categories.parent_id` sub-categories into groups and remaps their proclamations, preserving selections. No network is needed for it, so offline kiosks migrate on startup. --- ## First-run provisioning walkthrough Fixed prerequisite order (`src/provisioning.h`), evaluated on every boot; the first missing step's page is pushed: 1. Internet reachable? → `WiFiSetupPage` 2. Email address set? → `EmailAddressPage` 3. License key set? → `LicenseKeyPage` 4. Everything satisfied → `Home` `advance()` pops the current step and re-evaluates; internet is only re-probed when it was still missing (cached in `state.walkthroughInternetUp`). A brand-new device (nothing configured) only sets the `firstRunWalkthrough` placeholder flag — the welcome walkthrough is not implemented yet. --- ## Architecture ### Entry point (`src/main.cpp`) SDL3 callback API (`SDL_MAIN_USE_CALLBACKS`): `SDL_AppInit` inits subsystems and calls `provisioning::begin`, `SDL_AppEvent` routes through keybinds then pages, `SDL_AppIterate` updates/renders, `SDL_AppQuit` tears down. ### Pages & navigation `Page` lifecycle (`src/page.h`): `onPush`/`onResume` create textures, `onPause` destroys them, `onPop` final-cleans. `PageManager` is a stack (`push`/`pop`/`popToRoot`/`replace`). Home and the provisioning steps use `replace()`-style flows; only settings sub-pages stack deeply. TAB pops the stack and pushes a fresh Home. Existing pages: Home, Settings, WiFiSetupPage, EmailAddressPage, LicenseKeyPage, LicenseBlockedPage, AudioSettingsPage, ProclamationOptionsPage, ScrollerPage, LogViewerPage, TimezonePage, DateFormatPage. ### Keybinds (`src/keybind_manager.cpp`) Binds are checked **before** the active page sees events. Left/right modifier pairs are normalized to combined masks, so a bind's modifiers must be a subset of the held modifiers (plain binds fire regardless of modifiers). Global binds in `main.cpp`: **TAB** returns Home, **UP/DOWN** move the Home menu selection. There is no quit keybind — the app is a kiosk. App quit (SDL window/system close) fades audio out over ~5s before teardown. Note: Home's UP/DOWN handlers both increment `currentMenuItem` `(x+1)%2` (see `src/home.cpp`) — there is no separate "down" path. ### ScrollerPage Full-screen vertical scroller. Proclamation streams come from the DB (`scroller_streams.cpp`), colors from per-frequency palettes with a 528 Hz fallback, lines built from a Fibonacci length pattern (`fibonacci_line_pattern.cpp`). Audio is crossfaded per stream via `AudioManager` fade APIs. Word textures are cached and page text rendering is cached. --- ## Portal API & color palettes - Request/response changes against the portal are tracked in `ApiUpdates.md` (sections #4 and #5). - **Color palettes are meant to come from the portal, not code releases.** The client-side pieces are implemented but deliberately **not wired into any sync or startup flow** until the portal routes exist: - `api::fetchColorPalettes()` / `api::parseColorPalettesJson()` — the `GET /color-palettes` route. - `sync::syncColorPalettesFromApi()` / `sync::syncProclamationPalettesFromApi()` / `sync::parseProclamationPalettesJson()` — replace the local `color_palettes` table via `db::replaceColorPalettes()`. - `db::getPaletteForFrequencyId()` falls back to the 528 Hz palette for frequency ids with no rows; the startup seed only backfills missing ids, so portal values win after a sync. - Do **not** call the above sync functions from the app until the portal routes ship. Tests live in `tests/test_color_palettes.cpp` (`test_color_palettes` target). - **Intended wiring points** (marked with `TODO(portal #4/#5)` comments): `syncProclamationsFromApi()` in `src/network/proclamation_sync.cpp`, plus the call sites `Home::syncProclamationsIfEmpty()` (`src/home.cpp`) and `ProclamationOptionsPage::onPush()` (`src/proclamation_options_page.cpp`). --- ## Style & UI Layout Rules - `PascalCase` for types, `camelCase` for functions/vars, `snake_case` for files, `UPPER_SNAKE` for constants/macros. - Use `std::unique_ptr`; no raw `new`/`delete` outside the SDL callbacks. - Use `override` on all virtual implementations. `#pragma once` headers. - One class per header/source pair. SDL3 headers first, then stdlib, then project headers. ### UI Menu & List Layout Conventions - **Left Alignment & Centered Bounding Box**: In vertical menus/lists, all text options are left-aligned with each other (`sharedX`). The bounding box enclosing all options (width = widest item, height = sum of item heights + margins) MUST BE centered on the screen. - **Vertical Spacing**: All margins between consecutive list items are **12 pixels**. - **Selection Underline**: The 6-pixel underline occupies half the 12px margin (2-3px gap below the item text, 3-4px gap above the next item). --- ## Common Pitfalls - Do not commit audio assets (`.wav` files) — downloaded at runtime. - Do not modify files in `archive/` — reference only. - Build needs SDL3 + SDL3_ttf/image/mixer/net + GTest installed; CI uses Gitea Actions on a self-hosted host runner (`build-server`, no docker). The workflow lives in `.gitea/workflows/build.yml` and is **manual-only** (`workflow_dispatch`); pick the branch when dispatching. It runs `nix build -L .#checks..default` (build + the offline test suite; `checks` is keyed per-system, so the workflow resolves the current system via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes in a per-branch `APP_VERSION` — the current git tag on `main`, the current commit hash on `dev`. The flake reads `APP_VERSION` from the environment via `builtins.getEnv`, so the build must pass `--impure` (pure evals get the package defaults). The live portal tests run from `nix develop` when the `RWT_LIVE_*` secrets are set and need `RWT_LIVE_ACCOUNT_EMAIL`, `RWT_LIVE_LICENSE_EMAIL`, and `RWT_LIVE_LICENSE_KEY`. - `test_assets` must pass before committing — it catches missing fonts/images that break the app at runtime. - `cmake --build build` builds everything; pass `--target ` to build one test binary.