diff --git a/.agents/skills/karabiner/SKILL.md b/.agents/skills/karabiner/SKILL.md new file mode 100644 index 0000000..42bffc5 --- /dev/null +++ b/.agents/skills/karabiner/SKILL.md @@ -0,0 +1,54 @@ +--- +name: karabiner +description: Edit or inspect this machine's Karabiner-Elements config (~/.config/karabiner/). Use whenever the user mentions Karabiner, a key remap, caps-lock/layer behavior, a keyboard shortcut change, or files like karabiner.json — the config is generated from TypeScript; editing the JSON directly loses work on the next build. +--- + +# Karabiner config is generated + +`~/.config/karabiner/karabiner.json` is build output. The source of truth is +`~/.config/karabiner/generator/karabiner-config.ts` (karabiner.ts DSL). Any +remap, rule, condition, or threshold change goes there. The Karabiner GUI may +rewrite the JSON — that is expected; hand edits are what get lost. + +## Workflow + +1. Edit `generator/karabiner-config.ts`. The file is commented rule-by-rule; + match the existing style (`map()`, `rule()`, condition factories like + `capsPressed()`). +2. `cd ~/.config/karabiner/generator && npm ci` — first time only on a + machine (node_modules is not tracked). +3. `npm run build` — regenerates the JSON and writes it to the **live** + `~/.config/karabiner/karabiner.json` ($HOME, never the nearest copy — in + a yadm worktree those are different files). Karabiner-Elements watches + the file and hot-reloads. +4. `npm run verify` — asserts the generator reproduces the live JSON + byte-for-byte. + +Done means verify prints `✓ Generator output is byte-identical to live +karabiner.json`. Byte-exactness matters: the df repo's pre-commit hook +rejects karabiner.json in any other format, so a failing verify blocks the +next commit. + +## If karabiner.json was hand-edited + +The GUI or another agent may have changed the JSON. Recovery: + +1. `git -C ~/.config/karabiner diff` or compare against + `git show HEAD:.config/karabiner/karabiner.json` (run from the yadm + checkout / worktree that owns the file) +2. Port the semantic change into `generator/karabiner-config.ts` +3. `npm run build && npm run verify` until verify passes + +## Gotchas + +- Rule order is load-bearing: later rules take precedence. The vim nav rule + (8/11) must stay before the toggle rule (1/11) so the toggle's any-key + exit doesn't shadow navigation keys. +- Disabled rules (`enabled: false`) are intentional history — port them, + don't drop them. +- Build reads the live JSON to preserve everything outside + `complex_modifications` (devices, virtual_hid_keyboard, global). If a + rule needs adding while the live file is stale, fix the live file first + via the recovery path above. +- Devices: Adv360 Pro BLE = vendor 7504 / product 24926; its USB mode = + 10730/866; gaming keyboard = 7847/2311. diff --git a/.config/karabiner/.gitignore b/.config/karabiner/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/.config/karabiner/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/.config/karabiner/.prettierrc b/.config/karabiner/.prettierrc index 50ba16a..2efe6dd 100644 --- a/.config/karabiner/.prettierrc +++ b/.config/karabiner/.prettierrc @@ -2,7 +2,7 @@ "plugins": ["./prettier-plugin-karabiner.mjs"], "overrides": [ { - "files": ["*.json", "assets/**/*.json"], + "files": ["karabiner.json", "assets/**/*.json"], "options": { "parser": "karabiner-json" } } ] diff --git a/.config/karabiner/generator/karabiner-config.ts b/.config/karabiner/generator/karabiner-config.ts new file mode 100644 index 0000000..35a1538 --- /dev/null +++ b/.config/karabiner/generator/karabiner-config.ts @@ -0,0 +1,376 @@ +// Karabiner-Elements configuration — source of truth for the +// "Default profile" complex_modifications rules. +// +// npm run build generate ~/.config/karabiner/karabiner.json +// npm run verify build + format + compare against live config (byte-exact) +// +// The generator output is normalized to the Karabiner-Elements GUI's +// canonical format (alphabetized keys) and formatted with the repo's +// prettier-plugin-karabiner (json_writer byte format), so a clean build +// produces zero diff against a GUI-maintained karabiner.json. +// +// Ported 1:1 from the hand-maintained karabiner.json, including: +// - all three disabled rules (control+tab, MS RDP, home-row mods) +// - the previously-uncommitted Ctrl+Space input-source rule +// and scripts/toggle-input-source.sh (now tracked) +// - the two hand-written rules re-keyed to the GUI's canonical order +// (content unchanged), so the whole file round-trips byte-identically +// +// Device IDs (from Karabiner-Elements event viewer): +// Adv360 Pro (BLE) vendor 7504 product 24926 +// Adv360 Pro (USB) vendor 10730 product 866 (wired QMK mode) +// gaming keyboard vendor 7847 product 2311 (COMPX 2.4G dongle) + +import { + ifApp, + ifDevice, + ifVar, + map, + mapConsumerKey, + mapSimultaneous, + rule, + withCondition, + withMapper, + type Rule, +} from 'karabiner.ts' + +// --------------------------------------------------------------------------- +// Devices +// --------------------------------------------------------------------------- + +const adv360Ble = { vendor_id: 7504, product_id: 24926 } +const adv360Usb = { vendor_id: 10730, product_id: 866 } +const gamingKeyboard = { vendor_id: 7847, product_id: 2311 } + +// Condition factories — the live config repeats these on every manipulator. +const zenApps = () => ifApp(['app.zen-browser.zen', /^com\.jgoon\.satori$/]) +const capsPressed = () => ifVar('caps_lock pressed', 1) +const vimMode = () => ifVar('vim_mode', 1) +const notAdv360 = () => ifDevice(adv360Ble).unless() +const notTerminalApps = () => + ifApp(['com.googlecode.iterm2', 'com.github.atom', 'com.jetbrains.pycharm']).unless() + +/** Live config uses side-specific left_* modifiers; keep them explicit. */ +const hyper4 = ['left_command', 'left_option', 'left_control', 'left_shift'] + +/** Add `enabled: false` / `title` to a built rule (karabiner.ts has no API). */ +function disable(built: Rule, title?: string): Rule { + return title ? { ...built, enabled: false, title } : { ...built, enabled: false } +} + +// --------------------------------------------------------------------------- +// Rule 1 — Zen Browser tab switching +// --------------------------------------------------------------------------- + +const zenRule = rule( + 'Zen Browser - Cmd+Option+Left/Right to previous/next tab (all keyboards, including QMK macro order)', +).manipulators([ + // QMK macros arrive as left_option+left_command+arrow; fired strictly in + // macro order with a 500ms window, on either Adv360 connection mode. + mapSimultaneous( + ['left_arrow', 'left_command', 'left_option'], + { detect_key_down_uninterruptedly: true, key_down_order: 'strict', key_up_when: 'all' }, + 500, + ) + .modifiers('optionalAny') + .to('open_bracket', ['command', 'shift']) + .condition(zenApps(), ifDevice([adv360Ble, adv360Usb])), + mapSimultaneous( + ['right_arrow', 'left_command', 'left_option'], + { detect_key_down_uninterruptedly: true, key_down_order: 'strict', key_up_when: 'all' }, + 500, + ) + .modifiers('optionalAny') + .to('close_bracket', ['command', 'shift']) + .condition(zenApps(), ifDevice([adv360Ble, adv360Usb])), + // Vanilla Cmd+Option+←/→ on every other keyboard. + map('left_arrow', ['command', 'option']) + .to('open_bracket', ['command', 'shift']) + .condition(zenApps()), + map('right_arrow', ['command', 'option']) + .to('close_bracket', ['command', 'shift']) + .condition(zenApps()), + // Mac keyboard, caps held (layer var from rule 4): ctrl+h / ctrl+l. + map('h', 'left_control') + .to('open_bracket', ['command', 'shift']) + .condition(zenApps(), capsPressed(), notAdv360()) + .description('Mac keyboard Caps+Control+H to previous Zen tab'), + map('l', 'left_control') + .to('close_bracket', ['command', 'shift']) + .condition(zenApps(), capsPressed(), notAdv360()) + .description('Mac keyboard Caps+Control+L to next Zen tab'), +]) + +// --------------------------------------------------------------------------- +// Rule 2 (disabled) — Control+Tab -> immediate Cmd+Tab +// --------------------------------------------------------------------------- + +const controlTabRule = disable( + rule('Control+Tab -> immediate Cmd+Tab on keydown') + .manipulators( + map('tab', 'control', 'any') + .to$( + 'osascript -e \'tell application "System Events" to key code 48 using {command down}\'', + ) + .condition(notAdv360()), + ) + .build(), +) + +// --------------------------------------------------------------------------- +// Rule 3 (disabled) — MS Remote Desktop: left cmd -> left ctrl +// --------------------------------------------------------------------------- + +const msrdRule = disable( + rule('MS Remote Desktop - Left cmd to Left ctrl') + .manipulators( + map({ key_code: 'left_gui' }) + .to('left_control', undefined, { repeat: true }) + .condition(ifApp('com.microsoft.rdc.macos'), notAdv360()), + ) + .build(), + 'MS Remote Desktop', +) + +// --------------------------------------------------------------------------- +// Rule 4 — caps layer: ESC alone, hjkl arrows, du page keys, fg hyper +// --------------------------------------------------------------------------- + +const capsNavRule = rule( + 'CAPS > ESC, CAPS+H/J/K/L > ←↓↑→, CAPS+D/U > PG↓↑', +).manipulators([ + // The descriptions in live are legacy labels from when these were + // control-chords; behavior kept verbatim from the original config. + map('h', 'left_control') + .to('left_arrow', ['left_command', 'left_option']) + .condition(capsPressed(), notAdv360()) + .description('caps + left_control + h for moving to right tab'), + map('l', 'left_control') + .to('right_arrow', ['left_command', 'left_option']) + .condition(capsPressed(), notAdv360()) + .description('caps + left_control + l for moving to left tab'), + map('k', 'left_control') + .to('w', 'left_command') + .condition(capsPressed(), notAdv360()) + .description('caps + left_control + k for closing tabs'), + map('j', 'optionalAny').to('down_arrow').condition(capsPressed(), notAdv360()), + map('k', 'optionalAny').to('up_arrow').condition(capsPressed(), notAdv360()), + map('h', 'optionalAny').to('left_arrow').condition(capsPressed(), notAdv360()), + map('l', 'optionalAny').to('right_arrow').condition(capsPressed(), notAdv360()), + map('d', 'optionalAny').to('page_down').condition(capsPressed(), notAdv360()), + map('u', 'optionalAny').to('page_up').condition(capsPressed(), notAdv360()), + // Layer toggle: press = var on, release = var off, alone = ESC. + map('caps_lock', 'optionalAny') + .toVar('caps_lock pressed', 1) + .toAfterKeyUp({ set_variable: { name: 'caps_lock pressed', value: 0 } }) + .toIfAlone('escape') + .condition(notAdv360()), + map('f', 'optionalAny') + .to('f', hyper4) + .condition(capsPressed(), notAdv360()) + .description('caps + f -> cmd+opt+ctrl+shift+f'), + map('g', 'optionalAny') + .to('g', hyper4) + .condition(capsPressed(), notAdv360()) + .description('caps + g -> cmd+opt+ctrl+shift+g'), +]) + +// --------------------------------------------------------------------------- +// Rule 5 — caps+A / caps+1..0 -> ctrl+A / ctrl+1..0 +// --------------------------------------------------------------------------- + +const capsWorkflowRule = rule( + 'CAPS+A / CAPS+1..0 > CTRL+A / CTRL+1..0 (workflow)', +).manipulators( + withCondition(capsPressed(), notAdv360())( + withMapper(['a', ...'1234567890'.split('')])((key) => + map(key) + .to(key, 'left_control') + .description(`caps + ${key} -> control + ${key}`), + ), + ), +) + +// --------------------------------------------------------------------------- +// Rule 6 — vim mode toggle (caps_lock) +// --------------------------------------------------------------------------- + +/** Turn vim_mode off and clear its notification. */ +const vimOff = () => [ + { set_variable: { name: 'vim_mode', value: 0 } }, + { set_notification_message: { id: 'vim_mode_plus_enabled', text: '' } }, +] + +const vimToggleRule = rule( + '(Vim 1/11) caps_lock -> on, caps_lock, esc, control+[ or any pointing_button -> off', +).manipulators([ + // Hold caps (>=100ms) or tap it: both arm vim_mode. Key-up disarms. + map('caps_lock') + .toIfAlone({ set_variable: { name: 'vim_mode', value: 1 } }) + .toIfHeldDown({ set_variable: { name: 'vim_mode', value: 1 } }) + .toAfterKeyUp(vimOff()) + .parameters({ + 'basic.to_if_alone_threshold_milliseconds': 0, + 'basic.to_if_held_down_threshold_milliseconds': 100, + }) + .condition(notTerminalApps(), ifVar('vim_mode').unless(), notAdv360()), + map('caps_lock') + .to(vimOff()) + .condition(notTerminalApps(), vimMode(), notAdv360()), + map('escape') + .to(vimOff()) + .condition(notTerminalApps(), vimMode(), notAdv360()), + map('open_bracket', 'control') + .to(vimOff()) + .condition(notTerminalApps(), vimMode(), notAdv360()), + // Inside terminal/editor apps the mode is meaningless: any key exits it. + map({ any: 'key_code' }) + .to(vimOff()) + .condition( + ifApp(['com.googlecode.iterm2', 'com.github.atom', 'com.jetbrains.pycharm']), + vimMode(), + notAdv360(), + ), + // Any mouse button exits, on any device. + map({ any: 'pointing_button' }) + .to(vimOff()) + .condition(vimMode(), notAdv360()), +]) + +// --------------------------------------------------------------------------- +// Rule 7 — vim navigation while vim_mode == 1 +// --------------------------------------------------------------------------- + +/** Shared conditions for every vim navigation manipulator. */ +const vimNavConds = () => [notTerminalApps(), vimMode(), notAdv360()] + +const vimNavRule = rule( + '(Vim 8/11) h,j,k,l (+ control/option/command/shift),e,b,0,^,$,gg,G,{,}', +).manipulators([ + // hjkl + u/d as arrows / page keys (any optional modifiers). + ...withCondition(...vimNavConds())([ + map('h', { optional: ['control', 'option', 'command', 'shift'] }).to('left_arrow'), + map('j', { optional: ['control', 'option', 'command', 'shift'] }).to('down_arrow'), + map('k', { optional: ['control', 'option', 'command', 'shift'] }).to('up_arrow'), + map('l', { optional: ['control', 'option', 'command', 'shift'] }).to('right_arrow'), + map('u', { optional: ['control', 'option', 'command', 'shift'] }).to('page_up'), + map('d', { optional: ['control', 'option', 'command', 'shift'] }).to('page_down'), + // Word-wise motion. + map('e').to('right_arrow', 'left_alt'), + map('b').to('left_arrow', 'left_alt'), + map('w').to('right_arrow', 'left_alt'), + // 0 -> cmd+left twice (line start); 6^/4$ via shifted digits. + map('0').to('left_arrow', 'left_command').to('left_arrow', 'left_command'), + map('6', 'shift').to('left_arrow', 'left_command'), + map('4', 'shift').to('right_arrow', 'left_command'), + ]), + // gg: first g sets a var; delayed action (500ms) clears it. Second g + // while set jumps to top. These two carry g_pressed in the middle of + // their condition list (matching the original rule's order). + map('g') + .toVar('g_pressed', 1) + .toDelayedAction([{ set_variable: { name: 'g_pressed', value: 0 } }], []) + .parameters({ 'basic.to_delayed_action_delay_milliseconds': 500 }) + .condition(notTerminalApps(), vimMode(), ifVar('g_pressed').unless(), notAdv360()), + map('g') + .to('up_arrow', 'left_command') + .toVar('g_pressed', 0) + .condition(notTerminalApps(), vimMode(), ifVar('g_pressed'), notAdv360()), + ...withCondition(...vimNavConds())([ + map('g', 'shift').to('down_arrow', 'left_command'), + // {[ ]} -> ctrl+a / ctrl+e (bracketed paragraph jumps). + map('open_bracket', 'shift').to('a', 'left_control'), + map('close_bracket', 'shift').to('e', 'left_control'), + ]), +]) + +// --------------------------------------------------------------------------- +// Rule 8 (disabled) — home-row mods +// --------------------------------------------------------------------------- + +const homeRowModsRule = disable( + rule('Home Row Mods: ASDF -> GACS, JKL; -> SCAG') + .manipulators( + withMapper({ + a: 'left_command', + s: 'left_option', + d: 'left_control', + f: 'left_shift', + j: 'right_shift', + k: 'right_control', + l: 'right_option', + semicolon: 'right_command', + })((key, mod) => + map(key, 'optionalAny') + .toIfAlone({ halt: true, key_code: key }) + .toIfHeldDown(mod) + .toDelayedAction([], [{ key_code: key }]) + .parameters({ + 'basic.to_delayed_action_delay_milliseconds': 150, + 'basic.to_if_held_down_threshold_milliseconds': 150, + }) + .condition(notAdv360()), + ), + ) + .build(), +) + +// --------------------------------------------------------------------------- +// Rule 9 — EarPods center button +// --------------------------------------------------------------------------- + +const earpodsRule = rule( + 'EarPods: tap = toggle Handy dictation, hold ≥250ms = play/pause music (fires mid-press)', +).manipulators( + mapConsumerKey('play_or_pause') + .toAfterKeyUp({ + shell_command: '/Users/jgoon/.config/karabiner/scripts/earpods-release.sh', + }) + .toIfHeldDown({ + shell_command: '/Users/jgoon/.config/karabiner/scripts/earpods-music-toggle.sh', + }) + .parameters({ 'basic.to_if_held_down_threshold_milliseconds': 250 }), +) + +// --------------------------------------------------------------------------- +// Rule 10 — Ctrl+Space input source toggle +// --------------------------------------------------------------------------- + +const inputSourceRule = rule( + 'Ctrl+Space toggles EN/Chinese via macism on all keyboards EXCEPT gaming keyboard; never in Roblox', +).manipulators( + map('spacebar', 'control') + .to$('$HOME/.config/karabiner/scripts/toggle-input-source.sh') + .condition( + ifApp(/^com\.roblox\./).unless(), + ifDevice([gamingKeyboard, adv360Usb]).unless(), + ), +) + +// --------------------------------------------------------------------------- +// Assemble + write +// --------------------------------------------------------------------------- + +const rules = [ + zenRule, + controlTabRule, + msrdRule, + capsNavRule, + capsWorkflowRule, + // NOTE: live order puts the nav rule (Vim 8/11) before the toggle rule + // (Vim 1/11) — later rules take precedence, and the toggle's `any key` + // exit-off manipulator must not shadow navigation keys. + vimNavRule, + vimToggleRule, + homeRowModsRule, + earpodsRule, + inputSourceRule, +] + +export { rules } + +// When run directly (npm run build / verify), write the profile. +if (import.meta.url === `file://${process.argv[1]}`) { + const { writeProfile } = await import('./lib/write.ts') + writeProfile(rules) +} diff --git a/.config/karabiner/generator/lib/verify.ts b/.config/karabiner/generator/lib/verify.ts new file mode 100644 index 0000000..1e64494 --- /dev/null +++ b/.config/karabiner/generator/lib/verify.ts @@ -0,0 +1,42 @@ +// lib/verify.ts — prove the generator reproduces the live karabiner.json +// byte-for-byte. +// +// npm run verify (from .config/karabiner/generator) +// +// Reads the live config, swaps in the generated complex_modifications +// (exactly like writeProfile does), canonicalizes + formats through the +// same pipeline, and compares to the file on disk. Exits non-zero and +// writes the expected bytes to /tmp on divergence. + +import { readFileSync, writeFileSync } from 'node:fs' +import { canonicalize, format, KARABINER_JSON } from './write.ts' + +async function main() { + const live = readFileSync(KARABINER_JSON, 'utf8') + const expected = await renderExpected(live) + if (expected === live) { + console.log('✓ Generator output is byte-identical to live karabiner.json') + return + } + console.error('✗ Generator output diverges from live karabiner.json') + console.error(` generated: ${expected.length} bytes, live: ${live.length} bytes`) + writeFileSync('/tmp/karabiner-generated-expected.json', expected) + console.error(' expected bytes written to /tmp/karabiner-generated-expected.json') + process.exitCode = 1 +} + +/** Build the expected bytes for a given live-config body. */ +async function renderExpected(live: string) { + const { complexModifications } = await import('karabiner.ts') + const { rules } = await import('../karabiner-config.ts') + const existing = JSON.parse(live) + const cm = complexModifications(rules) + const profile = existing.profiles.find( + (p: { name: string }) => p.name === 'Default profile', + ) + if (!profile) throw new Error('Profile "Default profile" not found') + profile.complex_modifications = { rules: cm.rules } + return format(canonicalize(existing)) +} + +main() diff --git a/.config/karabiner/generator/lib/write.ts b/.config/karabiner/generator/lib/write.ts new file mode 100644 index 0000000..40edf29 --- /dev/null +++ b/.config/karabiner/generator/lib/write.ts @@ -0,0 +1,86 @@ +// lib/write.ts — merge generated rules into the live karabiner.json. +// +// karabiner.ts's writeToProfile() replaces complex_modifications wholesale +// and injects five default parameters the live config doesn't have, so we +// do the merge ourselves with complexModifications() + a strict writer: +// +// - profile "Default profile" complex_modifications is replaced by the +// generated rules (no parameters object, matching the live file) +// - everything else (global, devices, virtual_hid_keyboard, …) is +// preserved verbatim from the existing file +// - output is canonicalized to GUI key order (alphabetical, like +// nlohmann::json's std::map) and formatted byte-identically to +// Karabiner-Elements' json_writer via prettier + prettier-plugin-karabiner + +import { complexModifications } from 'karabiner.ts' +import prettier from 'prettier' +import { readFileSync, writeFileSync } from 'node:fs' + +export const KARABINER_JSON = + process.env.KARABINER_JSON ?? `${process.env.HOME}/.config/karabiner/karabiner.json` + +export { canonicalize, format } + +/** + * Sort keys recursively, matching Karabiner-Elements' GUI writer output. + * `global` is exempt: the GUI (and writeToGlobal) preserve declaration + * order there, e.g. show_in_menu_bar before enable_cgeventtap_fallback. + * Also drops empty `to_if_invoked`/`to_if_canceled` arrays that + * karabiner.ts emits but json_writer never writes. + */ +function canonicalize(value: unknown, isGlobal = false): unknown { + if (Array.isArray(value)) return value.map((v) => canonicalize(v)) + if (typeof value === 'object' && value !== null) { + const out: Record = {} + const keys = Object.keys(value as Record) + for (const key of isGlobal ? keys : keys.sort()) { + const v = (value as Record)[key] + const childIsGlobal = key === 'global' && !isGlobal + out[key] = canonicalize(v, childIsGlobal) + } + // Prune empty delayed-action branches (json_writer omits them). + if ('to_delayed_action' in out) { + const da = out.to_delayed_action as Record + for (const branch of ['to_if_invoked', 'to_if_canceled']) { + if (Array.isArray(da[branch]) && da[branch].length === 0) delete da[branch] + } + if (Object.keys(da).length === 0) delete out.to_delayed_action + } + return out + } + return value +} + +/** Format with the repo's karabiner prettier plugin (json_writer bytes). */ +async function format(json: unknown): Promise { + const text = JSON.stringify(json) + const out = await prettier.format(text, { + parser: 'karabiner-json', + plugins: [prettierPlugin()], + }) + // Karabiner's json_writer never emits a trailing newline. + return out.replace(/\n$/, '') +} + +function prettierPlugin() { + // The repo-local plugin, one directory above the generator project. + const url = new URL('../../prettier-plugin-karabiner.mjs', import.meta.url) + return import(url.href) +} + +export async function writeProfile(rules: unknown[]) { + const cm = complexModifications(rules as never) + // Live config omits a profile-level parameters object; complexModifications + // always injects five defaults, so strip them back out for byte parity. + const generated = { rules: cm.rules } + const existing = JSON.parse(readFileSync(KARABINER_JSON, 'utf8')) + const profile = existing.profiles.find( + (p: { name: string }) => p.name === 'Default profile', + ) + if (!profile) throw new Error('Profile "Default profile" not found') + profile.complex_modifications = generated + + const formatted = await format(canonicalize(existing)) + writeFileSync(KARABINER_JSON, formatted) + console.log(`✓ Wrote ${cm.rules.length} rules to ${KARABINER_JSON}`) +} diff --git a/.config/karabiner/generator/package.json b/.config/karabiner/generator/package.json new file mode 100644 index 0000000..bcab45b --- /dev/null +++ b/.config/karabiner/generator/package.json @@ -0,0 +1,17 @@ +{ + "name": "karabiner-config", + "version": "1.0.0", + "description": "Karabiner-Elements config generated by karabiner.ts", + "type": "module", + "dependencies": { + "karabiner.ts": "^1.38.0" + }, + "devDependencies": { + "prettier": "^3.9.9", + "@types/node": "^22" + }, + "scripts": { + "build": "node karabiner-config.ts", + "verify": "node lib/verify.ts" + } +} diff --git a/.config/karabiner/karabiner.json b/.config/karabiner/karabiner.json index 539d48a..1d88a90 100644 --- a/.config/karabiner/karabiner.json +++ b/.config/karabiner/karabiner.json @@ -2059,11 +2059,11 @@ "description": "EarPods: tap = toggle Handy dictation, hold ≥250ms = play/pause music (fires mid-press)", "manipulators": [ { - "type": "basic", "from": { "consumer_key_code": "play_or_pause" }, + "parameters": { "basic.to_if_held_down_threshold_milliseconds": 250 }, "to_after_key_up": [{ "shell_command": "/Users/jgoon/.config/karabiner/scripts/earpods-release.sh" }], "to_if_held_down": [{ "shell_command": "/Users/jgoon/.config/karabiner/scripts/earpods-music-toggle.sh" }], - "parameters": { "basic.to_if_held_down_threshold_milliseconds": 250 } + "type": "basic" } ] }, @@ -2071,33 +2071,33 @@ "description": "Ctrl+Space toggles EN/Chinese via macism on all keyboards EXCEPT gaming keyboard; never in Roblox", "manipulators": [ { - "type": "basic", - "from": { - "key_code": "spacebar", - "modifiers": { "mandatory": ["control"] } - }, "conditions": [ { - "type": "frontmost_application_unless", "bundle_identifiers": [ "^com\\.roblox\\." - ] + ], + "type": "frontmost_application_unless" }, { - "type": "device_unless", "identifiers": [ { - "vendor_id": 7847, - "product_id": 2311 + "product_id": 2311, + "vendor_id": 7847 }, { - "vendor_id": 10730, - "product_id": 866 + "product_id": 866, + "vendor_id": 10730 } - ] + ], + "type": "device_unless" } ], - "to": [{ "shell_command": "$HOME/.config/karabiner/scripts/toggle-input-source.sh" }] + "from": { + "key_code": "spacebar", + "modifiers": { "mandatory": ["control"] } + }, + "to": [{ "shell_command": "$HOME/.config/karabiner/scripts/toggle-input-source.sh" }], + "type": "basic" } ] }