aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-x.claude/hooks/validate-el.sh5
-rw-r--r--.gitignore1
-rw-r--r--CLAUDE.md2
-rw-r--r--docs/design/keybinding-console-safety-spec.org939
-rw-r--r--docs/design/messenger-unification-spec.org47
-rw-r--r--modules/dashboard-config.el9
-rw-r--r--modules/org-capture-config.el123
-rw-r--r--modules/org-drill-config.el9
-rw-r--r--modules/org-roam-config.el10
-rw-r--r--modules/prog-general.el16
-rw-r--r--modules/ui-navigation.el12
-rw-r--r--tests/test-dashboard-config-recentf-exclude.el33
-rw-r--r--tests/test-org-capture-config-popup-window.el281
-rw-r--r--tests/test-org-drill-config-commands.el45
-rw-r--r--tests/test-org-roam-config-dailies-head.el29
-rw-r--r--tests/test-prog-general--electric-pair-angle.el54
-rw-r--r--tests/test-ui-navigation-split-follow-undo-kill.el29
-rw-r--r--todo.org254
18 files changed, 1727 insertions, 171 deletions
diff --git a/.claude/hooks/validate-el.sh b/.claude/hooks/validate-el.sh
index d6999ac0..2529fccb 100755
--- a/.claude/hooks/validate-el.sh
+++ b/.claude/hooks/validate-el.sh
@@ -51,7 +51,12 @@ case "$f" in
fi
;;
*.el)
+ # -L the file's own directory (and a sibling project root for files
+ # under a tests/ subdir) so cross-project edits compile against their
+ # own modules, not just this project's.
if ! output="$(emacs --batch --no-site-file --no-site-lisp \
+ -L "$(dirname "$f")" \
+ -L "$(dirname "$f")/.." \
-L "$PROJECT_ROOT" \
-L "$PROJECT_ROOT/modules" \
-L "$PROJECT_ROOT/tests" \
diff --git a/.gitignore b/.gitignore
index 1ce36bf2..274ac4b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -99,3 +99,4 @@ __pycache__/
# editor/image backup files
*.bak
+smoke/
diff --git a/CLAUDE.md b/CLAUDE.md
index 79e68ada..b6383975 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -85,3 +85,5 @@ Prefer Write over cumulative Edits for nontrivial new code. Small functions (und
- **Warn at module load when an external tool path is configured but missing.** Calling `cj/executable-find-or-warn` (from `system-lib.el`) at `:config` time emits a `display-warning` if `prettier` / `pyright` / `pandoc` / etc. isn't on PATH, instead of letting the first format-on-save or LSP-attach fail with a confusing mid-edit error. Pattern in use: `modules/prog-webdev.el` (prettier), `modules/prog-python.el` (pyright). (`pattern` — 2026-05-16)
- **ghostel F-key / prefix bindings need `ghostel-keymap-exceptions` + a rebuild, not just `ghostel-mode-map`.** In semi-char mode ghostel forwards every key not in `ghostel-keymap-exceptions` to the pty, and `ghostel-semi-char-mode-map` (rebuilt from that list, and outranking the major-mode map) wins. So binding F9 / F12 / C-; in `ghostel-mode-map` alone is silently dead inside agent/terminal buffers — the key reaches the shell, not Emacs. Fix: add the key to `ghostel-keymap-exceptions` AND call `ghostel--rebuild-semi-char-keymap` (`add-to-list` updates the list but not the already-built map). `term-config.el` (C-;, F12) and `ai-term.el` (F9 family) do this in their `with-eval-after-load 'ghostel`. This is the opposite of vterm, where binding in `vterm-mode-map` sufficed. (`gotcha` — 2026-06-05)
+
+- **Rulesets-owned changes propagate by edit-local + send-copy + explanatory note.** A bug or enhancement that belongs to a rulesets-owned synced file (a workflow under `.ai/workflows/`, a skill, a rule under `.claude/rules/`, a script under `.ai/scripts/`) is handled by editing the local copy so it's usable now, then sending rulesets a copy of the edited file plus an explanatory note — a local edit alone is overwritten on the next template sync, so the canonical update is what makes it durable. The note covers: how the problem was hit, what outcomes the change should alter, any implementation recommendations, and any follow-up instructions (e.g. send a note back with more info). Send notes with the inbox-send script (`inbox-send rulesets --file <path>`). Offer the change proactively when it would help. (`pattern` — 2026-06-12)
diff --git a/docs/design/keybinding-console-safety-spec.org b/docs/design/keybinding-console-safety-spec.org
new file mode 100644
index 00000000..d06c5a27
--- /dev/null
+++ b/docs/design/keybinding-console-safety-spec.org
@@ -0,0 +1,939 @@
+#+TITLE: Keymap Consolidation — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-12
+
+* Metadata
+| Status | draft |
+|----------+--------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+--------------------------------------------------------------------|
+| Reviewer | TBD (multi-reviewer cycle) |
+|----------+--------------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org]] — "M-S- launcher keys" task (to be reclassified, Phase 0) |
+|----------+--------------------------------------------------------------------|
+
+* Summary
+
+Some commonly-used window/layout commands are bound to =M-S-<letter>= chords that only work in GUI frames, via a fragile =key-translation-map= layer that already caused a regression.
+
+The primary work consolidates the common commands under the =cj/custom-keymap= personal keymap and retires the fragile translation layer — independent of any prefix choice. Console reachability is then a one-line, *optional* follow-on: bind that one keymap to a single console-safe prefix (a =Control=+key, or a free =M-<punctuation>=; candidates in Appendix C), used everywhere. Per Path 2 (2026-06-13), the work proceeds up to the point of assigning that prefix and stops there; the assignment is a deferred phase Craig takes when he picks the key.
+
+The aim: consolidate the common commands into one keymap and retire the translation block now, leaving a single, optional console-safe prefix to switch on later.
+
+* Problem / Context
+
+A subset of common commands is bound to =M-S-<letter>= chords (Meta + Shift + lowercase letter). Pressing Meta+Shift+e emits the event =M-E= (uppercase Meta), but the command is bound to =M-S-e=; the bridge between them is a =key-translation-map= entry that =modules/keyboard-compat.el= installs *only* in GUI frames (=env-gui-p=). So these chords are dead in terminal frames and dead in the Linux console.
+
+Craig does not use terminal or console Emacs often, but falls back to the console in emergencies (a broken graphical session). When common keys are unavailable there, the editor stops being usable for the emergency and he has to switch tools. For *uncommon* commands, =M-x= is an acceptable fallback; for *common* ones it is not.
+
+How each key family actually behaves across the three contexts (the facts the design turns on):
+
+| Context | Meta sent as | =M-S-e= (as bound) | =M-E= (uppercase Meta) | =C-;= |
+|-------------------------+--------------+-------------------------+-------------------------+-------------------------|
+| GUI frame | native event | reached only via the | intercepted by the | works natively |
+| | | GUI translation map | translation map | |
+|-------------------------+--------------+-------------------------+-------------------------+-------------------------|
+| Terminal emulator | ESC prefix | dead (keypress emits | works (ESC E), if no | works if the emulator |
+| (xterm-family) | | =M-E=, binding is on | translation intercepts | speaks |
+| | | =M-S-e=) | | modifyOtherKeys/kitty |
+| | | | | (recent Emacs |
+| | | | | auto-enables for |
+| | | | | xterm-family) |
+|-------------------------+--------------+-------------------------+-------------------------+-------------------------|
+| Linux console | ESC prefix | dead (same reason) | works (ESC E) | DEAD — semicolon is not |
+| (TERM=linux) | | | | a control char; cannot |
+| | | | | be transmitted |
+|-------------------------+--------------+-------------------------+-------------------------+-------------------------|
+
+Three consequences: =M-S-e= is dead outside GUI by construction; =C-;= is solid in GUI, conditional in terminal emulators, and dead in the Linux console (so it cannot be the *only* home for console-critical commands); and =M-E= plus function keys and =C-c= sequences are transmittable everywhere, which is the material to build a console-safe path from.
+
+** The regression that triggered this
+
+Commit =4a1ecf64= "fixed" three launcher keys (=eww=/=elfeed=/=calibredb=) by rebinding them from =M-S-e/r/b= to =M-E/M-R/M-B=. It was wrong, and three review passes missed it because they all used =key-binding=, which consults keymaps only and ignores =key-translation-map=. The original audit "verified dead in the live daemon" with that blind check (false positive); the fix bound =M-E= but left the =M-E -> M-S-e= translation entry in place, so in GUI the keypress is rewritten to the now-unbound =M-S-e= and the launchers break on the next restart; and the new test asserted =(key-binding (kbd "M-E"))=, passing against a configuration broken at the keyboard. It only appears to work in the running daemon because the pre-fix binding is still loaded as stale state — the stale-daemon trap.
+
+The lesson is encoded into the acceptance criteria: real reachability is not =key-binding= when a translation map participates.
+
+* Goals and Non-Goals
+
+** Goals
+- Every *commonly used* command is reachable in GUI, terminal emulators, and the Linux console.
+- One canonical personal command surface, so console-reachability is solved once at the prefix level rather than per command.
+- Retire the =keyboard-compat.el= =M-uppercase -> M-S-lowercase= translation block, the root of the fragility.
+- Keep daily ergonomics: high-frequency commands keep a fast chord in GUI.
+
+** Non-Goals
+- Making *every* binding console-safe. Uncommon commands may live on =M-x= only.
+- A ground-up keymap redesign. This is about reachability and retiring one fragile mechanism.
+- Defeating the Linux virtual console's hard limits (it cannot transmit =C-;=, and Meta+Shift behaviour varies). The design routes around them.
+
+** Scope tiers
+- *v1 (primary — Phases 0–2):*
+ - revert =4a1ecf64= (Phase 0, unblocks the push);
+ - prune the forgotten keybindings Craig marks in Appendix D;
+ - migrate the common window/layout =M-S-= commands into =cj/custom-keymap=;
+ - drop the uncommon chords to =M-x=;
+ - retire the translation block;
+ - translation-aware tests.
+- *Deferred / optional (Phase 3):*
+ - bind =cj/custom-keymap= to a single console-safe prefix (D1/D3) once Craig picks the key — the console-reachability payoff, switched on later.
+- *Out of scope:*
+ - enabling modifyOtherKeys/kitty for terminal emulators (helps terminals, not the console; orthogonal).
+- *vNext:* auditing non-=M-S-= GUI-only chords (modified function keys, =C-+/C-==) for console behaviour.
+
+* Design
+
+Craig's first choice (review, 2026-06-12): instead of two prefixes, retrain muscle memory onto *one* console-safe prefix that works everywhere — a =Control= + home-row key that is lightly used or easily/intuitively rebound. =cj/custom-keymap= moves from =C-;= to that single prefix (=C-;= may stay bound during the transition since one keymap can carry many prefixes). The candidate list is Appendix C; the standout home-row candidate is =C-l=. The two-prefix design below is the documented fallback if no single prefix proves acceptable.
+
+The personal command surface is already a single keymap object, =cj/custom-keymap= (=modules/keybindings.el=), bound to =C-;=. The whole design rests on one Emacs fact: a keymap is an object and can be bound to more than one prefix. So console-reachability is a *prefix* problem, not a per-command problem.
+
+For a user (single-prefix path): you reach your personal commands with the one console-safe prefix in GUI, terminal, and console alike — same menu, same keys after the prefix, nothing per-context to remember. (Fallback two-prefix path: =C-;= in the GUI as today, plus a second console-safe prefix anywhere.)
+
+For the implementer: add one line — =(keymap-global-set "<console-safe-prefix>" cj/custom-keymap)= — and the entire tree in Appendix A becomes reachable through it; nothing per-command. The work then is to move the *common* console-dead commands (the window/layout =M-S-= subset, Appendix B) *into* =cj/custom-keymap= so they inherit that reachability, drop the *uncommon* =M-S-= chords to =M-x=, and delete the now-unused translation block. High-frequency window commands additionally keep a fast chord so daily GUI use doesn't regress to a 3-key sequence (Decision D4).
+
+The console-dead common set is window/layout work, which has no =C-;= sub-prefix today, so v1 adds one (a new window sub-map; letter is Decision D5). The =C-c=/=C-h=/=C-z=/=C-x= and plain function-key bindings already work in the console and stay where they are.
+
+* Alternatives Considered
+
+** A — Revert 4a1ecf64 and keep the translation layer as the end state
+- Good, because it is the smallest change and restores correctness immediately.
+- Bad, because it keeps 18 keys on the GUI-only mechanism that already bit us and
+ leaves the console-dead problem unsolved.
+- Neutral, because the revert itself is still needed as Phase 0; this option just
+ stops there.
+
+** B — Migrate the whole family to direct uppercase-Meta, delete the translation block, no C-; move
+- Good, because it preserves every single-chord and =M-E= (ESC + uppercase) is
+ transmittable in GUI, terminal, and console alike.
+- Bad, because it bets the emergency-console guarantee on Meta+Shift behaving
+ cleanly on every console keyboard, which is probable but not certain, and it
+ gives the common commands no robust prefix-based fallback.
+- Neutral, because it still deletes the translation block (shared with the chosen
+ design) and could be layered onto the frequent-chord subset (see D4 Option B).
+
+** C — Enable an enhanced keyboard protocol (modifyOtherKeys / kitty) so C-; works in terminals
+- Good, because it makes =C-;= itself work in capable terminal emulators.
+- Bad, because it does nothing for the Linux virtual console (a hard limit), and
+ adds a terminal-capability dependency.
+- Neutral, because it is orthogonal and could be added later without conflicting.
+
+** Chosen — one map, two prefixes (consolidate common commands under C-;, add a console-safe alt prefix)
+- Good, because console-reachability is solved once at the prefix; it depends on
+ exactly one prefix working, and that prefix is chosen to be bulletproof.
+- Bad, because moved commands cost a muscle-memory transition, and a pure
+ sub-prefix path is 3 keys (mitigated by D4 for the frequent ones).
+- Neutral, because it still requires the revert (Phase 0) and the translation-
+ block deletion (shared with B).
+
+* Decisions [3/5]
+
+Each decision is a TODO task. It flips to DONE when Craig agrees with the call; if
+he doesn't, it stays TODO and the discussion continues under its =*** Discussion=
+child header.
+
+*Gate (Path 2).* The decisions split by which work they block. D2, D4, and D5 gate
+the *primary* work (Phases 0–2: revert, prune, consolidate, retire the translation
+block); the spec is implementation-ready for that work once those three are DONE. D1
+and D3 (the console-safe prefix) gate *only* the optional Phase 3 — they can stay
+TODO indefinitely without blocking the consolidation. So yes: the work proceeds all
+the way to the point of assigning the prefix and stops there, exactly as Craig asked,
+even if D1/D3 are never decided. The =[n/5]= cookie tracks the overall tally; full
+=ready= (including Phase 3) still needs all five.
+
+** DONE D1 — One map, one console-safe prefix (single-prefix primary; two-prefix fallback)
+CLOSED: [2026-06-13 Sat 00:20]
+- Owner / by-when: Craig / review cycle
+- Context: the common console-dead commands need to be reachable in the console;
+ =C-;= alone is dead there; per-command console bindings would not scale.
+- Decision (revised): We keep =cj/custom-keymap= as the single personal surface.
+ *Primary (Craig's first choice):* rebind it to ONE console-safe prefix — a
+ =Control= + lightly-used home-row key (Appendix C; standout =C-l=) — used in GUI,
+ terminal, and console alike, retraining muscle memory off =C-;=. =C-;= may stay
+ bound during the transition. *Fallback:* if no single prefix is acceptable, bind
+ the map to both =C-;= (GUI) and one console-safe alternate prefix (D3).
+- Consequences: easier — one prefix to make console-safe, whole tree travels, and
+ the single-prefix path needs no per-context mnemonic; harder — every
+ console-critical command must actually live under =cj/custom-keymap= (so the
+ common =M-S-= set is still migrated in), and the single-prefix path costs a
+ full =C-;= → new-prefix muscle-memory transition.
+*** Discussion
+- Direction agreed by Craig 2026-06-12: single-prefix primary, two-prefix fallback.
+- Deferred by Craig 2026-06-13 (Path 2): the console-safe prefix becomes the optional
+ Phase 3, not part of the primary work. The consolidation (Phases 0–2) lands without
+ it, so D1 no longer blocks anything until Craig chooses to do Phase 3. It stays TODO
+ as the marker for "decide the prefix later." The phases are rewritten accordingly,
+ and the keybinding audit Craig asked for lives in Appendix D.
+
+** DONE D2 — Migrate only the common (window/layout) M-S- set; drop the uncommon to M-x
+CLOSED: [2026-06-13 Sat 00:22]
+- Owner / by-when: Craig / review cycle
+- Context: of the 18 =M-S-= commands, only window/layout control is plausibly
+ needed in an emergency console session; apps and one-off tools are not.
+- Decision: We will move the window/layout subset (=M-S-o/m/v/h/t/u/z=, and
+ =M-S-k= pending review) into =C-;=, and remove the other ten =M-S-= chords,
+ leaving those commands on =M-x=.
+- Consequences: easier — shrinks the translation block to nothing, focuses the
+ console surface on essentials; harder — the dropped commands lose a chord;
+ =show-kill-ring='s classification is a judgment call.
+*** Discussion
+- Not yet reviewed by Craig. Open: confirm the window/layout subset to migrate
+ (incl. =M-S-k= show-kill-ring's common/uncommon call) and that the other ten
+ drop to =M-x=. Flip to DONE on Craig's sign-off.
+
+
+** TODO D3 — The console-safe prefix (pick from Appendix C)
+- Owner / by-when: Craig / review cycle
+- Context: under D1's single-prefix primary, this prefix is THE personal-keymap
+ prefix; under the two-prefix fallback it is the second (alternate) binding. It
+ must transmit in the Linux console, where only =Control= + letter chords carry
+ (and TAB/RET/LF/ESC/DEL collisions and =C-g= are excluded). Full candidate
+ analysis is Appendix C.
+- Decision: For the single-prefix path, =C-l= is the standout (home-row,
+ console-safe, default =recenter-top-bottom= is light and trivially relocatable);
+ =C-q= / =C-o= / =C-t= are off-home-row runners-up. For the two-prefix fallback,
+ =C-c ;= (=C-c= transmits; =;= is a plain key; mnemonic mirror of =C-;=) stays the
+ recommendation. Craig picks the prefix.
+- Consequences: easier — solves console reachability for the whole tree at one
+ binding; harder — a single =Control=+letter prefix displaces its default command
+ (relocate =recenter-top-bottom= if =C-l=), and =C-l= is also bound to
+ =vertico-insert= inside the minibuffer (=selection-framework.el:42=) — minibuffer-
+ local, so no conflict with a global prefix, but worth noting.
+*** Discussion
+- Open: Craig picks the prefix. Recommendation =C-l= (only clean home-row option);
+ runners-up =C-q= / =C-o= / =C-t=. Flip to DONE on the pick. D1 closes with it.
+
+
+#+begin_src cj: comment
+it's not going to be C-l. That's too hard of a habit for me to kick right now. I'd rather go C-c ; altogether -- even in GUI -- than have C-l do the wrong thing when I hit it. We'll find something. But it's not decided yet. Change the status of this decision to waiting.
+#+end_src
+
+** DONE D4 — Fast-chord strategy for high-frequency window ops
+CLOSED: [2026-06-13 Sat 00:25]
+- Owner / by-when: Craig / review cycle
+- Context: =split-and-follow-right/below= and =undo-kill-buffer= are pressed
+ constantly; a 3-key =C-; <w> v= sequence is a real downgrade.
+- Decision: We will (Option A) keep a fast GUI chord for the frequent commands in
+ addition to their =C-;= entry, OR (Option B) bind them to direct uppercase-Meta
+ single chords and retire the translation block. Review picks.
+- Consequences: A — preserves speed, but the fast chord may itself be GUI-only
+ unless it is a function key; B — single chord works in all three contexts but
+ leans on console Meta+Shift.
+*** Discussion
+- Open: Craig picks Option A (keep fast GUI chord) vs Option B (direct
+ uppercase-Meta single chords). Note: if D3 lands a single console-safe prefix,
+ Option B's console rationale weakens. Flip to DONE on the pick.
+
+#+begin_src cj: comment
+we can simply revert
+#+end_src
+
+** TODO D5 — Window sub-prefix and apps disposition
+- Owner / by-when: Craig / review cycle
+- Context: window/layout has no =C-;= sub-prefix. Free single lowercase letters are
+ =i q u y z= (=g= is calendar, =h= is Hugo — both taken); most uppercase is free.
+ =C-; L= is reserved for the Pearl/Linear package — do NOT reuse it. The four apps
+ (=eww=/=elfeed=/=calibredb=/=wttrin=) could go to =M-x= or a launcher sub-prefix.
+
+ #+begin_src cj: comment
+ add a listing of the keybindings we're discussing. I don't know what the window/layout keybindings you're discussing. It's not shift arrow keys, is it?
+ #+end_src
+- Decision: We will add a window sub-prefix under =C-;= (letter TBD from the free
+ set). Apps: Craig decided the launcher commands get real keys under a launcher
+ sub-prefix (=e/f/b/w= leaves), NOT =M-x=. Sub-prefix letter TBD; the freed
+ =C-; a t= (ai-assistant toggle, see Phase 0) is one candidate location if the
+ apps belong nearer the AI tools. Both sub-prefix letters are Craig's pick.
+- Consequences: easier — groups window ops and launcher apps discoverably under
+ which-key, and the launcher apps inherit console reachability for free; harder —
+ spends two scarce top-level =C-;= letters from the small free set.
+*** Discussion
+- Apps half agreed by Craig 2026-06-12: launcher sub-prefix, not =M-x=. Open: the
+ window sub-prefix letter and the launcher sub-prefix letter, both from the free
+ set {=i q u y z=} + uppercase (NOT =L= — Pearl). Flip to DONE once both letters
+ are chosen.
+
+* Implementation phases
+
+Path 2 (Craig, 2026-06-13): Phase 0 is a *pure revert* that unblocks the held push; the migration follows, and the console-safe prefix is an *optional, deferred* phase. Everything proceeds up to the point of assigning the prefix (end of Phase 2) and stops there; Phase 3 is the optional assignment once Craig picks the prefix. So the consolidation does not wait on the prefix decision (D1/D3); only Phase 3 does.
+
+** Phase 0 — Revert the regression (unblocks the push)
+Revert =4a1ecf64= and nothing more: restore =M-S-e/r/b= in the three modules and delete the flawed test (=tests/test-launcher-meta-shift-keys.el=), leaving a clean, correct baseline. Reclassify the "M-S- launcher keys" task as not-a-bug — the keys worked via the GUI translation layer. This is the only step the held 12-commit stack needs before it can push. Per Path 2, the launchers get reverted to =M-S-= here and move to their new homes in Phase 2 — the accepted small throwaway (3 bindings) of not waiting on the full move-map.
+
+The flawed test asserts the launcher bindings with =key-binding= alone:
+
+#+begin_src emacs-lisp
+(should (eq (key-binding (kbd "M-E")) 'eww))
+(should (eq (key-binding (kbd "M-R")) 'cj/elfeed-open))
+(should (eq (key-binding (kbd "M-B")) 'calibredb))
+#+end_src
+
+=key-binding= consults keymaps only and ignores =key-translation-map=, so the test passes even though the GUI translation entry =M-E -> M-S-e= rewrites the keypress back to the now-unbound =M-S-e=. It cannot see the rewrite, so it certifies a configuration that is broken at the keyboard. Phase 2's translation-aware assertion replaces it.
+
+** Phase 1 — Audit and prune forgotten keybindings (Appendix D)
+Appendix D inventories every keybinding Craig has set outside the =C-;= tree and the =M-S-= family — the place to catch chords set-and-forgotten. Craig checks the boxes for the bindings to retire; remove those. Independent cleanup, and a good moment to clear cruft before the migration. Tree working.
+
+** Phase 2 — Consolidate: migrate the common set, retire the translation block
+The primary deliverable, needing *no* console-safe-prefix decision. Migrate the window/layout =M-S-= subset into =cj/custom-keymap= under a new window sub-prefix (D5); add the launcher sub-prefix (D5) with the =eww=/=elfeed=/=calibredb=/=wttrin= leaves (freeing =C-; a t= — the =cj/toggle-gptel= ai-assistant toggle, =ai-config.el:541=, unfinished and far less used than the =ai-term= F9 launcher — if the letter is tight); apply the fast-chord strategy (D4); drop the ten uncommon =M-S-= chords to =M-x= (D2); delete =keyboard-compat.el='s translation block and its hook (keep the arrow-key =input-decode-map= setup); add the translation-aware tests (see Acceptance criteria) and update the docs. At the end of Phase 2 the work is "done" per Craig's stop point. Tree working.
+
+** Phase 3 — (OPTIONAL, deferred) Bind the console-safe prefix
+Only once Craig picks the prefix (D1/D3, Appendix C). Bind =cj/custom-keymap= to it — =(keymap-global-set "<prefix>" cj/custom-keymap)= — and if the pick is =C-l=, relocate its default =recenter-top-bottom= first. This is the console-reachability payoff: the whole tree becomes reachable in =emacs -nw= and the Linux console through one prefix. Verify in a *fresh* session, not the live daemon. May be deferred indefinitely; the consolidation stands on its own without it.
+
+* Acceptance criteria
+- [ ] The whole =cj/custom-keymap= tree is reachable in a GUI frame, an =emacs -nw= xterm-family terminal, and the Linux virtual console via the alt prefix.
+- [ ] The final "common" commands are reachable in all three contexts.
+- [ ] =keyboard-compat.el='s translation block is gone; no command depends on it.
+- [ ] For any chord claimed to run command X, tests assert BOTH =(key-binding (kbd CHORD))= AND =(lookup-key key-translation-map (kbd CHORD))= are consistent (the latter =nil=, or pointing where intended). =key-binding= alone is insufficient — it is what let =4a1ecf64= through.
+- [ ] Reachability is verified in a *fresh* frame/session, not the live daemon (the stale-daemon trap masks results).
+- [ ] =make test= fully green (the 4 pre-existing =test-dupre-theme= failures are tracked separately and out of scope).
+
+* Readiness dimensions
+- Data model & ownership: keybindings are user-authored code in =modules/=;
+ =cj/custom-keymap= is the owned surface. Nothing generated/cached/remote;
+ nothing persists.
+- Errors, empty states & failure: N/A — a missing command symbol surfaces as a
+ load-time =void-function=, caught by byte-compile and the launch smoke test.
+- Security & privacy: N/A — no credentials or sensitive data.
+- Observability: which-key shows each prefix's menu; =C-h k= / =describe-bindings=
+ report the live binding; the translation-aware test reports reachability.
+- Performance & scale: N/A — keymap lookup is constant-time; one extra prefix
+ binding has no measurable cost.
+- Reuse & lost opportunities: reuse Emacs's native multi-prefix keymap binding
+ (one keymap object, two prefix keys) instead of duplicating bindings; reuse
+ which-key and the existing =cj/register-prefix-map= / =cj/register-command=
+ helpers. Deletes (does not wrap) the bespoke translation layer.
+- Architecture fit & weak points: integration points are =keybindings.el=
+ (=cj/custom-keymap=, the register helpers), =keyboard-compat.el= (translation
+ block to delete; arrow-key decode to keep), and the per-module =:bind= /
+ register calls for the migrated commands. Weak point: the stale-daemon trap can
+ mask whether a change actually works — mitigated by verifying in a fresh
+ =-nw=/console session (acceptance criterion).
+- Config surface: the console-safe alt prefix (D3) and the window sub-prefix
+ letter (D5) are the only new knobs; both are constants set once in config.
+- Documentation plan: update the =keyboard-compat.el= header (it documents the
+ retired translation table); note the moved/dropped keys wherever keybindings
+ are documented. No user-facing migration doc beyond that.
+- Dev tooling: existing =make test= / byte-compile / launch smoke cover it; the
+ new translation-aware assertion is an ERT test like the others.
+- Rollout, compatibility & rollback: user-facing keybinding change; rollback is
+ =git revert=. No persisted data, no public API, no external state. The only
+ compatibility cost is Craig's muscle memory for the moved/dropped keys —
+ a transition note, not a migration.
+- External APIs & deps: N/A — no external APIs; no new dependencies.
+
+* Risks, Rabbit Holes, and Drawbacks
+- *Muscle-memory disruption* for moved/dropped keys. Dodge: keep fast chords for the highest-frequency commands (D4); accept =M-x= only for genuinely uncommon ones.
+- *Console Meta+Shift uncertainty* if D4 Option B is chosen. Dodge: the prefix path (D1/D3) does not depend on it, so the emergency guarantee holds regardless of the fast-chord choice.
+- *Stale-daemon trap* masking test results — the exact failure mode behind the regression. Dodge: the acceptance criteria mandate verification in a fresh frame/session and a translation-aware assertion.
+
+* References / Appendix
+
+** Appendix A — Full C-; keybinding tree (live, 2026-06-12)
+
+Dumped from the running daemon by walking =cj/custom-keymap= recursively.
+Format: chord — command — what it does.
+
+*** Top-level leaves (directly on C-;)
+- C-; ) — cj/jump-to-matching-paren — jump to the matching paren
+- C-; / — cj/replace-fraction-glyphs — replace 1/2-style fractions with glyphs
+- C-; ? — cj/flycheck-list-errors — list flycheck errors for the buffer
+- C-; A — align-regexp — align region by a regexp
+- C-; B — cj/choose-browser — pick the default browser
+- C-; f — cj/format-region-or-buffer — format region or whole buffer
+- C-; k — cj/org-babel-toggle-confirm — toggle the org-babel eval confirmation
+- C-; P — cj/projectile-reset-cmds — reset projectile's cached project commands
+- C-; SPC — cj/switch-to-previous-buffer — toggle to the previous buffer
+- C-; T — cj/telega — open Telegram (telega)
+- C-; | — display-fill-column-indicator-mode — toggle the fill-column rule
+- C-; # c — cj/count-characters-buffer-or-region — count characters
+- C-; # w — cj/count-words-buffer-or-region — count words
+
+*** C-; ! — System commands
+- C-; ! ! — cj/system-command-menu — the system-command transient menu
+- C-; ! e — cj/system-cmd-restart-emacs — restart Emacs
+- C-; ! E — cj/system-cmd-exit-emacs — exit Emacs
+- C-; ! l — cj/system-cmd-lock — lock the screen
+- C-; ! L — cj/system-cmd-logout — log out of the session
+- C-; ! r — cj/system-cmd-reboot — reboot
+- C-; ! s — cj/system-cmd-shutdown — shut down
+- C-; ! S — cj/system-cmd-suspend — suspend
+
+*** C-; a — AI / gptel
+- C-; a . — cj/gptel-add-this-buffer — add current buffer to the gptel context
+- C-; a A — cj/gptel-autosave-toggle — toggle conversation autosave
+- C-; a b — cj/gptel-browse-conversations — browse saved conversations
+- C-; a B — cj/gptel-switch-backend — switch the LLM backend
+- C-; a c — cj/gptel-context-clear — clear the gptel context
+- C-; a d — cj/gptel-delete-conversation — delete a saved conversation
+- C-; a f — cj/gptel-add-file — add a file to the context
+- C-; a l — cj/gptel-load-conversation — load a saved conversation
+- C-; a m — cj/gptel-change-model — change the model
+- C-; a M — gptel-menu — the gptel transient menu
+- C-; a p — gptel-system-prompt — edit the system prompt
+- C-; a q — cj/gptel-quick-ask — quick one-off ask
+- C-; a r — cj/gptel-rewrite-with-directive — rewrite region with a directive
+- C-; a R — cj/gptel-rewrite-redo-with-different-directive — redo rewrite, new directive
+- C-; a s — cj/gptel-save-conversation — save the conversation
+- C-; a t — cj/toggle-gptel — toggle the gptel chat buffer
+- C-; a x — cj/gptel-clear-buffer — clear the chat buffer
+
+*** C-; b — Buffer & file operations
+- C-; b <arrows> — cj/window-resize-sticky — sticky window resize (arrow keys)
+- C-; b b — cj/clear-to-bottom-of-buffer — clear from point to end
+- C-; b c b — cj/copy-to-bottom-of-buffer — copy point-to-end
+- C-; b c t — cj/copy-to-top-of-buffer — copy point-to-start
+- C-; b c w — cj/copy-whole-buffer — copy the whole buffer
+- C-; b d — cj/delete-buffer-and-file — delete the buffer and its file
+- C-; b D — cj/diff-buffer-with-file — diff buffer against its file on disk
+- C-; b e — eval-buffer — eval the buffer
+- C-; b E — cj/view-email-in-buffer — view the buffer as email
+- C-; b g — revert-buffer — revert from disk
+- C-; b k — cj/kill-buffer-and-window — kill buffer and close its window
+- C-; b K — cj/kill-other-window-buffer — kill the other window's buffer
+- C-; b l — cj/copy-link-to-buffer-file — copy an org link to the file
+- C-; b m — cj/move-buffer-and-file — move/rename buffer + file
+- C-; b n — cj/copy-buffer-name — copy the buffer name
+- C-; b o — cj/xdg-open — open the file with the system handler
+- C-; b O — cj/open-this-file-with — open with a chosen program
+- C-; b p — cj/copy-buffer-source-as-kill — copy buffer source
+- C-; b P — cj/print-buffer-ps — print the buffer (PostScript)
+- C-; b r — cj/rename-buffer-and-file — rename buffer + file
+- C-; b s — mark-whole-buffer — select all
+- C-; b S — write-file — write/save-as
+- C-; b t — cj/clear-to-top-of-buffer — clear from start to point
+- C-; b w — cj/view-buffer-in-eww — render the buffer in EWW
+- C-; b x — erase-buffer — erase the buffer
+
+*** C-; c — Case
+- C-; c l — cj/downcase-dwim — downcase (dwim)
+- C-; c t — cj/title-case-region — title-case the region
+- C-; c u — cj/upcase-dwim — upcase (dwim)
+
+*** C-; C — Comments
+- C-; C - — cj/comment-hyphen — hyphen divider comment
+- C-; C b — cj/comment-box — boxed comment
+- C-; C c — cj/comment-inline-border — inline bordered comment
+- C-; C d — cj/delete-buffer-comments — delete all comments in the buffer
+- C-; C h — cj/comment-heavy-box — heavy box comment
+- C-; C n — cj/comment-block-banner — block banner comment
+- C-; C p — cj/comment-padded-divider — padded divider comment
+- C-; C r — cj/comment-reformat — reformat a comment
+- C-; C s — cj/comment-simple-divider — simple divider comment
+- C-; C u — cj/comment-unicode-box — unicode box comment
+
+*** C-; d — Date / time insertion
+- C-; d d — cj/insert-sortable-date — insert YYYY-MM-DD
+- C-; d D — cj/insert-readable-date — insert a human-readable date
+- C-; d r — cj/insert-readable-date-time — readable date + time
+- C-; d s — cj/insert-sortable-date-time — sortable date + time
+- C-; d t — cj/insert-sortable-time — sortable time
+- C-; d T — cj/insert-readable-time — readable time
+
+*** C-; D — Org-drill (flashcards)
+- C-; D c — cj/drill-capture — capture a drill question
+- C-; D e — cj/drill-edit — open a drill file to edit
+- C-; D f — cj/drill-this-file — drill the current file
+- C-; D r — cj/drill-refile — refile into a drill file
+- C-; D R — org-drill-resume — resume a drill session
+- C-; D s — cj/drill-start — start a drill session
+
+*** C-; e — Email (mu4e)
+- C-; e s — cj/mu4e-save-attachment-here — save attachment to current dir
+- C-; e S — cj/mu4e-save-all-attachments — save all attachments
+- C-; e m — cj/mu4e-save-some-attachments — save selected attachments
+- C-; e {c,d,g} {i,l,s,u} — mu4e maildir searches: account {c=cmail, d=dmail,
+ g=gmail} x view {i=inbox, l=large >5M, s=starred/flagged, u=unread}
+
+*** C-; E — ERC (IRC)
+- C-; E b — cj/erc-switch-to-buffer-with-completion — switch ERC buffer
+- C-; E c — cj/erc-join-channel-with-completion — join a channel
+- C-; E C — cj/erc-connect-server-with-completion — connect to a server
+- C-; E l — cj/erc-connected-servers — list connected servers
+- C-; E q — erc-part-from-channel — leave a channel
+- C-; E Q — erc-quit-server — quit a server
+
+*** C-; g — Calendar sync (Google Calendar)
+- C-; g s — calendar-sync-now — sync now
+- C-; g S — calendar-sync-start — start auto-sync
+- C-; g x — calendar-sync-stop — stop auto-sync
+- C-; g t — calendar-sync-toggle — toggle auto-sync
+- C-; g i — calendar-sync-status — sync status
+
+*** C-; h — Hugo (website/blog)
+- C-; h n — cj/hugo-new-post — new post
+- C-; h d — cj/hugo-open-draft — open a draft
+- C-; h D — cj/hugo-toggle-draft — toggle a post's draft flag
+- C-; h e — cj/hugo-export-post — export a post
+- C-; h p — cj/hugo-preview — preview the site
+- C-; h P — cj/hugo-publish — publish the site
+- C-; h o — cj/hugo-open-blog-dir — open the blog dir in Emacs
+- C-; h O — cj/hugo-open-blog-dir-external — open the blog dir externally
+
+*** C-; j — Jump to files
+- C-; j c — cj/jump-to-contacts ; C-; j g — cj/jump-to-gcal
+- C-; j i — cj/jump-to-inbox ; C-; j I — cj/jump-to-emacs-init
+- C-; j m — cj/jump-to-macros ; C-; j n — cj/jump-to-reading-notes
+- C-; j r — cj/jump-to-reference ; C-; j s — cj/jump-to-schedule
+- C-; j w — cj/jump-to-webclipped
+
+*** C-; L — Pearl (Linear tickets) [RESERVED — do not reuse]
+- C-; L … — pearl-prefix-map — Pearl/Linear ticket commands (lazy sub-map)
+- =C-; L= is reserved as the Pearl (Linear integration) leader key. Sub-prefix
+ letter picks (D5) must avoid it.
+
+*** C-; l — Line & paragraph
+- C-; l c — duplicate line/region (comment variant) ; C-; l d — cj/duplicate-line-or-region
+- C-; l j — cj/join-line-or-region ; C-; l J — cj/join-paragraph
+- C-; l r — cj/remove-lines-containing ; C-; l R — cj/remove-duplicate-lines-region-or-buffer
+- C-; l u — cj/underscore-line
+
+*** C-; m — Music (EMMS)
+- C-; m m — cj/music-playlist-toggle ; C-; m M — cj/music-playlist-show
+- C-; m SPC — emms-pause ; C-; m s — emms-stop
+- C-; m n — cj/music-next ; C-; m p — cj/music-previous
+- C-; m a — cj/music-fuzzy-select-and-add ; C-; m g — emms-playlist-mode-go
+- C-; m r — emms-toggle-repeat-playlist ; C-; m t — emms-toggle-repeat-track
+- C-; m x — cj/music-toggle-consume ; C-; m z — emms-toggle-random-playlist
+- C-; m Z — emms-shuffle ; C-; m R — cj/music-create-radio-station
+
+*** C-; M — Signal (signel)
+- C-; M m — cj/signel-message — message a contact
+- C-; M s — cj/signel-message-self — note to self
+- C-; M SPC — cj/signel-connect — start/connect the daemon
+- C-; M d — signel-dashboard — the Signal dashboard
+- C-; M q — signel-stop — stop the daemon
+
+*** C-; n — Org-noter
+- C-; n t — cj/org-noter-start — start noter on the document
+- C-; n n — cj/org-noter-insert-note-dwim — insert a note (dwim)
+
+*** C-; o — Ordering / text transforms
+- C-; o a — cj/arrayify ; C-; o j — cj/arrayify-json ; C-; o p — cj/arrayify-python
+- C-; o u — cj/unarrayify ; C-; o l — cj/listify ; C-; o L — cj/comma-separated-text-to-lines
+- C-; o A — cj/alphabetize-region ; C-; o r — cj/reverse-lines ; C-; o n — cj/number-lines
+- C-; o q — cj/toggle-quotes ; C-; o o — cj/org-sort-by-todo-and-priority
+
+*** C-; p — reveal.js presentations
+- C-; p n — cj/reveal-new ; C-; p h — cj/reveal-insert-header ; C-; p H — cj/reveal-remove-headers
+- C-; p e — cj/reveal-export ; C-; p SPC — cj/reveal-present
+- C-; p p — cj/reveal-preview-start ; C-; p s — cj/reveal-preview-stop
+
+*** C-; r — Recording (audio/video)
+- C-; r a — cj/audio-recording-toggle ; C-; r v — cj/video-recording-toggle
+- C-; r s — cj/recording-quick-setup ; C-; r S — cj/recording-select-devices
+- C-; r d — cj/recording-list-devices ; C-; r l — cj/recording-adjust-volumes
+- C-; r w — cj/recording-show-active-audio
+- C-; r t b/m/s — cj/recording-test-both / -mic / -monitor
+
+*** C-; R — restclient
+- C-; R n — cj/restclient-new-buffer ; C-; R o — cj/restclient-open-file
+
+*** C-; s — Enclose / surround / indent
+- C-; s s — cj/surround-word-or-region ; C-; s u — cj/unwrap-word-or-region
+- C-; s w — cj/wrap-word-or-region ; C-; s i — cj/indent-lines-in-region-or-buffer
+- C-; s d — cj/dedent-lines-in-region-or-buffer ; C-; s a — cj/append-to-lines-in-region-or-buffer
+- C-; s p — cj/prepend-to-lines-in-region-or-buffer
+- C-; s I — change-inner ; C-; s O — change-outer
+
+*** C-; t — Test runner
+- C-; t r — cj/test-run-smart ; C-; t R — cj/test-run-all ; C-; t . — cj/run-test-at-point
+- C-; t a — cj/test-focus-add ; C-; t b — cj/test-focus-add-this-buffer-file
+- C-; t c — cj/test-focus-clear ; C-; t v — cj/test-view-focused
+- C-; t L — cj/test-load-all ; C-; t t — cj/test-toggle-mode
+
+*** C-; v — Version control (git / forge)
+- C-; v c — cj/git-clone-clipboard-url ; C-; v d — cj/goto-git-gutter-diff-hunks
+- C-; v t — cj/git-timemachine ; C-; v f — forge-pull ; C-; v r — forge-list-pullreqs
+- C-; v i c — cj/forge-create-issue ; C-; v i l — forge-list-issues
+
+*** C-; w — Whitespace
+- C-; w c — cj/collapse-whitespace-line-or-region ; C-; w d — cj/delete-all-whitespace
+- C-; w l — cj/delete-blank-lines-region-or-buffer ; C-; w 1 — cj/ensure-single-blank-line
+- C-; w r — cj/remove-leading-trailing-whitespace ; C-; w - — cj/hyphenate-whitespace-in-region
+- C-; w t — untabify ; C-; w T — tabify
+
+*** C-; x — Terminal (ghostel)
+- C-; x t — cj/term-toggle ; C-; x N — ghostel (new) ; C-; x c — cj/term-copy-mode-dwim
+- C-; x h — cj/term-tmux-history ; C-; x l — ghostel-clear-scrollback
+- C-; x n — ghostel-next-prompt ; C-; x p — ghostel-previous-prompt
+- C-; x q — ghostel-send-next-key
+
+** Appendix B — The M-S- family (18 keys)
+
+All bound as =M-S-<letter>= and reachable in GUI only, via the
+=keyboard-compat.el= translation layer. Format: chord — command — what it does —
+source module.
+
+- M-S-o — cj/kill-other-window — kill the other window's buffer and close it — undead-buffers.el
+- M-S-m — cj/kill-all-other-buffers-and-windows — close all other windows, kill their buffers — undead-buffers.el
+- M-S-y — yank-media — paste an image/media object from the clipboard — keybindings.el
+- M-S-f — fontaine-set-preset — switch the font preset — font-config.el
+- M-S-w — wttrin — show the weather report — weather-config.el
+- M-S-e — eww — open the EWW web browser — eww-config.el
+- M-S-l — cj/switch-themes — select/cycle the theme — ui-theme.el
+- M-S-r — cj/elfeed-open — open the Elfeed RSS reader — elfeed-config.el
+- M-S-v — cj/split-and-follow-right — split window right and move focus there — ui-navigation.el
+- M-S-h — cj/split-and-follow-below — split window below and move focus there — ui-navigation.el
+- M-S-t — toggle-window-split — toggle horizontal/vertical split — ui-navigation.el
+- M-S-z — cj/undo-kill-buffer — reopen the most-recently-killed file buffer — ui-navigation.el
+- M-S-u — winner-undo — undo the last window-configuration change — ui-navigation.el
+- M-S-d — dwim-shell-commands-menu — DWIM shell-command menu on marked files — dwim-shell-config.el
+- M-S-i — edit-indirect-region — edit the region in an indirect buffer — text-config.el
+- M-S-c — time-zones — show the world-clock / time-zones view — chrono-tools.el
+- M-S-b — calibredb — open the Calibre ebook library — calibredb-epub-config.el
+- M-S-k — show-kill-ring — browse the kill ring — show-kill-ring.el
+
+Note: =4a1ecf64= (in-flight, reverted in Phase 0) currently leaves
+=eww=/=elfeed=/=calibredb= mis-bound to =M-E=/=M-R=/=M-B=; the table lists the
+intended/original =M-S-= bindings.
+
+** Appendix C — Console-safe single-prefix candidates (D1/D3)
+
+Craig's first choice (D1) is one =Control=+key prefix that works in GUI, terminal,
+and the Linux console, ideally a lightly-used home-row key. Console transmittability
+is the gate. Two classes of chord transmit in =TERM=linux=:
+
+1. =Control= + letter (ASCII 1–26). Several collide with terminal control characters
+ and so cannot serve as a distinct prefix — =C-i=/TAB, =C-j=/LF, =C-m=/RET,
+ =C-[=/ESC, =C-h=/DEL — and =C-g= (=keyboard-quit=) is sacred and excluded.
+2. =Meta= + key, which the console sends as an *ESC prefix* (=M-x= = ESC then x).
+ This is why the Problem table above shows =M-E= working as "ESC E" in the console.
+ So a plain =M-<key>= prefix is console-safe too — and unlike the broken =M-S-=
+ family, an unshifted =M-<key>= binds directly with no =key-translation-map= in the
+ path. The catch is finding a free one: the Meta namespace is crowded (Appendix D
+ shows =M-*=, =M-+=, =M-#=, =M-P=, =M-t=, and the whole =M-g=/=M-s=/=M-e=/=M-r=
+ consult family taken), so a free Meta prefix would be punctuation (=M-\\=, =M-/=…),
+ not a letter, and it carries the usual ESC-prefix timing caveat in terminals.
+
+=Control= + *non-letter* punctuation (=C-;=, =C-'=, =C-.=…) does NOT transmit in the
+console — the character isn't a control code. So =C-'= is a non-starter on two counts:
+dead in the console like =C-;=, and already bound (=cj/flyspell-then-abbrev=, globally
+at =flyspell-and-abbrev.el:253= and in =org-mode-map= at =:258=). =Control=+letter
+(the table below) stays the cleanest path; a free =M-<punctuation>= is the viable
+runner-up class if Craig prefers Meta.
+
+| Candidate | Home-row | Console-safe | Default binding | Verdict + note |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-l | yes | yes | recenter-top-bottom | TOP. Home-row, light default, |
+| | | | | trivially relocated. Also |
+| | | | | vertico-insert in the minibuffer |
+| | | | | (selection-framework.el:42) — |
+| | | | | minibuffer-local, no global |
+| | | | | conflict. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-q | no | yes | quoted-insert | Strong runner-up. Very light |
+| | | | | default; trivial rebind. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-o | no | yes | open-line | Strong runner-up. Light default; |
+| | | | | easy rebind. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-t | no | yes | transpose-chars | Strong runner-up. Light default; |
+| | | | | easy rebind. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-k | yes | yes | kill-line | Possible. Home-row, but kill-to-eol |
+| | | | | is muscle memory — medium retrain |
+| | | | | friction. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-s | yes | yes | cj/consult-line-or-repeat | Possible, but already a useful |
+| | | | (selection-framework.el:265) | rebind; using it as a prefix |
+| | | | | reverses that. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-a | yes | yes | move-beginning-of-line | Reject. Essential editing reflex. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-d | yes | yes | delete-char | Reject. Essential. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-f | yes | yes | forward-char | Reject. Essential. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-h | yes | collides (DEL) | help-command | Reject. Console DEL collision; help |
+| | | | | is frequent. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-j | yes | collides (LF) | newline | Reject. LF control char; cannot |
+| | | | | transmit distinctly. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-g | yes | sacred | keyboard-quit | Reject. Universal escape; never |
+| | | | | reuse. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-z | no | yes | suspend-frame (live prefix; C-z F = | Reject. Already an extended prefix. |
+| | | | fonts, font-config.el:300) | |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-' | no | no | cj/flyspell-then-abbrev | Reject. Punctuation — dead in the |
+| | | | (flyspell-and-abbrev.el:253) | console like C-;; and already bound |
+| | | | | (also org-mode-map :258). |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| M-<punct> | n/a | yes (ESC-prefix) | — (Meta namespace crowded; see | Viable runner-up class. Console-safe |
+| | | | Appendix D) | via ESC-prefix, no translation |
+| | | | | layer, distinct from the broken |
+| | | | | M-S-. Needs a free M-punctuation |
+| | | | | (M-\\, M-/); ESC-timing caveat in |
+| | | | | terminals. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+
+Recommendation: =C-l= is the single best fit — the only clean home-row option (every
+other home-row letter is essential, a collision, sacred, or already repurposed),
+console-safe, and its default =recenter-top-bottom= is light and trivially relocated.
+=C-q= / =C-o= / =C-t= are equally console-safe and lightly bound if Craig prefers to
+keep all home-row defaults; they cost a right-hand reach off home row. If Craig would
+rather a Meta prefix, a free =M-<punctuation>= (=M-\\=, =M-/=) is the viable runner-up
+class — console-safe via ESC-prefix and free of the translation layer — at the cost of
+the ESC-timing caveat. =C-'= is out (console-dead and already taken). Craig picks.
+
+** Appendix D — Personal keybindings set outside C-; (audit for pruning)
+
+Every keybinding Craig has set *outside* the =C-;= tree (Appendix A) and the =M-S-=
+family (Appendix B), grouped by context. Check a box to mark that binding — or a
+whole group — for removal in Phase 1. Boxes start unchecked; Craig marks them.
+Inventoried 2026-06-13. Some =:bind (:map …)= package-integration maps (lsp-mode,
+c-mode-base, python-ts, json-ts, outline-minor, magit-blame, quick-sdcv, cj/vc-map)
+have large package-managed binding lists not enumerated here.
+
+- [ ] Global bindings
+ - [ ] C-+ — text-scale-increase — (font-config.el:306)
+ - [ ] C-= — text-scale-increase — (font-config.el:307)
+ - [ ] C-_ — text-scale-decrease — (font-config.el:308)
+ - [ ] C-- — text-scale-decrease — (font-config.el:309)
+ - [ ] C-x C-f — find-file — (keybindings.el:147)
+ - [ ] C-x \ — sort-lines — (keybindings.el:160)
+ - [ ] C-x u — undo-reminder-message — (keybindings.el:164)
+ - [ ] <escape> — keyboard-escape-quit — (keybindings.el:156)
+ - [ ] <remap> <capitalize-region> — cj/title-case-region — (custom-case.el:124)
+ - [ ] <remap> <kill-buffer> — cj/kill-buffer-or-bury-alive — (undead-buffers.el:55)
+ - [ ] <remap> <list-buffers> — ibuffer — (system-utils.el:147)
+ - [ ] <remap> <mouse-wheel-text-scale> — cj/disabled — (system-defaults.el:191)
+ - [ ] C-z — prefix map (suspend-frame replacement) — (keybindings.el:148)
+ - [ ] C-z F — cj/display-available-fonts — (font-config.el:300)
+ - [ ] C-h A — cj/local-arch-wiki-search — (help-utils.el:82)
+ - [ ] C-h D s — devdocs-search — (help-utils.el:40)
+ - [ ] C-h D b — devdocs-peruse — (help-utils.el:41)
+ - [ ] C-h D l — devdocs-lookup — (help-utils.el:42)
+ - [ ] C-h D i — devdocs-install — (help-utils.el:43)
+ - [ ] C-h D d — devdocs-delete — (help-utils.el:44)
+ - [ ] C-h D u — devdocs-update-all — (help-utils.el:45)
+ - [ ] C-h P — list-packages — (help-config.el:31)
+ - [ ] C-h i — cj/browse-info-files — (help-config.el:90)
+ - [ ] C-c b — cj/eval-buffer-with-confirmation-or-error-message — (system-utils.el:57)
+ - [ ] C-c C — cj/org-contacts-map prefix — (org-contacts-config.el:271)
+ - [ ] C-c d — cj/debug-config-keymap prefix — (config-utilities.el:28)
+ - [ ] C-c f — cj/flyspell-toggle — (flyspell-and-abbrev.el:252)
+ - [ ] C-c l — org-store-link — (org-config.el:58)
+ - [ ] C-c m — mu4e — (mail-config.el:125)
+ - [ ] C-c M — mouse-trap-mode — (mousetrap-mode.el:275)
+ - [ ] C-' — cj/flyspell-then-abbrev — (flyspell-and-abbrev.el:253)
+ - [ ] C-s — cj/consult-line-or-repeat — (selection-framework.el:265)
+ - [ ] M-* — calculator — (keybindings.el:152)
+ - [ ] M-+ — balance-windows — (ui-navigation.el:67)
+ - [ ] M-P — cj/check-for-open-work — (reconcile-open-repos.el:221)
+ - [ ] C-c n d — org-roam-dailies-map prefix — (org-roam-config.el:94)
+ - [ ] C-c n I — cj/org-roam-node-insert-immediate — (org-roam-config.el:131)
+- [ ] Function keys
+ - [ ] <f1> — cj/dashboard-only — (dashboard-config.el:158)
+ - [ ] <f3> — call-last-kbd-macro — (keyboard-macros.el:131)
+ - [ ] C-<f3> — cj/kbd-macro-start-or-end — (keyboard-macros.el:130)
+ - [ ] M-<f3> — cj/save-maybe-edit-macro — (keyboard-macros.el:132)
+ - [ ] s-<f3> — cj/open-macros-file — (keyboard-macros.el:133)
+ - [ ] <f4> — cj/f4-compile-and-run — (dev-fkeys.el:535)
+ - [ ] C-<f4> — cj/f4-compile-only — (dev-fkeys.el:536)
+ - [ ] M-<f4> — cj/f4-clean-rebuild — (dev-fkeys.el:537)
+ - [ ] S-<f4> — recompile — (dev-fkeys.el:538)
+ - [ ] <f6> — cj/f6-test-runner — (dev-fkeys.el:539)
+ - [ ] C-<f6> — cj/f6-current-file-tests — (dev-fkeys.el:540)
+ - [ ] S-<f5> (Python) — cj/python-mypy — (prog-python.el:103)
+ - [ ] S-<f5> (Shell) — cj/shell-run-shellcheck — (prog-shell.el:98)
+ - [ ] S-<f5> (Go) — cj/go-staticcheck — (prog-go.el:102)
+ - [ ] S-<f5> (C) — cj/disabled — (prog-c.el:158)
+ - [ ] S-<f6> (Python) — cj/python-debug — (prog-python.el:106)
+ - [ ] S-<f6> (Shell) — cj/disabled — (prog-shell.el:101)
+ - [ ] S-<f6> (Go) — cj/go-debug — (prog-go.el:105)
+ - [ ] S-<f6> (C) — gdb — (prog-c.el:161)
+ - [ ] <f7> — cj/coverage-report — (coverage-core.el:537)
+ - [ ] <f8> — cj/main-agenda-display — (org-agenda-config.el:418)
+ - [ ] C-<f8> — cj/todo-list-single-project — (org-agenda-config.el:269)
+ - [ ] M-<f8> — cj/todo-list-from-this-buffer — (org-agenda-config.el:283)
+ - [ ] s-<f8> — cj/todo-list-all-agenda-files — (org-agenda-config.el:244)
+ - [ ] <f9> — cj/ai-term — (ai-term.el:920)
+ - [ ] C-<f9> — cj/ai-term-pick-project — (ai-term.el:921)
+ - [ ] M-<f9> — cj/ai-term-close — (ai-term.el:922)
+ - [ ] C-S-<f9> — cj/ai-term-close — (ai-term.el:923)
+ - [ ] <f10> — cj/music-playlist-toggle — (music-config.el:910)
+ - [ ] C-<f10> — cj/server-shutdown — (system-utils.el:105)
+ - [ ] <f12> — cj/term-toggle — (term-config.el:383)
+ - [ ] C-<f12> — eshell-toggle — (eshell-config.el:161)
+- [ ] use-package :bind (global)
+ - [ ] C-c L — slime — (prog-lisp.el:151)
+ - [ ] C-c G — geiser-guile — (prog-lisp.el:172)
+ - [ ] C-h L — leetcode — (prog-training.el:35)
+ - [ ] C-h M — man — (help-config.el:49)
+ - [ ] C-h T — tldr — (help-utils.el:53)
+ - [ ] C-h W — wiki-summary — (help-utils.el:58)
+ - [ ] C-` — accent-company — (text-config.el:122)
+ - [ ] C-x M-f — sudo-edit — (system-utils.el:66)
+ - [ ] C-x g — magit-status — (vc-config.el:34)
+ - [ ] C-c s i — consult-yasnippet — (selection-framework.el:191)
+ - [ ] M-# — calendar — (chrono-tools.el:38)
+ - [ ] M-t — tmr-prefix-map — (chrono-tools.el:110)
+ - [ ] C-M-p — proced — (system-utils.el:183)
+- [ ] Vertico / selection framework
+ - [ ] C-h C-k — free-keys — (keybindings.el:129)
+ - [ ] C-j (vertico-map) — vertico-next — (selection-framework.el:40)
+ - [ ] C-k (vertico-map) — vertico-previous — (selection-framework.el:41)
+ - [ ] C-l (vertico-map) — vertico-insert — (selection-framework.el:42)
+ - [ ] RET (vertico-map) — vertico-exit — (selection-framework.el:43)
+ - [ ] C-RET (vertico-map) — vertico-exit-input — (selection-framework.el:44)
+ - [ ] M-RET (vertico-map) — minibuffer-force-complete-and-exit — (selection-framework.el:45)
+ - [ ] TAB (vertico-map) — minibuffer-complete — (selection-framework.el:46)
+- [ ] Consult (global)
+ - [ ] C-c h — consult-history — (selection-framework.el:64)
+ - [ ] C-x M-: — consult-complex-command — (selection-framework.el:66)
+ - [ ] C-x b — consult-buffer — (selection-framework.el:67)
+ - [ ] C-x 4 b — consult-buffer-other-window — (selection-framework.el:68)
+ - [ ] C-x 5 b — consult-buffer-other-frame — (selection-framework.el:69)
+ - [ ] C-x r b — consult-bookmark — (selection-framework.el:70)
+ - [ ] C-x p b — consult-project-buffer — (selection-framework.el:71)
+ - [ ] M-g e — consult-compile-error — (selection-framework.el:73)
+ - [ ] M-g f — consult-flymake — (selection-framework.el:74)
+ - [ ] M-g g — consult-goto-line — (selection-framework.el:75)
+ - [ ] M-g M-g — consult-goto-line — (selection-framework.el:76)
+ - [ ] M-g o — consult-outline — (selection-framework.el:77)
+ - [ ] M-g m — consult-mark — (selection-framework.el:78)
+ - [ ] M-g k — consult-global-mark — (selection-framework.el:79)
+ - [ ] M-g i — consult-imenu — (selection-framework.el:80)
+ - [ ] M-g I — consult-imenu-multi — (selection-framework.el:81)
+ - [ ] M-s d — consult-find — (selection-framework.el:83)
+ - [ ] M-s D — consult-locate — (selection-framework.el:84)
+ - [ ] M-s g — consult-grep — (selection-framework.el:85)
+ - [ ] M-s G — consult-git-grep — (selection-framework.el:86)
+ - [ ] M-s r — consult-ripgrep — (selection-framework.el:87)
+ - [ ] M-s l — consult-line — (selection-framework.el:88)
+ - [ ] M-s L — consult-line-multi — (selection-framework.el:89)
+ - [ ] M-s k — consult-keep-lines — (selection-framework.el:90)
+ - [ ] M-s u — consult-focus-lines — (selection-framework.el:91)
+ - [ ] M-s e — consult-isearch-history — (selection-framework.el:93)
+- [ ] Isearch / minibuffer search
+ - [ ] M-e (isearch-mode-map) — consult-isearch-history — (selection-framework.el:95)
+ - [ ] M-s e (isearch-mode-map) — consult-isearch-history — (selection-framework.el:96)
+ - [ ] M-s l (isearch-mode-map) — consult-line — (selection-framework.el:97)
+ - [ ] M-s L (isearch-mode-map) — consult-line-multi — (selection-framework.el:98)
+ - [ ] M-s (minibuffer-local-map) — consult-history — (selection-framework.el:101)
+ - [ ] M-r (minibuffer-local-map) — consult-history — (selection-framework.el:102)
+- [ ] PDF view mode
+ - [ ] M — pdf-view-midnight-minor-mode — (pdf-config.el:49)
+ - [ ] m — bookmark-set — (pdf-config.el:50)
+ - [ ] C-= — pdf-view-enlarge — (pdf-config.el:51)
+ - [ ] C-- — pdf-view-shrink — (pdf-config.el:52)
+ - [ ] C-c l — org-store-link — (pdf-config.el:53)
+ - [ ] z — cj/open-file-with-command zathura — (pdf-config.el:54)
+ - [ ] j — image-next-line — (pdf-config.el:56)
+ - [ ] k — image-previous-line — (pdf-config.el:57)
+ - [ ] <down> — image-next-line — (pdf-config.el:58)
+ - [ ] <up> — image-previous-line — (pdf-config.el:59)
+ - [ ] i — cj/org-noter-insert-note-dwim — (pdf-config.el:61)
+ - [ ] C-<down> — pdf-view-next-page-command + image-bob — (pdf-config.el:63)
+ - [ ] C-<up> — pdf-view-previous-page-command + image-eob — (pdf-config.el:65)
+- [ ] Ediff mode
+ - [ ] j (ediff-mode-map) — ediff-next-difference — (diff-config.el:54)
+ - [ ] k (ediff-mode-map) — ediff-previous-difference — (diff-config.el:55)
+- [ ] Org / org-related
+ - [ ] C-' (org-mode-map) — cj/flyspell-then-abbrev — (flyspell-and-abbrev.el:258)
+ - [ ] S-<mouse-1> (org-mouse-map) — cj/org-follow-link-at-mouse-same-window — (org-config.el:338)
+ - [ ] <mouse-3> (org-mouse-map) — cj/org-follow-link-at-mouse-same-window — (org-config.el:339)
+- [ ] Dired / dirvish
+ - [ ] G (dired-mode-map) — cj/deadgrep-here — (prog-general.el:277)
+ - [ ] M-D (dirvish-mode-map) — dwim-shell-commands-menu — (dwim-shell-config.el:934)
+ - [ ] + (dirvish-mode-map) — cj/music-add-dired-selection — (music-config.el:597)
+ - [ ] T (dired/dirvish-mode-map) — cj/transcribe-media-at-point — (transcription-config.el:463/467)
+ - [ ] <f11> (dirvish-mode-map) — dirvish-side — (dirvish-config.el:481)
+- [ ] Shell / terminal
+ - [ ] C-r (eshell-mode-map) — cj/eshell-history-search — (eshell-config.el:202)
+ - [ ] <up> (eshell-hist-mode-map) — previous-line — (eshell-config.el:99)
+ - [ ] <down> (eshell-hist-mode-map) — next-line — (eshell-config.el:100)
+- [ ] Ghostel terminal
+ - [ ] <f9> (ghostel-mode-map) — cj/ai-term — (ai-term.el:932)
+ - [ ] C-<f9> (ghostel-mode-map) — cj/ai-term-pick-project — (ai-term.el:933)
+ - [ ] M-<f9> (ghostel-mode-map) — cj/ai-term-close — (ai-term.el:934)
+ - [ ] C-S-<f9> (ghostel-mode-map) — cj/ai-term-close — (ai-term.el:935)
+ - [ ] <f12> (ghostel-mode-map) — cj/term-toggle — (term-config.el:415)
+ - [ ] C-SPC (ghostel-mode-map) — cj/term-send-C-SPC — (term-config.el:416)
+- [ ] Version control / magit
+ - [ ] M-g (git-commit-mode-map) — gptel-magit-generate-message — (ai-config.el:498)
+ - [ ] N (magit-mode-map) — forge-pull — (vc-config.el:125)
+- [ ] Help / docs modes
+ - [ ] b (devdocs-mode-map) — devdocs-go-back — (help-utils.el:47)
+ - [ ] f (devdocs-mode-map) — devdocs-go-forward — (help-utils.el:48)
+- [ ] Org-roam dailies
+ - [ ] Y (org-roam-dailies-map) — org-roam-dailies-capture-yesterday — (org-roam-config.el:92)
+ - [ ] T (org-roam-dailies-map) — org-roam-dailies-capture-tomorrow — (org-roam-config.el:93)
+- [ ] Other mode maps
+ - [ ] C-<return> (slack-message-compose-buffer-mode-map) — slack-message-send-from-buffer — (slack-config.el:297)
+ - [ ] q (dashboard-mode-map) — nil (unbound) — (dashboard-config.el:223)
+ - [ ] q (show-kill-ring-mode-map) — show-kill-ring-exit — (show-kill-ring.el:67)
+ - [ ] <f2> (markdown-mode-map) — markdown-preview — (markdown-config.el:24)
+ - [ ] <remap> <shell-command> — dwim-shell-command — (dwim-shell-config.el:204)
+- [ ] key-translation-map / input-decode-map
+ - [ ] input-decode-map ESC [ A — [up] — (keyboard-compat.el:109)
+ - [ ] input-decode-map ESC [ B — [down] — (keyboard-compat.el:110)
+ - [ ] input-decode-map ESC [ C — [right] — (keyboard-compat.el:111)
+ - [ ] input-decode-map ESC [ D — [left] — (keyboard-compat.el:112)
+ - [ ] input-decode-map ESC O A — [up] — (keyboard-compat.el:115)
+ - [ ] input-decode-map ESC O B — [down] — (keyboard-compat.el:116)
+ - [ ] input-decode-map ESC O C — [right] — (keyboard-compat.el:117)
+ - [ ] input-decode-map ESC O D — [left] — (keyboard-compat.el:118)
+- [ ] Jumper
+ - [ ] jumper-prefix-key (computed at runtime) — jumper-map — (jumper.el:270) [computed key — exact binding depends on the variable value]
+
+Note: the global =M-S-<letter>= family is intentionally excluded (Appendix B). The
+arrow-key =input-decode-map= entries are the terminal setup the spec keeps (not the
+translation block being retired). =C-l= appears only minibuffer-local in
+=vertico-map=, consistent with Appendix C.
+
+* Review and iteration history
+** 2026-06-12 Fri @ 11:21:56 -0500 — Craig Jennings — author
+- What: initial draft. Problem, three-context analysis, the 4a1ecf64 regression
+ as motivating evidence, the one-map/two-prefix design, alternatives, five
+ open decisions, phased plan, acceptance criteria, readiness dimensions, and the
+ full C-; tree + M-S- family appendices.
+- Why: a touched key family broke in GUI and is dead in console; the fix path is
+ cross-cutting (18 keys, a translation layer to retire, a console-safety
+ architecture) with real trade-offs, so it clears the spec bar.
+- Artifacts: docs/design/keybinding-console-safety-spec.org; supersedes the
+ pre-template draft docs/design/keybinding-console-safety.org.
+** 2026-06-12 Fri @ 18:30:30 -0500 — Craig Jennings — review response
+- What: processed Craig's four review comments. Recorded his first-choice
+ direction — one console-safe =Control=+key prefix used everywhere (single-prefix
+ primary; the two-prefix design is now the documented fallback) — in the Summary,
+ Design, and D1. Added Appendix C, the console-safe single-prefix candidate table
+ (standout =C-l=; runners-up =C-q=/=C-o=/=C-t=). Reframed D3 around that pick.
+ Named the flawed test (=tests/test-launcher-meta-shift-keys.el=) and quoted its
+ =key-binding=-only assertion in Phase 0. Recorded Craig's decision that the
+ launcher apps (=eww=/=elfeed=/=calibredb=/=wttrin=) get new keys under a launcher
+ sub-prefix, not =M-x= (D2/D5, Phases 0/2), with =C-; a t= (=cj/toggle-gptel=,
+ =ai-config.el:541=) flagged as freeable space. Reserved =C-; L= for Pearl in
+ Appendix A and D5.
+- Why: Craig's review shifted the architecture from two-prefix to a single unified
+ console-safe prefix and resolved the apps disposition; the spec had to carry the
+ candidate data he asked for and reflect the choices through the phases.
+- Open: the specific prefix (Appendix C), the window and launcher sub-prefix
+ letters (D5) remain Craig's picks. D1–D5 still State: proposed pending those.
+** 2026-06-12 Fri @ 18:43:25 -0500 — Craig Jennings — decisions-as-TODO convention
+- What: switched the Decisions section to org TODO tasks. Each decision is =** TODO
+ Dn=, flips to =DONE= when Craig agrees, stays TODO with a =*** Discussion= child
+ thread when not. Added a =[0/5]= statistics cookie and a gate: spec Status cannot
+ reach =ready= while any decision is TODO. Current status: all 5 TODO (none fully
+ agreed — D1 awaits the prefix lock, D2 unreviewed, D3 awaits the prefix pick, D4
+ awaits the A/B pick, D5's apps half agreed but both sub-prefix letters open).
+- Why: replaces the inline =State: proposed/accepted= field with an org-native,
+ agenda-visible task + discussion-thread workflow, and makes the
+ all-decisions-resolved gate explicit and machine-checkable.
+** 2026-06-13 Sat @ 00:18:09 -0500 — Craig Jennings — Path 2 restructure + audit appendix
+- What: processed three more review comments. Restructured the phases to Path 2:
+ Phase 0 is a pure revert that unblocks the held push; Phase 1 prunes forgotten
+ keybindings (Appendix D); Phase 2 is the consolidation (migrate the common set,
+ retire the translation block) — the primary deliverable; Phase 3 (bind the
+ console-safe prefix) is now OPTIONAL and deferred until Craig picks the key. The
+ Decisions gate split accordingly: D2/D4/D5 gate the primary work, D1/D3 gate only
+ Phase 3, so the work runs to the prefix-assignment point and stops there.
+ Corrected Appendix C's premise (Meta transmits in the console as an ESC prefix, so
+ =M-<punctuation>= is a viable console-safe class); added the =C-'= row (rejected —
+ console-dead and already bound to flyspell) and the =M-<punct>= row. Added Appendix
+ D: every personal keybinding set outside the =C-;= tree and the =M-S-= family, as a
+ checkbox pruning tree (~190 bindings, inventoried by a read-only sweep).
+- Why: Craig pivoted to landing the consolidation first and treating the
+ console-safe prefix as a later switch-on, and wanted a one-time audit of his
+ set-and-forgotten keybindings while the keymap work was open.
+- Open: D1–D5 still TODO; the prefix (D1/D3) is now non-blocking. Phase 0 revert
+ pending so the push can proceed.
diff --git a/docs/design/messenger-unification-spec.org b/docs/design/messenger-unification-spec.org
index 7e9d218b..7e878030 100644
--- a/docs/design/messenger-unification-spec.org
+++ b/docs/design/messenger-unification-spec.org
@@ -1,7 +1,7 @@
#+TITLE: Messenger Unification — Shared Window Placement and Key Conventions
#+AUTHOR: Craig Jennings & Claude
#+DATE: 2026-06-11
-#+STATUS: Draft — decisions 1-8 settled (Craig, 2026-06-11); held open for further ideas before Ready
+#+STATUS: Draft — decisions 1-9 settled (Craig, 2026-06-11/12); held open for further ideas before Ready
* Problem
@@ -116,9 +116,13 @@ directions.
** Backend wiring (per messenger, in its existing config module)
-- Signel: registration call only. The fork's own local-set-key bindings stay —
- they're identical to the minor mode's dispatch, and harmless duplication
- beats a fork edit.
+- Smoke (the ground-up signel replacement at =~/code/smoke=, decided
+ 2026-06-12): implements the conventions natively from day one — bottom
+ drawer, dismiss-preserving C-c C-k per decision 3, unread tracking feeding
+ jump-to-unread — per its architecture spec. Signel remains the running
+ reference until smoke reaches parity; =signal-config.el='s private display
+ entry retires at the switchover. Registration stays one call; smoke is the
+ reference backend. (Tracked in the smoke project's todo.)
- telega: =:confirm #'telega-chatbuf-input-send=, =:cancel= wraps
=telega-chatbuf-cancel-dwim= (decision 3 ladder), =:buffer-match
'(telega-chat-mode)=.
@@ -132,11 +136,18 @@ directions.
height defcustom 0.3. Proven by signel. (Proposed.)
2. One registry call per messenger is the entire integration surface; the
library owns the display rule and keymap. (Proposed.)
-3. Cancel semantics (Craig, 2026-06-11): the dwim ladder for C-c C-k —
- (a) pending typed input → clear it; (b) backend pending state (telega
- edit/reply/forward) → backend's own dwim cancel; (c) nothing pending →
- =quit-window= (window closes, buffer buries). Bare C-c C-k on an idle chat
- closes the window.
+3. Cancel semantics (Craig, 2026-06-11; superseded 2026-06-12): C-c C-k
+ dismisses, never destroys — (a) backend pending state (telega
+ edit/reply/forward) → the backend's own dwim cancel; (b) otherwise →
+ =quit-window=. Typed drafts are not cancel's business: input survives the
+ burial and is waiting at the prompt on the next visit (signel's
+ pending-input machinery, generalized). Where a backend wants an explicit
+ clear-draft, it kills to the kill-ring so the text is recoverable.
+ /Superseded version (2026-06-11):/ a three-rung ladder whose first rung
+ cleared typed input before a second press closed the window — dropped
+ because the first press destroyed text while dismissing nothing, and it
+ broke the org-capture/git-commit muscle memory where C-c C-k means
+ "abandon and dismiss" in one press.
4. Telega shadow accepted (Craig, 2026-06-11): the minor mode's C-c C-c hides
=telega-chatbuf-filter-cancel= in telega chats. Craig doesn't use chat
filters; the command stays reachable via M-x and the C-c / filter flow.
@@ -144,8 +155,12 @@ directions.
from the beside-work split to the shared bottom rule; =cj/slack--display-buffer=
is retired in favor of the library's placement entry. Compose buffers
conform via the minor mode as planned.
-6. v1 verb set: confirm, cancel, attach. Candidates for a later phase:
- next/prev unread, jump-to-chat picker, mark-read-and-bury. (Proposed.)
+6. v1 verb set: confirm, cancel, attach. Revised 2026-06-12 (Craig):
+ jump-to-unread is promoted from candidate to committed verb — a global
+ chord that raises the most recent unread conversation in the bottom
+ window, completing the pull flow (toast → chord → chat). Backends supply
+ an unread source at registration (=:unread=). Still candidates:
+ next/prev-unread, jump-to-chat picker, mark-read-and-bury.
Addendum from the 2026-06 config audit: the notification path is the same
unification shape on the inbound side — four messengers, four mechanisms
(signel hardened with truncation/sound-gating/fallback; slack unhardened;
@@ -158,6 +173,11 @@ directions.
investigation task in =todo.org= — if it goes, it joins through the same
registration surface.
8. RET is never rebound or removed. (Proposed.)
+9. No auto-open, ever (Craig, 2026-06-12): no backend claims the bottom slot
+ unbidden — awareness is pull-based (hardened notifications +
+ jump-to-unread). =signel-auto-open-buffer= stays nil and equivalent knobs
+ in other backends are configured off. The drawer is summoned by the user,
+ not by traffic.
* Phases
@@ -168,8 +188,9 @@ directions.
- *Phase 2 — telega.* Registration + the decision-3 cancel ladder; audit what
else the minor-mode map hides in =telega-chat-mode-map=.
- *Phase 3 — slack.* Per decision 5; conform compose buffers either way.
-- *Phase 4 (optional) — shared verbs + ERC.* Decision-6 candidates, each verb
- landing in every backend at once.
+- *Phase 4 — shared verbs + ERC.* jump-to-unread first (committed per the
+ decision-6 revision), then remaining decision-6 candidates, each verb
+ landing in every backend at once. ERC joins when wanted.
Each phase ends with a manual-test checklist filed under the
"Manual testing and validation" parent in =todo.org= (placement, each chord,
diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el
index b4e4545d..3b8a3c5c 100644
--- a/modules/dashboard-config.el
+++ b/modules/dashboard-config.el
@@ -145,6 +145,13 @@ window."
;; --------------------------------- Dashboard ---------------------------------
;; a useful startup screen for Emacs
+(defun cj/--dashboard-exclude-emms-from-recentf ()
+ "Exclude the EMMS history file from recentf.
+Adds to `recentf-exclude' so entries set elsewhere (e.g. in
+system-defaults) are preserved rather than overwritten."
+ (require 'recentf)
+ (add-to-list 'recentf-exclude "/emms/history"))
+
(use-package dashboard
:demand t
:hook (emacs-startup . cj/dashboard-only)
@@ -196,7 +203,7 @@ window."
(setq dashboard-bookmarks-show-path nil) ;; don't show paths in bookmarks
(setq dashboard-recentf-show-base t) ;; show filename, not full path
(setq dashboard-recentf-item-format "%s")
- (setq recentf-exclude '("/emms/history")) ;; exclude EMMS history from recent files
+ (cj/--dashboard-exclude-emms-from-recentf) ;; exclude EMMS history from recent files
(setq dashboard-set-footer nil) ;; don't show footer and quotes
;; == navigation
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
index b4030479..393f1d97 100644
--- a/modules/org-capture-config.el
+++ b/modules/org-capture-config.el
@@ -351,12 +351,133 @@ Captured On: %U" :prepend t)
;; aborts, so the popup never lingers. Frames not named "org-capture" are
;; untouched — normal in-Emacs captures keep their windows.
+(defun cj/org-capture--popup-frame-p ()
+ "Return non-nil when the selected frame is the quick-capture popup."
+ (equal (frame-parameter nil 'name) "org-capture"))
+
(defun cj/org-capture--delete-popup-frame ()
"Delete the current frame when it is the quick-capture popup."
- (when (equal (frame-parameter nil 'name) "org-capture")
+ (when (cj/org-capture--popup-frame-p)
(delete-frame)))
(add-hook 'org-capture-after-finalize-hook #'cj/org-capture--delete-popup-frame)
+;; The popup opens a fresh emacsclient frame still showing the daemon's last
+;; buffer. `org-mks' shows the *Org Select* menu via
+;; `switch-to-buffer-other-window', and `org-capture-place-template' shows the
+;; CAPTURE-* buffer via `pop-to-buffer' with a split action — both split the
+;; small floating frame, so two reverse-video modelines read like tmux bars and
+;; the working buffer leaks into a popup that should only show capture UI. A
+;; frame-scoped `display-buffer-alist' entry forces both into the frame's sole
+;; window. Gated on the "org-capture" frame name, so normal in-Emacs captures
+;; keep their windows.
+
+(defun cj/org-capture--popup-sole-window-p (frame-name buffer-name)
+ "Return non-nil when BUFFER-NAME in a frame named FRAME-NAME is capture popup UI.
+Capture popup UI is the *Org Select* template menu or a CAPTURE-* buffer
+shown in the quick-capture frame (FRAME-NAME equal to \"org-capture\")."
+ (and (equal frame-name "org-capture")
+ (stringp buffer-name)
+ (or (equal buffer-name "*Org Select*")
+ (string-prefix-p "CAPTURE-" buffer-name))))
+
+(defun cj/org-capture--popup-display-condition (buffer-name &optional _action)
+ "`display-buffer' CONDITION matching capture UI in the quick-capture popup.
+BUFFER-NAME is the buffer's name; the selected frame supplies the frame name."
+ (cj/org-capture--popup-sole-window-p (frame-parameter nil 'name) buffer-name))
+
+(defun cj/org-capture--display-sole-window (buffer _alist)
+ "`display-buffer' ACTION showing BUFFER as the only window of the frame.
+Used for the quick-capture popup so the template menu and capture buffer
+never split the small floating frame."
+ (let ((window (frame-root-window)))
+ (delete-other-windows window)
+ (set-window-buffer window buffer)
+ window))
+
+(add-to-list 'display-buffer-alist
+ '(cj/org-capture--popup-display-condition
+ cj/org-capture--display-sole-window))
+
+;; The desktop quick-capture popup is launched globally (no browser selection,
+;; no mu4e message, no pdf/epub buffer), so most templates make no sense there:
+;; the context fields (%:link, %i) come up empty or point at the daemon's last
+;; buffer, and the pdf templates error outright. `cj/quick-capture' offers only
+;; Task, Bug, and Event; Task and Bug file to the global inbox rather than a
+;; project todo.org, since a desktop capture has no meaningful project context.
+;; It also closes the popup frame on every exit path (abort, error, finalize) —
+;; `org-capture' only runs `org-capture-after-finalize-hook' on a completed
+;; capture, so a q/C-g at the template menu or an erroring template would
+;; otherwise orphan the frame. The Hyprland script calls this instead of
+;; `org-capture'.
+
+(defun cj/--org-capture-popup-templates (templates inbox)
+ "Return the desktop-popup subset of TEMPLATES: Task, Bug, Event.
+Task (\"t\") and Bug (\"b\") are retargeted to INBOX's \"Inbox\" headline;
+Event (\"e\") passes through unchanged. All other templates are dropped.
+Template bodies and properties are preserved."
+ (delq nil
+ (mapcar
+ (lambda (entry)
+ (pcase (car-safe entry)
+ ((or "t" "b")
+ ;; (KEY DESC TYPE TARGET TEMPLATE . PROPS) -> retarget TARGET
+ (append (list (nth 0 entry) (nth 1 entry) (nth 2 entry)
+ (list 'file+headline inbox "Inbox"))
+ (nthcdr 4 entry)))
+ ("e" entry)
+ (_ nil)))
+ templates)))
+
+(defun cj/org-capture--popup-frame ()
+ "Return a live frame named \"org-capture\" (the quick-capture popup), or nil."
+ (seq-find (lambda (f)
+ (and (frame-live-p f)
+ (equal (frame-parameter f 'name) "org-capture")))
+ (frame-list)))
+
+(defun cj/quick-capture ()
+ "Org-capture entry point for the Hyprland desktop popup (frame \"org-capture\").
+Offers only Task, Bug, and Event; Task and Bug file to the global inbox.
+Closes the popup frame on abort or error so a stray selection never orphans it.
+
+Selects the \"org-capture\" frame by name before capturing rather than trusting
+the ambient selected frame: the launching =emacsclient -c -e= runs before
+Hyprland settles focus on the new float, so =(selected-frame)= is still the
+daemon's main frame and the capture would otherwise land there."
+ (interactive)
+ (let ((frame (cj/org-capture--popup-frame)))
+ (condition-case err
+ (progn
+ (when frame (select-frame-set-input-focus frame))
+ (let ((org-capture-templates
+ (cj/--org-capture-popup-templates org-capture-templates inbox-file)))
+ (org-capture)))
+ (quit (cj/org-capture--delete-popup-frame))
+ (error (message "Quick-capture: %s" (error-message-string err))
+ (cj/org-capture--delete-popup-frame)))))
+
+;; The template menu's "C — Customize org-capture-templates" special makes no
+;; sense in the desktop popup (it would open a Customize buffer in the floating
+;; frame). Strip it from the menu when the selection runs in the popup frame,
+;; keeping "q — Abort". `org-mks' is the menu primitive; advising it (gated on
+;; the frame name) catches the capture template selection without touching
+;; org-mks's other callers.
+
+(defun cj/--org-capture-popup-strip-specials (specials)
+ "Remove the \"C\" Customize entry from org-mks SPECIALS, keeping the rest.
+SPECIALS is the org-mks specials alist (e.g. the Customize and Abort entries)."
+ (delq nil (mapcar (lambda (s) (unless (equal (car-safe s) "C") s)) specials)))
+
+(defun cj/org-capture--popup-mks-advice (orig table title &optional prompt specials)
+ "Around-advice for `org-mks': hide the Customize special in the quick-capture popup.
+ORIG is the real `org-mks'; TABLE TITLE PROMPT SPECIALS are its arguments."
+ (funcall orig table title prompt
+ (if (cj/org-capture--popup-frame-p)
+ (cj/--org-capture-popup-strip-specials specials)
+ specials)))
+
+(advice-add 'org-mks :around #'cj/org-capture--popup-mks-advice)
+
(provide 'org-capture-config)
;;; org-capture-config.el ends here.
diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el
index 296b0550..2c6e400e 100644
--- a/modules/org-drill-config.el
+++ b/modules/org-drill-config.el
@@ -95,9 +95,12 @@ With a prefix arg OTHER-DIR, prompt for the directory instead of `drill-dir'."
(defun cj/drill-refile ()
"Refile to a drill file."
(interactive)
- (setq org-refile-targets '((nil :maxlevel . 1)
- (drill-dir :maxlevel . 1)))
- (call-interactively 'org-refile))
+ (let ((org-refile-targets
+ `((nil :maxlevel . 1)
+ (,(mapcar (lambda (f) (expand-file-name f drill-dir))
+ (cj/--drill-files-or-error drill-dir))
+ :maxlevel . 1))))
+ (call-interactively 'org-refile)))
;; ------------------------------- Drill Keymap --------------------------------
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
index fdd9e1fc..218f37d6 100644
--- a/modules/org-roam-config.el
+++ b/modules/org-roam-config.el
@@ -29,6 +29,12 @@
;; ---------------------------------- Org Roam ---------------------------------
+(defconst cj/--org-roam-dailies-head
+ "#+FILETAGS: Journal\n#+TITLE: %<%Y-%m-%d>\n"
+ "Head inserted into a new org-roam daily file.
+FILETAGS and TITLE must sit on separate lines so Org parses the
+#+TITLE keyword (see `org-roam-dailies-capture-templates').")
+
(use-package org-roam
:defer 1
:commands (org-roam-node-find org-roam-node-insert org-roam-db-autosync-mode)
@@ -37,9 +43,9 @@
(org-roam-dailies-directory journals-dir)
(org-roam-completion-everywhere t)
(org-roam-dailies-capture-templates
- '(("d" "default" entry "* %<%I:%M:%S %p %Z> %?"
+ `(("d" "default" entry "* %<%I:%M:%S %p %Z> %?"
:if-new (file+head "%<%Y-%m-%d>.org"
- "#+FILETAGS: Journal #+TITLE: %<%Y-%m-%d>"))))
+ ,cj/--org-roam-dailies-head))))
(org-roam-capture-templates
`(("d" "default" plain "%?"
diff --git a/modules/prog-general.el b/modules/prog-general.el
index a4be7205..8b4dedda 100644
--- a/modules/prog-general.el
+++ b/modules/prog-general.el
@@ -298,6 +298,22 @@ This is what makes universal snippets like =<cj= work in any buffer."
(yas-reload-all)
(yas-global-mode 1))
+;; Most of the snippet keys start with "<" (=<cj=, =<for=, =<main=…), mirroring
+;; org-tempo. But `electric-pair-mode' pairs "<" into "<>" wherever the mode's
+;; syntax table gives "<" paren syntax (org, and the prog modes that enable
+;; pairing), so typing "<cj" lands as "<cj>"; expanding the "<cj" key then
+;; strands the ">" after the snippet — the cj-comment fence comes out as
+;; "#+end_src>", which breaks the cj-scan fence parser. Inhibit pairing for the
+;; open angle bracket globally; defer to the default for every other character.
+(defun cj/--electric-pair-inhibit-angle (char)
+ "Return non-nil to stop `electric-pair-mode' from pairing the angle CHAR.
+Inhibit the open angle bracket so \"<\"-prefixed yasnippet keys expand cleanly;
+defer to `electric-pair-default-inhibit' for any other CHAR."
+ (or (eq char ?<)
+ (electric-pair-default-inhibit char)))
+
+(setq electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle)
+
;; --------------------- Display Color On Color Declaration --------------------
;; display the actual color as highlight to color hex code
diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el
index f1324c16..f2181d97 100644
--- a/modules/ui-navigation.el
+++ b/modules/ui-navigation.el
@@ -160,7 +160,9 @@ This function won't work with more than one split window."
;; UNDO KILL BUFFER
(defun cj/undo-kill-buffer (arg)
- "Re-open the last buffer killed. With ARG, re-open the nth buffer."
+ "Re-open the last buffer killed.
+With numeric prefix ARG, re-open the ARGth most-recently-killed file
+\(1-based, so no prefix re-opens the most recent)."
(interactive "p")
(require 'recentf)
(unless recentf-mode
@@ -177,9 +179,11 @@ This function won't work with more than one split window."
(delq buf-file recently-killed-list)))
buffer-files-list)
(when recently-killed-list
- (find-file
- (if arg (nth arg recently-killed-list)
- (car recently-killed-list))))))
+ (let ((file (nth (1- arg) recently-killed-list)))
+ (if file
+ (find-file file)
+ (user-error "Only %d killed file(s) to choose from"
+ (length recently-killed-list)))))))
(keymap-global-set "M-S-z" #'cj/undo-kill-buffer) ;; was M-Z, overrides zap-to-char
;; ---------------------------- Undo Layout Changes ----------------------------
diff --git a/tests/test-dashboard-config-recentf-exclude.el b/tests/test-dashboard-config-recentf-exclude.el
new file mode 100644
index 00000000..f35b3eda
--- /dev/null
+++ b/tests/test-dashboard-config-recentf-exclude.el
@@ -0,0 +1,33 @@
+;;; test-dashboard-config-recentf-exclude.el --- recentf-exclude is not clobbered -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--dashboard-exclude-emms-from-recentf' adds the EMMS history pattern
+;; to `recentf-exclude'. It must ADD to the list, not replace it, or it
+;; wipes the exclusions system-defaults.el set earlier in init order
+;; (emacs_bookmarks, elpa, recentf, ElfeedDB, airootfs).
+
+;;; Code:
+
+(require 'ert)
+(require 'recentf) ; makes `recentf-exclude' special so the let below is dynamic
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-general)
+(require 'dashboard-config)
+
+(ert-deftest test-dashboard-config-exclude-emms-preserves-existing-entries ()
+ "Error: excluding the EMMS history preserves prior recentf-exclude entries."
+ (let ((recentf-exclude (list "emacs_bookmarks" "airootfs")))
+ (cj/--dashboard-exclude-emms-from-recentf)
+ (should (member "/emms/history" recentf-exclude))
+ (should (member "emacs_bookmarks" recentf-exclude))
+ (should (member "airootfs" recentf-exclude))))
+
+(ert-deftest test-dashboard-config-exclude-emms-adds-the-pattern ()
+ "Normal: the EMMS history pattern is present after the call."
+ (let ((recentf-exclude nil))
+ (cj/--dashboard-exclude-emms-from-recentf)
+ (should (member "/emms/history" recentf-exclude))))
+
+(provide 'test-dashboard-config-recentf-exclude)
+;;; test-dashboard-config-recentf-exclude.el ends here
diff --git a/tests/test-org-capture-config-popup-window.el b/tests/test-org-capture-config-popup-window.el
new file mode 100644
index 00000000..34f67b36
--- /dev/null
+++ b/tests/test-org-capture-config-popup-window.el
@@ -0,0 +1,281 @@
+;;; test-org-capture-config-popup-window.el --- Quick-capture popup single-window tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the pure predicate behind the quick-capture popup single-window
+;; fix. The Hyprland Super+Shift+N popup opens an emacsclient frame named
+;; "org-capture"; in that frame the *Org Select* template menu and the
+;; CAPTURE-* buffer must fill the frame's sole window instead of splitting it.
+;; `cj/org-capture--popup-sole-window-p' is the frame+buffer decision; the
+;; display-buffer action that acts on it is exercised by hand (window ops),
+;; not here.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+(require 'org-capture) ; makes `org-capture-templates' a real special var
+(require 'user-constants)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+(defconst test-org-capture-popup--sample-templates
+ '(("t" "Task" entry (function cj/--org-capture-project-location)
+ "* TODO %?" :prepend t)
+ ("b" "Bug" entry (function cj/--org-capture-project-location)
+ "* TODO [#C] %?" :prepend t)
+ ("e" "Event" entry (file+headline schedule-file "Scheduled Events")
+ "* %?" :prepend t :prepare-finalize cj/org-capture-format-event-headline)
+ ("m" "Mu4e Email" entry (file+headline inbox-file "Inbox") "* TODO %?" :prepend t)
+ ("L" "Link" entry (file+headline inbox-file "Inbox") "* %?" :immediate-finish t)
+ ("d" "Drill Question" entry (file ignore) "* Item :drill:\n%?" :prepend t))
+ "A representative org-capture-templates list for popup-subset tests.")
+
+;;; cj/org-capture--popup-sole-window-p
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-select-menu ()
+ "Normal: the *Org Select* menu in the popup frame wants the sole window."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "*Org Select*")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-capture-buffer ()
+ "Normal: a CAPTURE-* buffer in the popup frame wants the sole window."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "CAPTURE-todo.org")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-capture-prefix-only ()
+ "Boundary: the bare \"CAPTURE-\" prefix still matches."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "CAPTURE-")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-other-frame ()
+ "Boundary: the same menu in a normal frame is left alone."
+ (should-not (cj/org-capture--popup-sole-window-p "emacs" "*Org Select*"))
+ (should-not (cj/org-capture--popup-sole-window-p nil "CAPTURE-todo.org")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-other-buffer ()
+ "Boundary: an unrelated buffer in the popup frame is left alone."
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" "todo.org"))
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" "*scratch*")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-nil-buffer ()
+ "Error: a nil or non-string buffer name returns nil without raising."
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" nil))
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" 42)))
+
+;;; Integration: the display-buffer-alist entry routes to a sole window
+
+(ert-deftest test-integration-org-capture-popup-display-sole-window ()
+ "Integration: in an \"org-capture\"-named frame, displaying a CAPTURE-*
+buffer fills the frame's sole window via the registered display-buffer-alist
+entry, instead of splitting.
+
+Components integrated:
+- cj/org-capture--popup-display-condition (real)
+- cj/org-capture--display-sole-window (real)
+- display-buffer / display-buffer-alist (real)
+
+Validates the popup frame ends with one window showing the CAPTURE buffer."
+ ;; The batch frame is auto-named (\"F1\"), which cannot be restored by name
+ ;; (\"F<num> usurped by Emacs\"); reset to nil to return it to auto-naming,
+ ;; keeping the test independent of execution order.
+ (let ((buf (get-buffer-create "CAPTURE-itest")))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "org-capture")
+ (delete-other-windows)
+ (display-buffer buf)
+ (should (= (length (window-list)) 1))
+ (should (eq (window-buffer (selected-window)) buf)))
+ (set-frame-parameter nil 'name nil)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+;;; cj/--org-capture-popup-templates (pure subset/retarget)
+
+(ert-deftest test-org-capture-config-popup-templates-keeps-tbe ()
+ "Normal: only Task, Bug, Event survive, preserving order."
+ (should (equal (mapcar #'car (cj/--org-capture-popup-templates
+ test-org-capture-popup--sample-templates "/inbox.org"))
+ '("t" "b" "e"))))
+
+(ert-deftest test-org-capture-config-popup-templates-retargets-task-bug ()
+ "Normal: Task and Bug retarget to the inbox \"Inbox\" headline; body + props kept."
+ (let* ((result (cj/--org-capture-popup-templates
+ test-org-capture-popup--sample-templates "/inbox.org"))
+ (task (assoc "t" result))
+ (bug (assoc "b" result)))
+ (should (equal (nth 3 task) '(file+headline "/inbox.org" "Inbox")))
+ (should (equal (nth 3 bug) '(file+headline "/inbox.org" "Inbox")))
+ (should (equal (nth 4 task) "* TODO %?"))
+ (should (equal (nth 4 bug) "* TODO [#C] %?"))
+ (should (memq :prepend task))))
+
+(ert-deftest test-org-capture-config-popup-templates-event-unchanged ()
+ "Boundary: Event passes through untouched, schedule-file target and props intact."
+ (let ((event (assoc "e" (cj/--org-capture-popup-templates
+ test-org-capture-popup--sample-templates "/inbox.org"))))
+ (should (equal (nth 3 event) '(file+headline schedule-file "Scheduled Events")))
+ (should (memq :prepare-finalize event))))
+
+(ert-deftest test-org-capture-config-popup-templates-drops-context-templates ()
+ "Boundary: context-dependent templates (mu4e, link, drill) are dropped."
+ (let ((result (cj/--org-capture-popup-templates
+ test-org-capture-popup--sample-templates "/inbox.org")))
+ (should-not (assoc "m" result))
+ (should-not (assoc "L" result))
+ (should-not (assoc "d" result))))
+
+(ert-deftest test-org-capture-config-popup-templates-empty ()
+ "Error/Boundary: empty or all-dropped input yields nil without raising."
+ (should-not (cj/--org-capture-popup-templates nil "/inbox.org"))
+ (should-not (cj/--org-capture-popup-templates
+ '(("L" "Link" entry (file+headline f "Inbox") "* %?")) "/inbox.org")))
+
+;;; cj/quick-capture (binds the subset; integration with a stubbed org-capture)
+
+(ert-deftest test-integration-org-capture-quick-capture-binds-subset ()
+ "Integration: cj/quick-capture runs org-capture with only Task/Bug/Event,
+Task and Bug retargeted to the inbox.
+
+Components integrated:
+- cj/quick-capture (real)
+- cj/--org-capture-popup-templates (real)
+- org-capture (MOCKED — records the dynamically-bound templates)"
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ captured)
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (setq captured org-capture-templates))))
+ (cj/quick-capture))
+ (should (equal (mapcar #'car captured) '("t" "b" "e")))
+ (should (equal (nth 3 (assoc "t" captured)) (list 'file+headline inbox-file "Inbox")))
+ (should (equal (nth 3 (assoc "b" captured)) (list 'file+headline inbox-file "Inbox")))))
+
+(ert-deftest test-integration-org-capture-quick-capture-closes-frame-on-abort ()
+ "Integration: when selection aborts (org-capture signals), cj/quick-capture
+deletes the popup frame instead of leaving it orphaned.
+
+Components integrated:
+- cj/quick-capture (real)
+- org-capture (MOCKED — signals user-error \"Abort\")
+- cj/org-capture--delete-popup-frame (MOCKED — records the call)"
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (deleted 0))
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (user-error "Abort")))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 1))))
+
+(ert-deftest test-integration-org-capture-quick-capture-closes-frame-on-quit ()
+ "Integration: a C-g (quit) during capture also closes the popup frame."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (deleted 0))
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (signal 'quit nil)))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 1))))
+
+(ert-deftest test-integration-org-capture-quick-capture-keeps-frame-on-success ()
+ "Integration: a successful capture (no signal) does NOT delete the frame —
+the finalize hook owns that."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (deleted 0))
+ (cl-letf (((symbol-function 'org-capture) (lambda (&rest _) nil))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 0))))
+
+;;; cj/--org-capture-popup-strip-specials (drop the Customize menu entry)
+
+(ert-deftest test-org-capture-config-popup-strip-specials-removes-customize ()
+ "Normal: the \"C\" Customize entry is removed, \"q\" Abort kept, order intact."
+ (should (equal (cj/--org-capture-popup-strip-specials
+ '(("C" "Customize org-capture-templates") ("q" "Abort")))
+ '(("q" "Abort")))))
+
+(ert-deftest test-org-capture-config-popup-strip-specials-no-customize ()
+ "Boundary: specials without a \"C\" entry pass through unchanged."
+ (should (equal (cj/--org-capture-popup-strip-specials '(("q" "Abort")))
+ '(("q" "Abort")))))
+
+(ert-deftest test-org-capture-config-popup-strip-specials-empty ()
+ "Error/Boundary: nil specials yields nil without raising."
+ (should-not (cj/--org-capture-popup-strip-specials nil)))
+
+;;; cj/org-capture--popup-frame-p
+
+(ert-deftest test-org-capture-config-popup-frame-p ()
+ "Normal/Boundary: true only when the selected frame is named \"org-capture\"."
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "org-capture")))
+ (should (cj/org-capture--popup-frame-p)))
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "emacs")))
+ (should-not (cj/org-capture--popup-frame-p))))
+
+;;; cj/org-capture--popup-mks-advice (frame-gated specials stripping)
+
+(ert-deftest test-org-capture-config-popup-mks-advice-strips-in-popup ()
+ "Integration: in the popup frame, org-mks receives specials without \"C\"."
+ (let (seen)
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame-p) (lambda () t)))
+ (cj/org-capture--popup-mks-advice
+ (lambda (_table _title _prompt specials) (setq seen specials))
+ nil nil nil '(("C" "Customize org-capture-templates") ("q" "Abort"))))
+ (should (equal seen '(("q" "Abort"))))))
+
+(ert-deftest test-org-capture-config-popup-mks-advice-keeps-elsewhere ()
+ "Integration: in a normal frame, org-mks receives the specials untouched."
+ (let (seen)
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame-p) (lambda () nil)))
+ (cj/org-capture--popup-mks-advice
+ (lambda (_table _title _prompt specials) (setq seen specials))
+ nil nil nil '(("C" "Customize org-capture-templates") ("q" "Abort"))))
+ (should (equal seen '(("C" "Customize org-capture-templates") ("q" "Abort"))))))
+
+;;; cj/org-capture--popup-frame (find the popup frame by name)
+
+(ert-deftest test-org-capture-config-popup-frame-found ()
+ "Normal: returns the live frame whose name is \"org-capture\"."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f _p) (if (eq f 'fb) "org-capture" "other"))))
+ (should (eq (cj/org-capture--popup-frame) 'fb))))
+
+(ert-deftest test-org-capture-config-popup-frame-none ()
+ "Boundary: no popup frame present yields nil."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) "other")))
+ (should-not (cj/org-capture--popup-frame))))
+
+;;; cj/quick-capture targets the popup frame
+
+(ert-deftest test-integration-org-capture-quick-capture-selects-named-frame ()
+ "Integration: cj/quick-capture selects the \"org-capture\" frame found by name,
+not whatever frame happens to be selected (the emacsclient -c focus race)."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (focused nil))
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () 'popup-frame))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f) (setq focused f)))
+ ((symbol-function 'org-capture) (lambda (&rest _) nil)))
+ (cj/quick-capture))
+ (should (eq focused 'popup-frame))))
+
+(ert-deftest test-integration-org-capture-quick-capture-no-frame-still-captures ()
+ "Integration: when no popup frame is found, cj/quick-capture skips the focus
+call and still runs the capture (no error)."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (focused 'unset)
+ (captured nil))
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () nil))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f) (setq focused f)))
+ ((symbol-function 'org-capture) (lambda (&rest _) (setq captured t))))
+ (cj/quick-capture))
+ (should (eq focused 'unset))
+ (should captured)))
+
+(provide 'test-org-capture-config-popup-window)
+;;; test-org-capture-config-popup-window.el ends here
diff --git a/tests/test-org-drill-config-commands.el b/tests/test-org-drill-config-commands.el
index 7d197616..c35bd6cd 100644
--- a/tests/test-org-drill-config-commands.el
+++ b/tests/test-org-drill-config-commands.el
@@ -71,21 +71,50 @@
;;; cj/drill-refile
-(ert-deftest test-org-drill-refile-sets-targets-and-delegates ()
- "Normal: drill-refile narrows `org-refile-targets' to current buffer +
-`drill-dir', then dispatches to `org-refile' via `call-interactively'."
- (let (seen-targets called-fn)
- (cl-letf (((symbol-function 'call-interactively)
+(ert-deftest test-org-drill-refile-targets-from-validated-helper ()
+ "Normal: drill-refile builds its drill targets from the shared
+`cj/--drill-files-or-error' helper, expanded against `drill-dir' — not from
+a raw `directory-files' call (so it inherits the helper's dot-file exclusion
+and validation)."
+ (let ((drill-dir "/tmp/cj-drill/")
+ seen-targets called-fn)
+ (cl-letf (((symbol-function 'cj/--drill-files-or-error)
+ (lambda (_dir) '("a.org" "b.org")))
+ ;; If the old raw path were still in use it would call
+ ;; `directory-files'; a sentinel here keeps it from masquerading.
+ ((symbol-function 'directory-files)
+ (lambda (&rest _) '("/WRONG/raw.org")))
+ ((symbol-function 'call-interactively)
(lambda (fn)
(setq called-fn fn
seen-targets org-refile-targets))))
(cj/drill-refile))
(should (eq called-fn 'org-refile))
- (should seen-targets)
- ;; Two entries: (nil :maxlevel . 1) and (drill-dir :maxlevel . 1).
(should (= 2 (length seen-targets)))
(should (assoc nil seen-targets))
- (should (assoc 'drill-dir seen-targets))))
+ (should (equal (car (nth 1 seen-targets))
+ '("/tmp/cj-drill/a.org" "/tmp/cj-drill/b.org")))))
+
+(ert-deftest test-org-drill-refile-does-not-clobber-global-targets ()
+ "Error: drill-refile let-binds `org-refile-targets'; the session-wide value
+survives the call instead of being permanently replaced."
+ (let ((drill-dir "/tmp/cj-drill/")
+ (org-refile-targets '((sentinel :maxlevel . 9))))
+ (cl-letf (((symbol-function 'cj/--drill-files-or-error) (lambda (_dir) '("a.org")))
+ ((symbol-function 'call-interactively) (lambda (_fn) nil)))
+ (cj/drill-refile))
+ (should (equal org-refile-targets '((sentinel :maxlevel . 9))))))
+
+(ert-deftest test-org-drill-refile-errors-on-missing-drill-dir ()
+ "Error: a missing or unreadable drill dir signals a clear `user-error' via
+the shared validated helper, instead of a low-level error, and never reaches
+`org-refile'."
+ (let ((drill-dir (expand-file-name "cj-drill-nonexistent-XYZ/"
+ temporary-file-directory))
+ (called nil))
+ (cl-letf (((symbol-function 'call-interactively) (lambda (_fn) (setq called t))))
+ (should-error (cj/drill-refile) :type 'user-error))
+ (should-not called)))
(provide 'test-org-drill-config-commands)
;;; test-org-drill-config-commands.el ends here
diff --git a/tests/test-org-roam-config-dailies-head.el b/tests/test-org-roam-config-dailies-head.el
new file mode 100644
index 00000000..631f017c
--- /dev/null
+++ b/tests/test-org-roam-config-dailies-head.el
@@ -0,0 +1,29 @@
+;;; test-org-roam-config-dailies-head.el --- Tests for the dailies template head -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--org-roam-dailies-head' is the head inserted into a new org-roam
+;; daily file. #+FILETAGS and #+TITLE must sit on separate lines, or Org
+;; never parses the #+TITLE keyword and the FILETAGS value swallows the
+;; rest of the line.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-roam-config)
+
+(ert-deftest test-org-roam-config-dailies-head-separates-filetags-and-title ()
+ "Boundary: #+FILETAGS and #+TITLE sit on separate lines."
+ (should (string-match-p "#\\+FILETAGS: Journal\n#\\+TITLE:"
+ cj/--org-roam-dailies-head))
+ ;; And never run together on one line.
+ (should-not (string-match-p "Journal #\\+TITLE:" cj/--org-roam-dailies-head)))
+
+(ert-deftest test-org-roam-config-dailies-head-ends-with-newline ()
+ "Boundary: the head ends with a newline so the capture body starts clean."
+ (should (string-suffix-p "\n" cj/--org-roam-dailies-head)))
+
+(provide 'test-org-roam-config-dailies-head)
+;;; test-org-roam-config-dailies-head.el ends here
diff --git a/tests/test-prog-general--electric-pair-angle.el b/tests/test-prog-general--electric-pair-angle.el
new file mode 100644
index 00000000..cb33725a
--- /dev/null
+++ b/tests/test-prog-general--electric-pair-angle.el
@@ -0,0 +1,54 @@
+;;; test-prog-general--electric-pair-angle.el --- Angle-bracket pairing inhibit -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--electric-pair-inhibit-angle, which stops electric-pair from
+;; pairing "<" into "<>". Craig's yasnippet keys start with "<" (e.g. <cj);
+;; auto-pairing the "<" strands a ">" after the expanded snippet, which broke
+;; the cj-comment close fence into "#+end_src>".
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'elec-pair)
+(require 'org)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-general)
+
+;;; cj/--electric-pair-inhibit-angle
+
+(ert-deftest test-prog-general-electric-pair-inhibit-angle-open ()
+ "Normal: the open angle bracket is inhibited."
+ (should (cj/--electric-pair-inhibit-angle ?<)))
+
+(ert-deftest test-prog-general-electric-pair-inhibit-angle-delegates ()
+ "Boundary: any other character defers to electric-pair-default-inhibit."
+ (cl-letf (((symbol-function 'electric-pair-default-inhibit)
+ (lambda (_c) 'delegated)))
+ (should (eq (cj/--electric-pair-inhibit-angle ?a) 'delegated))
+ (should (eq (cj/--electric-pair-inhibit-angle ?\() 'delegated))))
+
+(ert-deftest test-prog-general-electric-pair-predicate-installed ()
+ "Normal: prog-general installs the predicate as the global value."
+ (should (eq electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle)))
+
+;;; Integration — the actual pairing behavior
+
+(ert-deftest test-integration-prog-general-angle-not-paired-in-org ()
+ "Integration: in an org buffer (where < has paren syntax), typing < with the
+inhibit predicate active inserts just <, not <>.
+
+Components integrated:
+- cj/--electric-pair-inhibit-angle (real)
+- electric-pair-local-mode / self-insert-command (real)
+- org-mode syntax table (real — gives < paren syntax)"
+ (with-temp-buffer
+ (org-mode)
+ (electric-pair-local-mode 1)
+ (setq-local electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle)
+ (let ((last-command-event ?<))
+ (call-interactively #'self-insert-command))
+ (should (equal (buffer-substring-no-properties (point-min) (point-max)) "<"))))
+
+(provide 'test-prog-general--electric-pair-angle)
+;;; test-prog-general--electric-pair-angle.el ends here
diff --git a/tests/test-ui-navigation-split-follow-undo-kill.el b/tests/test-ui-navigation-split-follow-undo-kill.el
index 74c1e2fc..8e390074 100644
--- a/tests/test-ui-navigation-split-follow-undo-kill.el
+++ b/tests/test-ui-navigation-split-follow-undo-kill.el
@@ -54,8 +54,9 @@
;;; cj/undo-kill-buffer
-(ert-deftest test-ui-navigation-undo-kill-buffer-opens-most-recent ()
- "Normal: with no arg, opens the head of recentf-list that isn't currently visited."
+(ert-deftest test-ui-navigation-undo-kill-buffer-no-prefix-opens-most-recent ()
+ "Normal: no prefix (arg=1, the value `\"p\"' yields) opens the most-recent
+non-visited entry, not the second."
(let ((opened nil)
(recentf-mode t)
(recentf-list '("/tmp/dead.org" "/tmp/alive.txt")))
@@ -71,12 +72,12 @@
((symbol-function 'find-file)
(lambda (f) (setq opened f))))
(unwind-protect
- (cj/undo-kill-buffer 0)
+ (cj/undo-kill-buffer 1)
(when (get-buffer "*test-alive*") (kill-buffer "*test-alive*"))))
(should (equal opened "/tmp/dead.org"))))
-(ert-deftest test-ui-navigation-undo-kill-buffer-honors-numeric-arg ()
- "Normal: with N=1, opens the second non-visited entry from recentf-list."
+(ert-deftest test-ui-navigation-undo-kill-buffer-numeric-arg-is-one-based ()
+ "Normal: a numeric prefix is 1-based — N=2 opens the second non-visited entry."
(let ((opened nil)
(recentf-mode t)
(recentf-list '("/tmp/a.org" "/tmp/b.org" "/tmp/c.org")))
@@ -85,10 +86,7 @@
((symbol-function 'buffer-list) (lambda (&rest _) nil))
((symbol-function 'find-file)
(lambda (f) (setq opened f))))
- ;; cj/undo-kill-buffer takes a prefix `arg' and indexes into the list
- ;; with `(nth arg ...)` when arg is non-nil. Passing 1 grabs the 2nd
- ;; entry.
- (cj/undo-kill-buffer 1))
+ (cj/undo-kill-buffer 2))
(should (equal opened "/tmp/b.org"))))
(ert-deftest test-ui-navigation-undo-kill-buffer-no-op-when-list-empty ()
@@ -104,5 +102,18 @@
(cj/undo-kill-buffer 0))
(should-not opened)))
+(ert-deftest test-ui-navigation-undo-kill-buffer-out-of-range-arg-errors ()
+ "Error: a prefix larger than the killed-file list signals a clear user-error,
+not a wrong-type-argument from find-file on nil."
+ (let ((opened nil)
+ (recentf-mode t)
+ (recentf-list '("/tmp/a.org")))
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) t))
+ ((symbol-function 'recentf-mode) (lambda (&rest _) t))
+ ((symbol-function 'buffer-list) (lambda (&rest _) nil))
+ ((symbol-function 'find-file) (lambda (f) (setq opened f))))
+ (should-error (cj/undo-kill-buffer 5) :type 'user-error))
+ (should-not opened)))
+
(provide 'test-ui-navigation-split-follow-undo-kill)
;;; test-ui-navigation-split-follow-undo-kill.el ends here
diff --git a/todo.org b/todo.org
index c598582f..556f2918 100644
--- a/todo.org
+++ b/todo.org
@@ -44,13 +44,22 @@ Tags are additive. For example, a small wrong-behavior fix can be
=:bug:quick:=, and a feature that requires internal restructuring can be
=:feature:refactor:=.
* Emacs Open Work
+** TODO [#B] ai-term adaptive side/bottom window placement :feature:solo:
+The ai-term window should dock from whichever edge conserves more screen space, chosen at display time from the frame's aspect ratio: when the frame is wider than it is tall, dock from the right; when it is square or taller than wide, dock from the bottom. Compare the frame's pixel width against its height in the display-buffer rule to pick the edge.
+
+** TODO [#B] Keymap consolidation — resolve decisions, run Phase 1-2 :feature:refactor:solo:
+Spec: [[file:docs/design/keybinding-console-safety-spec.org][keybinding-console-safety-spec.org]]. Phase 0 (revert 4a1ecf64) is done and pushed. Decisions D1-D5 are open TODOs in the spec; D2/D4/D5 gate the primary work (Phase 1 prune via Appendix D, Phase 2 consolidate + retire the translation block), while D1/D3 (the console-safe prefix) gate only the optional Phase 3 and can stay open indefinitely. Resolve D2/D4/D5, then run Phase 1-2. Appendix D is the keybinding pruning checklist. Add a =#+TODO: TODO | DONE SUPERSEDED CANCELLED= header line to the spec if adopting those decision keywords (rulesets convention update, 2026-06-12).
+
+** TODO [#D] Desktop quick-capture: Note + Recipe types :feature:solo:
+Deferred 2026-06-13 — build when the need triggers, not ahead of use. Add generic Note (timestamped datetree) and Recipe (skeleton with Ingredients/Instructions + :SOURCE:) capture types to =cj/quick-capture= in =modules/org-capture-config.el=: one template each with an absolute target plus its key in the desktop subset; reuse the existing frame-cleanup. Full design in the archsetup handoff (2026-06-13 note in the inbox/sessions).
+
** TODO [#A] Calibre Open Work
:PROPERTIES:
:LAST_REVIEWED: 2026-06-06
:END:
Parent grouping the open Calibre / ebook-workflow issues; close each child independently. The EPUB reading-width tasks were already resolved (2026-05-12/14).
-*** DOING Calibre bookmark title format :feature:solo:quick:
+*** 2026-06-12 Fri @ 07:34:05 -0500 Calibre bookmark naming ships "Author, Title" from the filename
When I hit m in calibre, I'm making my place in the book with a bookmark.
While sometimes, the books look fine: "The A.B.C. Murders - Agatha Christie.epub"
Sometimes they look not so good: Engines of Logic_ Mathematicians and the O - Martin Davis.pdf or Software Architecture_ The Hard Parts _ Mo - Neal Ford.pdf
@@ -65,9 +74,9 @@ Implemented 2026-06-06. Source decision: parse the *filename*, not the embedded
Existing bookmarks: the 3 nov bookmarks in =~/sync/org/emacs_bookmarks= were renamed by hand (one-pass, in the daemon + saved; backup at =emacs_bookmarks.bak-2026-06-06=): "Edward Kanterian, Frege: A Guide for the Perplexed", "Agatha Christie, The A.B.C. Murders", "Edward Abbey, The Fool's Progress: An Honest Novel".
-Awaiting Craig's manual confirm: make a NEW bookmark (open an EPUB, hit m) and check the default name is "Author, Title" from the filename.
+Manual verify filed under the Manual testing and validation parent.
-*** DOING [#A] Reconsider Calibre keybindings :feature:
+*** 2026-06-12 Fri @ 07:34:05 -0500 Curated Calibre keybinding menu + docked description shipped
Relocated from the global capture inbox 2026-06-06. Want a discoverable set of keybindings (visible in which-key) for the most frequent calibredb workflows:
- Switch to a library (e.g. Literature), sort by last name, scroll the list.
- Scope/filter the list in place, keeping the current library scope:
@@ -81,7 +90,7 @@ Survey finding 2026-06-06: calibredb already binds almost all of this in calibre
Implemented 2026-06-06 in =modules/calibredb-epub-config.el=:
- A curated transient =cj/calibredb-menu= (library switch; filter format/author/reset; sort author/title/pubdate/format; open; describe; H = full calibredb-dispatch) bound to =?= in calibredb-search-mode-map. calibredb's own full dispatch moved to =H=. Defined in the use-package =:config= (needs the elpa transient, which batch doesn't load) -- the "? brings up a curated help menu" convention.
- Bottom-30% description dock: =calibredb-show-entry-switch= -> =pop-to-buffer= + a =display-buffer-alist= rule for =*calibredb-entry*= (display-buffer-at-bottom, height 0.3); =cj/calibredb-describe-at-point= shows the entry without switching focus so q dismisses it. Same pattern as the signel chat dock.
-1 ERT test (the describe command; the transient/bindings/dock need the elpa transient + live calibredb, verified in the daemon). Author "begins-with" is covered well enough by g a's completing-read over "Last, First"; a true regex filter was not built. Awaiting Craig's manual verify (M-B -> ? menu; d/v docked description; H full menu).
+1 ERT test (the describe command; the transient/bindings/dock need the elpa transient + live calibredb, verified in the daemon). Author "begins-with" is covered well enough by g a's completing-read over "Last, First"; a true regex filter was not built. Manual verify filed under the Manual testing and validation parent.
*** TODO Embed Calibre DB metadata into the EPUB files
Surfaced 2026-06-06 while building the bookmark naming: the metadata embedded in the EPUB files' OPF is worse than Calibre's database metadata. nov reads the embedded OPF and got truncated titles ("Frege" vs the filename's "Frege: A Guide for the Perplexed"), author-sort "Last, First" forms ("Christie, Agatha"), and lost punctuation ("A.B.C." -> "A B C"). The filenames (from Calibre's curated DB) are the good copy. Fix on the Calibre side: select all (or by library), run "Edit metadata -> Embed metadata into book files" so the DB metadata is written into each EPUB's OPF. Consider auditing author vs author_sort first. After embedding, the in-file metadata matches the library and any tool reading the files (nov, other readers, re-imports) gets the good data. Not an Emacs task; Calibre-side bulk maintenance.
@@ -761,8 +770,8 @@ Tie this into the existing coverage work:
** TODO [#B] Messenger window/key unification :feature:
Spec: [[file:docs/design/messenger-unification-spec.org][messenger-unification-spec.org]] (Draft, 2026-06-11). One library (=cj-messenger-lib.el=) gives every messenger the same shape: chat windows rise from the bottom (the signel rule, generalized), C-c C-c confirms, C-c C-k cancels, C-c C-a attaches — dispatched per backend through a registry + minor mode. Signel already conforms (reference backend); telega and slack join in phases 2-3; ERC later. All eight decisions settled 2026-06-11 (cancel closes an idle window; telega's filter-cancel shadow accepted; slack rooms join the bottom rule). Spec held open — Craig has more ideas to fold in before it's marked Ready.
-** TODO [#B] cj/undo-kill-buffer off-by-one on plain invocation :bug:quick:solo:
-=modules/ui-navigation.el:181= — =(interactive "p")= makes arg always ≥1, and the body does =(if arg (nth arg list) (car list))=, so the nth branch always runs and plain M-S-z reopens the SECOND-most-recently-killed file. The existing test passes 0 explicitly, masking it. Fix the indexing (=(interactive "P")= + =prefix-numeric-value=, or =nth (1- arg)=) and fix the test to cover the no-prefix path. From the 2026-06 config audit.
+** TODO [#C] cj/undo-kill-buffer skip-visited uses delq (eq) on path strings :bug:quick:solo:
+=modules/ui-navigation.el= — the visited-file filter calls =(delq buf-file recently-killed-list)= where =buf-file= is a fresh string from =expand-file-name=, never =eq= to the =recentf-list= entries, so already-open files are never skipped (the skip logic is dead). Use =delete= (equal-based). Found 2026-06-12 while fixing the off-by-one above; the two bugs cancel exactly when one file is open, which is why it went unnoticed.
** TODO [#B] reconcile-open-repos skips any repo with a dot in its name :bug:solo:
=modules/reconcile-open-repos.el:174= — discovery regexp ="^[^.]+$"= matches only dot-free names, so =~/code/mcp.el=, =capture.el=, =google-contacts.el=, =auto-dim-other-buffers.el= etc. are never reconciled while M-P still reports "Complete." Replace with =directory-files-no-dot-files-regexp= + a hidden-dir check; add a regression test with a dotted repo name. From the 2026-06 config audit.
@@ -776,9 +785,6 @@ Also =jumper.el:178= — the promised single-location toggle never toggles back
** TODO [#B] C-s C-s vertico-repeat path never works :bug:quick:solo:
=modules/selection-framework.el:263= — =cj/consult-line-or-repeat= calls =vertico-repeat= on the second consecutive C-s, but nothing adds =vertico-repeat-save= to =minibuffer-setup-hook= (grep: zero hits config-wide), so it always signals "No Vertico session". Add the hook next to the vertico use-package block. From the 2026-06 config audit.
-** TODO [#B] dashboard-config setq wipes recentf-exclude list :bug:quick:solo:
-=modules/dashboard-config.el:199= =(setq recentf-exclude '("/emms/history"))= discards the five exclusions system-defaults.el:239-243 added earlier in init order (bookmarks, elpa, recentf, ElfeedDB, airootfs). Change to =add-to-list=. From the 2026-06 config audit.
-
** TODO [#B] auth-config: unguarded gpg-connect-agent call + compile-time require :bug:quick:solo:
From the 2026-06 config audit. =modules/auth-config.el:88= — bare =(call-process "gpg-connect-agent" ...)= in a =:demand t= :config signals file-missing and aborts init on machines without the binary; guard with =cj/executable-find-or-warn=. =auth-config.el:36= — =user-constants= is required only =eval-when-compile= but =authinfo-file= is read at load time; works from .el source, fails from standalone .elc. Use a runtime require (system-defaults.el:32-35 documents this exact trap).
@@ -788,9 +794,6 @@ From the 2026-06 config audit. =modules/auth-config.el:88= — bare =(call-proce
** TODO [#B] markdown live preview clobbered by markdown-mode :bug:quick:solo:
=modules/markdown-config.el:54= defines bare =markdown-preview=, which markdown-mode redefines the moment the first .md loads — the impatient-mode live preview is dead and F2 silently runs the package command (agent verified in the live daemon). Also =:61= guards on =(boundp 'httpd-process)=, a variable that doesn't exist in simple-httpd — use =(httpd-running-p)=. And the =:config= =(setq imp-set-user-filter 'markdown-html)= at line 41 is doubly dead (function-not-variable, symbol names nothing) — delete. Rename to =cj/markdown-preview=, rebind F2. From the 2026-06 config audit.
-** TODO [#B] org-roam dailies template writes FILETAGS and TITLE on one line :bug:quick:solo:
-=modules/org-roam-config.el:42= — the "d" dailies head is ="#+FILETAGS: Journal #+TITLE: %<%Y-%m-%d>"= with no newline, so every C-c n d daily is malformed: no parsed #+TITLE, FILETAGS value "Journal #+TITLE: ...". The journal-copy template (lines 213-216) has it right. Add the newline; consider a sweep of existing dailies for the malformed first line. From the 2026-06 config audit.
-
** TODO [#B] agenda sources: roam Projects missing, no existence filtering :bug:solo:
From the 2026-06 config audit, =modules/org-agenda-config.el=:
- =:182-191= — commentary and docstrings promise org-roam nodes tagged "Project" as agenda sources, but =cj/--org-agenda-scan-files= never scans them, and files added by the roam finalize-hook are wiped on the next =cj/build-org-agenda-list= cache rebuild (≤1h). Add a roam Project pass (mirror =org-refile-config.el:101-109=) or correct the docs.
@@ -808,9 +811,6 @@ From the 2026-06 config audit, =modules/calendar-sync.el=:
- =:1284= — curl runs without =--fail=: an HTTP 404/500 error page exits 0 and the HTML proceeds into conversion.
- =:1229-1233= — =--parse-ics= returns nil for both garbage and a valid calendar with zero in-window events, so healthy near-empty calendars report "parse failed" in =calendar-sync-status=. Distinguish the cases.
-** TODO [#B] drill-refile clobbers global org-refile-targets with an invalid spec :bug:quick:solo:
-=modules/org-drill-config.el:95-98= — =setq org-refile-targets= replaces the session-wide value, so after one drill refile every org-refile everywhere offers only drill targets until restart; and the =(drill-dir :maxlevel . 1)= spec names a directory-path variable where org expects files, so the drill side yields nothing usable. Let-bind around the call with =((directory-files drill-dir t "\\.org$") :maxlevel . 1)=. From the 2026-06 config audit.
-
** TODO [#B] ERC: double mention notifications + tautological server list :bug:quick:solo:
From the 2026-06 config audit, =modules/erc-config.el=:
- =:281= — =erc-modules= includes the built-in =notifications= module AND :config adds =cj/erc-notify-on-mention= to the same hook — every mention fires two desktop notifications. Pick one path (keep the custom one, slated for messenger unification).
@@ -846,9 +846,7 @@ From the 2026-06 config audit, =modules/dwim-shell-config.el=:
** TODO [#B] prog hooks mutate global state per buffer :bug:quick:solo:
From the 2026-06 config audit: =prog-go.el:64=, =prog-c.el:73=, =prog-shell.el:77= call global =(electric-pair-mode t)= from buffer setup hooks — one Go/C/shell buffer turns on pairing in org/text everywhere (python/webdev correctly use =electric-pair-local-mode=). =prog-general.el:79-80= — =display-line-numbers-type 'relative= setq/setq-default run from the hook AFTER the mode is enabled, so the first prog buffer of a session gets absolute numbers. Local-mode for the three; move the line-number setqs to top level.
-
-** TODO [#B] M-S- launcher keys dead: eww, elfeed, calibredb unreachable :bug:quick:solo:
-=eww-config.el:70= (M-S-e), =elfeed-config.el:36= (M-S-r), =calibredb-epub-config.el:115= (M-S-b) — Meta+Shift+letter generates the uppercase event (M-E/M-R/M-B), which never matches an explicit S- spec on a lowercase letter; verified dead in the live daemon (chord falls through to M-r move-to-window-line etc.). Same class as the text-config M-S-i finding. Write them as "M-E"/"M-R"/"M-B". Weather's M-S-w works only via the keyboard-compat translation layer — audit that layer's coverage while here. From the 2026-06 config audit.
+The global electric-pair this turns on also paired "<" in org, stranding a ">" after "<"-key snippets (=#+end_src>=, broke cj-scan). That symptom is fixed separately (=d9c90e83=, an =electric-pair-inhibit-predicate= for "<"). This task remains the root fix: pairing should not be global at all.
** TODO [#B] ai-rewrite: chosen directive never reaches the request :bug:solo:
=modules/ai-rewrite.el:64= — the directive is let-bound around =(call-interactively #'gptel-rewrite)=, but gptel-rewrite is a transient prefix that returns when the menu shows; the send resolves the directive AFTER the binding unwound (verified against ~/code/gptel/gptel-rewrite.el:780-799). The picker's choice is silently dropped — the module's core feature is inert. Set =gptel--rewrite-directive= buffer-locally (restore via =gptel-post-rewrite-functions=) or use a self-removing global hook entry. From the 2026-06 config audit.
@@ -1115,55 +1113,6 @@ Full findings delivered as handoffs to each repo's inbox/ (2026-06-12-0057-from-
- chime (10 findings; suite green; the 2026-06-11 watchdog handoff VERIFIED landed in full): lookahead vars never injected into the async child (documented feature silently capped at 8 days — one-line fix); days-until-event nil crash on mixed timed/all-day events; stale-callback race after watchdog interrupt (generation counter needed); default test run prints green integration banner over "Ran 0 tests".
- emacs-wttrin (10 findings; ~56 ERT files, CI; the face-flood reminder VERIFIED resolved — test 8f3c770 + fix c5e5e1d, reminder cleared from notes.org): no network timeouts (wttr.in stalls hang the loading buffer); error-path response-buffer leak; non-favorite cache never expires; 17 unreleased commits incl. two features — tag v0.4.0.
-** PROJECT [#B] Implement ai-kb :feature:
-Build v1 of the AI knowledge base per [[file:docs/design/ai-kb.org][docs/design/ai-kb.org]] (Ready; six reviews incorporated, all decisions resolved 2026-05-24). Step 1 splits into 1a (the safe write path — minimum usable) and 1b (retrieval, maintenance, push), since =remember= depends on =index=+=lint= and the adapter depends on =remember=. Step 2 is the Emacs layer: a full org-roam profile on switch, the human-edit safety model (same write path as the agent), and the browsing surface. Step 3 and the LLM-Wiki layer are vNext. Children are ordered by build sequence; the server bootstrap is the prerequisite.
-
-*** TODO [#B] ai-kb bare repo on cjennings.net
-Prerequisite, one-time server bootstrap (not doable by the local script): =sudo git init --bare /var/git/ai-kb.git= + chown on cjennings.net. Leave the github-mirror hook OFF — this repo is private. Required before every per-machine clone.
-
-*** TODO [#B] ai-kb store + contract + seed
-Step 1a. Clone =git@cjennings.net:ai-kb.git= to =~/.local/share/ai-kb=. Author =AGENT_CONTRACT.org= (canonical repo-resident contract: node format, write protocol, operations, routing) and seed =index.org= + a README/index node with a generated =:ID:=. Node format per spec — a *required* one-line =:SUMMARY:= (the index/query read it straight, no inference/LLM), provenance (=:CREATED_BY:/:CONFIDENCE:/:VISIBILITY:/:SOURCE:/:STATUS:=), =:PROJECTS:= slugs, type filetags, relation labels. Define the durable external-pointer format as *ID-first*: =ai-kb: <Title> (<UUID>)=, resolved by ID with title fallback (filenames can change in curation).
-
-*** TODO [#B] ai-kb CLI 1a: index, lint, remember, doctor
-Step 1a. Shell wrapper calling Emacs for org work — =emacsclient= when a daemon is up, =emacs --batch= fallback, lint+index in *one* invocation per =remember=. =index= regenerates =index.org= from node properties incl. =:SUMMARY:= (never hand-maintained); the index references nodes as plain =Title (UUID)= text, never =[[id:]]= links, and is excluded from the scan so it can't manufacture backlinks or hide orphans. =lint= = org-lint fatal checks + duplicate IDs + broken id-links (excl =raw/= + index) + missing required props (incl =:SUMMARY:=) + bad project slugs + stale/incomplete index + credential scan of nodes *and* =raw/= text files (binaries skipped). =remember= = the write protocol: fetch + =pull --ff-only= (abort on diverge/dirty), write, regenerate index, then run the *full =ai-kb lint=* over the change as the commit gate (not just node org-lint — this is the safety boundary), commit locally, =flock=; no push. =doctor= / =status= = health + push-state + raw-dir-size report (repo, private remote, CLI on PATH, =graphviz= if the map needs it, adapter linked, db buildable, no secrets, "ahead N"/"push failed"/"diverged"); =status= is the fast non-diagnostic mode for the dashboard/nudge.
-
-*** TODO [#B] claude-rules/ai-kb.md adapter
-Step 1a. Global L1 rule in rulesets pointing at the repo-resident =AGENT_CONTRACT.org=: path, routing (T1/T2/T3 tiers; per-project =MEMORY.md= shrinks to ID-first pointers into ai-kb), proactive + contradiction rules, concrete "read the index first" triggers, link-grep recipes, "use =ai-kb remember=, never bypass =ai-kb lint=", one-line nudge on unpushed commits / recorded push rejection. =make install= symlinks it into =~/.claude/rules/=.
-
-*** TODO [#B] ai-kb provisioning: setup-ai-kb.sh + make ai-kb-init
-Step 1a (core; the timer-install line is added with 1b). Idempotent =scripts/setup-ai-kb.sh=: clone (or init+add-remote on first machine), seed, install the CLI on PATH, =ai-kb index=, =ai-kb doctor=. =make ai-kb-init= wraps it. The one-time server bootstrap stays a separate documented step.
-
-*** TODO [#B] ai-kb Step-1a tests :test:
-Write-path: a write with the remote unreachable still commits locally and does not error; =flock= serializes concurrent =remember=; each org-lint *fatal* check (malformed drawer, missing/dup =:ID:=, invalid required property, missing =#+title:=, unparseable org) rejects the commit, a style warning does not; a node missing =:SUMMARY:= fails lint; =remember= aborts the commit when the *full* lint fails (stale index, broken link, secret in a node or =raw/= text file); the credential scan skips binaries. Index: regen from a fixture produces expected entries; an out-of-band node appears only after regen; a node referenced only by =index.org= still reports as an orphan (the index is not a backlink source). Link recipes: backlink (excl =raw/= + index) + forward correct. Provisioning (bats): idempotent, valid =:ID:= + =:SUMMARY:=, =doctor= passes.
-
-*** TODO [#B] ai-kb CLI 1b: query, curate, sync
-Step 1b. =query <context>= with a *testable contract*: plain-text default + =--json=; fields title/ID/summary/projects/status/updated/path + *match reason*; searches index rows + title/tags/properties/body; ranks by lexical score — sum of each matched field's weight, counted once per field: title 100, tag/project/status 50 each, summary 20, body 5; no term-frequency weighting in v1 — with most-recently-updated (=:UPDATED:=) only as the *tie-break* on equal scores (recency alone buries stable old preferences); default max-results; =raw/= paths only as source references; exit codes for no-match / invalid KB / lint-index failure. =show <id-or-title>= (resolve ID-first, print the node) and =backlinks <id>= (excl =raw/= + index) as the inspection primitives the Emacs commands wrap. =curate --dry-run= (four buckets; also flags orphan =raw/= captures and any =raw/= file over 256 KB; destructive ops human-only). =sync= (=org-roam-db-sync= against ai-kb) only when the db is missing/stale or forced.
-
-*** TODO [#B] ai-kb push timer + failure observability
-Step 1b. =ai-kb-push.timer= + =ai-kb-push.service= =systemd --user= units: push only if ahead, ~15 min; installed + =enable --now= by the setup script (add this line to =setup-ai-kb.sh=). A failed push is logged to a state file (=$XDG_STATE_HOME/ai-kb=), never fatal; surfaced by =ai-kb doctor= and the adapter's startup nudge.
-
-*** TODO [#B] ai-kb-curate workflow in rulesets
-Step 1b. =~/code/rulesets/.ai/workflows/ai-kb-curate.org= — human-gated curation: the four buckets, node-count trigger (nudge at 150 nodes, re-fire every +50), =:LAST_CURATED:= rotation, pointer-integrity (merge/supersede changes the canonical ID, so grep inbound =[[id:]]= + =MEMORY.md= =ai-kb: ... (UUID)= refs and repoint before deleting). Surfaced by =ai-kb doctor= + session startup when due.
-
-*** TODO [#B] ai-kb Step-1b tests :test:
-=query --json= returns the specified fields (incl. match reason)/exit-codes on a fixture KB and =raw/= appears only as a source ref; a title match outranks a body-only match with recency only breaking ties (an old preference is not buried under a newer body-only hit); a simulated push failure is recorded to the state file and surfaced by =ai-kb doctor= / =status=. Performance (=:perf= tag): 100- and 1,000-node fixtures keep =index=/=query=/=lint=/=remember= under a stated time budget (catches an accidental per-check Emacs startup or an O(n²) scan).
-
-*** TODO [#B] Emacs: org-roam ai-kb profile + switch
-Step 2.
-=org-roam-config.el=: =cj/org-roam-switch-to-ai-kb= / =cj/org-roam-switch-to-personal= install a full org-roam *profile*, not a two-variable swap — dir + =org-roam-ai.db= + =org-roam-file-exclude-regexp= (=raw/= + =index*.org=), and dailies, capture templates, topic/project/recipe find wrappers, and the agenda/refile + completed-task→daily hooks all rescoped or neutralized so ai-kb nodes never leak into personal journals/agenda. Restore everything exactly on exit; re-assert personal state at startup (abnormal-exit safety). =cj/ai-kb-db-sync= syncs only when the db is missing/stale or forced, with a status indicator.
-
-*** TODO [#B] Emacs: ai-kb edit safety (same write path)
-Step 2. An =ai-kb= minor mode whose =after-save-hook= runs the agent's post-write sequence under =flock= — =ai-kb index=, full =ai-kb lint=, commit, push-state update — so a human Emacs edit can't bypass index/lint/commit. One write path for both agent and human. Failure UX: the save always writes to disk and the buffer stays editable (never read-only/blocked); on lint failure it does *not* commit, pops findings to a =*ai-kb-lint*= buffer (no focus steal), and shows the uncommitted-failing state in the modeline + dashboard — Craig fixes and re-saves, a clean save commits. Recursion guard, two layers: the mode's activation predicate excludes =index*.org= + =raw/=, and the pipeline binds a re-entrancy flag (=cj/ai-kb--in-pipeline=) the hook early-returns on; index regen prefers =write-region= over =save-buffer=.
-
-*** TODO [#B] Emacs: ai-kb browsing surface
-Step 2. =cj/ai-kb-dashboard= (status banner: active KB, node count, unpushed commits, push-failure state, curation due, last index/sync), =cj/ai-kb-find-node= (=org-roam-node-find= in the ai-kb profile), =cj/ai-kb-search= (=ai-kb query= or scoped =consult-ripgrep=), =cj/ai-kb-show-node= (resolve ID-first, open), =cj/ai-kb-backlinks= (excl =raw/= + index), =cj/ai-kb-map= (built-in =org-roam-graph= *first* — the profile's exclude regexp already keeps =raw/= + index out of the db, so the graph inherits the right scope; custom DOT export only if project/tag/status filtering proves necessary; =graphviz= dep). Simple wrappers over the CLI primitives where possible.
-
-*** TODO [#B] Emacs: ai-kb keybindings + which-key
-Bind the switch + sync + browsing commands under the =C-c n= roam prefix (e.g. =C-c n a= → ai-kb, =C-c n A= → personal, a small transient for the browsing commands), avoiding the dense existing set; which-key labels.
-
-*** TODO [#B] Emacs: ai-kb Step-2 ERT tests :test:
-Profile: switch installs the ai-kb dir + db + exclude regexp and switch-back restores personal *exactly* — completed-task hook, agenda/refile finalize hook, dailies, and capture templates all untouched by ai-kb while switched; startup re-asserts personal state after a simulated abnormal exit. Edit path: a save in an ai-kb buffer runs index+lint+commit (a bad save surfaces the lint failure rather than committing). Sync runs only when stale.
-
** PROJECT [#B] Architecture review follow-up from 2026-05-03 :refactor:
High-level pass over =init.el=, =early-init.el=, and all 104 files in
@@ -1493,64 +1442,6 @@ Expected outcome:
- Add a note to the local repository docs so future package failures do not
lead to permanent insecure defaults.
-** DOING [#B] Signel Client Open Work
-:PROPERTIES:
-:LAST_REVIEWED: 2026-06-06
-:END:
-Parent task for the Emacs Signal client. Engine: signal-cli (linked secondary device). Front end: a fork of signel at =~/code/signel=, wired through =modules/signal-config.el=. Design: [[file:docs/design/signal-client.org][docs/design/signal-client.org]]. Child issues below.
-
-*** TODO [#C] signel--handle-error leaks request-buffer-map entries :bug:quick:solo:
-Surfaced during the JSON-RPC dispatch refactor audit. =signel--handle-error= reads =signel--request-buffer-map= by id but never =remhash='es the entry, so every error response leaves the request-id → buffer-name mapping behind for the life of the process. Low impact (the map clears on stop/start, and id collisions are unlikely at the counter scale), but unbounded growth in a long-lived session and inconsistent with how the new request-handler-map is cleaned up on error.
-
-*** TODO [#B] Link command with QR :feature:
-=cj/signel-link= wrapping =signal-cli link -n NAME=, capturing the =sgnl://linkdevice= URI and rendering it as a scannable QR (qrencode). Convenience for re-linking; the first link was done by hand this session.
-
-*** TODO [#D] Include Signal groups in the picker :feature:
-vNext after the 1:1 initiate-message flow is stable. Merge =listGroups= with =listContacts=, label groups distinctly, and preserve the current v1 behavior where the picker is contacts-only.
-
-*** DOING [#B] Notify only for the unviewed conversation :feature:
-Wire =cj/signal--should-notify-p= (done) into signel's =signel--handle-receive= notify block (signel.el:277), route through Craig's notify script instead of bare =notifications-notify=, and gate sound behind a defcustom that defaults off. Spec addendum (the four notify details + wiring architecture) accepted 2026-06-11 — see [[file:docs/design/signal-client.org][signal-client.org]] "Notification slice".
-
-Built 2026-06-11 (TDD; fork commit e263367, dotemacs 9afc6128): =signel-notify-function= customization point in the fork; =cj/signel--notify= + =cj/signal--format-notify-body= + =cj/signel-notify-sound= in signal-config.el, wired in =:config= with a load-time =cj/executable-find-or-warn=. 17 new ERT tests green; full launch smoke clean; live-reloaded into the daemon and a synthetic toast fired through the script path. Stays DOING until the two manual checks below pass.
-
-**** Signel: real incoming message raises a toast through the notify script
-What we're verifying: the full receive path (signal-cli → signel --handle-receive → cj/signel--notify → notify script) fires on a real message.
-- Make sure you are NOT viewing the sender's chat buffer.
-- Have a real message sent to you on Signal (or send one from your phone to a second device thread that lands here).
-Expected: a transient info toast titled "Signal: <sender>" with the message text (one line, truncated if long), no sound.
-
-**** Signel: actively-viewed chat stays quiet
-What we're verifying: the suppression predicate gates the toast when you're reading that chat.
-- Open the sender's chat buffer (=C-; M m=) and keep it the selected window in a focused frame.
-- Have the same sender message you again.
-Expected: the message renders in the buffer, but no desktop toast appears.
-
-*** 2026-05-26 Tue @ 20:06:58 -0500 Decided: fork signel rather than depend on it
-signel is on MELPA but stale (one-author v0.1, all commits in a Jan-2026 burst, unattended tracker, no PRs). The spec needs internal edits (notify behavior, input-clobber fix), which are clean in a fork and hacky via advice, and a dead upstream means no divergence cost. Rejected: adopt-from-MELPA + advice, build-from-scratch, signal-cli-rest-api (Docker), MCP-tool, ERC bridge. Full rationale in the design doc.
-
-*** 2026-05-26 Tue @ 20:06:58 -0500 Linked as secondary device; contact parser verified against live shape
-Installed signal-cli 0.14.4.1 (AUR; imported AsamK's signing key FA10826A... to clear the makepkg verification). Linked the account via QR. Built and unit-tested the pure helper layer in =modules/signal-config.el= (contact-list parsing, notify-when-not-viewing predicate) with =tests/test-signal-config.el=. Confirmed the live =listContacts= shape: givenName/familyName are top-level in 0.14, not under profile as first assumed; corrected the parser and verified it produces a picker entry for all 94 real contacts. Sent a request to archsetup to add signal-cli to the standard install.
-
-*** 2026-05-27 Wed @ 22:08:40 -0500 Shipped initiate-message workflow: picker + Note-to-Self + keymap
-=cj/signel-message= (=C-; M m=) names contacts via =completing-read= over the cj-owned =cj/signel--contact-cache=, with "Note to Self" pinned first. =cj/signel-message-self= (=C-; M s=) sends straight to =signel-account=. Daemon guard =cj/signel--ensure-started= auto-starts the daemon when =signel-account= is set and =user-error='s with the remedy when it isn't; on start it pre-warms the cache. =cj/signel--fetch-contacts= rides the new RPC callback contract (=signel--send-rpc= with success-callback), the result feeds =cj/signal--parse-contacts=, and =cj/signel-refresh-contacts= (=C-; M no leaf=) clears + refetches. Cold-cache invocations =accept-process-output= up to =cj/signel-fetch-timeout= seconds (3s default) and =user-error= on timeout so a wedged daemon can't hang Emacs. Prefix keymap =cj/signel-prefix-map= bound under =C-; M= via =keybindings.el='s =cj/custom-keymap=: m / s / d / q / SPC. 15 new ERT tests in =tests/test-signal-config.el= cover ensure-started branches, fetch contract, cache empty-vs-failure, refresh, picker happy-path + cold-cache resolves + cold-cache timeout, message-self, and the prefix map bindings.
-
-*** 2026-05-27 Wed @ 21:55:57 -0500 Added JSON-RPC success-result dispatch in the signel fork
-Fork commit 4740d97 added =signel--request-handler-map= (id → success callback), extended =signel--send-rpc= with an optional =success-callback= that registers under the new request id, and gave =signel--dispatch= a result branch that invokes the callback and removes the handler. Error responses also remhash the handler entry, and =signel-start= / =signel-stop= both =clrhash= the map so reconnect is reliably empty. Backward-compatible: existing callers that don't pass a callback hit the same code path as before. Five ERT tests in this project (=tests/test-signel-rpc-dispatch.el=, dotemacs commit bfec0eab) lock the contract: Normal (result invokes callback + cleanup, send-rpc registers), Boundary (unknown id is a no-op), Error (error response cleans up handler), reconnect (=signel-stop= empties the map). Refactor audit surfaced a separate pre-existing leak in =signel--handle-error= (request-buffer-map entries aren't removed on error); filed as the [#C] follow-up below.
-
-*** 2026-05-27 Wed @ 22:08:40 -0500 Shipped clobber fix for both insert paths
-Fork commit 5ec56c0 added =signel--pending-input= (capture from input-marker to point-max) and =signel--restore-input= (re-insert after the redrawn prompt; nil-safe), and wired both into =signel--insert-msg= (the receive path) and =signel--insert-system-msg= (the error path). A mid-type send now survives both an incoming message and a system-error insertion. Four ERT tests in =tests/test-signel-input-preservation.el= cover the helpers (typed text, empty) and both insert paths via a temp =signel-chat-mode= buffer.
-
-*** 2026-05-27 Wed @ 22:08:40 -0500 use-package wired with C-; M keymap and local account config
-=use-package signel :load-path "~/code/signel" :ensure nil= already wired earlier with =signel-auto-open-buffer nil=. Account source is =signel-account= set from =cj/signal-private-config-file= (=signal-config.local.el=, gitignored) loaded in =:config=, decided in the workflow spec. Keymap prefix =C-; M= attached via =with-eval-after-load 'keybindings= so the binding survives load-order.
-
-*** 2026-06-06 Sat @ 12:29:24 -0500 Fixed C-; M load-order bug via canonical register-prefix-map
-Root cause: signal-config.el was the only feature module that violated the prefix-registration contract documented in =keybindings.el:41-45=. Every other prefix map uses =(require 'keybindings)= + a top-level =(cj/register-prefix-map "X" map)=; signal-config had neither, mutating =cj/custom-keymap= directly through a =(with-eval-after-load 'keybindings (when (boundp 'cj/custom-keymap) ...))= form. The =boundp= guard turned a load-order miss into a SILENT no-op — no error, the binding just never happened — which is why a live-reload (keybindings definitely loaded by then) papered over it.
-Fix: added =(require 'keybindings)= at the top of signal-config.el and replaced the guarded form with =(cj/register-prefix-map "M" cj/signel-prefix-map "signal messages")=, matching the 25+ other prefix maps.
-Verified: (1) new contract test =test-signal-config-prefix-map-registered-under-c-semi-m= asserts =C-; M= resolves to =cj/signel-prefix-map= (35/35 green); (2) full =emacs --batch= init.el launch — the exact failing scenario — now shows =C-; M= bound; (3) clean byte-compile; (4) live-reloaded into the daemon, binding confirmed. No unit-level red was possible: the =boundp= guard is robust under all standard test timings, which is the CLAUDE.md launch-only-failure class.
-
-*** 2026-05-28 Thu @ 03:09:18 -0500 Chat buffer docks bottom 30% and C-c C-k cancels
-=display-buffer-alist= entry in =modules/signal-config.el= matches =^\*Signel: = chat buffers and routes them through =display-buffer-at-bottom= with =window-height . 0.3=, so the chat docks to the bottom 30% of the frame. The signel fork's =signel-chat= switched from =switch-to-buffer= to =pop-to-buffer= so the rule can apply (=switch-to-buffer= ignores =display-buffer-alist=). =C-c C-c= was already bound to =signel--send-input= in the mode; =C-c C-k= now binds =signel--cancel-input=, a new fork helper that clears the editable region between =signel--input-marker= and =point-max= and then calls =quit-window=. Buffer stays alive so chat history above the marker survives revisits; cleared input means the next visit lands on a fresh prompt. Five ERT tests in =tests/test-signel-cancel-input.el= (clears pending, empty-area no-op, quit-window called, buffer preserved, keymap binding) and two new tests in =tests/test-signal-config.el= (entry shape + regex match set). Dotemacs commit 998e9c7a, fork commit df02d79.
-
** DOING [#B] Migrate All Terminals From Vterm to Ghostel
:PROPERTIES:
:LAST_REVIEWED: 2026-06-04
@@ -4381,6 +4272,15 @@ From the 2026-06-11 messenger-unification brainstorm. Google Voice has no offici
** TODO Manual testing and validation
Exercised once the phases above land.
+*** TODO org-capture quick-capture popup behaves correctly
+What we're verifying: the Hyprland Super+Shift+N popup is single-window, offers only the sensible templates, files to the inbox, never orphans its frame, and runs the capture in the popup even when launched from a focused main frame (archsetup request 2026-06-12; fixes in modules/org-capture-config.el incl. the frame-targeting focus-race fix, all pushed and live). archsetup verified the base case on ratio 2026-06-12; the focus-race fix landed after.
+- Press Super+Shift+N to open the quick-capture popup
+- The *Org Select* menu should fill the popup frame as one window (no top sliver of your last-visited buffer, one modeline) and list only Task / Bug / Event — and NOT split your main frame (the focus-race fix)
+- It should show no "C — Customize org-capture-templates" row
+- Pick Task (t): the CAPTURE buffer also fills the frame as one window; finishing with C-c C-c files it to the global inbox under "Inbox" (not a project's todo.org)
+- Re-open and pick Event (e): it prompts for a date and files to the schedule
+- Re-open and hit q (or C-g) at the menu: the popup frame closes (no orphan)
+Expected: single window at every step; menu limited to Task/Bug/Event; Task/Bug land in the inbox; aborting at the menu closes the frame; the frame still closes on normal finalize and C-c C-k.
*** 2026-06-11 Thu @ 18:29:39 -0500 Verified UI-face preview and contrast survive a ground bg change
Craig walked the repro: mode-line with its own fg/bg kept its preview bg and ratio through a ground change; ground-dependent rows re-rated; package-faces contrast column updated. Pass. Closed the [#A] contrast-cell and [#B] preview-bg parents.
*** 2026-06-11 Thu @ 18:29:39 -0500 Verified seeded package-face defaults, with steel tuning
@@ -4433,6 +4333,38 @@ What we're verifying: lowering a family's count leaves a referencing face visibl
- Lower that family's count to 2 so blue+3 disappears
- Read the assignment's dropdown
Expected: the dropdown shows "(gone)" for the removed step, never a silent jump to a different color; re-pointing it is a deliberate choice.
+*** TODO Calibre bookmark default name is "Author, Title"
+What we're verifying: a new nov bookmark takes the "Author, Title" form parsed from the filename, not the raw EPUB filename.
+- Open an EPUB in Calibre (nov buffer).
+- Hit m to set a bookmark.
+Expected: the default bookmark name is "Author, Title" (underscores stripped, colon restored), e.g. "Agatha Christie, The A.B.C. Murders".
+
+*** TODO Calibre curated ? menu and docked description
+What we're verifying: the curated ? transient, the docked description, and the full dispatch all work in a live calibredb buffer.
+- In a calibredb search buffer, press ? and confirm the curated menu (library / filter / sort / open / describe) appears.
+- Press d or v to dock the selected book's description in a bottom-30% buffer; press q to dismiss it.
+- Press H and confirm calibredb's full dispatch opens.
+Expected: ? shows the curated menu, d/v dock the description (q dismisses), H opens the full calibredb dispatch.
+
+*** TODO Signel: real incoming message raises a toast through the notify script
+What we're verifying: the full receive path (signal-cli → signel --handle-receive → cj/signel--notify → notify script) fires on a real message.
+- Make sure you are NOT viewing the sender's chat buffer.
+- Have a real message sent to you on Signal (or send one from your phone to a second device thread that lands here).
+Expected: a transient info toast titled "Signal: <sender>" with the message text (one line, truncated if long), no sound.
+
+*** TODO Signel: actively-viewed chat stays quiet
+What we're verifying: the suppression predicate gates the toast when you're reading that chat.
+- Open the sender's chat buffer (=C-; M m=) and keep it the selected window in a focused frame.
+- Have the same sender message you again.
+Expected: the message renders in the buffer, but no desktop toast appears.
+
+*** TODO Project-aware capture files into the right todo.org
+What we're verifying: C-c c t and C-c c b file into the current projectile project's todo.org under its "<Project> Open Work" header, and fall back to the global inbox outside a project.
+- Inside a projectile project that has a todo.org, run C-c c t (Task), capture a test entry, and confirm it lands under "<Project> Open Work".
+- Run C-c c b (Bug) similarly and confirm it lands as "* TODO [#C] ..." under the same header.
+- Run a capture from outside any project (or a project with no todo.org) and confirm the global-inbox fallback with a warning.
+Expected: in-project captures land in that project's Open Work; out-of-project captures fall back to the global inbox with a warning.
+
** TODO [#D] theme-studio per-tier reseed controls :feature:
Deferred from the seeding-engine spec (vNext). V1 reseeds all three guide-owned tiers at once; later consider separate "reseed syntax", "reseed UI", and "reseed package/org" controls if all-at-once proves too blunt. Spec: [[file:docs/design/theme-studio-seeding-engine-spec.org][spec]] (vNext; review folded in 2026-06-08).
** TODO [#D] theme-studio low-contrast preset/mask mode :feature:
@@ -4495,15 +4427,16 @@ Task: survey the modes/modules Craig works in and identify where a =?= -> curate
** TODO [#C] the preview splits an already split window into 3 temporarily.
looks strange. potentially problematic for ai-terms.
-** DOING [#C] Project-aware bug capture via C-c c t :feature:
-Relocated from the global capture inbox 2026-06-06. When inside a projectile project, C-c c t (Task) files into that project's root todo.org under the "<Project> Open Work" header. If the project has no todo.org, fall back to the global inbox-file and warn naming the project.
-
-Implemented 2026-06-06 in =modules/org-capture-config.el=: a shared project-aware =function= capture target (=cj/--org-capture-project-location=) used by =C-c c t= (Task, =* TODO=) and a new =C-c c b= (Bug, =* TODO [#C]=). Matches an existing top-level "... Open Work" heading (so ~/.emacs.d hits "Emacs Open Work") and creates "<Capitalized project> Open Work" only when absent. Outside a project / no todo.org -> global inbox under "Inbox" (with a warning in the no-todo.org case). 15 ERT tests in =tests/test-org-capture-config-project-target.el=; daemon e2e confirmed a real capture lands "** TODO [#C] ..." prepended under Open Work. Awaiting Craig's interactive manual verify (see the Manual Testing task) before close. NOTE: the matching "<Project> Resolved Work" header for the wrap-up workflow is a separate concern, not handled here.
-
** VERIFY [#C] Palette-columns spec review
SCHEDULED: <2026-06-12 Fri>
Read [[file:docs/theme-studio-palette-columns-spec.org][docs/theme-studio-palette-columns-spec.org]] (Draft, from the 2026-06-10 design discussion) and bless or amend. Decisions 9 and 10 are the two session calls awaiting your word: strips flip to lightest→darkest top→bottom to match the dropdown, and each dropdown column run places the base at its natural lightness position (vs bg/fg bases leading before any steps). On "spec's good": mark Ready, file the phase breakdown, cancel the [#C] hint-override task, start Phase 1.
+** VERIFY [#C] page-signal pager account deregistered — re-registration needs your hands
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-12
+:END:
+Reported by .emacs.d 2026-06-12 01:01: the dedicated pager number (+15045173983, the Claude Pager Google Voice number on signal-cli) returns "User ... is not registered" on every send — Signal appears to have deregistered it (GV numbers get periodically re-verified). Re-registration requires captcha/SMS, which only you can do. Until then every page-signal call fails; .emacs.d's config-audit page fell back to email. Wrapper lives at claude-templates/bin/page-signal.
+
* Emacs Resolved
** DONE [#B] Fix likely =elpa-mirror-location= path bug :bug:quick:
CLOSED: [2026-05-03 Sun]
@@ -7828,3 +7761,64 @@ CLOSED: [2026-06-11 Thu]
In the UI faces table, the preview cell for a face with its own bg renders with the ground bg instead. Repro: set mode-line fg=black, bg=blue — the preview cell should be black text on blue, but shows black on black (the live buffer mode-line is fine). Root cause: =applyGround= (app.js:300) blankets EVERY =.ex= element's background to =MAP['bg']=, and the preview cell =cP= shares =className='ex'= (app.js:753), so it clobbers the per-face bg =paintUI= sets (app.js:739) — runs on load and on every ground change. Fix: stop applyGround from touching the UI-face preview cells (scope its =.ex= selector to the code/example cells, give the preview cell its own class, or re-run paintUI after). The contrast cell shares the same staleness, so confirm both.
*** 2026-06-10 Wed @ 14:40:22 -0500 Fixed by the applyGround scoping under the contrast-cell task
Same root cause as the [#A] contrast-cell task, fixed there in one change: =applyGround= scopes its blanket to =#legbody .ex= + the code panes and repaints UI faces through =paintUI=. #contrasttest pins the preview-bg survival. Awaiting the same repro check.
+** DONE [#B] cj/undo-kill-buffer off-by-one on plain invocation :bug:quick:solo:
+CLOSED: [2026-06-12 Fri]
+Fixed in =modules/ui-navigation.el=: indexing is now =(nth (1- arg) ...)=, so a numeric prefix is 1-based and plain M-S-z re-opens the most-recently-killed file (was opening the second). Rewrote the two undo-kill tests to exercise the real no-prefix path (arg=1 -> first) and a 1-based numeric prefix; both red against the bug, green after. Full suite: no new failures (the 4 pre-existing dupre-theme failures are the separate task below). Live-reloaded into the daemon.
+** DONE [#B] dashboard-config setq wipes recentf-exclude list :bug:quick:solo:
+CLOSED: [2026-06-12 Fri]
+Fixed in =modules/dashboard-config.el=: extracted the EMMS exclusion into =cj/--dashboard-exclude-emms-from-recentf= (the =:config= side-effect was not reachable for a test) and switched =setq= to =add-to-list=, so the five exclusions system-defaults adds earlier in init order survive. Two ERT tests in =tests/test-dashboard-config-recentf-exclude.el= (preserves prior entries / adds the pattern); the preservation test was red before, green after. Live-reloaded into the daemon and restored the five wiped entries in the running session.
+** DONE [#B] org-roam dailies template writes FILETAGS and TITLE on one line :bug:quick:solo:
+CLOSED: [2026-06-12 Fri]
+Fixed in =modules/org-roam-config.el=: extracted the dailies head into the =cj/--org-roam-dailies-head= defconst (so it is unit-testable, the value was unreachable inside the use-package =:custom= form) and gave it real newlines — =#+FILETAGS: Journal\n#+TITLE: %<%Y-%m-%d>\n=. Two ERT tests in =tests/test-org-roam-config-dailies-head.el= assert FILETAGS and TITLE sit on separate lines and the head ends in a newline (both red before, green after). Live-reloaded into the daemon. Open follow-up for Craig: existing malformed daily files (with the run-together first line) are data, not code — sweep them by hand if desired.
+** DONE [#B] drill-refile clobbers global org-refile-targets with an invalid spec :bug:quick:solo:
+CLOSED: [2026-06-12 Fri]
+Fixed in =modules/org-drill-config.el=: =cj/drill-refile= now =let=-binds =org-refile-targets= (the session-wide value survives) and supplies =(directory-files drill-dir t "\\.org$")= as the file list instead of the bound =drill-dir= symbol (org reads a bound symbol as a directory string, which yielded nothing). Rewrote the stale test (it asserted the buggy =(assoc 'drill-dir ...)=) into two: targets are a real .org file list, and the global is not clobbered. Both red before, green after. Live-reloaded into the daemon.
+
+Follow-up 2026-06-12 (Codex review): the first fix reinvented file-listing with a raw =directory-files= call, bypassing the shared validated entry point =cj/--drill-files-or-error= — no missing/unreadable-dir =user-error=, silent fall-through on an empty dir, and it included leading-dot =.org= files the rest of the module excludes. Re-routed through =cj/--drill-files-or-error= + =expand-file-name=; the test was rewritten into three (validated-helper targets, no global clobber, =user-error= on a missing dir).
+** CANCELLED [#B] M-S- launcher keys dead: eww, elfeed, calibredb unreachable :bug:quick:solo:
+CLOSED: [2026-06-13 Sat]
+Not a bug. The audit used =key-binding=, which ignores =key-translation-map=, so it read the M-S- launcher chords as dead. They work in GUI: =keyboard-compat.el= installs a =key-translation-map= entry (=M-E -> M-S-e=, etc.) in GUI frames, so Meta+Shift+letter reaches eww/elfeed/calibredb. The "fix" =4a1ecf64= bound =M-E= directly and broke them instead; reverted here. The real console-reachability problem (the chords are dead outside GUI) is the subject of [[file:docs/design/keybinding-console-safety-spec.org][the keybinding-console-safety spec]].
+** DONE [#B] Signel Client Open Work
+CLOSED: [2026-06-12 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-12
+:END:
+Parent task for the Emacs Signal client bring-up. Engine: signal-cli (linked secondary device). Front end: a fork of signel at =~/code/signel=, wired through =modules/signal-config.el=. Design: [[file:docs/design/signal-client.org][docs/design/signal-client.org]].
+
+Closed 2026-06-12: the bring-up shipped (dated history below). The signel project now has its own =.ai/= scope, so all open signel/signal-cli issues moved to [[file:~/code/signel/todo.org][the signel todo]] and are tracked there flat (the three open children here — handle-error leak, link-with-QR, groups in picker — moved in that pass). Work on =modules/signal-config.el= stays in this file.
+
+*** 2026-06-12 Fri @ 07:34:05 -0500 Signel notify-only-for-unviewed-conversation shipped
+Wire =cj/signal--should-notify-p= (done) into signel's =signel--handle-receive= notify block (signel.el:277), route through Craig's notify script instead of bare =notifications-notify=, and gate sound behind a defcustom that defaults off. Spec addendum (the four notify details + wiring architecture) accepted 2026-06-11 — see [[file:docs/design/signal-client.org][signal-client.org]] "Notification slice".
+
+Built 2026-06-11 (TDD; fork commit e263367, dotemacs 9afc6128): =signel-notify-function= customization point in the fork; =cj/signel--notify= + =cj/signal--format-notify-body= + =cj/signel-notify-sound= in signal-config.el, wired in =:config= with a load-time =cj/executable-find-or-warn=. 17 new ERT tests green; full launch smoke clean; live-reloaded into the daemon and a synthetic toast fired through the script path. The two manual checks moved to the Manual testing and validation parent.
+
+*** 2026-05-26 Tue @ 20:06:58 -0500 Decided: fork signel rather than depend on it
+signel is on MELPA but stale (one-author v0.1, all commits in a Jan-2026 burst, unattended tracker, no PRs). The spec needs internal edits (notify behavior, input-clobber fix), which are clean in a fork and hacky via advice, and a dead upstream means no divergence cost. Rejected: adopt-from-MELPA + advice, build-from-scratch, signal-cli-rest-api (Docker), MCP-tool, ERC bridge. Full rationale in the design doc.
+
+*** 2026-05-26 Tue @ 20:06:58 -0500 Linked as secondary device; contact parser verified against live shape
+Installed signal-cli 0.14.4.1 (AUR; imported AsamK's signing key FA10826A... to clear the makepkg verification). Linked the account via QR. Built and unit-tested the pure helper layer in =modules/signal-config.el= (contact-list parsing, notify-when-not-viewing predicate) with =tests/test-signal-config.el=. Confirmed the live =listContacts= shape: givenName/familyName are top-level in 0.14, not under profile as first assumed; corrected the parser and verified it produces a picker entry for all 94 real contacts. Sent a request to archsetup to add signal-cli to the standard install.
+
+*** 2026-05-27 Wed @ 22:08:40 -0500 Shipped initiate-message workflow: picker + Note-to-Self + keymap
+=cj/signel-message= (=C-; M m=) names contacts via =completing-read= over the cj-owned =cj/signel--contact-cache=, with "Note to Self" pinned first. =cj/signel-message-self= (=C-; M s=) sends straight to =signel-account=. Daemon guard =cj/signel--ensure-started= auto-starts the daemon when =signel-account= is set and =user-error='s with the remedy when it isn't; on start it pre-warms the cache. =cj/signel--fetch-contacts= rides the new RPC callback contract (=signel--send-rpc= with success-callback), the result feeds =cj/signal--parse-contacts=, and =cj/signel-refresh-contacts= (=C-; M no leaf=) clears + refetches. Cold-cache invocations =accept-process-output= up to =cj/signel-fetch-timeout= seconds (3s default) and =user-error= on timeout so a wedged daemon can't hang Emacs. Prefix keymap =cj/signel-prefix-map= bound under =C-; M= via =keybindings.el='s =cj/custom-keymap=: m / s / d / q / SPC. 15 new ERT tests in =tests/test-signal-config.el= cover ensure-started branches, fetch contract, cache empty-vs-failure, refresh, picker happy-path + cold-cache resolves + cold-cache timeout, message-self, and the prefix map bindings.
+
+*** 2026-05-27 Wed @ 21:55:57 -0500 Added JSON-RPC success-result dispatch in the signel fork
+Fork commit 4740d97 added =signel--request-handler-map= (id → success callback), extended =signel--send-rpc= with an optional =success-callback= that registers under the new request id, and gave =signel--dispatch= a result branch that invokes the callback and removes the handler. Error responses also remhash the handler entry, and =signel-start= / =signel-stop= both =clrhash= the map so reconnect is reliably empty. Backward-compatible: existing callers that don't pass a callback hit the same code path as before. Five ERT tests in this project (=tests/test-signel-rpc-dispatch.el=, dotemacs commit bfec0eab) lock the contract: Normal (result invokes callback + cleanup, send-rpc registers), Boundary (unknown id is a no-op), Error (error response cleans up handler), reconnect (=signel-stop= empties the map). Refactor audit surfaced a separate pre-existing leak in =signel--handle-error= (request-buffer-map entries aren't removed on error); filed as the [#C] follow-up below.
+
+*** 2026-05-27 Wed @ 22:08:40 -0500 Shipped clobber fix for both insert paths
+Fork commit 5ec56c0 added =signel--pending-input= (capture from input-marker to point-max) and =signel--restore-input= (re-insert after the redrawn prompt; nil-safe), and wired both into =signel--insert-msg= (the receive path) and =signel--insert-system-msg= (the error path). A mid-type send now survives both an incoming message and a system-error insertion. Four ERT tests in =tests/test-signel-input-preservation.el= cover the helpers (typed text, empty) and both insert paths via a temp =signel-chat-mode= buffer.
+
+*** 2026-05-27 Wed @ 22:08:40 -0500 use-package wired with C-; M keymap and local account config
+=use-package signel :load-path "~/code/signel" :ensure nil= already wired earlier with =signel-auto-open-buffer nil=. Account source is =signel-account= set from =cj/signal-private-config-file= (=signal-config.local.el=, gitignored) loaded in =:config=, decided in the workflow spec. Keymap prefix =C-; M= attached via =with-eval-after-load 'keybindings= so the binding survives load-order.
+
+*** 2026-06-06 Sat @ 12:29:24 -0500 Fixed C-; M load-order bug via canonical register-prefix-map
+Root cause: signal-config.el was the only feature module that violated the prefix-registration contract documented in =keybindings.el:41-45=. Every other prefix map uses =(require 'keybindings)= + a top-level =(cj/register-prefix-map "X" map)=; signal-config had neither, mutating =cj/custom-keymap= directly through a =(with-eval-after-load 'keybindings (when (boundp 'cj/custom-keymap) ...))= form. The =boundp= guard turned a load-order miss into a SILENT no-op — no error, the binding just never happened — which is why a live-reload (keybindings definitely loaded by then) papered over it.
+Fix: added =(require 'keybindings)= at the top of signal-config.el and replaced the guarded form with =(cj/register-prefix-map "M" cj/signel-prefix-map "signal messages")=, matching the 25+ other prefix maps.
+Verified: (1) new contract test =test-signal-config-prefix-map-registered-under-c-semi-m= asserts =C-; M= resolves to =cj/signel-prefix-map= (35/35 green); (2) full =emacs --batch= init.el launch — the exact failing scenario — now shows =C-; M= bound; (3) clean byte-compile; (4) live-reloaded into the daemon, binding confirmed. No unit-level red was possible: the =boundp= guard is robust under all standard test timings, which is the CLAUDE.md launch-only-failure class.
+
+*** 2026-05-28 Thu @ 03:09:18 -0500 Chat buffer docks bottom 30% and C-c C-k cancels
+=display-buffer-alist= entry in =modules/signal-config.el= matches =^\*Signel: = chat buffers and routes them through =display-buffer-at-bottom= with =window-height . 0.3=, so the chat docks to the bottom 30% of the frame. The signel fork's =signel-chat= switched from =switch-to-buffer= to =pop-to-buffer= so the rule can apply (=switch-to-buffer= ignores =display-buffer-alist=). =C-c C-c= was already bound to =signel--send-input= in the mode; =C-c C-k= now binds =signel--cancel-input=, a new fork helper that clears the editable region between =signel--input-marker= and =point-max= and then calls =quit-window=. Buffer stays alive so chat history above the marker survives revisits; cleared input means the next visit lands on a fresh prompt. Five ERT tests in =tests/test-signel-cancel-input.el= (clears pending, empty-area no-op, quit-window called, buffer preserved, keymap binding) and two new tests in =tests/test-signal-config.el= (entry shape + regex match set). Dotemacs commit 998e9c7a, fork commit df02d79.
+** DONE [#C] Project-aware bug capture via C-c c t :feature:
+CLOSED: [2026-06-12 Fri]
+Relocated from the global capture inbox 2026-06-06. When inside a projectile project, C-c c t (Task) files into that project's root todo.org under the "<Project> Open Work" header. If the project has no todo.org, fall back to the global inbox-file and warn naming the project.
+
+Implemented 2026-06-06 in =modules/org-capture-config.el=: a shared project-aware =function= capture target (=cj/--org-capture-project-location=) used by =C-c c t= (Task, =* TODO=) and a new =C-c c b= (Bug, =* TODO [#C]=). Matches an existing top-level "... Open Work" heading (so ~/.emacs.d hits "Emacs Open Work") and creates "<Capitalized project> Open Work" only when absent. Outside a project / no todo.org -> global inbox under "Inbox" (with a warning in the no-todo.org case). 15 ERT tests in =tests/test-org-capture-config-project-target.el=; daemon e2e confirmed a real capture lands "** TODO [#C] ..." prepended under Open Work. Manual verify filed under the Manual testing and validation parent. NOTE: the matching "<Project> Resolved Work" header for the wrap-up workflow is a separate concern, not handled here.