Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d5397ff
Fix: Display '⌘' for KC_LGUI and KC_RGUI in editor
Meserlion Dec 7, 2025
40ca7ce
local
Meserlion Feb 18, 2026
2092926
Add compound keycode atoms for DK bracket/paren characters
Meserlion Feb 18, 2026
e5adec8
Fix compound keycode parsing on server side
Meserlion Feb 18, 2026
8d4f0a9
Add DK-layout symbols for < keys (NUBS and LS(FSLH))
Meserlion Feb 18, 2026
f606108
Fix LS(FSLH) symbol to _ (underscore) for DK layout
Meserlion Feb 18, 2026
3305bd0
Show behavior symbol on key when defined; add \ symbol to kp_dk_BLSH
Meserlion Feb 18, 2026
26abf31
Add macro editor: view, add, remove macros from the UI
Meserlion Feb 18, 2026
55c40c0
Add combo editor: view, add, remove combos from the UI
Meserlion Feb 18, 2026
18490be
Add alias loading, keymap validation, and git push functionality; enh…
Meserlion Feb 19, 2026
76f60a1
Refactor keyboard handling and improve key binding resolution
Meserlion Feb 19, 2026
4af09d9
Add CLAUDE.md and fix dev script for Windows PowerShell
Meserlion Feb 22, 2026
1fa9aef
Add working/ to gitignore
Meserlion Feb 22, 2026
37779d4
Add additional Bash commands to settings and update .gitignore to inc…
Meserlion Apr 1, 2026
931dd0c
Upgrade axios, jsonwebtoken, dotenv and audit fix all vulns
Meserlion Apr 11, 2026
2473214
Add LayerDisplay component with toggle button
Meserlion Apr 11, 2026
6813510
Fix npm run kill hanging when no ports are in use
Meserlion Apr 11, 2026
5ea6e64
Replace exec() with execFile() in gitCommitPush
Meserlion Apr 11, 2026
354db1f
Use Promise.allSettled in initialize to allow partial failures
Meserlion Apr 11, 2026
03e651c
Store GitHub auth token in sessionStorage instead of localStorage
Meserlion Apr 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npm start)",
"Bash(node_modules/.bin/react-scripts build:*)",
"Bash(powershell -command:*)",
"Bash(node_modules/.bin/cross-env ENABLE_DEV_SERVER=true node index.js)",
"Bash(python -c:*)",
"Bash(powershell.exe -NoProfile -Command \"Get-NetTCPConnection -LocalPort 8080 -State Listen | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force }\")",
"Bash(powershell.exe -NoProfile -Command \"Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force }\")"
]
}
}
24 changes: 24 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"permissions": {
"allow": [
"Bash(git restore:*)",
"WebSearch",
"Bash(npm run dev:*)",
"Bash(curl:*)",
"Bash(node -e:*)",
"Bash(dir:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(taskkill:*)",
"Bash(test:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(gh run list:*)",
"Bash(python3:*)",
"Bash(cmd /c \"dir \"\"D:\\\\Programs\\\\keymap-editor\\\\app\\\\src\\\\Keyboard\\\\Keys\"\" /b\")",
"Bash(node index.js:*)",
"Bash(ls:*)"
]
}
}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,5 @@ qmk_firmware
zmk-config

private-key.pem
.env
.env
working/
51 changes: 51 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# keymap-editor — CLAUDE.md

## Project Purpose
Local keymap editor for a Totem split keyboard (38 keys, ZMK firmware).
Edits `zmk-config/config/keymap.json` + `config/totem.keymap`, then git pushes to trigger GitHub Actions builds.

## Architecture
- **Frontend**: React (CRA) in `app/src/`, served on port 3000 in dev
- **Backend API**: Express in `api/`, listens on port 8080
- **zmk-config**: cloned at `D:/Programs/keymap-editor/zmk-config/` (GitHub: Meserlion/zmk-config-totem)

## Running the Dev Server (Windows)
- `npm run dev` — kills stale processes on 8080/3000, then starts API + React dev server
- Open at `http://127.0.0.1:3000` (not `localhost` — IPv6 conflicts on this machine)
- `ENABLE_DEV_SERVER=true` is in `.env` — do not use `cross-env` in scripts, it silently fails in PowerShell on this machine
- API and CRA both bind to `127.0.0.1` explicitly
- To manually kill port 8080: `powershell -command "Get-NetTCPConnection -LocalPort 8080 -State Listen | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force }"`

