<feed xmlns='http://www.w3.org/2005/Atom'>
<title>dotemacs, branch main</title>
<subtitle>My Emacs configuration
</subtitle>
<id>https://git.cjennings.net/dotemacs/atom?h=main</id>
<link rel='self' href='https://git.cjennings.net/dotemacs/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/'/>
<updated>2026-08-06T16:50:47+00:00</updated>
<entry>
<title>docs: record how the ambience audio was built</title>
<updated>2026-08-06T16:50:47+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-06T16:50:47+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=36c737f45efc4f9459a7923d62ccf2cf5bf2686a'/>
<id>urn:sha1:36c737f45efc4f9459a7923d62ccf2cf5bf2686a</id>
<content type='text'>
- Keeps the stream-copy loop technique and its measurements.
- Notes the amix normalize=0 trap that silently discards the mix ratio.
- Records the loop-period arithmetic behind the crossfade.
- Explains why the openly-licensed set sounds better but its rain is wrong.

The audio itself stays untracked.

Claude-Session: https://claude.ai/code/session_01WpQhUr9mNrXss3HiaN6G2s
</content>
</entry>
<entry>
<title>fix(music): size a playlist by its content, not by its symlink</title>
<updated>2026-08-03T20:27:17+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-03T20:27:17+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=6f90ec5b660c6a2552ac7284a3d9d963771fbbc2'/>
<id>urn:sha1:6f90ec5b660c6a2552ac7284a3d9d963771fbbc2</id>
<content type='text'>
cj/music--append-track-to-m3u-file decided whether to prepend a newline by
seeking to a byte offset from file-attributes, which doesn't follow
symlinks. On a stow-deployed playlist that measures the link string instead
of the file.

Two failure modes, and I only went looking for the second. All 100 symlinked
playlists read the wrong byte, so a file already ending in a newline looked
unterminated and gained a blank line on every append. On the 31 whose link
string is longer than their content the range fell outside the file
entirely. Nothing was inserted, and char-after handed nil to a numeric
comparison, so the append died with a wrong-type error.

The probe now reads the file and checks its last character. That takes
file-attributes out of the path, so this class can't come back. The largest
real playlist is 11 KB and the read costs 0.11 ms, so being clever about
offsets bought nothing.

A test per mode, because one fixture can't show both. Seed the blank-line
case with content shorter than the link string and it lands on the error
path instead, going red for the wrong reason and proving nothing.

The cheap patch was guarding char-after against nil. That would have
silenced the crash on 31 playlists and left the blank line live on all 100.
</content>
</entry>
<entry>
<title>feat(music): write an m3u path absolute when it leaves the playlist's directory</title>
<updated>2026-08-03T19:38:20+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-03T19:38:20+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=a457ade9207be48f6bfaa519046d7acea390ff1b'/>
<id>urn:sha1:a457ade9207be48f6bfaa519046d7acea390ff1b</id>
<content type='text'>
A track under the playlist's own directory still writes relative. Anything
outside it now writes the absolute path instead of a ../ chain. Playlists in
the mpd directory point into ~/music, so the relative form there was four
levels of .. and broke the moment anything moved.

I took the trade knowingly. ambience.m3u is the only playlist anywhere using
../ lines, and it reaches its audio through a sibling directory. So a newly
appended track there writes absolute while its hand-written lines stay
relative, and its header comment needs a matching edit. The alternative was
an exception for tracks inside the deployed tree, which would be a rule
serving exactly one file.

The comment I wrote for the earlier half of this argued one direction only.
Absolute survives the playlist moving, which I said. Relative survives the
library moving, which I didn't, and for a track in the same tree the ../ form
survives strictly more moves rather than fewer. Rewritten to name the trade
instead of implying there isn't one.

The boundary case is a directory whose name begins with two dots. The check
looks for a leading "../", so a real subdirectory called ..hidden sits under
the playlist and must stay relative. It has a test now, because loosening
that check to ".." would break that case and nothing else.
</content>
</entry>
<entry>
<title>fix(music): base an appended m3u path on the playlist, not the music root</title>
<updated>2026-08-03T17:57:36+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-03T17:57:36+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=90aa02c5cd7dc78729d4362d4962005627069e60'/>
<id>urn:sha1:90aa02c5cd7dc78729d4362d4962005627069e60</id>
<content type='text'>
cj/music--append-track-to-m3u-file wrote every relative path against
cj/music-root wherever the playlist actually sat. Both readers resolve
against the playlist's own directory: cj/music--m3u-file-tracks, and EMMS's
emms-source-playlist-parse-m3u. So the writer and the readers disagreed
whenever those two directories were not the same one.

That is not only the playlist-outside-the-music-root case I went looking
for. 39 of the 66 m3u files under ~/music are album playlists sitting beside
their tracks, storing bare filenames. Appending to any of them wrote a line
like Artist/Album/track.flac that resolved to a path which never existed.

