+ Open-source software for the Naya Create keyboard. No account, no cloud, no external dependencies.
+ Bindings, layers, lighting and modules, read from the keyboard and flashed back to it, all on your computer.
+
+
+Naya has shut down, so its own app, NayaFlow, is no longer maintained, and the keyboards outlive
+it. OpenFlow is an independent rebuild that keeps the Create configurable. It talks to the
+keyboard directly over USB and needs nothing else.
+
+## Highlights
+
+| Feature | What it means for you |
+|---|---|
+| **Read, back up, flash** | Read what the keyboard holds, keep backups (automatic and by hand), and flash your changes back. Nothing is written until you press Flash, and the preview lists anything the keyboard cannot store. |
+| **OneKey: four behaviours per key** | Tap, Hold, Double Tap and Tap + Hold on the same key, with the timing the keyboard uses. |
+| **Layers** | Add layers and move between them with keys that hold, toggle, force or stick a layer. |
+| **LED Map** | Colour every key per layer with a brush, fill and pipette, see the board as the keyboard will light it, and pick a Solid, Breathe, Swirl or Spectrum animation. |
+| **Modules** | Touch, Tune and Track profiles: taps, swipes, the dial and scrolling by finger count, with a module profile per layer. |
+| **Actions by name** | Keys, mouse, media, Bluetooth, lighting and layer actions, plus shortcuts for 150 apps, searchable in one palette. |
+| **Devices** | What each half runs and how charged it is, Bluetooth slots, a split-link check for a half that will not connect, and a read-only check of a half sitting in its bootloader. |
+| **Signed, and it updates itself** | The Windows installer is signed and the macOS app is notarized. OpenFlow asks once whether to look for updates weekly, and installs one only when you click Install, never during a flash. |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
LED Map
+
Modules
+
+
+
+## Held back in this release
+
+OpenFlow can already do more than this release switches on. These parts stay off until each has
+been proven on more keyboards, and the app says so where they appear:
+
+- **Firmware updates for the keyboard and for modules.** You can see what each half and module
+ runs, browse the firmware library and plan an update; writing firmware is held back.
+- **Recovery procedures and the split-link repair's repair step.** Every read-only check works:
+ the split-link check, the repair's plan, and the bootloader check.
+- **Macros are app-only.** You can record and keep them, but the keyboard's firmware has no
+ macro table, so they cannot run on the keyboard yet.
+
+## Install
+
+Download the file for your system from the
+[latest release](https://github.com/create-collective/openflow/releases/latest).
+
+- **Windows 10 or 11:** `OpenFlow--win-x64-setup.exe` installs for your user, no
+ administrator needed. `OpenFlow--win-x64-portable.exe` runs without installing (it
+ cannot update itself). Windows shows the publisher as Travis Wye; SmartScreen may still ask
+ for a few days after a new release while Microsoft's download reputation builds up.
+- **macOS:** `OpenFlow--mac-arm64.dmg` for Apple Silicon, `-mac-x64.dmg` for Intel.
+ Open it and drag OpenFlow into Applications. The app is notarized, so it opens on a
+ double-click.
+- **Linux:** the `.deb` for Debian, Ubuntu and their relatives, the AppImage for everything else.
+ See [Installing on Linux](#installing-on-linux): the keyboard needs a udev rule.
+
+Plug in both halves over USB and press **Read from keyboard**. The keyboard does not have to be
+attached to explore or edit; nothing is written to it until you flash.
+
+**Updates.** The first time the Hub opens, OpenFlow asks whether to check for updates once a week.
+A check downloads one small file from GitHub naming the newest release; nothing about you or your
+keyboard is sent. An update installs only when you click Install, in the Hub or Settings › About,
+and never while a flash is running. The portable build and a `.deb` install show a download link
+instead.
+
+**Report a bug** from inside the app (More › Report a bug), which attaches what it needs, or open
+an [issue](https://github.com/create-collective/openflow/issues/new).
-An open-source configurator for the **Naya Create** keyboard — an independent rebuild of Naya's
-own NayaFlow app, talking to the keyboard over USB CDC.
+## Installing on Linux
+
+Each half of the keyboard is a USB serial port (`/dev/ttyACM*`) that belongs to root and the
+`dialout` group, so a normal user cannot open it until OpenFlow's udev rule
+(`build/linux/70-openflow.rules`) is installed. The rule also keeps ModemManager from probing the
+keyboard, which otherwise collides with its protocol for several seconds after every plug-in.
+
+- **Debian, Ubuntu, Mint, Pop!_OS: use the .deb.** It installs the rule and reloads udev:
+ `sudo apt install ./OpenFlow--linux-amd64.deb`, then start OpenFlow from the menu.
+- **Anything else: use the AppImage** (`OpenFlow--linux-x86_64.AppImage`). `chmod +x`
+ it and run it. The first time, the Hub shows
+ a warning with the three commands that install the rule, and a button to copy them. Run them
+ once; the keyboard appears on the next poll (unplug and replug it if not). The same commands:
+
+```bash
+printf '%s\n' 'ACTION!="remove", SUBSYSTEMS=="usb", ATTRS{idVendor}=="37d1", ENV{ID_MM_DEVICE_IGNORE}="1"' 'ACTION!="remove", SUBSYSTEM=="tty", ATTRS{idVendor}=="37d1", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/70-openflow.rules >/dev/null
+sudo udevadm control --reload-rules
+sudo udevadm trigger --subsystem-match=tty
+```
+
+The rule grants access to whoever is logged in at the machine's own screen (systemd-logind's
+`uaccess`). Over SSH or on a system without logind, add yourself to the group instead and log
+in again: `sudo usermod -aG dialout $USER` (`uucp` on Arch).
-Naya has shut down, so the vendor software is unmaintained and the keyboards outlive it. This
-exists so the hardware stays configurable.
+An AppImage that will not start usually means one of two things. On Ubuntu 22.04 and later
+without `libfuse2`, run it with `--appimage-extract-and-run` or install `libfuse2t64`
+(`libfuse2` on 22.04). On Ubuntu 24.04 and later, AppArmor blocks Electron's sandbox for an
+AppImage; the .deb installs the AppArmor profile that allows it, so prefer the .deb there.
+
+## Where your data lives
-## Layout
+The database is **not** in the repository. It goes to `%APPDATA%/OpenFlow` on Windows,
+`~/Library/Application Support/OpenFlow` on macOS, `$XDG_DATA_HOME/OpenFlow` on Linux, or
+wherever `OPENFLOW_DATA_DIR` points if you set it. So you can throw it away and re-seed freely:
+
+```bash
+OPENFLOW_DATA_DIR=/tmp/openflow-scratch python -m openflow_backend 3001 # seeds itself on first run
+```
+
+Next to `user-data.db` you will find `backups/` (the automatic and manual backups),
+`logs/backend.log` (the backend's own log, rotated) and, when run as the desktop app,
+`logs/sidecar.log` (what the shell captured from the backend process) and `shell/` (Chromium's
+cache and preferences, kept apart from your data on purpose). Uninstalling leaves all of it in
+place.
+
+## Building from source
+
+OpenFlow is an Electron shell around a Python backend (FastAPI) and a React renderer.
+
+### Layout
```
backend/ Python (FastAPI). Device protocol, flash planning, the action catalogue.
@@ -18,7 +170,7 @@ device/ A few captured device reads, used as test fixtures.
No TypeScript; the renderer is plain JSX.
-## Running it
+### Running it
```bash
# backend (needs Python 3.12+)
@@ -43,7 +195,7 @@ and state setters, which render as a blank page rather than a build error.
The backend test suite runs without a keyboard attached:
```bash
-cd backend && python -m pytest -q # 746 pass; runs against a temporary data dir
+cd backend && python -m pytest -q # runs against a temporary data dir
```
The frontend has its own gates: `npm run verify` (undefined-reference check, the style budget in
@@ -56,7 +208,7 @@ Nothing here talks to the keyboard unless you explicitly flash; the app is read-
and it runs perfectly well with no keyboard attached -- which is the expected setup for design
work.
-## Building the desktop app
+### Building the desktop app
The app is the Electron shell in `electron/` around the backend frozen with PyInstaller
(`backend/openflow_backend.spec`) and the built renderer, which the frozen backend serves itself
@@ -99,52 +251,7 @@ needs a glibc at least as new as the build machine's, so the CI build runs on Ub
Debian 12, Fedora 36 and anything newer. A build from a newer machine only runs on that
machine's generation and newer; do not hand one to a tester.
-## Installing on Linux
-
-Each half of the keyboard is a USB serial port (`/dev/ttyACM*`) that belongs to root and the
-`dialout` group, so a normal user cannot open it until OpenFlow's udev rule
-(`build/linux/70-openflow.rules`) is installed. The rule also keeps ModemManager from probing the
-keyboard, which otherwise collides with its protocol for several seconds after every plug-in.
-
-- **Debian, Ubuntu, Mint, Pop!_OS: use the .deb.** It installs the rule and reloads udev:
- `sudo apt install ./OpenFlow--linux-amd64.deb`, then start OpenFlow from the menu.
-- **Anything else: use the AppImage** (`OpenFlow--linux-x86_64.AppImage`). `chmod +x`
- it and run it. The first time, the Hub shows
- a warning with the three commands that install the rule, and a button to copy them. Run them
- once; the keyboard appears on the next poll (unplug and replug it if not). The same commands:
-
-```bash
-printf '%s\n' 'ACTION!="remove", SUBSYSTEMS=="usb", ATTRS{idVendor}=="37d1", ENV{ID_MM_DEVICE_IGNORE}="1"' 'ACTION!="remove", SUBSYSTEM=="tty", ATTRS{idVendor}=="37d1", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/70-openflow.rules >/dev/null
-sudo udevadm control --reload-rules
-sudo udevadm trigger --subsystem-match=tty
-```
-
-The rule grants access to whoever is logged in at the machine's own screen (systemd-logind's
-`uaccess`). Over SSH or on a system without logind, add yourself to the group instead and log
-in again: `sudo usermod -aG dialout $USER` (`uucp` on Arch).
-
-An AppImage that will not start usually means one of two things. On Ubuntu 22.04 and later
-without `libfuse2`, run it with `--appimage-extract-and-run` or install `libfuse2t64`
-(`libfuse2` on 22.04). On Ubuntu 24.04 and later, AppArmor blocks Electron's sandbox for an
-AppImage; the .deb installs the AppArmor profile that allows it, so prefer the .deb there.
-
-## Where your data lives
-
-The database is **not** in the repository. It goes to `%APPDATA%/OpenFlow` on Windows,
-`~/Library/Application Support/OpenFlow` on macOS, `$XDG_DATA_HOME/OpenFlow` on Linux, or
-wherever `OPENFLOW_DATA_DIR` points if you set it. So you can throw it away and re-seed freely:
-
-```bash
-OPENFLOW_DATA_DIR=/tmp/openflow-scratch python -m openflow_backend 3001 # seeds itself on first run
-```
-
-Next to `user-data.db` you will find `backups/` (the automatic and manual backups),
-`logs/backend.log` (the backend's own log, rotated) and, when run as the desktop app,
-`logs/sidecar.log` (what the shell captured from the backend process) and `shell/` (Chromium's
-cache and preferences, kept apart from your data on purpose). Uninstalling leaves all of it in
-place.
-
-## Where the styling lives
+### Where the styling lives
- `frontend/src/styles/` — `app.css` (shell, buttons, tokens) and `editor.css` (the keyboard
board, palette, LED views)
@@ -160,3 +267,7 @@ so a theme is mostly a matter of redefining those.
`docs/reference/ATTRIBUTION.md`, which must travel with it.
`backend/openflow_backend/_vendor/nayactl` is a vendored copy of nayactl.
+
+## License
+
+Apache-2.0; see [LICENSE](LICENSE).
diff --git a/backend/openflow_backend/api/rest.py b/backend/openflow_backend/api/rest.py
index 5aa3b87..025a322 100644
--- a/backend/openflow_backend/api/rest.py
+++ b/backend/openflow_backend/api/rest.py
@@ -216,7 +216,7 @@ async def flash_firmware(body: dict = Body(...)) -> dict:
from the UI."""
if not FIRMWARE_FLASH_ENABLED:
raise HTTPException(status_code=400, detail=(
- "Firmware flashing is wired but disabled until it is verified on a donor unit. "
+ "Firmware flashing is held back in this release (OPENFLOW_ENABLE_FIRMWARE_FLASH). "
"Nothing was sent."))
import os
from pathlib import Path as _P
@@ -272,7 +272,7 @@ async def flash_procedure_run(body: dict = Body(...)) -> dict:
"""
if not FIRMWARE_FLASH_ENABLED:
raise HTTPException(status_code=400, detail=(
- "Firmware flashing is wired but disabled until it is verified on a donor unit. "
+ "Firmware flashing is held back in this release (OPENFLOW_ENABLE_FIRMWARE_FLASH). "
"Nothing was sent."))
import os
from pathlib import Path as _P
@@ -402,7 +402,7 @@ async def flash_module_firmware(body: dict = Body(...)) -> dict:
shipped with the left half's firmware (firmware_upload.require_module_pairing)."""
if not FIRMWARE_FLASH_ENABLED:
raise HTTPException(status_code=400, detail=(
- "Firmware flashing is wired but disabled until it is verified on a donor unit. "
+ "Firmware flashing is held back in this release (OPENFLOW_ENABLE_FIRMWARE_FLASH). "
"Nothing was sent."))
import os
from pathlib import Path as _P
@@ -438,7 +438,7 @@ async def module_flash_procedure_run(body: dict = Body(default={})) -> dict:
"""
if not FIRMWARE_FLASH_ENABLED:
raise HTTPException(status_code=400, detail=(
- "Firmware flashing is wired but disabled until it is verified on a donor unit. "
+ "Firmware flashing is held back in this release (OPENFLOW_ENABLE_FIRMWARE_FLASH). "
"Nothing was sent."))
from ..config import firmware_dir, logs_dir
from ..device import flash_runs as runs
@@ -491,7 +491,14 @@ async def recovery_read() -> dict:
"""What a half sitting in MCUboot recovery is running, matched against the catalogue, plus the
bootloader's slot map (`image slot info`) when it answers one. Reads only; opens the recovery
port for two SMP reads and closes it. This is the read that pins how uploads are addressed
- (work-queue step 3): expect `slotInfo.slots[*].uploadImageId` and a 1048576-byte slot."""
+ (work-queue step 3): expect `slotInfo.slots[*].uploadImageId` and a 1048576-byte slot.
+
+ The Information page offers it as "Check what it runs" on its bootloader banner (owner's
+ call, 2026-10-09: read-only checks are on in the first public build). Refused while a flash
+ runs: the flasher holds the same bootloader port, and a read must never land in its stream."""
+ from ..device import flash_runs as runs
+ if runs.active():
+ raise HTTPException(status_code=409, detail="A firmware flash is running; check again once it has finished.")
return await run_in_threadpool(recovery_mod.read_running_image, _firmware_catalog_raw())
@@ -1663,12 +1670,6 @@ async def split_link_check() -> dict:
raise HTTPException(status_code=400, detail=str(e))
-@router.post("/rpc/check-for-updates")
-async def check_for_updates() -> dict:
- # OpenFlow has no external update dependency. Always report up to date.
- return {"updateAvailable": False, "reason": "OpenFlow has no external update source"}
-
-
@router.get("/api/update-safe")
async def update_safe() -> dict:
"""Whether OpenFlow may close now to install an update of itself.
diff --git a/backend/openflow_backend/api/sse.py b/backend/openflow_backend/api/sse.py
index 8c6de13..3860544 100644
--- a/backend/openflow_backend/api/sse.py
+++ b/backend/openflow_backend/api/sse.py
@@ -3,9 +3,11 @@
NayaFlow's renderer opens one EventSource at `/sse` and listens for named events:
sse:naya-devices-stream, sse:flash-keymap-state,
sse:device-operation-options, sse:ui-state-change, sse:main-process-quit
-We keep those names so the rebuilt renderer's SSE hooks match. Phase 1 drives the
-device-list stream from a cheap USB-enumeration poll (no serial handshake); the
-flash-keymap / operation-options streams are placeholders until Phase 2/3.
+We keep those names so the rebuilt renderer's SSE hooks match. The device-list stream
+comes from a cheap USB-enumeration poll (no serial handshake). Three of NayaFlow's names are
+never sent here: sse:flash-keymap-state (a keymap flash answers its own request),
+sse:device-operation-options and sse:main-process-quit (the shell stops the backend over
+/rpc/shutdown instead).
`sse:flash-progress` is ours, added for SCRUM-102: a firmware run's steps as they happen.
diff --git a/backend/openflow_backend/db/settings.py b/backend/openflow_backend/db/settings.py
index 85493e0..648606c 100644
--- a/backend/openflow_backend/db/settings.py
+++ b/backend/openflow_backend/db/settings.py
@@ -141,12 +141,11 @@
"provenance": APP_ONLY},
# Input Source (keyboard-legend layout) moved to a dropdown on the virtual keyboard
# itself, where it is actually applied -- it did nothing as a global preference here.
+ # Hidden (owner's call, 2026-10-09): the desktop app has no tray yet, so the switch did
+ # nothing anywhere. Kept in the schema so a stored value survives until the tray exists.
{"id": "tray_battery", "label": "Show Battery in Tray", "kind": "toggle",
- "desc": "Show battery status in the system tray / menu bar. Available in the desktop "
- "app; the tray does not exist in the browser build.", "default": False,
- "provenance": APP_ONLY,
- "deferred": "Ships with the desktop (Electron) app -- there is no system tray to draw "
- "into from the browser dev build.", "deferred_badge": "desktop app"},
+ "desc": "Show battery status in the system tray / menu bar.", "default": False,
+ "provenance": APP_ONLY, "hidden": True},
{"id": "interface_scaling", "label": "Interface Scaling", "kind": "slider",
"desc": "Zoom the interface. 0 = default.",
"min": -500, "max": 500, "default": 0, "unit": "", "provenance": APP_ONLY},
diff --git a/backend/openflow_backend/device/actions_catalog.py b/backend/openflow_backend/device/actions_catalog.py
index 9b38f46..134b758 100644
--- a/backend/openflow_backend/device/actions_catalog.py
+++ b/backend/openflow_backend/device/actions_catalog.py
@@ -9,7 +9,7 @@
Action *codes* are functional facts (must match firmware/DB); labels are OpenFlow's
own (clean reskin). `actionType` matches NayaFlow's key_bindings.action_type so data
-round-trips. Some actions are marked comingSoon (present for parity, not yet wired).
+round-trips.
"""
from __future__ import annotations
@@ -33,14 +33,7 @@
}
# Behavior slots (the "OneKey" multi-behavior-per-key feature). "Tap" is the
-# primary (stored as 'tap'; legacy 'press' reads as 'tap'). Tap/Hold/Double Tap/
-# Tap+Hold are editable and stored offline in the DB; flashing them to the device
-# waits on the REMAP layer-data codec. Double Tap+Hold stays experimental — it may
-# exceed Naya's firmware and belongs to the fully-open OneKey firmware track.
-# Tap and Hold are the only slots Naya's firmware actually stores + honors. A live
-# read + reflash test (2026-09-01) proved NayaFlow drops Double Tap and Tap + Hold on
-# flash — they're half-finished firmware slots — so they're disabled + flagged
-# experimental like Double Tap + Hold until the open OneKey firmware lands.
+# primary (stored as 'tap'; legacy 'press' reads as 'tap').
# A key holds FOUR behaviours, stored as TWO hold-tap records 0x52 apart: the primary bank is
# tap + hold, the secondary bank is double-tap + tap+hold. Confirmed live 2026-09-03 by capturing
# NayaFlow writing both records and then pressing the key (b / zzz / x / yyy). Read, write and
@@ -57,7 +50,7 @@
]
-def _a(code, label, action_type="key", coming_soon=False, name=None):
+def _a(code, label, action_type="key", name=None):
"""One palette entry.
`label` is the keycap legend and stays terse -- a palette button is a 44px grid cell that
@@ -65,8 +58,6 @@ def _a(code, label, action_type="key", coming_soon=False, name=None):
tooltips and for anywhere the value is shown as prose rather than on a key.
"""
d = {"code": code, "label": label, "actionType": action_type}
- if coming_soon:
- d["comingSoon"] = True
if name:
d["name"] = name
return d
diff --git a/backend/openflow_backend/device/commands.py b/backend/openflow_backend/device/commands.py
index 2a2ee66..22cc014 100644
--- a/backend/openflow_backend/device/commands.py
+++ b/backend/openflow_backend/device/commands.py
@@ -26,20 +26,24 @@ def dispatch(svc: DeviceService, event: str, frames: list, *, side: str = "left"
# because it can wipe staged firmware + on-device keymaps.
if not force:
return {"event": event, "status": "test-only", **svc.spi_flash_test(side)}
- raise CommandError("Destructive flash repair not yet wired (needs force + confirm flow)")
+ raise CommandError("The destructive flash repair (format partition / erase chip) is a recovery "
+ "procedure, held back in this release")
if event == "clear_ble_devices":
return {"event": event, **svc.clear_ble_devices(side, force=force)}
if event == "clear_data":
- # REMAP CLEAR ALL DATA — Phase 2 (destructive keymap wipe).
- raise CommandError("clear_data (REMAP CLEAR_ALL_DATA) is Phase 2 work")
+ # REMAP CLEAR ALL DATA: the "clear all keymap data" recovery procedure (recovery_ops).
+ raise CommandError("clear_data is the 'clear all keymap data' recovery procedure, held back "
+ "in this release")
if event == "create_pairing_start":
- raise CommandError("create_pairing_start (pairing workflow) is Phase 2 work")
+ raise CommandError("create_pairing_start: pairing runs through the guided split-link repair "
+ "(Settings › Troubleshooting)")
if event in ("update_create_fw", "update_module_fw"):
- # Phase 3 — blocked on obtaining a firmware image (NayaOS open problem).
- raise CommandError(f"{event} (firmware update) is blocked until a firmware image is obtained")
+ # Firmware updates run as a procedure with a plan and an arm token (/rpc/flash-procedure,
+ # /rpc/flash-module-procedure), never as a fire-and-forget command.
+ raise CommandError(f"{event}: firmware updates run from Settings › Firmware, not as a command")
raise CommandError(f"unknown command event: {event}")
diff --git a/backend/openflow_backend/device/firmware_fetch.py b/backend/openflow_backend/device/firmware_fetch.py
index d89e6fc..99869e0 100644
--- a/backend/openflow_backend/device/firmware_fetch.py
+++ b/backend/openflow_backend/device/firmware_fetch.py
@@ -17,11 +17,10 @@
every NayaFlow installer this project has unpacked was kept. The layout there is exactly the
catalogue's `historyPath`: /v1.25.1/kb_fwl.bin.
-That archive is PRIVATE as of 2026-09-21, so an unauthenticated fetch gets a 404 dressed up as
-"not found" rather than "not allowed" -- GitHub hides private repositories rather than admitting
-to them. `OPENFLOW_FIRMWARE_TOKEN` (or GITHUB_TOKEN) is sent as a bearer token when set, which is
-how the owner fetches from it today. Until the source is public, or a public mirror exists, this
-does nothing for anybody else, and the UI says so rather than reporting a mysterious 404. Nothing
+That archive is public (since 2026-09-21), so a plain fetch works. `OPENFLOW_FIRMWARE_TOKEN` (or
+GITHUB_TOKEN) is still sent as a bearer token when set, for a private mirror; GitHub answers a
+private repository it will not admit to with a 404 dressed up as "not found", and the UI says so
+rather than reporting a mysterious 404. Nothing
here reads or stores a token beyond passing it to the one host it was configured for.
CUSTOM FIRMWARE, LATER. The plan is a community-signed bootloader and an open firmware, and at
@@ -84,10 +83,10 @@ def holdings(catalog: list, dest: Path) -> list[dict]:
"""Every catalogued image that IS a file, and whether this machine has it.
Downloadable is not the same question as flashable, and conflating them was wrong twice over.
- The module bundles and the dongle image are withheld from FLASHING until the path is proven on
- a donor unit -- that is a statement about writing to a keyboard, not about keeping a copy of a
- file -- and two pre-production keyboard images are withheld because nobody can say which side
- or generation they are. All of them are part of the archive this library exists to mirror, and
+ Some images are withheld from FLASHING -- the dongle image (no hardware to prove it on), the
+ module apps that are not shipping modules, and pre-production keyboard images nobody can place
+ by side or generation -- and that is a statement about writing to a keyboard, not about keeping
+ a copy of a file. All of them are part of the archive this library exists to mirror, and
refusing to download them left "a ton of firmware history" on screen with nine of its files
unobtainable and no reason given. `flashable` is carried per row instead, so the page can say
which is which.
diff --git a/backend/openflow_backend/device/firmware_upload.py b/backend/openflow_backend/device/firmware_upload.py
index e96ca12..3629aef 100644
--- a/backend/openflow_backend/device/firmware_upload.py
+++ b/backend/openflow_backend/device/firmware_upload.py
@@ -1106,7 +1106,8 @@ def flash_module_bundle(image_path: str | Path, catalog: list, *, arm: str,
the procedure's wait, which only ever resets a half it can see still sitting there.
GATED: reached only through the FIRMWARE_FLASH_ENABLED endpoints. The upload shape matches the
- vendor's capture; this function itself has not yet run on hardware.
+ vendor's capture, and module flashes through module_procedure, which uploads with this
+ function, have run on hardware since 2026-09-23 (a Track downgrade among them).
"""
if state is None:
state = rec.read_running_image(catalog)
diff --git a/backend/openflow_backend/device/flash.py b/backend/openflow_backend/device/flash.py
index 678a66e..0b7917e 100644
--- a/backend/openflow_backend/device/flash.py
+++ b/backend/openflow_backend/device/flash.py
@@ -320,8 +320,6 @@ def desired_from_db(conn, profile_id: str | None = None) -> DesiredState:
# that is what decides the record -- not the code. Anything unlisted still reports, with the
# type named, rather than falling through to silence.
_DROP_REASONS = {
- "none": "Disabled cannot be written yet.",
- "trans": "Transparent cannot be written yet.",
"out": "Only Wireless (BT_OUT) and USB-C (USB_DEVICE) have a known output record.",
"LED": "Not one of the LED actions NayaCore defines (its 19 are all written).",
"macro": "The keyboard reserves a macro type but implements no macro table, so a macro "
@@ -332,7 +330,8 @@ def desired_from_db(conn, profile_id: str | None = None) -> DesiredState:
# Layer switches DO have encoders. When one of these is dropped it is never the type that is
# unsupported -- it is the layer it points AT that could not be resolved.
-_LAYER_TYPES_WITH_ENCODERS = ("layer_polite_hold", "layer_rude_toggle", "layer_polite_toggle")
+_LAYER_TYPES_WITH_ENCODERS = ("layer_polite_hold", "layer_rude_toggle", "layer_polite_toggle",
+ "layer_polite_oneshot")
def _keypress(action_type: str | None, code: str | None) -> bytes:
@@ -491,7 +490,7 @@ def _half(row: dict | None, layer_order: dict) -> tuple[int, bytes] | None:
if row is None:
return R.NONE_BEH, b""
rec = _record_for(row, layer_order)
- if rec is None and row["at"] in (*_LAYER_TYPES_WITH_ENCODERS, "layer_polite_oneshot"):
+ if rec is None and row["at"] in _LAYER_TYPES_WITH_ENCODERS:
# Said outright, as a layer-tap's hold always has been (SCRUM-110): the layer is gone,
# there is no index to write.
raise R.RemapEncodeError(f"{_behaviour(row)} layer {row['ac']!r} is not in this profile")
diff --git a/backend/tests/route-manifest.json b/backend/tests/route-manifest.json
index a70c6cc..19973c8 100644
--- a/backend/tests/route-manifest.json
+++ b/backend/tests/route-manifest.json
@@ -46,7 +46,6 @@
"MOUNT /",
"POST /rpc/add-macro-step",
"POST /rpc/add-macro-steps",
- "POST /rpc/check-for-updates",
"POST /rpc/clear-ble-profile",
"POST /rpc/clear-device-log",
"POST /rpc/clear-key-binding",
diff --git a/backend/tests/smoke.py b/backend/tests/smoke.py
index f9a865c..f1ee5a2 100644
--- a/backend/tests/smoke.py
+++ b/backend/tests/smoke.py
@@ -23,8 +23,6 @@
for path in ["/health", "/api/info/system", "/api/ui/state", "/api/devices", "/api/status"]:
r = client.get(path)
print(f"GET {path} -> {r.status_code} {r.json()}")
- r = client.post("/rpc/check-for-updates")
- print(f"POST /rpc/check-for-updates -> {r.status_code} {r.json()}")
r = client.post("/rpc/send-nayacore-zmq-message",
json={"messages": ["command", "update_create_fw"]})
print(f"POST send-zmq update_create_fw -> {r.status_code} {r.json()}")
diff --git a/backend/tests/test_dropped_bindings.py b/backend/tests/test_dropped_bindings.py
index b5c7c3a..acee1ac 100644
--- a/backend/tests/test_dropped_bindings.py
+++ b/backend/tests/test_dropped_bindings.py
@@ -39,9 +39,9 @@ def test_an_encodable_binding_produces_no_drop():
def test_every_known_unencodable_type_has_a_reason_a_user_can_act_on():
"""Not a generic 'unsupported'. Each says what is actually wrong."""
+ # Disabled and Transparent are not here: both are written (NONE and TRANS records in
+ # flash._record_for), so neither can drop.
for at, code, expect in [
- ("none", "DISABLE", "Disabled"),
- ("trans", "TRANSPARENT", "Transparent"),
# BT_OUT / USB_DEVICE are written since 2026-09-09; only an unknown output drops.
("out", "BT_SOMETHING_ELSE", "Wireless"),
("macro", "some-uuid", "macro table"),
diff --git a/docs/api-contract.md b/docs/api-contract.md
index e001385..d1f9d82 100644
--- a/docs/api-contract.md
+++ b/docs/api-contract.md
@@ -26,7 +26,6 @@ back to `3001`. The Electron preload sets `window.EXPOSED.bgServerPort` from arg
| POST | `/rpc/led` | LED on/off/toggle/brightness/effect (OpenFlow-added convenience). |
| POST | `/rpc/text-command` | ASCII text protocol passthrough. |
| POST | `/rpc/dump-settings` | `dump_settings` text command. |
-| POST | `/rpc/check-for-updates` | Always "up to date" — no external update source. |
### `/rpc/send-nayacore-zmq-message` command events
@@ -50,10 +49,10 @@ One `EventSource`; named events (renderer uses `addEventListener`):
|---|---|---|
| `sse:ui-state-change` | 1 | `{ready}` |
| `sse:naya-devices-stream` | 1 | `{devices:[...]}` — emitted on change (2s USB poll). |
-| `sse:flash-keymap-state` | 2 | keymap flash progress (placeholder). |
+| `sse:flash-keymap-state` | not sent | NayaFlow's keymap flash progress; a keymap flash answers its own request here. |
| `sse:flash-progress` | — | Ours, not NayaFlow's: a firmware run's steps as they happen (SCRUM-102). `{id, running, sides, seq, events:[...], verdict}`, with only the events after the last `seq` sent. While a run is going the device poll is suspended — it would queue behind the run's service lock — and the tick becomes this one. |
-| `sse:device-operation-options` | 2 | device op options (placeholder). |
-| `sse:main-process-quit` | — | shell shutdown (Electron only). |
+| `sse:device-operation-options` | not sent | NayaFlow's device op options; nothing here uses them. |
+| `sse:main-process-quit` | not sent | NayaFlow's shell shutdown; the shell stops the backend over `/rpc/shutdown`. |
## Original routes not yet implemented
diff --git a/docs/media/banner-dark.png b/docs/media/banner-dark.png
new file mode 100644
index 0000000..20da394
Binary files /dev/null and b/docs/media/banner-dark.png differ
diff --git a/docs/media/banner-light.png b/docs/media/banner-light.png
new file mode 100644
index 0000000..edea404
Binary files /dev/null and b/docs/media/banner-light.png differ
diff --git a/docs/media/bindings-dark.png b/docs/media/bindings-dark.png
new file mode 100644
index 0000000..267c758
Binary files /dev/null and b/docs/media/bindings-dark.png differ
diff --git a/docs/media/bindings-light.png b/docs/media/bindings-light.png
new file mode 100644
index 0000000..e0979b0
Binary files /dev/null and b/docs/media/bindings-light.png differ
diff --git a/docs/media/led-map-dark.png b/docs/media/led-map-dark.png
new file mode 100644
index 0000000..0c34902
Binary files /dev/null and b/docs/media/led-map-dark.png differ
diff --git a/docs/media/led-map-light.png b/docs/media/led-map-light.png
new file mode 100644
index 0000000..2cf3756
Binary files /dev/null and b/docs/media/led-map-light.png differ
diff --git a/docs/media/modules-dark.png b/docs/media/modules-dark.png
new file mode 100644
index 0000000..6bf5c81
Binary files /dev/null and b/docs/media/modules-dark.png differ
diff --git a/docs/media/modules-light.png b/docs/media/modules-light.png
new file mode 100644
index 0000000..e56ad4c
Binary files /dev/null and b/docs/media/modules-light.png differ
diff --git a/docs/media/social-preview.png b/docs/media/social-preview.png
new file mode 100644
index 0000000..9eed746
Binary files /dev/null and b/docs/media/social-preview.png differ
diff --git a/electron/main.js b/electron/main.js
index 8d9c370..049b8d6 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -203,7 +203,7 @@ function createWindow(port) {
// The window and taskbar icon. The packaged build gets it from electron-builder; in dev
// mode nothing else sets it and the window wore the Electron default.
icon: path.join(__dirname, "..", "build", "icon.png"),
- backgroundColor: "#0e0e11", // the dark theme's --bg until the theme work lands
+ backgroundColor: "#0e0e11", // the dark theme's --bg: what shows before the page paints
autoHideMenuBar: true, // native frame; the menu is there behind Alt
webPreferences: {
preload: path.join(__dirname, "preload.js"),
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f62601a..1c8229c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -9,7 +9,6 @@ import TopNav from "./components/shell/TopNav";
import Hub from "./pages/Hub.jsx";
import Troubleshooting from "./pages/Troubleshooting.jsx";
import Settings from "./pages/Settings.jsx";
-import Placeholder from "./pages/Placeholder.jsx";
import Bindings from "./pages/Bindings.jsx";
import Color from "./pages/Color.jsx";
import Modules from "./pages/Modules.jsx";
diff --git a/frontend/src/components/ActionPalette.jsx b/frontend/src/components/ActionPalette.jsx
index 7192cca..d33b118 100644
--- a/frontend/src/components/ActionPalette.jsx
+++ b/frontend/src/components/ActionPalette.jsx
@@ -176,7 +176,6 @@ export default function ActionPalette({
for (const t of tabs) {
for (const c of t.categories || []) {
for (const a of c.actions || []) {
- if (a.comingSoon) continue;
const s = rate({ code: a.code, label: a.label, name: a.name, alias: a.alias,
group: c.name, tooltip: a.tooltip });
if (s) push(`${a.actionType}/${a.code}`,
@@ -364,10 +363,10 @@ export default function ActionPalette({
// Disabled by CLASS, not attribute: a disabled control gets no hover, so
// its title never showed -- and the palette is disabled whenever no key
// is selected, which is exactly when people read it to decide.
- className={"palette-key" + (a.comingSoon ? " soon" : "") + (disabled ? " disabled" : "")}
- aria-disabled={disabled || a.comingSoon || undefined}
- title={a.comingSoon ? `${a.name || a.label} (coming soon)` : describe(a)}
- onClick={() => !disabled && !a.comingSoon
+ className={"palette-key" + (disabled ? " disabled" : "")}
+ aria-disabled={disabled || undefined}
+ title={describe(a)}
+ onClick={() => !disabled
&& onPick({ actionCode: a.code, actionType: a.actionType })}
>
{(() => {
diff --git a/frontend/src/components/BootloaderCheck.jsx b/frontend/src/components/BootloaderCheck.jsx
new file mode 100644
index 0000000..6507c75
--- /dev/null
+++ b/frontend/src/components/BootloaderCheck.jsx
@@ -0,0 +1,57 @@
+import { useState } from "react";
+import { api } from "../lib/api";
+import Button from "./ui/Button";
+import Notice from "./ui/Notice";
+
+// "Check what it runs", on the Information page's bootloader banner: asks a half that is sitting
+// in MCUboot which firmware images it holds and names each against the catalogue, the read
+// tools/naya-probe.py does from a terminal. Reads only (/api/recovery-read): two SMP reads, the
+// port closed after. Bringing the half OUT of the bootloader is a write and stays off in this
+// build (owner, 2026-10-09); a power cycle does that.
+export default function BootloaderCheck() {
+ const [read, setRead] = useState(null); // { busy } | { error } | the endpoint's answer
+
+ const check = async () => {
+ setRead({ busy: true });
+ try { setRead(await api.recoveryRead()); } catch (e) { setRead({ error: e.message }); }
+ };
+
+ return (
+
+
+ {read?.error && Could not read it: {read.error}}
+ {read?.state === "none" && (
+ No half is in the bootloader now. It may have restarted on its own; press Read device info.
+ )}
+ {read?.state === "error" && {read.detail}}
+ {read?.state === "ok" && (
+
+ If the half keeps coming back here after a power cycle, include this in a bug report.
+
+
+ )}
+
+ );
+}
+
+function describe(img) {
+ const side = img.side ? `${img.side[0].toUpperCase()}${img.side.slice(1)}` : "";
+ return `Create ${side} ${img.versionLabel || img.version || ""}`.replace(/\s+/g, " ").trim();
+}
diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx
index 1516332..89f3408 100644
--- a/frontend/src/components/ErrorBoundary.jsx
+++ b/frontend/src/components/ErrorBoundary.jsx
@@ -42,25 +42,28 @@ export default class ErrorBoundary extends Component {
The rest of the app still works — switch pages using the bar at the top. Nothing was written to
your keyboard, and nothing was saved.
-
{String(error?.message || error)}
{/* After a hot-reload goes stale this is by far the most common cause, and a reload is
- the whole fix -- worth saying before someone goes hunting through the code. */}
-
- If you are running the dev server, a hard reload (Ctrl+Shift+
- R) clears a stale hot-reload, which causes this more often than a real bug.
-
+ the whole fix -- worth saying before someone goes hunting through the code. Only in a
+ development run: an installed copy has no dev server to speak of. */}
+ {import.meta.env.DEV && (
+
+ If you are running the dev server, a hard reload (Ctrl+Shift+
+ R) clears a stale hot-reload, which causes this more often than a real bug.
+
-
- )}
+ {/* The why, collapsed: what a bug report needs, not what the person reads first. */}
+
+ Technical details
+
{String(error?.message || error)}
+ {info?.componentStack &&
{info.componentStack}
}
+
);
}
diff --git a/frontend/src/components/FirmwareLibrary.jsx b/frontend/src/components/FirmwareLibrary.jsx
index 97d34ed..0794252 100644
--- a/frontend/src/components/FirmwareLibrary.jsx
+++ b/frontend/src/components/FirmwareLibrary.jsx
@@ -15,10 +15,10 @@ import Notice from "./ui/Notice";
// half needs is decided from its product id at flash time, and holding three of them is how that
// decision fails later.
//
-// DOWNLOADABLE IS NOT FLASHABLE, and conflating them hid most of the archive. The module bundles
-// and the dongle image are withheld from FLASHING until that path is proven on a donor unit; two
-// pre-production keyboard images are withheld because nobody can say which side they are. None
-// of that is a reason to refuse someone a copy of the file. Every entry with a file of its own
+// DOWNLOADABLE IS NOT FLASHABLE, and conflating them hid most of the archive. Some images are
+// withheld from FLASHING: the dongle image (no hardware to prove it on), module apps that are not
+// shipping modules, and pre-production keyboard images nobody can place by side. None of that is
+// a reason to refuse someone a copy of the file. Every entry with a file of its own
// can be downloaded; whether it can then be written to a keyboard is the badge on its row.
//
// Entries with no file of their own -- the .sfb userapps extracted from FlashMemory.bin -- get no
diff --git a/frontend/src/components/FirmwareUpdate.jsx b/frontend/src/components/FirmwareUpdate.jsx
index bb49cfc..b0ad907 100644
--- a/frontend/src/components/FirmwareUpdate.jsx
+++ b/frontend/src/components/FirmwareUpdate.jsx
@@ -233,7 +233,7 @@ export default function FirmwareUpdate({ connected, dockedModules = [] }) {
onClick={start}
disabled={starting || gateOff || !anyChosen || docked || needsAccept}
title={
- gateOff ? "Firmware flashing is switched off in this build"
+ gateOff ? "Firmware updates are held back in this release"
: docked ? "Undock the modules first"
: !anyChosen ? "Choose a half to update"
: needsAccept ? "Confirm above that the current firmware can be replaced"
@@ -277,10 +277,12 @@ export default function FirmwareUpdate({ connected, dockedModules = [] }) {
{!running && !verdict && plan && (
<>
{gateOff && (
-
- The whole procedure is wired and tested, but the gate that lets it write is set
- by the environment (OPENFLOW_ENABLE_FIRMWARE_FLASH) and is off here. Nothing
- below will be sent.
+ Writing is switched on by the environment variable
+ OPENFLOW_ENABLE_FIRMWARE_FLASH=1 when OpenFlow starts; release builds ship
+ without it.>}>
+ You can choose a version and see what each half would get, but nothing below
+ will be sent to the keyboard.
)}
diff --git a/frontend/src/components/ModuleFirmwareUpdate.jsx b/frontend/src/components/ModuleFirmwareUpdate.jsx
index 4b90097..9588d9d 100644
--- a/frontend/src/components/ModuleFirmwareUpdate.jsx
+++ b/frontend/src/components/ModuleFirmwareUpdate.jsx
@@ -158,7 +158,7 @@ export default function ModuleFirmwareUpdate({ connected = true, force = false }
variant={force || target?.downgrade ? "danger" : "primary"}
onClick={start}
disabled={starting || !canStart}
- title={gateOff ? "Firmware flashing is switched off in this build"
+ title={gateOff ? "Module firmware updates are held back in this release"
: blockers.length ? blockers[0].text
: needsDownload ? "Download this version first"
: force && !forceType ? "Choose the module that is in the left bay"
@@ -202,9 +202,11 @@ export default function ModuleFirmwareUpdate({ connected = true, force = false }
{!running && !verdict && plan && (
<>
{gateOff && (
-
- The procedure is wired, but the gate that lets it write is set by the environment
- (OPENFLOW_ENABLE_FIRMWARE_FLASH) and is off here. Nothing will be sent.
+ Writing is switched on by the environment variable
+ OPENFLOW_ENABLE_FIRMWARE_FLASH=1 when OpenFlow starts; release builds ship
+ without it.>}>
+ You can see what the module would get, but nothing will be sent to it.
)}
diff --git a/frontend/src/components/SelectedKeyPanel.jsx b/frontend/src/components/SelectedKeyPanel.jsx
index 3983884..2bb1d00 100644
--- a/frontend/src/components/SelectedKeyPanel.jsx
+++ b/frontend/src/components/SelectedKeyPanel.jsx
@@ -95,7 +95,7 @@ export default function SelectedKeyPanel({
{slots.filter((slot) => slot.enabled).map((slot) => {
const b = bindings[slot.id];
const isActive = activeSlot === slot.id;
- const disabled = !slot.enabled || !label;
+ const disabled = !label;
const text = b ? actionText(b, layerMap) : null;
const nf = b && names[b.actionCode];
const name = nf?.name && nf.name !== text ? nf.name : null;
@@ -116,9 +116,7 @@ export default function SelectedKeyPanel({
{slot.label}
- {!slot.enabled
- ? {slot.experimental ? "experimental" : "soon"}
- : isActive && Selected}
+ {isActive && Selected}
{/* A keycap, text only: the big cap already shows the icon, and for a letter
the icon IS the letter, so icon plus text read "A A". */}
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 69dec07..ed283b9 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -28,7 +28,6 @@ async function req(method, path, body) {
export const api = {
systemInfo: () => req("GET", "/api/info/system"),
- uiState: () => req("GET", "/api/ui/state"),
devices: () => req("GET", "/api/devices"),
ignorePort: (port) => req("POST", "/rpc/ignore-port", { port }),
unignorePort: (port) => req("POST", "/rpc/unignore-port", { port }),
@@ -37,7 +36,6 @@ export const api = {
// half, so it is opt-in: Device Manager does not need it, the Information page does.
statusDeep: () => req("GET", "/api/status?verbose=1"),
statusLastDeep: () => req("GET", "/api/status/last?deep=1"),
- statusLive: () => req("GET", "/api/status/live"),
statusLast: () => req("GET", "/api/status/last"),
diagnostics: () => req("GET", "/api/diagnostics/report"),
releaseDevice: () => req("POST", "/rpc/release-device"),
@@ -72,18 +70,18 @@ export const api = {
// Either whole versions (what the update dialog asks for) or exact catalogue paths (what the
// library sends, having just listed them). Nothing outside the catalogue can be named.
fetchFirmware: (body) => req("POST", "/rpc/fetch-firmware", body),
- flashLogs: () => req("GET", "/api/flash-logs"),
flashLog: (id) => req("GET", `/api/flash-logs/${encodeURIComponent(id)}`),
deviceLog: (limit = 200) => req("GET", `/api/device-log?limit=${limit}`),
clearDeviceLog: () => req("POST", "/rpc/clear-device-log", {}),
openLogsFolder: () => req("POST", "/rpc/open-logs-folder", {}),
recoveryOps: () => req("GET", "/api/recovery-ops"),
+ // What a half sitting in the bootloader runs, matched to the catalogue. Reads only.
+ recoveryRead: () => req("GET", "/api/recovery-read"),
runRecoveryOp: (op, opts = {}, force = true) => req("POST", "/rpc/run-recovery-op", { op, opts, force }),
pairingRepairPlan: () => req("GET", "/api/pairing-repair/plan"),
pairingRepairVerify: () => req("GET", "/api/pairing-repair/verify"),
pairingRepair: (arm, force = true) => req("POST", "/rpc/pairing-repair", { arm, force }),
readKeyboard: (body = {}) => req("POST", "/rpc/read-keyboard", body),
- readModules: (body = {}) => req("POST", "/rpc/read-modules", body),
moduleVariants: () => req("GET", "/api/module-variants"),
createModuleProfile: (variant, name) => req("POST", "/rpc/create-module-profile", { variant, name }),
renameModuleProfile: (configId, name) => req("POST", "/rpc/rename-module-profile", { configId, name }),
@@ -144,21 +142,14 @@ export const api = {
led: (side, action, value) => req("POST", "/rpc/led", { side, action, value }),
restoreLighting: (side = "left") => req("POST", "/rpc/restore-lighting", { side }),
- bleProfiles: (side = "left") => req("GET", `/api/ble/profiles?side=${side}`),
selectBleProfile: (side, index) =>
req("POST", "/rpc/select-ble-profile", { side, index, confirm: "SELECT" }),
clearBleProfile: (side, index) =>
req("POST", "/rpc/clear-ble-profile", { side, index, confirm: "CLEAR" }),
- textCommand: (side, command, force = false) =>
- req("POST", "/rpc/text-command", { side, command, force }),
- dumpSettings: (side) => req("POST", "/rpc/dump-settings", { side }),
readSettings: (side) => req("POST", "/rpc/read-settings", { side }),
splitLinkCheck: () => req("GET", "/api/split-link-check"),
- checkForUpdates: () => req("POST", "/rpc/check-for-updates"),
flashPreview: (body = {}) => req("POST", "/rpc/flash-preview", body),
- moduleGestures: (types) =>
- req("GET", "/api/module-gestures" + (types ? `?types=${encodeURIComponent(types)}` : "")),
settings: () => req("GET", "/api/settings"),
setSetting: (key, value) => req("POST", "/rpc/set-setting", { key, value }),
diff --git a/frontend/src/lib/moduleRun.js b/frontend/src/lib/moduleRun.js
index 29fd263..0b3ab2a 100644
--- a/frontend/src/lib/moduleRun.js
+++ b/frontend/src/lib/moduleRun.js
@@ -11,7 +11,7 @@
// the largest words it has: this is the one step that needs the person at the keyboard.
// * what BLOCKS a start. The backend's plan returns every precondition as {code, text}; Force
// Update lifts the ones about a module that does not identify (plan.forceLifts), never the
-// others -- a Track stays refused, the right half must still be unplugged.
+// others -- the module must still be in the left bay, the right half unplugged.
export const isModuleRun = (run) => !!run && String(run.id || "").startsWith("module-");
diff --git a/frontend/src/lib/useSSE.js b/frontend/src/lib/useSSE.js
deleted file mode 100644
index 5634567..0000000
--- a/frontend/src/lib/useSSE.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { BASE } from "./api";
-
-// Subscribe to the backend SSE stream. Mirrors NayaFlow's renderer: one
-// EventSource at /sse, named events (sse:naya-devices-stream, etc.), with
-// auto-reconnect. Returns the latest parsed payload for the given event name.
-export function useSSE(eventName) {
- const [data, setData] = useState(null);
- const [connected, setConnected] = useState(false);
- const esRef = useRef(null);
-
- useEffect(() => {
- let closed = false;
- let retry = 0;
-
- function connect() {
- const es = new EventSource(`${BASE}/sse`);
- esRef.current = es;
-
- es.onopen = () => {
- retry = 0;
- setConnected(true);
- };
- es.onerror = () => {
- setConnected(false);
- es.close();
- if (!closed && retry < 20) {
- retry += 1;
- setTimeout(connect, 3000);
- }
- };
- es.addEventListener(eventName, (e) => {
- try {
- setData(JSON.parse(e.data));
- } catch {
- setData(e.data);
- }
- });
- }
-
- connect();
- return () => {
- closed = true;
- if (esRef.current) esRef.current.close();
- };
- }, [eventName]);
-
- return { data, connected };
-}
diff --git a/frontend/src/pages/Placeholder.jsx b/frontend/src/pages/Placeholder.jsx
deleted file mode 100644
index 0d3cb47..0000000
--- a/frontend/src/pages/Placeholder.jsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import Badge from "../components/ui/Badge";
-import Card from "../components/ui/Card";
-import Notice from "../components/ui/Notice";
-
-export default function Placeholder({ title, phase, note }) {
- return (
-
-
- {title} {phase}
-
-
This page is scaffolded but not yet implemented.
-
- {note}
-
-
- );
-}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 19af9cc..fb488c7 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -20,7 +20,6 @@ import Tabs from "../components/ui/Tabs";
import { useDeviceStream } from "../lib/deviceStream";
import { setShowAllKeyboards, useShowAllKeyboards } from "../lib/showAllKeyboards";
import { THEME_PREFERENCES, setThemePreference, useThemePreference } from "../lib/theme";
-// Placeholder repo paths — update to the real OpenFlow / firmware repos once public.
import { ORG, REPOS, checkRelease } from "../lib/updates";
// The rail, as the storyboard draws it: an icon per section (Tabler Icons, MIT, Pawel Kuna:
@@ -85,7 +84,8 @@ function SettingsGroups({ groups, onChange }) {
{groups.map((g) => (
{g.desc &&
{g.desc}
}
- {g.fields.map((f) => )}
+ {/* `hidden`: in the schema (a stored value survives) but not offered, e.g. tray_battery. */}
+ {g.fields.filter((f) => !f.hidden).map((f) => )}
))}
@@ -123,8 +123,8 @@ export default function Settings() {
// unlocked the repair (OPENFLOW_ENABLE_PAIRING_REPAIR) still had no way to start it.
const pairingBlocker = !pairPlan ? "Plan the repair first"
: pairPlan.refused ? `Refused: ${pairPlan.refused}`
- : !pairPlan.enabled ? "Locked: OpenFlow was not started with pairing repair unlocked"
- : pairPlan.opsDisabled?.length ? `Locked: these steps are not unlocked: ${pairPlan.opsDisabled.join(", ")}`
+ : !pairPlan.enabled ? "The repair step is held back in this release; the plan shows what it would do"
+ : pairPlan.opsDisabled?.length ? `The repair step is held back in this release (${pairPlan.opsDisabled.join(", ")})`
: null;
const canRunPairing = !!pairPlan?.armToken && !pairingBlocker;
const { out, busy, run } = useRunLog();
@@ -302,10 +302,11 @@ export default function Settings() {
Recovery procedures
- Device-recovery procedures recovered from the vendor software. Each is wired and
- its exact command is pinned by tests, but they stay disabled until
- each is verified on a spare/donor unit — a mistaken one can leave a keyboard worse
- off. Every one runs only behind a confirmation.
+ Device-recovery procedures recovered from the vendor software. They are
+ held back in this release: each one restarts, writes to or
+ erases part of the keyboard, and stays off until it has been verified on a spare
+ keyboard, since a mistaken one can leave a keyboard worse off. When enabled, each
+ runs only behind a confirmation.
{["reset", "recovery", "destructive"].map((danger) => {
const ops = recovery.filter((o) => o.danger === danger);
@@ -320,13 +321,13 @@ export default function Settings() {
control={
}
desc={<>
- {op.desc}{!op.enabled && <> — wired, enabled after testing on a {op.needs}.>}
+ {op.desc}{!op.enabled && <> Held back in this release.>}
{op.command}
>} />
))}
diff --git a/frontend/src/pages/Troubleshooting.jsx b/frontend/src/pages/Troubleshooting.jsx
index 76411e2..927511a 100644
--- a/frontend/src/pages/Troubleshooting.jsx
+++ b/frontend/src/pages/Troubleshooting.jsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../lib/api";
import BleSlots from "../components/BleSlots";
+import BootloaderCheck from "../components/BootloaderCheck";
import { useShowAllKeyboards, visibleHalves } from "../lib/showAllKeyboards";
import useRunLog from "../lib/useRunLog";
import Badge from "../components/ui/Badge";
@@ -423,6 +424,7 @@ export default function Troubleshooting() {
own. Switch it off and on again. If it comes straight back here, its firmware needs
reinstalling.
+
)}
{/* A DIFFERENT keyboard, not merely an old reading. Everything below -- ports,
diff --git a/frontend/src/styles/editor.css b/frontend/src/styles/editor.css
index 26fbf23..9c6ece2 100644
--- a/frontend/src/styles/editor.css
+++ b/frontend/src/styles/editor.css
@@ -1046,11 +1046,6 @@
cursor: not-allowed;
}
-.palette-key.soon {
- opacity: 0.45;
- cursor: not-allowed;
-}
-
/* Modules page. The rail runs the full height (it spans both grid rows), and the palette sits
in the second column beneath the module content. 288px: wide enough that profile names do
not wrap, since the rail carries three row actions. */
@@ -2116,6 +2111,9 @@ textarea.report-input { resize: vertical; line-height: 1.5; font-family: inherit
font-size: 15px; font-weight: 600; margin-bottom: 6px;
}
.info-banner-detail { color: var(--text-muted); font-size: 13px; }
+/* The read-only bootloader check on that banner (components/BootloaderCheck). */
+.bootloader-check { display: grid; gap: 8px; justify-items: start; margin-top: 10px; }
+.bootloader-check-result ul { margin: 6px 0; padding-left: 18px; font-size: 13px; }
.info-banner-help {
margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border);
color: var(--text-dim); font-size: 12px; line-height: 1.6; max-width: 78ch;
diff --git a/frontend/tests/e2e/readme-shots.spec.js b/frontend/tests/e2e/readme-shots.spec.js
new file mode 100644
index 0000000..b0b5b56
--- /dev/null
+++ b/frontend/tests/e2e/readme-shots.spec.js
@@ -0,0 +1,52 @@
+// The README's screenshots, regenerated on demand against the route walk's own backend (seeded
+// from the stock profile, so a real keymap shows): Bindings with a key selected, the LED Map, and
+// a Tune's scroll gesture selected, each in both themes, written to ../docs/media/. Skipped unless asked for:
+//
+// README_SHOTS=1 npx playwright test tests/e2e/readme-shots.spec.js
+import { test, expect } from "@playwright/test";
+import { E2E_BACKEND_PORT } from "./env.js";
+
+test.skip(!process.env.README_SHOTS, "README screenshots run only when README_SHOTS=1");
+
+const OUT = "../docs/media";
+
+for (const theme of ["dark", "light"]) {
+ test.describe(`${theme} theme`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(([port, t]) => {
+ window.EXPOSED = { bgServerPort: port };
+ try { localStorage.setItem("openflow.theme", t); } catch {}
+ const apply = () => { if (document.documentElement) document.documentElement.dataset.theme = t; };
+ apply();
+ document.addEventListener("DOMContentLoaded", apply);
+ }, [E2E_BACKEND_PORT, theme]);
+ await page.route("**/rpc/flash", (r) => r.abort());
+ });
+
+ test(`bindings (${theme})`, async ({ page }) => {
+ await page.goto("/#/layer-management");
+ await expect(page.getByText("Typing").first()).toBeVisible();
+ // The board's keys draw their legends, so they carry no text: pos 0 is the top-left key,
+ // Esc on the stock profile.
+ await page.locator('button.kc[title="pos 0"]').click();
+ await page.waitForTimeout(800);
+ await page.screenshot({ path: `${OUT}/bindings-${theme}.png` });
+ });
+
+ test(`led map (${theme})`, async ({ page }) => {
+ await page.goto("/#/colormapping");
+ await expect(page.getByText("LED Mapping Tools")).toBeVisible();
+ await page.waitForTimeout(800);
+ await page.screenshot({ path: `${OUT}/led-map-${theme}.png` });
+ });
+
+ test(`modules (${theme})`, async ({ page }) => {
+ await page.goto("/#/module-configuration");
+ await page.getByText("Naya Tune Mac/Win").click();
+ await page.waitForTimeout(600);
+ await page.getByText("Vertical Scroll", { exact: true }).first().click();
+ await page.waitForTimeout(800);
+ await page.screenshot({ path: `${OUT}/modules-${theme}.png` });
+ });
+ });
+}
diff --git a/frontend/tests/unit/bootloaderCheck.test.jsx b/frontend/tests/unit/bootloaderCheck.test.jsx
new file mode 100644
index 0000000..1eced99
--- /dev/null
+++ b/frontend/tests/unit/bootloaderCheck.test.jsx
@@ -0,0 +1,43 @@
+// "Check what it runs" on the Information page's bootloader banner: a read-only look at a half
+// sitting in MCUboot (/api/recovery-read). No real half is ever in the bootloader on CI, so the
+// answers are the endpoint's three shapes, mocked.
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+const recoveryRead = vi.fn();
+vi.mock("../../src/lib/api", () => ({ api: { recoveryRead: (...a) => recoveryRead(...a) } }));
+
+import BootloaderCheck from "../../src/components/BootloaderCheck";
+
+describe("BootloaderCheck", () => {
+ it("names each image the half holds against the catalogue", async () => {
+ recoveryRead.mockResolvedValueOnce({
+ state: "ok", port: "COM7", pidSide: "right",
+ images: [
+ { slot: 0, active: true, confirmed: true, version: "3.41.0", hash: "aa", identified: true, side: "right", versionLabel: "3.41.0" },
+ { slot: 1, active: false, confirmed: false, version: "9.0.0", hash: "bb", identified: false },
+ ],
+ });
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Check what it runs" }));
+ expect(await screen.findByText(/COM7, a right half holds 2 firmware images/)).toBeInTheDocument();
+ expect(screen.getByText(/Slot 0: Create Right 3\.41\.0 · running · confirmed/)).toBeInTheDocument();
+ expect(screen.getByText(/Slot 1: version 9\.0\.0, an image OpenFlow does not hold/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Check again" })).toBeInTheDocument();
+ });
+
+ it("says so when no half is in the bootloader any more", async () => {
+ recoveryRead.mockResolvedValueOnce({ state: "none", detail: "No half is in MCUboot recovery." });
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Check what it runs" }));
+ expect(await screen.findByText(/No half is in the bootloader now/)).toBeInTheDocument();
+ });
+
+ it("shows the refusal while a flash is running", async () => {
+ recoveryRead.mockImplementationOnce(() => Promise.reject(new Error("A firmware flash is running; check again once it has finished.")));
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Check what it runs" }));
+ expect(await screen.findByText(/Could not read it: A firmware flash is running/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/tools/render-banner.mjs b/frontend/tools/render-banner.mjs
new file mode 100644
index 0000000..1df92e9
--- /dev/null
+++ b/frontend/tools/render-banner.mjs
@@ -0,0 +1,69 @@
+// Renders the README banners (dark and light, 1600x520) and GitHub's social preview card
+// (1280x640) from the vector mark in public/brand/openflow-mark.svg, into ../docs/media/. The
+// layout follows Create Companion's banner (mark, name, tagline with an accent rule, platform
+// line) so the two projects read as a family. Uses the Playwright Chromium the route walk
+// already installs, like render-icon.mjs.
+//
+// node tools/render-banner.mjs
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { chromium } from "playwright";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const mark = readFileSync(join(here, "..", "public", "brand", "openflow-mark.svg"), "utf8")
+ .replace(//g, "");
+const out = join(here, "..", "..", "docs", "media");
+mkdirSync(out, { recursive: true });
+
+// The Hub's own words, so the README and the app say the same thing.
+const TAGLINE = ["Open-Source Software for Naya Create.", "No cloud or external dependencies."];
+const PLATFORMS = "Windows · macOS · Linux · Naya Create, Tune, Touch & Track";
+
+const THEMES = {
+ // --bg of the dark theme (electron/main.js), the mark's cyan, the wordmark white from the sheet.
+ dark: { bg: "#0e0e11", ink: "#fcfcfd", muted: "#9a9aa6", accent: "#03b9c9" },
+ light: { bg: "#fafafc", ink: "#18181c", muted: "#64646e", accent: "#0298a5" },
+};
+
+function page(theme, { width, height, markSize, title, tag, small }) {
+ const t = THEMES[theme];
+ const svg = mark.replace('width="100" height="100"', `width="${markSize}" height="${markSize}"`)
+ .replace('fill="#03b9c9"', `fill="${t.accent}"`);
+ return `
+ ${svg}
+