From c089594269e2757687d831a68ce157c29cc7334e Mon Sep 17 00:00:00 2001
From: traviswye
Date: Fri, 9 Oct 2026 16:12:40 -0400
Subject: [PATCH 1/3] release prep: the first public build's gates, said
plainly
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The owner's decisions for the first public build (2026-10-09, recorded in SCRUM-116):
- Information › bootloader banner gets "Check what it runs": the read-only
/api/recovery-read (what tools/naya-probe.py shows from a terminal), now
refused while a flash runs since the flasher holds the same port. Bringing a
half out of the bootloader is a write and stays off.
- "Show Battery in Tray" is hidden: the desktop app has no tray. The field
stays in the schema with `hidden`, so a stored value survives.
- The error page keeps the stack and the message behind "Technical details",
shows the dev-server hint only in a development run, and offers Report a bug.
- Locks read as a release decision instead of a configuration fault: firmware
and module updates, the split-link repair step and the recovery procedures
are "held back in this release"; the environment switch is behind Details.
---
backend/openflow_backend/api/rest.py | 17 ++++--
backend/openflow_backend/db/settings.py | 9 ++-
frontend/src/components/BootloaderCheck.jsx | 57 +++++++++++++++++++
frontend/src/components/ErrorBoundary.jsx | 27 +++++----
frontend/src/components/FirmwareUpdate.jsx | 12 ++--
.../src/components/ModuleFirmwareUpdate.jsx | 10 ++--
frontend/src/lib/api.js | 2 +
frontend/src/pages/Settings.jsx | 20 ++++---
frontend/src/pages/Troubleshooting.jsx | 2 +
frontend/src/styles/editor.css | 3 +
frontend/tests/unit/bootloaderCheck.test.jsx | 43 ++++++++++++++
11 files changed, 162 insertions(+), 40 deletions(-)
create mode 100644 frontend/src/components/BootloaderCheck.jsx
create mode 100644 frontend/tests/unit/bootloaderCheck.test.jsx
diff --git a/backend/openflow_backend/api/rest.py b/backend/openflow_backend/api/rest.py
index a79f2cd..17d4d1e 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())
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/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/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/lib/api.js b/frontend/src/lib/api.js
index 69dec07..3962c2c 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -78,6 +78,8 @@ export const api = {
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"),
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index ce32617..6a0e41f 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -84,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 +124,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();
@@ -303,10 +304,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);
@@ -321,13 +323,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..3d14da6 100644
--- a/frontend/src/styles/editor.css
+++ b/frontend/src/styles/editor.css
@@ -2116,6 +2116,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/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();
+ });
+});
From 8adbc99b6b6e592c4301364a1bf663e72ebbef23 Mon Sep 17 00:00:00 2001
From: traviswye
Date: Fri, 9 Oct 2026 16:19:43 -0400
Subject: [PATCH 2/3] cleanup: dead code and stale comments before the first
public release (SCRUM-116)
Each item checked against the code first; two items on the list were wrong and
are left alone: VirtualKeyboard's `k.off` is set by the keyboard itself (F21-F24
on a Mac), and nothing else was found to depend on the rest.
- Removed: the never-set `comingSoon` palette flag and its frontend branches,
the "soon" badge behind a filter that hides those slots first, useSSE.js and
Placeholder.jsx (never used), eight API client methods nothing calls, and the
/rpc/check-for-updates stub that always said "up to date" (OpenFlow updates
itself through the shell now).
- Flash: Disabled and Transparent have encoders, so their "cannot be written
yet" drop reasons are gone (and the test that pinned them); Sticky Layer joins
the layer types whose drop names the missing layer instead of "no encoder".
- Command stubs say where the real path is (firmware updates, pairing, the
held-back recovery procedures) instead of "Phase 2 work" or "blocked until a
firmware image is obtained".
- Comments brought up to date: the firmware archive is public, shipping module
bundles are flashable, the bundle upload has run on hardware, a Track is no
longer refused, Double Tap and Tap + Hold are live, three NayaFlow SSE events
are never sent here, and the window's background colour.
---
backend/openflow_backend/api/rest.py | 6 ---
backend/openflow_backend/api/sse.py | 8 +--
.../device/actions_catalog.py | 15 ++----
backend/openflow_backend/device/commands.py | 16 +++---
.../openflow_backend/device/firmware_fetch.py | 17 +++----
.../device/firmware_upload.py | 3 +-
backend/openflow_backend/device/flash.py | 7 ++-
backend/tests/route-manifest.json | 1 -
backend/tests/smoke.py | 2 -
backend/tests/test_dropped_bindings.py | 4 +-
docs/api-contract.md | 7 ++-
electron/main.js | 2 +-
frontend/src/App.jsx | 1 -
frontend/src/components/ActionPalette.jsx | 9 ++--
frontend/src/components/FirmwareLibrary.jsx | 8 +--
frontend/src/components/SelectedKeyPanel.jsx | 6 +--
frontend/src/lib/api.js | 11 -----
frontend/src/lib/moduleRun.js | 2 +-
frontend/src/lib/useSSE.js | 49 -------------------
frontend/src/pages/Placeholder.jsx | 17 -------
frontend/src/pages/Settings.jsx | 1 -
frontend/src/styles/editor.css | 5 --
22 files changed, 48 insertions(+), 149 deletions(-)
delete mode 100644 frontend/src/lib/useSSE.js
delete mode 100644 frontend/src/pages/Placeholder.jsx
diff --git a/backend/openflow_backend/api/rest.py b/backend/openflow_backend/api/rest.py
index 17d4d1e..0523e34 100644
--- a/backend/openflow_backend/api/rest.py
+++ b/backend/openflow_backend/api/rest.py
@@ -1670,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"}
-
-
# --- flash (write) : preview only for now (dry-run); real write is Phase C ---
@router.get("/api/module-variants")
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/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 d4cb481..e1f4af7 100644
--- a/backend/tests/route-manifest.json
+++ b/backend/tests/route-manifest.json
@@ -45,7 +45,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/electron/main.js b/electron/main.js
index f7b88ad..6d67b3c 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -201,7 +201,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/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/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 3962c2c..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,7 +70,6 @@ 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", {}),
@@ -85,7 +82,6 @@ export const api = {
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 }),
@@ -146,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 6a0e41f..bf474db 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -19,7 +19,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:
diff --git a/frontend/src/styles/editor.css b/frontend/src/styles/editor.css
index 3d14da6..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. */
From 66a7de628905ca1eb0644adb0658d8542a25944b Mon Sep 17 00:00:00 2001
From: traviswye
Date: Fri, 9 Oct 2026 16:30:12 -0400
Subject: [PATCH 3/3] README: a landing page like Create Companion's
Banner (light and dark), badges, download buttons for Windows, macOS and Linux,
the Bindings page as the hero, a Highlights table, the LED Map and Modules
beside each other, what is held back in this release, install per platform,
how updates work, and where to report a bug. The developer material moves under
"Building from source" unchanged, apart from a stale test count.
The images are generated: `node frontend/tools/render-banner.mjs` draws the
banners and the social preview from the vector mark, and
`README_SHOTS=1 npx playwright test tests/e2e/readme-shots.spec.js` takes the
screenshots against the route walk's own seeded backend, both themes.
---
README.md | 221 ++++++++++++++++++------
docs/media/banner-dark.png | Bin 0 -> 49610 bytes
docs/media/banner-light.png | Bin 0 -> 49531 bytes
docs/media/bindings-dark.png | Bin 0 -> 188606 bytes
docs/media/bindings-light.png | Bin 0 -> 193405 bytes
docs/media/led-map-dark.png | Bin 0 -> 142045 bytes
docs/media/led-map-light.png | Bin 0 -> 142811 bytes
docs/media/modules-dark.png | Bin 0 -> 107093 bytes
docs/media/modules-light.png | Bin 0 -> 108388 bytes
docs/media/social-preview.png | Bin 0 -> 44139 bytes
frontend/tests/e2e/readme-shots.spec.js | 52 ++++++
frontend/tools/render-banner.mjs | 69 ++++++++
12 files changed, 287 insertions(+), 55 deletions(-)
create mode 100644 docs/media/banner-dark.png
create mode 100644 docs/media/banner-light.png
create mode 100644 docs/media/bindings-dark.png
create mode 100644 docs/media/bindings-light.png
create mode 100644 docs/media/led-map-dark.png
create mode 100644 docs/media/led-map-light.png
create mode 100644 docs/media/modules-dark.png
create mode 100644 docs/media/modules-light.png
create mode 100644 docs/media/social-preview.png
create mode 100644 frontend/tests/e2e/readme-shots.spec.js
create mode 100644 frontend/tools/render-banner.mjs
diff --git a/README.md b/README.md
index e987198..9499dbe 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,164 @@
-# OpenFlow
+
+
+
+
+
+
+
+
+ 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. |
+
+