The fix is the base: (file-name-directory m3u-file).

Worth recording how the tests went, because the first three I wrote all
passed against the broken writer. I had put cj/music-root and the playlist
directory at the same depth, so file-relative-name returned the same string
for both bases. The fixture reached the bug's code path and still could not
see it. The discriminating cases put the two bases at different depths, and
reverting the one-line fix now fails them.

Eight existing tests moved with it. They built track paths from the real
cj/music-root while the playlist sat in a temp directory, which pinned the
old base and also read my actual music root. Rebinding the root to the
fixture directory restores every assertion verbatim and makes them hermetic.

One thing this does not change: MPD resolves relative playlist entries
against music_directory and rejects entries that escape it, so a ../ line is
for EMMS rather than MPD. That costs nothing today, since EMMS drives mpv
here and MPD is not running.
</content>
</entry>
<entry>
<title>feat(recording): put the video and audio toggles on F9 and Shift-F9</title>
<updated>2026-08-03T14:59:58+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-03T14:59:58+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=fa1e63e4b3e4dbe3841247c8efd2096d2d75390c'/>
<id>urn:sha1:fa1e63e4b3e4dbe3841247c8efd2096d2d75390c</id>
<content type='text'>
The two toggles were only on C-; r v and C-; r a. F9 has been free since
ai-term moved its family to M-SPC, so it goes to what I reach for mid-work.
Both keep their prefix argument, so C-u F9 still prompts for a location.

Reaching them from inside a terminal buffer turns on which key categories
each EAT input mode claims. My first read of that was backwards.

Semi-char mode is the default and where agent buffers sit. It's built from
:ascii, :arrow and :navigation, so it never claims function keys and F9
already reached Emacs there. That entry is redundant.

Char mode is the one that mattered. It adds :function, binding f1 through
f63 to eat-self-input. It's also a minor mode, so its map outranks
eat-mode-map.

Worse, :function claims only the unmodified keys. Leaving char mode alone
would have split the pair rather than broken it: Shift-F9 toggling audio
while F9 went to the program under the cursor. That is a recording I believe
I started and didn't. Both chords are claimed in eat-char-mode-map and
eat-eshell-char-mode-map, which costs a char-mode program the use of F9. I'd
rather pay that than ship a toggle that half works.

The tests resolve the char-mode cases through key-binding in a fixture that
reproduces minor-mode precedence, not by reading the binding back out of the
map the module just wrote. A positive control asserts F8 still reaches
eat-self-input, since F8 sits in the same category and nothing here touches
it. Without that control the fixture could be inert and every assertion
would still pass, because F9 resolves through the global map either way.
</content>
</entry>
<entry>
<title>fix(calendar-sync): run the sync from a timer instead of the agenda hook</title>
<updated>2026-08-03T14:39:08+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-03T14:39:08+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=eb0c2191b77e795cca7884e0690ea51c515527fd'/>
<id>urn:sha1:eb0c2191b77e795cca7884e0690ea51c515527fd</id>
<content type='text'>
The hourly timer armed itself from org-agenda-mode-hook, so a session where
I never opened the agenda never synced at all. After a reboot that is every
session until the first agenda call. I found it with all three calendar
files still frozen at the pre-reboot write, five hours stale.

The deferral had a real reason. Feed URLs behind :secret-host live in
authinfo.gpg, and starting at load would prompt for a passphrase on a cold
gpg-agent. The cost was worse than what it bought.

A user timer owns the schedule now, every ten minutes.
calendar-sync-auto-start defaults to nil so the editor no longer arms its
own. scripts/calendar-sync-run is the batch entry point, and it blocks
until every calendar leaves the syncing state. The pipeline is asynchronous
end to end, so a batch Emacs that returns early exits zero having written
nothing.

Installing it on a machine that resolves its feeds through :secret-host
surfaced two bugs the inline-url machine could never show:

- cj/auth-source-secret-value called auth-source-search behind nothing but
  a declare-function, which quiets the compiler and loads nothing. An
  interactive Emacs always has auth-source in by the time anyone calls, so
  the omission stayed invisible until a batch -Q sync died on a void
  function. The require is guarded on fboundp. An unconditional one reloads
  auth-source over whatever is already there, which replaced a caller's
  stubbed search mid-call and sent an existing test out to the real
  authinfo.

- A synchronous failure in one calendar aborted the whole loop. The async
  callbacks record their own failures, but they never run when the error
  lands before a process starts. Resolving a :secret-host feed signals
  outright on a cold agent. Failures are contained per calendar now, so one
  bad feed no longer costs the other two.