## Important Files
| File | Purpose |
|------|---------|
| `index.js` | Entry point — starts Express on port 8080 |
| `api/index.js` | Express app setup, mounts routes, starts dev server if `ENABLE_DEV_SERVER` |
| `api/config.js` | Loads `.env` via dotenv, exports all config values |
| `api/routes/application.js` | Spawns the CRA dev server process |
| `api/routes/keyboards.js` | API routes: `GET/POST /keyboards/:id/keymap`, `GET /keyboards/:id/layout` |
| `api/services/zmk/local-source.js` | Reads/writes zmk-config files; falls back to totem.json if info.json missing |
| `api/services/zmk/data/zmk-behaviors.json` | **Single source of truth** for ZMK behaviors — do not duplicate |
| `app/src/App.js` | Main React component |
| `app/src/keymap.js` | `parseKeymap`, `parseKeyBinding`, `getBehaviourParams`, re-exports `loadKeymap` |
| `app/src/data/totem.json` | Totem 38-key layout (from keymap-editor-contrib) |
| `app/src/data/totem.keymap.json` | Default keymap (38 `&none` bindings) |

## Behaviors — Single Source of Truth
`api/services/zmk/data/zmk-behaviors.json` is the **only** behaviors file.
The frontend fetches behaviors at runtime via `GET /behaviors` — there is no frontend copy.
`app/src/data/zmk-behaviors.json` was intentionally deleted to prevent drift.

## Key Architectural Notes
- Layout array is at `layoutData.layouts.LAYOUT.layout` — don't pass the full JSON object
- Key bindings must be `{value, params}` objects (not raw strings) before passing to `<Keyboard>`
- `editingKeymap` (not `keymap`) is the in-progress state; it is always set on init via `parseKeymap()`
- Frontend fetches keymap, behaviors, macros, and combos at runtime from the API — no bundled copies

## ZMK Version — IMPORTANT
`west.yml` is pinned to commit `fee2404d5d886c455e3820f9ca624cf9275e9cb5` (Dec 30, 2025).
**Do NOT change `revision` back to `main`** — ZMK main (post-Dec 2025) has a regression that breaks BT advertising on the Totem/XIAO BLE. The keyboard boots and works over USB but never appears in the BT device list.
Working firmware is also stored in `D:/Programs/keymap-editor/working/` as a backup.

## Totem Layout Source
`https://raw.githubusercontent.com/nickcoutsos/keymap-editor-contrib/main/keyboard-data/totem.json`
16 changes: 10 additions & 6 deletions api/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ const path = require('path')
const config = require('../config')

const appDir = path.join(__dirname, '..', '..', 'app')
const API_BASE_URL = 'http://localhost:8080'
const APP_BASE_URL = 'http://localhost:3000'
const API_BASE_URL = 'http://127.0.0.1:8080'
const APP_BASE_URL = 'http://127.0.0.1:3000'

function init (app) {
const opts = {
cwd: appDir,
env: Object.assign({}, process.env, {
HOST: '127.0.0.1',
BROWSER: 'none',
REACT_APP_ENABLE_LOCAL: true,
REACT_APP_ENABLE_GITHUB: config.ENABLE_GITHUB,
REACT_APP_GITHUB_APP_NAME: config.GITHUB_APP_NAME,
Expand All @@ -19,10 +21,12 @@ function init (app) {
})
}

childProcess.execFile('npm', ['start'], opts, err => {
console.error(err)
console.error('Application serving failed')
process.exit(1)
const child = childProcess.spawn('npm', ['start'], { ...opts, shell: true, stdio: 'inherit' })
child.on('error', err => console.error('Failed to start app:', err.message))
child.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`App process exited with code ${code}`)
}
})

app.get('/', (req, res) => res.redirect(APP_BASE_URL))
Expand Down
37 changes: 37 additions & 0 deletions api/routes/keyboards.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ const router = Router()