A failed row prints the reason it recorded. Batch Emacs discards the
*Messages* buffer the interactive path logs to, so without it the journal
shows only "error", with no way to tell a cold agent from a revoked token.
</content>
</entry>
<entry>
<title>feat(dirvish): add a quick-access shortcut to the wallpaper videos</title>
<updated>2026-08-01T20:17:41+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-01T20:17:41+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=0eb65b6dcc2db54663b39dc7201c3dcf151add74'/>
<id>urn:sha1:0eb65b6dcc2db54663b39dc7201c3dcf151add74</id>
<content type='text'>
wp adds a slash to pix-dir, which already ends in one, so it resolves to pictures//wallpaper/. I built this entry off videos-dir without the extra slash and left wp alone.
</content>
</entry>
<entry>
<title>fix(prompts): make destructive confirms one keystroke</title>
<updated>2026-07-31T17:39:27+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-31T17:39:27+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=0b93863689038a385fd320440ac0a413ce81c4e1'/>
<id>urn:sha1:0b93863689038a385fd320440ac0a413ce81c4e1</id>
<content type='text'>
I had to kill an Emacs session today over a prompt I could not answer. A
second agent session held the selected window while "Overwrite hiroshi.m3u?
(yes or no)" waited in another frame, so my keystrokes went to a terminal
and the minibuffer stayed empty. Losing unsaved buffers is a far worse
outcome than a mis-keyed confirm.

So cj/confirm-strong becomes cj/confirm-destructive, and instead of binding
use-short-answers to nil for a typed "yes" it reads a single y or n.

Two things I kept:

- No default. Only y and n answer, so a stray RET or space re-prompts
  rather than confirming a shutdown.
- Pending input is discarded before the read. Without that the change would
  have been a bad trade: read-char-choice reads the input queue, so a key
  typed before the prompt painted would confirm instantly, where the old
  typed-"yes" absorbed it harmlessly.

Worth being honest about what this does not fix. One keystroke does not
make a prompt reachable when focus is elsewhere, and C-g is still the
escape. It lowers the cost of the situation rather than preventing it.

Six call sites: shutdown and reboot, shred, playlist overwrite and delete,
and the two file overwrites.
</content>
</entry>
<entry>
<title>feat(agenda): refresh the render cache on a timer, without the daemon</title>
<updated>2026-07-31T16:04:42+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-31T16:04:42+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=445a0be315f3d6fff585f1558262fb5dacd10d12'/>
<id>urn:sha1:445a0be315f3d6fff585f1558262fb5dacd10d12</id>
<content type='text'>
The surface reading this has to stay correct while Emacs is down, and
emacsclient is exactly the thing that cannot. So the writer is a batch
Emacs and a user timer runs it every five minutes.

- scripts/agenda-render-cache writes the cache from a batch Emacs. It
  resolves the agenda file list through the config's own resolver rather
  than restating it, so adding a calendar source stays a one-place change.
- The timer uses OnCalendar so Persistent actually applies. On a monotonic
  schedule it is silently ignored, and a machine that slept would come back
  to a stale file with nothing to trigger a catch-up.
- The window is now three days, yesterday through tomorrow. A consumer
  drawing a rolling window centred on now has nothing to draw for the part
  of its span outside today, which late in the evening is half the surface.

The keyword list moved to user-constants, where a batch Emacs can reach it
without this config's package dependencies. Without it org did not
recognise DOING or VERIFY, so it stopped parsing those headlines as tasks
at all: the keyword and priority cookie stayed glued to the front of every
title and each row reported no keyword and not-done. The file parsed
cleanly and was wrong, which is the failure worth guarding.

The bats tests run against a fixture and this checkout, not the machine's
real agenda and installed config. Asserting over live data let the same
suite pass on a day that happened to have no keyworded entries.
</content>
</entry>
<entry>
<title>feat(agenda): add the renderer output profile and its cache file</title>
<updated>2026-07-31T14:59:29+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-07-31T14:59:29+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/dotemacs/commit/?id=37f21839ca62617fef07684b2f98268de47a71c2'/>
<id>urn:sha1:37f21839ca62617fef07684b2f98268de47a71c2</id>
<content type='text'>
The surface that reads this went live, and it reads a different shape than
the canonical profile emits.

- cj/agenda-render-json adds s and e in epoch milliseconds, plus t for the
  title. The canonical fields ride along, so one file serves both readers.
- Every row gets an end it can be drawn with. An all-day entry spans its
  day. A point event has zero width. The canonical profile still reports
  null there, which is faithful to the source but not a width.
- cj/agenda-render-cache-update writes today to the XDG cache path.

Input stays epoch seconds on both profiles. Only the output converts, and
the guards that catch a milliseconds-for-seconds caller still apply.

Nothing schedules the cache write yet, so the file is only as fresh as its
last call. Picking a refresh mechanism is its own decision.
</content>
</entry>
</feed>