router.get('/behaviors', (req, res) => res.json(zmk.loadBehaviors()))
router.get('/keycodes', (req, res) => res.json(zmk.loadKeycodes()))
router.get('/aliases', (req, res) => res.json(zmk.loadAliases()))
router.get('/layout', (req, res) => res.json(zmk.loadLayout()))
router.get('/keymap', (req, res) => res.json(zmk.loadKeymap()))
router.post('/keymap', (req, res) => {
const keymap = req.body

try {
zmk.validateKeymapJson(keymap)
} catch (err) {
if (err.name === 'KeymapValidationError') {
return res.status(400).json({ errors: err.errors })
}
return res.status(500).send(String(err))
}

const layout = zmk.loadLayout()
const generatedKeymap = zmk.generateKeymap(layout, keymap)
const exportStdout = zmk.exportKeymap(generatedKeymap, 'flash' in req.query, err => {
Expand All @@ -27,4 +38,30 @@ router.post('/keymap', (req, res) => {
// })
})

router.get('/macros', (req, res) => res.json(zmk.loadMacros()))
router.post('/macros', (req, res) => {
zmk.exportMacros(req.body, err => {
if (err) { res.status(500).send(err); return }
res.send()
})
})

router.get('/combos', (req, res) => res.json(zmk.loadCombos()))
router.post('/combos', (req, res) => {
zmk.exportCombos(req.body, err => {
if (err) { res.status(500).send(err); return }
res.send()
})
})

router.post('/git/push', (req, res) => {
zmk.gitCommitPush((err, stdout, stderr) => {
if (err) {
return res.status(500).json({ error: err.message, stderr })
}
const actionsUrl = zmk.getActionsUrl()
res.json({ stdout, stderr, actionsUrl })
})
})

module.exports = router
17 changes: 17 additions & 0 deletions api/services/zmk/data/zmk-behaviors.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
"type": "integer",
"enum": [0, 1, 2, 3, 4]
}]
}, {
"code": "BT_CLR_ALL",
"description": "Clear bond information for all Bluetooth profiles."
}, {
"code": "BT_DISC",
"description": "Disconnect from the host for a given profile index.",
"additionalParams": [{"name": "index", "type": "integer", "enum": [0, 1, 2, 3, 4]}]
}]
},
{
Expand Down Expand Up @@ -189,6 +196,16 @@
"description": "Toggle the external power"
}]
},
{
"code": "&kt",
"name": "Key Toggle",
"params": ["code"]
},
{
"code": "&sys_reset",
"name": "System Reset",
"params": []
},
{
"code": "&trans",
"name": "Transparent",
Expand Down
24 changes: 21 additions & 3 deletions api/services/zmk/index.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
const {
parseKeyBinding,
generateKeymap
generateKeymap,
validateKeymapJson,
KeymapValidationError
} = require('./keymap')

const {
loadBehaviors,
loadKeycodes,
loadLayout,
loadKeymap,
exportKeymap
exportKeymap,
loadMacros,
exportMacros,
loadCombos,
exportCombos,
loadAliases,
getActionsUrl,
gitCommitPush
} = require('./local-source')

module.exports = {
parseKeyBinding,
generateKeymap,
validateKeymapJson,
KeymapValidationError,
loadBehaviors,
loadKeycodes,
loadLayout,
loadKeymap,
exportKeymap
exportKeymap,
loadMacros,
exportMacros,
loadCombos,
exportCombos,
loadAliases,
getActionsUrl,
gitCommitPush
}
66 changes: 59 additions & 7 deletions api/services/zmk/keymap.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,17 @@ function getBehavioursUsed(keymap) {
* @param {String} binding
* @returns {Object}
*/
const COMPOUND_KEYCODES = new Set([
'LA(LC(N7))', 'LA(LC(N8))', 'LA(LC(N9))', 'LA(LC(N0))',
'RS(NUMBER_8)', 'RS(N9)',
'LS(FSLH)'
])

function parseKeyBinding(binding) {
const paramsPattern = /\((.+)\)/

function parse(code) {
if (COMPOUND_KEYCODES.has(code)) return { value: code, params: [] }
const value = code.replace(paramsPattern, '')
const params = get(code.match(paramsPattern), '[1]', '').split(',')
.map(s => s.trim())
Expand Down Expand Up @@ -157,16 +164,61 @@ function validateKeymapJson(keymap) {
const key = layer[j]
const keyPath = `layers[${i}][${j}]`

if (typeof key !== 'string') {
errors.push(`Value at "${keyPath}" must be a string`)
// Accept both string bindings ("&kp A") and parsed objects ({value, params})
let bindCode, params
if (typeof key === 'string') {
const m = key.match(/^(&\S+)/)
bindCode = m && m[1]
params = bindCode ? parseKeyBinding(key).params : []
} else if (typeof key === 'object' && key !== null && key.value) {
bindCode = key.value
params = key.params || []
} else {
const bind = key.match(/^&.+?\b/)
if (!(bind && bind[0] in behavioursByBind)) {
errors.push(`Key bind at "${keyPath}" has invalid behaviour`)
}
errors.push(`Value at "${keyPath}" has invalid format`)
continue
}

if (!bindCode) {
errors.push(`Value at "${keyPath}" has invalid format`)
continue
}

// TODO: validate remaining bind parameters
const behaviour = behavioursByBind[bindCode]
if (!behaviour) {
// Unknown behavior — likely a user-defined macro, skip param validation
continue
}

const expectedParams = behaviour.params || []
const commandsByCode = keyBy(behaviour.commands || [], 'code')

// Calculate total expected params including additionalParams for commands
let totalExpected = expectedParams.length
if (expectedParams[0] === 'command' && params[0]) {
const cmdCode = typeof params[0] === 'string' ? params[0] : params[0].value
const cmd = commandsByCode[cmdCode]
totalExpected += (cmd && cmd.additionalParams ? cmd.additionalParams.length : 0)
}

if (params.length !== totalExpected) {
errors.push(
`Key bind at "${keyPath}" (${bindCode}) expects ${totalExpected} param(s), got ${params.length}`
)
continue
}

// Validate command params
for (let k = 0; k < expectedParams.length; k++) {
if (expectedParams[k] === 'command' && params[k]) {
const cmdCode = typeof params[k] === 'string' ? params[k] : params[k].value
const validCmds = (behaviour.commands || []).map(c => c.code)
if (cmdCode && !validCmds.includes(cmdCode)) {
errors.push(
`Key bind at "${keyPath}" has unknown command "${cmdCode}" for ${bindCode}`
)
}
}
}
}
}
}
Expand Down
Loading