aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitattributes2
-rw-r--r--.gitignore8
-rw-r--r--Makefile7
-rw-r--r--archive/README.org71
-rw-r--r--archive/custom/edit-indirect.el (renamed from custom/edit-indirect.el)0
-rw-r--r--archive/custom/eplot.el (renamed from custom/eplot.el)0
-rw-r--r--archive/custom/profile-dotemacs.el (renamed from custom/profile-dotemacs.el)0
-rw-r--r--archive/custom/titlecase-data.el (renamed from custom/titlecase-data.el)0
-rw-r--r--archive/custom/titlecase.el (renamed from custom/titlecase.el)0
-rw-r--r--archive/modules/duet-config.el (renamed from modules/duet-config.el)0
-rw-r--r--archive/modules/mu4e-org-contacts-setup.el (renamed from modules/mu4e-org-contacts-setup.el)0
-rw-r--r--archive/modules/show-kill-ring.el (renamed from modules/show-kill-ring.el)0
-rw-r--r--archive/modules/signal-config.el (renamed from modules/signal-config.el)37
-rw-r--r--archive/tests/test-show-kill-ring--insert-item.el (renamed from tests/test-show-kill-ring--insert-item.el)0
-rw-r--r--archive/tests/test-signal-config--contact-cache.el61
-rw-r--r--archive/tests/test-signal-config-notify.el (renamed from tests/test-signal-config-notify.el)0
-rw-r--r--archive/tests/test-signal-config.el (renamed from tests/test-signal-config.el)11
-rw-r--r--archive/tests/test-signel-cancel-input.el (renamed from tests/test-signel-cancel-input.el)0
-rw-r--r--archive/tests/test-signel-input-preservation.el (renamed from tests/test-signel-input-preservation.el)0
-rw-r--r--archive/tests/test-signel-notify-function.el (renamed from tests/test-signel-notify-function.el)0
-rw-r--r--archive/tests/test-signel-rpc-dispatch.el (renamed from tests/test-signel-rpc-dispatch.el)0
-rw-r--r--assets/vinyl-placeholder.svg20
-rw-r--r--docs/design/2026-07-10-curated-help-menu-survey.org96
-rw-r--r--docs/design/2026-07-10-ledger-config-audit.org146
-rw-r--r--docs/design/2026-07-10-org-module-ownership.org138
-rw-r--r--docs/design/signal-client-review.org2
-rw-r--r--docs/design/subr-mock-migration.org (renamed from docs/subr-mock-migration-spec.org)2
-rw-r--r--docs/design/utility-inventory.org6
-rw-r--r--docs/specs/2026-07-06-fancy-music-player-ui-spec.org203
-rw-r--r--docs/specs/2026-07-06-fancy-music-player-ui.prototype.html207
-rw-r--r--docs/specs/2026-07-06-radio-browser-lookup-spec.org202
-rw-r--r--docs/specs/2026-07-06-radio-browser-lookup.prototype.html190
-rw-r--r--docs/specs/2026-07-10-org-workflow-doctor-spec.org260
-rw-r--r--docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org404
-rw-r--r--docs/specs/ai-kb-spec.org14
-rw-r--r--docs/specs/ai-vterm-spec.org (renamed from docs/specs/ai-vterm-spec-superseded.org)12
-rw-r--r--docs/specs/cache-helper-design-spec.org (renamed from docs/specs/cache-helper-design-spec-implemented.org)14
-rw-r--r--docs/specs/company-to-corfu-migration-spec.org12
-rw-r--r--docs/specs/coverage-spec.org (renamed from docs/specs/coverage-spec-implemented.org)12
-rw-r--r--docs/specs/debug-profiling-spec.org12
-rw-r--r--docs/specs/dev-setup-project-spec.org12
-rw-r--r--docs/specs/dupre-clear-theme-spec.org12
-rw-r--r--docs/specs/face-font-diagnostic-popup-spec.org (renamed from docs/specs/face-font-diagnostic-popup-spec-implemented.org)13
-rw-r--r--docs/specs/flycheck-modeline-customization-spec.org (renamed from docs/specs/flycheck-modeline-customization-spec-implemented.org)12
-rw-r--r--docs/specs/gloss-spec-doing.org320
-rw-r--r--docs/specs/google-keep-emacs-integration-spec.org9
-rw-r--r--docs/specs/init-load-graph-spec.org (renamed from docs/specs/init-load-graph-spec-doing.org)54
-rw-r--r--docs/specs/keybinding-console-safety-spec.org (renamed from docs/specs/keybinding-console-safety-spec-doing.org)19
-rw-r--r--docs/specs/messenger-unification-spec.org14
-rw-r--r--docs/specs/music-config-without-emms-spec.org12
-rw-r--r--docs/specs/org-faces-spec.org (renamed from docs/specs/org-faces-spec-implemented.org)13
-rw-r--r--docs/specs/signal-client-spec.org (renamed from docs/specs/signal-client-spec-doing.org)13
-rw-r--r--docs/specs/theme-studio-completion-preview-spec.org11
-rw-r--r--docs/specs/theme-studio-nerd-icons-colors-spec.org9
-rw-r--r--docs/specs/theme-studio-package-faces-spec.org (renamed from docs/specs/theme-studio-package-faces-spec-doing.org)14
-rw-r--r--docs/specs/theme-studio-palette-generator-spec.org (renamed from docs/specs/theme-studio-palette-generator-spec-doing.org)15
-rw-r--r--docs/specs/theme-studio-perceptual-color-metrics-spec.org (renamed from docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org)14
-rw-r--r--docs/specs/theme-studio-preview-locate-spec.org13
-rw-r--r--docs/specs/theme-studio-seeding-engine-spec.org (renamed from docs/specs/theme-studio-seeding-engine-spec-doing.org)21
-rw-r--r--docs/specs/theme-studio-semantic-theme-architecture-spec.org19
-rw-r--r--docs/specs/theme-studio-structured-output-spec.org13
-rw-r--r--docs/specs/utility-consolidation-spec.org (renamed from docs/specs/utility-consolidation-spec-doing.org)20
-rw-r--r--docs/specs/vterm-to-ghostel-migration-spec.org (renamed from docs/specs/vterm-to-ghostel-migration-spec-implemented.org)14
-rwxr-xr-xgithooks/pre-commit41
-rw-r--r--init.el3
-rw-r--r--modules/ai-term-backend-eat.el22
-rw-r--r--modules/ai-term-display.el11
-rw-r--r--modules/ai-term-sessions.el50
-rw-r--r--modules/ai-term.el227
-rw-r--r--modules/auth-config.el1
-rw-r--r--modules/auto-dim-config.el84
-rw-r--r--modules/browser-config.el22
-rw-r--r--modules/calendar-sync-ics.el109
-rw-r--r--modules/calendar-sync-recurrence.el225
-rw-r--r--modules/calendar-sync-source.el69
-rw-r--r--modules/calendar-sync.el32
-rw-r--r--modules/calibredb-epub-config.el27
-rw-r--r--modules/config-utilities.el12
-rw-r--r--modules/coverage-core.el2
-rw-r--r--modules/custom-buffer-file.el53
-rw-r--r--modules/custom-case.el111
-rw-r--r--modules/custom-comments.el109
-rw-r--r--modules/custom-datetime.el2
-rw-r--r--modules/custom-line-paragraph.el61
-rw-r--r--modules/custom-ordering.el41
-rw-r--r--modules/custom-text-enclose.el64
-rw-r--r--modules/dashboard-config.el85
-rw-r--r--modules/dev-fkeys.el45
-rw-r--r--modules/diff-config.el1
-rw-r--r--modules/dirvish-config.el53
-rw-r--r--modules/dwim-shell-config.el5
-rw-r--r--modules/eat-config.el103
-rw-r--r--modules/elfeed-config.el24
-rw-r--r--modules/erc-config.el1
-rw-r--r--modules/external-open.el27
-rw-r--r--modules/flycheck-config.el16
-rw-r--r--modules/flyspell-and-abbrev.el13
-rw-r--r--modules/font-config.el295
-rw-r--r--modules/font-profiles.el116
-rw-r--r--modules/help-utils.el42
-rw-r--r--modules/host-environment.el19
-rw-r--r--modules/httpd-config.el10
-rw-r--r--modules/hugo-config.el21
-rw-r--r--modules/jumper.el7
-rw-r--r--modules/keybindings.el2
-rw-r--r--modules/keyboard-compat.el10
-rw-r--r--modules/keyboard-macros.el12
-rw-r--r--modules/local-repository.el62
-rw-r--r--modules/lorem-optimum.el18
-rw-r--r--modules/mail-config.el105
-rw-r--r--modules/markdown-config.el19
-rw-r--r--modules/media-utils.el157
-rw-r--r--modules/mu4e-attachments.el12
-rw-r--r--modules/mu4e-org-contacts-integration.el26
-rw-r--r--modules/music-config.el1474
-rw-r--r--modules/nov-reading.el42
-rw-r--r--modules/org-agenda-config.el27
-rw-r--r--modules/org-agenda-frame.el874
-rw-r--r--modules/org-babel-config.el3
-rw-r--r--modules/org-capture-config.el63
-rw-r--r--modules/org-config.el26
-rw-r--r--modules/org-contacts-config.el37
-rw-r--r--modules/org-drill-config.el55
-rw-r--r--modules/org-export-config.el16
-rw-r--r--modules/org-refile-config.el41
-rw-r--r--modules/org-reveal-config.el33
-rw-r--r--modules/org-roam-config.el52
-rw-r--r--modules/org-webclipper.el20
-rw-r--r--modules/pdf-config.el112
-rw-r--r--modules/prog-c.el13
-rw-r--r--modules/prog-general.el131
-rw-r--r--modules/prog-go.el23
-rw-r--r--modules/prog-json.el11
-rw-r--r--modules/prog-lisp.el6
-rw-r--r--modules/prog-lsp.el110
-rw-r--r--modules/prog-python.el19
-rw-r--r--modules/prog-shell.el14
-rw-r--r--modules/prog-training.el5
-rw-r--r--modules/prog-webdev.el33
-rw-r--r--modules/restclient-config.el13
-rw-r--r--modules/selection-framework.el6
-rw-r--r--modules/slack-config.el57
-rw-r--r--modules/system-commands.el17
-rw-r--r--modules/system-defaults.el29
-rw-r--r--modules/system-lib.el20
-rw-r--r--modules/system-utils.el2
-rw-r--r--modules/takuzu-config.el17
-rw-r--r--modules/telega-config.el9
-rw-r--r--modules/test-runner.el18
-rw-r--r--modules/text-config.el6
-rw-r--r--modules/tramp-config.el29
-rw-r--r--modules/ui-config.el9
-rw-r--r--modules/ui-theme.el10
-rw-r--r--modules/undead-buffers.el30
-rw-r--r--modules/vc-config.el99
-rw-r--r--modules/video-audio-recording-capture.el86
-rw-r--r--modules/video-audio-recording-devices.el48
-rw-r--r--modules/wrap-up.el6
-rw-r--r--org-roam-templates/recipe.org17
-rw-r--r--org-roam-templates/topic.org7
-rw-r--r--org-roam-templates/v2mom.org40
-rwxr-xr-xscripts/remote-repository-reset.sh19
-rw-r--r--scripts/theme-studio/WIP.json262
-rw-r--r--scripts/theme-studio/app.js3
-rw-r--r--scripts/theme-studio/browser-gates.js31
-rw-r--r--scripts/theme-studio/generate.py6
-rw-r--r--scripts/theme-studio/samples.py6
-rw-r--r--scripts/theme-studio/seed-core.js249
-rw-r--r--scripts/theme-studio/test-seed-core.mjs173
-rw-r--r--scripts/theme-studio/test_generate.py15
-rw-r--r--scripts/theme-studio/theme-studio.html281
-rw-r--r--tests/test-ai-term--attached-agent-dirs.el58
-rw-r--r--tests/test-ai-term--buffer-name.el21
-rw-r--r--tests/test-ai-term--close.el16
-rw-r--r--tests/test-ai-term--keybindings.el16
-rw-r--r--tests/test-ai-term--quit.el16
-rw-r--r--tests/test-ai-term--runtime.el136
-rw-r--r--tests/test-ai-term--session-threading.el40
-rw-r--r--tests/test-ai-term--show-or-create.el19
-rw-r--r--tests/test-auto-dim-config.el211
-rw-r--r--tests/test-browser-config--preferred-default.el89
-rw-r--r--tests/test-calendar-sync--apply-recurrence-exceptions.el31
-rw-r--r--tests/test-calendar-sync--expand-daily.el47
-rw-r--r--tests/test-calendar-sync--expand-monthly.el119
-rw-r--r--tests/test-calendar-sync--expand-weekly.el28
-rw-r--r--tests/test-calendar-sync--expand-yearly.el26
-rw-r--r--tests/test-calendar-sync--format-timestamp.el54
-rw-r--r--tests/test-calendar-sync--get-exdates.el29
-rw-r--r--tests/test-calendar-sync--nth-weekday-of-month.el67
-rw-r--r--tests/test-calendar-sync--parse-byday-entry.el41
-rw-r--r--tests/test-calendar-sync--parse-event.el32
-rw-r--r--tests/test-calendar-sync--parse-exception-event.el28
-rw-r--r--tests/test-calendar-sync--parse-rrule.el21
-rw-r--r--tests/test-calendar-sync--syncing-p.el116
-rw-r--r--tests/test-calendar-sync-properties.el40
-rw-r--r--tests/test-calendar-sync-source-fetch-sentinel.el72
-rw-r--r--tests/test-calendar-sync.el45
-rw-r--r--tests/test-calibredb-epub-config--epub-mode.el70
-rw-r--r--tests/test-calibredb-epub-config.el51
-rw-r--r--tests/test-config-utilities--recompile-emacs-home.el23
-rw-r--r--tests/test-custom-buffer-file--view-email-in-buffer.el20
-rw-r--r--tests/test-custom-buffer-file-copy-link-to-buffer-file.el11
-rw-r--r--tests/test-custom-case-title-case-region.el22
-rw-r--r--tests/test-custom-comments-comment-inline-border.el10
-rw-r--r--tests/test-custom-comments-comment-padded-divider.el18
-rw-r--r--tests/test-custom-comments-comment-reformat.el11
-rw-r--r--tests/test-custom-comments-public-wrappers.el25
-rw-r--r--tests/test-custom-datetime-all-methods.el5
-rw-r--r--tests/test-custom-line-paragraph-duplicate-line-or-region.el34
-rw-r--r--tests/test-custom-line-paragraph-join-line-or-region.el18
-rw-r--r--tests/test-custom-line-paragraph-jump-to-matching-paren.el24
-rw-r--r--tests/test-custom-ordering-number-lines.el5
-rw-r--r--tests/test-custom-ordering-reverse-lines.el6
-rw-r--r--tests/test-custom-text-enclose-indent.el29
-rw-r--r--tests/test-dashboard-config-launchers.el23
-rw-r--r--tests/test-dashboard-config.el10
-rw-r--r--tests/test-dev-fkeys--f4-clean-rebuild-impl.el94
-rw-r--r--tests/test-dev-fkeys--f4-compile-and-run-impl.el101
-rw-r--r--tests/test-dev-fkeys--f4-make-once-hook.el15
-rw-r--r--tests/test-dev-fkeys--f6-test-runner-cmd-for.el13
-rw-r--r--tests/test-diff-config--ediff-options.el27
-rw-r--r--tests/test-dirvish-config--quantize-thumb-size.el60
-rw-r--r--tests/test-dirvish-config-runtime-requires.el24
-rw-r--r--tests/test-dwim-shell-config-runtime-requires.el24
-rw-r--r--tests/test-eat-config--xtwinops.el120
-rw-r--r--tests/test-elfeed-config-helpers.el39
-rw-r--r--tests/test-external-open--open-with-argv.el59
-rw-r--r--tests/test-external-open-commands.el39
-rw-r--r--tests/test-flycheck-config-ledger-hook.el31
-rw-r--r--tests/test-flyspell-and-abbrev.el40
-rw-r--r--tests/test-font-config--frame-lifecycle.el75
-rw-r--r--tests/test-font-config.el300
-rw-r--r--tests/test-help-utils--arch-wiki-search.el124
-rw-r--r--tests/test-host-environment--detect-system-timezone.el19
-rw-r--r--tests/test-httpd-config--defer.el34
-rw-r--r--tests/test-hugo-config--keymap.el71
-rw-r--r--tests/test-integration-calendar-sync-timezone.el17
-rw-r--r--tests/test-integration-org-agenda-frame-load-order.el89
-rw-r--r--tests/test-integration-recording-device-workflow.el178
-rw-r--r--tests/test-integration-recording-toggle-workflow.el22
-rw-r--r--tests/test-integration-recurring-events.el113
-rw-r--r--tests/test-jumper.el25
-rw-r--r--tests/test-keyboard-compat-setup.el22
-rw-r--r--tests/test-ledger-config.el70
-rw-r--r--tests/test-local-repository--car-member.el58
-rw-r--r--tests/test-local-repository.el32
-rw-r--r--tests/test-lorem-optimum.el21
-rw-r--r--tests/test-mail-config--account-search-queries.el22
-rw-r--r--tests/test-mail-config-transport.el17
-rw-r--r--tests/test-media-utils--argv.el72
-rw-r--r--tests/test-media-utils--yt-dl-message.el64
-rw-r--r--tests/test-media-utils.el106
-rw-r--r--tests/test-mu4e-attachments.el50
-rw-r--r--tests/test-music-config--add-dired-selection.el64
-rw-r--r--tests/test-music-config--after-playlist-clear.el25
-rw-r--r--tests/test-music-config--art-cache-key.el69
-rw-r--r--tests/test-music-config--art-favicon-url.el69
-rw-r--r--tests/test-music-config--art-valid-image.el51
-rw-r--r--tests/test-music-config--bar-fill.el63
-rw-r--r--tests/test-music-config--bar-string.el50
-rw-r--r--tests/test-music-config--completion-table.el14
-rw-r--r--tests/test-music-config--delete-playlist-file.el135
-rw-r--r--tests/test-music-config--display-name.el139
-rw-r--r--tests/test-music-config--get-m3u-basenames.el21
-rw-r--r--tests/test-music-config--get-m3u-files.el25
-rw-r--r--tests/test-music-config--header-text.el17
-rw-r--r--tests/test-music-config--m3u-entries.el67
-rw-r--r--tests/test-music-config--m3u-file-tracks.el18
-rw-r--r--tests/test-music-config--m3u-labels.el62
-rw-r--r--tests/test-music-config--m3u-roots.el97
-rw-r--r--tests/test-music-config--m3u-text.el93
-rw-r--r--tests/test-music-config--music-files-recursive.el88
-rw-r--r--tests/test-music-config--pin-point.el78
-rw-r--r--tests/test-music-config--playlist-dock.el114
-rw-r--r--tests/test-music-config--playlist-open-position.el147
-rw-r--r--tests/test-music-config--playlist-side.el45
-rw-r--r--tests/test-music-config--radio-station-track.el135
-rw-r--r--tests/test-music-config--radio-tags.el79
-rw-r--r--tests/test-music-config--radio.el207
-rw-r--r--tests/test-music-config--renumber-rows.el232
-rw-r--r--tests/test-music-config--safe-filename.el97
-rw-r--r--tests/test-music-config--save-helpers.el90
-rw-r--r--tests/test-music-config--tidy-host.el47
-rw-r--r--tests/test-music-config--track-description.el181
-rw-r--r--tests/test-music-config-commands.el20
-rw-r--r--tests/test-music-config-create-radio-station.el220
-rw-r--r--tests/test-music-config-helpers-untested.el13
-rw-r--r--tests/test-music-config-more-commands.el149
-rw-r--r--tests/test-nov-reading--config-defaults.el67
-rw-r--r--tests/test-org-agenda-config-commands.el84
-rw-r--r--tests/test-org-agenda-frame.el1167
-rw-r--r--tests/test-org-capture-config--neutralize.el136
-rw-r--r--tests/test-org-config-noop-state-log.el59
-rw-r--r--tests/test-org-contacts-config-find.el72
-rw-r--r--tests/test-org-drill-config-source.el31
-rw-r--r--tests/test-org-refile-config--advice-helpers.el87
-rw-r--r--tests/test-org-reveal-config-keymap.el38
-rw-r--r--tests/test-org-roam-config-format.el16
-rw-r--r--tests/test-org-roam-config-tag-and-find.el11
-rw-r--r--tests/test-pdf-config--reading-palette.el87
-rw-r--r--tests/test-pre-commit-hook.bats126
-rw-r--r--tests/test-prog-c--tool-warnings.el34
-rw-r--r--tests/test-prog-general--pin-go-treesit-revision.el51
-rw-r--r--tests/test-prog-general-lsp.el148
-rw-r--r--tests/test-prog-go--classic-mode-hooks.el46
-rw-r--r--tests/test-prog-lsp--add-file-watch-ignored-extras.el116
-rw-r--r--tests/test-prog-lsp.el66
-rw-r--r--tests/test-prog-python--lsp-guard.el64
-rw-r--r--tests/test-prog-shell--tool-warnings.el34
-rw-r--r--tests/test-prog-webdev--classic-and-web-mode-hooks.el78
-rw-r--r--tests/test-restclient-config--keymap.el29
-rw-r--r--tests/test-slack-config--notify.el113
-rw-r--r--tests/test-slack-config-reactions.el8
-rw-r--r--tests/test-system-commands-resolve-and-run.el32
-rw-r--r--tests/test-system-defaults-functions.el31
-rw-r--r--tests/test-system-lib--ensure-marginalia-align.el57
-rw-r--r--tests/test-test-runner--nil-global-directory.el51
-rw-r--r--tests/test-test-runner.el19
-rw-r--r--tests/test-text-config.el13
-rw-r--r--tests/test-ui-theme-persistence.el13
-rw-r--r--tests/test-undead-buffers-kill-all-other-buffers-and-windows.el17
-rw-r--r--tests/test-undead-buffers-kill-other-window.el9
-rw-r--r--tests/test-validate-el-hook.bats97
-rw-r--r--tests/test-vc-config--git-clone.el135
-rw-r--r--tests/test-vc-config--gutter-hunk-candidates.el69
-rw-r--r--tests/test-vc-config--timemachine-commands.el36
-rw-r--r--tests/test-video-audio-recording--build-video-command.el8
-rw-r--r--tests/test-video-audio-recording--start-race.el56
-rw-r--r--tests/test-video-audio-recording-group-devices-by-hardware.el194
-rw-r--r--tests/test-video-audio-recording-process-sentinel.el152
-rw-r--r--tests/test-wrap-up--bury-buffers.el96
-rw-r--r--themes/WIP-theme.el10
-rw-r--r--working/eat-sixel-patch/eat-xtwinops.patch46
-rw-r--r--working/eat-sixel-patch/rulesets-companion-note.org5
-rw-r--r--working/eat-sixel-patch/takuzu-original-bug-report.org17
-rw-r--r--working/music-svg-app-design/2026-07-19-music-config-ui-remodel-render-fixture.svg87
-rw-r--r--working/music-svg-app-design/2026-07-19-music-config-ui-remodel-spec.org923
-rw-r--r--working/music-svg-app-design/README.org426
-rw-r--r--working/music-svg-app-design/assets/vinyl-placeholder.svg20
-rw-r--r--working/music-svg-app-design/concept-board.html25
-rw-r--r--working/music-svg-app-design/concepts/01-champagne-receiver.pngbin0 -> 2064469 bytes
-rw-r--r--working/music-svg-app-design/concepts/02-le-mans-night-cluster.pngbin0 -> 2451131 bytes
-rw-r--r--working/music-svg-app-design/concepts/03-geneva-playback-chronograph.pngbin0 -> 3097288 bytes
-rw-r--r--working/music-svg-app-design/concepts/04-mastering-room-reel-console.pngbin0 -> 2243918 bytes
-rw-r--r--working/music-svg-app-design/concepts/05-perpetual-calendar-salon.pngbin0 -> 2360111 bytes
-rw-r--r--working/music-svg-app-design/concepts/06-transatlantic-broadcast-navigator.pngbin0 -> 1897511 bytes
-rw-r--r--working/music-svg-app-design/concepts/07-functional-black-glass-receiver.pngbin0 -> 1890841 bytes
-rw-r--r--working/music-svg-app-design/concepts/08-functional-black-glass-flipped.pngbin0 -> 1804000 bytes
-rw-r--r--working/music-svg-app-design/concepts/09-functional-black-glass-retro.pngbin0 -> 2226236 bytes
-rw-r--r--working/music-svg-app-design/concepts/10-warm-black-glass-player-volume.pngbin0 -> 1872741 bytes
-rw-r--r--working/music-svg-app-design/concepts/11-illuminated-black-glass-controls.pngbin0 -> 1950069 bytes
-rw-r--r--working/music-svg-app-design/concepts/12-consolidated-functional-receiver.pngbin0 -> 1884863 bytes
-rw-r--r--working/music-svg-app-design/concepts/13-black-silver-chronograph-receiver.pngbin0 -> 2119461 bytes
-rw-r--r--working/music-svg-app-design/concepts/14-corrected-luxury-chronograph-receiver.pngbin0 -> 2061668 bytes
-rw-r--r--working/music-svg-app-design/concepts/15-champagne-brass-digital-hifi.pngbin0 -> 2010447 bytes
-rw-r--r--working/music-svg-app-design/concepts/16-champagne-aluminum-radio-groups.pngbin0 -> 1995487 bytes
-rw-r--r--working/music-svg-app-design/concepts/17-dupre-coltrane-scrollbar.pngbin0 -> 2027803 bytes
-rw-r--r--working/music-svg-app-design/concepts/18-dupre-branding-control-study.pngbin0 -> 2071824 bytes
-rw-r--r--working/music-svg-app-design/concepts/19a-dupre-playlist-state.pngbin0 -> 2013519 bytes
-rw-r--r--working/music-svg-app-design/concepts/19b-dupre-radio-state.pngbin0 -> 1954409 bytes
-rw-r--r--working/music-svg-app-design/concepts/20-dupre-playlist-radio-comparison.pngbin0 -> 3976510 bytes
-rw-r--r--working/music-svg-app-design/concepts/20a-dupre-long-playlist.pngbin0 -> 2051749 bytes
-rw-r--r--working/music-svg-app-design/concepts/20b-dupre-compact-radio.pngbin0 -> 2015466 bytes
-rw-r--r--working/music-svg-app-design/concepts/21-dupre-semantic-controls-comparison.pngbin0 -> 4015119 bytes
-rw-r--r--working/music-svg-app-design/concepts/21a-dupre-mode-lamps-playlist.pngbin0 -> 2084254 bytes
-rw-r--r--working/music-svg-app-design/concepts/21b-dupre-two-on-air-radio.pngbin0 -> 2015323 bytes
-rw-r--r--working/music-svg-app-design/concepts/22-dupre-identity-bay-comparison.pngbin0 -> 4029125 bytes
-rw-r--r--working/music-svg-app-design/concepts/22a-dupre-identity-bay-playlist.pngbin0 -> 2091641 bytes
-rw-r--r--working/music-svg-app-design/concepts/22b-dupre-identity-bay-radio.pngbin0 -> 2024997 bytes
-rw-r--r--working/music-svg-app-design/concepts/23-dupre-swapped-info-comparison.pngbin0 -> 3733539 bytes
-rw-r--r--working/music-svg-app-design/concepts/23a-dupre-swapped-info-playlist.pngbin0 -> 1959413 bytes
-rw-r--r--working/music-svg-app-design/concepts/23b-dupre-swapped-info-radio.pngbin0 -> 1860779 bytes
-rw-r--r--working/music-svg-app-design/concepts/24-dupre-full-brass-vu-comparison.pngbin0 -> 3843296 bytes
-rw-r--r--working/music-svg-app-design/concepts/24a-dupre-full-brass-vu-playlist.pngbin0 -> 2013020 bytes
-rw-r--r--working/music-svg-app-design/concepts/24b-dupre-full-brass-vu-radio.pngbin0 -> 1911285 bytes
-rw-r--r--working/music-svg-app-design/concepts/25-dupre-seamless-flush-comparison.pngbin0 -> 3847925 bytes
-rw-r--r--working/music-svg-app-design/concepts/25a-dupre-seamless-flush-playlist.pngbin0 -> 2037192 bytes
-rw-r--r--working/music-svg-app-design/concepts/25b-dupre-seamless-flush-radio.pngbin0 -> 1895006 bytes
-rw-r--r--working/music-svg-app-design/concepts/26-dupre-studios-rounded-chrome-comparison.pngbin0 -> 3795106 bytes
-rw-r--r--working/music-svg-app-design/concepts/26a-dupre-studios-rounded-chrome-playlist.pngbin0 -> 1979416 bytes
-rw-r--r--working/music-svg-app-design/concepts/26b-dupre-studios-rounded-chrome-radio.pngbin0 -> 1896697 bytes
-rw-r--r--working/music-svg-app-design/concepts/27-dupre-studios-leather-analog-header-comparison.pngbin0 -> 4153861 bytes
-rw-r--r--working/music-svg-app-design/concepts/27a-dupre-studios-leather-analog-header-playlist.pngbin0 -> 2148939 bytes
-rw-r--r--working/music-svg-app-design/concepts/27b-dupre-studios-leather-analog-header-radio.pngbin0 -> 2101436 bytes
-rw-r--r--working/music-svg-app-design/concepts/28-dupre-studios-balanced-controls-comparison.pngbin0 -> 4163731 bytes
-rw-r--r--working/music-svg-app-design/concepts/28a-dupre-studios-balanced-controls-playlist.pngbin0 -> 2144998 bytes
-rw-r--r--working/music-svg-app-design/concepts/28b-dupre-studios-balanced-controls-radio.pngbin0 -> 2111994 bytes
-rw-r--r--working/music-svg-app-design/concepts/29a-dupre-studios-brass-seek-playlist.pngbin0 -> 2176613 bytes
-rw-r--r--working/music-svg-app-design/concepts/29a-dupre-studios-brass-seek-playlist.xcfbin0 -> 2303994 bytes
-rw-r--r--working/music-svg-app-design/concepts/30a-dupre-studios-user-refined-playlist.pngbin0 -> 2288732 bytes
-rw-r--r--working/music-svg-app-design/music-svg-directions.html35
-rw-r--r--working/music-svg-app-design/prototypes/2026-07-19-music-config-ui-remodel-benchmark.org61
-rw-r--r--working/music-svg-app-design/prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html1450
-rw-r--r--working/music-svg-app-design/references/29-alignment-guide.pngbin0 -> 2328747 bytes
-rw-r--r--working/music-svg-app-design/references/dupre-gallery-tall.pngbin0 -> 2154502 bytes
-rw-r--r--working/music-svg-app-design/references/dupre-gallery.pngbin0 -> 287964 bytes
-rw-r--r--working/music-svg-app-design/references/waybar-redesign-tall.pngbin0 -> 735385 bytes
-rw-r--r--working/music-svg-app-design/references/waybar-redesign.pngbin0 -> 205084 bytes
398 files changed, 20672 insertions, 3786 deletions
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..fd4eb2ad
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Unified diff context lines can carry a required trailing space marker.
+working/**/*.patch whitespace=-blank-at-eol
diff --git a/.gitignore b/.gitignore
index adb08d60..b92678f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
/.coverage/
flycheck_*
projectile-bookmarks.eld
+/fontaine-latest-state.eld
/recentf
/backups/
auto-save-list/
@@ -84,6 +85,9 @@ history
# Data directory (calendar sync output, API rest files)
/data/
+# Network Security Manager runtime database (cert fingerprints, written by Emacs)
+/network-security.data
+
# --- elisp ruleset ---
*.eln
@@ -105,3 +109,7 @@ todo.org
# Claude Code: task archive (follows todo file privacy)
/archive/task-archive.org
+takuzu-stats.eld
+
+# disposable project-local scratch; active task artifacts live in versioned working/
+/temp/
diff --git a/Makefile b/Makefile
index a2f491a9..dade0be9 100644
--- a/Makefile
+++ b/Makefile
@@ -32,8 +32,13 @@ EMACS_HOME = $(HOME)/.emacs.d
OUT ?= themes
# Test files
+# NOTE: filter-out takes make patterns, where % is the wildcard; $(wildcard ...)
+# takes a shell glob, where % is a literal character and * is the wildcard. The
+# two are not interchangeable. INTEGRATION_TESTS used a % here and so expanded
+# to nothing, which silently skipped every integration test in `make test' while
+# it still printed ALL TESTS COMPLETE.
UNIT_TESTS = $(filter-out $(TEST_DIR)/test-integration-%.el, $(wildcard $(TEST_DIR)/test-*.el))
-INTEGRATION_TESTS = $(wildcard $(TEST_DIR)/test-integration-%.el)
+INTEGRATION_TESTS = $(wildcard $(TEST_DIR)/test-integration-*.el)
ALL_TESTS = $(UNIT_TESTS) $(INTEGRATION_TESTS)
BASH_TESTS = $(wildcard $(TEST_DIR)/*.bats)
diff --git a/archive/README.org b/archive/README.org
new file mode 100644
index 00000000..e292065a
--- /dev/null
+++ b/archive/README.org
@@ -0,0 +1,71 @@
+#+TITLE: Archive — Retired Modules
+#+AUTHOR: Craig Jennings
+
+* What this is
+
+The pasture upstate. Modules that were once part of the config but are no
+longer loaded, kept here instead of deleted so they can be read, mined for
+ideas, or brought back later.
+
+Nothing in this directory is on the Emacs =load-path=, and nothing in the
+active config requires anything here. Retiring a file means moving it here and
+removing whatever orphaned reference kept pointing at it. Restoring one means
+moving it back to =modules/= or =custom/= and re-adding its =require= (or
+=use-package=) in =init.el=.
+
+The directory is tracked in git, so every retired file stays retrievable from
+history even after it moves again.
+
+* The rule this directory enforces
+
+Every =.el= file *outside* this directory should be of active use — required,
+loaded, or otherwise reached by the running config. If a file is never touched,
+loaded, or referenced, it belongs here. The candidate list is generated by a
+reachability sweep from =init.el= (transitive =require= closure), then checked
+by hand for the cases pure requires miss: =use-package= with =:load-path=,
+autoloads, and vendored copies shadowed by a package-manager install.
+
+* Layout
+
+Retired files keep their origin directory so a restore is a straight move back:
+
+- =archive/modules/= — retired first-party modules
+- =archive/custom/= — retired vendored third-party files
+- =archive/tests/= — tests belonging to retired modules
+- =archive/gptel/= — the gptel feature subtree, retired as a unit
+
+* Inventory
+
+** archive/modules/
+
+- =show-kill-ring.el= — kill-ring viewer bound to M-S-k. Its =require= was
+ dropped in b785a19d and consult-yank-pop covers the same need. Retiring it
+ also removed the orphaned M-K -> M-S-k translation in =keyboard-compat.el=,
+ which had been leaving M-K a dead key.
+- =duet-config.el= — no reference anywhere in the config.
+- =mu4e-org-contacts-setup.el= — nothing requires it; its featurep gate would
+ be nil at init in any case. The live mu4e contacts wiring lives in
+ =mu4e-org-contacts-integration.el=.
+- =signal-config.el= — the in-Emacs Signal client (forked signel at
+ =~/code/signel= + signal-cli), retired whole on Craig's call 2026-07-14:
+ agents drive Signal via signal-cli / signal-mcp, so the interactive Emacs
+ client earns no keep. The fork repo itself is untouched. Its seven test
+ files moved to =archive/tests/= with it (=test-signal-config*.el=,
+ =test-signel-*.el=). The spec record is
+ =docs/specs/signal-client-spec.org= (IMPLEMENTED, then retired).
+
+** archive/custom/
+
+- =eplot.el= — vendored plotting package, unreferenced.
+- =profile-dotemacs.el= — startup-profiling tool, unreferenced.
+- =titlecase.el= + =titlecase-data.el= — the duckwork/titlecase.el package,
+ superseded by =custom-case.el='s own =cj/title-case-region=.
+- =edit-indirect.el= — a vendored copy of the MELPA package that
+ =text-config.el= installs (its =use-package= carries no =:ensure nil=). With
+ this copy off the =load-path=, the MELPA copy loads, which is the intended
+ one.
+
+** archive/gptel/
+
+The gptel AI-assistant feature, retired as a whole subtree (modules, tests,
+tools, custom, and its spec docs) when the config moved off gptel.
diff --git a/custom/edit-indirect.el b/archive/custom/edit-indirect.el
index 307f9695..307f9695 100644
--- a/custom/edit-indirect.el
+++ b/archive/custom/edit-indirect.el
diff --git a/custom/eplot.el b/archive/custom/eplot.el
index c00a3e31..c00a3e31 100644
--- a/custom/eplot.el
+++ b/archive/custom/eplot.el
diff --git a/custom/profile-dotemacs.el b/archive/custom/profile-dotemacs.el
index 8baee47b..8baee47b 100644
--- a/custom/profile-dotemacs.el
+++ b/archive/custom/profile-dotemacs.el
diff --git a/custom/titlecase-data.el b/archive/custom/titlecase-data.el
index 7415a210..7415a210 100644
--- a/custom/titlecase-data.el
+++ b/archive/custom/titlecase-data.el
diff --git a/custom/titlecase.el b/archive/custom/titlecase.el
index 319befef..319befef 100644
--- a/custom/titlecase.el
+++ b/archive/custom/titlecase.el
diff --git a/modules/duet-config.el b/archive/modules/duet-config.el
index 2dc7ad2e..2dc7ad2e 100644
--- a/modules/duet-config.el
+++ b/archive/modules/duet-config.el
diff --git a/modules/mu4e-org-contacts-setup.el b/archive/modules/mu4e-org-contacts-setup.el
index bfb9b1f2..bfb9b1f2 100644
--- a/modules/mu4e-org-contacts-setup.el
+++ b/archive/modules/mu4e-org-contacts-setup.el
diff --git a/modules/show-kill-ring.el b/archive/modules/show-kill-ring.el
index e65d48b5..e65d48b5 100644
--- a/modules/show-kill-ring.el
+++ b/archive/modules/show-kill-ring.el
diff --git a/modules/signal-config.el b/archive/modules/signal-config.el
index edb7d0dc..a1ec7933 100644
--- a/modules/signal-config.el
+++ b/archive/modules/signal-config.el
@@ -143,6 +143,9 @@ BODY is formatted by `cj/signal--format-notify-body'. Installed as
(if script
(apply #'start-process "signel-notify" nil script "info" title text
(unless cj/signel-notify-sound (list "--silent")))
+ ;; notifications.el is not autoloaded; load it on the first fallback.
+ (unless (fboundp 'notifications-notify)
+ (require 'notifications))
(notifications-notify :title title :body text)))))
;;; signel — fork integration
@@ -199,10 +202,27 @@ time."
(declare-function signel--send-rpc "signel" (method params &optional target-buffer success-callback))
(defvar cj/signel--contact-cache nil
- "Cached `(LABEL . RECIPIENT)' alist for the contact picker.
+ "Contact-picker cache: nil (cold), `empty', or a `(LABEL . RECIPIENT)' alist.
Populated by `cj/signel--fetch-contacts' on first invocation (or after a
-`cj/signel-refresh-contacts'), and cleared on `signel-stop' / restart so
-a stale list can't survive a reconnect. In-memory only.")
+`cj/signel-refresh-contacts'). A fetched-and-empty account caches the
+symbol `empty' rather than nil, so it reads as warm and the picker does
+not re-run its blocking fetch on every open -- read through
+`cj/signel--cached-contacts'. Cleared back to cold by
+`cj/signel--clear-contact-cache', advised onto `signel-stop' so a stale
+list can't survive a reconnect. In-memory only.")
+
+(defun cj/signel--clear-contact-cache (&rest _)
+ "Return the contact cache to cold (nil) so the next picker refetches.
+Advised `:after' `signel-stop': a relink or reconnect may change the
+contact list, so a cache from the previous connection must not survive."
+ (setq cj/signel--contact-cache nil))
+
+(advice-add 'signel-stop :after #'cj/signel--clear-contact-cache)
+
+(defun cj/signel--cached-contacts ()
+ "Return the cached contact alist, treating the `empty' sentinel as none."
+ (unless (eq cj/signel--contact-cache 'empty)
+ cj/signel--contact-cache))
(defcustom cj/signel-fetch-timeout 3.0
"Seconds the picker blocks on `accept-process-output' for a cold-cache fetch.
@@ -249,8 +269,10 @@ fires a void-variable error before the autoload would trigger."
Issues a `listContacts' RPC and registers a success callback that runs
the result through `cj/signal--parse-contacts' (the verified parser) and
stores the resulting `(LABEL . RECIPIENT)' alist in the cache. An empty
-result populates the cache as nil; a failure goes through the dispatch
-error path and never invokes the callback, so the prior cache survives.
+result caches the `empty' sentinel -- nil would read as a cold cache and
+re-run the picker's blocking fetch on every open. A failure goes
+through the dispatch error path and never invokes the callback, so the
+prior cache survives.
AFTER-CALLBACK, when non-nil, is invoked with no arguments after the
cache has been populated -- the picker uses this to unblock its
@@ -258,7 +280,8 @@ bounded-wait on cold caches."
(signel--send-rpc
"listContacts" nil nil
(lambda (result)
- (setq cj/signel--contact-cache (cj/signal--parse-contacts result))
+ (setq cj/signel--contact-cache
+ (or (cj/signal--parse-contacts result) 'empty))
(when after-callback (funcall after-callback)))))
(defun cj/signel-refresh-contacts ()
@@ -306,7 +329,7 @@ opens the chosen recipient in `signel-chat'."
"Signal contact fetch timed out after %.1fs; try again or run M-x cj/signel-refresh-contacts (see *signel-log* for detail)"
cj/signel-fetch-timeout))))
(let* ((note-self (cons "Note to Self" signel-account))
- (candidates (cons note-self cj/signel--contact-cache))
+ (candidates (cons note-self (cj/signel--cached-contacts)))
(table (lambda (string pred action)
(if (eq action 'metadata)
`(metadata
diff --git a/tests/test-show-kill-ring--insert-item.el b/archive/tests/test-show-kill-ring--insert-item.el
index a29ca75e..a29ca75e 100644
--- a/tests/test-show-kill-ring--insert-item.el
+++ b/archive/tests/test-show-kill-ring--insert-item.el
diff --git a/archive/tests/test-signal-config--contact-cache.el b/archive/tests/test-signal-config--contact-cache.el
new file mode 100644
index 00000000..720e0c3a
--- /dev/null
+++ b/archive/tests/test-signal-config--contact-cache.el
@@ -0,0 +1,61 @@
+;;; test-signal-config--contact-cache.el --- Contact-cache lifecycle tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The picker's contact cache has two lifecycle bugs from the 2026-06 config
+;; audit: (1) its docstring promised clearing on signel-stop but nothing
+;; cleared it, so a stale list survived a relink/reconnect; (2) a
+;; fetched-and-empty list was cached as nil, indistinguishable from a cold
+;; cache, so a zero-contact account re-ran the blocking fetch (up to
+;; `cj/signel-fetch-timeout') on every picker open. The fix names the empty
+;; state with an `empty' sentinel and clears the cache via a named function
+;; advised onto `signel-stop'.
+;;
+;; Boundary mocks only (the RPC send, the prompt); the cache logic runs
+;; real. The ensure-started branches the audit called untested were
+;; already covered in test-signal-config.el -- that claim was stale.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'signal-config)
+
+;;; ------------------------- clear-on-stop (F1) ------------------------------
+
+(ert-deftest test-signal-config-clear-contact-cache-resets ()
+ "Normal: the clear function empties the cache back to cold (nil)."
+ (let ((cj/signel--contact-cache '(("Al (+1)" . "+1"))))
+ (cj/signel--clear-contact-cache)
+ (should (null cj/signel--contact-cache))))
+
+(ert-deftest test-signal-config-clear-contact-cache-advises-stop ()
+ "Normal: `signel-stop' carries the clear advice, so the docstring's
+\"cleared on stop/restart\" promise is real."
+ (should (advice-member-p #'cj/signel--clear-contact-cache 'signel-stop)))
+
+;;; ------------------------- empty sentinel (F2) ------------------------------
+
+(ert-deftest test-signal-config-fetch-empty-caches-sentinel ()
+ "Boundary: a fetched-and-empty list caches the `empty' sentinel, not nil.
+nil means cold cache; without the sentinel a zero-contact account re-ran
+the blocking fetch on every picker open."
+ (let ((cj/signel--contact-cache nil)
+ (captured-callback nil))
+ (cl-letf (((symbol-function 'signel--send-rpc)
+ (lambda (_method _params _buf callback)
+ (setq captured-callback callback))))
+ (cj/signel--fetch-contacts)
+ (funcall captured-callback '()))
+ (should (eq cj/signel--contact-cache 'empty))))
+
+(ert-deftest test-signal-config-cached-contacts-unwraps-sentinel ()
+ "Normal: the cache reader returns the alist, and nil for the sentinel."
+ (let ((cj/signel--contact-cache '(("Al (+1)" . "+1"))))
+ (should (equal (cj/signel--cached-contacts) '(("Al (+1)" . "+1")))))
+ (let ((cj/signel--contact-cache 'empty))
+ (should (null (cj/signel--cached-contacts)))))
+
+(provide 'test-signal-config--contact-cache)
+;;; test-signal-config--contact-cache.el ends here
diff --git a/tests/test-signal-config-notify.el b/archive/tests/test-signal-config-notify.el
index 1a772289..1a772289 100644
--- a/tests/test-signal-config-notify.el
+++ b/archive/tests/test-signal-config-notify.el
diff --git a/tests/test-signal-config.el b/archive/tests/test-signal-config.el
index 7556efdb..f8bf4410 100644
--- a/tests/test-signal-config.el
+++ b/archive/tests/test-signal-config.el
@@ -251,9 +251,12 @@ and stores the (LABEL . RECIPIENT) alist in `cj/signel--contact-cache'."
(should (equal cj/signel--contact-cache
'(("Alice (+15555550100)" . "+15555550100"))))))
-(ert-deftest test-signal-config-fetch-contacts-empty-result-clears-cache ()
- "Boundary: an empty listContacts result populates the cache as nil,
-distinct from a failure path (which never invokes the success callback)."
+(ert-deftest test-signal-config-fetch-contacts-empty-result-caches-sentinel ()
+ "Boundary: an empty listContacts result caches the `empty' sentinel.
+nil would read as a cold cache and re-run the picker's blocking fetch on
+every open; the sentinel marks warm-but-empty. Still distinct from a
+failure path, which never invokes the success callback. (This test
+formerly pinned the nil behavior -- the 2026-06 audit's F2 bug.)"
(let (sent-callback)
(cl-letf (((symbol-function 'signel--send-rpc)
(lambda (_method _params _target callback)
@@ -261,7 +264,7 @@ distinct from a failure path (which never invokes the success callback)."
(setq cj/signel--contact-cache '(("stale" . "+10000000000")))
(cj/signel--fetch-contacts)
(funcall sent-callback []))
- (should-not cj/signel--contact-cache)))
+ (should (eq cj/signel--contact-cache 'empty))))
;;; cj/signel-refresh-contacts
diff --git a/tests/test-signel-cancel-input.el b/archive/tests/test-signel-cancel-input.el
index b2a7ef89..b2a7ef89 100644
--- a/tests/test-signel-cancel-input.el
+++ b/archive/tests/test-signel-cancel-input.el
diff --git a/tests/test-signel-input-preservation.el b/archive/tests/test-signel-input-preservation.el
index e8ce4ddb..e8ce4ddb 100644
--- a/tests/test-signel-input-preservation.el
+++ b/archive/tests/test-signel-input-preservation.el
diff --git a/tests/test-signel-notify-function.el b/archive/tests/test-signel-notify-function.el
index e3d97af5..e3d97af5 100644
--- a/tests/test-signel-notify-function.el
+++ b/archive/tests/test-signel-notify-function.el
diff --git a/tests/test-signel-rpc-dispatch.el b/archive/tests/test-signel-rpc-dispatch.el
index 5ae023d6..5ae023d6 100644
--- a/tests/test-signel-rpc-dispatch.el
+++ b/archive/tests/test-signel-rpc-dispatch.el
diff --git a/assets/vinyl-placeholder.svg b/assets/vinyl-placeholder.svg
new file mode 100644
index 00000000..cf01519f
--- /dev/null
+++ b/assets/vinyl-placeholder.svg
@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300" role="img" aria-label="Vinyl record placeholder">
+ <defs>
+ <radialGradient id="disc" cx="50%" cy="50%" r="50%">
+ <stop offset="0%" stop-color="#1a1a1a"/>
+ <stop offset="100%" stop-color="#000000"/>
+ </radialGradient>
+ </defs>
+ <circle cx="150" cy="150" r="148" fill="url(#disc)"/>
+ <g fill="none" stroke="#2b2b2b" stroke-width="1">
+ <circle cx="150" cy="150" r="138"/>
+ <circle cx="150" cy="150" r="126"/>
+ <circle cx="150" cy="150" r="114"/>
+ <circle cx="150" cy="150" r="102"/>
+ <circle cx="150" cy="150" r="90"/>
+ <circle cx="150" cy="150" r="78"/>
+ </g>
+ <circle cx="150" cy="150" r="52" fill="#d99a2b"/>
+ <circle cx="150" cy="150" r="52" fill="none" stroke="#a8741a" stroke-width="2"/>
+ <circle cx="150" cy="150" r="7" fill="#111111"/>
+</svg>
diff --git a/docs/design/2026-07-10-curated-help-menu-survey.org b/docs/design/2026-07-10-curated-help-menu-survey.org
new file mode 100644
index 00000000..a7469ac1
--- /dev/null
+++ b/docs/design/2026-07-10-curated-help-menu-survey.org
@@ -0,0 +1,96 @@
+#+TITLE: Survey — where a curated "?" help menu makes sense
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+
+* What this is
+
+The mechanical half of the "=?= = curated help menu" convention task. It reports,
+per mode, whether =?= is free, whether =H= is free, and what the package already
+ships. It changes no keybinding and makes no per-mode recommendation, because which
+commands belong in a curated menu is a taste call.
+
+The reference implementation is calibredb (=modules/calibredb-epub-config.el:129-131=):
+=?= runs =cj/calibredb-menu=, a curated transient of the frequent workflows, and =H=
+runs the package's own =calibredb-dispatch=.
+
+* Method
+
+Bindings were read from the live keymaps, not from source, because a package's
+=define-key= calls and its =:bind= forms don't always agree with what ends up in the
+map.
+
+The probe ran in a throwaway =emacs --batch= with =package-initialize=, not in the
+running daemon, for two reasons. Probing =calibredb-search-mode-map= or
+=mu4e-headers-mode-map= in the daemon would force-load those packages into a live
+session. And a batch Emacs reports the *package's* defaults rather than the defaults
+plus Craig's config, which is the question being asked: what does the package ship?
+
+=mu4e= is not an ELPA package. It installs with =mu= into
+=/usr/share/emacs/site-lisp/mu4e=, so it needed an explicit =load-path= entry.
+
+* The table
+
+=FREE= means the key is unbound in that mode's map.
+
+| Mode | =?= | =H= | Ships a dispatch? |
+|---------------+--------------------------------+---------------------------+------------------------|
+| calibredb | =cj/calibredb-menu= (curated) | =calibredb-dispatch= | yes, moved to =H= |
+|---------------+--------------------------------+---------------------------+------------------------|
+| nov | FREE | FREE | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| eat | FREE | FREE | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| elfeed-search | FREE | FREE | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| elfeed-show | FREE | FREE | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| dired/dirvish | =dired-summary= | =dired-do-hardlink= | no (summary is a |
+| | | | one-line echo) |
+|---------------+--------------------------------+---------------------------+------------------------|
+| magit-status | =magit-dispatch= | =magit-describe-section= | yes, already on =?= |
+|---------------+--------------------------------+---------------------------+------------------------|
+| pdf-view | =describe-mode= | =pdf-view-fit-height-...= | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| org-agenda | =org-agenda-show-the-flagging- | =org-agenda-holidays= | no |
+| | note= | | |
+|---------------+--------------------------------+---------------------------+------------------------|
+| mu4e-headers | =mu4e-headers-mark-for-unread= | =mu4e-display-manual= | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+
+* What the table shows
+
+Four modes have both keys free: nov, eat, elfeed-search, elfeed-show. Nothing has to
+be displaced to adopt the convention there. These are the cheap ones.
+
+Three modes bind =?= to something small. =dired-summary= echoes a one-line key
+reminder. =pdf-view= sends =?= to =describe-mode=. =org-agenda= puts a
+flagging-note command on it. In each case the convention would displace a command of
+low value, but it is a displacement and needs a decision.
+
+=mu4e-headers= is the awkward one. Its =?= is =mu4e-headers-mark-for-unread=, a real
+editing command in the marking family (=u=, =d=, =!=). Rebinding it would break
+muscle memory for an operation that mutates mail state. Its =H= is already
+=mu4e-display-manual=.
+
+=magit= is the counterexample worth noting. It already does exactly what the
+convention proposes: =?= opens =magit-dispatch=, its own curated transient. The
+convention is not new, it is magit's habit, generalized. Magit needs no change.
+
+=dirvish= inherits =dired-mode-map=, so a change there lands on plain dired too.
+
+* Caveats
+
+The probe reads package defaults. Craig's own config may rebind =?= in a mode after
+the package loads, and the batch probe would not see it. A grep for =?=-rebinding in
+=modules/*.el= found only the calibredb case, so the table should hold, but a mode
+whose config binds =?= inside a =:config= block reached by a path the grep missed
+would not show up.
+
+=signel= and =pearl= are in the task's candidate list and are absent from the table.
+Neither was probeable: they are local checkouts, not ELPA packages, and loading them
+in batch pulls in dependencies the probe deliberately avoided.
+
+* The decisions this leaves
+
+For each mode, whether a curated =?= menu is worth its cost, and if so, which
+commands go in it. That is the taste call, and the reason this survey stops here.
diff --git a/docs/design/2026-07-10-ledger-config-audit.org b/docs/design/2026-07-10-ledger-config-audit.org
new file mode 100644
index 00000000..ff969d78
--- /dev/null
+++ b/docs/design/2026-07-10-ledger-config-audit.org
@@ -0,0 +1,146 @@
+#+TITLE: Ledger config audit — correctness and guardrail gaps
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+
+* Scope
+
+The correctness half of the ledger guardrail work. It audits
+=modules/ledger-config.el= (71 lines, no test file before today) and reports what's
+wrong and what's missing. It designs no guardrails, because those choices are
+Craig's.
+
+Characterization tests for the behavior that exists now landed alongside this note
+at =tests/test-ledger-config.el=.
+
+The stakes set the bar. This is a financial file, and Craig's stated goal for the
+follow-on work is "enough guardrails that it's hard to make a costly mistake."
+
+* Findings
+
+** F1. The linting doesn't run. Nothing checks unbalanced transactions.
+
+FIXED 2026-07-10. =flycheck-config.el= now hooks =flycheck-mode= to =ledger-mode=.
+Verified end to end on an unbalanced fixture: flycheck reports "Transaction does not
+balance" with the =$10.00= remainder. The finding is kept below as the record of
+what was wrong.
+
+The module's own commentary says it provides "flycheck linting."
+
+=flycheck-ledger= is loaded (=ledger-config.el:59-60=) and registers the =ledger=
+checker. But =flycheck-mode= is never enabled in a ledger buffer.
+=flycheck-config.el:47= hooks =flycheck-mode= to exactly two modes:
+
+#+begin_src elisp
+:hook ((sh-mode emacs-lisp-mode) . flycheck-mode)
+#+end_src
+
+There is no =global-flycheck-mode= anywhere in the config. Verified against a real
+=ledger-mode= buffer in the running daemon: =major-mode= is =ledger-mode=,
+=flycheck-checkers= contains =ledger=, and =flycheck-mode= is =nil=.
+
+So an unbalanced transaction, a malformed date, and a typo'd account name all
+produce no warning. The guardrail Craig believes he has is not connected.
+
+This is the finding that matters. Everything below is smaller.
+
+** F2. Every save silently reorders the entire file.
+
+=cj/ledger--clean-before-save= runs =ledger-mode-clean-buffer= on
+=before-save-hook= (=ledger-config.el:32-40=), with =cj/ledger-clean-on-save=
+defaulting to =t=.
+
+=ledger-mode-clean-buffer= (=ledger-mode.el:226=) is not a formatter. Its own
+docstring says "Indent, remove multiple line feeds and sort the buffer," and its
+body calls, in order: =untabify=, =ledger-sort-buffer=, =ledger-post-align-postings=,
+=ledger-mode-remove-extra-lines=.
+
+=ledger-sort-buffer= (=ledger-sort.el:106=) sorts the whole buffer by date, from
+=point-min= to =point-max= unless the file carries explicit sort markers.
+
+So every =C-x C-s= rewrites the transaction order of a financial file. The task
+asked whether clean-on-save "ever reorders or rewrites in a surprising way." It
+reorders, every time, by design of the upstream function.
+
+*Accepted 2026-07-10, on inspection of the sort key.* The reordering is
+chronological and nothing else. =ledger-sort-startkey= (=ledger-sort.el:62=) builds
+its key from the first ten characters of a transaction's opening line, the ISO date,
+through =ledger-parse-iso-date= and =float-time=. Payee, amount, and account never
+enter the key. =sort-subr= uses Emacs's stable sort, so transactions sharing a date
+keep the order they were typed in.
+
+The scope is bounded on request: =ledger-sort-buffer= narrows to the region between
+=; Ledger-mode: Start sort= and =; Ledger-mode: End sort= marker comments when the
+file carries them, and only falls back to the whole buffer when it doesn't.
+
+So a save sorts by date, stably, over a region the file can choose. Craig accepts
+that. The finding stays recorded because "clean-on-save" still reads as whitespace
+tidying, and the next person to meet this function should know it sorts.
+
+** F3. The demoted error hides a partial rewrite, not just a message.
+
+The clean is wrapped in =with-demoted-errors= so "a malformed buffer still saves"
+(=ledger-config.el:34-37=).
+
+The comment is accurate about the save. It is misleading about the buffer. The
+operations inside =ledger-mode-clean-buffer= run in sequence and mutate as they go.
+An error raised by =ledger-post-align-postings= or by the =search-forward= that
+restores point (=ledger-mode.el:239=) happens *after* =ledger-sort-buffer= has
+already reordered everything. Nothing rolls back.
+
+The result is a file that saved, in a state neither the user nor the cleaner
+intended, with a message in the echo area that scrolls away.
+
+** F4. Reconcile has no confirmation, and clears whole transactions.
+
+=ledger-clear-whole-transactions= is =t= (=ledger-config.el:43=), so a reconcile
+marks entire transactions cleared rather than individual postings. Combined with no
+confirmation step anywhere in the module, a stray keypress in a reconcile buffer
+mutates the ledger file.
+
+This is upstream behavior, not a bug in this module. It is named here because the
+follow-on guardrail work asked about "reconcile safety" and this is what reconcile
+safety currently amounts to.
+
+* What is not a problem
+
+Worth recording so the guardrail work doesn't chase it.
+
+- *company-ledger's global backend is correctly scoped.* =ledger-config.el:68= does
+ =(add-to-list 'company-backends 'company-ledger)= globally, which looks like it
+ would offer ledger completions everywhere. It doesn't: the backend's own =prefix=
+ command (=company-ledger.el:110-111=) returns nil unless the buffer is
+ =beancount-mode= or derives from =ledger-mode=.
+- *The reports pass =--strict=.* All five entries in =ledger-reports=
+ (=ledger-config.el:48-52=) use =--strict=, so a report over a file with an
+ undeclared account errors rather than silently inventing one. This is a real
+ guardrail and it is on.
+- *The missing-binary check is at the right place.* =cj/executable-find-or-warn=
+ runs at =:config= (=ledger-config.el:54=), so a missing =ledger= CLI warns at load
+ rather than failing cryptically inside a report.
+- *The clean-on-save hook is installed buffer-locally* (=ledger-config.el:40=), not
+ globally. Pinned by a test.
+
+* Gaps, as a list
+
+1. +No linting reaches ledger buffers (F1).+ Fixed 2026-07-10.
+2. No confirmation before the date-sort on save (F2). Accepted: the sort is
+ chronological and stable.
+3. No rollback when that rewrite fails partway (F3).
+4. No confirmation before reconcile mutates the file (F4).
+5. No validation that a save leaves the file balanced.
+6. No test coverage before today.
+
+* What this note deliberately does not do
+
+It designs no guardrails. Whether a clean that fails should roll back, and what
+shape a reconcile confirmation takes, are preference calls about Craig's own
+accounting workflow. Those belong to the ledger guardrail UX task.
+
+The one finding that wasn't a preference call was F1: turning flycheck on in ledger
+buffers restored a guardrail the module already claimed to have. That was a defect,
+not a design choice, and it is fixed.
+
+F2 was resolved by reading the sort key rather than by changing code. What remains
+for the guardrail task is F3 (no rollback when a clean fails partway) and F4 (no
+confirmation before reconcile), plus the open question of whether a save should
+verify the file still balances.
diff --git a/docs/design/2026-07-10-org-module-ownership.org b/docs/design/2026-07-10-org-module-ownership.org
new file mode 100644
index 00000000..5717b66f
--- /dev/null
+++ b/docs/design/2026-07-10-org-module-ownership.org
@@ -0,0 +1,138 @@
+#+TITLE: Org module ownership and load boundaries
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+
+* Why this note exists
+
+The Org workflow is spread across sixteen modules with overlapping
+responsibilities. The code works. What's missing is a map: which module owns which
+behavior, what each one adds to a shared variable, and in what order they load.
+
+Future load-order work needs that map. Four modules add capture templates, four
+touch =org-agenda-files=, and five register org-protocol handlers. None of that is
+wrong, but none of it is written down either, so the only way to answer "who sets
+this?" is to grep.
+
+This is a description of what is, not a proposal. Where the map turned up something
+that looks like a defect, it's flagged at the bottom rather than fixed here.
+
+* The modules
+
+Every Org module declares =Layer=, =Category=, and =Load shape= in its commentary
+header. The layer numbers below come from those headers, not from this note.
+
+| Module | Layer | Owns |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-faces-config.el= | 2 | Agenda keyword and priority faces, plus their -dim |
+| | | variants for unfocused windows |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-config.el= | 3 | Base org-mode settings, the org keymap, tag alignment |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-agenda-config.el= | 3 | Agenda views, task tracking, notifications; loads |
+| | | =org-agenda-config-debug= on demand |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-capture-config.el= | 3 | The base capture templates and their dispatch keys |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-refile-config.el= | 3 | Refile targets and the cached target list |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-roam-config.el= | 3 | Roam database, capture, and its agenda-file additions |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-contacts-config.el= | 3 | Contacts storage and its capture template |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-babel-config.el= | 3 | Source-block languages and tempo templates |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-export-config.el= | 3 | Export backends and their defaults |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-spec-links.el= | 3 | Resolves =[[id:]]= links into project spec docs |
+|------------------------------+-------+---------------------------------------------------------|
+| =hugo-config.el= | 3 | ox-hugo blog publishing (=C-; h=) |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-drill-config.el= | 4 | Spaced repetition, its own refile targets |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-noter-config.el= | 4 | PDF and EPUB annotation |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-reveal-config.el= | 4 | reveal.js presentations (=C-; p=) |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-webclipper.el= | 4 | org-protocol web clipping into roam |
+|------------------------------+-------+---------------------------------------------------------|
+| =quick-video-capture.el= | 4 | Video download via an org-protocol bookmark |
+|------------------------------+-------+---------------------------------------------------------|
+
+* Shared variables and who writes to them
+
+This is the part grep answers slowly and a table answers fast. Each of these
+variables is appended to by more than one module.
+
+** =org-capture-templates= — four writers
+
+=org-capture-config.el= establishes the base set. =org-contacts-config.el=,
+=org-webclipper.el=, and =quick-video-capture.el= each append their own. Dispatch
+keys must not collide across all four, which is why
+=tests/test-org-capture-templates-integrity.el= exists: it loads the cleanly
+loadable capture modules, applies their additions, and asserts no two templates
+share a key.
+
+That test is the enforcement mechanism for a boundary no single module can see.
+
+** =org-agenda-files= — four writers
+
+=org-agenda-config.el= sets the base list. =org-roam-config.el= adds roam files.
+=config-utilities.el= manipulates it, and =org-agenda-config-debug.el= reads it for
+diagnostics.
+
+** org-protocol handlers — five registrars
+
+=org-config.el=, =org-capture-config.el=, =org-drill-config.el=,
+=org-webclipper.el=, and =quick-video-capture.el= all touch org-protocol. The
+handlers are keyed by protocol name, so collisions are possible in principle and
+nothing checks for them today.
+
+** =org-refile-targets= — two writers
+
+=org-refile-config.el= owns the general targets. =org-drill-config.el= adds its
+own, because drill files are refile destinations but not agenda files.
+
+* Load order
+
+=init.el= loads the Org stack at lines 130 through 145, in this order: base config,
+faces, agenda, babel, capture, contacts, drill, export, hugo, reveal, refile, roam,
+spec-links, webclipper, noter.
+
+The order is deliberate in two places. =org-config= precedes everything because it
+establishes the keymap the rest bind into. =org-faces-config= precedes
+=org-agenda-config= because the agenda renders with those faces.
+
+One module sits outside that block. =quick-video-capture.el= loads at line 106,
+twenty-four lines before =org-config=, because it is grouped with the media modules
+rather than the Org modules. It nonetheless appends to =org-capture-templates=. It
+works because the append is a plain =add-to-list= against a variable org-mode has
+not yet claimed, but the grouping means a reader looking for "who adds capture
+templates" in the Org block will not find it.
+
+* Load shape
+
+Every Org module declares =Load shape: eager=. Nothing in the Org stack defers.
+
+This is the single largest fact about the Org workflow's startup cost, and it is
+worth stating plainly rather than leaving implicit across sixteen headers. Layer-4
+modules (drill, noter, reveal, webclipper, video capture) are optional features by
+their own classification, and all five load eagerly on every launch.
+
+Whether that matters is a measurement question, not an argument, and this note does
+not make it. It records the fact so the question can be asked.
+
+* Observations for follow-up
+
+These turned up while mapping. None is fixed here.
+
+1. =modules/org-config.el.faces.bak= is tracked in git (25KB). It appears to be a
+ leftover from the 2026-06-14 face-stripping work. A =.bak= file in =modules/=
+ is not loaded, but it is on the load-path's directory and will confuse the next
+ reader.
+2. =org-agenda-config-debug.el= carries no =Layer= / =Category= / =Load shape=
+ header, unlike every other module. It loads conditionally from
+ =org-agenda-config.el:42= when =cj/debug-modules= names it, so its absence from
+ =init.el= is by design, not an orphan.
+3. Nothing checks org-protocol handler collisions the way the capture-template test
+ checks key collisions. Five modules register handlers.
+4. Every Layer-4 Org module loads eagerly despite being classified optional.
diff --git a/docs/design/signal-client-review.org b/docs/design/signal-client-review.org
index 7e8a73e9..e1ac462e 100644
--- a/docs/design/signal-client-review.org
+++ b/docs/design/signal-client-review.org
@@ -5,7 +5,7 @@
* Scope reviewed
- =.ai/workflows/spec-review.org=.
-- =docs/specs/signal-client-spec-doing.org=, including the base design, open-question dispositions, initiate-message workflow, architecture additions, accepted caveats, test plan, scope summary, and readiness rubric.
+- =docs/specs/signal-client-spec.org=, including the base design, open-question dispositions, initiate-message workflow, architecture additions, accepted caveats, test plan, scope summary, and readiness rubric.
- =modules/signal-config.el=, including =cj/signal--parse-contacts=, notify-suppression helpers, private config loading, and current =use-package signel= wiring.
- =~/code/signel/signel.el=, including =signel-start=, =signel--send-rpc=, =signel--dispatch=, =signel--handle-error=, =signel--handle-receive=, =signel--insert-msg=, =signel--insert-system-msg=, =signel--send-input=, =signel-chat=, and dashboard commands.
- =tests/test-signal-config.el=, covering contact parsing and notify-suppression helpers.
diff --git a/docs/subr-mock-migration-spec.org b/docs/design/subr-mock-migration.org
index 26f1dd57..874d03ef 100644
--- a/docs/subr-mock-migration-spec.org
+++ b/docs/design/subr-mock-migration.org
@@ -10,7 +10,7 @@ primitives (native-comp robustness) :test:refactor:solo:=) so the scope and
approach can be settled before any code moves. Execution is deferred; this
document is the discussion vehicle.
-Companion reference: [[file:native-comp-subr-mocking.org][native-comp-subr-mocking.org]] holds the full mechanism, the
+Companion reference: [[file:../native-comp-subr-mocking.org][native-comp-subr-mocking.org]] holds the full mechanism, the
upstream research, and the 2026-06-21 decision. This spec does not restate the
mechanism; it plans the remaining work that decision deferred.
diff --git a/docs/design/utility-inventory.org b/docs/design/utility-inventory.org
index 8438a592..9b811429 100644
--- a/docs/design/utility-inventory.org
+++ b/docs/design/utility-inventory.org
@@ -4,7 +4,7 @@
* Status
-Living inventory. Phase 1 of [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]]. Records the current state of helpers identified in the spec's Candidate Extraction Table plus any new candidates discovered during module walkthroughs. Decisions become concrete tasks in =todo.org= for Phase 2+.
+Living inventory. Phase 1 of [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec.org]]. Records the current state of helpers identified in the spec's Candidate Extraction Table plus any new candidates discovered during module walkthroughs. Decisions become concrete tasks in =todo.org= for Phase 2+.
* Scope
@@ -82,7 +82,7 @@ Caller counts in the inventory below reflect grep results from 2026-05-10. The c
| Symbol | File | Vis | Deps | Side effects | Proposed home / name | Callers (modules) | Tests | Pri | Decision | Rationale |
|--------+------+-----+------+--------------+----------------------+-------------------+-------+-----+----------+-----------|
-| =cj/modeline-vc-cache-*= helpers (key/get/put/clear/valid-p) | =modeline-config.el:108-140= | private | buffer-local vars | mutates buffer-local state | =cj-cache.el= / =cj/cache-valid-p=, =cj/cache-get=, =cj/cache-put=, =cj/cache-clear= | 1 (within file) | =test-modeline-config-vc-cache.el= | Medium | Defer | Good pattern, but variable-local cache shape differs from the agenda/refile caches. Needs design before extraction. Spec calls out a Phase 5 design addendum at =docs/specs/cache-helper-design-spec-implemented.org=. |
+| =cj/modeline-vc-cache-*= helpers (key/get/put/clear/valid-p) | =modeline-config.el:108-140= | private | buffer-local vars | mutates buffer-local state | =cj-cache.el= / =cj/cache-valid-p=, =cj/cache-get=, =cj/cache-put=, =cj/cache-clear= | 1 (within file) | =test-modeline-config-vc-cache.el= | Medium | Defer | Good pattern, but variable-local cache shape differs from the agenda/refile caches. Needs design before extraction. Spec calls out a Phase 5 design addendum at =docs/specs/cache-helper-design-spec.org=. |
| agenda/refile cache vars and build flags | =org-agenda-config.el=, =org-refile-config.el= | n/a | timers, file scans | scans filesystem, sets vars | =cj-cache.el= / =cj/cache-value-or-rebuild= | 2 | none | Medium | Defer | TTL/build/invalidate lifecycle; higher risk than the modeline cache. Same Phase 5 work. |
** Logging / Warnings
@@ -144,7 +144,7 @@ These become =todo.org= entries (or update existing ones) as Phase 2 starts.
** Deferred (track in =todo.org= but no commit yet)
-- Cache abstraction (modeline + agenda/refile) -- needs Phase 5 design addendum at =docs/specs/cache-helper-design-spec-implemented.org=.
+- Cache abstraction (modeline + agenda/refile) -- needs Phase 5 design addendum at =docs/specs/cache-helper-design-spec.org=.
- =cj/--open-with-is-launcher-p= -- move when external-open ownership is finalized.
- =cj/log-silently= rename -- low value; do during incidental =system-lib= work.
- HTML/text helpers (=strip-html=, =clean-text=) -- defer until a second consumer.
diff --git a/docs/specs/2026-07-06-fancy-music-player-ui-spec.org b/docs/specs/2026-07-06-fancy-music-player-ui-spec.org
new file mode 100644
index 00000000..832a9dfa
--- /dev/null
+++ b/docs/specs/2026-07-06-fancy-music-player-ui-spec.org
@@ -0,0 +1,203 @@
+#+TITLE: Fancy music-player UI (hi-fi / vinyl) — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-06
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Fancy music-player UI
+:PROPERTIES:
+:ID: af4f2688-ce7d-43f5-82e5-595a603e2593
+:END:
+- 2026-07-09 Thu @ 17:50:32 -0500 — Craig ran the Phase-3 visual VERIFY and found no issues: hero cover art, serif title, amber accents, advancing bar, on-air marker, and the plain-text fallback all pass. All three phases built and pushed. DOING -> IMPLEMENTED.
+- 2026-07-06 Mon @ 15:14:06 -0500 — spec-response Phase 6: decomposed into 3 build tasks + flip-to-IMPLEMENTED under the todo.org parent (stamped :SPEC_ID: af4f2688), Phase-3 visual VERIFY filed. READY -> DOING.
+- 2026-07-06 Mon @ 15:14:06 -0500 — spec-response: all 7 findings dispositioned (5 accept, 2 modify, 0 reject), Review findings [7/7]. Both blockers resolved and folded in. Decisions [5/5], no blocking open → Ready. DRAFT -> READY.
+- 2026-07-06 Mon @ 15:14:06 -0500 — spec-review: Not ready. Decisions [5/5] but Review findings [0/7], two :blocking: (progress-bar data source undefined; row rendering overloads cj/music--track-description). Stays DRAFT until dispositioned.
+- 2026-07-06 Mon @ 15:03:57 -0500 — Craig accepted all five recommended decisions; Decisions [5/5]. Still DRAFT, ready for spec-review.
+- 2026-07-06 Mon @ 14:38:15 -0500 — drafted.
+
+* Metadata
+| Status | implemented |
+|----------+-------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+-------------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+-------------------------------------------------------------------|
+| Related | [[file:2026-07-06-fancy-music-player-ui.prototype.html][prototype (fancy direction)]]; modules/music-config.el |
+|----------+-------------------------------------------------------------------|
+
+* Summary
+
+Turn the EMMS playlist buffer from a raw-URL text list into a hi-fi "fancy" reading surface: station and track names instead of stream URLs, cover art, a now-playing hero with a serif title and a progress bar, and a warm amber palette, all still inside the Emacs buffer. It degrades cleanly to a plain text version in a terminal frame, where images can't render.
+
+* Problem / Context
+
+The music player is an EMMS playlist buffer with a custom multi-line header (Playlist / Current / Mode / Keys / Radio) drawn by cj/music--header-text as an overlay. The track list shows whatever EMMS stores as the track name, which for internet radio is the raw stream URL (https://ice6.somafm.com/groovesalad-256-mp3). It reads as a debug buffer, not a music player. Craig chose the "fancy" direction from the prototype: cover art, serif titles, a now-playing hero, warm amber.
+
+Two facts shape the design. First, Emacs renders images and variable-pitch faces in a GUI frame but not in a TTY, so the fancy look needs a text fallback. Second, cover art is reachable: radio-browser stations carry a favicon (logo) URL, retrievable at search time from the station plist or later via /json/stations/byuuid/<uuid> (verified live 2026-07-06); local files carry embedded album art. Stations with neither get a generated placeholder.
+
+* Goals and Non-Goals
+
+** Goals
+- Show human names, not URLs: station name for streams (from the .m3u #EXTINF or a radio-browser lookup), artist and title for local files.
+- A now-playing hero: cover art, a serif title, a subtitle (station/album), a progress bar, and a time or "on air" line.
+- A track list with small cover thumbnails, serif names, and right-aligned meta; the current row accented.
+- A warm amber palette, theme-owned (the dupre theme).
+- Degrade to a plain text version (names + a dim glyph + a thin bar) in a TTY or when the fancy view is off.
+
+** Non-Goals
+- No separate EMMS browser pane, library manager, or tag editor.
+- No mouse-first chrome, animated equalizers, or transport buttons you click (keys stay the interface).
+- No change to the radio lookup, playback engine, or keybindings (the n/t/m radio row and the transport keys are untouched).
+- No streaming-service integrations.
+
+** Scope tiers
+- v1: name resolution + the text base (TTY-safe), cover-art fetch + cache, and the fancy GUI render layered on top.
+- Out of scope: album/library browsing, playlists-of-playlists UI, per-track ratings.
+- vNext (log to todo.org): a scrubbable/seekable progress bar, homepage/link affordances in the hero, animated now-playing transitions, per-station color theming from the cover art.
+
+* Design
+
+At a caller's altitude: Craig opens the music player as today (same keys). Instead of URLs he sees named rows with a small logo each; the top of the buffer is a now-playing panel with the current station's cover, its name in a serif face, and a progress bar. In a terminal frame or with the fancy view disabled, the same buffer shows the plain text version (names, a dim glyph, a thin bar) with no images.
+
+At the implementer's altitude, three layers, each shippable on its own:
+
+1. Name resolution (pure + text, TTY-safe). A pure cj/music--display-name maps a track to its name only: for a url track, the #EXTINF label from its .m3u if present, else the station name resolved from radio-browser, else a tidied host; for a file track, artist and title from EMMS track info. This helper is shared — the header's Current line calls it directly, and so does the row renderer — so the two never drift and the glyph/meta never leak into the header. Row rendering is a separate seam: emms-track-description-function composes cj/music--display-name + a dim nerd-icon glyph (broadcast for streams, note for files) + right-aligned meta. The glyph is guarded on nerd-icons being available with a Nerd Font present (music-config.el gains the require/guard — it doesn't reference nerd-icons today), degrading to a plain marker or none otherwise. The meta is right-aligned with a display :align-to spec so it stays aligned across window resizes, not a pad-to-column computed once at insert time. The now-playing line under Current gets a thin faces-drawn bar plus a time / "on air" string. This is the plain text base and the fallback; it has no images.
+
+2. Cover art (image infra). cj/music-art--for-track returns a cached local image path for a track: a station favicon (from the station plist at creation time, or a byuuid lookup for an existing .m3u carrying #RADIOBROWSERUUID), embedded album art for a file, or the vinyl placeholder when neither exists. An empty favicon field counts as no art, and a fetched response is cached only after it validates as a displayable image (a create-image / image-type-available-p probe); a non-image or unsupported-format response (an .ico Emacs can't show, an HTML error page served 200) falls to the placeholder, a distinct outcome from a failed fetch. The placeholder is a single shipped static SVG asset under assets/ that scales cleanly at hero and thumbnail sizes on any DPI — not procedurally generated per station (per-station tinting is vNext); on an Emacs without svg support it degrades to no image. Fetches are asynchronous and written to an on-disk cache keyed by station UUID or file, so a redisplay never blocks on the network and art survives restarts.
+
+3. Fancy render (GUI). When the frame is graphical and the fancy view is on, cj/music--header-text and the row renderer insert the cover images (create-image / insert-image), remap the title to a serif variable-pitch face, and paint the amber palette and the segmented progress bar. A small timer advances the bar during playback. When the frame is a TTY or the fancy view is off, the render falls through to layer 1's text.
+
+Pure pieces (name resolution, the m3u-label extraction, the bar-fill computation, the placeholder-vs-art decision) carry the tests; the image insertion, the fetch, and the timer are exercised live.
+
+* Alternatives Considered
+
+** Layer the fancy view on top of a text base (chosen)
+- Good, because the text base ships value immediately (names beat URLs for everyone), is the honest TTY fallback, and de-risks the image work.
+- Bad, because two render paths to keep in sync.
+- Neutral, because EMMS already separates track-name from display.
+
+** Fancy-only, no text fallback
+- Good, because one render path.
+- Bad, because it breaks in a TTY and forces the image/art work before any value lands.
+
+** A separate dedicated buffer/major-mode instead of the EMMS playlist overlay
+- Good, because full control of layout.
+- Bad, because it abandons EMMS's playlist machinery (marking, reorder, the existing keymap) and doubles maintenance.
+- Neutral, deferred: the overlay approach reuses everything and is enough for v1.
+
+* Decisions [5/5]
+
+** DONE Cover art for streams — where the favicon comes from
+- Owner / by-when: Craig / before Phase 2
+- Context: a radio-browser search result carries a favicon URL, so a newly-created station can capture it. An existing .m3u only has the stream URL and (on 15 of 73) a #RADIOBROWSERUUID; the other 58 have no UUID. A byuuid lookup gets the favicon for the ones that have a UUID.
+- Decision: We will (a) write a #RADIOBROWSERFAVICON line into search-created stations at creation (the radio-browser result carries the favicon) so they need no lookup, (b) for an existing station carrying #RADIOBROWSERUUID, fetch the favicon by UUID once and cache it, and (c) for a station with neither — including manually-created stations (the m row and cj/music-create-radio-station), which have no favicon and no UUID — use the vinyl placeholder.
+- Consequences: easier — most stations get real art with at most one lookup; harder — a small .m3u format addition (an extra comment line) and a byuuid path for legacy files. Manually-entered stations always show the placeholder, which is acceptable.
+
+** DONE Image cache location and refresh
+- Owner / by-when: Craig / before Phase 2
+- Context: fetched favicons and extracted album art need to persist so redisplay is instant and offline-safe.
+- Decision: We will cache under data/music-art/ (gitignored runtime state), keyed by station UUID or a file hash, fetched once and reused; a manual command clears the cache, and there is no automatic TTL.
+- Consequences: easier — instant, offline, no invalidation logic; harder — a stale logo persists until the user clears the cache (acceptable for logos).
+
+** DONE Serif title face
+- Owner / by-when: Craig / before Phase 3
+- Context: the fancy titles want a serif variable-pitch face, theme-owned.
+- Decision: We will add a defcustom for the title family defaulting to the shared Reading font profile's serif (currently "Merriweather") and a theme-owned face the dupre theme colors.
+- Consequences: easier — one knob, consistent with the nov-reading typography choice; harder — another face to register in theme-studio if we want it tunable there (deferred).
+
+** DONE Progress bar rendering and cadence
+- Owner / by-when: Craig / before Phase 3
+- Context: the bar can be drawn with block-character faces or a small SVG; it advances during playback via a timer.
+- Decision: We will draw the bar with faces (block characters, accent-colored fill) rather than SVG for v1, and redraw on a ~1s timer only while a track is playing, only when the player buffer is visible. The bar's data is per track type: a stream has no duration, so it renders indeterminate (the existing "on air" line, no fill); a local file renders elapsed/total, where total is info-playing-time (already read at music-config.el:807) and elapsed comes from an mpv IPC get_property round-trip on percent-pos — cj/music--mpv-command (currently send-only) is extended to read its reply, or a small cj/music--mpv-get-property helper is added. v1 does not re-enable EMMS's own playing-time timer (deliberately disabled at music-config.el:941); mpv is the single position source.
+- Consequences: easier — no SVG dependency, works the moment faces do, and mpv already runs the socket the seek commands use; harder — a character-cell bar is coarser than an SVG one (fine for v1; SVG is a vNext upgrade), and the file bar needs a reply-reading round-trip the seek path never needed. mpv's get_property over the JSON IPC socket is a documented, stable command.
+
+** DONE Fancy view toggle and TTY fallback
+- Owner / by-when: Craig / before Phase 3
+- Context: images need a GUI frame; a TTY (or a user who wants plain) needs the text version.
+- Decision: We will gate the fancy render on (display-graphic-p) AND a defcustom (default on), falling through to the layer-1 text render otherwise, decided per-redisplay so a frame on a TTY and a frame on a GUI can differ live.
+- Consequences: easier — never broken in a TTY, user can opt out; harder — both render paths stay maintained (already a chosen tradeoff).
+
+* Review findings [7/7]
+
+** DONE Progress-bar data source is undefined :blocking:
+Decision 4 picks the bar's rendering (block-char faces) and cadence (~1s timer), but never its input: where elapsed and total come from per track type. Two facts from the code make this a gap the implementer would have to invent. First, a radio stream has no duration — the bar is indeterminate, and the header already shows "on air" for it. Second, for a local file the elapsed position is not available today: emms-playing-time-display-mode is disabled (music-config.el:941), and cj/music--mpv-command (music-config.el:168) is send-only — it writes to the mpv IPC socket and discards the reply, so nothing reads back time-pos. The total duration is reachable (info-playing-time, already used at music-config.el:807), but the moving position is not.
+MODIFY — folded into Decision 4. Accepted the gap; narrowed the resolution: v1 uses mpv's JSON IPC get_property on percent-pos (simplest — 0..100 directly) as the single position source for local files, with cj/music--mpv-command extended to read its reply; a stream renders indeterminate ("on air", no fill). Deliberately did NOT re-enable EMMS's own playing-time timer (it was disabled on purpose), keeping mpv the one source rather than reviving parallel machinery.
+
+** DONE Row rendering overloads cj/music--track-description :blocking:
+The Design named the row renderer as cj/music--track-description, but that function is also the source of the header's now-playing line (music-config.el:843) and is EMMS's emms-track-description-function (music-config.el:952), so EMMS calls it for the mode line and elsewhere. Overloading it with glyph + meta + image leaks those into the header and every other consumer.
+ACCEPT — folded into Design layer 1 and Phase 1. A pure cj/music--display-name (name only) is now the shared seam the header's Current line and the row renderer both call; only the row renderer (emms-track-description-function) adds the glyph, :align-to meta, and Phase-3 image, so the header stays clean.
+
+** DONE "#RADIOBROWSERFAVICON at creation" only covers search-created stations
+Decision 1(a) wrote a favicon line into "new stations at creation," but only the radio-browser search path (cj/music-radio--station-m3u, music-config.el:1078) has a favicon in hand. The manual creators — the m row and cj/music-create-radio-station (music-config.el:1014) — take just a name and URL and have none.
+ACCEPT — folded into Decision 1. Scoped clause (a) to search-created stations and stated that manually-created stations (no favicon, no UUID) show the vinyl placeholder.
+
+** DONE Fetched favicon may be empty, non-image, or an unsupported format
+radio-browser's favicon field is frequently the empty string, and when present can be an .ico, .svg, .gif, an HTML error page served 200, or an oversized image. Placeholder-on-fetch-failure covers a failed request but not a successful fetch that isn't a usable image.
+ACCEPT — folded into Design layer 2, Phase 2, and acceptance. Empty favicon counts as no art; a fetched response is cached only after it validates as a displayable image (create-image / image-type-available-p probe), else placeholder — a distinct outcome from a failed fetch. The decision helper's tests cover empty and non-image inputs.
+
+** DONE Right-aligned meta goes stale on window resize
+emms-track-description-function runs at track-insert time, not on redisplay, so a meta column computed from window width at insert is wrong after the playlist window resizes (F10 docks it right or bottom at varying widths — music-config.el:659).
+ACCEPT — folded into Design layer 1 and Phase 1. The meta is right-aligned with a display :align-to spec, recomputed by redisplay, rather than a pad-to-column measured once.
+
+** DONE Vinyl placeholder generation mechanism unnamed
+The spec called the placeholder "generated" without saying how — SVG (needs svg support) or a shipped static asset.
+MODIFY — folded into Design layer 2 and Phase 2. Named it and narrowed scope: v1 ships a single static vinyl-placeholder SVG under assets/ (scales at hero and thumbnail sizes, degrades to no image where svg is unavailable), NOT procedurally generated per station. Per-station tinted generation is explicitly vNext — that's the "generated" ambition the original word implied, deferred.
+
+** DONE nerd-icons is not required in music-config.el
+The Reuse dimension claimed the row glyphs reuse nerd-icons "already used by dashboard/dirvish," but music-config.el neither requires nor references it today (it loads only transitively).
+ACCEPT — folded into Design layer 1 and Phase 1. Phase 1 adds the require/availability guard; the glyph needs a Nerd Font in the GUI and degrades to a plain marker or none otherwise, so a row never renders a tofu box.
+
+* Implementation phases
+
+** Phase 1 — Name resolution + text base (TTY-safe, no images)
+Extract a pure cj/music--display-name (url -> #EXTINF label / radio-browser name / tidy host; file -> artist and title) and point the header's Current line at it. Add the row renderer on emms-track-description-function: display-name + dim glyph (nerd-icons require/guard, TTY fallback) + :align-to right-aligned meta. Add the thin now-playing bar + time/on-air line and the header spacing + rule. Unit-test the pure name/label/bar-fill helpers. Ships the "names not URLs" win on its own and is the fallback for later phases.
+
+** Phase 2 — Cover art fetch + cache (image infra)
+cj/music-art--for-track returning a cached local image path: favicon capture at search-station creation (a #RADIOBROWSERFAVICON line), byuuid favicon fetch for legacy UUID stations, embedded album art for files, and the shipped vinyl-placeholder SVG when neither resolves. Validate a fetched response is a displayable image before caching (empty / non-image / unsupported-format -> placeholder). Async fetch into data/music-art/. Unit-test the art-vs-placeholder decision (including empty and non-image inputs) and the cache-key logic; the fetch is a smoke test.
+
+** Phase 3 — Fancy GUI render
+The now-playing hero (cover image + serif title + subtitle + segmented bar) and the thumbnailed serif list with the amber palette, gated on (display-graphic-p) + the toggle, degrading to Phase 1's text. The ~1s playback timer. Live verification (images, faces, the bar advancing) since it can't run headless.
+
+* Acceptance criteria
+- [ ] A radio row shows the station name, not the stream URL; a local track shows artist and title.
+- [ ] Each row shows a dim glyph (broadcast for streams, note for files) and right-aligned meta.
+- [ ] The header's Current line and the playlist rows both derive their name from the shared cj/music--display-name; the header line carries no row glyph or meta.
+- [ ] In a GUI frame with the fancy view on, the now-playing hero shows the current station's cover art, a serif title, and a progress bar. For a local file the bar advances from mpv's reported position; for a stream it shows "on air" with no fill.
+- [ ] A station with no favicon or embedded art shows the vinyl placeholder, not a broken image; a fetched favicon that is empty, non-image, or an unsupported format also yields the placeholder and is not cached as art.
+- [ ] In a TTY frame, or with the fancy view off, the same buffer renders the text version (names + glyph + thin bar) with no images and no errors.
+- [ ] Art is fetched once and cached under data/music-art/; a second open is instant and works offline.
+- [ ] The name/label, bar-fill, and art-decision helpers have Normal/Boundary/Error tests.
+
+* Readiness dimensions
+- Data model & ownership: display names are derived (never stored); .m3u files gain an optional #RADIOBROWSERFAVICON comment (Craig-owned, written at creation); cached art under data/music-art/ is generated and disposable.
+- Errors, empty states & failure: a fetch failure or missing art yields the placeholder, never a broken image; a TTY yields text; an empty playlist yields the header with no rows.
+- Security & privacy: fetching a station's favicon URL hits a third-party host the user chose; fetch with a timeout and cache locally; no credentials; nothing sensitive logged.
+- Observability: the player messages when it can't fetch art (once, quietly); a clear-art-cache command exists.
+- Performance & scale: art fetch is async and cached, never blocking redisplay; the bar timer runs ~1s only while playing and only when the buffer is visible; the list is small (a playlist), so per-row images are cheap.
+- Reuse & lost opportunities: reuses the EMMS playlist buffer + keymap, the existing header overlay (cj/music--header-text / update-header), cj/music--track-description, nerd-icons (already used by dashboard/dirvish), the dupre theme, and the nov-reading serif choice. Images via built-in create-image/insert-image. Nothing new is invented that Emacs already provides.
+- Architecture fit & weak points: layers on music-config.el's existing header/render path; the seam is the header overlay + a per-row renderer. Weak point: keeping the text and fancy render paths in sync — mitigated by making the text render the base and the fancy render an image/face overlay on the same rows.
+- Config surface: cj/music-fancy-ui (default on), the serif title face + family, the amber palette (theme-owned), the art cache dir, the bar redraw interval. All with defaults and doc.
+- Documentation plan: a note in the module commentary and the keybinding/header list; no separate doc.
+- Dev tooling: existing make test / test-file; no new tooling.
+- Rollout, compatibility & rollback: additive; cj/music-fancy-ui off restores the plain text render; the #RADIOBROWSERFAVICON line is an ignorable comment in older readers; deleting data/music-art/ is a safe reset.
+- External APIs & deps: radio-browser favicon field and /json/stations/byuuid/<uuid> VERIFIED live 2026-07-06. Local album-art extraction depends on an image being embedded (or a folder cover.jpg) — the fallback is the placeholder.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Local album-art extraction is the likeliest rabbit hole (embedded art via a tag reader vs a sibling cover.jpg). v1 can start with sibling-cover-file + placeholder and defer embedded-tag extraction if it balloons.
+- Image sizing across frame DPIs: pick a fixed pixel size for the hero and thumbnails, scaled by the frame, and don't chase per-monitor perfection.
+- Two render paths drifting: the text base must stay the source of truth for row content; the fancy path only adds images/faces, never different text.
+
+* References / Appendix
+- Prototype (open in a browser): [[file:2026-07-06-fancy-music-player-ui.prototype.html][2026-07-06-fancy-music-player-ui.prototype.html]] — three directions (minimal / fancy / modern); this spec builds the fancy one, with minimal as the text fallback.
+- radio-browser favicon + byuuid verified live 2026-07-06 against de1.api.radio-browser.info.
+
+* Review and iteration history
+** 2026-07-06 Mon @ 15:14:06 -0500 — Claude Code (emacs-d) — responder
+- What: dispositioned all 7 review findings — 5 accept, 2 modify, 0 reject; Review findings [7/7]. Modifies: (1) the progress-bar data source narrowed to mpv percent-pos over the existing IPC socket for files + indeterminate "on air" for streams, deliberately not reviving EMMS's disabled playing-time timer; (6) the placeholder named as a single shipped static vinyl SVG under assets/, with per-station tinted generation pushed to vNext. Accepts folded into Design layers 1-2, Decisions 1/3/4, Phases 1-2, and acceptance criteria: the pure cj/music--display-name split so the header never gets row glyph/meta, :align-to meta, fetched-image validation, favicon-at-creation scoped to search stations, the nerd-icons guard, and Decision 3's serif pinned to Merriweather. Flipped DRAFT -> READY (keyword + history + Metadata mirror).
+- Why: two findings were true blockers that would have forced the implementer to invent behavior; the rest tightened scope and named mechanisms. The modifies avoid re-introducing machinery that was turned off on purpose (the EMMS timer) and defer the expensive half of "generated" art.
+- Artifacts: Review findings [7/7] in this spec; scope-expansion check — mpv get_property percent-pos is a documented IPC command on an already-open socket, the SVG asset uses built-in svg on the GUI-only path, so neither adds an unverified dependency needing a new finding.
+
+** 2026-07-06 Mon @ 15:14:06 -0500 — Claude Code (emacs-d) — reviewer
+- What: first review pass. Read the render path in modules/music-config.el (cj/music--track-description, cj/music--header-text/update-header, the emms use-package config, the radio client) before critiquing. Rubric: Not ready — recorded Review findings [0/7], two :blocking:. The three implementation phases are present and cleanly decomposable (each reaches a clean stopping point; no broken intermediate state). Confirmed the concrete default for Decision 3's serif is cj/nov-reading-font-family = "Merriweather" (nov-reading.el:197), and that data/ is gitignored (so data/music-art/ under Decision 2 is covered).
+- Why: two gaps would force the implementer to invent product behavior. The progress bar has a chosen rendering and cadence but no data source — a stream has no duration, and for a local file the elapsed position isn't wired (playing-time display disabled, the mpv IPC helper is send-only). And the named row-render seam, cj/music--track-description, is shared by the header's Current line and EMMS internals, so overloading it with glyph/meta/image corrupts them. Five non-blocking findings tighten the favicon-at-creation scope, fetched-image validation, resize-safe meta alignment, the placeholder mechanism, and the nerd-icons dependency.
+- Artifacts: Review findings [0/7] in this spec; source checks at music-config.el:168/807/843/941/952/1014/1078 and nov-reading.el:197.
diff --git a/docs/specs/2026-07-06-fancy-music-player-ui.prototype.html b/docs/specs/2026-07-06-fancy-music-player-ui.prototype.html
new file mode 100644
index 00000000..8b7ddd21
--- /dev/null
+++ b/docs/specs/2026-07-06-fancy-music-player-ui.prototype.html
@@ -0,0 +1,207 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Music player UI — three directions</title>
+<style>
+ :root {
+ --page-bg: #f3f0ea; --page-fg: #29261f; --page-dim: #6b6459; --card-line: #ded8cc; --accent: #9a6b2f;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root { --page-bg: #131210; --page-fg: #d6cfc0; --page-dim: #8a8272; --card-line: #2b271f; --accent: #d8a24f; }
+ }
+ :root[data-theme="light"] { --page-bg: #f3f0ea; --page-fg: #29261f; --page-dim: #6b6459; --card-line: #ded8cc; --accent: #9a6b2f; }
+ :root[data-theme="dark"] { --page-bg: #131210; --page-fg: #d6cfc0; --page-dim: #8a8272; --card-line: #2b271f; --accent: #d8a24f; }
+
+ * { box-sizing: border-box; }
+ body { margin: 0; background: var(--page-bg); color: var(--page-fg);
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; line-height: 1.55;
+ padding: 3rem 1.25rem 5rem; }
+ .wrap { max-width: 64rem; margin: 0 auto; }
+ .eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: .72rem; color: var(--accent); font-weight: 600; margin: 0 0 .5rem; }
+ h1 { font-size: 1.85rem; margin: 0 0 .6rem; text-wrap: balance; font-weight: 650; }
+ header p { margin: .3rem 0; max-width: 64ch; color: var(--page-dim); }
+ header p strong { color: var(--page-fg); font-weight: 600; }
+
+ .dir { margin: 2.6rem 0 0; }
+ .dir > .head { display: flex; align-items: baseline; gap: .8rem; flex-wrap: wrap; margin-bottom: .7rem; }
+ .dir .n { font-variant-numeric: tabular-nums; color: var(--accent); font-weight: 700; }
+ .dir h2 { font-size: 1.15rem; margin: 0; font-weight: 640; }
+ .dir .sub { color: var(--page-dim); font-size: .92rem; }
+ .feas { margin: .55rem 0 0; font-size: .86rem; color: var(--page-dim); }
+ .feas b { color: var(--page-fg); font-weight: 600; }
+ .cost { display: inline-block; font-size: .72rem; letter-spacing: .04em; text-transform: uppercase;
+ border: 1px solid var(--card-line); border-radius: 999px; padding: .06rem .55rem; margin-right: .4rem; color: var(--page-dim); }
+
+ /* shared editor frame */
+ .frame { border-radius: 9px; overflow: hidden; box-shadow: 0 16px 40px -22px rgba(0,0,0,.65); border: 1px solid #0000; }
+ .mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
+
+ /* ---------- CURRENT (reference) ---------- */
+ .cur-ref { background: #15140f; color: #b9b1a1; border-color: #322d25; font-size: 12.5px; }
+ .cur-ref .buf { padding: .6rem 0; }
+ .cur-ref .r { white-space: pre; padding: .05rem 1rem; }
+ .cur-ref .lbl { color: #8a8170; }
+ .cur-ref .play { color: #e6c98a; font-weight: 700; }
+
+ /* ---------- 1 · MINIMAL ---------- */
+ .min { background: #15140f; color: #cfc8b8; border-color: #322d25; font-size: 13px; }
+ .min .buf { padding: .7rem 0 .8rem; }
+ .min .r { white-space: pre; padding: .07rem 1.1rem; }
+ .min .lbl { color: #9a917f; }
+ .min .dim { color: #6f685b; }
+ .min .on { color: #e6c98a; } .min .off { color: #4a453b; }
+ .min .keys { color: #6f685b; } .min .keys .k { color: #9a917f; }
+ .min .rule { color: #4a453b; padding: .12rem 1.1rem .28rem; white-space: pre; }
+ .min .bar-full { color: #8faf7f; } .min .bar-empty { color: #43403a; } .min .time { color: #6f685b; }
+ .min .track { display: grid; grid-template-columns: 1.4ch 1fr auto; gap: .7ch; align-items: baseline; padding: .13rem 1.1rem; }
+ .min .g-radio { color: #8fb0c4; } .min .g-note { color: #a7b58f; }
+ .min .track .name { color: #cfc8b8; } .min .track .meta { color: #6f685b; white-space: pre; }
+ .min .track.cur { background: #262119; } .min .track.cur .name { color: #e6c98a; font-weight: 700; }
+
+ /* ---------- 2 · FANCY (hi-fi / vinyl) ---------- */
+ .fan { background: radial-gradient(120% 90% at 12% 0%, #241a10 0%, #17110a 55%, #120d07 100%); color: #e8d9bd; border-color: #3c2c17; }
+ .fan .top { display: flex; gap: 1rem; padding: 1.1rem 1.2rem 1rem; align-items: center; border-bottom: 1px solid #3a2b18; }
+ .fan .cover { width: 78px; height: 78px; border-radius: 6px; flex: none; position: relative;
+ background: radial-gradient(circle at 50% 50%, #3a2c18 0 22%, #1a130b 23% 26%, #2a2012 27% 46%, #17110a 47% 50%, #2a2012 51%);
+ box-shadow: inset 0 0 0 1px #4a3820, 0 6px 16px -8px #000; }
+ .fan .cover::after { content: ""; position: absolute; inset: 45% 45% auto auto; width: 8px; height: 8px; border-radius: 50%;
+ background: #e6c98a; box-shadow: 0 0 0 3px #17110a; top: 46%; left: 46%; }
+ .fan .np-title { font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif; font-size: 1.5rem; font-weight: 600; color: #f2e4c6; line-height: 1.15; }
+ .fan .np-sub { color: #b99a63; font-size: .9rem; margin-top: .15rem; letter-spacing: .02em; }
+ .fan .seg { display: flex; gap: 3px; margin-top: .7rem; }
+ .fan .seg i { height: 6px; flex: 1; border-radius: 2px; background: #3a2c18; }
+ .fan .seg i.on { background: linear-gradient(#f0cf8a, #d8a24f); }
+ .fan .tline { display: flex; justify-content: space-between; color: #a98c58; font-size: .74rem; margin-top: .3rem; font-variant-numeric: tabular-nums; }
+ .fan .list { padding: .55rem .4rem .7rem; }
+ .fan .t { display: grid; grid-template-columns: 30px 1fr auto; gap: .7rem; align-items: center; padding: .34rem .8rem; border-radius: 7px; }
+ .fan .t .th { width: 26px; height: 26px; border-radius: 4px; background: #241a10; box-shadow: inset 0 0 0 1px #4a3820; display: grid; place-items: center; color: #c79a54; font-size: 12px; }
+ .fan .t .nm { font-family: "Iowan Old Style", Georgia, serif; font-size: 1.02rem; color: #ecdcbc; }
+ .fan .t .mt { color: #9c8154; font-size: .8rem; font-variant-numeric: tabular-nums; }
+ .fan .t.cur { background: linear-gradient(90deg, #2a1f10, #201810); box-shadow: inset 2px 0 0 #e6c98a; }
+ .fan .t.cur .nm { color: #f7e7c4; }
+
+ /* ---------- 3 · MODERN (streaming card) ---------- */
+ .mod { background: #0e1013; color: #cdd3da; border-color: #1c2027; }
+ .mod .top { display: flex; gap: .95rem; padding: 1.05rem 1.1rem; align-items: center; }
+ .mod .cover { width: 66px; height: 66px; border-radius: 12px; flex: none;
+ background: linear-gradient(145deg, #2a6f5a, #1b3b6b); box-shadow: 0 8px 20px -10px #000; display: grid; place-items: center; color: #bfeede; font-size: 20px; }
+ .mod .np-title { font-size: 1.28rem; font-weight: 680; color: #f2f5f8; letter-spacing: -.01em; }
+ .mod .np-sub { color: #8b94a0; font-size: .88rem; margin-top: .1rem; }
+ .mod .prog { display: flex; align-items: center; gap: .6rem; padding: 0 1.1rem .2rem; }
+ .mod .track-bar { position: relative; height: 4px; border-radius: 4px; background: #232830; flex: 1; }
+ .mod .track-bar > i { position: absolute; left: 0; top: 0; bottom: 0; width: 42%; border-radius: 4px; background: #5fd0a8; }
+ .mod .track-bar > b { position: absolute; left: 42%; top: 50%; width: 11px; height: 11px; border-radius: 50%; background: #eafaf3; transform: translate(-50%, -50%); box-shadow: 0 0 0 3px rgba(95,208,168,.25); }
+ .mod .tm { color: #7f8894; font-size: .72rem; font-variant-numeric: tabular-nums; }
+ .mod .ctl { display: flex; gap: .5rem; padding: .55rem 1.1rem .1rem; }
+ .mod .ctl .pill { border: 1px solid #262c34; background: #171b21; border-radius: 999px; padding: .2rem .7rem; font-size: .82rem; color: #aeb6c0; }
+ .mod .ctl .pill.main { background: #5fd0a8; color: #08130e; border-color: #5fd0a8; font-weight: 700; }
+ .mod .list { padding: .5rem .45rem .7rem; }
+ .mod .t { display: grid; grid-template-columns: 34px 1fr auto; gap: .7rem; align-items: center; padding: .4rem .7rem; border-radius: 10px; }
+ .mod .t .th { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; font-size: 13px; color: #dfe6ee; }
+ .mod .t .th.radio { background: linear-gradient(145deg, #2a6f5a, #1b3b6b); } .mod .t .th.note { background: linear-gradient(145deg, #3a3f4a, #23272e); }
+ .mod .t .nm { color: #dfe4ea; font-weight: 550; } .mod .t .mt { color: #7f8894; font-size: .8rem; font-variant-numeric: tabular-nums; }
+ .mod .t.cur { background: #151b22; } .mod .t.cur .nm { color: #eafaf3; }
+ .mod .t.cur .eq { color: #5fd0a8; }
+
+ .foot { margin-top: 2.4rem; padding-top: 1.2rem; border-top: 1px solid var(--card-line); color: var(--page-dim); font-size: .9rem; }
+ .foot b { color: var(--page-fg); }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <p class="eyebrow">EMMS playlist buffer · three directions</p>
+ <h1>Music player: pick a look</h1>
+ <p>Same playlist, three treatments. All are achievable inside an Emacs buffer, but they cost different amounts. Today it shows raw stream URLs (below); each direction fixes that and goes further. Mockups, not live renders.</p>
+ <div class="frame cur-ref mono" style="margin-top:1rem; max-width:34rem">
+ <div class="buf">
+ <div class="r"><span class="lbl">Current :</span> https://ice6.somafm.com/groovesalad-256-mp3</div>
+ <div class="r play">https://ice6.somafm.com/groovesalad-256-mp3</div>
+ <div class="r">https://ice1.somafm.com/groovesalad-256-mp3</div>
+ <div class="r">https://ice2.somafm.com/groovesalad-256-mp3</div>
+ </div>
+ </div>
+ </header>
+
+ <!-- 1 · MINIMAL -->
+ <section class="dir">
+ <div class="head"><span class="n">1</span><h2>Minimal</h2><span class="sub">refined terminal — names, one dim glyph, thin now-playing bar</span></div>
+ <div class="frame min mono">
+ <div class="buf">
+ <div class="r"><span class="lbl">Playlist</span> <span class="lbl">:</span> Evening mix <span class="dim">(5)</span></div>
+ <div class="r"><span class="lbl">Current </span> <span class="lbl">:</span> <span style="color:#8faf7f">▶ </span>Groove Salad <span class="dim">· SomaFM</span></div>
+ <div class="r"><span class="dim"> </span><span class="bar-full">━━━━━━━━━━━━</span><span class="bar-empty">────────────────</span> <span class="time">live · 256k</span></div>
+ <div class="r keys"><span class="k">Keys </span> <span class="k">:</span> a:add c:clear L:load S:stop &lt;&gt;:skip</div>
+ <div class="r keys"><span class="k">Radio </span> <span class="k">:</span> n:by name t:by tag m:enter manually</div>
+ <div class="rule"> ────────────────────────────────────────</div>
+ <div class="track cur"><span class="g-radio">◉</span><span class="name">Groove Salad</span><span class="meta">SomaFM · 256k</span></div>
+ <div class="track"><span class="g-radio">◉</span><span class="name">Drone Zone</span><span class="meta">SomaFM · 256k</span></div>
+ <div class="track"><span class="g-note">♪</span><span class="name">Miles Davis — So What</span><span class="meta">9:22</span></div>
+ <div class="track"><span class="g-note">♪</span><span class="name">Bill Evans — Peace Piece</span><span class="meta">6:41</span></div>
+ <div class="track"><span class="g-radio">◉</span><span class="name">Jazz Radio Blues</span><span class="meta">FR · 128k</span></div>
+ </div>
+ </div>
+ <p class="feas"><span class="cost">low lift</span><b>Faces + text only.</b> Names come from the .m3u #EXTINF and local tags; the glyph is a nerd-icon; the bar is one timer-driven line. No images. Sits naturally in the buffer you already have.</p>
+ </section>
+
+ <!-- 2 · FANCY -->
+ <section class="dir">
+ <div class="head"><span class="n">2</span><h2>Fancy</h2><span class="sub">hi-fi / vinyl — cover art, serif titles, warm amber, a real now-playing hero</span></div>
+ <div class="frame fan">
+ <div class="top">
+ <div class="cover"></div>
+ <div>
+ <div class="np-title">Groove Salad</div>
+ <div class="np-sub">SOMA FM · AMBIENT / DOWNTEMPO · 256K</div>
+ <div class="seg"><i class="on"></i><i class="on"></i><i class="on"></i><i class="on"></i><i class="on"></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
+ <div class="tline"><span>on air</span><span>live stream</span></div>
+ </div>
+ </div>
+ <div class="list">
+ <div class="t cur"><span class="th">◉</span><span class="nm">Groove Salad</span><span class="mt">SomaFM</span></div>
+ <div class="t"><span class="th">◉</span><span class="nm">Drone Zone</span><span class="mt">SomaFM</span></div>
+ <div class="t"><span class="th">♪</span><span class="nm">Miles Davis — So What</span><span class="mt">9:22</span></div>
+ <div class="t"><span class="th">♪</span><span class="nm">Bill Evans — Peace Piece</span><span class="mt">6:41</span></div>
+ <div class="t"><span class="th">◉</span><span class="nm">Jazz Radio Blues</span><span class="mt">FR</span></div>
+ </div>
+ </div>
+ <p class="feas"><span class="cost">medium lift</span><b>Cover art + variable-pitch serif.</b> Emacs shows images (station favicons, embedded album art) and a serif face for titles via `display` and face remaps. The vinyl cover and segmented bar are drawn with faces/SVG. Needs an art fetch-and-cache layer; the warmth is a theme overlay.</p>
+ </section>
+
+ <!-- 3 · MODERN -->
+ <section class="dir">
+ <div class="head"><span class="n">3</span><h2>Modern</h2><span class="sub">streaming-app card — rounded art, sans title, a slim seek bar with a handle, pill controls</span></div>
+ <div class="frame mod">
+ <div class="top">
+ <div class="cover">♪</div>
+ <div>
+ <div class="np-title">Groove Salad</div>
+ <div class="np-sub">SomaFM · ambient · 256 kbps</div>
+ </div>
+ </div>
+ <div class="prog"><span class="tm">LIVE</span><span class="track-bar"><i></i><b></b></span><span class="tm">∞</span></div>
+ <div class="ctl"><span class="pill">shuffle</span><span class="pill">⏮</span><span class="pill main">⏸ pause</span><span class="pill">⏭</span><span class="pill">repeat</span></div>
+ <div class="list">
+ <div class="t cur"><span class="th radio">◉</span><span class="nm">Groove Salad <span class="eq">▎▍▎</span></span><span class="mt">SomaFM</span></div>
+ <div class="t"><span class="th radio">◉</span><span class="nm">Drone Zone</span><span class="mt">SomaFM</span></div>
+ <div class="t"><span class="th note">♪</span><span class="nm">Miles Davis — So What</span><span class="mt">9:22</span></div>
+ <div class="t"><span class="th note">♪</span><span class="nm">Bill Evans — Peace Piece</span><span class="mt">6:41</span></div>
+ <div class="t"><span class="th radio">◉</span><span class="nm">Jazz Radio Blues</span><span class="mt">FR</span></div>
+ </div>
+ </div>
+ <p class="feas"><span class="cost">higher lift</span><b>Pushes hardest against the buffer model.</b> Rounded art, pill controls, the seek-bar dot handle, and rounded rows are easy in a browser but need SVG-rendered widgets or `svg-lib`-style images in Emacs, redrawn on playback. Doable in GUI Emacs, but it's the most code and the least "text buffer." A cool accent (mint) instead of the theme's amber to read contemporary.</p>
+ </section>
+
+ <div class="foot">
+ <p>My read: <b>Minimal</b> is the honest sweet spot for an Emacs player you live in, and it kills the primitive feeling on its own. <b>Fancy</b> is worth it if you want the now-playing moment to feel like hi-fi and you're happy to add cover art. <b>Modern</b> is the most striking but fights Emacs the most, so it's the biggest build for a look that a browser does more naturally. All three keep the n/t/m radio row and the same keys.</p>
+ <p style="margin:.7rem 0 0">Left out of every direction to stay tasteful: animated equalizers everywhere, a separate browser pane, mouse-first chrome. Glyphs shown here (◉ ♪) stand in for nerd-icons.</p>
+ </div>
+</div>
+<script>
+ (function () { var r = document.documentElement;
+ try { var t = localStorage.getItem('theme'); if (t === 'dark' || t === 'light') r.setAttribute('data-theme', t); } catch (e) {} })();
+</script>
+</body>
+</html>
diff --git a/docs/specs/2026-07-06-radio-browser-lookup-spec.org b/docs/specs/2026-07-06-radio-browser-lookup-spec.org
new file mode 100644
index 00000000..c65de850
--- /dev/null
+++ b/docs/specs/2026-07-06-radio-browser-lookup-spec.org
@@ -0,0 +1,202 @@
+#+TITLE: Radio-browser station lookup + playlist creator — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-06
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Radio-browser lookup
+:PROPERTIES:
+:ID: 4839b2c8-0552-4029-9e0f-4bf69b9a4dcd
+:END:
+- 2026-07-09 Thu @ 17:50:32 -0500 — Craig ran the queue-first VERIFY (n / t / m picks, save on v, file-playlist save unchanged, empty and offline searches) and found no issues. DOING -> IMPLEMENTED.
+- 2026-07-08 Wed @ 10:16:25 -0500 — model revision (Craig): queue-first, save-on-request. The lookup (and the manual m creator) no longer writes .m3u files at pick time; picked stations become url tracks in the queue (name/uuid/favicon as track properties) and play immediately. Saving is the normal playlist save (moved to w — the earlier S binding was shadowed by emms-stop): an all-stream queue saves into the MPD playlist dir, the plain station name pre-fills the prompt (the "-Radio" filename suffix is retired), and a custom .m3u emitter writes the station metadata back out so load round-trips names and cover art. First pick names a multi-station save. Still DOING.
+- 2026-07-06 Mon @ 14:04:53 -0500 — build UX changes (Craig, during Phase 2 verify): pulled tag search from vNext into v1; the feature is a Radio row in the playlist buffer (n: by name, t: by tag, m: enter manually) rather than one command on S; single mode moved off t to s and emms-stop off s to S; created filenames carry a "-Radio" suffix. Still DOING.
+- 2026-07-06 Mon @ 13:08:32 -0500 — spec-response Phase 6: decomposed into build tasks in todo.org (parent stamped :SPEC_ID:); READY -> DOING. Build under way.
+- 2026-07-06 Mon @ 13:01:55 -0500 — spec-response: all 7 findings dispositioned (6 accept/modify, 1 resolved via new Decision 5); decisions [5/5], findings [7/7]. No blocking finding remains; readiness rubric re-run on the expanded spec passes. DRAFT -> READY. Awaiting Craig's go to decompose into build tasks (spec-response Phase 6, flips READY -> DOING).
+- 2026-07-06 Mon @ 10:48:20 -0500 — spec-review: Not ready. 7 findings recorded (1 blocking: completing-read-multiple splits on commas in station names). Stays DRAFT pending disposition via spec-response.
+- 2026-07-06 Mon @ 10:12:00 -0500 — all four decisions resolved (Craig): url.el; multi-select one-file-per-station; save into the MPD playlist dir; create-and-play. Ready for spec-review.
+- 2026-07-06 Mon @ 10:01:27 -0500 — drafted.
+
+* Metadata
+| Status | implemented |
+|----------+-------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+-------------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+-------------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: Music — create playlists from a radio.info lookup]] |
+|----------+-------------------------------------------------------------------|
+
+* Summary
+
+Add an in-Emacs command that searches the radio-browser.info directory for internet-radio stations and turns a selection into an M3U the music player can load and play. It closes the loop opened by the multi-directory sourcing work: the player already reads and plays radio .m3u from both ~/music/ and the MPD playlist directory, but every station still has to be found in a browser and hand-entered through cj/music-create-radio-station (name + URL typed by hand). This feature makes discovery native.
+
+* Problem / Context
+
+Craig listens to internet radio through the EMMS + mpv-subprocess player in music-config.el. A radio station is a one-line .m3u: an #EXTINF label and a stream URL. Today those come from two places, neither good. The 73 existing stations were built up over time in an MPD client and relocated into dotfiles; adding a new one means opening radio-browser.info in a browser, copying the stream URL, running cj/music-create-radio-station, and pasting name and URL by hand. There is no way to search, compare, or audition stations without leaving Emacs.
+
+radio-browser.info is a free community directory with an open JSON API: search by name, tag, country, language; each station carries a stable UUID, a resolved stream URL, codec, bitrate, tags, and popularity counts. That is exactly the metadata a good picker needs, and the existing radio .m3u already use radio-browser's UUID header on 15 of 73 files. The problem is purely that nothing in the config talks to the API.
+
+The verified API shape (checked live 2026-07-06):
+- Server list: GET https://all.api.radio-browser.info/json/servers -> [{ip, name}, ...]. A concrete host like de1.api.radio-browser.info answers directly.
+- Search: GET https://<server>/json/stations/search?name=<q>&limit=<n>&hidebroken=true -> array of station objects.
+- Station fields used: stationuuid, name, url_resolved, codec, bitrate, tags (comma string), countrycode, votes, clickcount.
+
+* Goals and Non-Goals
+
+** Goals
+- Search radio-browser.info from inside Emacs and pick from the results.
+- Turn the selection into a playable M3U in the format the existing player already reads (#EXTM3U / #EXTINF:1,<name> / <url>), carrying the #RADIOBROWSERUUID header for provenance.
+- Reuse the existing writer and the multi-directory sourcing so a created station is immediately loadable and playable.
+- Be a good radio-browser client: a descriptive User-Agent and server rotation.
+
+** Non-Goals
+- No replacement of cj/music-create-radio-station; the manual name+URL path stays for stations not in the directory.
+- No station editing, tagging, or curation UI beyond create.
+- No favorites/rating sync back to radio-browser (vote/click counting is vNext).
+- No dependence on MPD's stored-playlist mechanism; this writes .m3u files, consistent with the sourcing design.
+- No audio format work; mpv already plays whatever the stream serves.
+
+** Scope tiers
+- v1: search by name and by tag, a result picker, and playlist creation from the selection, wired to the existing writer + sourcing. Surfaced as a Radio row in the playlist buffer (n: by name, t: by tag, m: enter manually).
+- Out of scope: transient dashboards, station browsing by curated category, in-buffer station management.
+- vNext (log to todo.org): click/vote counting (POST /json/url/<uuid>), country faceted search, choosing among a station's codec/bitrate variants, an audition-before-save preview, a homepage/favicon-rich annotation. (Tag search moved into v1 2026-07-06.)
+
+* Design
+
+At a caller's altitude: Craig runs a search command (bound in the playlist keymap next to R), types a query, and gets a completion list of stations annotated with codec, bitrate, country, ♥votes, and top tags (Decision 5, Variant B). He picks one or more. The command writes an M3U and reports where it landed; the station is then visible in the load list and plays through mpv like any other radio .m3u.
+
+At the implementer's altitude, four pieces:
+
+1. A thin API client. cj/music-radio--server returns a pinned default host (cj/music-radio-server). On a connection failure or timeout against it, the client fetches /json/servers once and retries against the first reachable host from that list, capped at one such retry so it never loops. cj/music-radio--search runs the GET, parses JSON with the built-in json-parse-string, and returns a list of plists with only the fields the picker and writer need. All network calls send a descriptive User-Agent and carry a timeout. A failed or empty response returns a clear user-error, never a stack trace.
+
+2. A pure result-to-candidate layer. cj/music-radio--format-candidate turns a station plist into the completion string plus an annotation, and the reverse map recovers the chosen station. Keeping this pure keeps the picker testable without the network.
+
+3. A pure M3U emitter. cj/music-radio--station-m3u takes a station plist and returns the exact file text: #EXTM3U, an optional #RADIOBROWSERUUID line, #EXTINF:1,<name>, and the stream URL. It prefers url_resolved and falls back to url; a station with neither is not emitted (see the writer below). This mirrors the existing radio files byte-for-byte, so old and new stations are indistinguishable to the player.
+
+4. The interactive command. cj/music-radio-search reads the query, calls the client, and drives a repeated single-select picker: completing-read is called in a loop, each pick removed from the pool and a "[done]" sentinel finishing selection, so several stations are chosen in one search. This avoids completing-read-multiple, whose comma separator would mis-split a station name that contains a comma; each pick maps straight from its display string to the station object, never through a delimiter. For each selected station the command hands the station to the writer, which builds the filename from cj/music--safe-filename (disambiguating a name that collides with an already-written pick this run by appending a short stationuuid fragment) and writes cj/music-radio--station-m3u into cj/music-radio-save-dir (default the MPD playlist directory, alongside the existing radio set). A station with no usable stream URL is skipped and named in the closing message. The command then enqueues the created stations and starts playback through mpv, so a search ends in radio audio (see Decision 4 for the interrupt-vs-append behavior). The feature reuses cj/music--safe-filename and the existing radio M3U format; it does not call cj/music-create-radio-station, which stays as-is on its own write target.
+
+The network client and the interactive command are the only impure pieces. The candidate formatting and the M3U emission are pure and carry the test weight, following the module's existing internal/interactive split.
+
+* Alternatives Considered
+
+** HTTP via built-in url.el
+- Good, because no new dependency; ships with Emacs; url-retrieve-synchronously with a let-bound timeout is enough for a one-shot search.
+- Bad, because url.el's error handling and header ergonomics are clunky; async needs a callback dance.
+- Neutral, because JSON parsing is json-parse-string either way.
+
+** HTTP via plz.el (or request.el)
+- Good, because a clean synchronous-or-async API, straightforward headers, better error surfacing.
+- Bad, because a new package dependency for one feature; another thing to keep installed and byte-clean.
+- Neutral, because both are actively maintained.
+
+** Shell out to curl
+- Good, because trivial and already used ad hoc; robust header/timeout handling.
+- Bad, because process management and quoting in Elisp, and a hard curl dependency at runtime; less portable than staying in-process.
+- Neutral, because output still parses with json-parse-string.
+
+** Playlist shape: one .m3u per station vs one multi-station .m3u
+- One-per-station is Good, because it matches all 73 existing files and MPD's per-station model; each station is independently loadable.
+- One multi-station .m3u is Good for a genre queue you skip through, but Bad because it diverges from the existing shape and complicates naming.
+- This is the load-bearing product question; see Decision 2.
+
+* Decisions [5/5]
+
+** DONE HTTP client choice
+- Context: one JSON GET (plus an occasional server-list GET). No streaming, no auth. url.el ships with Emacs; plz is cleaner but a new dep.
+- Decision: We will use built-in url.el (url-retrieve-synchronously with a bound timeout) and json-parse-string, adding no dependency.
+- Consequences: easier install and byte-compile story, no new package to track; harder error ergonomics and any future async, which we accept for a one-shot search.
+
+** DONE Playlist shape and selection
+- Context: "create playlists from a lookup" can mean one file per station (matches the existing 73) or one file holding several stations (a genre queue). Selection can be single or multi.
+- Decision: We will support multi-select and write one .m3u per selected station (matching the existing shape), so a search that returns several good stations creates several stations in one pass rather than a combined file.
+- Consequences: easier consistency with the existing library and independent loadability; harder to express "a single genre playlist of five streams" (deferred to vNext if wanted).
+
+** DONE Save destination for created stations
+- Context: the multi-directory sourcing reads both ~/music/ and ~/.local/share/mpd/playlists/. The radio home is the MPD playlist directory, where the existing 73 stations live as dotfiles-tracked relative symlinks. Craig wants new stations to land with the rest of the radio set, not in ~/music/.
+- Decision: We will write new stations into ~/.local/share/mpd/playlists/ (Craig's call), so a created station sits alongside the existing radio playlists and both MPD and the multi-directory sourcing see it immediately. A defcustom cj/music-radio-save-dir (default that directory) keeps it configurable.
+- Consequences: easier — new stations are co-located with the radio set and instantly playable through either client; harder — a newly written file is a real file in a directory otherwise made of dotfiles-tracked symlinks, so it is not version-controlled until Craig stows it into ~/.dotfiles. The config does not automate that promotion.
+
+** DONE Play-on-create behavior
+- Context: after creating a station Craig may want it to start playing, or just exist for later. Craig's call: play it.
+- Decision: We will create the station and immediately enqueue and play the selection through EMMS (mpv), so a search ends in audio. With several picks the created stations enqueue in order and the first starts playing right away, interrupting whatever was playing (the natural reading of "always play" — the search ends in the radio, not behind the current queue). A prefix argument suppressing playback (create-only) is a vNext nicety, not v1.
+- Consequences: easier — search-to-sound in one command, no separate load step; harder — creating always interrupts current playback, so a "just save it for later" flow means creating and then stopping. Acceptable given radio is play-oriented, and confirmable against a real listen.
+
+** DONE Candidate annotation format
+- Context: the marginalia annotation on each station line can carry codec/bitrate/country plus either popularity or genre. The prototype (docs/specs/2026-07-06-radio-browser-lookup.prototype.html) shows both.
+- Decision: Variant B (Craig's call): codec, bitrate, country, ♥votes, and top tags. Drop the play count — Craig isn't interested in it, and the tags help tell same-named stations apart by what they play.
+- Consequences: the station line runs a little wider to fit the tags, and cj/music-radio--format-candidate emits the tag snippet (first few tags) rather than the play count.
+
+* Review findings [7/7]
+** DONE Multi-select splits on commas in station names :blocking:
+Accepted. Design piece 4 now specifies a repeated single-select loop (completing-read with a "[done]" sentinel, each pick removed from the pool) instead of completing-read-multiple, so a comma in a station name can never mis-split the selection — each pick maps straight from its display string to the station object. This keeps Decision 2's "several picks in one search" without the delimiter hazard.
+** DONE Empty or missing url_resolved writes a broken station
+Accepted. The emitter (piece 3) prefers url_resolved and falls back to url; the writer (piece 4) skips a station with neither and names it in the closing message.
+** DONE Two picks with the same name collide on the filename
+Accepted. Piece 4's writer disambiguates a name that collides with an already-written pick this run by appending a short stationuuid fragment.
+** DONE "Reuses the existing writer" is imprecise
+Accepted (modified). Rather than refactor cj/music-create-radio-station (scope creep + test churn on working code), piece 4 now states the feature reuses cj/music--safe-filename and the existing radio M3U format but ships its own emitter/writer, and cj/music-create-radio-station stays as-is on its own write target. Clarifies the imprecision without expanding scope.
+** DONE Server-selection fallback underspecified
+Accepted. Piece 1 now pins a default host (cj/music-radio-server) and, on a connection failure or timeout, fetches /json/servers once and retries against the first reachable host, capped at one retry.
+** DONE create-and-play behavior when music is already playing
+Accepted (modified). Decision 4's body now states create-and-play interrupts current playback (plays the first created station immediately), the natural reading of "always play." Proposed default surfaced to Craig for veto; a create-only prefix arg is logged as vNext.
+** DONE Annotation format A vs B unresolved
+Resolved via new Decision 5: Craig picked Variant B (codec/bitrate/country/♥votes/tags, drop play count). Folded into the caller-altitude design description and Decision 5.
+
+* Implementation phases
+
+** Phase 1 — API client + pure emitter (no UI)
+Add cj/music-radio--server, cj/music-radio--search (returns station plists), cj/music-radio--format-candidate, and cj/music-radio--station-m3u. Unit-test the pure pieces (candidate formatting, M3U emission) against fixture station plists; test the client with a stubbed url-retrieve or a recorded JSON fixture, never a live call. Leaves the tree working: helpers exist, nothing bound yet.
+
+** Phase 2 — interactive command + binding
+Add cj/music-radio-search (query -> search -> completing-read multi-select -> writer), reusing cj/music--safe-filename and the save-dir defcustom. Bind it in the playlist keymap next to R. Handle empty results, network failure, and cancel with clear user-errors. Manual live verification (the network + picker + play can't be driven headless) filed as a VERIFY.
+
+* Acceptance criteria
+- [ ] A search for a known station name returns annotated candidates in a completion prompt.
+- [ ] Selecting a station writes a .m3u whose bytes match the existing radio format (#EXTM3U / #EXTINF:1,<name> / <url_resolved>), with a #RADIOBROWSERUUID line.
+- [ ] Creating a station writes it into cj/music-radio-save-dir, then enqueues and starts it playing through mpv; it also appears in the player's load list (multi-directory sourcing).
+- [ ] An empty result set and a network failure each produce a clear message, not a stack trace.
+- [ ] Selecting several stations in one search creates one file per station, and a station name containing a comma is selected correctly (no mis-split).
+- [ ] A station with no usable stream URL is skipped and named in the closing message, not written as a broken file.
+- [ ] The pure emitter and candidate formatter have Normal/Boundary/Error tests that run without the network.
+
+* Readiness dimensions
+- Data model & ownership: a station plist (uuid, name, url, codec, bitrate, tags, country, votes, clickcount) derived from the API; the .m3u file is generated and owned by Craig once written. No local cache in v1.
+- Errors, empty states & failure: named user-errors — no server reachable, empty results, cancelled selection, write failure (naming the file). No silent data loss; overwrite reuses the existing cj/confirm-strong prompt from create-radio-station.
+- Security & privacy: no credentials. The only outbound data is the search query and a User-Agent to a public API. No sensitive data logged.
+- Observability: the command messages the server used, the result count, and each file written. Search is one short synchronous call; if it ever feels slow, a "Searching radio-browser…" message covers it.
+- Performance & scale: result sets bounded by an explicit limit (default ~30). One GET per search. No scaling concern.
+- Reuse & lost opportunities: reuses cj/music--safe-filename, the overwrite-confirm pattern, cj/music-m3u-root, and the whole multi-directory sourcing + mpv play path. json-parse-string and url.el are built in. Nothing new is invented that the platform already provides.
+- Architecture fit & weak points: integrates at music-config.el alongside cj/music-create-radio-station; the writer is the shared seam. Weak point: radio-browser server availability — mitigated by the server-list fallback and a timeout.
+- Config surface: cj/music-radio-save-dir (default ~/.local/share/mpd/playlists/, the radio home), cj/music-radio-server (default pinned host, with the /json/servers fallback), cj/music-radio-search-limit (default 30), cj/music-radio-user-agent (descriptive default). All with defaults and doc.
+- Documentation plan: a line in the module commentary and the keybinding list; no separate doc needed.
+- Dev tooling: existing make test / test-file targets cover the new unit tests; no new tooling.
+- Rollout, compatibility & rollback: additive — a new command and helpers, no change to existing behavior or files. Removing it is deleting the code; created .m3u files land in the MPD playlist directory as ordinary files (untracked until Craig stows them) and stay.
+- External APIs & deps: radio-browser /json/servers and /json/stations/search VERIFIED live 2026-07-06 (shape recorded in Problem/Context). The vote/click endpoint (vNext) is a research prerequisite if that feature is pursued.
+
+* Risks, Rabbit Holes, and Drawbacks
+- radio-browser etiquette: the project asks clients to identify via User-Agent and to rotate servers rather than hammer one. v1 honors both; skipping click-counting is polite-neutral (it slightly under-reports popularity but adds no load).
+- url.el error handling is the likeliest rabbit hole. Keep the client tiny: one GET, parse, or a single user-error. Do not build a general HTTP layer.
+- Stream URL choice: a station can list several codec/bitrate variants under one name. v1 takes url_resolved as-is; picking among a station's variants is a vNext refinement, not a v1 problem.
+
+* References / Appendix
+- UI prototype (open in a browser): [[file:2026-07-06-radio-browser-lookup.prototype.html][2026-07-06-radio-browser-lookup.prototype.html]] — faithful vertico + marginalia mockup of the four minibuffer screens (query, station list, multi-select, created+playing) with real jazz-search data. Shows the two candidate-annotation variants the design chooses between (A: codec/bitrate/country/votes/plays; B: codec/bitrate/country/votes/tags).
+- API shape verified live 2026-07-06 against de1.api.radio-browser.info (recorded in Problem / Context).
+
+* Review and iteration history
+** 2026-07-06 Mon @ 13:01:55 -0500 — Claude (for Craig) — responder
+- What: dispositioned all 7 review findings. Six accepted or accepted-with-modification and folded into the Design, Decisions, config, and acceptance sections; the seventh (annotation format) resolved by adding Decision 5, which Craig answered Variant B (tags, drop play count). Re-ran the readiness rubric on the expanded spec and flipped DRAFT -> READY.
+- Why: the blocking CRM comma-split needed a selection-mechanism decision (now a loop-based single-select), and the empty-URL, filename-collision, writer-precision, and server-fallback gaps each needed a concrete rule before an implementer could build without inventing behavior.
+- Artifacts: * Review findings [7/7]; * Decisions [5/5]; the loop-select, skip-empty-URL, filename-dedup, and server-fallback rules in the Design; Decision 5 (Variant B).
+** 2026-07-06 Mon @ 10:48:20 -0500 — Claude (for Craig) — reviewer
+- What: first review pass. Recorded 7 findings (1 blocking: CRM comma-splitting breaks multi-select for station names containing commas); rubric Not ready pending disposition. Confirmed the two Implementation phases decompose cleanly.
+- Why: verify implementation-readiness before build. The multi-select mechanism, empty-URL and duplicate-name robustness, the shared-writer seam, server fallback, and the create-and-play semantics were the real gaps an implementer would hit.
+- Artifacts: * Review findings [0/7]; grounded in music-config.el (writer, m3u reader, multi-dir sourcing, cj/music-create-radio-station) and the live spike; API verified live.
+** 2026-07-06 Mon @ 10:12:00 -0500 — Claude (for Craig) — author
+- What: resolved all four open decisions from Craig's answers and folded them into the design, config, rollout, and acceptance sections.
+- Why: HTTP client, playlist shape, save destination, and play-on-create were the real product choices gating a build.
+- Artifacts: Decisions section now [4/4]; save dir is the MPD playlist directory; create-and-play is the default.
+** 2026-07-06 Mon @ 10:01:27 -0500 — Claude (for Craig) — author
+- What: initial draft.
+- Why: the music player can source and play radio .m3u but has no native way to discover stations; radio-browser's API supplies exactly the needed metadata.
+- Artifacts: todo.org "Music — create playlists from a radio.info lookup"; API shape verified live against de1.api.radio-browser.info.
diff --git a/docs/specs/2026-07-06-radio-browser-lookup.prototype.html b/docs/specs/2026-07-06-radio-browser-lookup.prototype.html
new file mode 100644
index 00000000..69d3c435
--- /dev/null
+++ b/docs/specs/2026-07-06-radio-browser-lookup.prototype.html
@@ -0,0 +1,190 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Radio-browser lookup — UI prototype</title>
+<style>
+ :root {
+ --page-bg: #f3f0ea; --page-fg: #2b2822; --page-dim: #6b6459;
+ --card-line: #d9d3c7; --accent: #9a6b2f;
+ /* Emacs frame (always dark — it depicts a dark editor) */
+ --e-bg: #15140f; --e-fg: #cfc8b8; --e-dim: #857c6c;
+ --e-sel-bg: #2a2620; --e-sel-fg: #e6c98a; --e-prompt: #8fb0c4;
+ --e-mark: #d98f7a; --e-play: #8faf7f; --e-modeline: #201d17;
+ --e-rule: #322d25;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root { --page-bg: #14130f; --page-fg: #d4cdbf; --page-dim: #8a8272;
+ --card-line: #2c281f; --accent: #d8a24f; }
+ }
+ :root[data-theme="light"] { --page-bg: #f3f0ea; --page-fg: #2b2822; --page-dim: #6b6459; --card-line: #d9d3c7; --accent: #9a6b2f; }
+ :root[data-theme="dark"] { --page-bg: #14130f; --page-fg: #d4cdbf; --page-dim: #8a8272; --card-line: #2c281f; --accent: #d8a24f; }
+
+ * { box-sizing: border-box; }
+ body {
+ margin: 0; background: var(--page-bg); color: var(--page-fg);
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ line-height: 1.55; padding: 3rem 1.25rem 5rem;
+ }
+ .wrap { max-width: 60rem; margin: 0 auto; }
+ header { margin-bottom: 2.5rem; }
+ .eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: .72rem;
+ color: var(--accent); font-weight: 600; margin: 0 0 .5rem; }
+ h1 { font-size: 1.9rem; margin: 0 0 .6rem; text-wrap: balance; font-weight: 650; }
+ header p { margin: .3rem 0; max-width: 62ch; color: var(--page-dim); }
+ header p strong { color: var(--page-fg); font-weight: 600; }
+
+ .step { margin: 2.4rem 0 .8rem; }
+ .step .n { color: var(--accent); font-weight: 700; font-variant-numeric: tabular-nums; }
+ .step h2 { display: inline; font-size: 1.15rem; font-weight: 620; }
+ .step + .note { margin: 0 0 1rem; color: var(--page-dim); font-size: .92rem; max-width: 64ch; }
+
+ /* Emacs frame */
+ .frame {
+ background: var(--e-bg); color: var(--e-fg);
+ border: 1px solid var(--e-rule); border-radius: 8px; overflow: hidden;
+ box-shadow: 0 12px 34px -20px rgba(0,0,0,.6);
+ font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
+ font-size: 13.5px;
+ }
+ .buffer { padding: .55rem 0 0; }
+ .buf-line { padding: .05rem 1rem; white-space: pre; color: var(--e-dim); }
+ .buf-line .txt { color: var(--e-fg); }
+ .modeline {
+ background: var(--e-modeline); color: var(--e-dim);
+ padding: .2rem 1rem; margin-top: .55rem;
+ border-top: 1px solid var(--e-rule); border-bottom: 1px solid var(--e-rule);
+ display: flex; gap: 1.2rem; font-size: 12.5px;
+ }
+ .modeline .lead { color: var(--e-fg); }
+ .mini { padding: .45rem 1rem .6rem; display: flex; align-items: baseline; }
+ .prompt { color: var(--e-prompt); }
+ .input { color: var(--e-fg); }
+ .caret { display: inline-block; width: .55ch; height: 1.15em; background: var(--e-fg);
+ translate: 0 .18em; margin-left: 1px; }
+
+ /* vertico completion list */
+ .vhead { padding: .45rem 1rem .3rem; color: var(--e-prompt); border-top: 1px solid var(--e-rule); }
+ .vhead .count { color: var(--e-dim); float: right; font-variant-numeric: tabular-nums; }
+ .cand { display: grid; grid-template-columns: 1.4ch 1fr auto; gap: .5ch;
+ padding: .12rem 1rem; align-items: baseline; }
+ .cand .mark { color: var(--e-mark); }
+ .cand .name { color: var(--e-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ .cand .ann { color: var(--e-dim); white-space: pre; font-variant-numeric: tabular-nums; text-align: right; }
+ .cand.sel { background: var(--e-sel-bg); }
+ .cand.sel .name { color: var(--e-sel-fg); }
+ .cand.marked .name { color: var(--e-fg); }
+ .cand.marked .mark { color: var(--e-mark); }
+ .tag { color: #7f94a6; }
+
+ .playrow .np { color: var(--e-play); }
+ .legend { margin-top: 1.4rem; padding-top: 1.2rem; border-top: 1px solid var(--card-line);
+ color: var(--page-dim); font-size: .9rem; }
+ .legend code { background: color-mix(in srgb, var(--page-fg) 8%, transparent);
+ padding: .05rem .35rem; border-radius: 4px; font-family: ui-monospace, monospace; font-size: .85em; }
+ .pick { display: flex; gap: .6rem; flex-wrap: wrap; margin: .6rem 0 0; }
+ .pill { border: 1px solid var(--card-line); border-radius: 999px; padding: .15rem .7rem;
+ font-size: .82rem; color: var(--page-dim); }
+ .pill b { color: var(--accent); }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <p class="eyebrow">Emacs · vertico + marginalia · prototype</p>
+ <h1>Radio-browser station lookup — how the screens read</h1>
+ <p>One command, <strong>cj/music-radio-search</strong>, taking you from a query to audio. These are faithful mockups of the four minibuffer screens, drawn with a real live search for <strong>jazz</strong> (results sorted by popularity). Everything is monospace because it all lives in the minibuffer.</p>
+ <p>The one real design choice is the station line: what metadata rides alongside the name, and in what order. Step 2 shows two variants — pick one.</p>
+ </header>
+
+ <!-- STEP 1 -->
+ <div class="step"><span class="n">1</span> &nbsp;<h2>The query</h2></div>
+ <p class="note">Bound next to <code style="font-family:ui-monospace,monospace">R</code> in the playlist keymap. You type a name or keyword; Enter fires the search.</p>
+ <div class="frame">
+ <div class="buffer">
+ <div class="buf-line">;; <span class="txt">*EMMS-Playlist*</span> — 3 tracks, playing “Kind of Blue / So What”</div>
+ </div>
+ <div class="modeline"><span class="lead">*EMMS-Playlist*</span><span>Radio</span><span>▶ playing</span></div>
+ <div class="mini"><span class="prompt">Radio search:&nbsp;</span><span class="input">jazz</span><span class="caret"></span></div>
+ </div>
+
+ <!-- STEP 2A -->
+ <div class="step"><span class="n">2</span> &nbsp;<h2>The station list</h2> &nbsp;<span style="color:var(--page-dim);font-size:.9rem">— Variant A: codec · bitrate · country · ♥votes ▶plays</span></div>
+ <p class="note">vertico lists the matches; marginalia annotates each on the right, dimmed. The current row carries the gold highlight. Codec and bitrate tell you the quality; ♥ is radio-browser votes, ▶ is play count — both proxies for “is this station any good.”</p>
+ <div class="frame">
+ <div class="vhead">Stations for “jazz” (TAB to mark, RET to create+play): <span class="count">7/842</span></div>
+ <div class="cand sel"><span class="mark"></span><span class="name">Adroit Jazz Underground</span><span class="ann">MP3 320k US ♥174208 ▶148</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">101 SMOOTH JAZZ</span><span class="ann">MP3 128k US ♥86929 ▶389</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Adroit Jazz Underground HD Opus</span><span class="ann">OGG 192k US ♥67573 ▶51</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Jazz Radio Blues</span><span class="ann">MP3 128k FR ♥62960 ▶113</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Jazz Radio</span><span class="ann">MP3 192k FR ♥49272 ▶108</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Jazz Radio Classic Jazz</span><span class="ann">MP3 128k FR ♥27269 ▶52</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Radio Swiss Jazz</span><span class="ann">MP3 128k CH ♥26626 ▶58</span></div>
+ <div class="mini"><span class="prompt">Stations for “jazz”…:&nbsp;</span><span class="input"></span><span class="caret"></span></div>
+ </div>
+
+ <!-- STEP 2B -->
+ <div class="step"><span class="n">2</span> &nbsp;<h2>The station list</h2> &nbsp;<span style="color:var(--page-dim);font-size:.9rem">— Variant B: adds a tag snippet, drops play count</span></div>
+ <p class="note">Same list, but the annotation trades ▶plays for the station’s top tags — more help telling two same-named stations apart by what they actually play, at the cost of a wider line.</p>
+ <div class="frame">
+ <div class="vhead">Stations for “jazz” (TAB to mark, RET to create+play): <span class="count">7/842</span></div>
+ <div class="cand sel"><span class="mark"></span><span class="name">Adroit Jazz Underground</span><span class="ann">MP3 320k US ♥174208 · <span class="tag">bebop, hard bop, cool</span></span></div>
+ <div class="cand"><span class="mark"></span><span class="name">101 SMOOTH JAZZ</span><span class="ann">MP3 128k US ♥86929 · <span class="tag">smooth jazz, easy</span></span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Adroit Jazz Underground HD Opus</span><span class="ann">OGG 192k US ♥67573 · <span class="tag">avant-garde, opus</span></span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Jazz Radio Blues</span><span class="ann">MP3 128k FR ♥62960 · <span class="tag">blues, jazz</span></span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Jazz Radio</span><span class="ann">MP3 192k FR ♥49272 · <span class="tag">jazz, soul</span></span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Jazz Radio Classic Jazz</span><span class="ann">MP3 128k FR ♥27269 · <span class="tag">classical, jazz</span></span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Radio Swiss Jazz</span><span class="ann">MP3 128k CH ♥26626 · <span class="tag">public radio</span></span></div>
+ <div class="mini"><span class="prompt">Stations for “jazz”…:&nbsp;</span><span class="input"></span><span class="caret"></span></div>
+ </div>
+
+ <!-- STEP 3 -->
+ <div class="step"><span class="n">3</span> &nbsp;<h2>Marking several</h2></div>
+ <p class="note">It’s <code style="font-family:ui-monospace,monospace">completing-read-multiple</code>: TAB marks a row (red bullet, name stays lit), and one search can create several stations at once. Here three are marked; Enter creates all three.</p>
+ <div class="frame">
+ <div class="vhead">Stations for “jazz” (TAB to mark, RET to create+play): <span class="count">7/842</span></div>
+ <div class="cand marked"><span class="mark">●</span><span class="name">Adroit Jazz Underground</span><span class="ann">MP3 320k US ♥174208 ▶148</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">101 SMOOTH JAZZ</span><span class="ann">MP3 128k US ♥86929 ▶389</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Adroit Jazz Underground HD Opus</span><span class="ann">OGG 192k US ♥67573 ▶51</span></div>
+ <div class="cand marked"><span class="mark">●</span><span class="name">Jazz Radio Blues</span><span class="ann">MP3 128k FR ♥62960 ▶113</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Jazz Radio</span><span class="ann">MP3 192k FR ♥49272 ▶108</span></div>
+ <div class="cand"><span class="mark"></span><span class="name">Jazz Radio Classic Jazz</span><span class="ann">MP3 128k FR ♥27269 ▶52</span></div>
+ <div class="cand sel marked"><span class="mark">●</span><span class="name">Radio Swiss Jazz</span><span class="ann">MP3 128k CH ♥26626 ▶58</span></div>
+ <div class="mini"><span class="prompt">Stations for “jazz”…:&nbsp;</span><span class="input">Adroit Jazz Underground,Jazz Radio Blues,Radio Swiss Jazz</span><span class="caret"></span></div>
+ </div>
+
+ <!-- STEP 4 -->
+ <div class="step"><span class="n">4</span> &nbsp;<h2>Created and playing</h2></div>
+ <p class="note">Each pick is written as an .m3u into the MPD playlist directory (Decision 3), then enqueued and started through mpv (Decision 4) — the search ends in sound. The echo area confirms; the three land in the playlist, the first now playing.</p>
+ <div class="frame">
+ <div class="buffer">
+ <div class="buf-line playrow"><span class="np">▶ </span><span class="txt">Adroit Jazz Underground</span></div>
+ <div class="buf-line">&nbsp;&nbsp;<span class="txt">Jazz Radio Blues</span></div>
+ <div class="buf-line">&nbsp;&nbsp;<span class="txt">Radio Swiss Jazz</span></div>
+ </div>
+ <div class="modeline"><span class="lead">*EMMS-Playlist*</span><span>Radio</span><span class="playrow"><span class="np">▶ playing</span></span></div>
+ <div class="mini"><span class="prompt" style="color:var(--e-play)">Created + playing 3 stations&nbsp;</span><span style="color:var(--e-dim)">→ ~/.local/share/mpd/playlists/</span></div>
+ </div>
+
+ <div class="legend">
+ <div>Two things to react to:</div>
+ <div class="pick">
+ <span class="pill"><b>A</b> — quality + popularity (codec · bitrate · country · ♥ ▶)</span>
+ <span class="pill"><b>B</b> — quality + tags (codec · bitrate · country · ♥ · tags)</span>
+ </div>
+ <p style="margin:.9rem 0 0">Everything else (the flow, multi-select, create-and-play into the MPD dir) matches the resolved spec. Data is a live jazz search; ♥ = radio-browser votes, ▶ = play count. This is a static mockup — no live search runs in the page.</p>
+ </div>
+</div>
+
+<script>
+ (function () {
+ var root = document.documentElement;
+ try {
+ var t = localStorage.getItem('theme');
+ if (t === 'dark' || t === 'light') root.setAttribute('data-theme', t);
+ } catch (e) {}
+ })();
+</script>
+</body>
+</html>
diff --git a/docs/specs/2026-07-10-org-workflow-doctor-spec.org b/docs/specs/2026-07-10-org-workflow-doctor-spec.org
new file mode 100644
index 00000000..b7f5db6c
--- /dev/null
+++ b/docs/specs/2026-07-10-org-workflow-doctor-spec.org
@@ -0,0 +1,260 @@
+#+TITLE: Org workflow doctor — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* CANCELLED Org workflow doctor
+:PROPERTIES:
+:ID: c0e06025-a3b8-4238-a9a0-07f9e55913f4
+:END:
+- 2026-07-10 Fri @ 05:58:00 -0500 — CANCELLED. The feature has no job. Craig asked what the doctor buys when it refuses to install anything, and the answer is nothing that isn't already there. Every external binary is already guarded at its point of use, with a clear message: hugo-config guards hugo and the file-manager opener, org-webclipper guards pandoc, org-export-config guards zathura, and ox-pandoc guards itself upstream (ox-pandoc.el:1533, 1970). Package availability surfaces at load or first use. Path checks were the only genuinely new capability, and a startup warning about a missing org-dir contradicts this spec's own goal of keeping startup quiet, while an on-demand check nobody remembers to run is worth roughly nothing. DRAFT -> CANCELLED.
+- 2026-07-10 Fri @ 00:29:10 -0500 — drafted.
+
+* Postmortem
+
+The spec cleared spec-create's Phase 0 bar narrowly, and that was the signal to stop
+and ask what the feature bought over the mechanisms already in the tree. It wasn't
+taken.
+
+The =Reuse & lost opportunities= dimension exists to catch exactly this. It was
+filled in, it named =cj/executable-find-or-warn=, and it dismissed the helper in one
+clause for warning as a side effect. Warning as a side effect is the feature. The
+dimension was answered without being used.
+
+Three claims in the surrounding audit came from grepping for a helper's *name* rather
+than reading for the *behavior*, and all three were wrong: the org modules were said
+not to guard their binaries when they guard them at the point of use, which is the
+better place. Reading corrected the claim each time.
+
+The one real finding this line of work produced landed elsewhere and stands: ledger
+buffers were never linted, fixed in 55b85754.
+
+* Metadata
+| Status | cancelled |
+|----------+----------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+----------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+----------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org]] — "Add an Org workflow health check command" |
+|----------+----------------------------------------------------------|
+
+* Summary
+
+A single on-demand command, =cj/org-workflow-doctor=, that checks whether the Org
+workflow's prerequisites are actually present: the files and directories it reads
+and writes, the external programs it shells out to, and the optional packages it
+defers loading. It reports what it found and never changes anything.
+
+Today a missing prerequisite surfaces at command time, in whatever shape the first
+module to trip over it happens to produce. The doctor moves that discovery to a
+moment the user chose.
+
+* Problem / Context
+
+#+begin_quote
+*Correction, 2026-07-10.* The premise below is false and the body is left intact as
+the record of the mistake. Every external binary *is* checked, at its point of use:
+=hugo-config= guards hugo and the file-manager opener, =org-webclipper= guards
+pandoc, =org-export-config= guards zathura, and =ox-pandoc= guards itself upstream.
+The claim came from grepping for =cj/executable-find-or-warn= by name instead of
+reading the modules for the behavior. See the Postmortem above.
+#+end_quote
+
+The Org workflow spans many modules, and each depends on some mix of a personal
+path (=org-dir=, =roam-dir=), an external binary (pandoc, hugo), and an optional
+package that loads lazily (=org-noter=, =org-web-tools=). None of those
+dependencies is checked anywhere.
+
+When one is missing, the failure appears wherever the first module happens to hit
+it. A missing =contacts-file= surfaces as a capture template erroring mid-capture.
+An absent pandoc surfaces as a shell command returning nothing useful. The user
+learns about a broken prerequisite at the least convenient moment, and the message
+rarely names the prerequisite.
+
+Nothing about this is hard. It just isn't anywhere, and the checks are scattered
+across modules that each know only their own corner.
+
+* Goals and Non-Goals
+
+** Goals
+- One command reports the health of every Org workflow prerequisite.
+- The check never mutates user data. It reads, it does not create or repair.
+- The result is structured data, so it is unit-testable and can be rendered to
+ either the echo area or a buffer without recomputing.
+- Startup stays quiet. Nothing runs unless asked.
+
+** Non-Goals
+- It will not fix anything. No creating a missing directory, no installing a
+ package, no offering to. A doctor that repairs is a different, riskier command.
+- It will not check every package in the config, only the Org workflow's.
+- It will not run on a timer, a hook, or at startup.
+- It will not be a general config linter. The scope is the Org workflow.
+
+** Scope tiers
+- v1: the seven paths, the two external binaries, the six optional packages, a
+ structured result, and a rendering to a buffer.
+- Out of scope: repair actions, non-Org prerequisites, scheduled runs.
+- vNext: a =--fix= variant that offers to create missing directories after
+ confirmation; checking that =org-agenda-files= entries all resolve.
+
+* Design
+
+** For the caller
+
+=M-x cj/org-workflow-doctor= opens a report buffer listing every prerequisite with
+its status. A prerequisite is =ok=, =missing=, or =skipped= (checked something the
+user hasn't configured). With a prefix argument the command reports a one-line
+summary to the echo area instead, for a quick "is anything broken?" glance.
+
+Nothing on disk changes. Running it twice produces the same report.
+
+** For the implementer
+
+The command splits in two, per the interactive-versus-internal rule.
+
+=cj/org-workflow--check= is pure with respect to user data: it probes the
+environment and returns a list of plists, one per prerequisite, each carrying
+=:name=, =:kind= (=path= / =executable= / =package=), =:status= and =:detail=. It
+takes no arguments and prompts for nothing, so a test can call it directly against
+a temp =user-emacs-directory= and assert on the structure.
+
+=cj/org-workflow-doctor= is the thin interactive wrapper: call the internal,
+render the result, done.
+
+*** The probe each kind uses
+
+Paths are checked with =file-exists-p= against the variable's value, and reported
+=skipped= when the variable is unbound or nil rather than =missing= — an unset
+=cj/hugo-content-org-dir= means the user doesn't publish with Hugo, which is not a
+fault.
+
+Executables are checked with =executable-find=.
+
+Packages are the one place a naive implementation gets it wrong, and this is the
+decision the spec exists to record. The obvious probe is =featurep=, and it is
+incorrect here: this config defers loading, so =featurep= returns nil for a package
+that is installed and perfectly healthy. Probed on 2026-07-10, =org-noter= and
+=org-web-tools= both report =(featurep) => nil= while =(locate-library) => t=. A
+doctor built on =featurep= would report a false failure for precisely the packages
+the config is designed to load lazily, which is worse than no doctor: it teaches
+the user to ignore it.
+
+=locate-library= answers the question actually being asked, which is "can this load
+when something needs it?" rather than "has it loaded already?".
+
+* Alternatives considered
+
+*** Probe packages with =featurep=
+- Good, because: it is the first thing that comes to mind and costs nothing.
+- Bad, because: it reports false failures for every lazily-loaded package, which is
+ most of them. Verified, not theorised.
+- Neutral, because: it would be correct in a config that loads everything eagerly.
+
+*** Check prerequisites at startup instead of on demand
+- Good, because: the user learns about a broken prerequisite before they need it.
+- Bad, because: it costs startup time on every launch to answer a question asked a
+ few times a year, and it puts warnings in front of a user who didn't ask.
+- Neutral, because: a doctor command can be run from a startup hook later if the
+ cost turns out to be trivial.
+
+*** Have each module check its own prerequisites
+- Good, because: the check lives next to the thing that needs it.
+- Bad, because: this is the status quo, and the problem is that the checks don't
+ compose into an answer to "is my Org workflow healthy?".
+- Neutral, because: the doctor doesn't prevent a module from also checking.
+
+* Decisions [2/2]
+
+** DONE Probe optional packages with =locate-library=, not =featurep=
+Context: the config loads Org packages lazily, so a healthy package is routinely
+unloaded. Probed 2026-07-10: =org-noter= and =org-web-tools= are both
+=featurep=-nil and =locate-library=-non-nil.
+
+Decision: we will probe package availability with =locate-library=.
+
+Consequences: the doctor answers "can this load?", which is the question that
+matters, and it stops reporting false failures for deferred packages. Harder: the
+doctor cannot distinguish "installed but broken on load" from "installed and fine",
+because it deliberately does not load anything. That is the right trade for a
+read-only check, and a load error surfaces at use time anyway.
+
+** DONE An unset optional path reports =skipped=, not =missing=
+Context: not every user of this config publishes with Hugo or uses reveal.js. An
+unbound or nil =cj/hugo-content-org-dir= is a configuration choice, not a fault.
+
+Decision: we will report =skipped= when a path variable is unbound or nil, and
+=missing= only when it holds a value that does not resolve on disk.
+
+Consequences: the report stays honest, so a clean report means something. Harder:
+the status vocabulary grows a third value, and the renderer has to distinguish
+three states rather than two.
+
+* Implementation phases
+
+1. *The internal, with tests.* =cj/org-workflow--check= plus its three probe
+ helpers (path, executable, package). Tests drive real state: a temp directory
+ that exists and one that doesn't, an executable that resolves and a nonsense
+ name, a library that locates and one that doesn't. Tree is working; nothing is
+ bound to a key yet.
+2. *The renderer and the command.* =cj/org-workflow-doctor=, the report buffer, and
+ the prefix-argument echo-area summary. Tests cover the rendering of a synthetic
+ result list, not the environment.
+
+* Acceptance criteria
+
+- =cj/org-workflow--check= returns one plist per prerequisite, each with =:name=,
+ =:kind=, =:status= and =:detail=.
+- A path variable holding a resolving directory reports =ok=; one holding a
+ nonexistent path reports =missing=; one unbound or nil reports =skipped=.
+- =org-noter= and =org-web-tools= report =ok= on this machine despite being
+ unloaded. This is the regression the spec exists to prevent.
+- Running the command twice leaves the filesystem byte-identical.
+- The command is absent from every hook and timer.
+
+* Readiness dimensions
+
+- *Data model & ownership* — the result is generated, ephemeral, and owned by the
+ command. Nothing persists.
+- *Errors, empty states & failure* — a probe that throws is caught per-prerequisite
+ and reported as =missing= with the error text as =:detail=, so one bad probe
+ cannot abort the report.
+- *Security & privacy* — the report prints paths from the user's config. It stays
+ in a local buffer and is never written to disk or transmitted.
+- *Observability* — the report is the observability.
+- *Performance & scale* — fifteen probes, all local filesystem stats. No concern.
+- *Reuse & lost opportunities* — =executable-find= and =locate-library= are the
+ platform's answers; nothing is reimplemented. =cj/executable-find-or-warn=
+ (=system-lib.el=) exists but warns as a side effect, which a read-only check must
+ not do, so the doctor calls =executable-find= directly.
+- *Architecture fit* — a new module, =modules/org-workflow-doctor.el=, requiring
+ nothing but the variables it probes. It must not require the Org modules, or
+ probing them would load them and defeat the lazy-loading it is checking.
+- *Config surface* — none in v1. The prerequisite list is a defconst.
+- *Documentation plan* — module commentary, plus the keybinding if one is added.
+- *Dev tooling* — the existing =make test= covers it. No new target.
+- *Rollout, compatibility & rollback* — additive, read-only, deletable. N/A.
+- *External APIs & deps* — none. Both binaries were verified present on 2026-07-10
+ (=/usr/bin/pandoc=, =/usr/bin/hugo=), and their absence is the case under test.
+
+* Risks, rabbit holes, and drawbacks
+
+The rabbit hole is scope. "Check the Org workflow's prerequisites" slides easily
+into "lint the whole config", and from there into "offer to fix what it finds". The
+non-goals exist to hold that line. If the doctor is useful, a =--fix= variant is a
+separate spec with a separate risk profile, because a command that creates
+directories is no longer read-only.
+
+The smaller risk is the prerequisite list going stale as modules change. A doctor
+that checks the wrong things is worse than none, since it reports health that isn't
+real. The defconst lives next to the probes so it is at least easy to find.
+
+* Review and iteration history
+
+** 2026-07-10 Fri @ 00:29:10 -0500 — Craig Jennings — Author
+What: drafted the spec.
+Why: the task is feature-level, so the speedrun's per-item disposition rule
+delivers a spec rather than an implementation.
+Artifacts: this file. Probed the live daemon for the seven paths, both binaries,
+and the six packages; the =featurep= finding drove the first decision.
diff --git a/docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org b/docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org
new file mode 100644
index 00000000..567da53b
--- /dev/null
+++ b/docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org
@@ -0,0 +1,404 @@
+#+TITLE: org-agenda fullscreen frame — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-17
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED org-agenda fullscreen frame
+:PROPERTIES:
+:ID: 7705c94b-9bb7-47d8-9828-e9584172c54f
+:END:
+- 2026-07-20 Mon @ 15:33 -0500 — post-implementation correction: dropped =(fullscreen . fullboth)= from the spawned frame. Craig's "fullscreen" meant a normal frame at its full tiled position, not a compositor-wide fullscreen; the frame is now a plain =make-frame= that a tiling WM (Hyprland) places side by side with the working frame. The engage-routing and focus logic are unchanged and now matter more (open a task in the adjacent working pane). The spec's "fullscreen" wording and filename are retained as historical; read them as "dedicated tiled frame". Covered by =test-org-agenda-frame-parameters-normal-tiled-frame=.
+- 2026-07-20 Mon @ 14:10 -0500 — IMPLEMENTED. Built both phases in =modules/org-agenda-frame.el= (58 ERT tests, full suite green, byte-compile clean, full init loads clean, live-reloaded into the daemon with all wiring confirmed). Phase 1: frame lookup/predicate/working-frame routing, the =F= today-anchored 7-day view + registration, the default-deny =cj/agenda-frame-mode= (allowlist + two message classes + menu removal + finalize re-enable), transactional spawn/raise/delete/toggle, engage routing, sticky/close lifecycle. Phase 2: the 5-min wall-clock =org-agenda-redo= timer with the window/focus contract, duplicate-timer prevention, deterministic point restoration, the frame-owned last-good snapshot with cloned markers + failure overlay + report-once latch, and the public =cj/agenda-frame-toggle= on =S-<f8>= with the force-rescan moved to =C-M-<f8>=. The compositor fullscreen/focus and real-redo behaviors are a residual manual check (VERIFY filed under Manual testing).
+- 2026-07-20 Mon @ 13:30 -0500 — DOING. Decomposed into build tasks (Phase 1, Phase 2, flip-to-IMPLEMENTED) under the fullscreen-frame PROJECT in todo.org; =:SPEC_ID:= stamped. Building Phase 1.
+- 2026-07-20 Mon @ 13:26 -0500 — READY (Craig accepted for build). Folded Codex's marker-clone finding (all 28 findings, 13 decisions resolved). Craig's call: build v1 now; the Hyprland-window variant is vNext once the in-Emacs frame proves out. Decomposing into build tasks next.
+- 2026-07-20 Mon @ 13:07 -0500 — spec-review (Codex): still DRAFT. The two prior responses close their stated behavior gaps, but Org invalidates the old agenda's marker objects during redo; the specified shallow property-bearing snapshot therefore restores dead =org-marker=/=org-hd-marker= links after a failed rebuild. One blocking marker-cloning/ownership finding added.
+- 2026-07-20 Mon @ 12:59 -0500 — spec-response: both fifth-pass findings dispositioned. Enumerated the full default-deny allowlist + honest keys/mouse enforcement boundary (menu removed, M-x out of contract); made failed-redo a complete retryable state (frame-owned snapshot with Org properties, explicit policy re-enable on the error path, overlay header, rebuild from =org-redo-cmd=). Findings [27/27]; Decisions [13/13]. Stays DRAFT pending Codex re-review.
+- 2026-07-20 Mon @ 12:47 -0500 — spec-review (Codex): still DRAFT. The structural response closes the prior eight findings, but two blockers remain: the default-deny minor-mode map neither defines its actual allowlist nor covers menu/M-x invocation, and failed-redo rollback does not restore a complete policy-enabled, retryable agenda state.
+- 2026-07-20 Mon @ 12:29 -0500 — renamed the file =…-dock-mode-spec.org= → =…-org-agenda-fullscreen-frame-spec.org= to match the design; inbound links in todo.org and .ai/notes.org updated. The ":ID:" is unchanged.
+- 2026-07-20 Mon @ 12:26 -0500 — spec-response: all 8 fourth-pass findings dispositioned. Root fix: replaced keymap enumeration with a default-deny =cj/agenda-frame-mode= policy (re-enabled by a finalize hook so redo can't strip it), frame-owned timer/failure state, sticky-kill-on-close, failed-redo snapshot restore, non-interactive Phase 1, synced ERT. Decisions now 13; findings [25/25]. Stays DRAFT pending Codex re-review.
+- 2026-07-20 Mon @ 12:11 -0500 — spec-review (Codex): still DRAFT. Detail-complete re-review found eight blocking gaps beneath the resolved values: Phase 1 remains callable through M-x; mutation, display-opening, view-change, and manual-redo command classes are incomplete; Org redo discards dedicated buffer-local policy/state; sticky reopen can show stale content; failed-redo display state is undefined; and the ERT surface does not cover the expanded contracts.
+- 2026-07-20 Mon @ 12:02 -0500 — spec-response: all 7 third-pass findings dispositioned (accepted; 4 with a chosen value). Key =F=, current-window, cached build on spawn, read-only command policy, source-opening routing, retry-report-once refresh, unbound-Phase-1 then binding-in-Phase-2, deterministic point tie-break. Decisions now 12; findings [17/17]. Stays DRAFT pending Codex re-review.
+- 2026-07-20 Mon @ 11:29 -0500 — spec-review (Codex): still DRAFT. A stricter second implementation-readiness pass found seven blocking choices still deferred to implementation: agenda command identity/window setup, initial source-list preparation, the permitted command surface, non-RET/TAB display routing, timer-failure recovery, phase exposure, and refresh point tie-breaking.
+- 2026-07-20 Mon @ 10:22 -0500 — spec-response: all 10 findings dispositioned (accepted; 2 with a chosen behavior). Design, Decisions (now 10), phases (now 2), acceptance, and the test surface updated. Stays DRAFT pending a re-review to confirm the blockers are closed.
+- 2026-07-20 Mon @ 10:12 -0500 — spec-review (Codex): demoted READY → DRAFT. Independent code-grounded re-review found six blocking gaps in date anchoring, exit/buffer lifecycle, working-frame fallback, phase safety, refresh context, and automated test coverage; three non-blocking implementation details also need definition.
+- 2026-07-20 Mon @ 09:53 -0500 — spec-review: READY. All 8 decisions resolved; code read confirmed the F8 bindings and display rule. One non-blocking finding recorded (the frame view needs its own custom-command entry). Ready to decompose the 3 phases.
+- 2026-07-20 Mon @ 09:45 -0500 — redesigned: a dedicated fullscreen frame replaces the right-side side-window dock. All eight design decisions resolved (see Decisions). The design is a fullscreen agenda frame, not a side dock; the file was later renamed to match.
+- 2026-07-17 Fri @ 19:34:07 -0500 — drafted.
+
+* Metadata
+| Status | implemented |
+|----------+------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+------------------------------------------------|
+| Reviewer | Codex |
+|----------+------------------------------------------------|
+| Related | [[file:../../todo.org][fullscreen-frame task (Emacs Open Work)]] |
+|----------+------------------------------------------------|
+
+* Summary
+
+A dedicated fullscreen Emacs frame that shows the agenda. One key spawns (or raises) a frame of the running daemon, fullscreened, displaying the next seven days of schedule and tasks, refreshing itself every few minutes. Inside that frame focus stays on the agenda. The point is a standing, always-current agenda surface you can throw on its own workspace or monitor, one keystroke away, without the agenda ever stealing the frame you work in.
+
+* Problem / Context
+
+The normal agenda (=<f8>=, =cj/main-agenda-display=) opens below-selected at 75% of the frame (=cj/org-agenda-window-height=, applied through =cj/--org-agenda-display-rule=). That's a modal, take-over-the-frame view: you summon it, read it, dismiss it, and go back to work. There's no way to keep the schedule and task list glanceable while working, and no live refresh, so an agenda left open drifts stale (the now-line and freshly-synced calendar events don't move).
+
+Craig wants a second, non-modal surface: the agenda living in its own fullscreen frame, current and ready to engage, so it can sit on a separate workspace or monitor while the working frames stay untouched. This is a distinct mode from the existing full-view agenda, not a replacement for it.
+
+* Goals and Non-Goals
+
+** Goals
+- A dedicated fullscreen frame of the running daemon showing the seven-day schedule and task list, wholly separate from the working frames.
+- Live: the frame refreshes on a few-minute cadence so the now-line and synced events stay current. It shares the daemon's state, so calendar-sync results and buffer edits are already reflected.
+- One key to spawn, raise, and close it.
+- Focus stays on the agenda inside that frame; engaging a task opens the file in the working frame, not over the agenda.
+
+** Non-Goals
+- Not a replacement for the existing =<f8>= full-view agenda — that stays as is.
+- Not a separate OS process. It's a frame of the running daemon (chosen for live shared state over an isolated second Emacs). It therefore dies with the daemon, which is acceptable.
+- Not a capture/scratch surface in v1. Whether an agenda surface should double as a scratchpad is a separate exploration (see the [#D] task); v1 is read + engage only.
+- Not a startup auto-open. The frame is spawned on demand by its key; there is no auto-open defcustom.
+- Not a redesign of agenda content or faces — it reuses org-agenda's rendering.
+
+** Scope tiers
+- v1: a spawn/raise/close command bound to =S-<f8>=; a fullscreen frame; a seven-day agenda view filling it; focus held on the agenda; jump-to-task routed to the working frame; a frame-scoped refresh timer; the force-refresh keybinding move.
+- Out of scope: the scratchpad surface; a separate-process agenda; persisting frame state across daemon restarts.
+- vNext: scratchpad exploration (its own task); richer view tuning once the mode is in daily use; if the fullscreen frame proves out, reimplement as a Hyprland-managed window with its own keybinding — an external launcher replacing the in-Emacs =S-<f8>= spawn (Craig, 2026-07-20). v1 stays the in-Emacs frame.
+
+* Design
+
+The agenda lives in its own frame of the running daemon, created with =make-frame= carrying a marker parameter (=(cj/agenda-frame . t)=) so the toggle can find, raise, or delete it. (The frame was originally spawned =(fullscreen . fullboth)=; that was dropped 2026-07-20 so a tiling WM places it side by side with the working frame rather than covering the whole output — see the status history.) Sharing the daemon means the frame sees the same live state as every working frame: calendar-sync writes, unsaved buffer edits, and the current now-line are all already there, so "live" needs only a periodic redo, not a reload.
+
+For the user, the interface is one key. =S-<f8>= toggles the frame: spawn it fullscreen if none exists, raise and select it if it exists but isn't focused, delete it if it's the selected frame. The current occupant of =S-<f8>= (=cj/org-agenda-refresh-files=, the manual force-rescan shipped in 17ae3e2a) moves to =C-M-<f8>=, keeping the whole force-refresh idea in the F8 family.
+
+Inside the agenda frame, focus stays on the agenda. The frame holds a single window showing the seven-day agenda buffer, so point rests there by construction. Engaging a task (=RET=/=TAB= on an agenda line) opens the target file in the working frame and raises it; the agenda frame is never chosen as the display target, so it keeps showing the agenda. In the working frame (the one that launched it, and every other frame) there are no such restrictions — normal behavior throughout.
+
+For the implementer, these pieces compose:
+
+1. A toggle command that spawns, raises, or deletes the marked frame. Spawn is transactional: create the fullscreen frame, run the cached non-forced =(cj/build-org-agenda-list)= (the same prep =cj/main-agenda-display= does at :380-391, so a frame spawned early after daemon startup isn't the base-files-only agenda), build and display the dedicated agenda buffer, install the frame-local routing, and start the timer. If any step fails after =make-frame= succeeds — including a build failure — cancel the timer, delete the partial frame and buffer, leave the original working frame selected, and report =Agenda frame: <operation> failed: <cause>=. Delete removes the frame and stops the timer. The frame carries the =cj/agenda-frame= marker so the command locates it among the daemon's frames.
+
+2. A dedicated seven-day agenda view, defined as its own =org-agenda-custom-commands= key =F= (the existing top-level key at =org-agenda-config.el:344= is =d=, so =F= is collision-free), with =org-agenda-sticky= and =(org-agenda-window-setup 'current-window)= bound in that command's *local* settings. =org-agenda-sticky= local gives Org a distinct =*Org Agenda(F)*= buffer without touching the ordinary =<f8>= behavior; =current-window= keeps the view in the frame's sole window (Org 9.7.11 defaults =org-agenda-window-setup= to =reorganize-frame=, which would split the new frame). The span is anchored to today, not the week: =(org-agenda-span 7)= plus =(org-agenda-start-day "0d")= and =(org-agenda-start-on-weekday nil)= (=org-agenda-list= otherwise anchors any seven-day span to Monday in Org 9.7.11; the daily command uses the same pair at :347-349). No separate TODO block — the span surfaces scheduled work; unscheduled-priority surfacing is a vNext question.
+
+3. A read-only command policy by *default-deny, not enumeration*. The dedicated buffer runs a minor mode =cj/agenda-frame-mode= whose keymap binds =[t]= (the catch-all) to a denial handler, so every *key or mouse* command not on an explicit allowlist is intercepted by one fallthrough — the whole keymap is covered, and a future Org binding is denied by default. The enforcement boundary is keys and mouse, stated honestly: a minor-mode map can't intercept an Agenda-menu item or a direct =M-x=, so the mode also *removes the Agenda menu-bar* in the dedicated buffer (no menu path to a mutation), and direct =M-x org-agenda-…= is explicitly *out of contract* — typing the command name is a deliberate bypass of a read-only surface, like editing a read-only buffer under =inhibit-read-only=. The complete allowlist is: (a) navigation — =org-agenda-next-line=/=-previous-line=, =org-agenda-next-item=/=-previous-item=, the arrow keys, =C-n=/=C-p=, =C-v=/=M-v= scroll, =M-<=/=M->=, and =C-g=; (b) engage/open — =RET=/=TAB= (=org-agenda-switch-to=/=org-agenda-goto=), mouse-2 (=org-agenda-goto-mouse=), =C-c C-o= (=org-agenda-open-link=), each routed to the working frame — the MRU live non-agenda frame at engagement time, or a new normal (non-fullscreen) frame when none exists — never into the agenda frame; and (c) the frame's own controls — =S-<f8>= toggle, =C-M-<f8>= force-rescan, =q=/=Q=/=x= close, and =r= (remapped to the safe-redo wrapper, below). Everything else is denied with a one-line message: source mutations (TODO-state, tags, priority, effort, schedule/deadline, refile, archive, kill, bulk, and any other) and buffer-opening/preview/follow commands that would split or replace the frame (=SPC=/=DEL=, mouse-3, =C-c C-x b=, clock-goto, follow mode) show =Agenda frame is read-only — press RET to edit in your working frame=; and view-changing commands that would break the today-anchored span (=d=/=w=/=y=, =f=/=b=, =j=, =g=) show =Agenda frame is fixed to the 7-day view=. The one exception is =r= (manual redo): it's allowlisted but remapped to the frame's own safe-redo wrapper (the manual version of the timer tick), so a manual refresh takes the same failure-latched, focus-safe path. The dedicated view also binds =org-agenda-start-with-follow-mode= nil locally, so a non-nil global default can't turn follow mode on at buffer creation. Because =org-agenda-redo= rebuilds through =org-agenda-mode=, which runs =kill-all-local-variables=, the mode is re-enabled after every build by =org-agenda-finalize-hook= gated on the frame's =cj/agenda-frame= marker (a frame parameter, which survives the buffer reset) — so the refresh never strips the policy. =q=/=Q=/=x= delete the marked frame and cancel its timer, and closing the frame (by any path) also kills the =*Org Agenda(F)*= sticky buffer, so the next spawn regenerates fresh rather than reusing stale sticky content; killing the dedicated buffer deletes the frame too, so no orphan fullscreen frame survives.
+
+4. A frame-scoped refresh timer: =org-agenda-redo= on the dedicated buffer every 5 minutes, wall-clock aligned, started on spawn and cancelled on delete/kill, with a live-frame/live-buffer guard and duplicate-timer prevention. The callback runs with the dedicated agenda window selected for the redo's dynamic extent, restores the prior selected frame and window afterward, and never calls an input-focus function — so a tick while Craig works in another frame neither errors on an out-of-range =window-start= nor steals focus. Point restoration is deterministic: restore the same-=org-marker= occurrence closest to the old agenda line (one marker can appear twice, e.g. a scheduled and a deadline line); if the marker is gone, clamp the old line number into the rebuilt buffer's range; on a header line move to the first agenda item; an empty view leaves point at buffer start. On a failed redo the timer keeps running and retries on the next scheduled tick — a transient failure shouldn't kill live refresh — and the failure is reported once per consecutive-failure run (naming the next action: =C-M-<f8>= force-rescan or re-spawn), with the next successful tick clearing the failure state. Because =org-agenda-redo= erases the buffer before it rebuilds and =org-agenda-mode= would strip the policy, the callback keeps a *frame-owned last-good snapshot* — the buffer text with its =org-redo-cmd=/=org-lprops= properties, point, window-start, and any active filter/identity — captured from the last successful build, undecorated. Markers get special handling: =org-agenda-reset-markers= nulls the old =org-marker=/=org-hd-marker= objects during a rebuild, so the snapshot must not keep them by reference — it *clones* each agenda marker into a snapshot-owned live marker (=copy-marker= into the unchanged source buffer) and reinstalls the clones on restore, so =RET=/=TAB= still resolve to the right source line; the clones are released when a successful build replaces the snapshot or the frame closes, so repeated failures don't leak markers. On a redo error it restores that snapshot verbatim, *explicitly re-enables =cj/agenda-frame-mode=* (the finalize hook runs only on success, so the error path must reinstate the policy or the frame would be left unrestricted), and shows the "refresh failed (=C-M-<f8>= to force-rescan)" line as an *overlay*, not inserted text — so the Org properties aren't corrupted and consecutive failures don't accumulate headers. The next tick then redoes from the preserved =org-redo-cmd= (a real rebuild, not a no-op off an error header); a success removes the overlay and replaces the snapshot. So the frame is never blank, never unrestricted, and always retryable — =RET= and navigation keep working on the restored snapshot. A thin wrapper suppresses =org-agenda-redo='s routine "Rebuilding..." chatter but lets that one failure message through. =redo= re-reads file contents, not the file list — a new project's todo.org still needs the manual force-rescan.
+
+** Not a prototype-pipeline UI
+
+=ui-prototyping.md='s research → five-prototype → iterate process governs bespoke visual surfaces (panels, multi-control widgets, SVG faceplates). This frame is org-agenda's existing text rendering shown fullscreen; the layout question ("where does it go?") is answered in a sentence (its own fullscreen frame). So the prototype pipeline is intentionally skipped. The open questions here were behavioral (frame vs process, view span, focus, jump routing, auto-open), not visual-layout, and they're settled in the Decisions below.
+
+* Alternatives Considered
+
+** Dedicated fullscreen frame of the daemon (chosen)
+- Good, because it shares the daemon's live state: calendar-sync, open buffers, and edits are already reflected, so "live" costs only a periodic redo.
+- Good, because it's genuinely separate from the working frames — it can sit on its own workspace or monitor and never steals working space.
+- Neutral, because it dies with the daemon (no cross-restart persistence). Acceptable for v1.
+
+** A separate Emacs process
+- Good, because it's fully isolated and survives a daemon restart.
+- Bad, because it doesn't share the daemon's in-memory state — it reads the org files from disk and only reflects what's been saved. The "always current, shares live edits" goal argues against it, so it was rejected in favor of a frame.
+
+** A right-side side-window dock (the prior draft's design)
+- Good, because it lives in the working frame and is protected from =delete-other-windows=.
+- Bad, because it isn't what Craig wants: it shares the working frame rather than standing alone, it's bounded to a dock width that crowds the working area, and it can't move to its own workspace or monitor. Superseded by the fullscreen frame.
+
+** A normal split window
+- Good, because it's the least new machinery.
+- Bad, because it isn't protected, has no clean "this is the agenda surface" identity, and takes over the working frame. This is what the current 0.75 rule already gives and what the frame needs to be different from.
+
+** Global "refresh any visible agenda" timer
+- Good, because it would also refresh the =<f8>= full view when left open.
+- Bad, because it has no clean lifecycle — it has to poll for visible agenda buffers and decide when to stop. The frame-scoped timer starts and stops with the frame, which is simpler and matches the feature's boundary.
+
+* Decisions [13/13]
+
+** DONE Dedicated fullscreen frame as the display mechanism
+- Context: the surface must stand wholly apart from the working frames, be placeable on its own workspace or monitor, and stay live.
+- Decision: We display the agenda in a dedicated frame of the running daemon, created with =make-frame= + =(fullscreen . fullboth)= and a =cj/agenda-frame= marker parameter. A frame (not a separate process) so it shares the daemon's live state.
+- Consequences: easier — live state, no reload, full isolation from working frames. Harder — it dies with the daemon; frame lookup and lifecycle must be explicit.
+
+** DONE Launch/toggle on S-<f8>; force-refresh moves to C-M-<f8>
+- Context: =S-<f8>= currently runs =cj/org-agenda-refresh-files= (shipped 17ae3e2a); Craig wants =S-<f8>= to spawn/raise/close the agenda frame.
+- Decision: =S-<f8>= toggles the agenda frame (spawn if none, raise+select if unfocused, delete if selected). =cj/org-agenda-refresh-files= rebinds to =C-M-<f8>=, keeping both in the F8 family.
+- Consequences: easier — one gesture, next to the other agenda keys. Harder — one existing binding moves; the docstring/family comment needs updating.
+
+** DONE View: the seven-day agenda span alone
+- Context: "the day's work visible" is a composite; how many days, and whether to append a TODO block.
+- Decision: the frame shows a seven-day agenda span (=org-agenda-span= 7) as one =org-agenda-custom-commands= entry, with no separate prioritized TODO block. The span already surfaces scheduled work.
+- Consequences: easier — one custom-command entry, no block composition to tune. Harder — unscheduled priorities aren't surfaced; that's a vNext tuning question if it turns out to matter.
+
+** DONE Focus stays on the agenda inside the frame
+- Context: opening or working the agenda frame should keep point on the agenda, not scatter into other buffers; the launching frame stays unrestricted.
+- Decision: the agenda frame holds a single window on the agenda buffer and point rests there; no focus restrictions apply to any other frame.
+- Consequences: easier — the frame is unambiguous to use. Harder — anything that would open a buffer in the frame must be routed elsewhere (see jump-to-task).
+
+** DONE Jump-to-task opens the file in the working frame
+- Context: engaging a task from a fullscreen agenda must not replace the agenda with the target file.
+- Decision: =RET=/=TAB= on an agenda line opens the target file in the working frame (the most-recently-selected non-agenda frame) and raises it; the agenda frame keeps showing the agenda.
+- Consequences: easier — the agenda frame is genuinely "ready to engage" and stays pure. Harder — needs deliberate =org-agenda-window-setup= / =display-buffer= handling so the agenda frame is never the jump target (the likeliest rabbit hole).
+
+** DONE Frame-scoped refresh via org-agenda-redo, wall-clock aligned
+- Context: the frame must stay current (now-line, synced events) without re-scanning the file list or churning a timer when it's closed.
+- Decision: run =org-agenda-redo= on the agenda buffer every 5 minutes aligned to the :00/:05/:10 mark, started on spawn and cancelled on delete/kill. Not a file-list rebuild — that's the manual force-rescan.
+- Consequences: easier — cheap, self-scoped, picks up content changes. Harder — a brand-new project's todo.org won't appear until a manual force-rescan or the 24h cache TTL lapses.
+
+** DONE No startup auto-open
+- Context: the frame could spawn automatically on daemon start.
+- Decision: no auto-open. The frame is spawned on demand by =S-<f8>=; there is no auto-open defcustom.
+- Consequences: easier — no startup coupling, no small-frame-at-startup edge case, one fewer knob. Harder — none; a standing frame is one keystroke away.
+
+** DONE Not a separate process — a frame of the daemon
+- Context: "its own Emacs" could mean an isolated process or a frame of the running daemon.
+- Decision: a frame of the daemon, for live shared state (calendar-sync, edits, now-line). A separate process would only reflect saved-to-disk state.
+- Consequences: easier — always current, zero duplicated config load. Harder — no cross-restart persistence; the frame is gone after a daemon restart and re-spawned by its key.
+
+** DONE Frame-local exit and buffer-kill semantics
+- Context: =S-<f8>= is the intended close, but Org's =q=/=Q=/=x= and a buffer-kill can otherwise leave the sole-window fullscreen frame alive on an unrelated buffer.
+- Decision: =q=/=Q=/=x= in the agenda frame delete the marked frame and cancel its timer; killing the dedicated agenda buffer deletes the frame too. Ordinary agenda buffers in other frames are unaffected.
+- Consequences: easier — the frame can never strand on a non-agenda buffer. Harder — needs frame-local key remaps and a buffer-kill hook scoped to the dedicated buffer.
+
+** DONE Working-frame target and no-frame fallback
+- Context: "the most-recently-selected non-agenda frame" is the intent; the launch frame may be gone, several may exist, or the agenda frame may be the only live frame.
+- Decision: engage targets the MRU live non-agenda frame at engagement time; when none exists, create a normal (non-fullscreen) frame and open there. The engage action never falls back into the agenda frame.
+- Consequences: easier — a deterministic target in every frame state. Harder — the deleted-launch, multiple-frame, and no-frame cases each need explicit handling and tests.
+
+** DONE Read-only policy by default-deny, not enumeration
+- Context: enumerating =org-agenda-mode-map= command-by-command can't converge (a review always finds a missed key), and =org-agenda-redo='s =kill-all-local-variables= would strip a buffer-local policy on every refresh.
+- Decision: a minor mode =cj/agenda-frame-mode= with a =[t]= catch-all denial handler shadows =org-agenda-mode-map= — only an enumerated allowlist (navigation, the engage/open keys routed to the working frame, and the frame's own controls =S-<f8>=/=C-M-<f8>=/=q=/=Q=/=x=/=r=) is permitted; every other key/mouse command shows a read-only or fixed-view message. The enforcement boundary is keys and mouse: the mode removes the Agenda menu-bar in the buffer, and direct =M-x= is out of contract. The mode is re-enabled after each build by =org-agenda-finalize-hook= gated on the frame's =cj/agenda-frame= marker (and by the failed-redo error path), so redo can't strip it.
+- Consequences: easier — one rule covers the whole keymap and survives refresh, with nothing to enumerate or keep in sync. Harder — the allowlist and the two message classes (read-only vs fixed-view) need explicit definition and tests.
+
+** DONE Sticky buffer and failed-redo display lifecycle
+- Context: closing the frame left the command-local sticky =*Org Agenda(F)*= buffer alive (a reopen would reuse stale content), and =org-agenda-redo= erases the buffer before rebuilding (a failed redo would show blank/partial).
+- Decision: closing the frame by any path also kills the =*Org Agenda(F)*= buffer, so the next spawn regenerates fresh. The refresh keeps a frame-owned last-good snapshot (buffer text with =org-redo-cmd=/=org-lprops=, point, window-start, filter/identity — undecorated; agenda markers *cloned* via =copy-marker= since =org-agenda-reset-markers= nulls the originals on rebuild); a failed redo restores it verbatim, re-enables the policy explicitly, and shows the failure as an overlay; the next tick rebuilds from the preserved =org-redo-cmd=. The clones are released on the next success or on close. The frame is never blank, unrestricted, or non-retryable.
+- Consequences: easier — reopen is always current and a failed refresh degrades to stale-but-readable. Harder — the close paths must kill the sticky buffer, and the callback carries a pre-redo snapshot.
+
+** DONE Refresh-failure recovery: retry, report once
+- Context: a failed =org-agenda-redo= tick needs a defined recovery, not "cancel or retry".
+- Decision: keep the timer running and retry on the next scheduled tick; report the failure once per consecutive-failure run, naming the next action (=C-M-<f8>= force-rescan or re-spawn); the next successful tick clears the failure state.
+- Consequences: easier — a transient failure doesn't kill live refresh, and the user isn't spammed. Harder — needs a small failure-state latch across ticks.
+
+* Review findings [28/28]
+
+** DONE Frame view needs its own custom-command entry (avoid the span-8 collision)
+=modules/org-agenda-config.el:344-348= already sets =org-agenda-custom-commands= with a =(org-agenda-span 8)= entry. The frame view must be its own custom-command key rather than reusing or editing the existing span-8 entry, and =org-agenda-sticky= is bound in that command's local settings, not globally, so Org derives the dedicated =*Org Agenda(KEY)*= buffer without changing the ordinary =<f8>= behavior.
+Response (accept): folded into Design piece 2.
+
+** DONE Seven-day view is week-anchored, not next-seven-days
+=org-agenda-span= 7 alone anchors to Monday in Org 9.7.11 (=org-agenda-start-on-weekday= defaults to 1). The frame command adds =(org-agenda-start-day "0d")= and =(org-agenda-start-on-weekday nil)= (the same pair the daily command uses at :347-349) so the range is today..today+6.
+Response (accept): folded into Design piece 2; acceptance criterion added (midweek starts today, ends six days later).
+
+** DONE Agenda exit paths can abandon the dedicated frame
+=q=/=Q=/=x= and a buffer-kill could otherwise leave the sole-window fullscreen frame alive on an unrelated buffer.
+Response (accept): added a Decision (frame-local exit + buffer-kill semantics), folded into Design piece 3, and added two acceptance criteria.
+
+** DONE Working-frame target and fallback are undefined
+The launch frame may be gone, several working frames may exist, or the agenda frame may be the only live frame.
+Response (accept, with a chosen behavior): target the MRU live non-agenda frame at engagement time; when none exists, create a normal (non-fullscreen) frame and open there (chose Codex's create-a-frame option over a user-facing refusal, so engage always succeeds). Added a Decision, folded into Design piece 3, and added the three frame-state acceptance cases.
+
+** DONE Phase 1 exposed a frame that violates v1 invariants
+The former Phase 1 exposed a fullscreen frame before the sticky buffer and jump routing existed, which is a broken intermediate state.
+Response (accept, with a chosen restructure): merged the former Phase 1+2 into a single Phase 1 that ships the dedicated sticky view plus full frame-local navigation/exit routing, so the first user-reachable state is fully isolated. Phases are now 2, not 3.
+
+** DONE Refresh callback lacks a window-and-focus contract
+=org-agenda-redo= reads selected-window state (=window-start=), so a bare =with-current-buffer= tick from another frame can miscalculate or error.
+Response (accept): folded into Design piece 4 (select the agenda window for the redo's extent, restore the prior frame/window, no input-focus calls) with an acceptance criterion.
+
+** DONE Frame and timer test surface is understated
+The repo already tests these boundaries (=tests/test-dirvish-config-popup.el= mocks frame lookup/focus/delete; =tests/test-ai-term--project-color.el= drives timer callbacks + a dead-buffer case), so "not cleanly unit-testable" was wrong.
+Response (accept): rewrote the Dev-tooling readiness dimension to require ERT coverage across frame lookup, spawn/raise/close, keybindings, sticky-buffer identity, the today-anchored settings, RET/TAB/exit routing, the MRU + no-frame fallback, timer alignment, duplicate-timer prevention, every cancellation path, dead frame/buffer guards, and silent-success/visible-failure behavior, with a live-daemon checklist only for compositor fullscreen/focus.
+
+** DONE Refresh point-preservation semantics are ambiguous
+=org-agenda-redo= preserves a line number, not the same item when lines shift.
+Response (accept): folded into Design piece 4 (restore the same =org-marker= when it exists, else clamp to the nearest valid line); Phase 2 carries it.
+
+** DONE Silent timer conflicts with org-agenda-redo messages
+=org-agenda-redo= emits "Rebuilding..." chatter on every run.
+Response (accept): folded into Design piece 4 (a wrapper suppresses routine success chatter, surfaces a failure once, and cancels/retries the stale timer); Observability updated; acceptance criterion added.
+
+** DONE Spawn failure cleanup and error message are undefined
+A failure after =make-frame= could leave an orphan frame and partial state.
+Response (accept): made spawn transactional in Design piece 1 (on failure: cancel the timer, delete the partial frame/buffer, restore the working frame, report =Agenda frame: <operation> failed: <cause>=); Errors dimension and an acceptance criterion added.
+
+** DONE Dedicated view identity and window setup remain placeholders
+The custom-command key and =org-agenda-window-setup= value are observable behavior, and the Org 9.7.11 default (=reorganize-frame=) would split the frame.
+Response (accept): chose key =F= (existing top-level key is =d=) and =(org-agenda-window-setup 'current-window)=, both in the command's local settings; folded into Design piece 2 with an acceptance criterion.
+
+** DONE Initial agenda-file preparation is undefined
+Spawning early after startup could show the base-files-only agenda, since the project-file list is built by an idle timer and =cj/main-agenda-display= calls =cj/build-org-agenda-list= first (:380-391).
+Response (accept): spawn runs the cached non-forced =(cj/build-org-agenda-list)= before rendering, and a build failure is part of the transactional spawn (Design piece 1); early-start acceptance criterion added.
+
+** DONE Read-and-engage command surface conflicts with Org's mutation keys
+=org-agenda-mode-map= exposes source-mutating keys the "read + engage only" non-goal doesn't want.
+Response (accept, resolved from the existing non-goal): source-mutating keys (TODO-state, schedule/deadline, refile, archive, kill, bulk) are remapped to a read-only message; engage (=RET=) reaches the file to edit it. Added a Decision (read-only command policy), folded into Design piece 3, acceptance criterion added.
+
+** DONE Source-opening routes beyond RET and TAB are unaccounted for
+=SPC=/=DEL= preview, mouse-2, =C-c C-o=, and follow mode can also show source or split the frame.
+Response (accept): defined the full class — =RET=/=TAB=/mouse-2/=C-c C-o= route to the working frame; =SPC=/=DEL= and follow mode are disabled in the dedicated buffer. Folded into Design piece 3 (and the read-only-policy Decision); invariant acceptance criterion added.
+
+** DONE Refresh failure recovery still contains an unresolved branch
+"Cancels or safely retries" is two different user-visible outcomes.
+Response (accept, chose retry): the timer keeps running and retries next tick; the failure is reported once per consecutive-failure run (naming =C-M-<f8>= / re-spawn), and the next success clears the state. Added a Decision (refresh-failure recovery), folded into Design piece 4, acceptance criterion added.
+
+** DONE Phase 1 exposes a non-live version of a live feature
+The former Phase 1 bound =S-<f8>= before the timer (the "live" contract) existed, shipping a static intermediate.
+Response (accept, chose defer-the-binding): Phase 1 now builds the command unbound (reachable only from ERT/=M-x=); Phase 2 adds the timer and only then binds =S-<f8>= and moves the force-rescan. No user-reachable intermediate is non-live. Phases and an acceptance criterion updated.
+
+** DONE Refresh point restoration lacks duplicate and fallback rules
+A source marker can occur twice, and "nearest" was undefined.
+Response (accept): restore the same-marker occurrence closest to the old agenda line; if the marker is gone, clamp the old line number into range; header line → first item; empty view → buffer start. Folded into Design piece 4, acceptance criterion added.
+
+** DONE Phase 1 is still user-reachable through M-x
+Phase 1's body made the toggle an interactive command, so =M-x= could still open the non-live intermediate.
+Response (accept): Phase 1 now builds only non-interactive helpers (=cj/--agenda-frame-*=); no =interactive= command exists until Phase 2, so it's reachable only from ERT. Phases + acceptance criterion updated.
+
+** DONE Read-only mutation policy is not exhaustive
+Enumerating blocked mutations misses many keys (tags, priority, effort, clock, capture, =C-c C-c=, the menu).
+Response (accept, resolved structurally): replaced the denylist with a *default-deny* policy — the =cj/agenda-frame-mode= keymap shadows =org-agenda-mode-map= and permits only a small allowlist; every unlisted command (all mutations, present and future) is denied with the read-only message. Nothing to enumerate. Design piece 3 + the Decision rewritten.
+
+** DONE Frame-isolation policy omits buffer-opening commands and initial follow state
+mouse-3, =C-c C-x b=, clock-goto, calendar, and non-nil =org-agenda-start-with-follow-mode= could still split/replace the frame.
+Response (accept): the same default-deny allowlist covers every buffer-opening command (denied unless allowlisted); the dedicated view binds =org-agenda-start-with-follow-mode= nil locally. Folded into Design piece 3.
+
+** DONE View-changing and manual-redo commands have no dedicated-frame policy
+=d=/=w=/=y=, =f=/=b=, =j=, =r=, =g= had no policy against the fixed today-anchored view.
+Response (accept, chose fixed view): the seven-day view is fixed — view-changers are denied with a =fixed to the 7-day view= message; =r= is remapped to the frame's safe-redo wrapper (manual version of the timer tick) so a manual refresh uses the failure-latched path; =g= is denied (it targets other buffers). Design piece 3 + acceptance criterion.
+
+** DONE Org redo discards dedicated buffer-local policy and state
+=org-agenda-redo= → =org-agenda-mode= runs =kill-all-local-variables=, stripping a buffer-local map and any buffer-local timer/failure state.
+Response (accept): =cj/agenda-frame-mode= is re-enabled after every build by =org-agenda-finalize-hook= gated on the frame's =cj/agenda-frame= marker (a frame parameter, which survives the reset), and the timer/failure state is frame-owned, not buffer-local — so redo can't strip either. Design piece 3 (policy) + Decision; acceptance criterion for policy-survives-redo.
+
+** DONE Sticky close and reopen freshness is undefined
+Closing left the command-local sticky =*Org Agenda(F)*= buffer alive, so a reopen reused stale content.
+Response (accept, chose kill-on-close): every close path also kills the sticky buffer, so the next spawn regenerates fresh. Added to Design piece 3 and the buffer-lifecycle Decision; close→change→reopen acceptance criterion.
+
+** DONE Failed-redo display state is undefined
+=org-agenda-redo= erases before rebuilding, so a failure could leave the frame blank/partial.
+Response (accept, chose last-known-good): the callback snapshots the last-good buffer before redoing and, on error, restores it with a "refresh failed" header; the frame is never blank/half-built, and the next success replaces it. Design piece 4 + the buffer-lifecycle Decision; acceptance criterion.
+
+** DONE Required ERT surface was not synchronized with the new contracts
+The Dev-tooling dimension still listed the older surface.
+Response (accept): expanded it to require ERT for the cached-build early-start, the default-deny policy (denied + allowed keys, both message classes), the policy surviving a redo, sticky kill-on-close / fresh reopen, the failed-redo snapshot restore, the deterministic marker cases, and the Phase-1 no-interactive-entry boundary; only compositor fullscreen/focus stays on the live-daemon checklist.
+
+** DONE Default-deny does not yet define or enforce the actual interactive surface
+The allowlist wasn't concrete (lifecycle keys, =C-g=, scrolling absent), and a minor-mode map can't intercept the Agenda menu or a direct =M-x=, so "every command is intercepted" overclaimed.
+Response (accept): enumerated the complete allowlist in Design piece 3 (navigation with concrete commands + =C-g=, engage/open routed to the working frame, and the frame's own controls =S-<f8>=/=C-M-<f8>=/=q=/=Q=/=x=/=r=), and chose the honest enforcement boundary — keys/mouse via the =[t]= catch-all, the Agenda menu-bar removed in the buffer, and direct =M-x= explicitly out of contract. Decision + acceptance updated.
+
+** DONE Failed-redo rollback is not yet a complete retryable agenda state
+The error path didn't re-enable the policy (finalize runs only on success), and the snapshot didn't promise Org properties / point/window/filter / an undecorated copy — so =RET= and the next rebuild weren't guaranteed.
+Response (accept): defined a frame-owned last-good snapshot (text + =org-redo-cmd=/=org-lprops=/markers + point/window/filter, undecorated); the error path restores it verbatim, explicitly re-enables =cj/agenda-frame-mode=, and shows the failure as an *overlay* (no header accumulation, no property corruption); the next tick rebuilds from the preserved =org-redo-cmd=. Design piece 4 + the buffer-lifecycle Decision; acceptance for failure-before-finalize, failure→failure, and failure→retry updated.
+
+** DONE Failed-redo snapshots retain markers only by shallow reference
+=org-agenda-reset-markers= nulls the old =org-marker=/=org-hd-marker= objects during rebuild, so a text-property copy holds dead markers and =RET=/=TAB= break after a failed restore.
+Response (accept): the snapshot *clones* each agenda marker into a snapshot-owned live marker (=copy-marker= into the unchanged source buffer) and reinstalls the clones on restore, so engage/navigation resolve to the right source line; the clones are released on the next successful build or on frame close, so repeated failures don't leak markers. Design piece 4 + the buffer-lifecycle Decision; ERT covers fail-after-reset → restored-marker targets → repeat → success → clones released.
+
+* Implementation phases
+
+** Phase 1 — Frame, view, and routing (private helpers, not user-reachable)
+Build the mechanism as *non-interactive* helpers (=cj/--agenda-frame-spawn/-raise/-delete/-toggle=, =cj/agenda-frame-mode=): transactional spawn (cached =(cj/build-org-agenda-list)=, fullscreen =make-frame= + =fullboth= + the =cj/agenda-frame= marker, the dedicated =F= today-anchored seven-day view with =(org-agenda-window-setup 'current-window)= + local sticky), the default-deny read-only policy + source-opening routing to the working frame (MRU live non-agenda frame, or a new normal frame when none exists), and =q=/=Q=/=x=/buffer-kill close that also kills the sticky buffer. No =interactive= command exists yet, so the mechanism is reachable only from ERT — not =M-x=, not a key. Do NOT bind =S-<f8>= and do NOT move the force-rescan. Clean stop: the frame, view, and policy are correct and tested; nothing user-visible changed.
+
+** Phase 2 — Refresh timer + public command
+Add the frame-scoped 5-minute wall-clock =org-agenda-redo= timer (window/focus contract, duplicate-timer prevention, dead frame/buffer guard, deterministic point restoration, retry-and-report-once failure recovery with the pre-redo snapshot restore, message-suppressing wrapper). Then wrap =cj/--agenda-frame-toggle= in the public interactive =cj/agenda-frame-toggle=, bind it to =S-<f8>=, and move =cj/org-agenda-refresh-files= to =C-M-<f8>=. The public gesture appears only when the feature is complete and live — the first user-reachable state is the finished feature.
+
+* Acceptance criteria
+- [ ] =S-<f8>= spawns a fullscreen agenda frame; pressing it from that frame closes it; pressing it from a working frame raises it.
+- [ ] =C-M-<f8>= runs the force-rescan (=cj/org-agenda-refresh-files=); =S-<f8>= no longer does.
+- [ ] The agenda frame shows a seven-day span and keeps focus on the agenda.
+- [ ] Opening a file from an agenda line (=RET=) shows the file in the working frame and raises it; the agenda frame remains on the agenda.
+- [ ] A midweek invocation starts today and ends six days later (not Monday-anchored).
+- [ ] =q=, =Q=, and =x= in the agenda frame close the frame and cancel its timer; ordinary =<f8>= agenda buffers are unaffected.
+- [ ] Killing the dedicated agenda buffer deletes the frame (no orphan fullscreen frame).
+- [ ] =RET= with the launch frame deleted, with multiple working frames, and with no working frame each opens the file outside the agenda frame (creating a normal frame in the last case).
+- [ ] The frame refreshes on the wall-clock 5-minute mark while open, and no timer runs after it's closed.
+- [ ] A refresh tick while another frame is active neither errors nor changes focus.
+- [ ] The refresh timer is silent on success and surfaces a failure once with an actionable message.
+- [ ] A spawn failure after =make-frame= leaves no orphan frame or timer and reports the failure.
+- [ ] Deleting the frame (or killing its buffer) cancels the timer without erroring.
+- [ ] The view uses key =F= with =current-window= — the frame stays a single window (no split).
+- [ ] A frame spawned right after daemon startup shows the full project agenda (the cached build ran), not the base-files-only view.
+- [ ] A source-mutating key (TODO-state, schedule, refile, archive, kill) in the frame shows the read-only message and edits nothing; =SPC=/=DEL= and follow mode don't split the frame; mouse-2 and =C-c C-o= open in the working frame.
+- [ ] A failed refresh keeps the timer, reports once per consecutive-failure run, and the next successful tick clears the failure state.
+- [ ] Point restoration: a duplicate source marker restores the occurrence nearest the old line; a missing marker clamps the old line into range; header-line and empty-view cases are handled.
+- [ ] A denied command in the frame (a mutation, a view-changer like =w=/=d=, a splitting preview) shows the right read-only/fixed-view message and does not act; an allowlisted command (navigation, =RET= engage) works.
+- [ ] After a refresh tick (=org-agenda-redo=), a denied command is still blocked — the policy survives =kill-all-local-variables=.
+- [ ] Closing the frame kills the =*Org Agenda(F)*= sticky buffer; the next spawn shows fresh content, not the stale sticky buffer.
+- [ ] A redo error restores the last-good buffer with a "refresh failed" header; the frame is never blank or half-built.
+- [ ] The Agenda menu-bar is absent in the frame; every allowlisted key (=C-g=, scroll, navigation, the lifecycle keys) works and every other key shows the message.
+- [ ] After a failed redo the policy is still active (a denied key is still blocked) and =RET=/navigation still work on the restored snapshot with point/filter preserved.
+- [ ] Failure → failure shows one overlay and one message (no header/overlay accumulation); failure → success removes the overlay and rebuilds from =org-redo-cmd=.
+- [ ] After a failed redo the restored snapshot's cloned markers point at the right source lines (=RET=/=TAB= resolve correctly); on the next success or on close the clones are released (no marker leak across repeated failures).
+- [ ] Phase 1 exposes no interactive command — the mechanism is reachable only from ERT (no =M-x=, no key); =S-<f8>= and the =C-M-<f8>= move appear only in Phase 2.
+- [ ] The normal =<f8>= full-view agenda is unchanged.
+
+* Readiness dimensions
+- Data model & ownership: N/A — the frame reads existing agenda files; it authors nothing. Refresh is display-only.
+- Errors, empty states & failure: an empty agenda renders as org's normal empty agenda in the frame. A frame/buffer killed out from under the timer must cancel the timer rather than error on the next tick — a live-frame/live-buffer guard in the callback. A spawn that fails after =make-frame= is transactional: cancel the timer, delete the partial frame/buffer, restore the working frame, and report =Agenda frame: <operation> failed: <cause>=.
+- Security & privacy: N/A — no credentials, no new data surface.
+- Observability: the frame is its own visible state; the refresh wrapper is quiet on success and surfaces a failure once with an actionable message.
+- Performance & scale: =redo= is cheap and already the manual-refresh path; a 5-minute cadence on one buffer is negligible. The file-list scan (the expensive path) is deliberately not on the tick.
+- Reuse & lost opportunities: reuses =make-frame=, =org-agenda-custom-commands=, =org-agenda-sticky=, =org-agenda-redo=, and the F8 family. No new rendering.
+- Architecture fit & weak points: integrates at =make-frame= + a frame parameter, the agenda display call, and the F8 keymap. Weak point: jump-to-task display routing across frames; timer lifecycle tied to frame deletion is the other watch point.
+- Config surface: the seven-day custom-command view is the one implicit knob. No width, no auto-open flag (both removed by the redesign).
+- Documentation plan: an entry in the keybinding/agenda notes; docstrings on the toggle command. No user-facing README beyond that.
+- Dev tooling: existing =make test= / byte-compile / live-reload. ERT covers frame lookup, spawn/raise/close, the =S-<f8>=/=C-M-<f8>= rebind, distinct sticky-buffer identity, the today-anchored seven-day settings, the cached-build early-start, the =RET=/=TAB=/mouse/=C-c C-o= engage routing and the MRU + no-frame fallback, the default-deny policy (the full allowlist works including =C-g=/scroll/lifecycle keys, representative denied keys show the right message, the Agenda menu-bar is absent), the policy surviving an =org-agenda-redo= (re-enabled by the finalize hook) and being re-enabled on the failed-redo error path, sticky-buffer kill on close and a fresh reopen, the failed-redo snapshot restore (policy active, =RET=/nav work, point/filter preserved, one overlay across consecutive failures, rebuild-from-=org-redo-cmd= on retry), timer alignment, duplicate-timer prevention, every cancellation path, dead frame/buffer guards, and silent-success/visible-failure behavior — the repo already tests these boundaries (=tests/test-dirvish-config-popup.el= mocks frame lookup/focus/delete; =tests/test-ai-term--project-color.el= drives timer callbacks + a dead-buffer case). One live-daemon checklist covers compositor fullscreen/focus; manual verification supplements, not replaces, the ERT surface.
+- Rollout, compatibility & rollback: additive. The one compatibility touch is the =S-<f8>= rebind (force-refresh moves to =C-M-<f8>=). Rollback is removing the command and the binding; nothing persisted changes.
+- External APIs & deps: N/A — all built-in Emacs/org.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Jump-to-task display routing across frames is the likeliest rabbit hole: getting =RET= to reliably open in the working frame (not the agenda frame) across =org-agenda-window-setup= values and single-frame states can take fiddling. Dodge: pin the agenda buffer's window-setup and target the file's =display-buffer= at a non-agenda frame explicitly.
+- Frame lifecycle: closing or killing the frame must cancel the timer; a killed frame must not error on tick. Dodge: guard the callback and hang cancellation off =delete-frame-functions= / buffer-kill.
+- Daemon-only lifetime: the frame is gone after a daemon restart. Accepted (re-spawn by key); noted so it isn't mistaken for a bug.
+
+* Review and iteration history
+** 2026-07-20 Mon @ 13:26:53 -0500 — Claude Code (emacs-d) — responder
+- What: dispositioned Codex's sixth-pass marker finding — the snapshot now clones agenda markers (=copy-marker=) instead of holding them by reference, since =org-agenda-reset-markers= nulls the originals on rebuild; restore reinstalls the clones (so =RET=/=TAB= resolve), and they're released on the next success or on close. Added the vNext note (Hyprland-managed window with its own keybind, once the in-Emacs frame proves out). All 28 findings and 13 decisions resolved. Craig accepted the spec for build, so flipped =DRAFT= → =READY=.
+- Why: the marker fix is a real correctness point (a text-property copy holds dead markers). With it folded, the spec is behavior-complete; Craig's call is to build v1 rather than run further review rounds.
+- Artifacts: all Review findings DONE [28/28]; Decisions [13/13]; status =READY=, mirror =ready=. Six review/response rounds total; the default-deny structural pivot (12:26) was the turning point.
+** 2026-07-20 Mon @ 13:07:58 -0500 — Codex (emacs-d) — reviewer
+- What: re-ran the full readiness gate after all 27 prior findings were dispositioned. Verdict: Not ready. Added one blocking finding: a failed-redo snapshot must clone or reconstruct live agenda markers rather than retain shallow text-property references.
+- Why: Org 9.7.11 calls =org-agenda-reset-markers= during regeneration, moving the old =org-marker=/=org-hd-marker= objects to nil. Emacs text-property snapshots retain those same objects, so the promised verbatim restore cannot support =RET=/=TAB= after a failed rebuild without an explicit marker-copy and cleanup contract.
+- Artifacts: this spec's =Review findings [27/28]=. Source checks: =org-agenda-redo=, =org-agenda-prepare=, =org-agenda-reset-markers=, =org-agenda-new-marker=, and =org-agenda-goto= in installed Emacs 30.2 / Org 9.7.11; a batch check confirmed the snapshot property and source property hold the same marker object and both become dead after reset.
+** 2026-07-20 Mon @ 12:59:00 -0500 — Claude Code (emacs-d) — responder
+- What: dispositioned both of Codex's fifth-pass findings. (1) Enumerated the complete default-deny allowlist (navigation commands + =C-g= + scroll, engage/open routed out, and the frame's own controls =S-<f8>=/=C-M-<f8>=/=q=/=Q=/=x=/=r=) and set the honest enforcement boundary — keys/mouse via the =[t]= catch-all, the Agenda menu-bar removed in the buffer, direct =M-x= out of contract (dropped the overclaim that "every command" is intercepted). (2) Made the failed-redo rollback a complete retryable state: a frame-owned last-good snapshot carrying Org properties (=org-redo-cmd=/=org-lprops=/markers), point/window/filter, undecorated; the error path restores it and *explicitly re-enables the policy* (finalize only runs on success), shows the failure as an *overlay* (no accumulation, no property corruption), and the next tick rebuilds from the preserved =org-redo-cmd=.
+- Why: Codex accepted the default-deny structure but flagged the two absolute claims it hadn't yet backed — the map can't cover the menu/M-x, and a text-only snapshot doesn't guarantee a re-enabled, retryable agenda. Both are now bounded by an explicit contract.
+- Artifacts: all Review findings DONE [27/27]; Decisions [13/13] (the read-only and buffer-lifecycle Decisions were tightened, not added). Status stays DRAFT pending Codex's re-review.
+** 2026-07-20 Mon @ 12:47:44 -0500 — Codex (emacs-d) — reviewer
+- What: verified the renamed spec and all 25 resolved findings against installed Org's key dispatch, finalize, redo, and text-property behavior. Verdict: Not ready. Added two blocking findings: the default-deny map's exact/enforced interaction boundary and the failed-redo snapshot's policy-enabled, metadata-preserving retry contract.
+- Why: the new structural design closes the earlier enumeration and sticky-lifecycle problems, but its remaining absolute claims exceed what a minor-mode map and a text-only rollback guarantee. Both gaps would make the implementer choose observable behavior and could leave the frame unrestricted or permanently stale after a failed refresh.
+- Artifacts: this spec's =Review findings [25/27]=. Source checks: =org-agenda-mode-map=, =org-agenda-mode=, =org-agenda-finalize=, =org-agenda-redo=, and the =org-redo-cmd=/=org-lprops= text-property flow in installed Emacs 30.2 / Org 9.7.11. Filename/location precondition passes after the tracked rename.
+** 2026-07-20 Mon @ 12:26:29 -0500 — Claude Code (emacs-d) — responder
+- What: dispositioned all 8 of Codex's fourth-pass findings — accepted every one. The key change is structural: replaced the command-by-command enumeration (which the prior two rounds proved can't converge against a ~100-entry keymap) with a *default-deny* policy — a =cj/agenda-frame-mode= minor mode shadows =org-agenda-mode-map= and permits only a small allowlist, so all mutations / buffer-openers / view-changers are denied in one rule (closes findings 2, 3, 4). Re-enabled via =org-agenda-finalize-hook= keyed on the frame marker so =org-agenda-redo='s =kill-all-local-variables= can't strip it, with timer/failure state frame-owned (finding 5). Close kills the sticky buffer (finding 6); failed redo restores a pre-redo snapshot (finding 7); Phase 1 is non-interactive helpers only (finding 1); the ERT surface is synced (finding 8). Fixed the 7-day view, remapped =r= to the safe wrapper, bound follow-mode nil locally. Rewrote the read-only Decision to default-deny and added one (buffer/failed-redo lifecycle) — 13 Decisions.
+- Why: the fourth review showed the earlier responses were enumerating a keymap instead of stating a policy — the root cause of the round-over-round regress. Default-deny covers the whole map in one rule and terminates.
+- Artifacts: all Review findings DONE [25/25]; Decisions [13/13]. Status stays DRAFT pending Codex's re-review.
+** 2026-07-20 Mon @ 12:11:31 -0500 — Codex (emacs-d) — reviewer
+- What: verified all 17 prior findings against the current repo and installed Org, then traced every agenda command/lifecycle path through regeneration and sticky reuse. Verdict: Not ready. Added eight blocking findings covering the still-interactive Phase 1, incomplete mutation/display/view/manual-redo policies, loss of dedicated local state on redo, stale sticky reopen, failed-redo display state, and the unsynchronized ERT matrix.
+- Why: the chosen values from the prior response are viable, but implementation would still require behavior and state-ownership decisions. In particular, Org's full keymap is broader than the enumerated commands, =org-agenda-mode= resets the local map during every redo, and command-local sticky agendas reuse a closed buffer without regenerating it.
+- Artifacts: this spec's =Review findings [17/25]=. Source checks: =org-agenda-mode-map=, =org-agenda-mode=, =org-agenda-prepare=, =org-agenda-use-sticky-p=, =org-agenda-redo=, =org-agenda-goto-calendar=, =org-agenda-tree-to-indirect-buffer=, and =org-agenda-clock-goto= in installed Emacs 30.2 / Org 9.7.11; current hooks and display rules in =modules/org-agenda-config.el=.
+** 2026-07-20 Mon @ 12:02:00 -0500 — Claude Code (emacs-d) — responder
+- What: dispositioned all 7 of Codex's third-pass findings — accepted every one; four carried a chosen behavior. View identity: key =F= + =(org-agenda-window-setup 'current-window)=. Command policy: source-mutating keys read-only-remapped, all source-opening keys routed to the working frame, preview/follow disabled. Refresh failure: retry + report-once-per-run. Phase safety: Phase 1 builds the command unbound, Phase 2 adds the timer and the =S-<f8>= binding. Also pinned the cached-build-on-spawn and the point-restoration tie-break. Added three Decisions (read-only policy, refresh-failure recovery, plus the earlier two = now 12), and expanded acceptance criteria.
+- Why: Craig's strict gate — no behavior decision deferred to implementation. Codex's third pass named seven still-open keymap/lifecycle choices against the installed Org 9.7.11; each is now pinned with a concrete value.
+- Artifacts: all Review findings DONE [17/17]; Decisions [12/12]. The read-only mutation policy was resolved from the existing "read + engage only" non-goal, not a fresh product call — flip it if in-agenda mutation was actually wanted. Status stays DRAFT pending Codex's re-review.
+** 2026-07-20 Mon @ 11:29:33 -0500 — Codex (emacs-d) — reviewer
+- What: re-ran the authoritative code-grounded review after all 10 earlier findings were dispositioned. Verdict: Not ready. Added seven blocking findings covering the exact dedicated-command identity/window setup, initial agenda-file preparation, read-versus-mutate command policy, source-opening commands beyond =RET=/=TAB=, refresh-failure recovery, safe phase exposure, and deterministic point restoration.
+- Why: Craig set the stricter gate that no behavior decision may be deferred to implementation. The revised spec closes the earlier findings, but still contains explicit alternatives and conflicts with the installed Org keymap and this repo's deferred agenda-file initialization.
+- Artifacts: this spec's =Review findings [10/17]=. Source checks: =modules/org-agenda-config.el:186-220,344-391,431-433= and =org-agenda-mode-map=, =org-agenda-prepare-window=, =org-agenda-goto=, =org-agenda-open-link=, and =org-agenda-show-and-scroll-up= in installed Emacs 30.2 / Org 9.7.11.
+** 2026-07-20 Mon @ 10:22:00 -0500 — Claude Code (emacs-d) — responder
+- What: dispositioned all 10 of Codex's findings — accepted every one, two with a chosen behavior. Working-frame fallback: the MRU live non-agenda frame, creating a normal frame when none exists (Codex's create-a-frame option over a refusal). Phase safety: merged the former Phase 1+2 into one isolated Phase 1 so no broken intermediate state ships. Folded the resolutions into Design (transactional spawn, today-anchored sticky seven-day view, frame-local navigation/exit routing, refresh window/focus contract + message wrapper + org-marker restore), added two Decisions (exit semantics, working-frame fallback), cut to 2 phases, and expanded the acceptance criteria and the Dev-tooling test surface.
+- Why: Codex's code-grounded re-review found six blocking gaps the first (my) review missed — week anchoring, exit lifecycle, working-frame fallback, phase safety, refresh window context, and the understated test surface. Each needed the spec to specify behavior it had left implicit.
+- Artifacts: all Review findings DONE [10/10]; Decisions [10/10]. Status stays DRAFT pending a re-review to confirm the responses close the blockers.
+** 2026-07-20 Mon @ 10:12:30 -0500 — Codex (emacs-d) — reviewer
+- What: independently re-ran the full spec-review against =modules/org-agenda-config.el=, its ERT coverage, relevant frame/timer precedent, and the installed Emacs 30.2 / Org 9.7.11 source. Verdict: Not ready. Added six blocking and three non-blocking findings; retained the prior non-blocking custom-command finding.
+- Why: the first review checked the local F8 premises but did not trace Org's seven-day anchoring, sticky exit behavior, cross-frame jump boundaries, selected-window dependency during redo, phase-by-phase safety, or the repository's existing frame/timer test patterns. Those gaps would force implementation decisions or ship broken intermediate behavior.
+- Artifacts: this spec's =Review findings [0/10]=; lifecycle demoted =READY= → =DRAFT= and Metadata mirrored to =draft=. Source checks: =org-agenda-list=, =org-agenda-quit=, =org-agenda-redo=, =org-agenda-switch-to=, and =org-agenda-goto= in Org 9.7.11.
+** 2026-07-20 Mon @ 09:53:02 -0500 — Claude Code (emacs-d) — reviewer
+- What: spec-review of the redesigned spec. Verdict READY. Read =modules/org-agenda-config.el= first — confirmed =cj/org-agenda-refresh-files= is bound to =S-<f8>= (:232), the =cj/--org-agenda-display-rule= / =cj/org-agenda-window-height= 0.75 rule (:27-37), and =<f8>= = =cj/main-agenda-display= (:392), so the rebind and override premises hold. All 8 decisions resolved (cookie complete); phases decompose cleanly into 3 sessions; no blocking finding. One non-blocking finding recorded: the frame's seven-day view needs its own custom-command entry (the code already has a span-8 entry).
+- Why: gate the DRAFT → READY transition before decomposing the build. The reworked design has no unverified API assumptions (all built-in Emacs/org) and no data/security surface.
+- Artifacts: this spec's Review findings [0/1]; flipped status heading DRAFT → READY and the Metadata mirror.
+** 2026-07-20 Mon @ 09:45:00 -0500 — Craig Jennings — redesign
+- What: replaced the right-side side-window dock with a dedicated fullscreen frame of the daemon. Resolved all eight decisions: fullscreen frame (not side window, not separate process), =S-<f8>= toggle, seven-day span, focus-on-agenda, jump-to-task opens in the working frame, frame-scoped 5-min redo, no startup auto-open.
+- Why: Craig wants a standing, fullscreen agenda surface placeable on its own workspace/monitor, sharing the daemon's live state, rather than a dock bounded to the working frame.
+- Artifacts: this spec; the dock-mode task in todo.org (Emacs Open Work).
+** 2026-07-17 Fri @ 19:34:07 -0500 — Craig Jennings — author
+- What: initial draft (right-side side-window dock).
+- Why: dock mode had four-plus open design questions and real trade-offs; settling them on paper before code.
+- Artifacts: dock-mode task in todo.org (Emacs Open Work); supersedes the folded "auto-refresh every 5 min" roam item.
diff --git a/docs/specs/ai-kb-spec.org b/docs/specs/ai-kb-spec.org
index fbd35ca5..6d973b94 100644
--- a/docs/specs/ai-kb-spec.org
+++ b/docs/specs/ai-kb-spec.org
@@ -1,11 +1,15 @@
-:PROPERTIES:
-:ID: 03742426-35ce-41c5-aed7-d4e248e91833
-:STATUS: not-started
-:END:
#+TITLE: Design: AI Knowledge Base (ai-kb)
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-24
#+OPTIONS: toc:nil num:nil
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Design: AI Knowledge Base (ai-kb)
+:PROPERTIES:
+:ID: 03742426-35ce-41c5-aed7-d4e248e91833
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started
* Status
@@ -15,7 +19,7 @@ In scope: Step 1 (store + contract/CLI + global rule + provisioning) and Step 2
* Scope decision: memory store, not (yet) an LLM Wiki
-ai-kb v1 is a *global, durable, cross-project memory store* for AI coding agents (Claude Code today; agent-neutral by contract): org-roam nodes holding lessons, principles, Craig's preferences, reusable procedures, and durable observations. It is the concrete first slice of the broader "org-roam as agent memory" vision in [[file:agentic-knowledgebase.org][agentic-knowledgebase.org]].
+ai-kb v1 is a *global, durable, cross-project memory store* for AI coding agents (Claude Code today; agent-neutral by contract): org-roam nodes holding lessons, principles, Craig's preferences, reusable procedures, and durable observations. It is the concrete first slice of the broader "org-roam as agent memory" vision in [[file:../design/agentic-knowledgebase.org][agentic-knowledgebase.org]].
It is *not* a Karpathy-style LLM Wiki in v1. That pattern — immutable =raw/= sources, compiled =wiki/= synthesis pages, =schema.org=, source hashes, and full ingest/query/lint pipelines — is a larger product whose value is *grounding compiled knowledge in re-checkable sources*. v1 adopts the one piece that pays off immediately: a =raw/= capture for *external* sources (see [[*Grounding external sources][Grounding external sources]]). The rest of that machinery is the documented evolution path (see [[*vNext][vNext]]); v1's structure is chosen so it can grow that way without a rewrite.
diff --git a/docs/specs/ai-vterm-spec-superseded.org b/docs/specs/ai-vterm-spec.org
index 0b6bfb86..7015a862 100644
--- a/docs/specs/ai-vterm-spec-superseded.org
+++ b/docs/specs/ai-vterm-spec.org
@@ -1,11 +1,15 @@
-:PROPERTIES:
-:ID: 3abd0270-e87c-42b7-9b3a-ef60300db99d
-:STATUS: superseded
-:END:
#+TITLE: Design: ai-vterm — in-Emacs Claude launcher
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-07
#+OPTIONS: toc:nil num:nil
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* SUPERSEDED Design: ai-vterm — in-Emacs Claude launcher
+:PROPERTIES:
+:ID: 3abd0270-e87c-42b7-9b3a-ef60300db99d
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword SUPERSEDED from existing :STATUS: superseded + -superseded filename (Craig's prior determination)
* Status
diff --git a/docs/specs/cache-helper-design-spec-implemented.org b/docs/specs/cache-helper-design-spec.org
index 27c818dc..5bfb661b 100644
--- a/docs/specs/cache-helper-design-spec-implemented.org
+++ b/docs/specs/cache-helper-design-spec.org
@@ -1,14 +1,18 @@
-:PROPERTIES:
-:ID: 647c5101-21c2-47bb-aaa7-72c757f45fb7
-:STATUS: implemented
-:END:
#+TITLE: Cache Helper Design Addendum
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-10
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Cache Helper Design Addendum
+:PROPERTIES:
+:ID: 647c5101-21c2-47bb-aaa7-72c757f45fb7
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword IMPLEMENTED from existing :STATUS: implemented + -implemented filename (Craig's prior determination)
* Status
-Phase 5 design addendum to [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]]. Specifies the cache API to extract before any code moves.
+Phase 5 design addendum to [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec.org]]. Specifies the cache API to extract before any code moves.
* Problem
diff --git a/docs/specs/company-to-corfu-migration-spec.org b/docs/specs/company-to-corfu-migration-spec.org
index a7b059a3..ef094937 100644
--- a/docs/specs/company-to-corfu-migration-spec.org
+++ b/docs/specs/company-to-corfu-migration-spec.org
@@ -1,11 +1,15 @@
-:PROPERTIES:
-:ID: 68733ba2-37a7-4a7b-bfaa-b845d82ff1e7
-:STATUS: not-started
-:END:
#+TITLE: Design: Migrate from Company to Corfu (with prescient integration)
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-15
#+OPTIONS: toc:nil num:nil
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Design: Migrate from Company to Corfu (with prescient integration)
+:PROPERTIES:
+:ID: 68733ba2-37a7-4a7b-bfaa-b845d82ff1e7
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started
* Status
diff --git a/docs/specs/coverage-spec-implemented.org b/docs/specs/coverage-spec.org
index 65734fb3..e2ac4b3c 100644
--- a/docs/specs/coverage-spec-implemented.org
+++ b/docs/specs/coverage-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: 7d7f4486-fad7-4f0a-bd9a-775bd4cd8f7e
-:STATUS: implemented
-:END:
#+TITLE: Design: Coverage Reporting
#+AUTHOR: Craig Jennings
#+DATE: 2026-04-22
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Design: Coverage Reporting
+:PROPERTIES:
+:ID: 7d7f4486-fad7-4f0a-bd9a-775bd4cd8f7e
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword IMPLEMENTED from existing :STATUS: implemented + -implemented filename (Craig's prior determination)
* Status
diff --git a/docs/specs/debug-profiling-spec.org b/docs/specs/debug-profiling-spec.org
index 5961071b..3492d3a2 100644
--- a/docs/specs/debug-profiling-spec.org
+++ b/docs/specs/debug-profiling-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: c713b431-ae14-498d-aba9-b84d52f981b6
-:STATUS: not-started
-:END:
#+TITLE: Design: debug-profiling.el module
#+AUTHOR: Craig Jennings
#+DATE: 2026-04-26
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Design: debug-profiling.el module
+:PROPERTIES:
+:ID: c713b431-ae14-498d-aba9-b84d52f981b6
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started
* Status
diff --git a/docs/specs/dev-setup-project-spec.org b/docs/specs/dev-setup-project-spec.org
index 5d64f368..058784a5 100644
--- a/docs/specs/dev-setup-project-spec.org
+++ b/docs/specs/dev-setup-project-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: 596fce5d-1bab-46e7-8567-d4a2e0923091
-:STATUS: not-started
-:END:
#+TITLE: Design: cj/dev-setup-project
#+AUTHOR: Craig Jennings
#+DATE: 2026-04-22
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Design: cj/dev-setup-project
+:PROPERTIES:
+:ID: 596fce5d-1bab-46e7-8567-d4a2e0923091
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started
* Status
diff --git a/docs/specs/dupre-clear-theme-spec.org b/docs/specs/dupre-clear-theme-spec.org
index 578eb240..8027ee2a 100644
--- a/docs/specs/dupre-clear-theme-spec.org
+++ b/docs/specs/dupre-clear-theme-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: 20df7f50-4759-47ba-9782-8dd25a2e173e
-:STATUS: not-started
-:END:
#+TITLE: dupre-clear — a contrast-first AAA sibling theme
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-07
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT dupre-clear — a contrast-first AAA sibling theme
+:PROPERTIES:
+:ID: 20df7f50-4759-47ba-9782-8dd25a2e173e
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started
* Status
diff --git a/docs/specs/face-font-diagnostic-popup-spec-implemented.org b/docs/specs/face-font-diagnostic-popup-spec.org
index 3e8fadcd..aae355f9 100644
--- a/docs/specs/face-font-diagnostic-popup-spec-implemented.org
+++ b/docs/specs/face-font-diagnostic-popup-spec.org
@@ -1,11 +1,14 @@
-:PROPERTIES:
-:ID: 98f065cf-8bd5-46a0-ac24-da94d66855ad
-:STATUS: implemented
-:END:
#+TITLE: Face and Font Diagnostic Popup — Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-14
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Face and Font Diagnostic Popup — Spec
+:PROPERTIES:
+:ID: 98f065cf-8bd5-46a0-ac24-da94d66855ad
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword IMPLEMENTED from existing :STATUS: implemented + -implemented filename (Craig's prior determination)
* Metadata
diff --git a/docs/specs/flycheck-modeline-customization-spec-implemented.org b/docs/specs/flycheck-modeline-customization-spec.org
index 59567be6..2a58b447 100644
--- a/docs/specs/flycheck-modeline-customization-spec-implemented.org
+++ b/docs/specs/flycheck-modeline-customization-spec.org
@@ -1,11 +1,15 @@
-:PROPERTIES:
-:ID: 76979608-956e-474f-90a8-8d0c958101a0
-:STATUS: implemented
-:END:
#+TITLE: Design: Flycheck modeline customization
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-15
#+OPTIONS: toc:nil num:nil
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Design: Flycheck modeline customization
+:PROPERTIES:
+:ID: 76979608-956e-474f-90a8-8d0c958101a0
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword IMPLEMENTED from existing :STATUS: implemented + -implemented filename (Craig's prior determination)
* Status
diff --git a/docs/specs/gloss-spec-doing.org b/docs/specs/gloss-spec-doing.org
deleted file mode 100644
index 320b83eb..00000000
--- a/docs/specs/gloss-spec-doing.org
+++ /dev/null
@@ -1,320 +0,0 @@
-:PROPERTIES:
-:ID: 295f9969-ccef-4df9-945b-9e08d8069daf
-:STATUS: doing
-:END:
-#+TITLE: Design — gloss (Glossary Lookup with Online-Sourced Selection)
-#+DATE: 2026-04-28
-#+STATUS: Draft
-
-* Problem
-
-A personal glossary inside Emacs, modelled on the existing =quick-sdcv= UX (=C-h d=) but for self-curated terms rather than packaged dictionaries. =C-h g= prompts for a term (defaulting to word-at-point), looks it up in a single git-tracked org file, and shows the definition in a side buffer that =q= dismisses. On a local miss, the package fetches candidate definitions from an online source, lets the user pick one, and saves it with provenance. The same org file feeds =org-drill= for spaced-repetition study.
-
-The pain point: domain jargon — government acronyms, technical terms, philosophy vocabulary, project-specific names — doesn't live in any general dictionary, so existing tools like =quick-sdcv= can't help. A personal glossary that grows by use (encounter term → save it once → it's permanently looked-up-able and study-card-able) closes that gap.
-
-* Non-Goals
-
-The following are explicitly out of scope for v1. Each is a defensible v2+ topic on its own.
-
-- *Multi-language support.* English only. Wiktionary returns French/Latin/etc. — v1 ignores everything but the =en= key.
-- *Synonyms, cross-references, related terms.* Even when the upstream source returns them, v1 stores only the picked definition.
-- *Audio pronunciation.* Not fetched, not played.
-- *Etymology, usage notes, parsed examples.* Discarded during HTML strip.
-- *Multiple glossaries / domain separation.* One file, one glossary.
-- *Backup or sync infrastructure.* Delegated to git on whatever path =gloss-file= points at.
-- *Org-drill scheduling control.* The exporter prepares entries; =org-drill= itself runs unmodified.
-
-In scope (kept after triage): edit-in-place via =C-h g e=, which jumps to the source file at the entry's heading.
-
-* Approaches Considered
-
-Six approaches evaluated during brainstorm. Three conventional, three tail samples for diversity.
-
-** Recommended: Layered multi-module package
-
-Five =.el= files, each owning one concern: =gloss-core= (data), =gloss-fetch= (network), =gloss-display= (UI), =gloss-drill= (drill export), =gloss= (orchestration entry point). Each layer mocks at its own natural boundary; no layer mocks another layer's internals.
-
-*Why this over the alternatives.* The codebase already prefers layering — =coverage-core= + =coverage-elisp= split, Hugo pure-helpers + interactive wrappers, LSP file-watch defvar + function. The four concerns (data, fetch, display, drill) have genuinely different test boundaries (file I/O, HTTP, mode UI, =org-element=). Mixing them in one file would force overmocking, which the project's testing rules flag as a smell. The package is also public-style — clear module boundaries reward cold readers.
-
-*What's traded away.* About 30 minutes more structural setup at the start, in exchange for boilerplate that may never pay off if the package stays personal forever. Cheap trade against the testing and reading wins.
-
-** Rejected: Single-file quick-sdcv-clone
-
-One =.el= file (~400 lines) covering all four concerns. Simplest path, lowest dependency footprint, but everything (data, HTTP, mode definition, drill) cohabits a single namespace. Test isolation gets awkward; refactor cost grows when one piece needs replacing.
-
-** Rejected: Backend-pluggable registry
-
-A =glossary-backend= protocol covering both local-org and online sources, with =lookup= / =save= / =list= operations. Local and online become interchangeable backends. Real future-proofing, but for v1 with two backends and probably never a third, the protocol is overkill — YAGNI risk. The forward-compat shape we did adopt (the =gloss-fetch-sources= registry, see Architecture) gets the same benefit at a fraction of the design weight, scoped only to where source variety is real.
-
-** Rejected: quick-sdcv + generated StarDict
-
-Round-trip the org file through StarDict format on save; reuse =quick-sdcv='s UI verbatim. Reuses 100% of an existing UI but loses provenance metadata in the round-trip, fights drill (which reads org, not StarDict), and forces a binary intermediate format for what should be a plain-text data store.
-
-** Rejected: Org-roam node per term
-
-Each entry is its own =org-roam= node. Free fuzzy/exact title search, free backlinks. But it's a heavy dependency for an otherwise self-contained package, file-explodes (1000 terms = 1000 files), and contradicts the locked single-file storage decision.
-
-** Rejected: Lazy-reactive minor mode
-
-Passive recognition — =gloss-mode= scans buffer text for known terms, underlines them, hover/click reveals definitions. Different and arguably more-natural mental model, but it reframes the brief (active =C-h g= lookup is what was asked for) and doesn't naturally support online fallback or auto-add. Probably belongs as a v3 feature on top of the layered architecture, not as the architecture itself.
-
-* Design
-
-** Architecture
-
-Five =.el= files:
-
-#+begin_example
-gloss-core.el data layer — org file I/O + in-memory cache
-gloss-fetch.el network layer — Wiktionary REST + HTML strip
-gloss-display.el UI layer — side buffer + picker
-gloss-drill.el drill export — :drill: tag + twosided property
-gloss.el entry point — defcustoms, prefix keymap, user commands
-#+end_example
-
-*Public API by layer.*
-
-=gloss-core=: =gloss-core-lookup TERM=, =gloss-core-save TERM DEFINITION SOURCE=, =gloss-core-list=, =gloss-core-find-buffer-position TERM=.
-
-=gloss-fetch=: =gloss-fetch-definitions TERM= → =(:ok DEFS) | (:empty :no-defs SOURCES :failed SOURCES)=. Internally a registry: =gloss-fetch--sources= alist (source-symbol → fetcher function), walked in order per the user-facing =gloss-fetch-sources= defcustom.
-
-=gloss-display=: =gloss-display-show-entry TERM BODY=, =gloss-display-pick-definition TERM DEFINITIONS=. Defines =gloss-mode= (derived from =special-mode=, =q= quits).
-
-=gloss-drill=: =gloss-drill-export-all=, =gloss-drill-untag-all=. Operates on the org file via =org-element=.
-
-=gloss=: =defcustom gloss-file= (path), =gloss-prefix-map= for =C-h g=, user commands =gloss-lookup=, =gloss-add=, =gloss-edit=, =gloss-fetch-online=, =gloss-drill-export=.
-
-** Data Flow
-
-*Shapes.*
-
-A definition (in flight from fetch through display to save) is a plist:
-
-#+begin_src emacs-lisp
-(:source wiktionary :text "Reference to something earlier in the discourse...")
-#+end_src
-
-An entry (saved in cache and on disk) is a plist:
-
-#+begin_src emacs-lisp
-(:term "anaphora"
- :body "Reference to something earlier in the discourse..."
- :source wiktionary
- :added "2026-04-28"
- :marker #<marker at 1247 in gloss.org>)
-#+end_src
-
-The cache is a hash table, term-string → entry-plist. The org file is the source of truth; the cache is a read-side index.
-
-*Lookup flow (=C-h g=).*
-
-1. Read input — word-at-point if available, else minibuffer prompt.
-2. =gloss-core-lookup TERM=. Cache loaded if cold.
-3. Hit → =gloss-display-show-entry=. Done.
-4. Miss → silent fall-through to =gloss-fetch-definitions TERM=.
-5. Orchestrate on result:
- - 0 definitions or all-failures → side buffer message (see Error Handling).
- - 1 definition → auto-save via =gloss-core-save=, then =gloss-display-show-entry=.
- - >1 definitions → =gloss-display-pick-definition= → user picks → =gloss-core-save= → =gloss-display-show-entry=.
-
-*Add flow (=C-h g a=).*
-
-=gloss-add= prompts for term and body (small temp buffer for multi-line body, =C-c C-c= accepts). =gloss-core-save TERM BODY 'manual=. Then =gloss-display-show-entry=.
-
-*Edit flow (=C-h g e=).*
-
-=gloss-edit= resolves the term to a buffer position via =gloss-core-find-buffer-position=. Opens the org file at that heading in the *source* buffer (not the side buffer). User edits inline. On save, the buffer-local =after-save-hook= refreshes the cache for that single term.
-
-*Drill export (=C-h g D=).*
-
-=gloss-drill-export-all= walks the org file via =org-element=, ensures every term heading has =:drill:= tag and =:DRILL_CARD_TYPE: twosided= property. =M-x org-drill= runs the session — gloss does not wrap or invoke =org-drill= itself.
-
-** Persistence
-
-*File shape.* Single org file at =gloss-file= (default: =(expand-file-name "gloss.org" (or org-directory user-emacs-directory))=). One =* term= heading per entry, alphabetical order maintained on insert. Each entry has a =:PROPERTIES:= drawer with =:SOURCE:= and =:ADDED:=. Body is plain text immediately under the heading.
-
-#+begin_example
-#+TITLE: Glossary
-#+STARTUP: showall
-
-* anaphora
-:PROPERTIES:
-:SOURCE: wiktionary
-:ADDED: 2026-04-28
-:END:
-Reference to something earlier in the discourse...
-
-* SBIR
-:PROPERTIES:
-:SOURCE: wiktionary
-:ADDED: 2026-04-28
-:END:
-Initialism of Small Business Innovation Research...
-#+end_example
-
-After =gloss-drill-export-all=, the heading line gains a =:drill:= tag and the properties drawer gains =:DRILL_CARD_TYPE: twosided=.
-
-*Cache lifecycle.* Hash table loaded lazily on first lookup of the session. Populated by reading =gloss-file= once and parsing with =org-element-parse-buffer=. Subsequent lookups hit the cache directly.
-
-*Cache invalidation.* Four triggers, in order of cost:
-
-1. =gloss-core-save= mutates the cache directly when it writes.
-2. *mtime check on every lookup.* =file-attributes= the file before each =gloss-core-lookup= returns; if mtime > cached-mtime, reload before answering. Sub-millisecond cost; catches every out-of-band edit (other Emacs session, =git pull=, hand-edit, =sed=).
-3. =gloss-edit='s buffer-local =after-save-hook= updates the single edited term immediately; overlaps with #2 but doesn't wait for the next lookup.
-4. Manual =gloss-reload= command — nuclear option for paranoia.
-
-=file-notify-add-watch= rejected: platform-specific backend, async callback complicates the model, mtime path is already sub-millisecond.
-
-*Write strategy.* Append-on-add via direct buffer editing (=find-file-noselect=, insert at the alphabetically-correct heading position, save, kill the buffer if not previously open). No journal, no temp file — org-mode's =auto-save-mode= and the user's git tracking provide durability. Single-user, single-Emacs assumed; concurrent access isn't a concern.
-
-*Alphabetical order.* Maintained on insert via case-insensitive string compare. Cheap; the file stays diff-clean (only the inserted block changes).
-
-** Error Handling
-
-*Per-source status taxonomy.* Five internal values; three user-facing rollups.
-
-#+begin_src emacs-lisp
-;; Internal per-source result:
-(:source SYM :status STATUS :reason STRING)
-
-;; STATUS values:
-;; :ok :defs (def1 def2 ...) — success
-;; :no-defs — server reached, term not there (HTTP 404 or empty 200)
-;; :unreachable — network problem (DNS, refused, timeout)
-;; :server-error — HTTP 5xx, malformed JSON, schema mismatch, HTTP 4xx other than 404/429
-;; :rate-limited — HTTP 429
-#+end_src
-
-*=:reason= strings* carry the technical detail (=timeout (5s)=, =HTTP 503=, =malformed JSON: ...=) and land in =*gloss-debug*=. They are never user-facing.
-
-*User-facing rollup.* =gloss-fetch-definitions= aggregates per-source results into:
-
-#+begin_src emacs-lisp
-(:ok DEFS) ;; any source returned >=1 def
-(:empty :no-defs (...) :failed (...)) ;; everything else
-#+end_src
-
-=:failed= unions =:unreachable=, =:server-error=, =:rate-limited=.
-
-| Result shape | Message |
-|-------------------------------------------+--------------------------------------------------------------------|
-| Every source =:no-defs=, none failed | "No definition for X in Wiktionary." |
-| Every source failed, none =:no-defs= | "Couldn't reach Wiktionary." |
-| Mix of =:no-defs= and failures | "No definition in Wiktionary; couldn't reach DictionaryAPI." |
-| Any =:ok= with defs | Silent on others — picker shows what came back |
-
-When v2 starts surfacing =:rate-limited= regularly, the rollup wording will gain a third visible category. v1 with no-key Wiktionary doesn't need it.
-
-*libxml as a precondition, not a per-source failure.* First time =gloss-fetch-definitions= runs, probe =(libxml-parse-html-region 1 1)= on a temp buffer. If unavailable, online fetching is disabled package-wide for the session with a one-shot =user-error=: "Online fetch requires Emacs built with libxml2; manual add still works." Subsequent online attempts in the session short-circuit to that message.
-
-*Partial-success on per-sense HTML failures.* If libxml is available but fails on a specific sense's content, drop that sense and return the rest. Source status stays =:ok= with N-1 entries; the dropped sense logs to =*gloss-debug*=. A single bad sense doesn't poison the whole source.
-
-*Storage failures.* First call creates =gloss-file= and any missing parent directory with a =#+TITLE: Glossary= header. Permission denied raises =user-error= naming the path. Corrupt org file (=org-element-parse-buffer= raises) preserves the existing cache and surfaces "glossary file corrupt at line N; cache not refreshed" — operations fall back to the stale cache until the user fixes the file and runs =gloss-reload=. Term collision (saving an existing term) prompts: replace, append-with-separator, or cancel.
-
-*Drill.* =org-drill= checked via =featurep= before export runs. If absent: =user-error= with install hint.
-
-*User cancellations.* =C-g= during the picker → no save, side buffer shows the local-miss state. Empty term input from =gloss-add= → re-prompt once, then abort silently. Cancelled at the term-collision prompt → no write.
-
-** Testing
-
-Per-function test files; three categories (Normal/Boundary/Error) per function. TDD by default. Real production code via =require=, never inlined.
-
-*=gloss-core=.* Temp files + real =org-element-parse-buffer=. No mocking — exercises the actual file I/O and parser.
-
-#+begin_example
-test-gloss-core--lookup.el
-test-gloss-core--save.el
-test-gloss-core--invalidate-on-mtime.el
-test-gloss-core--corrupt-file-preserves-cache.el
-test-gloss-core--alphabetical-insert.el
-test-gloss-core--first-call-creates-file.el
-#+end_example
-
-*=gloss-fetch=.* =cl-letf= mock on =url-retrieve-synchronously=, injecting canned response buffers. Captured Wiktionary fixtures in =tests/fixtures/wiktionary-*.json= — real responses for SBIR, anaphora, API, frozen once, replayed forever.
-
-#+begin_example
-test-gloss-fetch--definitions-200-returns-ok.el
-test-gloss-fetch--definitions-404-returns-no-defs.el
-test-gloss-fetch--definitions-500-returns-server-error.el
-test-gloss-fetch--definitions-timeout-returns-unreachable.el
-test-gloss-fetch--strip-html.el
-test-gloss-fetch--multi-source-walks-registry.el
-test-gloss-fetch--libxml-probe.el
-#+end_example
-
-*=gloss-display=.* The candidate-formatting helper =gloss-display--format-candidate PLIST → "[wiktionary] text..."= is pure → full N/B/E coverage. =gloss-display-show-entry= and =gloss-mode= get one smoke test each (Emacs already tests =switch-to-buffer= and major-mode definition).
-
-#+begin_example
-test-gloss-display--format-candidate.el
-test-gloss-display--show-entry-smoke.el
-#+end_example
-
-*=gloss-drill=.* Temp file + real =org-element=. Tests assert tag/property changes on entries.
-
-#+begin_example
-test-gloss-drill--export-all-tags-untagged.el
-test-gloss-drill--export-all-skips-already-tagged.el
-test-gloss-drill--export-all-no-orgdrill-installed.el
-test-gloss-drill--untag-all.el
-#+end_example
-
-*=gloss=.* The orchestration policy =gloss--orchestrate-fetch-result RESULT → SYMBOL= is a pure pattern-matcher. Tested with shaped inputs covering every result variant.
-
-#+begin_example
-test-gloss--orchestrate-fetch-result.el
-#+end_example
-
-*Integration tests.* Three small ones, each with a docstring naming participants per project convention.
-
-#+begin_example
-test-integration-gloss-lookup-flow-local-hit.el
-test-integration-gloss-lookup-flow-online-fall-through.el
-test-integration-gloss-lookup-flow-online-failure.el
-#+end_example
-
-*Coverage targets.* 90%+ on =gloss-core=, =gloss-fetch=, =gloss-drill=, and pure helpers in =gloss-display= / =gloss=. 70%+ on display mode-glue. Overall ≥80%.
-
-** Observability
-
-*=*gloss-debug*= log buffer.* Off until =gloss-debug= defcustom is non-nil, or session-only =gloss-toggle-debug= flips it. One timestamped, layer-prefixed line per significant event.
-
-#+begin_example
-2026-04-28 11:14:02 [fetch:wiktionary] GET /API → 200, 12 senses
-2026-04-28 11:14:02 [fetch:wiktionary] sense 7 HTML parse failed, dropping
-2026-04-28 11:14:02 [core] cache hit for "anaphora"
-2026-04-28 11:14:09 [core] mtime change detected, reloading cache (47 terms)
-2026-04-28 11:14:11 [save] "API" → wiktionary, 11 alts not saved
-#+end_example
-
-Per-source statuses from Error Handling land here verbatim. No personal data beyond user-supplied terms.
-
-*=*Messages*= for user-facing events.* Saves, picker-shown, "no definition found" messages — short single-line =message= calls, persisted in =*Messages*= via Emacs idiom. Strict separation: =*Messages*= for things the user did or asked for; =*gloss-debug*= for everything else.
-
-*Inspection commands.*
-
-- =gloss-list-terms= — completing-read over every term in the cache. Pick one to jump to it.
-- =gloss-stats= — small buffer summarizing total terms, breakdown by =:source=, count of drill-tagged entries, file size, cache mtime.
-
-No metrics export, no telemetry, no profiling hooks — v3 territory if the package ever needs them.
-
-* Open Questions (will become ADRs)
-
-Each was decided during the brainstorm. Listed for traceability; each becomes an ADR in the gloss repo's =docs/decisions/=.
-
-- [ ] *ADR-1: storage path default* → =(expand-file-name "gloss.org" (or org-directory user-emacs-directory))=. Rationale: respects the user's existing =org-directory= convention; falls back gracefully.
-- [ ] *ADR-2: auto-fetch on local miss* → silent fall-through with graceful network-failure path. Rationale: y/n prompt is yes 99% of the time and an annoyance the other 1%; the offline case is better handled by detecting the failure than by pre-asking permission.
-- [ ] *ADR-3: drill direction* → =:DRILL_CARD_TYPE: twosided=. Rationale: tests both recognition and recall over time without doubling the deck.
-- [ ] *ADR-4: HTML strip strategy* → =libxml-parse-html-region= (plain text only, no italic/bold preservation). Rationale: more robust than regex on edge cases; libxml2 is standard on Linux/Mac; ~30 lines.
-
-* Next Steps
-
-1. *Scaffold the repo.* =~/code/gloss= with the claude-template structure: =.ai/= and =todo.org= and =inbox/= gitignored, =Makefile= for tests/lint/compile, =README.org= placeholder, =LICENSE=, package skeleton (=gloss.el= with package-header autoload entry).
-2. *Set up remotes.* Bare repo on cjennings.net at =/var/cjennings/git/gloss.git/= with the existing post-receive hook pattern that mirrors to =github.com/cjennings/gloss=.
-3. *Decompose into todo.org tasks.* One TODO per layer, in implementation order: core → fetch → display → drill → entry-point → integration tests → README. Each task carries its acceptance criteria from this design.
-4. *Implement v1 layer by layer*, TDD per project rules. Run =/start-work= once per task.
-5. *First-week shakedown.* Use the package on real terms for a week. File issues against any rough edges as v1.1 tasks.
-6. *Record the four ADRs* in =docs/decisions/= once the repo exists.
-
-* Status
-
-Draft. Pending: repo scaffold, ADR records, implementation.
diff --git a/docs/specs/google-keep-emacs-integration-spec.org b/docs/specs/google-keep-emacs-integration-spec.org
index 376522ab..96fd83e5 100644
--- a/docs/specs/google-keep-emacs-integration-spec.org
+++ b/docs/specs/google-keep-emacs-integration-spec.org
@@ -1,7 +1,14 @@
#+TITLE: Google Keep <-> Emacs integration — Spec
#+AUTHOR: Craig Jennings & Claude
#+DATE: 2026-06-24
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DOING Google Keep <-> Emacs integration — Spec
+:PROPERTIES:
+:ID: 4c796fb9-1d3e-42a9-9b76-eb286eee8732
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from Metadata Status: v1 implemented, v2 next -> work ongoing
* Metadata
| Status | v1 implemented (Phases 1-3); live setup pending; v2 next |
diff --git a/docs/specs/init-load-graph-spec-doing.org b/docs/specs/init-load-graph-spec.org
index 05dd9e0a..33ed0d34 100644
--- a/docs/specs/init-load-graph-spec-doing.org
+++ b/docs/specs/init-load-graph-spec.org
@@ -1,10 +1,15 @@
-:PROPERTIES:
-:ID: e1fd137e-e164-42f4-a658-f4d32fbe3228
-:STATUS: doing
-:END:
#+TITLE: Design: Untangle the init.el Load Graph
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-04
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DOING Design: Untangle the init.el Load Graph
+:PROPERTIES:
+:ID: e1fd137e-e164-42f4-a658-f4d32fbe3228
+:END:
+- 2026-07-10 Fri @ 22:50:03 -0500 — reconciled the programming target to shipped decisions: generic LSP policy consolidated under =prog-general= (not =prog-lsp=, which was folded in and deleted, commit dfdb3580), and tree-sitter auto-install gated to ='prompt=. Both were owned items of this spec; recording the outcome, keyword stays DOING for the remaining load-graph work.
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing
* Status
@@ -178,7 +183,7 @@ Foundation modules should be able to load in batch mode without package,
network, timer, or UI-package side effects.
Adding a new Layer 1 module requires a coordinated update to the
-=system-lib.el= dependency budget in [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]].
+=system-lib.el= dependency budget in [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec.org]].
Topic libraries introduced by the utility project join Layer 1 only when their
first consumer is foundation-eager. Otherwise they are Layer 2 and loaded by an
@@ -308,7 +313,7 @@ Category key:
| =video-audio-recording= | O/D/S | command-loaded | External process/device probing only on command. |
| =transcription-config= | O/D/P | command-loaded | Auth/process workflow. |
| =weather-config= | O/D/P | command-loaded | Optional command. |
-| =prog-general= | C/P/S | eager or hooks | Projectile, treesit policy, LSP ownership concerns. |
+| =prog-general= | C/P/S | eager or hooks | Projectile, treesit 'prompt, sole LSP policy owner. |
| =test-runner= | C/L | eager command entry | Test keymap and project-scoped state. |
| =vc-config= | C/P | eager command entry | Magit/git keymap; clone command hardening separate. |
| =flycheck-config= | C/P | hooks | General linting. |
@@ -316,7 +321,6 @@ Category key:
| =prog-c= | D/P | mode-loaded | C hooks and compile command. |
| =prog-go= | D/P | mode-loaded | Go hooks/LSP. |
| =prog-lisp= | D/P | mode-loaded | Lisp package config. |
-| =prog-lsp= | C/P | package policy owner | Should consolidate generic LSP policy. |
| =prog-shell= | D/P/S | mode-loaded | after-save executable hook should be opt-in or scoped. |
| =prog-python= | D/P | mode-loaded | Python hooks/LSP. |
| =prog-webdev= | D/P | mode-loaded | Webdev modes/LSP. |
@@ -395,7 +399,7 @@ Worked example:
;; Runtime requires: user-constants, seq, subr-x.
;; Direct test load: yes (batch-safe; private config is optional).
;;
-;; See also: docs/specs/init-load-graph-spec-doing.org, tests/test-calendar-sync.el.
+;; See also: docs/specs/init-load-graph-spec.org, tests/test-calendar-sync.el.
;;
;;; Code:
#+end_src
@@ -452,7 +456,7 @@ Inventory rules:
- Every module required by =init.el= must be represented before Phase 2 starts.
- Discoveries during later phases update the inventory.
- This inventory is independent from the helper inventory owned by
- [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]].
+ [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec.org]].
Exit criteria:
@@ -599,15 +603,20 @@ Programming target:
- Keep generic programming defaults and F-key command entry points available.
- Load language-specific modules by major mode.
-- Consolidate generic LSP policy under =prog-lsp=.
- - Move to =prog-lsp=: global LSP toggles such as =lsp-idle-delay=,
- =lsp-log-io=, =lsp-enable-folding=, =lsp-enable-snippet=,
- =lsp-headerline-breadcrumb-enable=, and file-watch ignore lists.
- - Keep per-language: server client settings such as
- =lsp-clients-clangd-args= and =lsp-pyright-*=, plus language-mode hook
- wiring.
-- Tree-sitter grammar auto-install is always on; the project policy is global
- allow. =treesit-auto-install= is =t= without per-language conditionals.
+- Generic LSP policy is consolidated under =prog-general= (done 2026-07-10, commit
+ dfdb3580). The original plan named =prog-lsp= as owner, but that module was
+ required by nothing and never loaded, so its config was dead; folding it into the
+ module that actually loads (=prog-general=) and deleting it was the working fix.
+ - In =prog-general=: global LSP toggles such as =lsp-idle-delay=, =lsp-log-io=,
+ =lsp-enable-folding=, =lsp-enable-snippet=, the quiet-UI toggles, and the
+ file-watch ignore list.
+ - Kept per-language: server client settings such as =lsp-clients-clangd-args= and
+ =lsp-pyright-*=, plus the =lsp-deferred= language-mode hook wiring.
+ - Remaining: several language modules call =lsp-deferred= from both a local setup
+ function and a package hook; collapse each to one hook path per language.
+- Tree-sitter grammar auto-install is gated to ='prompt= (done). Batch/test runs never
+ auto-install, and =cj/install-treesit-grammars= is the explicit bootstrap for a new
+ machine.
Org target:
@@ -621,9 +630,10 @@ Org target:
=cj-cache.el= extraction is owned by utility-consolidation Phase 5 and may
follow.
-The =prog-lsp= consolidation and tree-sitter policy decisions are owned by this
-load-graph project. Utility consolidation owns reusable helper extraction, not
-programming policy.
+The LSP consolidation and tree-sitter policy decisions are owned by this
+load-graph project. Both landed 2026-07-10 (LSP under =prog-general=, tree-sitter at
+='prompt=). Utility consolidation owns reusable helper extraction, not programming
+policy.
Exit criteria:
@@ -647,7 +657,7 @@ rollback shapes.
This sibling project can run beside Phase 2. When explicit-dependency work finds
a generic duplicated helper, the sibling project owns the extraction commit when
the helper is in scope for that project. See
-[[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]] for candidate
+[[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec.org]] for candidate
helpers, naming rules, dependency budgets, migration phases, and test policy.
* Testing Strategy
diff --git a/docs/specs/keybinding-console-safety-spec-doing.org b/docs/specs/keybinding-console-safety-spec.org
index 4a1dec81..5fd7d52c 100644
--- a/docs/specs/keybinding-console-safety-spec-doing.org
+++ b/docs/specs/keybinding-console-safety-spec.org
@@ -1,13 +1,18 @@
-:PROPERTIES:
-:ID: 540bf06b-16b8-46c6-b459-c40d1b9c795d
-:STATUS: doing
-:END:
#+TITLE: Keymap Consolidation — Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-12
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* READY Keymap Consolidation — Spec
+:PROPERTIES:
+:ID: 540bf06b-16b8-46c6-b459-c40d1b9c795d
+:END:
+- 2026-07-21 Tue @ 07:07 -0500 — DOING → READY: the DOING was a legacy :STATUS: retrofit with no build tasks ever decomposed, and the primary work has not started (the key-translation layer is still live in keyboard-compat.el, M-S- bindings global). READY is the honest state: design settled per Path 2, awaiting decomposition. Flipped in the 2026-07-21 board review with Craig.
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing
* Metadata
-| Status | doing |
+| Status | ready |
|----------+--------------------------------------------------------------------|
| Owner | Craig Jennings |
|----------+--------------------------------------------------------------------|
@@ -568,7 +573,7 @@ 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-f — cj/fontaine-select-profile — switch the workflow font profile — 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
@@ -894,7 +899,7 @@ translation block being retired). =C-l= appears only minibuffer-local in
- 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/specs/keybinding-console-safety-spec-doing.org; supersedes the
+- Artifacts: docs/specs/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
diff --git a/docs/specs/messenger-unification-spec.org b/docs/specs/messenger-unification-spec.org
index 92985f59..7847fd04 100644
--- a/docs/specs/messenger-unification-spec.org
+++ b/docs/specs/messenger-unification-spec.org
@@ -1,11 +1,15 @@
-:PROPERTIES:
-:ID: 4bfc2011-8ffc-4765-8886-91df12141171
-:STATUS: not-started
-:END:
#+TITLE: Messenger Unification — Shared Window Placement and Key Conventions
#+AUTHOR: Craig Jennings & Claude
#+DATE: 2026-06-11
-#+STATUS: Draft — decisions 1-9 settled (Craig, 2026-06-11/12); held open for further ideas before Ready
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Messenger Unification — Shared Window Placement and Key Conventions
+:PROPERTIES:
+:ID: 4bfc2011-8ffc-4765-8886-91df12141171
+:END:
+- 2026-07-14 Tue @ 01:10:00 -0500 — premise shift: the signel client was retired to archive/ (agents drive Signal via signal-cli), so the registry example, the "signel remains the running reference" language, and the backend list all need a rewrite before this leaves DRAFT — the live in-Emacs backends are now telega and Slack, with smoke (~/code/smoke) still the future native adopter. Also landed since the 06-11 survey: slack-config gained signel-shape notification hardening (c69f2f56) and telega notifications are on (5bc5ef7a), so the shared cj/messenger-notify extraction has two live call sites ready.
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started (held open for more ideas)
* Problem
diff --git a/docs/specs/music-config-without-emms-spec.org b/docs/specs/music-config-without-emms-spec.org
index 32fd6736..c63706e5 100644
--- a/docs/specs/music-config-without-emms-spec.org
+++ b/docs/specs/music-config-without-emms-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: 423bc355-18d3-4e39-9e7a-f768b865d95b
-:STATUS: not-started
-:END:
#+TITLE: Design: music-config Without EMMS
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-15
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Design: music-config Without EMMS
+:PROPERTIES:
+:ID: 423bc355-18d3-4e39-9e7a-f768b865d95b
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started
* Status
diff --git a/docs/specs/org-faces-spec-implemented.org b/docs/specs/org-faces-spec.org
index c8855906..94fe7bb4 100644
--- a/docs/specs/org-faces-spec-implemented.org
+++ b/docs/specs/org-faces-spec.org
@@ -1,11 +1,14 @@
-:PROPERTIES:
-:ID: 35578114-8c29-43af-97a2-fdfea01a802e
-:STATUS: implemented
-:END:
#+TITLE: Org Header-Row Faces — Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-15
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Org Header-Row Faces — Spec
+:PROPERTIES:
+:ID: 35578114-8c29-43af-97a2-fdfea01a802e
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword IMPLEMENTED from existing :STATUS: implemented + -implemented filename (Craig's prior determination)
* Metadata
| Status | implemented |
diff --git a/docs/specs/signal-client-spec-doing.org b/docs/specs/signal-client-spec.org
index beee0acf..56c9ea1a 100644
--- a/docs/specs/signal-client-spec-doing.org
+++ b/docs/specs/signal-client-spec.org
@@ -1,10 +1,15 @@
+#+TITLE: Design: Signal client in Emacs (forked signel)
+#+DATE: 2026-05-26
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Design: Signal client in Emacs (forked signel)
:PROPERTIES:
:ID: 0cabd6ee-c458-47b5-a8af-3ee054b25821
-:STATUS: doing
:END:
-#+TITLE: Design: Signal client in Emacs (forked signel)
-#+DATE: 2026-05-26
-#+STATUS: Draft
+- 2026-07-14 Tue @ 01:10:00 -0500 — RETIRED from the config (Craig's call, ~an hour after the IMPLEMENTED flip): agents drive Signal via signal-cli / signal-mcp, so the interactive in-Emacs client earns no keep. signal-config.el and its seven test files moved to archive/ (see archive/README.org); the C-; M prefix unregistered; the ~/code/signel fork repo untouched. Keyword stays IMPLEMENTED as the honest record of what was built.
+- 2026-07-14 Tue @ 01:01:32 -0500 — IMPLEMENTED (Craig's call): v1 is shipped and in daily use — forked signel engine, contact picker with cached contacts (lifecycle hardened 703b4841 / 1296cc45), notifications with sound gating and script-with-fallback delivery, bottom-30% window rule. Next-generation client work belongs to the smoke project (~/code/smoke, its own architecture spec), which the messenger-unification spec designates as the ground-up replacement; signal-config.el stays the running reference until smoke reaches parity.
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing
* Problem
I want a Signal chat client inside Emacs: link it as a secondary device to my phone, pick a contact from my contact list, hold a text 1:1 conversation (read and send), and get a desktop notification on incoming messages, with an optional sound. Signal has no official API, so this is built on =signal-cli=, the mature headless CLI, driven over JSON-RPC.
diff --git a/docs/specs/theme-studio-completion-preview-spec.org b/docs/specs/theme-studio-completion-preview-spec.org
index 588f35a9..7d0c2608 100644
--- a/docs/specs/theme-studio-completion-preview-spec.org
+++ b/docs/specs/theme-studio-completion-preview-spec.org
@@ -1,7 +1,14 @@
#+TITLE: Theme Studio Minibuffer-Completion Preview — Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-23
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Theme Studio Minibuffer-Completion Preview — Spec
+:PROPERTIES:
+:ID: 2462f067-4c8d-4c33-a5be-54c0abc2eb1d
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from Metadata Status: Not ready, review found blockers
* Metadata
| Status | Not ready — first Codex review found implementation-readiness blockers (2026-06-23) |
@@ -195,7 +202,7 @@ Add the caption naming minibuffer-prompt + highlight as living in UI Faces. When
- Manual: open theme-studio in Chrome on the owner's inventory and confirm the Vertico section + baseline render, orderless/marginalia toggle, and vertico-current shows no background.
* References / Appendix
-- Reuse: [[file:theme-studio-preview-locate-spec.org][theme-studio-preview-locate-spec.org]] (hover/click locate), [[file:theme-studio-package-faces-spec-doing.org][theme-studio-package-faces-spec-doing.org]].
+- Reuse: [[file:theme-studio-preview-locate-spec.org][theme-studio-preview-locate-spec.org]] (hover/click locate), [[file:theme-studio-package-faces-spec.org][theme-studio-package-faces-spec.org]].
- Spike: /tmp completion-face-preview.el (verified render; not committed — informs this spec, not grown into it).
- Live face values captured 2026-06-23 (WIP theme): minibuffer-prompt #899bb1/#100f0f bold; orderless-match-face-0..3 #cbd0d6 / #c99990 / #c5d4ae / #bea9dc bold italic; vertico-current inherits highlight (#eddba7 bold, no background).
diff --git a/docs/specs/theme-studio-nerd-icons-colors-spec.org b/docs/specs/theme-studio-nerd-icons-colors-spec.org
index c0f07b6d..94a5d178 100644
--- a/docs/specs/theme-studio-nerd-icons-colors-spec.org
+++ b/docs/specs/theme-studio-nerd-icons-colors-spec.org
@@ -1,7 +1,14 @@
#+TITLE: Theme-driven nerd-icons colors + theme-studio filetype legend — Spec
#+AUTHOR: Craig Jennings & Claude
#+DATE: 2026-06-23
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* READY Theme-driven nerd-icons colors + theme-studio filetype legend — Spec
+:PROPERTIES:
+:ID: 6df4e8a3-1fca-452a-9416-3fa0647b8dff
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword READY from Metadata Status: Ready pending Craig's go
* Metadata
| Status | Ready pending Craig's go — Codex review rounds 1-3 incorporated |
diff --git a/docs/specs/theme-studio-package-faces-spec-doing.org b/docs/specs/theme-studio-package-faces-spec.org
index 566f34db..6e431dd5 100644
--- a/docs/specs/theme-studio-package-faces-spec-doing.org
+++ b/docs/specs/theme-studio-package-faces-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: 8f37a1fd-cfd3-4b25-92e5-772468092bdc
-:STATUS: doing
-:END:
#+TITLE: theme-studio — package faces (tier 3), starting with org-mode
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-07
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DOING theme-studio — package faces (tier 3), starting with org-mode
+:PROPERTIES:
+:ID: 8f37a1fd-cfd3-4b25-92e5-772468092bdc
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing
* Status
@@ -548,7 +552,7 @@ generalized face-control helper, package style kept inside the package object,
- *Why:* The direction is coherent and the first-round decisions are folded in,
but v1 now depends on behavior that is not yet implementable from the current
static generator without a defined inventory and state/export contract.
-- *Artifacts:* [[file:theme-studio-package-faces-spec-review.org][theme-studio-package-faces-spec-review.org]]
+- *Artifacts:* =theme-studio-package-faces-spec-review.org=
** 2026-06-07 Sun @ 18:28:02 -0500 — Claude Code (emacs-d) — responder
- *What:* Ran spec-response against the Codex review. Added Implementation
diff --git a/docs/specs/theme-studio-palette-generator-spec-doing.org b/docs/specs/theme-studio-palette-generator-spec.org
index b98e1078..ab84894c 100644
--- a/docs/specs/theme-studio-palette-generator-spec-doing.org
+++ b/docs/specs/theme-studio-palette-generator-spec.org
@@ -1,11 +1,14 @@
-:PROPERTIES:
-:ID: 2df157b8-c7c1-47a9-b080-d9586c6f424c
-:STATUS: doing
-:END:
#+TITLE: Theme Studio Palette Generator -- Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-14
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DOING Theme Studio Palette Generator -- Spec
+:PROPERTIES:
+:ID: 2df157b8-c7c1-47a9-b080-d9586c6f424c
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing
* Metadata
| Status | doing |
@@ -275,7 +278,7 @@ Use the existing Theme Studio test stack:
- Manual Chrome pass on at least one dark palette and one light palette.
* References / Appendix
-- [[file:design/theme-studio-color-harmony.org][theme-studio color harmony explainer]]
+- [[file:../design/theme-studio-color-harmony.org][theme-studio color harmony explainer]]
- [[id:15db8ae3-fc14-49f3-9ed5-d5ff59790904][perceptual color metrics spec]]
- [[file:theme-studio-palette-ramps-spec.org][palette ramps and contrast safety spec]]
- [[file:theme-studio-palette-columns-spec.org][palette columns spec]]
diff --git a/docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org b/docs/specs/theme-studio-perceptual-color-metrics-spec.org
index 57a4c70b..f84bc5bb 100644
--- a/docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org
+++ b/docs/specs/theme-studio-perceptual-color-metrics-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: 15db8ae3-fc14-49f3-9ed5-d5ff59790904
-:STATUS: implemented
-:END:
#+TITLE: theme-studio — perceptual color metrics (OKLCH, APCA, ΔE)
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-08
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED theme-studio — perceptual color metrics (OKLCH, APCA, ΔE)
+:PROPERTIES:
+:ID: 15db8ae3-fc14-49f3-9ed5-d5ff59790904
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword IMPLEMENTED from existing :STATUS: implemented + -implemented filename (Craig's prior determination)
* Status
@@ -506,7 +510,7 @@ Modified or rejected recommendations only; everything else in the Codex review
values or make that second chromatic fixture optional.
- *Why:* The implementation is otherwise ready-shaped, but APCA math and numeric
fixtures need a single authoritative source before coding starts.
-- *Artifacts:* [[file:theme-studio-perceptual-color-metrics-spec-review.org][theme-studio-perceptual-color-metrics-spec-review.org]]
+- *Artifacts:* =theme-studio-perceptual-color-metrics-spec-review.org=
** 2026-06-08 Mon @ 13:19:15 -0500 — Claude Code — responder
- *What changed:* Processed Codex's second pass. Accepted all three findings, no
diff --git a/docs/specs/theme-studio-preview-locate-spec.org b/docs/specs/theme-studio-preview-locate-spec.org
index dee27e8c..2f07d9dd 100644
--- a/docs/specs/theme-studio-preview-locate-spec.org
+++ b/docs/specs/theme-studio-preview-locate-spec.org
@@ -1,11 +1,14 @@
-:PROPERTIES:
-:ID: fbcf0e20-1328-42b4-aa36-3401509e7816
-:STATUS: ready-pending-go
-:END:
#+TITLE: Theme Studio Preview Element Locate — Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-15
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* READY Theme Studio Preview Element Locate — Spec
+:PROPERTIES:
+:ID: fbcf0e20-1328-42b4-aa36-3401509e7816
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword READY from existing :STATUS: ready-pending-go
* Metadata
| Status | Ready pending Craig's go — four reviews incorporated (Codex, 2026-06-23) |
diff --git a/docs/specs/theme-studio-seeding-engine-spec-doing.org b/docs/specs/theme-studio-seeding-engine-spec.org
index baf9f5b0..25fab26c 100644
--- a/docs/specs/theme-studio-seeding-engine-spec-doing.org
+++ b/docs/specs/theme-studio-seeding-engine-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: b70b37f2-37df-4c8e-ac2f-1f20d12e33dd
-:STATUS: doing
-:END:
#+TITLE: theme-studio — seeding engine (role table to guide-correct defaults)
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-08
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DOING theme-studio — seeding engine (role table to guide-correct defaults)
+:PROPERTIES:
+:ID: b70b37f2-37df-4c8e-ac2f-1f20d12e33dd
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing
* Status
@@ -317,7 +321,7 @@ response resolved; everything else was woven into the body as written.
budget this engine executes.
- =scripts/theme-studio/generate.py= — =CATS=, =UI_FACES=/=UIMAP=, =APPS= /
=seedPkgmap=, =exportObj= (the target shape).
-- =docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org= — the
+- =docs/specs/theme-studio-perceptual-color-metrics-spec.org= — the
=colormath.js= core that v1 OKLCH shade generation uses.
* Review and iteration history
@@ -352,3 +356,10 @@ response resolved; everything else was woven into the body as written.
and reseed behavior, package preservation rules, artifact round-trip tests,
and vNext boundaries.
- *Artifacts:* No review file written; no blocking findings.
+
+** 2026-07-04 Sat @ 18:33:29 -0500 — Claude Code — implementation finding (Phase 2 blocked)
+- *What changed or was recommended:* Phase 1 landed clean (commit f407959b). Starting Phase 2, tracing the actual generator surfaced that a load-bearing premise of this spec is false against the code. The spec's Package scope says the ~20 non-org bespoke packages "keep their curated =APPS= seed colors" as the state the engine preserves and reseed resets to. They don't: =apply_default_face_seeds= (=app_inventory.py:139=) unconditionally overwrites *every* bespoke package face with =defaults.seed(face, False)= — the Emacs-default snapshot color — whenever =emacs-default-faces.json= is present (it is). So in the built page the curated dupre =SEED= dicts in =face_data.py= (=ORG_SEED=, =MAGIT_SEED=, …) are shadowed and never live: org faces open empty, and magit/elfeed/mu4e/the rest open on light-theme snapshot hexes (e.g. =magit-section-heading= #8b6508 on #f2f2f2), not their dupre curated colors.
+- *Why it blocks Phase 2:* Two acceptance criteria then contradict under the real data. "Non-org packages keep their curated =APPS= defaults" and "a Chrome eyeball confirms a coherent dupre" cannot both hold — seeding syntax/UI/org to dark dupre while the package tiers stay on light snapshot hexes reads incoherent. A second wrinkle: =seed()= (JS) emits only org among packages by design, but the non-org dupre colors live in =face_data.py= (Python, name-resolved), so the =dupre-revised.json= emitter cannot be pure-Node — it needs a Python contribution or a different source for the non-org package colors.
+- *The fork (Craig to decide before the spec is revised):* (1) reseed the non-org packages from the curated dupre =SEED= dicts in =face_data.py=, resolved against a dupre palette — coherent, honors "curated," but the seeded build must stop =apply_default_face_seeds= from shadowing them and the emitter needs a Python side; or (2) keep the Emacs-default snapshot hexes — literally "keep current defaults," but the theme reads incoherent and the coherence gate fails.
+- *Disposition:* Craig chose to pause Phase 2 and revise the spec first (its "curated seeds are live" premise needs correcting). Spec kept =DOING=; Phase-2 and test-surface build tasks marked =:blocked:= in =todo.org= with a =VERIFY= capturing the direction decision. Phase 1 (=seed()= + =#seedtest=) stands.
+- *Artifacts:* the =todo.org= seeding-engine subtree (Phase-2 subtask + VERIFY).
diff --git a/docs/specs/theme-studio-semantic-theme-architecture-spec.org b/docs/specs/theme-studio-semantic-theme-architecture-spec.org
index 01ef1902..cc46d336 100644
--- a/docs/specs/theme-studio-semantic-theme-architecture-spec.org
+++ b/docs/specs/theme-studio-semantic-theme-architecture-spec.org
@@ -1,11 +1,14 @@
-:PROPERTIES:
-:ID: fe980b12-451a-4d8b-a550-d99f9ec49f45
-:STATUS: not-started
-:END:
#+TITLE: Theme Studio Semantic Theme Architecture -- Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-14
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Theme Studio Semantic Theme Architecture -- Spec
+:PROPERTIES:
+:ID: fe980b12-451a-4d8b-a550-d99f9ec49f45
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started
* Metadata
| Status | not-started |
@@ -255,9 +258,9 @@ Rollout should keep the current flat output path as the default and add a separa
* References / Appendix
- Modus Themes source: [[https://github.com/protesilaos/modus-themes][github.com/protesilaos/modus-themes]]
-- Current converter: [[file:../scripts/theme-studio/build-theme.el][scripts/theme-studio/build-theme.el]]
-- Current Theme Studio README: [[file:../scripts/theme-studio/README.md][scripts/theme-studio/README.md]]
-- Package-face model spec: [[id:8f37a1fd-cfd3-4b25-92e5-772468092bdc][theme-studio-package-faces-spec-doing.org]]
+- Current converter: [[file:../../scripts/theme-studio/build-theme.el][scripts/theme-studio/build-theme.el]]
+- Current Theme Studio README: [[file:../../scripts/theme-studio/README.md][scripts/theme-studio/README.md]]
+- Package-face model spec: [[id:8f37a1fd-cfd3-4b25-92e5-772468092bdc][theme-studio-package-faces-spec.org]]
* Review and iteration history
** 2026-06-14 Sunday @ 14:37:00 -0500 -- Craig -- author
diff --git a/docs/specs/theme-studio-structured-output-spec.org b/docs/specs/theme-studio-structured-output-spec.org
index ad189b7e..10aea1a8 100644
--- a/docs/specs/theme-studio-structured-output-spec.org
+++ b/docs/specs/theme-studio-structured-output-spec.org
@@ -1,11 +1,14 @@
-:PROPERTIES:
-:ID: eaac7707-ed05-43df-9e51-b17c1d672531
-:STATUS: not-started
-:END:
#+TITLE: Theme-Studio Structured Theme Output — Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-15
-#+TODO: TODO | DONE SUPERSEDED CANCELLED
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Theme-Studio Structured Theme Output — Spec
+:PROPERTIES:
+:ID: eaac7707-ed05-43df-9e51-b17c1d672531
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DRAFT from existing :STATUS: not-started
* Metadata
| Status | not-started |
diff --git a/docs/specs/utility-consolidation-spec-doing.org b/docs/specs/utility-consolidation-spec.org
index b0a5fe2b..871295d7 100644
--- a/docs/specs/utility-consolidation-spec-doing.org
+++ b/docs/specs/utility-consolidation-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: fc2e3926-b4a1-4b45-92eb-20841e13f655
-:STATUS: doing
-:END:
#+TITLE: Design: Consolidate Shared Utility Helpers
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-04
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DOING Design: Consolidate Shared Utility Helpers
+:PROPERTIES:
+:ID: fc2e3926-b4a1-4b45-92eb-20841e13f655
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword DOING from existing :STATUS: doing
* Status
@@ -295,7 +299,7 @@ Worked =system-lib.el= header:
;; Private helpers rename without alias when all call sites change in the
;; same commit.
;;
-;; See also: docs/specs/utility-consolidation-spec-doing.org for design rationale.
+;; See also: docs/specs/utility-consolidation-spec.org for design rationale.
;;
;;; Code:
#+end_src
@@ -332,7 +336,7 @@ Load shape:
- =cj-cache.el= follows the first real cache consumer's layer, likely Layer 2 if
modeline/agenda/refile remain eager or near-eager.
- Coordinate every new topic library with
- [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init-load-graph-spec-doing.org]] before migrating its first consumer.
+ [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init-load-graph-spec.org]] before migrating its first consumer.
* Naming Rules
@@ -785,7 +789,7 @@ Recommendation:
design addendum proves the API can drive the alignment.
- Then decide whether modeline's buffer-local cache can use the same library or
should remain specialized.
-- Phase 5 step 1 produces =docs/specs/cache-helper-design-spec-implemented.org=. Until that
+- Phase 5 step 1 produces =docs/specs/cache-helper-design-spec.org=. Until that
file exists, =cj-cache.el= must not be created. The addendum is the
prerequisite for any cache extraction commit.
@@ -906,7 +910,7 @@ Inventory artifact:
- Treat the inventory as living documentation. Cleared high-priority candidates
may move to Phase 2 before the whole inventory is complete.
- This inventory is independent from the module-shape inventory maintained by
- [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init-load-graph-spec-doing.org]]. The two projects may walk the same files, but they
+ [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init-load-graph-spec.org]]. The two projects may walk the same files, but they
record different facts in separate artifacts.
For each helper record:
diff --git a/docs/specs/vterm-to-ghostel-migration-spec-implemented.org b/docs/specs/vterm-to-ghostel-migration-spec.org
index 1be4fe22..f3d39d22 100644
--- a/docs/specs/vterm-to-ghostel-migration-spec-implemented.org
+++ b/docs/specs/vterm-to-ghostel-migration-spec.org
@@ -1,10 +1,14 @@
-:PROPERTIES:
-:ID: b54c94a0-d762-4b41-afd7-cf5593ce6675
-:STATUS: implemented
-:END:
#+TITLE: Migration: vterm → ghostel (single terminal engine)
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-04
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Migration: vterm → ghostel (single terminal engine)
+:PROPERTIES:
+:ID: b54c94a0-d762-4b41-afd7-cf5593ce6675
+:END:
+- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword IMPLEMENTED from existing :STATUS: implemented + -implemented filename (Craig's prior determination)
* Status
@@ -175,7 +179,7 @@ Audited file set.
** Docs (active references only — historical notes stay)
- =todo.org= current task link (already updated to this -spec path).
-- =docs/design/module-inventory.org=, =docs/specs/init-load-graph-spec-doing.org= —
+- =docs/design/module-inventory.org=, =docs/specs/init-load-graph-spec.org= —
update active =vterm-config= / =ai-vterm= references to the new names.
** Tests (~35 files)
diff --git a/githooks/pre-commit b/githooks/pre-commit
index 909cde22..a87bedf8 100755
--- a/githooks/pre-commit
+++ b/githooks/pre-commit
@@ -5,15 +5,37 @@
set -u
REPO_ROOT="$(git rev-parse --show-toplevel)"
-cd "$REPO_ROOT"
+cd "$REPO_ROOT" || exit 1
# --- 1. Secret scan ---
# Patterns for common credentials. Scans only added lines in the staged diff.
-SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])'
+#
+# Two passes because case-sensitivity differs. AWS keys are uppercase, sk- keys
+# lowercase, PEM headers fixed, so those match case-SENSITIVELY: under -i,
+# AKIA[0-9A-Z]{16} matches any mixed-case 20-char run, which random base64 in an
+# embedded image blob hits ~6% of the time per 100KB and blocks real commits.
+# Only the keyword=value patterns need -i.
+SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)'
+SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']'
-secret_hits="$(git diff --cached -U0 --diff-filter=AM \
- | grep '^+' | grep -v '^+++' \
- | grep -iEn "$SECRET_PATTERNS" || true)"
+# Read the diff on its own so a git failure is distinguishable from "grep
+# matched nothing". Both end in a non-zero status, but only one of them means
+# there is nothing to scan; piping them together and swallowing the result with
+# `|| true` made a broken git look like a clean commit — the scan searched an
+# empty string, found nothing, and the secret went in.
+if ! staged_diff="$(git diff --cached -U0 --diff-filter=AM)"; then
+ echo "pre-commit: cannot read the staged diff — refusing to skip the secret scan" >&2
+ exit 1
+fi
+
+# The greps keep their `|| true`: exiting 1 on no match is their normal result.
+added_lines="$(printf '%s\n' "$staged_diff" | grep '^+' | grep -v '^+++' || true)"
+
+cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)"
+ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)"
+# awk dedupes lines both passes matched, keeping first-seen order.
+secret_hits="$(printf '%s\n%s' "$cs_hits" "$ci_hits" \
+ | grep -v '^[[:space:]]*$' | awk '!seen[$0]++' || true)"
if [ -n "$secret_hits" ]; then
echo "pre-commit: potential secret in staged changes:" >&2
@@ -25,7 +47,14 @@ if [ -n "$secret_hits" ]; then
fi
# --- 2. Paren check on staged .el files ---
-staged_el="$(git diff --cached --name-only --diff-filter=AM | grep '\.el$' || true)"
+# Same split as the secret scan above: a git failure must not read as "no files
+# staged", which would skip the language check silently.
+if ! staged_names="$(git diff --cached --name-only --diff-filter=AM)"; then
+ echo "pre-commit: cannot read the staged file list — refusing to skip the check" >&2
+ exit 1
+fi
+
+staged_el="$(printf '%s\n' "$staged_names" | grep '\.el$' || true)"
if [ -n "$staged_el" ]; then
paren_fail=""
diff --git a/init.el b/init.el
index 591de3c8..f4355820 100644
--- a/init.el
+++ b/init.el
@@ -78,7 +78,6 @@
(require 'slack-config) ;; slack client via emacs-slack
(require 'pearl-config) ;; Linear.app issue tracking via pearl (deepsat + craigjennings)
(require 'telega-config) ;; telegram client via telega.el (TDLib in docker)
-(require 'signal-config) ;; signal client via forked signel + signal-cli
(require 'eshell-config) ;; emacs shell configuration
(require 'eat-config) ;; EAT terminal + the F12 dock-and-remember toggle
(require 'ai-term) ;; in-Emacs Claude launcher (vertical-split EAT terminal)
@@ -130,6 +129,7 @@
(require 'org-config) ;; basic org-mode settings
(require 'org-faces-config) ;; custom themeable faces for agenda keywords + priorities
(require 'org-agenda-config) ;; agenda, task tracking, and notifications
+(require 'org-agenda-frame) ;; S-<f8> dedicated agenda frame (tiled beside the working frame)
(require 'org-babel-config) ;; org-mode prog blocks; literate programming
(require 'org-capture-config)
(require 'org-contacts-config) ;; fully integrated org-mode contacts management
@@ -162,6 +162,7 @@
;; their own commands via package.el; games-config only supplies malyon's config,
;; so load it when malyon loads rather than requiring it at startup.
(with-eval-after-load 'malyon (require 'games-config))
+(require 'takuzu-config) ;; Takuzu (Binairo) puzzle on M-x takuzu; package in ~/code/takuzu
;; ------------------------------- Misc Modules --------------------------------
diff --git a/modules/ai-term-backend-eat.el b/modules/ai-term-backend-eat.el
index 9a166ff8..906abd00 100644
--- a/modules/ai-term-backend-eat.el
+++ b/modules/ai-term-backend-eat.el
@@ -100,14 +100,21 @@ typed into a bare shell. Returns the poll timer."
(cancel-timer timer))))))
timer))
-(defun cj/--ai-term-show-or-create (dir name)
+(defun cj/--ai-term-show-or-create (dir name &optional agent-command sessions)
"Show or create the AI-term buffer for project DIR with buffer NAME.
+SESSIONS, when non-nil, is a pre-fetched
+`cj/--ai-term-live-tmux-sessions' list threaded from the caller so the
+launch path pays for the tmux subprocess once.
If a buffer named NAME exists with a live process, display it. If
the buffer exists but its process is dead, kill it and recreate. If
no such buffer exists, create a new EAT terminal in DIR and send
the project's tmux launch command (see `cj/--ai-term-launch-command') so
the same project basename reattaches across Emacs restarts.
+AGENT-COMMAND, when non-nil, is the full agent launch command for a
+fresh session (the multi-backend picker's choice); nil falls back to
+`cj/ai-term-agent-command'. A reattach ignores it (`tmux new-session
+-A' attaches without running the command).
EAT runs a plain shell with no auto-tmux hook, so the named
`tmux new-session -A' launch command is the only thing that starts the
@@ -131,7 +138,8 @@ buffer."
;; session gets the project /color injected below; a reattach carries
;; whatever color the running Claude already has.
(let ((fresh (not (cj/--ai-term-session-active-p
- dir (cj/--ai-term-live-tmux-sessions)))))
+ dir (or sessions
+ (cj/--ai-term-live-tmux-sessions))))))
;; `eat' switches to its buffer in the selected window before our
;; display-buffer-alist rule can route it; `save-window-excursion'
;; reverts that, and the explicit display-buffer below routes the buffer
@@ -148,19 +156,21 @@ buffer."
(with-current-buffer buf
(cj/--ai-term-apply-accent buf)
(cj/--ai-term-send-string
- buf (concat (cj/--ai-term-launch-command dir) "\n")))
+ buf (concat (cj/--ai-term-launch-command dir agent-command) "\n")))
(when fresh
(cj/--ai-term-schedule-color buf (cj/--ai-term-project-color dir)))
(display-buffer buf)
buf))))))
;; In EAT's semi-char mode, keys not bound in `eat-semi-char-mode-map' are
-;; forwarded to the pty. M-SPC (swap to the next agent) must reach Emacs from
-;; inside an agent buffer, so bind it in that map -- no exception-list or rebuild
+;; forwarded to the pty. The swap-to-next chords must reach Emacs from inside
+;; an agent buffer, so bind them in that map -- no exception-list or rebuild
;; dance like ghostel needed. C-; is already bound there (eat-config), so the
;; C-; a family resolves through the global prefix without extra wiring.
+;; M-SPC cycles attached agents only; M-S-SPC cycles all (attaching a detached).
(with-eval-after-load 'eat
- (keymap-set eat-semi-char-mode-map "M-SPC" #'cj/ai-term-next))
+ (keymap-set eat-semi-char-mode-map "M-SPC" #'cj/ai-term-next-attached)
+ (keymap-set eat-semi-char-mode-map "M-S-SPC" #'cj/ai-term-next))
(provide 'ai-term-backend-eat)
;;; ai-term-backend-eat.el ends here
diff --git a/modules/ai-term-display.el b/modules/ai-term-display.el
index b78a2638..fdb2b7ed 100644
--- a/modules/ai-term-display.el
+++ b/modules/ai-term-display.el
@@ -248,10 +248,13 @@ or a layout split on the other axis), so the chain falls through to
nil when the edge window is dedicated -- those are not ours to replace.
Records the displaced buffer through `display-buffer-record-window'
-\(type `reuse') before swapping, so the native `quit-restore-window'
-called at toggle-off puts that buffer back into the slot instead of
-deleting the window -- toggling swaps the slot's buffer between the
-displaced buffer and the agent, never changing the window count.
+\(type `reuse') before swapping. Toggle-off does NOT put that buffer
+back: `cj/--ai-term-toggle-off' deletes the agent's window outright in
+multi-window layouts, because the slot's `quit-restore' parameter goes
+stale when several agents share it (see its docstring). The displaced
+buffer stays alive and reachable through normal buffer switching; the
+record call just keeps the window's `quit-restore' parameter accurate
+for native `quit-window' paths outside the toggle.
Runs after `cj/--ai-term-reuse-existing-agent', so an agent already on
screen has been handled already; the window reused here always holds a
diff --git a/modules/ai-term-sessions.el b/modules/ai-term-sessions.el
index 99585a70..57d735d1 100644
--- a/modules/ai-term-sessions.el
+++ b/modules/ai-term-sessions.el
@@ -65,6 +65,20 @@ the start so names like \"foo agent [bar]\" do not match."
(buffer-live-p buffer)
(string-prefix-p cj/--ai-term-name-prefix (buffer-name buffer))))
+(defun cj/--ai-term-buffer-basename (buffer)
+ "Return the project basename embedded in BUFFER's AI-term name, or nil.
+
+The buffer name is \"agent [<basename>]\" (see
+`cj/--ai-term-buffer-name') and never changes for the buffer's life,
+unlike `default-directory', which ghostel retargets via OSC 7 every time
+the shell cds. Teardown paths must key tmux-session lookups off this,
+not the directory, or a close after a cd kills the wrong aiv- session.
+Returns nil when BUFFER is not a live AI-term buffer."
+ (when (cj/--ai-term-buffer-p buffer)
+ (let ((name (buffer-name buffer)))
+ (when (string-suffix-p "]" name)
+ (substring name (length cj/--ai-term-name-prefix) -1)))))
+
(defun cj/--ai-term-agent-buffers ()
"Return the live AI-term buffers in `buffer-list' order.
@@ -107,6 +121,23 @@ which the step materializes by attaching."
(lambda (a b)
(string< (cj/--ai-term-buffer-name a) (cj/--ai-term-buffer-name b))))))
+(defun cj/--ai-term-attached-agent-dirs ()
+ "Return project dirs that have a live agent BUFFER (attached only).
+
+Like `cj/--ai-term-active-agent-dirs' but excludes detached tmux
+sessions with no Emacs buffer -- this is the queue `cj/ai-term-next-attached'
+\(M-SPC) steps through, so the fast chord stays among agents already on
+screen. Detached sessions are reachable only via `cj/ai-term-next'
+\(M-S-SPC). Sorted by agent buffer name for a stable rotation."
+ (let ((live-names (mapcar #'buffer-name (cj/--ai-term-agent-buffers))))
+ (sort
+ (seq-filter
+ (lambda (dir)
+ (member (cj/--ai-term-buffer-name dir) live-names))
+ (cj/--ai-term-candidates))
+ (lambda (a b)
+ (string< (cj/--ai-term-buffer-name a) (cj/--ai-term-buffer-name b))))))
+
(defun cj/--ai-term-tmux-session-name (dir)
"Return the tmux session name for project directory DIR.
@@ -157,7 +188,7 @@ looked up in SESSIONS, so the lossy whitespace->hyphen transform in
`cj/--ai-term-tmux-session-name' never needs reversing."
(and (member (cj/--ai-term-tmux-session-name dir) sessions) t))
-(defun cj/--ai-term-launch-command (dir)
+(defun cj/--ai-term-launch-command (dir &optional agent-command)
"Return the shell command line that runs the AI tool in a project tmux session.
Uses `tmux new-session -A' so a second toggle on the same project reattaches
@@ -167,9 +198,12 @@ comes from `cj/--ai-term-tmux-session-name'; the first window is named
window auto-names after its command and the two read distinctly.
The shell command run on first creation is
- <cj/ai-term-agent-command>; exec bash
+ <agent command>; exec bash
so the tmux window survives the AI command exiting -- the session stays
-alive with a bare bash prompt for recovery, and reattach works the same way."
+alive with a bare bash prompt for recovery, and reattach works the same way.
+AGENT-COMMAND overrides `cj/ai-term-agent-command' for the fresh-session
+case (the multi-backend picker passes the chosen runtime's command); on a
+reattach `tmux new-session -A' ignores the command either way."
(let ((session (cj/--ai-term-tmux-session-name dir))
(start-dir (expand-file-name dir)))
;; Pass the inner shell-command-string through `shell-quote-argument'
@@ -184,7 +218,8 @@ alive with a bare bash prompt for recovery, and reattach works the same way."
(shell-quote-argument cj/ai-term-tmux-window-name)
(shell-quote-argument start-dir)
(shell-quote-argument
- (concat cj/ai-term-agent-command "; exec bash")))))
+ (concat (or agent-command cj/ai-term-agent-command)
+ "; exec bash")))))
(defun cj/--ai-term-kill-tmux-session (session)
"Kill the tmux SESSION via `tmux kill-session -t SESSION'.
@@ -316,8 +351,11 @@ the metadata keeps the order ALIST was built in."
(cycle-sort-function . identity))
(complete-with-action action alist string predicate))))
-(defun cj/--ai-term-pick-project ()
+(defun cj/--ai-term-pick-project (&optional sessions)
"Prompt for an AI-agent project; return its absolute path.
+SESSIONS, when non-nil, is a pre-fetched result of
+`cj/--ai-term-live-tmux-sessions', so a caller that already paid for the
+tmux subprocess can thread it through instead of spawning another.
Candidates come from `cj/--ai-term-candidates', ordered by
`cj/--ai-term-sort-candidates' so projects with a live tmux session
@@ -333,7 +371,7 @@ Signals `user-error' when no candidates exist."
(append cj/ai-term-project-roots
cj/ai-term-container-roots)
", ")))
- (let* ((sessions (cj/--ai-term-live-tmux-sessions))
+ (let* ((sessions (or sessions (cj/--ai-term-live-tmux-sessions)))
(sorted (cj/--ai-term-sort-candidates candidates sessions))
(display-alist
(mapcar (lambda (p)
diff --git a/modules/ai-term.el b/modules/ai-term.el
index b67245fd..52494c18 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -46,15 +46,30 @@
(defcustom cj/ai-term-agent-command
"claude \"Read .ai/protocols.org and follow all instructions.\""
- "Shell command sent to a fresh AI-term to start the agent.
+ "Shell command for the default (\"claude\") agent runtime.
-The default invokes the Claude Code CLI; set it to whatever terminal
-agent you run (aider, an open-source LLM TUI, etc.)."
+Sent to a fresh AI-term when no other runtime is picked; also the
+fallback when launch paths bypass the runtime picker (e.g. attaching a
+detached session, where the command is ignored anyway). Non-Claude
+runtimes compose their commands from `cj/ai-term-agent-prompt' instead
+-- see `cj/--ai-term-runtime-command'."
+ :type 'string
+ :group 'ai-term)
+
+(defcustom cj/ai-term-agent-prompt
+ "Read .ai/protocols.org and follow all instructions."
+ "Opening instructions passed to non-Claude agent runtimes.
+
+Claude, Codex, and codex --oss all take the opening instructions as a
+positional prompt, so this one string serves every runtime; only the
+command in front of it varies (the \"claude\" runtime carries its full
+line in `cj/ai-term-agent-command' for backward compatibility)."
:type 'string
:group 'ai-term)
(defcustom cj/ai-term-project-roots
- (list (expand-file-name "~/.emacs.d"))
+ (list (expand-file-name "~/.emacs.d")
+ (expand-file-name "~/.dotfiles"))
"Directories that are themselves AI-agent projects.
Each entry is included as a candidate when it exists and contains
.ai/protocols.org. Use this for single-project roots like ~/.emacs.d."
@@ -239,6 +254,80 @@ without firing real `display-buffer' or `quit-window' calls."
(car buffers))))
(t '(pick-project))))))))
+;; ------------------------- Agent runtime selection ---------------------------
+;; A fresh session can run Claude, Codex (ChatGPT), or a local model through
+;; codex --oss (ollama). Runtime names and launch strings mirror the rulesets
+;; bin/ai launcher so the two launchers stay one mental model: "claude",
+;; "codex", "local:<model>". The choice list itself comes from
+;; `ai --print-runtimes' when that launcher is installed (single source of
+;; truth, including the live ollama model scan with its own timeout); without
+;; it a static claude/codex list stands in.
+
+(defun cj/--ai-term-runtime-command (runtime)
+ "Return the full agent shell command for RUNTIME.
+RUNTIME is \"claude\" (or nil, both meaning `cj/ai-term-agent-command'
+verbatim), \"codex\", or \"local:<model>\" for an ollama model via
+codex --oss. The non-Claude commands append `cj/ai-term-agent-prompt'
+as the positional opening prompt. An unknown RUNTIME signals a
+`user-error' rather than launching something half-formed. The explicit
+--local-provider flag is deliberate: setting the provider through
+config.toml silently does nothing (rulesets, 2026-07-13)."
+ (cond
+ ((or (null runtime) (equal runtime "claude"))
+ cj/ai-term-agent-command)
+ ((equal runtime "codex")
+ (concat "codex " (shell-quote-argument cj/ai-term-agent-prompt)))
+ ((string-prefix-p "local:" runtime)
+ (let ((model (substring runtime (length "local:"))))
+ (when (string-empty-p model)
+ (user-error "Agent runtime %s names no ollama model" runtime))
+ (concat "codex --oss --local-provider=ollama -m "
+ (shell-quote-argument model) " "
+ (shell-quote-argument cj/ai-term-agent-prompt))))
+ (t (user-error "Unknown agent runtime: %s" runtime))))
+
+(defun cj/--ai-term-parse-runtime-lines (output)
+ "Parse `ai --print-runtimes' OUTPUT into an alist of (NAME . LABEL).
+Each line is \"NAME — LABEL\"; blank lines and lines without the
+separator are dropped, so a stray warning in the output degrades to a
+shorter list instead of a parse error."
+ (delq nil
+ (mapcar (lambda (line)
+ (when (string-match "\\`\\(.+?\\) — \\(.+\\)\\'" line)
+ (cons (match-string 1 line) (match-string 2 line))))
+ (split-string output "\n" t))))
+
+(defun cj/--ai-term-runtime-choices ()
+ "Return the agent runtime choices as an alist of (NAME . LABEL).
+Shells out to `ai --print-runtimes' when the launcher is installed --
+that keeps the two launchers' lists identical and reuses its live
+ollama scan (which carries its own dead-server timeout). When the
+launcher is absent, errors, or prints nothing parseable, a static
+claude-first list stands in."
+ (or (when-let* ((ai (executable-find "ai")))
+ (with-temp-buffer
+ (when (eq 0 (ignore-errors
+ (process-file ai nil t nil "--print-runtimes")))
+ (cj/--ai-term-parse-runtime-lines (buffer-string)))))
+ '(("claude" . "Claude Code")
+ ("codex" . "ChatGPT (Codex CLI)"))))
+
+(defun cj/--ai-term-pick-runtime ()
+ "Prompt for the agent runtime of a fresh session; RET picks the first.
+The first choice is claude, so launching a project stays Enter-Enter
+for the common case. Labels annotate the candidates."
+ (let* ((choices (cj/--ai-term-runtime-choices))
+ (default (caar choices)))
+ (completing-read
+ (format "Agent runtime (default %s): " default)
+ (cj/completion-table-annotated
+ 'ai-term-runtime
+ (lambda (cand)
+ (when-let* ((label (cdr (assoc cand choices))))
+ (format " %s" label)))
+ choices)
+ nil t nil nil default)))
+
(defun cj/ai-term-pick-project (&optional arg)
"Pick an AI-agent project and open or reuse its EAT terminal.
@@ -253,12 +342,27 @@ With prefix ARG, display the buffer without selecting its window.
Bound to C-; a s -- always shows the project picker, even when an agent
buffer is currently displayed.
+A genuinely fresh launch (no live agent buffer AND no surviving tmux
+session) also asks which agent runtime to run -- claude, codex, or a
+local model; RET keeps claude, so the common case stays Enter-Enter.
+Reattaches and redisplays never ask: the session already runs whatever
+it runs.
+
EAT renders in terminal frames as well as GUI frames, so this
launches from either."
(interactive "P")
- (let* ((dir (cj/--ai-term-pick-project))
+ ;; One tmux fetch per launch: the same list feeds the picker's sorting,
+ ;; the fresh check here, and show-or-create's own fresh check.
+ (let* ((sessions (cj/--ai-term-live-tmux-sessions))
+ (dir (cj/--ai-term-pick-project sessions))
(name (cj/--ai-term-buffer-name dir))
- (buf (cj/--ai-term-show-or-create dir name)))
+ (existing (get-buffer name))
+ (fresh (and (not (and existing
+ (cj/--ai-term-process-live-p existing)))
+ (not (cj/--ai-term-session-active-p dir sessions))))
+ (command (when fresh
+ (cj/--ai-term-runtime-command (cj/--ai-term-pick-runtime))))
+ (buf (cj/--ai-term-show-or-create dir name command sessions)))
(unless arg
(let ((win (get-buffer-window buf)))
(when win (select-window win))))
@@ -298,18 +402,22 @@ C-; a k closes an agent via `cj/ai-term-close'."
(defun cj/--ai-term-close-buffer (buffer)
"Gracefully tear down AI-term BUFFER: tmux session, then buffer.
-Derives the tmux session name from BUFFER's `default-directory' (the
-project dir the terminal was created in) and kills it so the agent
-process stops. When BUFFER is shown, swaps its window to a non-agent
-buffer (the working file) rather than deleting the window -- closing an
-agent must not collapse the user's window layout; the hide toggle is
-what collapses the split. Then kills BUFFER (suppressing the
+Derives the tmux session name from BUFFER's immutable name (\"agent
+[<basename>]\") and kills it so the agent process stops. The name, not
+`default-directory', is the reliable key: ghostel retargets the
+directory via OSC 7 as the shell cds, so a directory-derived name after
+a cd misses the real session (orphaning the agent) or collides with a
+different aiv- session. When BUFFER is shown, swaps its window to a
+non-agent buffer (the working file) rather than deleting the window --
+closing an agent must not collapse the user's window layout; the hide
+toggle is what collapses the split. Then kills BUFFER (suppressing the
process-still-running prompt -- the session is already down). No-op
when BUFFER isn't an AI-term buffer."
(when (cj/--ai-term-buffer-p buffer)
(cj/--ai-term-kill-tmux-session
(cj/--ai-term-tmux-session-name
- (buffer-local-value 'default-directory buffer)))
+ (or (cj/--ai-term-buffer-basename buffer)
+ (buffer-local-value 'default-directory buffer))))
(let ((win (get-buffer-window buffer)))
(when (window-live-p win)
(cj/--ai-term-swap-to-working-buffer win)))
@@ -352,26 +460,18 @@ interrupt work in progress. Bound to C-; a k."
;; ------------------------- Step to the next agent ----------------------------
-(defun cj/ai-term-next ()
- "Step to the next open AI-term agent in the queue.
-
-The queue is every active agent ordered by buffer name -- a stable
-rotation, unaffected by which agent was most recently selected. Active
-means a live agent buffer (attached) OR a live tmux session with no Emacs
-buffer (detached); stepping onto a detached agent attaches it (recreates
-its terminal, which reattaches the session). When an agent window is on
-screen, swap it to the next agent (wrapping after the last) and select it.
-When no agent is displayed but agents exist, show the first. When none
-are open, open the project picker to launch the first agent rather than
-erroring. When the sole agent is already focused, echo that there are
-no other ai-terms to switch to instead of swapping to itself.
-
-Bound to M-SPC. Unlike C-; a a (toggle the most-recent agent on/off), this
-is the \"switch among existing agents\" surface; C-; a s opens the project
-picker and C-; a k closes an agent."
- (interactive)
- (let* ((dirs (cj/--ai-term-active-agent-dirs))
- (win (cj/--ai-term-displayed-agent-window))
+(defun cj/--ai-term-step-among (dirs)
+ "Step to the next AI-term agent among DIRS, an ordered active-dir list.
+
+Shared body for `cj/ai-term-next' (all active agents) and
+`cj/ai-term-next-attached' (attached agents only). When an agent window
+is on screen, swap it to the next agent in DIRS (wrapping after the last)
+and select it: a live attached agent swaps buffer-only, a detached one is
+materialized by `cj/--ai-term-show-or-create'. When DIRS is empty, open
+the project picker rather than erroring, so the swap key doubles as a
+start-an-agent key. When the sole eligible agent is already focused, echo
+that there is nowhere else to go instead of swapping to itself."
+ (let* ((win (cj/--ai-term-displayed-agent-window))
(current-name (and win (buffer-name (window-buffer win))))
(current-dir (and current-name
(seq-find (lambda (d)
@@ -380,8 +480,8 @@ picker and C-; a k closes an agent."
(next-dir (cj/--ai-term-next-agent-dir current-dir dirs)))
(cond
((not next-dir)
- ;; No agents open: launch the first via the project picker instead of
- ;; erroring, so the swap key doubles as a "start an agent" key.
+ ;; No eligible agents: launch the first via the project picker instead
+ ;; of erroring, so the swap key doubles as a "start an agent" key.
(cj/ai-term-pick-project))
;; Sole agent, already focused: the rotation wraps back to the same
;; agent, so a swap would be a silent no-op. Say there's nowhere to
@@ -406,16 +506,49 @@ picker and C-; a k closes an agent."
(let ((w (get-buffer-window name)))
(when w (select-window w)))))))))
+(defun cj/ai-term-next ()
+ "Step to the next open AI-term agent -- attached or detached.
+
+The queue is every active agent ordered by buffer name -- a stable
+rotation, unaffected by which agent was most recently selected. Active
+means a live agent buffer (attached) OR a live tmux session with no Emacs
+buffer (detached); stepping onto a detached agent attaches it (recreates
+its terminal, which reattaches the session).
+
+Bound to M-S-SPC (and C-; a n). For a chord that stays among the agents
+already on screen, use `cj/ai-term-next-attached' (M-SPC). Unlike C-; a a
+\(toggle the most-recent agent on/off), this is the \"switch among existing
+agents\" surface; C-; a s opens the project picker and C-; a k closes an
+agent."
+ (interactive)
+ (cj/--ai-term-step-among (cj/--ai-term-active-agent-dirs)))
+
+(defun cj/ai-term-next-attached ()
+ "Step to the next ATTACHED AI-term agent -- live Emacs buffers only.
+
+Cycles only agents currently on screen (a live agent buffer), skipping
+detached tmux sessions. Use `cj/ai-term-next' (M-S-SPC) to include
+detached sessions and attach them. When no agent is attached, opens the
+project picker.
+
+Bound to M-SPC -- the fast \"swap to the next visible agent\" chord."
+ (interactive)
+ (cj/--ai-term-step-among (cj/--ai-term-attached-agent-dirs)))
+
;; ai-term lives under the C-; a prefix (vacated when gptel was archived).
-;; The frequent "swap to the next agent" also gets M-SPC for a fast chord.
+;; The frequent "swap to the next agent" gets M-SPC (attached only) for a fast
+;; chord, with M-S-SPC to include detached sessions.
(defvar-keymap cj/ai-term-keymap
:doc "Keymap for ai-term agent commands (C-; a)."
"a" #'cj/ai-term ;; toggle the most-recent agent on/off
"s" #'cj/ai-term-pick-project ;; select / launch via the project picker
- "n" #'cj/ai-term-next ;; swap to the next open agent
+ "n" #'cj/ai-term-next ;; swap to the next open agent (all)
"k" #'cj/ai-term-close) ;; kill the current agent
(cj/register-prefix-map "a" cj/ai-term-keymap "ai-term")
-(keymap-global-set "M-SPC" #'cj/ai-term-next)
+;; M-SPC cycles only attached agents (on-screen); M-S-SPC cycles all, attaching
+;; a detached tmux session when it lands on one.
+(keymap-global-set "M-SPC" #'cj/ai-term-next-attached)
+(keymap-global-set "M-S-SPC" #'cj/ai-term-next)
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
@@ -424,7 +557,8 @@ picker and C-; a k closes an agent."
"C-; a s" "select / launch"
"C-; a n" "next agent"
"C-; a k" "kill agent"
- "M-SPC" "ai-term: next agent"))
+ "M-SPC" "ai-term: next attached"
+ "M-S-SPC" "ai-term: next (all)"))
;; ------------------- Wrap-it-up teardown + shutdown -------------------------
;;
@@ -445,11 +579,16 @@ A defcustom so development and tests can stub it instead of powering off
(defun cj/ai-term-quit (&optional project)
"Tear down PROJECT's AI-term: kill its tmux session, buffer, and restore layout.
PROJECT is a project basename (as the rulesets Stop hook passes) or a directory;
-nil means the current project (`default-directory'). Kills the `aiv-<name>'
-tmux session (taking the agent process with it), then, when the agent buffer is
-live, swaps its window back to the working buffer and kills it. Idempotent and
-safe headless: a session or buffer already gone is a no-op, not an error."
- (let* ((key (or project default-directory))
+nil means the current project -- the current agent buffer's embedded basename
+when called from inside one (immune to the OSC 7 `default-directory' drift a
+cd in the agent shell causes), else `default-directory'. Kills the
+`aiv-<name>' tmux session (taking the agent process with it), then, when the
+agent buffer is live, swaps its window back to the working buffer and kills
+it. Idempotent and safe headless: a session or buffer already gone is a
+no-op, not an error."
+ (let* ((key (or project
+ (cj/--ai-term-buffer-basename (current-buffer))
+ default-directory))
(session (cj/--ai-term-tmux-session-name key))
(buffer (get-buffer (cj/--ai-term-buffer-name key))))
(cj/--ai-term-kill-tmux-session session)
diff --git a/modules/auth-config.el b/modules/auth-config.el
index c2df244b..c862e916 100644
--- a/modules/auth-config.el
+++ b/modules/auth-config.el
@@ -26,6 +26,7 @@
;; below. oauth2-auto is required at runtime inside the advised function; these
;; declarations satisfy the byte-compiler without forcing an eager load.
(declare-function oauth2-auto--compute-id "oauth2-auto")
+(declare-function plstore-open "plstore")
(declare-function plstore-get "plstore")
(declare-function plstore-close "plstore")
(defvar oauth2-auto--plstore-cache)
diff --git a/modules/auto-dim-config.el b/modules/auto-dim-config.el
index efae5341..980d301f 100644
--- a/modules/auto-dim-config.el
+++ b/modules/auto-dim-config.el
@@ -23,6 +23,14 @@
;; terminal background, so -- unlike the old ghostel/vterm engines, which baked
;; color per-terminal with no per-window hook -- they follow the per-window
;; dimmed background like any other buffer.
+;;
+;; One caveat, so nobody chases it through this alist: ANSI-coloured spans in a
+;; terminal keep their colour when dimmed. EAT attaches those as anonymous face
+;; plists carrying a literal foreground, e.g. (:foreground "#AFD7FF" :inherit
+;; (eat-term-font-0)), and `face-remap-add-relative' only reaches named faces.
+;; There is no face name to add below. Reaching them would need an overlay
+;; (whose face outranks a text property), not a remap. Background and uncoloured
+;; text still dim, which is close enough; this is deliberate, not an oversight.
;;; Code:
@@ -50,7 +58,11 @@ focus cue on a split-displayed dashboard, accepted as a fair trade."
;; Emacs loses focus -- on Hyprland focus moves to other apps constantly,
;; and the ai-term agents live in their own windows.
(auto-dim-other-buffers-dim-on-focus-out nil)
- (auto-dim-other-buffers-dim-on-switch-to-minibuffer t)
+ ;; Entering the minibuffer leaves dimming exactly as it was -- a dim window
+ ;; stays dim, a lit one stays lit. With this at t, the window being worked
+ ;; in went dark on every minibuffer prompt, since selecting the minibuffer
+ ;; deselects it and the dim follows selection.
+ (auto-dim-other-buffers-dim-on-switch-to-minibuffer nil)
:config
;; Remap these faces to auto-dim-other-buffers (pure-black background +
;; faded gray foreground, defined in the theme) in non-selected windows.
@@ -75,6 +87,76 @@ focus cue on a split-displayed dashboard, accepted as a fair trade."
(font-lock-builtin-face . (auto-dim-other-buffers . nil))
(font-lock-preprocessor-face . (auto-dim-other-buffers . nil))
(font-lock-warning-face . (auto-dim-other-buffers . nil))
+ ;; Faces that sit AHEAD of a mapped face in a face list and outrank it, so
+ ;; the text under them stayed lit until each was named here: a table header
+ ;; renders as (org-table-header org-table), a TODO line as
+ ;; (org-faces-todo org-level-3). tests/test-auto-dim-config.el walks a
+ ;; fontified org buffer and fails when a built-in org face is left out.
+ (org-table-header . (auto-dim-other-buffers . nil))
+ (org-formula . (auto-dim-other-buffers . nil))
+ (org-checkbox . (auto-dim-other-buffers . nil))
+ (org-checkbox-statistics-done . (auto-dim-other-buffers . nil))
+ (org-headline-done . (auto-dim-other-buffers . nil))
+ (org-drill-visible-cloze-face . (auto-dim-other-buffers . nil))
+ ;; org-indent inherits org-hide, so its foreground IS the background: that
+ ;; is what makes indent prefixes invisible. -hide face, never the flat dim.
+ (org-indent . (auto-dim-other-buffers-hide . nil))
+ ;; org-superstar draws heading stars and list bullets, and puts its own face
+ ;; ahead of the org face beneath, so a star renders as
+ ;; (org-superstar-header-bullet org-level-1) and outranks the dimmed level.
+ ;; Without these three, bullets are the last thing left lit in a dimmed window.
+ (org-superstar-header-bullet . (auto-dim-other-buffers . nil))
+ (org-superstar-item . (auto-dim-other-buffers . nil))
+ (org-superstar-first . (auto-dim-other-buffers . nil))
+ ;; org-superstar-leading takes the -hide face, not the flat dim: its
+ ;; foreground IS the background colour, which is what keeps hidden leading
+ ;; stars invisible. Flat-dimming it would reveal them. Same as org-hide.
+ (org-superstar-leading . (auto-dim-other-buffers-hide . nil))
+ ;; The built-in link faces, distinct from org-link below. They fontify
+ ;; links in help, info, and customize buffers. Both carry :underline t,
+ ;; which survives the relative remap, so a dimmed link still reads as one.
+ (link . (auto-dim-other-buffers . nil))
+ (link-visited . (auto-dim-other-buffers . nil))
+ ;; Org structure faces flat-dim like font-lock rather than getting
+ ;; -dim variants: the active theme gives org-level-1..8 one shared
+ ;; foreground and no height or weight, so there is no level-by-colour
+ ;; signal to preserve. The remap is relative, so org-link keeps its
+ ;; underline and the heading stars / org-indent keep conveying depth.
+ ;; That premise is theme-dependent -- a theme that colours heading
+ ;; levels distinctly would make the flat dim discard real signal, and
+ ;; those levels would then want -dim variants like the keywords below.
+ (org-level-1 . (auto-dim-other-buffers . nil))
+ (org-level-2 . (auto-dim-other-buffers . nil))
+ (org-level-3 . (auto-dim-other-buffers . nil))
+ (org-level-4 . (auto-dim-other-buffers . nil))
+ (org-level-5 . (auto-dim-other-buffers . nil))
+ (org-level-6 . (auto-dim-other-buffers . nil))
+ (org-level-7 . (auto-dim-other-buffers . nil))
+ (org-level-8 . (auto-dim-other-buffers . nil))
+ (org-link . (auto-dim-other-buffers . nil))
+ (org-tag . (auto-dim-other-buffers . nil))
+ ;; org-todo and org-priority are deliberately absent: they are keyword
+ ;; class, like the org-faces-* set below, and flat-dimming them would
+ ;; erase the status colour those -dim variants exist to preserve.
+ ;; Document header: #+TITLE:, #+AUTHOR:, #+ARCHIVE: and their values.
+ (org-document-title . (auto-dim-other-buffers . nil))
+ (org-document-info . (auto-dim-other-buffers . nil))
+ (org-document-info-keyword . (auto-dim-other-buffers . nil))
+ (org-meta-line . (auto-dim-other-buffers . nil))
+ ;; Inline markup and source blocks.
+ (org-code . (auto-dim-other-buffers . nil))
+ (org-verbatim . (auto-dim-other-buffers . nil))
+ (org-block-begin-line . (auto-dim-other-buffers . nil))
+ (org-block-end-line . (auto-dim-other-buffers . nil))
+ ;; Drawers, properties, and planning lines.
+ (org-drawer . (auto-dim-other-buffers . nil))
+ (org-special-keyword . (auto-dim-other-buffers . nil))
+ (org-property-value . (auto-dim-other-buffers . nil))
+ (org-date . (auto-dim-other-buffers . nil))
+ ;; Tables and the fold indicator.
+ (org-table . (auto-dim-other-buffers . nil))
+ (org-table-row . (auto-dim-other-buffers . nil))
+ (org-ellipsis . (auto-dim-other-buffers . nil))
;; Org TODO-keyword + priority faces dim to their own -dim variant
;; (a darker shade of the same colour) rather than the flat gray, so
;; a dimmed window's keywords stay recognizable. Faces are defined
diff --git a/modules/browser-config.el b/modules/browser-config.el
index 564e7a27..4571c1d9 100644
--- a/modules/browser-config.el
+++ b/modules/browser-config.el
@@ -143,7 +143,23 @@ Persists the choice for future sessions."
('save-failed (message "Failed to save browser choice"))
('invalid-plist (message "Invalid browser configuration"))))))))
-;; Initialize: Load saved choice or use first available browser
+(defun cj/--preferred-default-browser (browsers)
+ "Return the browser plist to adopt as the first-run default from BROWSERS.
+
+Prefers the first entry with a non-nil :executable -- a real external
+browser -- and falls back to the first entry overall when none is
+installed. Built-in browsers carry a nil :executable and so are always
+\"available\", which put EWW at the head of `cj/discover-browsers' on
+every machine. Taking the head therefore opened every link in the text
+browser on a fresh checkout even with Chrome installed, until the user
+happened to run `cj/choose-browser'. EWW stays reachable as the
+deliberate fallback when nothing external is on PATH.
+
+Returns nil for an empty BROWSERS list."
+ (or (seq-find (lambda (b) (plist-get b :executable)) browsers)
+ (car browsers)))
+
+;; Initialize: Load saved choice or use the preferred available browser
(defun cj/--do-initialize-browser ()
"Initialize browser configuration.
Returns: (cons \\='loaded browser-plist) if saved choice was loaded,
@@ -153,10 +169,10 @@ Returns: (cons \\='loaded browser-plist) if saved choice was loaded,
(let ((saved-choice (cj/load-browser-choice)))
(if saved-choice
(cons 'loaded saved-choice)
- ;; No saved choice - try to set first available browser
+ ;; No saved choice - adopt the preferred available browser
(let ((browsers (cj/discover-browsers)))
(if browsers
- (cons 'first-available (car browsers))
+ (cons 'first-available (cj/--preferred-default-browser browsers))
(cons 'no-browsers nil))))))
(defun cj/initialize-browser ()
diff --git a/modules/calendar-sync-ics.el b/modules/calendar-sync-ics.el
index 9cb57e96..7fecce10 100644
--- a/modules/calendar-sync-ics.el
+++ b/modules/calendar-sync-ics.el
@@ -188,6 +188,22 @@ Monday = 1, Sunday = 7."
(dow (nth 6 decoded))) ; 0 = Sunday, 1 = Monday, etc.
(if (= dow 0) 7 dow)))
+(defun calendar-sync--nth-weekday-of-month (year month weekday n)
+ "Return the day-of-month of the Nth WEEKDAY in YEAR/MONTH, or nil.
+WEEKDAY is 1-7 (Monday = 1), matching `calendar-sync--date-weekday'.
+Positive N counts from the start of the month (1 = first); negative N
+counts from the end (-1 = last). Returns nil when the month has no such
+occurrence (a 5th Friday most months), or when N is zero."
+ (when (and (integerp n) (not (zerop n)))
+ (let* ((first-dow (calendar-sync--date-weekday (list year month 1)))
+ (first-day (1+ (mod (- weekday first-dow) 7)))
+ (next-month (calendar-sync--add-months (list year month 1) 1))
+ (last-day (nth 2 (calendar-sync--add-days next-month -1)))
+ (total (1+ (/ (- last-day first-day) 7)))
+ (index (if (> n 0) n (+ total n 1))))
+ (when (and (>= index 1) (<= index total))
+ (+ first-day (* 7 (1- index)))))))
+
(defun calendar-sync--add-days (date days)
"Add DAYS to DATE (year month day).
Returns new (year month day).
@@ -218,6 +234,18 @@ Both dates should be lists like (year month day)."
(time-less-p (calendar-sync--date-to-time date1)
(calendar-sync--date-to-time date2)))
+(defun calendar-sync--date-on-or-before-p (date1 date2)
+ "Return t if DATE1 falls on or before DATE2.
+Both dates should be lists like (year month day); like
+`calendar-sync--date-to-time', only the first three elements are compared,
+so any hour/minute tail is ignored.
+
+This is the comparison RRULE UNTIL needs. RFC 5545 3.3.10 bounds a
+recurrence \"in an inclusive manner\": when UNTIL lines up with the
+recurrence, that date is the last instance. A strict
+`calendar-sync--before-date-p' drops it."
+ (not (calendar-sync--before-date-p date2 date1)))
+
;;; Datetime Parsing
(defun calendar-sync--parse-ics-datetime (value)
@@ -492,39 +520,90 @@ Returns nil if parsing fails."
(string-to-number (match-string 3 timestamp-str))))
(t nil)))
+(defun calendar-sync--format-stamp (date &optional time-str)
+ "Return one org timestamp for DATE, with TIME-STR appended when non-nil.
+DATE is a (year month day) list; TIME-STR is a preformatted leading-space
+string such as \" 14:00\" or \" 14:00-15:30\". Produces
+`<2025-11-16 Sun 14:00>' or `<2025-11-16 Sun>'. Both the compact same-day
+form and each half of a multi-day range are built from this."
+ (concat (format-time-string
+ "<%Y-%m-%d %a"
+ (encode-time 0 0 0 (nth 2 date) (nth 1 date) (nth 0 date)))
+ time-str
+ ">"))
+
+(defun calendar-sync--format-hhmm (hour minute)
+ "Return \" HH:MM\" for HOUR and MINUTE, or nil unless both are non-nil."
+ (when (and hour minute) (format " %02d:%02d" hour minute)))
+
(defun calendar-sync--format-timestamp (start end)
- "Format START and END timestamps as org timestamp.
+ "Format START and END timestamps as an org timestamp.
START and END are lists from `calendar-sync--parse-timestamp'.
-Returns string like '<2025-11-16 Sun 14:00-15:00>' or '<2025-11-16 Sun>'."
- (let* ((year (nth 0 start))
- (month (nth 1 start))
- (day (nth 2 start))
+
+Same-day events keep the compact form: `<2025-11-16 Sun 14:00-15:00>' when
+timed, `<2025-11-16 Sun>' when all-day. An event whose last day is later
+than its start renders as an org range, `<start>--<end>', so the agenda shows
+it on every day it covers rather than only the first.
+
+DTEND is the non-inclusive end of the event (RFC 5545 3.6.1). For an all-day
+event that makes DTEND the day AFTER the last day, so the last day is
+DTEND-1 and a one-day all-day event (DTEND = start+1) stays a single stamp.
+For a timed event DTEND is the end instant, so its date is already the last
+day. The decrement is therefore gated on both ends being date-only; a
+date-only start with a timed end is malformed, and treating it as all-day
+would push the last day BEFORE the start and emit a backwards range."
+ (let* ((start-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
(start-hour (nth 3 start))
(start-min (nth 4 start))
(end-hour (and end (nth 3 end)))
(end-min (and end (nth 4 end)))
- (date-str (format-time-string
- "<%Y-%m-%d %a"
- (encode-time 0 0 0 day month year)))
- (time-str (when (and start-hour end-hour)
- (format " %02d:%02d-%02d:%02d"
- start-hour start-min end-hour end-min))))
- (concat date-str time-str ">")))
+ (all-day-span (and end (null start-hour) (null end-hour)))
+ (last-date (when end
+ (let ((end-date (list (nth 0 end) (nth 1 end) (nth 2 end))))
+ (if all-day-span
+ (calendar-sync--add-days end-date -1)
+ end-date))))
+ (spans-days (and last-date
+ (calendar-sync--before-date-p start-date last-date))))
+ (if spans-days
+ (concat (calendar-sync--format-stamp
+ start-date (calendar-sync--format-hhmm start-hour start-min))
+ "--"
+ (calendar-sync--format-stamp
+ last-date (calendar-sync--format-hhmm end-hour end-min)))
+ ;; Same-day: the compact HH:MM-HH:MM range lives inside one stamp.
+ (calendar-sync--format-stamp
+ start-date
+ (when (and start-hour end-hour)
+ (format " %02d:%02d-%02d:%02d"
+ start-hour start-min end-hour end-min))))))
;;; Single Event Parsing
+(defun calendar-sync--event-cancelled-p (event-str)
+ "Return non-nil when EVENT-STR carries STATUS:CANCELLED.
+This is the VEVENT's own STATUS property (RFC 5545 3.8.1.11), not the
+user's attendee PARTSTAT. Matching is case-insensitive."
+ (let ((status (calendar-sync--get-property event-str "STATUS")))
+ (and status (string= (upcase status) "CANCELLED"))))
+
(defun calendar-sync--parse-event (event-str)
"Parse single VEVENT string EVENT-STR into plist.
Returns plist with :uid :summary :description :location :start :end
:attendees :organizer :url :status.
Returns nil if event lacks required fields (DTSTART, SUMMARY).
Skips events with RECURRENCE-ID (individual instances of recurring events
-are handled separately via exception collection).
+are handled separately via exception collection) and events whose own
+STATUS is CANCELLED -- a cancelled meeting must not render, and a
+cancelled series master kills its whole series because RRULE expansion
+builds its base event through this function.
Handles TZID-qualified timestamps by converting to local time.
Cleans text fields (description, location, summary) via
`calendar-sync--clean-text'."
- ;; Skip individual instances of recurring events (they're collected as exceptions)
- (unless (calendar-sync--get-property event-str "RECURRENCE-ID")
+ ;; Skip individual instances of recurring events (they're collected as
+ ;; exceptions) and cancelled events (they must not render).
+ (unless (or (calendar-sync--get-property event-str "RECURRENCE-ID")
+ (calendar-sync--event-cancelled-p event-str))
(let* ((uid (calendar-sync--get-property event-str "UID"))
(summary (calendar-sync--clean-text
(calendar-sync--get-property event-str "SUMMARY")))
diff --git a/modules/calendar-sync-recurrence.el b/modules/calendar-sync-recurrence.el
index 72576a6f..9ef12ce5 100644
--- a/modules/calendar-sync-recurrence.el
+++ b/modules/calendar-sync-recurrence.el
@@ -90,6 +90,9 @@ dropped by `calendar-sync--filter-declined'."
(list :recurrence-id (calendar-sync--localize-parsed-datetime
recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid)
:recurrence-id-raw recurrence-id
+ ;; A cancelled override removes its occurrence downstream
+ ;; rather than rescheduling it.
+ :cancelled (calendar-sync--event-cancelled-p event-str)
:start start-parsed
:end end-parsed
:summary summary
@@ -164,24 +167,30 @@ Compares year, month, day, hour, minute."
"Apply EXCEPTIONS to OCCURRENCES list.
OCCURRENCES is list of event plists from RRULE expansion.
EXCEPTIONS is hash table from `calendar-sync--collect-recurrence-exceptions'.
-Returns new list with matching occurrences replaced by exception times."
+Returns new list with matching occurrences replaced by exception times.
+A cancelled exception (STATUS:CANCELLED override) removes its occurrence
+from the list instead of overriding it."
(if (or (null occurrences) (null exceptions))
occurrences
- (mapcar
- (lambda (occurrence)
- (let* ((uid (plist-get occurrence :uid))
- (uid-exceptions (and uid (gethash uid exceptions))))
- (if (null uid-exceptions)
- occurrence
- ;; Check if any exception matches this occurrence
- (let ((matching-exception
- (cl-find-if (lambda (exc)
- (calendar-sync--occurrence-matches-exception-p occurrence exc))
- uid-exceptions)))
- (if matching-exception
- (calendar-sync--apply-single-exception occurrence matching-exception)
- occurrence)))))
- occurrences)))
+ (delq nil
+ (mapcar
+ (lambda (occurrence)
+ (let* ((uid (plist-get occurrence :uid))
+ (uid-exceptions (and uid (gethash uid exceptions))))
+ (if (null uid-exceptions)
+ occurrence
+ ;; Check if any exception matches this occurrence
+ (let ((matching-exception
+ (cl-find-if (lambda (exc)
+ (calendar-sync--occurrence-matches-exception-p occurrence exc))
+ uid-exceptions)))
+ (cond
+ ((null matching-exception) occurrence)
+ ;; Cancelled instance: drop it entirely.
+ ((plist-get matching-exception :cancelled) nil)
+ (t (calendar-sync--apply-single-exception
+ occurrence matching-exception)))))))
+ occurrences))))
;;; EXDATE (Excluded Date) Handling
@@ -193,21 +202,30 @@ Handles both simple values and values with parameters like TZID."
(when (and event-str (stringp event-str) (not (string-empty-p event-str)))
(let ((exdates '())
(pos 0))
- ;; Find all EXDATE lines
+ ;; Find all EXDATE lines. One line may carry several comma-separated
+ ;; datetimes (RFC 5545); split them so each is excluded individually.
+ ;; Capture the match end BEFORE split-string: its internal matching
+ ;; clobbers the match data, and reading (match-end 0) afterwards made
+ ;; pos jump backwards to a comma offset inside the value -- re-matching
+ ;; the same line forever and growing the list until the OOM killer
+ ;; intervened (took two agent sessions down on 2026-07-13).
(while (string-match "^EXDATE[^:\n]*:\\([^\n]+\\)" event-str pos)
- (push (match-string 1 event-str) exdates)
- (setq pos (match-end 0)))
+ (let ((line-end (match-end 0)))
+ (dolist (val (split-string (match-string 1 event-str) "," t))
+ (push val exdates))
+ (setq pos line-end)))
(nreverse exdates))))
(defun calendar-sync--get-exdate-line (event-str exdate-value)
"Find the full EXDATE line containing EXDATE-VALUE from EVENT-STR.
Returns the complete line like
-`EXDATE;TZID=America/New_York:20260210T130000'.
-Returns nil if not found."
+`EXDATE;TZID=America/New_York:20260210T130000'. Matches the value anywhere
+in the value list, so a comma-separated line's shared TZID reaches every
+value on it. Returns nil if not found."
(when (and event-str (stringp event-str) exdate-value)
- (let ((pattern (format "^\\(EXDATE[^:]*:%s\\)" (regexp-quote exdate-value))))
+ (let ((pattern (format "^EXDATE[^:\n]*:[^\n]*%s" (regexp-quote exdate-value))))
(when (string-match pattern event-str)
- (match-string 1 event-str)))))
+ (match-string 0 event-str)))))
(defalias 'calendar-sync--parse-exdate #'calendar-sync--parse-ics-datetime
"Parse EXDATE value. See `calendar-sync--parse-ics-datetime'.")
@@ -282,7 +300,9 @@ OCCURRENCE-DATE should be a list (year month day hour minute second)."
(defun calendar-sync--parse-rrule (rrule-str)
"Parse RRULE string into plist.
-Returns plist with :freq :interval :byday :until :count."
+Returns plist with :freq :interval :byday :bysetpos :bymonth :until :count.
+BYMONTH keeps only the first value of a comma-separated list -- feeds in
+practice emit a single month there."
(let ((parts (split-string rrule-str ";"))
(result '()))
(dolist (part parts)
@@ -293,6 +313,8 @@ Returns plist with :freq :interval :byday :until :count."
("FREQ" (setq result (plist-put result :freq (intern (downcase value)))))
("INTERVAL" (setq result (plist-put result :interval (string-to-number value))))
("BYDAY" (setq result (plist-put result :byday (split-string value ","))))
+ ("BYSETPOS" (setq result (plist-put result :bysetpos (string-to-number value))))
+ ("BYMONTH" (setq result (plist-put result :bymonth (string-to-number value))))
("UNTIL" (setq result (plist-put result :until (calendar-sync--parse-timestamp value))))
("COUNT" (setq result (plist-put result :count (string-to-number value))))))))
;; Set defaults
@@ -313,7 +335,8 @@ ADVANCE-FN takes (current-date interval) and returns the next date."
(num-generated 0)
(range-end-time (cadr range)))
(while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
- (or (not until) (calendar-sync--before-date-p current-date until))
+ ;; UNTIL is inclusive (RFC 5545 3.3.10) -- on-or-before, not before.
+ (or (not until) (calendar-sync--date-on-or-before-p current-date until))
(or (not count) (< num-generated count)))
(let ((occurrence-datetime (append current-date (nthcdr 3 start))))
(setq num-generated (1+ num-generated))
@@ -355,7 +378,8 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
(while (and (< iterations max-iterations)
(or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
(or (not count) (< num-generated count))
- (or (not until) (calendar-sync--before-date-p current-date until)))
+ ;; UNTIL is inclusive (RFC 5545 3.3.10) -- on-or-before, not before.
+ (or (not until) (calendar-sync--date-on-or-before-p current-date until)))
(setq iterations (1+ iterations))
;; Generate occurrences for each weekday in this week
(dolist (weekday weekdays)
@@ -363,8 +387,8 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
(days-ahead (mod (- weekday current-weekday) 7))
(occurrence-date (calendar-sync--add-days current-date days-ahead))
(occurrence-datetime (append occurrence-date (nthcdr 3 start))))
- ;; Check UNTIL date first
- (when (or (not until) (calendar-sync--before-date-p occurrence-date until))
+ ;; Check UNTIL date first -- inclusive per RFC 5545 3.3.10.
+ (when (or (not until) (calendar-sync--date-on-or-before-p occurrence-date until))
;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
(when (or (not count) (< num-generated count))
(setq num-generated (1+ num-generated))
@@ -378,18 +402,149 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
(calendar-sync--log-silently "calendar-sync: WARNING: Hit max iterations (%d) expanding weekly event" max-iterations))
(nreverse occurrences)))
+(defun calendar-sync--parse-byday-entry (entry)
+ "Parse a single RRULE BYDAY ENTRY into a cons (ORDINAL . WEEKDAY).
+ENTRY is a string like \"2WE\" (2nd Wednesday), \"-1TU\" (last Tuesday),
+or \"SU\" (bare weekday). ORDINAL is nil for a bare weekday. WEEKDAY is
+1-7 (Monday = 1). Returns nil for unparseable input."
+ (when (and (stringp entry)
+ (string-match "\\`\\(-?[0-9]+\\)?\\([A-Z][A-Z]\\)\\'" entry))
+ (let ((ordinal (match-string 1 entry))
+ (weekday (calendar-sync--weekday-to-number (match-string 2 entry))))
+ (when weekday
+ (cons (and ordinal (string-to-number ordinal)) weekday)))))
+
+(defun calendar-sync--byday-days-in-month (year month byday-entries bysetpos)
+ "Return the sorted day-of-month list BYDAY-ENTRIES select in YEAR/MONTH.
+An entry with an ordinal (\"2WE\") resolves directly via
+`calendar-sync--nth-weekday-of-month'. A bare entry (\"SU\") expands to
+every matching weekday in the month. When BYSETPOS is non-nil it then
+selects one day from the combined set (1-based; negative counts from the
+end), per RFC 5545 3.8.5.3. Months with no match return nil."
+ (let ((days '()))
+ (dolist (entry byday-entries)
+ (let ((parsed (calendar-sync--parse-byday-entry entry)))
+ (when parsed
+ (let ((ordinal (car parsed))
+ (weekday (cdr parsed)))
+ (if ordinal
+ (let ((day (calendar-sync--nth-weekday-of-month year month weekday ordinal)))
+ (when day (push day days)))
+ (let ((n 1) day)
+ (while (setq day (calendar-sync--nth-weekday-of-month year month weekday n))
+ (push day days)
+ (setq n (1+ n)))))))))
+ (setq days (sort (delete-dups days) #'<))
+ (if (and bysetpos days)
+ (let* ((total (length days))
+ (index (if (> bysetpos 0) bysetpos (+ total bysetpos 1))))
+ (if (and (>= index 1) (<= index total))
+ (list (nth (1- index) days))
+ '()))
+ days)))
+
+(defun calendar-sync--expand-monthly-byday (base-event rrule range)
+ "Expand a monthly nth-weekday (BYDAY) recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule (carrying :byday and
+optionally :bysetpos), RANGE is date range. Steps month by month from
+DTSTART's month, landing each occurrence on the day its BYDAY rule selects
+-- never on DTSTART's day-of-month."
+ (let* ((start (plist-get base-event :start))
+ (interval (plist-get rrule :interval))
+ (byday (plist-get rrule :byday))
+ (bysetpos (plist-get rrule :bysetpos))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (month-anchor (list (nth 0 start) (nth 1 start) 1))
+ (start-day (nth 2 start))
+ (first-month t)
+ (num-generated 0)
+ (range-end-time (cadr range))
+ (max-iterations 1000)
+ (iterations 0))
+ (when (<= interval 0)
+ (error "Invalid RRULE interval: %s (must be > 0)" interval))
+ (while (and (< iterations max-iterations)
+ (or count until
+ (time-less-p (calendar-sync--date-to-time month-anchor) range-end-time))
+ (or (not count) (< num-generated count))
+ ;; A month starting after UNTIL can't contain an occurrence
+ ;; on-or-before it (UNTIL is inclusive, RFC 5545 3.3.10).
+ (or (not until) (calendar-sync--date-on-or-before-p month-anchor until)))
+ (setq iterations (1+ iterations))
+ (dolist (day (calendar-sync--byday-days-in-month
+ (nth 0 month-anchor) (nth 1 month-anchor) byday bysetpos))
+ (let* ((occurrence-date (list (nth 0 month-anchor) (nth 1 month-anchor) day))
+ (occurrence-datetime (append occurrence-date (nthcdr 3 start))))
+ ;; The series starts at DTSTART: skip earlier days in the first month.
+ (unless (and first-month (< day start-day))
+ (when (or (not until) (calendar-sync--date-on-or-before-p occurrence-date until))
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences)))))))
+ (setq first-month nil)
+ (setq month-anchor (calendar-sync--add-months month-anchor interval)))
+ (when (>= iterations max-iterations)
+ (calendar-sync--log-silently
+ "calendar-sync: WARNING: Hit max iterations (%d) expanding monthly BYDAY event"
+ max-iterations))
+ (nreverse occurrences)))
+
(defun calendar-sync--expand-monthly (base-event rrule range)
"Expand monthly recurring event.
-BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
- (calendar-sync--expand-simple-recurrence
- base-event rrule range #'calendar-sync--add-months))
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range.
+A rule with BYDAY (nth weekday, e.g. 2WE, -1TU, or SU with BYSETPOS)
+expands via `calendar-sync--expand-monthly-byday'; a plain rule steps
+DTSTART's day-of-month, skipping months without that day."
+ (if (plist-get rrule :byday)
+ (calendar-sync--expand-monthly-byday base-event rrule range)
+ (calendar-sync--expand-simple-recurrence
+ base-event rrule range #'calendar-sync--next-monthly-date)))
+
+(defun calendar-sync--next-monthly-date (date interval)
+ "Step DATE forward INTERVAL months, skipping months without DATE's day.
+A plain FREQ=MONTHLY on the 31st must skip short months (RFC 5545):
+`calendar-sync--add-months' keeps day-of-month verbatim, so Jan 31 would
+step to Feb 31, which encode-time normalizes into a phantom Mar 3
+occurrence. Bounded so a pathological input can't loop forever."
+ (require 'time-date)
+ (let ((next (calendar-sync--add-months date interval))
+ (day (nth 2 date))
+ (guard 0))
+ (while (and (< guard 100)
+ (> day (date-days-in-month (nth 0 next) (nth 1 next))))
+ (setq guard (1+ guard))
+ (setq next (calendar-sync--add-months next interval)))
+ next))
+
+(defun calendar-sync--expand-yearly-byday (base-event rrule range)
+ "Expand a yearly nth-weekday event (e.g. BYMONTH=3;BYDAY=2SU).
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range.
+Reuses the monthly BYDAY expander with a 12-month step, anchored on
+:bymonth (falling back to DTSTART's month)."
+ (let* ((start (plist-get base-event :start))
+ (month (or (plist-get rrule :bymonth) (nth 1 start)))
+ (sched-event (plist-put (copy-sequence base-event) :start
+ (append (list (nth 0 start) month (nth 2 start))
+ (nthcdr 3 start))))
+ (sched-rrule (plist-put (copy-sequence rrule) :interval
+ (* 12 (or (plist-get rrule :interval) 1)))))
+ (calendar-sync--expand-monthly-byday sched-event sched-rrule range)))
(defun calendar-sync--expand-yearly (base-event rrule range)
"Expand yearly recurring event.
-BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
- (calendar-sync--expand-simple-recurrence
- base-event rrule range
- (lambda (date interval) (calendar-sync--add-months date (* 12 interval)))))
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range.
+A rule with BYDAY (the DST clock-change shape, BYMONTH=n;BYDAY=nWD)
+expands via `calendar-sync--expand-yearly-byday'; a plain rule repeats
+DTSTART's calendar date."
+ (if (plist-get rrule :byday)
+ (calendar-sync--expand-yearly-byday base-event rrule range)
+ (calendar-sync--expand-simple-recurrence
+ base-event rrule range
+ (lambda (date interval) (calendar-sync--add-months date (* 12 interval))))))
(defun calendar-sync--expand-recurring-event (event-str range)
"Expand recurring event EVENT-STR into individual occurrences within RANGE.
diff --git a/modules/calendar-sync-source.el b/modules/calendar-sync-source.el
index 15c91c59..10dea66e 100644
--- a/modules/calendar-sync-source.el
+++ b/modules/calendar-sync-source.el
@@ -138,39 +138,25 @@ Checks `cj/debug-modules' for symbol `calendar-sync' or t (all)."
;;; .ics Fetch
-(defun calendar-sync--fetch-ics (url callback)
- "Fetch .ics file from URL asynchronously using curl.
-Calls CALLBACK with the .ics content as string (normalized to Unix line endings)
-or nil on error. CALLBACK signature: (lambda (content) ...).
-
-The fetch happens asynchronously and doesn't block Emacs. The callback is
-invoked when the fetch completes, either successfully or with an error."
- (condition-case err
- (let ((buffer (generate-new-buffer " *calendar-sync-curl*")))
- (make-process
- :name "calendar-sync-curl"
- :buffer buffer
- :command (list "curl" "-s" "-L" "--fail"
- "--connect-timeout" "10"
- "--max-time" (number-to-string calendar-sync-fetch-timeout)
- url)
- :sentinel
- (lambda (process event)
- (when (memq (process-status process) '(exit signal))
- (let ((buf (process-buffer process)))
- (when (buffer-live-p buf)
- (let ((content
- (with-current-buffer buf
- (if (and (eq (process-status process) 'exit)
- (= (process-exit-status process) 0))
- (calendar-sync--normalize-line-endings (buffer-string))
- (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s" (string-trim event))
- nil))))
- (kill-buffer buf)
- (funcall callback content))))))))
- (error
- (calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err))
- (funcall callback nil))))
+(defun calendar-sync--fetch-sentinel-finish (success event temp-file buffer callback)
+ "Finish an async .ics fetch.
+SUCCESS is non-nil when curl exited cleanly, EVENT the process event
+string, TEMP-FILE the curl output path, BUFFER the process buffer, and
+CALLBACK the continuation. On success CALLBACK receives TEMP-FILE (the
+caller owns deleting it); on failure the error is logged, TEMP-FILE is
+removed, and CALLBACK receives nil. Extracted from the sentinel so the
+success, failure, and cleanup branches are unit-testable without a live
+curl process."
+ (when (buffer-live-p buffer)
+ (unless success
+ (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s"
+ (string-trim event)))
+ (kill-buffer buffer))
+ (if success
+ (funcall callback temp-file)
+ (when (file-exists-p temp-file)
+ (delete-file temp-file))
+ (funcall callback nil)))
(defun calendar-sync--fetch-ics-file (url callback)
"Fetch .ics from URL to a temp file asynchronously.
@@ -190,19 +176,10 @@ owns deleting the temp file after a successful callback."
:sentinel
(lambda (process event)
(when (memq (process-status process) '(exit signal))
- (let ((buf (process-buffer process))
- (success (and (eq (process-status process) 'exit)
- (= (process-exit-status process) 0))))
- (when (buffer-live-p buf)
- (unless success
- (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s"
- (string-trim event)))
- (kill-buffer buf))
- (if success
- (funcall callback temp-file)
- (when (file-exists-p temp-file)
- (delete-file temp-file))
- (funcall callback nil)))))))
+ (calendar-sync--fetch-sentinel-finish
+ (and (eq (process-status process) 'exit)
+ (= (process-exit-status process) 0))
+ event temp-file (process-buffer process) callback)))))
(error
(calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err))
(funcall callback nil))))
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 804d71fa..d504f246 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -300,16 +300,28 @@ When called non-interactively with nil, syncs all calendars."
;;; Timer management
(defun calendar-sync--sync-timer-function ()
- "Function called by sync timer.
-Checks for timezone changes and triggers re-sync if detected."
- (when (calendar-sync--timezone-changed-p)
- (let ((old-tz (calendar-sync--format-timezone-offset
- calendar-sync--last-timezone-offset))
- (new-tz (calendar-sync--format-timezone-offset
- (calendar-sync--current-timezone-offset))))
- (message "calendar-sync: Timezone change detected (%s → %s), re-syncing..."
- old-tz new-tz)))
- (calendar-sync--sync-all-calendars))
+ "Function called by the hourly sync timer.
+Checks for timezone changes and triggers re-sync if detected.
+
+The body is wrapped so a signal — from the timezone check or the sync
+fan-out — is caught and logged rather than propagated: this runs from a
+`run-at-time' timer, and an unguarded error would repeat on every tick,
+once an hour, indefinitely. The timezone-change notice goes to the silent
+log, not the echo area, since an hourly timer must not spam `message'."
+ (condition-case err
+ (progn
+ (when (calendar-sync--timezone-changed-p)
+ (let ((old-tz (calendar-sync--format-timezone-offset
+ calendar-sync--last-timezone-offset))
+ (new-tz (calendar-sync--format-timezone-offset
+ (calendar-sync--current-timezone-offset))))
+ (calendar-sync--log-silently
+ "calendar-sync: Timezone change detected (%s → %s), re-syncing..."
+ old-tz new-tz)))
+ (calendar-sync--sync-all-calendars))
+ (error
+ (calendar-sync--log-silently
+ "calendar-sync: sync timer error: %s" (error-message-string err)))))
;;;###autoload
(defun calendar-sync-start ()
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index b03d83ed..27fa6369 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -205,22 +205,14 @@ Adjust it live with `cj/nov-widen-text' and `cj/nov-narrow-text'.")
(defvar cj/nov-margin-step 2
"Percentage points each `cj/nov-widen-text'/`cj/nov-narrow-text' press changes.")
-;; Prevent magic-fallback-mode-alist from opening epub as archive-mode
-;; Advise set-auto-mode to force nov-mode for .epub files before magic-fallback runs
-(defun cj/force-nov-mode-for-epub (orig-fun &rest args)
- "Force nov-mode for .epub files, bypassing archive-mode detection."
- (if (and buffer-file-name
- (string-match-p "\\.epub\\'" buffer-file-name))
- (progn
- (unless (featurep 'nov)
- (require 'nov nil t))
- ;; Call nov-mode if available, otherwise fallback to default behavior
- (if (fboundp 'nov-mode)
- (nov-mode)
- (apply orig-fun args)))
- (apply orig-fun args)))
-
-(advice-add 'set-auto-mode :around #'cj/force-nov-mode-for-epub)
+;; .epub reaches nov-mode through auto-mode-alist -- nov's use-package :mode
+;; below registers "\\.epub\\'" there, and `set-auto-mode' consults
+;; auto-mode-alist before magic-fallback-mode-alist, so the zip container never
+;; reaches the archive-mode fallback. An :around advice on `set-auto-mode' used
+;; to force this and was pure overhead: set-auto-mode runs on every file visit,
+;; so it added a frame and a failure surface to every file of every type.
+;; Verified live before removal -- a real zip-format .epub opened in nov-mode
+;; both with the advice and without it.
;; Define helper functions before use-package so they're available for hooks
(defun cj/forward-paragraph-and-center ()
@@ -519,8 +511,7 @@ computed column based on the window text area width."
(goto-char (point-min))
;; Work in the selected window showing this buffer (if any).
(when-let* ((win (get-buffer-window (current-buffer) t))
- (col-width (window-body-width win)) ;; columns
- (col-px (* col-width (window-font-width win))))
+ (col-width (window-body-width win))) ;; columns
(while (let ((m (text-property-search-forward
'display nil
(lambda (_ p) (and (consp p) (eq (car-safe p) 'image))))))
diff --git a/modules/config-utilities.el b/modules/config-utilities.el
index 72427ef9..4332f407 100644
--- a/modules/config-utilities.el
+++ b/modules/config-utilities.el
@@ -131,12 +131,18 @@ Signals `user-error' if METHOD-SYMBOL is nil or not fboundp."
;;; ----------------------------- Config Compilation ----------------------------
+(defun cj/--native-comp-p ()
+ "Return non-nil when native compilation is available on this build.
+Detected with `native-comp-available-p', not `boundp' of the async function:
+`native-compile-async' is a function, so `boundp' is always nil."
+ (and (fboundp 'native-comp-available-p) (native-comp-available-p)))
+
(defun cj/--recompile-emacs-home (dir &optional native-p)
"Delete all .elc/.eln files under DIR, then recompile.
NATIVE-P chooses native compilation when non-nil, byte otherwise.
-Also removes the eln (native) or elc (byte) cache directory.
+Also removes the eln-cache (native) or elc (byte) cache directory.
Returns the compilation method used: \\='native or \\='byte."
- (let ((elt-dir (expand-file-name (if native-p "eln" "elc") dir)))
+ (let ((elt-dir (expand-file-name (if native-p "eln-cache" "elc") dir)))
(message "Deleting all compiled files in %s" dir)
(dolist (file (directory-files-recursively dir "\\(\\.elc\\|\\.eln\\)$"))
(delete-file file))
@@ -157,7 +163,7 @@ Returns the compilation method used: \\='native or \\='byte."
"Delete all compiled files in the Emacs home before recompiling.
Recompile natively when supported, otherwise fall back to byte compilation."
(interactive)
- (let* ((native (boundp 'native-compile-async))
+ (let* ((native (cj/--native-comp-p))
(mode-word (if native "native" "byte")))
(if (yes-or-no-p
(format "Please confirm recursive %s recompilation of %s: "
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index e8f7a474..c320651d 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -220,7 +220,7 @@ empty hash table. Malformed hunk headers are skipped silently."
"Return the merge-base between HEAD and BASE."
(let ((merge-base (string-trim
(cj/git-output-or-error "merge-base" "HEAD" base))))
- (unless (not (string-empty-p merge-base))
+ (when (string-empty-p merge-base)
(user-error "git merge-base HEAD %s returned no commit" base))
merge-base))
diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el
index 25555b53..0ca06cf9 100644
--- a/modules/custom-buffer-file.el
+++ b/modules/custom-buffer-file.el
@@ -51,8 +51,13 @@
(declare-function ps-print-buffer-with-faces "ps-print")
(declare-function ps-print-region-with-faces "ps-print")
-;; mm-decode for email viewing (mm-handle-type is a macro, needs early require)
-(require 'mm-decode)
+;; mm-handle-type is a macro used in `cj/--email-handle-is-type-p', so mm-decode
+;; is only needed at compile time here; `cj/view-email-in-buffer' requires it at
+;; runtime before any mm-* call, so the eager startup require is unnecessary.
+(eval-when-compile (require 'mm-decode))
+(declare-function mm-dissect-buffer "mm-decode")
+(declare-function mm-insert-part "mm-decode")
+(declare-function mm-destroy-parts "mm-decode")
(require 'external-open) ;; for cj/xdg-open, cj/open-this-file-with
(require 'system-lib) ;; cj/confirm-strong (overwrite confirms), used below
@@ -236,13 +241,16 @@ blast-radius operation on this map. The VC path is not double-prompted:
(cj/--delete-buffer-and-file))))))
(defun cj/copy-link-to-buffer-file ()
- "Copy the full file:// path of the current buffer's source file to the kill ring."
+ "Copy the full file:// path of the current buffer's source file to the kill ring.
+Signal a `user-error' when the buffer is not visiting a file, matching the
+other copy commands in this module."
(interactive)
(let ((file-path (buffer-file-name)))
- (when file-path
- (setq file-path (concat "file://" file-path))
- (kill-new file-path)
- (message "Copied file link to kill ring: %s" file-path))))
+ (unless file-path
+ (user-error "Buffer is not visiting a file"))
+ (setq file-path (concat "file://" file-path))
+ (kill-new file-path)
+ (message "Copied file link to kill ring: %s" file-path)))
(defvar cj/buffer-source-functions
'((eww-mode . (lambda () (eww-current-url)))
@@ -941,19 +949,24 @@ Signals an error if:
(let* ((handle (mm-dissect-buffer t))
(displayable-part (cj/--email-find-displayable-part handle))
(buffer-name (format "*Email: %s*" (file-name-nondirectory buffer-file-name))))
- (unless displayable-part
- (user-error "No displayable content found in email"))
- (with-current-buffer (get-buffer-create buffer-name)
- (let ((inhibit-read-only t))
- (erase-buffer)
- (mm-insert-part displayable-part)
- (goto-char (point-min))
- (when (cj/--email-handle-is-type-p displayable-part "text/html")
- (shr-render-region (point-min) (point-max)))
- (goto-char (point-min))
- (special-mode)))
- (mm-destroy-parts handle)
- (switch-to-buffer buffer-name)))
+ ;; `mm-dissect-buffer' allocates handles that must be freed even when we
+ ;; bail out early (no displayable part), so destroy them from the cleanup
+ ;; form rather than after the body.
+ (unwind-protect
+ (progn
+ (unless displayable-part
+ (user-error "No displayable content found in email"))
+ (with-current-buffer (get-buffer-create buffer-name)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (mm-insert-part displayable-part)
+ (goto-char (point-min))
+ (when (cj/--email-handle-is-type-p displayable-part "text/html")
+ (shr-render-region (point-min) (point-max)))
+ (goto-char (point-min))
+ (special-mode)))
+ (switch-to-buffer buffer-name))
+ (mm-destroy-parts handle))))
;; --------------------------- Buffer And File Keymap --------------------------
diff --git a/modules/custom-case.el b/modules/custom-case.el
index 87622695..c203cd9e 100644
--- a/modules/custom-case.el
+++ b/modules/custom-case.el
@@ -49,49 +49,92 @@
(downcase-region (car bounds) (cdr bounds))
(user-error "No symbol at point")))))
-(defun cj/--title-case-capitalize-word-p (word is-first prev-word-end word-skip chars-skip-reset)
+(defun cj/--title-case-capitalize-word-p (word is-first is-last prev-word-end word-skip chars-skip-reset)
"Return non-nil when WORD at point should be capitalized in title case.
Point is at WORD's first character. WORD is capitalized when it is the first
-word (IS-FIRST), is not a minor skip word (in WORD-SKIP), or immediately follows
-a skip-reset character (one of CHARS-SKIP-RESET: : ! ?), reached by skipping
-blanks back to PREV-WORD-END."
+word (IS-FIRST) or the last word (IS-LAST), is not a minor skip word (in
+WORD-SKIP), or immediately follows a skip-reset character (one of
+CHARS-SKIP-RESET: : ! ? .), reached by skipping blanks back to PREV-WORD-END."
(or is-first
+ is-last
(not (member word word-skip))
(save-excursion
(and (not (zerop (skip-chars-backward "[:blank:]" prev-word-end)))
(memq (char-before (point)) chars-skip-reset)))))
+(defconst cj/--title-case-reset-chars '(?: ?! ?? ?.)
+ "Characters that restart capitalization for the following word.
+So \"Warning: An Example\" capitalizes the \"An\" and a sentence-ending
+period capitalizes the next word (\"End. The Next\").")
+
+(defconst cj/--title-case-separator-chars '(?\\ ?- ?' ?.)
+ "Characters whose following character is never capitalized.
+Covers \"Foo-bar\", \"Foo\\bar\", and \"Foo's\". The period keeps
+\"3.14\" and \"foo.bar\" untouched; a period followed by a blank still
+restarts capitalization via `cj/--title-case-reset-chars'.")
+
+(defconst cj/--title-case-minor-words
+ '("a" "an" "and" "as" "at" "but" "by"
+ "for" "if" "in" "nor" "of"
+ "on" "or" "so" "the" "to" "yet")
+ "Minor words kept lowercase mid-title.
+\"is\" and other linking verbs are major words, so they are not here.")
+
+(defconst cj/--title-case-word-chars "[:alnum:]"
+ "skip-chars set that constitutes a word for title-casing.")
+
+(defun cj/--title-case-region-bounds ()
+ "Return (BEG . END) for the active region, else the current line."
+ (if (region-active-p)
+ (cons (region-beginning) (region-end))
+ (cons (line-beginning-position) (line-end-position))))
+
+(defun cj/--title-case-last-word-start (beg end)
+ "Return the start position of the last word in BEG..END.
+The last word is always capitalized in title case, so it is located once:
+from END, skip back over trailing non-word characters, then the word."
+ (save-excursion
+ (goto-char end)
+ (skip-chars-backward (concat "^" cj/--title-case-word-chars) beg)
+ (skip-chars-backward cj/--title-case-word-chars beg)
+ (point)))
+
+(defun cj/--title-case-maybe-capitalize (word-end end is-first last-word-start prev-word-end)
+ "Capitalize the character at point when title-case rules call for it.
+Point sits on a word's first character, WORD-END past its last. END bounds
+the operation; IS-FIRST, LAST-WORD-START, and PREV-WORD-END feed
+`cj/--title-case-capitalize-word-p'. Modifies the buffer in place."
+ (unless (or (>= (point) end)
+ (memq (char-before (point)) cj/--title-case-separator-chars))
+ (let* ((c-orig (char-to-string (char-after (point))))
+ (c-up (capitalize c-orig)))
+ (unless (string-equal c-orig c-up)
+ (let ((word (buffer-substring-no-properties (point) word-end)))
+ (when (cj/--title-case-capitalize-word-p
+ word is-first (= (point) last-word-start)
+ prev-word-end cj/--title-case-minor-words
+ cj/--title-case-reset-chars)
+ (delete-region (point) (1+ (point)))
+ (insert c-up)))))))
+
(defun cj/title-case-region ()
"Capitalize the region in title case format.
Title case is a capitalization convention where major words are capitalized,
and most minor words are lowercase. Nouns, verbs (including linking verbs),
adjectives, adverbs,pronouns, and all words of four letters or more are
considered major words. Short (i.e., three letters or fewer) conjunctions,
-short prepositions, and all articles are considered minor words."
+short prepositions, and all articles are considered minor words. The first
+and last words are always capitalized, and a word following a sentence-ending
+period (or a colon, exclamation mark, or question mark) restarts
+capitalization even when it is a minor word."
(interactive)
- (let ((beg nil)
- (end nil)
- (prev-word-end nil)
- ;; Allow capitals for skip characters after this, so:
- ;; Warning: An Example
- ;; Capitalizes the `An'.
- (chars-skip-reset '(?: ?! ??))
- ;; Don't capitalize characters directly after these. e.g.
- ;; "Foo-bar" or "Foo\bar" or "Foo's".
- (chars-separator '(?\\ ?- ?' ?.))
- (word-chars "[:alnum:]")
- (word-skip
- (list "a" "an" "and" "as" "at" "but" "by"
- "for" "if" "in" "is" "nor" "of"
- "on" "or" "so" "the" "to" "yet"))
- (is-first t))
- (cond
- ((region-active-p)
- (setq beg (region-beginning))
- (setq end (region-end)))
- (t
- (setq beg (line-beginning-position))
- (setq end (line-end-position))))
+ (let* ((bounds (cj/--title-case-region-bounds))
+ (beg (car bounds))
+ (end (cdr bounds))
+ (last-word-start (cj/--title-case-last-word-start beg end))
+ (word-chars cj/--title-case-word-chars)
+ (prev-word-end nil)
+ (is-first t))
(save-excursion
;; work on uppercased text (e.g., headlines) by downcasing first
(downcase-region beg end)
@@ -105,16 +148,8 @@ short prepositions, and all articles are considered minor words."
(save-excursion
(skip-chars-forward word-chars end)
(point))))
- (unless (or (>= (point) end)
- (memq (char-before (point)) chars-separator))
- (let* ((c-orig (char-to-string (char-after (point))))
- (c-up (capitalize c-orig)))
- (unless (string-equal c-orig c-up)
- (let ((word (buffer-substring-no-properties (point) word-end)))
- (when (cj/--title-case-capitalize-word-p
- word is-first prev-word-end word-skip chars-skip-reset)
- (delete-region (point) (1+ (point)))
- (insert c-up))))))
+ (cj/--title-case-maybe-capitalize
+ word-end end is-first last-word-start prev-word-end)
(goto-char word-end)
(setq is-first nil))))))
diff --git a/modules/custom-comments.el b/modules/custom-comments.el
index a2604a55..2e77af5a 100644
--- a/modules/custom-comments.el
+++ b/modules/custom-comments.el
@@ -35,19 +35,19 @@
;; ------------------------------ Comment Reformat -----------------------------
(defun cj/comment-reformat ()
- "Reformat commented text into a single paragraph."
+ "Reformat the commented text in the active region into a single paragraph.
+Signal a `user-error' when no region is active."
(interactive)
- (if mark-active
- (let ((beg (region-beginning))
- (end (copy-marker (region-end)))
- (orig-fill-column fill-column))
- (uncomment-region beg end)
- (setq fill-column (- fill-column 3))
- (cj/join-line-or-region)
- (comment-region beg end)
- (setq fill-column orig-fill-column )))
- ;; if no region
- (message "No region was selected. Select the comment lines to reformat."))
+ (unless (use-region-p)
+ (user-error "No region selected: select the comment lines to reformat"))
+ (let ((beg (region-beginning))
+ (end (copy-marker (region-end)))
+ ;; Dynamically narrow the fill target for the join, then let it
+ ;; restore itself -- an error mid-join no longer strands fill-column.
+ (fill-column (- fill-column 3)))
+ (uncomment-region beg end)
+ (cj/join-line-or-region)
+ (comment-region beg end)))
;; ======================== Comment Generation Functions =======================
@@ -72,6 +72,18 @@ is the line-opening prologue shared by the divider and inline-border emitters."
(when (equal cmt-start ";") (insert cmt-start))
(insert " "))
+(defun cj/--comment-read-syntax ()
+ "Return the buffer's comment syntax as a cons (COMMENT-START . COMMENT-END).
+Falls back to prompting for the start when the buffer has none, and to an
+empty end string. The single source of the resolution that was previously
+copied into each command wrapper."
+ (cons (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): "))
+ (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ "")))
+
;; ----------------------------- Inline Border ---------------------------------
(defun cj/--comment-inline-border (cmt-start cmt-end decoration-char text length)
@@ -95,6 +107,14 @@ LENGTH is the total width of the line."
text-length
(if (> text-length 0) 2 0)) ; spaces around text
2))
+ ;; The right side fills the exact remaining width so the line always
+ ;; reaches LENGTH. Keying this off text-length parity (as before) left
+ ;; even-length and empty text two columns short, misaligning stacked
+ ;; dividers of differing text lengths.
+ (right-space (- available-width
+ text-length
+ (if (> text-length 0) 2 0)
+ space-on-each-side))
(min-space 2))
;; Validate we have enough space
(when (< space-on-each-side min-space)
@@ -108,10 +128,8 @@ LENGTH is the total width of the line."
;; Text with spaces
(when (> text-length 0)
(insert " " text " "))
- ;; Right decoration (handle odd-length text)
- (dotimes (_ (if (= (% text-length 2) 0)
- (- space-on-each-side 1)
- space-on-each-side))
+ ;; Right decoration -- fills the exact remaining width so the line reaches LENGTH.
+ (dotimes (_ right-space)
(insert decoration-char))
;; Comment end
(when (not (string-empty-p cmt-end))
@@ -123,12 +141,9 @@ LENGTH is the total width of the line."
DECORATION-CHAR defaults to \"#\" if not provided.
Uses the lesser of `fill-column\\=' or 80 for line length."
(interactive)
- (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
- comment-start
- (read-string "Comment start character(s): ")))
- (comment-end (if (and (boundp 'comment-end) comment-end)
- comment-end
- ""))
+ (let* ((comment-syntax (cj/--comment-read-syntax))
+ (comment-start (car comment-syntax))
+ (comment-end (cdr comment-syntax))
(decoration-char (or decoration-char "#"))
(text (capitalize (string-trim (read-from-minibuffer "Comment: "))))
(length (min fill-column 80)))
@@ -151,12 +166,9 @@ delegates to `cj/--comment-padded-divider' with PADDING 0."
"Insert a simple divider comment banner.
Prompts for decoration character, text, and length option."
(interactive)
- (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
- comment-start
- (read-string "Comment start character(s): ")))
- (comment-end (if (and (boundp 'comment-end) comment-end)
- comment-end
- ""))
+ (let* ((comment-syntax (cj/--comment-read-syntax))
+ (comment-start (car comment-syntax))
+ (comment-end (cdr comment-syntax))
(decoration-char (read-string "Decoration character (default =): " nil nil "="))
(text (read-string "Comment text: "))
(length-option (completing-read "Comment length: "
@@ -194,8 +206,13 @@ PADDING is the number of spaces before the text."
(if (string-empty-p cmt-end) 0 (1+ (length cmt-end))))))
(when (< length min-length)
(error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ ;; Mirror every term the emit path adds: the prologue also inserts a
+ ;; doubled semicolon (elisp) and a trailing space that this budget used
+ ;; to omit, rendering dividers LENGTH+2 (elisp) or LENGTH+1 wide.
(let* ((available-width (- length current-column-pos
(length cmt-start)
+ (if (equal cmt-start ";") 1 0) ; doubled semicolon
+ 1 ; space after comment-start
(if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
(line (make-string available-width (string-to-char decoration-char))))
;; Top line
@@ -226,12 +243,9 @@ PADDING is the number of spaces before the text."
"Insert a padded divider comment banner.
Prompts for decoration character, text, padding, and length option."
(interactive)
- (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
- comment-start
- (read-string "Comment start character(s): ")))
- (comment-end (if (and (boundp 'comment-end) comment-end)
- comment-end
- ""))
+ (let* ((comment-syntax (cj/--comment-read-syntax))
+ (comment-start (car comment-syntax))
+ (comment-end (cdr comment-syntax))
(decoration-char (read-string "Decoration character (default =): " nil nil "="))
(text (read-string "Comment text: "))
(padding (string-to-number (read-string "Padding spaces (default 2): " nil nil "2")))
@@ -327,12 +341,9 @@ LENGTH is the total width of each line."
"Insert a 3-line comment box with centered text.
Prompts for decoration character, text, and uses `fill-column' for length."
(interactive)
- (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
- comment-start
- (read-string "Comment start character(s): ")))
- (comment-end (if (and (boundp 'comment-end) comment-end)
- comment-end
- ""))
+ (let* ((comment-syntax (cj/--comment-read-syntax))
+ (comment-start (car comment-syntax))
+ (comment-end (cdr comment-syntax))
(decoration-char (read-string "Decoration character (default -): " nil nil "-"))
(text (capitalize (string-trim (read-from-minibuffer "Comment: "))))
(length (min fill-column 80)))
@@ -355,12 +366,9 @@ text, so it delegates to `cj/--comment-box-emit' with HEAVY non-nil."
"Insert a heavy box comment with blank lines around centered text.
Prompts for decoration character, text, and length option."
(interactive)
- (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
- comment-start
- (read-string "Comment start character(s): ")))
- (comment-end (if (and (boundp 'comment-end) comment-end)
- comment-end
- ""))
+ (let* ((comment-syntax (cj/--comment-read-syntax))
+ (comment-start (car comment-syntax))
+ (comment-end (cdr comment-syntax))
(decoration-char (read-string "Decoration character (default *): " nil nil "*"))
(text (read-string "Comment text: "))
(length-option (completing-read "Comment length: "
@@ -438,12 +446,9 @@ BOX-STYLE is either \\='single or \\='double for line style."
"Insert a unicode box comment.
Prompts for text, box style, and length option."
(interactive)
- (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
- comment-start
- (read-string "Comment start character(s): ")))
- (comment-end (if (and (boundp 'comment-end) comment-end)
- comment-end
- ""))
+ (let* ((comment-syntax (cj/--comment-read-syntax))
+ (comment-start (car comment-syntax))
+ (comment-end (cdr comment-syntax))
(text (read-string "Comment text: "))
(box-style (intern (completing-read "Comment box style: "
'("single" "double")
diff --git a/modules/custom-datetime.el b/modules/custom-datetime.el
index 0528688c..52e8c2b3 100644
--- a/modules/custom-datetime.el
+++ b/modules/custom-datetime.el
@@ -51,7 +51,7 @@ See `format-time-string' for possible replacements.")
;; ------------------------------- Sortable Time -------------------------------
-(defvar sortable-time-format "%I:%M:%S %p %Z "
+(defvar sortable-time-format "%H:%M:%S %Z "
"Format string used by `cj/insert-sortable-time'.
See `format-time-string' for possible replacements.")
diff --git a/modules/custom-line-paragraph.el b/modules/custom-line-paragraph.el
index d29d4125..5d96fe41 100644
--- a/modules/custom-line-paragraph.el
+++ b/modules/custom-line-paragraph.el
@@ -46,7 +46,10 @@
(when (> (line-number-at-pos) 1)
(join-line))
(end-of-line)
- (newline)))
+ ;; Only add a newline at end of buffer. Doing it unconditionally left a
+ ;; stray blank line when joining a line in the middle of the buffer.
+ (when (eobp)
+ (newline))))
(defun cj/join-paragraph ()
"Join all lines in the current paragraph using `cj/join-line-or-region'."
@@ -67,18 +70,27 @@ produce malformed output silently."
(> (length comment-start) 0))))
(user-error
"Cannot comment in %s: no comment syntax defined" major-mode))
- (let* ((b (if (region-active-p) (region-beginning) (line-beginning-position)))
- (e (if (region-active-p) (region-end) (line-end-position)))
- (lines (split-string (buffer-substring-no-properties b e) "\n")))
+ ;; Normalize the bounds to whole lines: extend to the start of the first
+ ;; line and the end of the last line the region touches. The old open-line
+ ;; loop mishandled a region ending mid-line or at beginning-of-line, either
+ ;; splitting a line or duplicating a stray empty line.
+ (let* ((rb (if (region-active-p) (region-beginning) (point)))
+ (re (if (region-active-p) (region-end) (point)))
+ (beg (save-excursion (goto-char rb) (line-beginning-position)))
+ (end (save-excursion
+ (goto-char re)
+ ;; A region ending exactly at beginning-of-line does not
+ ;; include that line, so step back to the previous line's end.
+ (when (and (> re rb) (bolp))
+ (backward-char))
+ (line-end-position)))
+ (text (buffer-substring-no-properties beg end)))
(save-excursion
- (goto-char e)
- (dolist (line lines)
- (open-line 1)
- (forward-line 1)
- (insert line)
- ;; If the COMMENT prefix argument is non-nil, comment the inserted text
- (when comment
- (comment-region (line-beginning-position) (line-end-position)))))))
+ (goto-char end)
+ (insert "\n" text)
+ ;; Comment the freshly-inserted copy when the COMMENT prefix arg is set.
+ (when comment
+ (comment-region (1+ end) (point))))))
(defun cj/remove-duplicate-lines-region-or-buffer ()
"Remove duplicate lines in the region or buffer, keeping the first occurrence.
@@ -175,9 +187,9 @@ If not on a delimiter, show a message. Respects the current syntax table."
(cb (char-before))
;; Check if on opening paren
(open-p (and ca (eq (char-syntax ca) ?\()))
- ;; Check if on or just after closing paren
- (close-p (or (and ca (eq (char-syntax ca) ?\)))
- (and cb (eq (char-syntax cb) ?\))))))
+ ;; On a closing paren (point sits on it) vs just after one.
+ (on-close-p (and ca (eq (char-syntax ca) ?\))))
+ (after-close-p (and cb (eq (char-syntax cb) ?\)))))
(cond
;; Jump forward from opening
(open-p
@@ -185,12 +197,19 @@ If not on a delimiter, show a message. Respects the current syntax table."
(forward-sexp)
(scan-error
(message "No matching delimiter: %s" (error-message-string err)))))
- ;; Jump backward from closing
- (close-p
- (condition-case err
- (backward-sexp)
- (scan-error
- (message "No matching delimiter: %s" (error-message-string err)))))
+ ;; Jump backward from closing to its matching opener. When point is ON
+ ;; the closer, step past it first so `backward-sexp' spans the whole
+ ;; expression to the opener rather than the last inner sexp. Restore
+ ;; point if the delimiter is unmatched.
+ ((or on-close-p after-close-p)
+ (let ((start (point)))
+ (condition-case err
+ (progn
+ (when on-close-p (forward-char))
+ (backward-sexp))
+ (scan-error
+ (goto-char start)
+ (message "No matching delimiter: %s" (error-message-string err))))))
;; Not on delimiter
(t
(message "Point is not on a delimiter.")))))
diff --git a/modules/custom-ordering.el b/modules/custom-ordering.el
index 4dc5bff8..71477948 100644
--- a/modules/custom-ordering.el
+++ b/modules/custom-ordering.el
@@ -145,8 +145,15 @@ START and END identify the active region."
START and END define the region to operate on.
Returns the transformed string without modifying the buffer."
(cj/--ordering-validate-region start end)
- (let ((lines (split-string (buffer-substring start end) "\n")))
- (mapconcat #'identity (nreverse lines) "\n")))
+ ;; Strip a trailing newline before splitting so it doesn't become a spurious
+ ;; empty line (which reversing would float to the top); reattach it after.
+ ;; Internal blank lines are preserved.
+ (let* ((raw (buffer-substring start end))
+ (trailing-newline (string-suffix-p "\n" raw))
+ (body (if trailing-newline (substring raw 0 -1) raw))
+ (lines (split-string body "\n")))
+ (concat (mapconcat #'identity (nreverse lines) "\n")
+ (if trailing-newline "\n" ""))))
(defun cj/reverse-lines (start end)
"Reverse the order of lines in region between START and END.
@@ -164,20 +171,28 @@ ZERO-PAD when non-nil pads numbers with zeros for alignment.
Example with 100 lines: \"001\", \"002\", ..., \"100\".
Returns the transformed string without modifying the buffer."
(cj/--ordering-validate-region start end)
- (let* ((lines (split-string (buffer-substring start end) "\n"))
+ ;; Strip a trailing newline before splitting so it doesn't become a spurious
+ ;; extra numbered empty line; reattach it after. Internal blank lines are
+ ;; preserved and numbered.
+ (let* ((raw (buffer-substring start end))
+ (trailing-newline (string-suffix-p "\n" raw))
+ (body (if trailing-newline (substring raw 0 -1) raw))
+ (lines (split-string body "\n"))
(line-count (length lines))
(width (if zero-pad (length (number-to-string line-count)) 1))
(format-spec (if zero-pad (format "%%0%dd" width) "%d")))
- (mapconcat
- (lambda (pair)
- (let* ((num (car pair))
- (line (cdr pair))
- (num-str (format format-spec num)))
- (concat (replace-regexp-in-string "N" num-str format-string) line)))
- (cl-loop for line in lines
- for i from 1
- collect (cons i line))
- "\n")))
+ (concat
+ (mapconcat
+ (lambda (pair)
+ (let* ((num (car pair))
+ (line (cdr pair))
+ (num-str (format format-spec num)))
+ (concat (replace-regexp-in-string "N" num-str format-string) line)))
+ (cl-loop for line in lines
+ for i from 1
+ collect (cons i line))
+ "\n")
+ (if trailing-newline "\n" ""))))
(defun cj/number-lines (start end format-string zero-pad)
"Number lines in region between START and END with custom format.
diff --git a/modules/custom-text-enclose.el b/modules/custom-text-enclose.el
index 4d72347d..3c33dcad 100644
--- a/modules/custom-text-enclose.el
+++ b/modules/custom-text-enclose.el
@@ -126,18 +126,27 @@ active, otherwise the entire buffer."
(cons (region-beginning) (region-end))
(cons (point-min) (point-max))))
-(defun cj/append-to-lines-in-region-or-buffer (str)
- "Append STR to the end of each line in the region or entire buffer."
- (interactive "sEnter string to append: ")
+(defun cj/--replace-region-or-buffer (transform)
+ "Replace the region (or whole buffer) with TRANSFORM applied to its text.
+TRANSFORM takes the current text and returns the replacement. The
+replacement is computed before anything is deleted, so a TRANSFORM error
+leaves the buffer untouched. Point lands at the start of the replaced
+span. The shared delete/goto/insert tail of the line-transform commands."
(let* ((bounds (cj/--region-or-buffer-bounds))
(start-pos (car bounds))
(end-pos (cdr bounds))
(text (buffer-substring start-pos end-pos))
- (insertion (cj/--append-to-lines text str)))
+ (insertion (funcall transform text)))
(delete-region start-pos end-pos)
(goto-char start-pos)
(insert insertion)))
+(defun cj/append-to-lines-in-region-or-buffer (str)
+ "Append STR to the end of each line in the region or entire buffer."
+ (interactive "sEnter string to append: ")
+ (cj/--replace-region-or-buffer
+ (lambda (text) (cj/--append-to-lines text str))))
+
(defun cj/--prepend-to-lines (text prefix)
"Internal implementation: Prepend PREFIX to each line in TEXT.
TEXT is the string containing one or more lines.
@@ -158,14 +167,8 @@ Returns the transformed string without modifying the buffer."
(defun cj/prepend-to-lines-in-region-or-buffer (str)
"Prepend STR to the beginning of each line in the region or entire buffer."
(interactive "sEnter string to prepend: ")
- (let* ((bounds (cj/--region-or-buffer-bounds))
- (start-pos (car bounds))
- (end-pos (cdr bounds))
- (text (buffer-substring start-pos end-pos))
- (insertion (cj/--prepend-to-lines text str)))
- (delete-region start-pos end-pos)
- (goto-char start-pos)
- (insert insertion)))
+ (cj/--replace-region-or-buffer
+ (lambda (text) (cj/--prepend-to-lines text str))))
(defun cj/--indent-lines (text count use-tabs)
"Internal implementation: Indent each line in TEXT by COUNT characters.
@@ -180,17 +183,16 @@ Returns the indented text without modifying the buffer."
(defun cj/indent-lines-in-region-or-buffer (count use-tabs)
"Indent each line in region or buffer by COUNT characters.
-COUNT is the number of characters to indent (default 4).
-USE-TABS when non-nil (prefix argument) uses tabs instead of spaces."
- (interactive "p\nP")
- (let* ((bounds (cj/--region-or-buffer-bounds))
- (start-pos (car bounds))
- (end-pos (cdr bounds))
- (text (buffer-substring start-pos end-pos))
- (insertion (cj/--indent-lines text count use-tabs)))
- (delete-region start-pos end-pos)
- (goto-char start-pos)
- (insert insertion)))
+COUNT is the numeric prefix argument, defaulting to 4 with no prefix.
+USE-TABS non-nil indents with tabs instead of spaces; interactively it
+follows the buffer's `indent-tabs-mode', so the prefix argument is free to
+mean the count. Call it from Lisp with an explicit USE-TABS to override."
+ (interactive (list (if current-prefix-arg
+ (prefix-numeric-value current-prefix-arg)
+ 4)
+ indent-tabs-mode))
+ (cj/--replace-region-or-buffer
+ (lambda (text) (cj/--indent-lines text count use-tabs))))
(defun cj/--dedent-lines (text count)
"Internal implementation: Remove up to COUNT leading characters from each line.
@@ -224,17 +226,13 @@ Returns the dedented text without modifying the buffer."
(defun cj/dedent-lines-in-region-or-buffer (count)
"Remove up to COUNT leading whitespace characters from each line.
-COUNT is the number of characters to remove (default 4).
+COUNT is the numeric prefix argument, defaulting to 4 with no prefix.
Works on region if active, otherwise entire buffer."
- (interactive "p")
- (let* ((bounds (cj/--region-or-buffer-bounds))
- (start-pos (car bounds))
- (end-pos (cdr bounds))
- (text (buffer-substring start-pos end-pos))
- (insertion (cj/--dedent-lines text count)))
- (delete-region start-pos end-pos)
- (goto-char start-pos)
- (insert insertion)))
+ (interactive (list (if current-prefix-arg
+ (prefix-numeric-value current-prefix-arg)
+ 4)))
+ (cj/--replace-region-or-buffer
+ (lambda (text) (cj/--dedent-lines text count))))
;; Text enclosure keymap
(defvar-keymap cj/enclose-map
diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el
index 53f19b72..c7ff39dc 100644
--- a/modules/dashboard-config.el
+++ b/modules/dashboard-config.el
@@ -68,12 +68,12 @@
(declare-function cj/erc-switch-to-buffer-with-completion "erc-config")
(declare-function cj/telega "telega-config")
(declare-function cj/slack-start "slack-config")
-(declare-function cj/signel-message "signal-config")
(declare-function cj/kill-all-other-buffers-and-windows "undead-buffers")
;; External package commands invoked by launchers.
(declare-function mu4e "mu4e")
(declare-function pearl-list-issues "pearl")
+(declare-function wttrin "wttrin")
;; ------------------------ Dashboard Bookmarks Override -----------------------
;; overrides the bookmark insertion from the dashboard package to provide an
@@ -85,38 +85,47 @@
(defvar dashboard-bookmarks-item-format "%s"
"Format to use when showing the base of the file name.")
-;; `el' is bound dynamically by dashboard's section-insertion machinery, which the
-;; override below plugs into. Declare it so the byte-compiler reads the
-;; references as that special variable rather than a free variable. The name is
-;; dashboard's, not ours, so the missing-prefix lint is suppressed rather than
-;; renamed (renaming would break the dynamic binding dashboard supplies).
-(with-suppressed-warnings ((lexical el))
- (defvar el))
-
-(defun dashboard-insert-bookmarks (list-size)
- "Add the list of LIST-SIZE items of bookmarks."
- (require 'bookmark)
- (dashboard-insert-section
- "Bookmarks:"
- (dashboard-subseq (bookmark-all-names) list-size)
- list-size
- 'bookmarks
- (dashboard-get-shortcut 'bookmarks)
- `(lambda (&rest _) (bookmark-jump ,el))
- (if-let* ((filename el)
- (path (bookmark-get-filename el))
- (path-shorten (dashboard-shorten-path path 'bookmarks)))
- (cl-case dashboard-bookmarks-show-path
- (`align
- (unless dashboard--bookmarks-cache-item-format
- (let* ((len-align (dashboard--align-length-by-type 'bookmarks))
- (new-fmt (dashboard--generate-align-format
- dashboard-bookmarks-item-format len-align)))
- (setq dashboard--bookmarks-cache-item-format new-fmt)))
- (format dashboard--bookmarks-cache-item-format filename path-shorten))
- (`nil filename)
- (t (format dashboard-bookmarks-item-format filename path-shorten)))
- el)))
+;; No `(defvar el)' here on purpose. `el' is the per-item variable that
+;; dashboard's `dashboard-insert-section' macro binds inside its own expansion;
+;; the override's forms below reference it within that binding. Declaring `el'
+;; special (as an earlier attempt did) is what CREATED a byte-compile warning --
+;; it turned the macro's ordinary lexical binding into one that "shadows the
+;; dynamic variable el". Left lexical, the references resolve inside the
+;; expansion and the compile is clean.
+
+;; The override body uses the `dashboard-insert-section' MACRO, so it must be
+;; known when this module byte-compiles or the call compiles as a plain
+;; function call that evaluates `el' eagerly -- void-variable at render time.
+(eval-when-compile (require 'dashboard-widgets nil t))
+
+;; Registered after dashboard-widgets, not as a bare top-level defun: the
+;; use-package below reloads dashboard-widgets, which would clobber an eager
+;; override. Same shape as the banner-title override further down.
+(with-eval-after-load 'dashboard-widgets
+ (defun dashboard-insert-bookmarks (list-size)
+ "Add the list of LIST-SIZE items of bookmarks."
+ (require 'bookmark)
+ (dashboard-insert-section
+ "Bookmarks:"
+ (dashboard-subseq (bookmark-all-names) list-size)
+ list-size
+ 'bookmarks
+ (dashboard-get-shortcut 'bookmarks)
+ `(lambda (&rest _) (bookmark-jump ,el))
+ (if-let* ((filename el)
+ (path (bookmark-get-filename el))
+ (path-shorten (dashboard-shorten-path path 'bookmarks)))
+ (cl-case dashboard-bookmarks-show-path
+ (`align
+ (unless dashboard--bookmarks-cache-item-format
+ (let* ((len-align (dashboard--align-length-by-type 'bookmarks))
+ (new-fmt (dashboard--generate-align-format
+ dashboard-bookmarks-item-format len-align)))
+ (setq dashboard--bookmarks-cache-item-format new-fmt)))
+ (format dashboard--bookmarks-cache-item-format filename path-shorten))
+ (`nil filename)
+ (t (format dashboard-bookmarks-item-format filename path-shorten)))
+ el))))
;; ------------------------- Banner Title Centering Fix ------------------------
;; The default centering can be off due to font width calculations.
@@ -149,15 +158,15 @@ Adjust this if the title doesn't appear centered under the banner image.")
(list "i" #'nerd-icons-faicon "nf-fa-comments" "IRC" "Emacs Relay Chat" (lambda () (cj/erc-switch-to-buffer-with-completion)))
(list "G" #'nerd-icons-faicon "nf-fa-telegram" "Telegram" "Telega Telegram Client" (lambda () (cj/telega)))
(list "s" #'nerd-icons-faicon "nf-fa-slack" "Slack" "Slack Client" (lambda () (cj/slack-start)))
- (list "l" #'nerd-icons-octicon "nf-oct-issue_tracks" "Linear" "Linear Issue Tracker" (lambda () (pearl-list-issues)))
- (list "S" #'nerd-icons-mdicon "nf-md-message" "Signal" "Signal Messenger" (lambda () (cj/signel-message))))
+ (list "l" #'nerd-icons-octicon "nf-oct-issue_tracks" "Linear" "Linear Issue Tracker" (lambda () (pearl-list-issues))))
"Dashboard launcher table: (KEY ICON-FN ICON-NAME LABEL TOOLTIP ACTION).
Drives both `dashboard-navigator-buttons' and the dashboard-mode-map keys.")
-(defconst cj/dashboard--row-sizes '(5 4 3 3)
+(defconst cj/dashboard--row-sizes '(5 4 3 2)
"Navigator row lengths. Must sum to the number of `cj/dashboard--launchers'.
-The top row carries Weather alongside the core tools; the last row groups
-Slack, Linear, and Signal together.")
+The top row carries Weather alongside the core tools; the last row pairs
+Slack and Linear. (Signal left the table when the signel client was
+retired to archive/ -- agents drive Signal via signal-cli.)")
(defun cj/dashboard--navigator-button (l)
"Build a `dashboard-navigator-buttons' entry from launcher L."
diff --git a/modules/dev-fkeys.el b/modules/dev-fkeys.el
index 80b43600..c760e392 100644
--- a/modules/dev-fkeys.el
+++ b/modules/dev-fkeys.el
@@ -82,13 +82,25 @@ recognized markers both return nil."
;; ---------- Action handlers ----------
+(defun cj/--f4-install-once-hook (buffer then-fn)
+ "Install a one-shot buffer-local compilation finish hook in BUFFER.
+Installing in the compilation buffer itself (rather than globally)
+means a quit before the compile starts, or an unrelated concurrent
+compile, can never fire the chained THEN-FN. No-op when BUFFER is not
+a live buffer."
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (add-hook 'compilation-finish-functions
+ (cj/--f4-make-once-hook then-fn) nil t))))
+
(defun cj/--f4-compile-and-run-impl ()
"Run `projectile-compile-project', then `projectile-run-project' on success.
-Installs a one-shot `compilation-finish-functions' hook to chain the run."
- (add-hook 'compilation-finish-functions
- (cj/--f4-make-once-hook
- (lambda () (projectile-run-project nil))))
- (projectile-compile-project nil))
+Chains the run via a one-shot finish hook installed buffer-locally in
+the compilation buffer projectile returns."
+ (let ((result (projectile-compile-project nil)))
+ (cj/--f4-install-once-hook
+ (cj/--projectile-compilation-buffer result)
+ (lambda () (projectile-run-project nil)))))
(defun cj/--f4-dispatch (action)
"Route ACTION (a symbol from `cj/--f4-candidates') to its handler.
@@ -109,24 +121,26 @@ command (prompted-and-cached by projectile) drives the build."
(let ((clean-cmd (cj/--f4-derive-clean-cmd root)))
(unless clean-cmd
(user-error "Clean + Rebuild: no clean command for this project type"))
- (add-hook 'compilation-finish-functions
- (cj/--f4-make-once-hook
- (lambda () (projectile-compile-project nil))))
- (let ((default-directory root))
- (compile clean-cmd))))
+ (let* ((default-directory root)
+ (buffer (compile clean-cmd)))
+ (cj/--f4-install-once-hook
+ buffer (lambda () (projectile-compile-project nil))))))
;; ---------- One-shot compilation-finish hook ----------
(defun cj/--f4-make-once-hook (then-fn)
"Build a one-shot `compilation-finish-functions' hook that chains THEN-FN.
The returned lambda removes itself from `compilation-finish-functions' on
-first invocation regardless of status, then calls THEN-FN only if the
-status string starts with \"finished\" (the convention used by compile.el
-for a successful compile)."
+first invocation regardless of status — from both the global value and
+the running buffer's local value, so it is one-shot wherever it was
+installed — then calls THEN-FN only if the status string starts with
+\"finished\" (the convention used by compile.el for a successful
+compile)."
(let (hook)
(setq hook
(lambda (_buf status)
(remove-hook 'compilation-finish-functions hook)
+ (remove-hook 'compilation-finish-functions hook t)
(when (and (stringp status)
(string-prefix-p "finished" status))
(funcall then-fn))))
@@ -350,9 +364,10 @@ TypeScript / JavaScript and unknown languages return nil."
(if (string-empty-p rel-dir)
"./"
(format "./%s" rel-dir)))))
- ('typescript
+ ((or 'typescript 'javascript)
;; Prefer vitest when present on PATH, fall back to jest otherwise.
- ;; Both runners take a path argument and accept relative paths.
+ ;; Both runners take a path argument and accept relative paths, and
+ ;; both run JS test files the same way they run TS ones.
(let ((runner (or (and (executable-find "vitest") "vitest")
(and (executable-find "jest") "jest")
"jest"))) ; reasonable default for stack traces
diff --git a/modules/diff-config.el b/modules/diff-config.el
index 0c09b951..75911587 100644
--- a/modules/diff-config.el
+++ b/modules/diff-config.el
@@ -40,7 +40,6 @@
:custom
(ediff-window-setup-function 'ediff-setup-windows-plain)
(ediff-split-window-function 'split-window-horizontally)
- (ediff-diff-options "-w")
(ediff-highlight-all-diffs nil)
:bind-keymap ("C-c D" . cj/ediff-map)
:init
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index 713a5e69..edbb0b35 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -8,8 +8,8 @@
;; Load shape: eager.
;; Eager reason: none; file manager, a command/hook-loaded deferral candidate.
;; Top-level side effects: three add-hook, package configuration via use-package.
-;; Runtime requires: user-constants, system-utils, host-environment, system-lib,
-;; external-open-lib.
+;; Runtime requires: user-constants, system-utils, external-open,
+;; host-environment, system-lib, external-open-lib.
;; Direct test load: yes.
;;
;; Enhanced file management via Dirvish (modern dired replacement) with icons,
@@ -34,7 +34,8 @@
;;; Code:
(require 'user-constants) ;; code-dir, music-dir, pix-dir et al. used at load time
-(require 'system-utils) ;; cj/xdg-open, cj/open-file-with-command bound to keys
+(require 'system-utils) ;; cj/open-file-with-command bound to keys
+(require 'external-open) ;; cj/xdg-open bound to keys ("o" and OS-handler fallback)
(require 'host-environment)
(require 'system-lib)
(require 'external-open-lib)
@@ -589,6 +590,7 @@ no popup frame is live."
("ps" ,(concat pix-dir "/screenshots/") "pictures screenshots")
("px" ,pix-dir "pictures directory")
("wp" ,(concat pix-dir "/wallpaper/") "pictures wallpaper")
+ ("fp" "/ftp:android@192.168.86.13#2221:/" "phone ftp (android)")
("rcj" "/sshx:cjennings@cjennings.net:~" "remote c@cjennings.net")
("rtl" "/sshx:cjennings@truenas.local:~" "remote cjennings@truenas.local")
("rtt" "/sshx:cjennings@truenas:~" "remote cjennings@truenas (tailscale)")
@@ -685,6 +687,51 @@ no popup frame is live."
("v" . dirvish-vc-menu)
("y" . dirvish-yank-menu)))
+;;; --------------------- Dirvish Thumbnail Cache Width Bucket ------------------
+;; dirvish keys its media thumbnail cache path on the exact preview-window pixel
+;; width (thumbnails/<width>/<md5>.jpg). Fast image thumbnails finish before the
+;; window settles, so their width is stable across a landing. Slow video (webm
+;; VP9 decode + seek) loses that race: the jpg is written at the generation-time
+;; width, then `dirvish-media--cache-sentinel' recomputes the width for redisplay,
+;; and if the preview window jittered even one pixel across a `floor' boundary the
+;; lookup hits a different bucket, misses, regenerates, and flashes blank.
+;; Quantizing the computed size to a coarse bucket maps that jitter to one stable
+;; key. A local workaround for the upstream cache design.
+
+(defgroup cj/dirvish nil
+ "Local dirvish tweaks on top of the upstream package."
+ :group 'dirvish)
+
+(defcustom cj/dirvish-thumb-width-bucket 100
+ "Pixel bucket dirvish's thumbnail cache width is quantized to.
+Rounding `dirvish-media--img-size' to this bucket keeps small preview-window
+jitter on one cache key, so a slow video thumbnail isn't looked up under a
+different width than it was written at. A larger bucket tolerates more jitter at
+a slight thumbnail-resolution cost. Set to 0 or nil to disable quantization."
+ :type '(choice (const :tag "Off" nil) integer)
+ :group 'cj/dirvish)
+
+(defun cj/--dirvish-quantize-thumb-size (size bucket)
+ "Round SIZE to the nearest multiple of BUCKET, clamped to at least BUCKET.
+BUCKET nil or non-positive returns SIZE unchanged (quantization off). Pure
+helper behind the `dirvish-media--img-size' advice."
+ (if (and (integerp bucket) (> bucket 0))
+ (max bucket (* bucket (round size bucket)))
+ size))
+
+(defun cj/--dirvish-thumb-size-advice (size)
+ "Quantize dirvish's computed thumbnail SIZE to a stable cache bucket.
+`:filter-return' advice on `dirvish-media--img-size'; the bucket is
+`cj/dirvish-thumb-width-bucket'."
+ (cj/--dirvish-quantize-thumb-size size cj/dirvish-thumb-width-bucket))
+
+;; dirvish-media--img-size lives in dirvish-widgets.el (loaded lazily on the
+;; first media preview), so advise it only once that file is present. advice-add
+;; with a named function is idempotent, so a module reload never stacks copies.
+(with-eval-after-load 'dirvish-widgets
+ (advice-add 'dirvish-media--img-size :filter-return
+ #'cj/--dirvish-thumb-size-advice))
+
;;; ----------------------------- Dired Text Greying ----------------------------
;; `default' is remapped buffer-locally to `shadow' inside dired/dirvish (see
diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el
index e8790a48..54272fd5 100644
--- a/modules/dwim-shell-config.el
+++ b/modules/dwim-shell-config.el
@@ -7,7 +7,7 @@
;; Load shape: eager.
;; Eager reason: none; Dired/Dirvish shell commands can load by command.
;; Top-level side effects: package configuration via use-package.
-;; Runtime requires: cl-lib, system-lib.
+;; Runtime requires: cl-lib, system-lib, external-open.
;; Direct test load: yes.
;;
;; Configures dwim-shell-command actions for marked Dired/Dirvish files:
@@ -23,6 +23,7 @@
(require 'cl-lib)
(require 'system-lib) ;; cj/confirm-strong (permanent file destruction confirm)
+(require 'external-open) ;; cj/xdg-open, called to open conversion output files
;; Function declarations (lazily-loaded packages and sibling modules).
(declare-function dwim-shell-command-on-marked-files "dwim-shell-command")
@@ -222,7 +223,7 @@ not apply to). Signals a `user-error' when a used second count is negative."
:utils "convert"))
(defun cj/dwim-shell-commands-flip-image-vertically ()
- "Horizontally flip image(s)."
+ "Vertically flip image(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Image vertical flip"
diff --git a/modules/eat-config.el b/modules/eat-config.el
index e059fe36..01d0fbe6 100644
--- a/modules/eat-config.el
+++ b/modules/eat-config.el
@@ -110,6 +110,102 @@ not recognize (which would later trip (cl-assert charset) on write)."
(with-eval-after-load 'eat
(advice-add 'eat--t-set-charset :filter-args #'cj/--eat-charset-never-nil))
+;; EAT 0.9.4 XTWINOPS gap. tmux 3.7b has native Sixel but refuses to emit it
+;; until it learns the client's cell pixel size, which it asks for with the
+;; XTWINOPS window-size requests CSI 14 t (text area in pixels), CSI 16 t (cell
+;; size in pixels) and CSI 18 t (text area in characters). EAT's parser
+;; (eat--t-handle-output) has no `t' case, so it silently drops these -- tmux
+;; never learns the geometry and images never render. We can't add the parser
+;; clause without forking the vendored 541-line pcase (see the charset note
+;; above), so answer the query from a `:before' advice instead: eat--t-handle-output
+;; is called inside (eat--t-with-env terminal ...), which dynamically binds
+;; eat--t-term, so the advice can read the live display + cell dimensions and
+;; write the report back through the terminal's own input function. The reply
+;; format matches EAT's existing XTSMGRAPHICS reply (same char-width/height and
+;; display fields). Verified live on ratio 2026-07-13: images render and
+;; persist across window switches, scrolling, and resizing. An upstream-shaped
+;; patch (a real parser clause) is kept locally for a PR to akib/emacs-eat.
+;; The advice does NOT become a no-op when upstream
+;; ships that clause: it runs :before the parser and scans raw output, so
+;; queries always survive to it, and two answerers means tmux gets a double
+;; reply -- it treats the second as unrequested input and forwards the raw
+;; escape bytes into the pane as keystrokes (this killed an agent session on
+;; 2026-07-13 when a second patched parser was live in the daemon). The
+;; advice-add is therefore guarded: it installs only while EAT itself cannot
+;; answer, and stays out the day the upstream clause (which defines
+;; eat--t-send-window-size-report) lands.
+
+(declare-function eat--t-handle-output "eat")
+(declare-function eat--t-term-display "eat")
+(declare-function eat--t-term-input-fn "eat")
+(declare-function eat--t-term-char-width "eat")
+(declare-function eat--t-term-char-height "eat")
+(declare-function eat--t-disp-width "eat")
+(declare-function eat--t-disp-height "eat")
+(defvar eat--t-term)
+
+(defun cj/--eat-xtwinops-report (n cols rows char-width char-height)
+ "Return the XTWINOPS reply string for window-size request N, or nil.
+COLS and ROWS are the display size in characters; CHAR-WIDTH and
+CHAR-HEIGHT are the pixel size of one cell. N is 14 (text area in
+pixels), 16 (cell size in pixels), or 18 (text area in characters); any
+other N returns nil so the request goes unanswered."
+ (pcase n
+ (14 (format "\e[4;%d;%dt" (* rows char-height) (* cols char-width)))
+ (16 (format "\e[6;%d;%dt" char-height char-width))
+ (18 (format "\e[8;%d;%dt" rows cols))))
+
+(defun cj/--eat-xtwinops-queries (output)
+ "Return the XTWINOPS request numbers found in terminal OUTPUT, in order.
+Matches only the bare CSI 14/16/18 t window-size requests EAT drops -- a
+parametrized form (e.g. CSI 3 ; 14 t) or a different CSI t op (e.g. the
+CSI 24 t resize) is not one we answer and is left alone. Returns nil
+when OUTPUT carries no such request. OUTPUT is one pty chunk, so a
+query split across two reads would be missed; in practice tmux writes
+the few-byte query atomically, so it arrives whole."
+ (let ((start 0) (found '()))
+ (while (string-match "\e\\[\\(14\\|16\\|18\\)t" output start)
+ (push (string-to-number (match-string 1 output)) found)
+ (setq start (match-end 0)))
+ (nreverse found)))
+
+(defun cj/--eat-send-window-size-report (n)
+ "Answer XTWINOPS window-size request N on the current EAT terminal.
+Runs with `eat--t-term' dynamically bound (inside `eat--t-with-env'),
+reads the live display and cell dimensions, and writes the report back
+through the terminal's own input function. A request number EAT does
+not report on (`cj/--eat-xtwinops-report' returns nil) is ignored."
+ (let* ((disp (eat--t-term-display eat--t-term))
+ (reply (cj/--eat-xtwinops-report
+ n
+ (eat--t-disp-width disp)
+ (eat--t-disp-height disp)
+ (eat--t-term-char-width eat--t-term)
+ (eat--t-term-char-height eat--t-term))))
+ (when reply
+ (funcall (eat--t-term-input-fn eat--t-term) eat--t-term reply))))
+
+(defun cj/--eat-answer-xtwinops (output)
+ "`:before' advice for `eat--t-handle-output'.
+Answer every XTWINOPS window-size request in OUTPUT that EAT 0.9.4 would
+otherwise drop, so tmux learns the cell pixel size it needs before it
+will emit Sixel. Runs inside `eat--t-with-env', so `eat--t-term' is
+bound for the responder."
+ (mapc #'cj/--eat-send-window-size-report (cj/--eat-xtwinops-queries output)))
+
+(defun cj/--eat-xtwinops-advice-needed-p ()
+ "Return non-nil when EAT itself cannot answer XTWINOPS window-size queries.
+EAT 0.9.4 has no CSI t parser clause, so the advice must answer them.
+An EAT that ships the clause defines `eat--t-send-window-size-report'
+(the function the upstream-shaped parser patch adds); with that present
+the advice must stay out, or every query gets two replies and tmux
+types the second one's raw bytes into the pane."
+ (not (fboundp 'eat--t-send-window-size-report)))
+
+(with-eval-after-load 'eat
+ (when (cj/--eat-xtwinops-advice-needed-p)
+ (advice-add 'eat--t-handle-output :before #'cj/--eat-answer-xtwinops)))
+
;; ------------------------------- eat package ---------------------------------
(defun cj/--eat-clear-mode-line-process ()
@@ -560,7 +656,12 @@ pty; without tmux, moves point up in EAT's emacs-mode buffer."
;; (point jumped back on the next keystroke). Window arrows (S-, C-M-) keep
;; reaching Emacs for windmove / buffer-move.
(dolist (key '("C-<left>" "C-<right>" "M-<left>" "M-<right>"))
- (keymap-set eat-semi-char-mode-map key #'eat-self-input)))
+ (keymap-set eat-semi-char-mode-map key #'eat-self-input))
+ ;; C-z is prohibited. Forwarded to the pty it sends SIGTSTP to the foreground
+ ;; job, backgrounding the agent; with eat-over-tmux, `fg' does not reliably
+ ;; bring it back. Swallowing it here (a no-op that reaches Emacs, not the pty)
+ ;; keeps a stray C-z from stopping the agent.
+ (keymap-set eat-semi-char-mode-map "C-z" #'ignore))
(provide 'eat-config)
;;; eat-config.el ends here
diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el
index dbc7e4a4..f4feef3a 100644
--- a/modules/elfeed-config.el
+++ b/modules/elfeed-config.el
@@ -142,30 +142,6 @@
(elfeed-search-update--force)
(goto-char (point-min)))
-;; ----------------------------- Extract Stream URL ----------------------------
-;; TASK: Is this method reused anywhere here or in another file?
-
-(defun cj/extract-stream-url (url format)
- "Extract the direct stream URL from URL using yt-dlp with FORMAT.
-Returns the stream URL or nil on failure."
- (unless (executable-find "yt-dlp")
- (error "The program yt-dlp is not installed or not in PATH"))
- (let* ((format-args (if format
- (list "-f" format)
- nil))
- (cmd-args (append '("yt-dlp" "-q" "-g")
- format-args
- (list url)))
- (output (with-temp-buffer
- (let ((exit-code (apply #'call-process
- (car cmd-args) nil t nil
- (cdr cmd-args))))
- (if (zerop exit-code)
- (string-trim (buffer-string))
- nil)))))
- (when (and output (string-match-p "^https?://" output))
- output)))
-
;; -------------------------- Elfeed Core Processing ---------------------------
(defun cj/elfeed-process-entries (action-fn action-name &optional skip-error-handling)
diff --git a/modules/erc-config.el b/modules/erc-config.el
index 57d4eb56..afcb3901 100644
--- a/modules/erc-config.el
+++ b/modules/erc-config.el
@@ -38,6 +38,7 @@
;; without forcing an eager require.
;; Functions provided by the erc package.
+(defvar erc-server-process)
(declare-function erc-buffer-list "erc")
(declare-function erc-server-process-alive "erc")
(declare-function erc-server-or-unjoined-channel-buffer-p "erc")
diff --git a/modules/external-open.el b/modules/external-open.el
index 811c32c2..f7f09816 100644
--- a/modules/external-open.el
+++ b/modules/external-open.el
@@ -142,6 +142,18 @@ Logs output and exit code to buffer *external-open.log*."
;; ------------------------------- Open File With ------------------------------
+(defun cj/--open-with-argv (command file)
+ "The argv list to open FILE with the user-typed COMMAND.
+COMMAND may carry arguments (\"mpv --fs\"); `split-string-and-unquote'
+splits it so a double-quoted argument survives as one word. FILE is
+appended as the final element, so paths with spaces or shell
+metacharacters never meet a shell. Signals a `user-error' when COMMAND
+is empty or whitespace."
+ (let ((argv (split-string-and-unquote command)))
+ (unless argv
+ (user-error "No program given"))
+ (append argv (list file))))
+
(defun cj/open-this-file-with (command)
"Open this buffer's file with COMMAND, detached from Emacs."
(interactive "MOpen with program: ")
@@ -152,12 +164,12 @@ Logs output and exit code to buffer *external-open.log*."
;; Windows: launch via ShellExecute so the child isn't tied to Emacs.
((env-windows-p)
(w32-shell-execute "open" command (format "\"%s\"" file)))
- ;; POSIX: disown with nohup + background. No child remains.
+ ;; POSIX: argv launch, DESTINATION 0 detaches with no shell in between.
(t
- (call-process-shell-command
- (format "nohup %s %s >/dev/null 2>&1 &"
- command (shell-quote-argument file))
- nil 0)))))
+ (let ((argv (cj/--open-with-argv command file)))
+ (unless (executable-find (car argv))
+ (user-error "Program not found: %s" (car argv)))
+ (apply #'call-process (car argv) nil 0 nil (cdr argv)))))))
;; -------------------------- Open Videos On Repeat ----------------------------
@@ -185,6 +197,11 @@ blocks Emacs."
(if (env-windows-p)
(w32-shell-execute "open" cj/video-open-command
(mapconcat (lambda (a) (format "\"%s\"" a)) args " "))
+ ;; Guard like `cj/open-this-file-with': this fires via the find-file
+ ;; advice, so a missing player must fail with a clear message, not an
+ ;; opaque call-process error mid-visit.
+ (unless (executable-find cj/video-open-command)
+ (user-error "Program not found: %s" cj/video-open-command))
(apply #'call-process cj/video-open-command nil 0 nil args))))
;; -------------------- Open Files With Default File Handler -------------------
diff --git a/modules/flycheck-config.el b/modules/flycheck-config.el
index 2a5a5e74..ad762b3b 100644
--- a/modules/flycheck-config.el
+++ b/modules/flycheck-config.el
@@ -44,15 +44,21 @@
:defer t
:commands (flycheck-list-errors
cj/flycheck-list-errors)
- :hook ((sh-mode emacs-lisp-mode) . flycheck-mode)
+ ;; ledger-mode is here, not in ledger-config.el, so this list stays the one
+ ;; answer to "where is flycheck turned on?". flycheck-ledger registers a
+ ;; `ledger' checker but never enables the mode, so before this hook existed an
+ ;; unbalanced transaction in a ledger file produced no warning at all.
+ :hook ((sh-mode emacs-lisp-mode ledger-mode) . flycheck-mode)
:bind
(:map cj/custom-keymap
("?" . cj/flycheck-list-errors))
:custom
- ;; Only disable these two Checkdoc warnings; leave all others intact.
- (checkdoc-arguments
- '(("sentence-end-double-space" nil)
- ("warn-escape" nil)))
+ ;; No checkdoc suppression here: the old `checkdoc-arguments' entry named a
+ ;; variable that doesn't exist (checkdoc has no such option and this
+ ;; flycheck runs checkdoc via a fixed subprocess form), so it never
+ ;; suppressed anything. Removing it changes no behavior; if specific
+ ;; checkdoc warnings need silencing, that's a new feature against
+ ;; `flycheck-emacs-lisp-checkdoc-form'.
;; Modeline customization (rendered via mode-line-format in modeline-config.el).
;; The count portion picks up `error' / `warning' faces because
;; `flycheck-mode-line-color' stays t (the default).
diff --git a/modules/flyspell-and-abbrev.el b/modules/flyspell-and-abbrev.el
index b73bfdf3..d0cdd09c 100644
--- a/modules/flyspell-and-abbrev.el
+++ b/modules/flyspell-and-abbrev.el
@@ -73,8 +73,10 @@
;; personal directory goes with sync'd files
(setq ispell-personal-dictionary
(concat org-dir "aspell-personal-dictionary"))
- ;; skip code blocks in org mode
- (add-to-list 'ispell-skip-region-alist '("^#+BEGIN_SRC" . "^#+END_SRC")))
+ ;; Skip code blocks in org mode. The # must be literal and the + escaped:
+ ;; "#+" in regex means one-or-more #, which matches no real begin_src line,
+ ;; so ispell used to spell-check inside every org code block.
+ (add-to-list 'ispell-skip-region-alist '("^#\\+BEGIN_SRC" . "^#\\+END_SRC")))
(use-package flyspell
:ensure nil ;; built-in
@@ -197,9 +199,10 @@ Without prefix argument, it's created in the global abbrev table.
Press C-' repeatedly to step through misspellings one at a time."
(interactive "P")
(cj/--require-spell-checker)
- ;; Run flyspell-buffer only if buffer hasn't been checked yet
- (unless (bound-and-true-p flyspell-mode)
- (flyspell-buffer))
+ ;; Enable Flyspell for the buffer type so the mode sticks and the buffer is
+ ;; scanned once. A bare flyspell-buffer here never turned the mode on, so
+ ;; the guard never tripped and every C-' press re-scanned the whole buffer.
+ (cj/flyspell-on-for-buffer-type)
(let ((misspelled-word (cj/flyspell-goto-previous-misspelling (point))))
(if (not misspelled-word)
diff --git a/modules/font-config.el b/modules/font-config.el
index 3aa3d80f..e4549c95 100644
--- a/modules/font-config.el
+++ b/modules/font-config.el
@@ -8,12 +8,12 @@
;; Load shape: eager.
;; Eager reason: first-frame font setup and font keybindings.
;; Top-level side effects: font keys, font checks, package config.
-;; Runtime requires: host-environment, keybindings.
+;; Runtime requires: host-environment, font-profiles, keybindings.
;; Direct test load: yes.
;;
-;; Configures fontaine presets, text scaling keys, icon/emoji fonts, and
-;; programming ligatures. Presets are applied per frame so daemon clients get
-;; the intended fixed/variable pitch sizes.
+;; Configures task-oriented Fontaine profiles, text scaling keys, icon/emoji
+;; fonts, and programming ligatures. The selected profile is global, persists
+;; across restarts, and applies to every daemon frame without per-frame resets.
;;
;; Also carries font-rendering safeguards for known HarfBuzz/font-cache crashes
;; triggered by emoji and Arabic shaping in this setup.
@@ -21,6 +21,7 @@
;;; Code:
(require 'host-environment)
+(require 'font-profiles)
(require 'keybindings) ;; establishes the C-z prefix used for "C-z F" below
(defvar text-scale-mode-step)
@@ -50,106 +51,156 @@
(#xFE70 . #xFEFF))) ;; Arabic Presentation Forms-B
(set-char-table-range composition-function-table range nil)))
-;; ----------------------- Font Family And Size Selection ----------------------
-;; preset your fixed and variable fonts, then apply them to text as a set
+;; ------------------------- Workflow Font Profiles ----------------------------
+;; Each choice is a complete destination. Font size adjustments within one
+;; buffer remain on C-+/C--; Fontaine owns the global workflow typography.
+
+(defconst cj/fontaine-profile-order
+ cj/font-profile-order
+ "Fontaine profiles in picker order.")
+
+(defconst cj/fontaine-profile-names
+ '((everyday . "Everyday")
+ (writing . "Writing")
+ (reading . "Reading")
+ (coding-xs . "Coding XS")
+ (coding-m . "Coding M")
+ (coding-l . "Coding L")
+ (coding-xl . "Coding XL")
+ (presentation . "Presentation"))
+ "Human names for Fontaine workflow profiles.")
+
+(defconst cj/fontaine-profile-fonts
+ '((everyday . "Berkeley Mono + Lexend")
+ (writing . "Berkeley Mono + Merriweather")
+ (reading . "Merriweather")
+ (coding-xs . "Berkeley Mono")
+ (coding-m . "Berkeley Mono")
+ (coding-l . "Berkeley Mono")
+ (coding-xl . "Berkeley Mono")
+ (presentation . "Berkeley Mono + Lexend"))
+ "Human-readable font combinations for Fontaine workflow profiles.")
+
+(defconst cj/fontaine-profile-heights
+ (mapcar (lambda (profile)
+ (cons profile
+ (plist-get (cj/font-profile-properties profile)
+ :default-height)))
+ cj/fontaine-profile-order)
+ "Default face heights for Fontaine workflow profiles.")
+
+(defconst cj/fontaine-ui-family "BerkeleyMono Nerd Font"
+ "Font family reserved for the mode line, echo area, and minibuffer.")
+
+(defvar fontaine-current-preset)
+(defvar fontaine-preset-history)
+(defvar fontaine-presets)
+(defvar enable-theme-functions)
+(defvar cj/fontaine-profile-history nil
+ "Minibuffer history for `cj/fontaine-select-profile'.")
+
+(declare-function fontaine-mode "fontaine")
+(declare-function fontaine-restore-latest-preset "fontaine")
+(declare-function fontaine-set-preset "fontaine")
+(declare-function face-remap-set-base "face-remap")
+
+(defun cj/fontaine-profile-p (profile)
+ "Return non-nil when PROFILE is a configured workflow profile."
+ (cj/font-profile-p profile))
+
+(defun cj/fontaine-profile-label (profile)
+ "Return the complete picker label for PROFILE."
+ (when (cj/fontaine-profile-p profile)
+ (format "%s — %s · %d pt"
+ (alist-get profile cj/fontaine-profile-names)
+ (alist-get profile cj/fontaine-profile-fonts)
+ (/ (alist-get profile cj/fontaine-profile-heights) 10))))
+
+(defun cj/fontaine-profile-candidates ()
+ "Return complete labels for all Fontaine workflow profiles."
+ (mapcar #'cj/fontaine-profile-label cj/fontaine-profile-order))
+
+(defun cj/fontaine-profile-from-label (label)
+ "Return the workflow profile represented by LABEL, or nil."
+ (seq-find (lambda (profile)
+ (equal label (cj/fontaine-profile-label profile)))
+ cj/fontaine-profile-order))
+
+(defun cj/fontaine-profile-annotation (candidate)
+ "Mark CANDIDATE when it represents the active Fontaine profile."
+ (if (eq (cj/fontaine-profile-from-label candidate)
+ fontaine-current-preset)
+ " current"
+ ""))
+
+(defun cj/fontaine-apply-profile (profile)
+ "Apply workflow PROFILE and record it for Fontaine persistence."
+ (unless (cj/fontaine-profile-p profile)
+ (user-error "Unknown font profile: %s" profile))
+ (add-to-history 'fontaine-preset-history (symbol-name profile))
+ (fontaine-set-preset profile))
+
+(defun cj/fontaine-select-profile ()
+ "Select and apply one complete Fontaine workflow profile."
+ (interactive)
+ (let* ((candidates (cj/fontaine-profile-candidates))
+ (default (cj/fontaine-profile-label
+ (if (cj/fontaine-profile-p fontaine-current-preset)
+ fontaine-current-preset
+ 'everyday)))
+ (completion-extra-properties
+ '(:annotation-function cj/fontaine-profile-annotation))
+ (choice (completing-read "Font profile: " candidates nil t
+ nil 'cj/fontaine-profile-history default)))
+ (cj/fontaine-apply-profile (cj/fontaine-profile-from-label choice))))
+
+(defun cj/fontaine-restored-or-default-profile ()
+ "Return the saved Fontaine profile, or the `everyday' fallback."
+ (let ((restored (fontaine-restore-latest-preset)))
+ (if (cj/fontaine-profile-p restored) restored 'everyday)))
+
+(defalias 'cj/fontaine-profile-properties #'cj/font-profile-properties)
+(defalias 'cj/fontaine-remap-buffer-to-profile #'cj/font-profile-remap-buffer)
+
+(defun cj/fontaine-remap-ui-buffer ()
+ "Keep the current minibuffer or echo-area buffer in Berkeley Mono."
+ (face-remap-set-base
+ 'default `(:family ,cj/fontaine-ui-family)))
+
+(defun cj/fontaine-keep-ui-chrome-monospace (&rest _ignored)
+ "Keep mode-line, minibuffer, and echo-area chrome in Berkeley Mono."
+ (dolist (face '(mode-line mode-line-active mode-line-inactive
+ minibuffer-prompt))
+ (when (facep face)
+ (set-face-attribute face nil :family cj/fontaine-ui-family)))
+ (dolist (name '(" *Echo Area 0*" " *Echo Area 1*"))
+ (when-let* ((buffer (get-buffer name)))
+ (with-current-buffer buffer
+ (cj/fontaine-remap-ui-buffer)))))
+
+;; Fontaine 3 is global rather than frame-specific. Remove the retired hooks
+;; as well as omitting them below, so a live module reload migrates cleanly.
+(remove-hook 'server-after-make-frame-hook #'cj/apply-font-settings-to-frame)
+(remove-hook 'delete-frame-functions #'cj/cleanup-frame-list)
(use-package fontaine
:demand t
:bind
- ("M-S-f" . fontaine-set-preset) ;; was M-F, overrides forward-word
+ ("M-S-f" . cj/fontaine-select-profile) ;; was M-F, overrides forward-word
:config
(setq fontaine-presets
- `(
- (default
- :default-family "BerkeleyMono Nerd Font"
- :default-weight regular
- :default-height ,(if (env-laptop-p) 130 140)
- :fixed-pitch-family nil ;; falls back to :default-family
- :fixed-pitch-weight nil ;; falls back to :default-weight
- :fixed-pitch-height 1.0
- :variable-pitch-family "Lexend"
- :variable-pitch-weight regular
- :variable-pitch-height 1.0)
- (FiraCode
- :default-family "FiraCode Nerd Font Mono"
- :variable-pitch-family "Merriweather"
- :variable-pitch-weight light)
- (Hack
- :default-family "Hack Nerd Font Mono"
- :variable-pitch-family "Hack Nerd Font Mono")
- (BerkeleyMono
- :default-family "Berkeley Mono"
- :variable-pitch-family "Charis SIL")
- (FiraCode-Literata
- :default-family "Fira Code Nerd Font"
- :variable-pitch-family "Literata")
- (24-point-font
- :default-height 240)
- (20-point-font
- :default-height 200)
- (16-point-font
- :default-height 160)
- (14-point-font
- :default-height 140)
- (13-point-font
- :default-height 130)
- (12-point-font
- :default-height 120)
- (11-point-font
- :default-height 110)
- (10-point-font
- :default-height 100)
- (t ;; shared fallback properties go here
- :default-family "FiraCode Nerd Font Mono"
- :default-weight regular
- :default-height 120
- :fixed-pitch-family nil ;; falls back to :default-family
- :fixed-pitch-weight nil ;; falls back to :default-weight
- :fixed-pitch-height 1.0
- :fixed-pitch-serif-family nil ;; falls back to :default-family
- :fixed-pitch-serif-weight nil ;; falls back to :default-weight
- :fixed-pitch-serif-height 1.0
- :variable-pitch-family "Merriweather"
- :variable-pitch-weight light
- :variable-pitch-height 1.0
- :bold-family nil ;; use whatever the underlying face has
- :bold-weight bold
- :italic-family nil
- :italic-slant italic
- :line-spacing nil))))
-
-;; Track which frames have had fonts applied
-(defvar cj/fontaine-configured-frames nil
- "List of frames that have had fontaine configuration applied.")
-
-(declare-function fontaine-set-preset "fontaine")
-
-(defun cj/apply-font-settings-to-frame (&optional frame)
- "Apply font settings to FRAME if not already configured.
-If FRAME is nil, uses the selected frame."
- (let ((target-frame (or frame (selected-frame))))
- (unless (member target-frame cj/fontaine-configured-frames)
- (with-selected-frame target-frame
- (when (env-gui-p)
- (fontaine-set-preset 'default)
- (push target-frame cj/fontaine-configured-frames))))))
-
-(defun cj/cleanup-frame-list (frame)
- "Remove FRAME from the configured frames list when deleted."
- (setq cj/fontaine-configured-frames
- (delq frame cj/fontaine-configured-frames)))
-
-(with-eval-after-load 'fontaine
- ;; Handle daemon mode and regular mode
- (if (daemonp)
- (progn
- ;; Apply to each new frame in daemon mode
- (add-hook 'server-after-make-frame-hook #'cj/apply-font-settings-to-frame)
- ;; Clean up deleted frames from tracking list
- (add-hook 'delete-frame-functions #'cj/cleanup-frame-list))
- ;; Apply immediately in non-daemon mode
- (when (env-gui-p)
- (cj/apply-font-settings-to-frame))))
+ (append (copy-tree cj/font-profile-definitions)
+ (list (cons t (copy-sequence
+ cj/font-profile-shared-properties)))))
+ (fontaine-mode 1)
+ (add-hook 'fontaine-set-preset-hook
+ #'cj/fontaine-keep-ui-chrome-monospace)
+ (add-hook 'enable-theme-functions
+ #'cj/fontaine-keep-ui-chrome-monospace)
+ (add-hook 'minibuffer-setup-hook #'cj/fontaine-remap-ui-buffer)
+ (cj/fontaine-keep-ui-chrome-monospace)
+ (when (or (daemonp) (env-gui-p))
+ (cj/fontaine-apply-profile (cj/fontaine-restored-or-default-profile))))
;; ----------------------------- Font Install Check ----------------------------
;; convenience function to indicate whether a font is available by name.
@@ -210,6 +261,15 @@ the fontset repeatedly is harmless, so it can be called from
;; ---------------------------------- Emojify ----------------------------------
;; converts emoji identifiers into emojis; allows for easy emoji entry.
+(defvar emojify-display-style) ;; emojify's, forward-declared for the helper
+
+(defun cj/set-emojify-display-style ()
+ "Set `emojify-display-style' to `image' on a graphical frame, else `unicode'.
+Image emoji only render on a GUI frame. In daemon mode no GUI frame exists when
+emojify loads, so this runs per-frame from `server-after-make-frame-hook';
+otherwise the value would latch to `unicode' and GUI frames never get images."
+ (setq emojify-display-style (if (env-gui-p) 'image 'unicode)))
+
(use-package emojify
:defer 1
:hook ((erc-mode . emojify-mode))
@@ -221,7 +281,11 @@ the fontset repeatedly is harmless, so it can be called from
:config
(setq emojify-show-help nil)
(setq emojify-point-entered-behaviour 'uncover)
- (setq emojify-display-style (if (env-gui-p) 'image 'unicode))
+ ;; In daemon mode `env-gui-p' is nil at :config time (no GUI frame yet), so
+ ;; recompute the display style per-frame; otherwise set it now.
+ (if (daemonp)
+ (add-hook 'server-after-make-frame-hook #'cj/set-emojify-display-style)
+ (cj/set-emojify-display-style))
(setq emojify-emoji-styles '(ascii unicode github))
;; Disable emojify in programming modes
@@ -242,21 +306,24 @@ the fontset repeatedly is harmless, so it can be called from
(let ((font-list (font-family-list)))
(setq font-list (cl-remove-duplicates (cl-sort font-list 'string-lessp :key 'downcase)))
(with-current-buffer "*Available Fonts*"
- (erase-buffer)
- (dolist (font-family font-list)
- (insert (propertize (concat font-family) 'face '(font-lock-keyword-face (:weight bold))))
- (insert (concat "\n"(propertize "Regular: ")))
- (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
- 'face `((:family, font-family))))
- (insert (concat "\n" (propertize "Bold: ")))
- (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
- 'face `((:family, font-family :weight bold))))
- (insert (concat "\n" (propertize "Italic: ")))
- (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
- 'face `((:family, font-family :slant italic))))
- (insert (concat "\n\n"))))
- (move-to-window-line 0)
- (special-mode)))
+ ;; The buffer is left in `special-mode' (read-only) after the first call,
+ ;; so re-running must relax read-only to erase and rewrite it.
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (dolist (font-family font-list)
+ (insert (propertize (concat font-family) 'face '(font-lock-keyword-face (:weight bold))))
+ (insert (concat "\n"(propertize "Regular: ")))
+ (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
+ 'face `((:family, font-family))))
+ (insert (concat "\n" (propertize "Bold: ")))
+ (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
+ 'face `((:family, font-family :weight bold))))
+ (insert (concat "\n" (propertize "Italic: ")))
+ (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
+ 'face `((:family, font-family :slant italic))))
+ (insert (concat "\n\n"))))
+ (move-to-window-line 0)
+ (special-mode))))
(keymap-global-set "C-z F" #'cj/display-available-fonts)
diff --git a/modules/font-profiles.el b/modules/font-profiles.el
new file mode 100644
index 00000000..a2c40562
--- /dev/null
+++ b/modules/font-profiles.el
@@ -0,0 +1,116 @@
+;;; font-profiles.el --- Shared Workflow Font Profile Data -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Layer: 1 (Foundation).
+;; Category: F/L.
+;; Load shape: library.
+;; Top-level side effects: none.
+;; Runtime requires: host-environment.
+;; Direct test load: yes.
+;;
+;; Owns the effective font properties shared by the global Fontaine adapter and
+;; buffer-local mode adapters such as nov-reading. Consumers can therefore use
+;; the same named profile without making a global Fontaine selection.
+
+;;; Code:
+
+(require 'host-environment)
+
+(declare-function face-remap-add-relative "face-remap")
+
+(defconst cj/font-profile-shared-properties
+ `(:default-family "BerkeleyMono Nerd Font"
+ :default-weight regular
+ :default-height ,(if (env-laptop-p) 130 140)
+ :fixed-pitch-family nil
+ :fixed-pitch-weight nil
+ :fixed-pitch-height 1.0
+ :fixed-pitch-serif-family nil
+ :fixed-pitch-serif-weight nil
+ :fixed-pitch-serif-height 1.0
+ :variable-pitch-family "Lexend"
+ :variable-pitch-weight regular
+ :variable-pitch-height 1.0
+ :bold-family nil
+ :bold-weight bold
+ :italic-family nil
+ :italic-slant italic
+ :line-spacing nil)
+ "Properties shared by every workflow font profile unless overridden.")
+
+(defconst cj/font-profile-definitions
+ '((everyday)
+ (writing
+ :default-height 140
+ :variable-pitch-family "Merriweather"
+ :variable-pitch-weight light)
+ (reading
+ :default-family "Merriweather"
+ :default-height 140
+ :fixed-pitch-family "Merriweather"
+ :fixed-pitch-serif-family "Merriweather"
+ :variable-pitch-family "Merriweather")
+ (coding-xs
+ :default-height 110
+ :variable-pitch-family "BerkeleyMono Nerd Font")
+ (coding-m
+ :default-height 130
+ :variable-pitch-family "BerkeleyMono Nerd Font")
+ (coding-l
+ :default-height 140
+ :variable-pitch-family "BerkeleyMono Nerd Font")
+ (coding-xl
+ :default-height 160
+ :variable-pitch-family "BerkeleyMono Nerd Font")
+ (presentation
+ :default-height 200))
+ "Profile-specific font properties in user-facing order.")
+
+(defconst cj/font-profile-order
+ (mapcar #'car cj/font-profile-definitions)
+ "Workflow font profiles in user-facing order.")
+
+(defun cj/font-profile-p (profile)
+ "Return non-nil when PROFILE is a configured workflow font profile."
+ (memq profile cj/font-profile-order))
+
+(defun cj/font-profile-properties (profile)
+ "Return effective font properties for workflow PROFILE."
+ (let ((entry (assq profile cj/font-profile-definitions)))
+ (unless entry
+ (user-error "Unknown font profile: %s" profile))
+ (append (cdr entry) cj/font-profile-shared-properties)))
+
+(defun cj/font-profile-remap-buffer (profile &optional height)
+ "Apply PROFILE's face families buffer-locally and return remap cookies.
+When HEIGHT is non-nil, use it for every remapped face instead of the profile's
+configured heights. No global face or Fontaine state is changed."
+ (let ((properties (cj/font-profile-properties profile))
+ (cookies nil))
+ (dolist (face-property '((default
+ :default-family :default-height)
+ (fixed-pitch
+ :fixed-pitch-family :fixed-pitch-height)
+ (fixed-pitch-serif
+ :fixed-pitch-serif-family
+ :fixed-pitch-serif-height)
+ (variable-pitch
+ :variable-pitch-family
+ :variable-pitch-height)))
+ (pcase-let ((`(,face ,family-property ,height-property)
+ face-property))
+ (let ((family (or (plist-get properties family-property)
+ (and (memq face '(fixed-pitch fixed-pitch-serif))
+ (plist-get properties :default-family))))
+ (face-height (or height
+ (plist-get properties height-property))))
+ (when family
+ (push (face-remap-add-relative
+ face :family family :height face-height)
+ cookies)))))
+ (nreverse cookies)))
+
+(provide 'font-profiles)
+;;; font-profiles.el ends here
diff --git a/modules/help-utils.el b/modules/help-utils.el
index 9792841a..2709ac45 100644
--- a/modules/help-utils.el
+++ b/modules/help-utils.el
@@ -65,24 +65,40 @@
;; on Arch: yay (or whatever your AUR package manager is) -S arch-wiki-docs
;; browse the arch wiki topics offline
+(defvar cj/arch-wiki-html-dir "/usr/share/doc/arch-wiki/html/en"
+ "Directory holding the offline ArchWiki HTML copies.
+Populated by the arch-wiki-docs package on Arch systems.")
+
+(defun cj/--arch-wiki-topics (dir)
+ "Return an alist of (BASENAME . FULLPATH) for ArchWiki topics under DIR.
+
+Returns nil when DIR does not exist rather than signaling. This is the
+whole point of the helper: `directory-files' raises file-missing on an
+absent directory, and the caller's \"is arch-wiki-docs installed?\" hint
+sat below that call, so on the one machine state the hint was written for
+it could never be reached."
+ (when (file-directory-p dir)
+ (mapcar (lambda (f) (cons (file-name-base f) f))
+ (directory-files dir t "\\.html\\'"))))
+
(defun cj/local-arch-wiki-search ()
"Prompt for an ArchWiki topic and open its local HTML copy in EWW.
-Looks for “*.html” files under \"/usr/share/doc/arch-wiki/html/en\",
-lets you complete on their basenames, and displays the chosen file
-with `eww-browse-url'. If no file is found, reminds you to install
+Looks for “*.html” files under `cj/arch-wiki-html-dir', lets you complete
+on their basenames, and displays the chosen file with `eww-browse-url'.
+If the directory is missing or empty, reminds you to install
arch-wiki-docs."
(interactive)
- (let* ((dir "/usr/share/doc/arch-wiki/html/en")
- (full-filenames (directory-files dir t "\\.html\\'"))
- (basenames (mapcar 'file-name-base full-filenames))
- (chosen (completing-read "Choose an ArchWiki Topic: " basenames)))
- (if (member chosen basenames)
- (let* ((idx (cl-position chosen basenames :test 'equal))
- (fullname (nth idx full-filenames))
- (url (concat "file://" fullname)))
- (eww-browse-url url))
- (message "File not found! Is arch-wiki-docs installed?"))))
+ (let ((topics (cj/--arch-wiki-topics cj/arch-wiki-html-dir)))
+ (if (null topics)
+ (message "No ArchWiki topics in %s. Is arch-wiki-docs installed?"
+ cj/arch-wiki-html-dir)
+ (let* ((chosen (completing-read "Choose an ArchWiki Topic: "
+ (mapcar #'car topics)))
+ (fullname (cdr (assoc chosen topics))))
+ (if fullname
+ (eww-browse-url (concat "file://" fullname))
+ (message "No ArchWiki topic named %s" chosen))))))
(keymap-global-set "C-h A" #'cj/local-arch-wiki-search)
(provide 'help-utils)
diff --git a/modules/host-environment.el b/modules/host-environment.el
index 0afb39cb..1c33e342 100644
--- a/modules/host-environment.el
+++ b/modules/host-environment.el
@@ -138,15 +138,13 @@ find /usr/share/zoneinfo -type f ! -name `posixrules' \\
(defun cj/detect-system-timezone ()
"Detect the system timezone in IANA format (e.g., `America/Los_Angeles').
-Tries multiple methods in order of reliability:
-1. File comparison of /etc/localtime with zoneinfo database
-2. Environment variable TZ
-3. /etc/timezone file contents
-4. /etc/localtime symlink target"
+Tries the cheap methods first and the exhaustive scan last:
+1. Environment variable TZ (most explicit if set)
+2. /etc/timezone file contents (Debian/Ubuntu)
+3. /etc/localtime symlink target (O(1) on symlinked systems)
+4. File comparison of /etc/localtime against the zoneinfo database
+ (reads hundreds of files; only needed when localtime is a copy)"
(or
- ;; Compare file contents (reliable on Arch/modern systems)
- (cj/match-localtime-to-zoneinfo)
-
;; Environment variable (most explicit if set)
(getenv "TZ")
@@ -156,12 +154,15 @@ Tries multiple methods in order of reliability:
(insert-file-contents "/etc/timezone")
(string-trim (buffer-string))))
- ;; Method 4: Parse symlink (fallback for older systems)
+ ;; Parse the symlink -- O(1), answers on any symlinked /etc/localtime
(when (file-symlink-p "/etc/localtime")
(let ((target (file-truename "/etc/localtime")))
(when (string-match ".*/zoneinfo/\\(.+\\)" target)
(match-string 1 target))))
+ ;; Compare file contents -- the last resort for a copied /etc/localtime
+ (cj/match-localtime-to-zoneinfo)
+
;; Default to nil if detection fails
nil))
diff --git a/modules/httpd-config.el b/modules/httpd-config.el
index 1a2a5c61..a3ae0fac 100644
--- a/modules/httpd-config.el
+++ b/modules/httpd-config.el
@@ -5,8 +5,9 @@
;;
;; Layer: 4 (Optional).
;; Category: O/D/P.
-;; Load shape: eager.
-;; Eager reason: none; local web server, a command-loaded deferral candidate.
+;; Load shape: deferred.
+;; Defer reason: impatient-mode requires simple-httpd on demand; nothing
+;; needs the server (or its www/ root) at startup.
;; Top-level side effects: package configuration via use-package.
;; Runtime requires: none.
;; Direct test load: yes.
@@ -17,14 +18,15 @@
;;;; -------------------------- Simple-Httpd -------------------------
(use-package simple-httpd
- :defer 1
+ :defer t
:preface
(defconst cj/httpd-wwwdir (concat user-emacs-directory "www"))
(defun cj/httpd-check-or-create-wwwdir ()
(unless (file-exists-p cj/httpd-wwwdir)
(make-directory cj/httpd-wwwdir)))
- :init (cj/httpd-check-or-create-wwwdir)
:config
+ ;; Create the doc root only when the server package actually loads.
+ (cj/httpd-check-or-create-wwwdir)
(setq httpd-root cj/httpd-wwwdir)
(setq httpd-show-backtrace-when-error t)
(setq httpd-serve-files t))
diff --git a/modules/hugo-config.el b/modules/hugo-config.el
index b26398c6..36f9e07a 100644
--- a/modules/hugo-config.el
+++ b/modules/hugo-config.el
@@ -28,6 +28,7 @@
(require 'user-constants)
(require 'host-environment)
(require 'system-lib) ;; completion table + file annotator
+(require 'keybindings) ;; cj/register-prefix-map, cj/custom-keymap
;; --------------------------------- Constants ---------------------------------
@@ -247,14 +248,18 @@ to /var/www/cjennings/, so a successful push is the deploy."
;; -------------------------------- Keybindings --------------------------------
-(global-set-key (kbd "C-; h n") #'cj/hugo-new-post)
-(global-set-key (kbd "C-; h e") #'cj/hugo-export-post)
-(global-set-key (kbd "C-; h o") #'cj/hugo-open-blog-dir)
-(global-set-key (kbd "C-; h O") #'cj/hugo-open-blog-dir-external)
-(global-set-key (kbd "C-; h d") #'cj/hugo-open-draft)
-(global-set-key (kbd "C-; h D") #'cj/hugo-toggle-draft)
-(global-set-key (kbd "C-; h p") #'cj/hugo-preview)
-(global-set-key (kbd "C-; h P") #'cj/hugo-publish)
+(defvar-keymap cj/hugo-keymap
+ :doc "Keymap for Hugo blog commands"
+ "n" #'cj/hugo-new-post
+ "e" #'cj/hugo-export-post
+ "o" #'cj/hugo-open-blog-dir
+ "O" #'cj/hugo-open-blog-dir-external
+ "d" #'cj/hugo-open-draft
+ "D" #'cj/hugo-toggle-draft
+ "p" #'cj/hugo-preview
+ "P" #'cj/hugo-publish)
+
+(cj/register-prefix-map "h" cj/hugo-keymap)
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
diff --git a/modules/jumper.el b/modules/jumper.el
index 1fbd1293..bfafe08b 100644
--- a/modules/jumper.el
+++ b/modules/jumper.el
@@ -194,6 +194,10 @@ Returns: \\='no-locations if no locations stored,
locations))
(choice (completing-read "Jump to: " locations nil t))
(idx (cdr (assoc choice locations))))
+ ;; A UI that permits empty input (no vertico) yields a choice with no
+ ;; entry; nil would crash the index arithmetic downstream.
+ (unless idx
+ (user-error "No matching location"))
(jumper--do-jump-to-location idx)
(message "Jumped to location")))))
@@ -230,7 +234,8 @@ Returns: \\='no-locations if no locations stored,
(jumper--location-candidates))
(locations (cons (cons "Cancel" -1) locations))
(choice (completing-read "Remove location: " locations nil t))
- (idx (cdr (assoc choice locations))))
+ ;; Empty input (no matching entry) cancels, same as picking Cancel.
+ (idx (or (cdr (assoc choice locations)) -1)))
(pcase (jumper--do-remove-location idx)
('cancelled (message "Operation cancelled"))
('t (message "Location removed"))))))
diff --git a/modules/keybindings.el b/modules/keybindings.el
index 3e51b2dd..5d0262ed 100644
--- a/modules/keybindings.el
+++ b/modules/keybindings.el
@@ -153,8 +153,6 @@ Errors if VAR is unbound, not a non-empty string, or the file does not exist."
;; is armed everywhere. cj/modeline-reset repairs a hijacked buffer.
(keymap-global-unset "<f2>") ;; 2C-command prefix
(keymap-global-unset "C-x 6") ;; 2C-command prefix (same map)
-(keymap-global-unset "C-x C-f") ;; find-file-read-only
-(keymap-global-set "C-x C-f" #'find-file)
(keymap-global-set "C-z" (make-sparse-keymap)) ;; replace suspend-frame with prefix map
(keymap-global-unset "M-o") ;; facemenu-mode
diff --git a/modules/keyboard-compat.el b/modules/keyboard-compat.el
index 9395b9c8..73138ca5 100644
--- a/modules/keyboard-compat.el
+++ b/modules/keyboard-compat.el
@@ -45,8 +45,11 @@ This runs after init to override any package settings."
(define-key input-decode-map "\eOC" [right])
(define-key input-decode-map "\eOD" [left])))
-;; Run after init completes to override any package settings
-(add-hook 'emacs-startup-hook #'cj/keyboard-compat-terminal-setup)
+;; `input-decode-map' is terminal-local, and a daemon's `emacs-startup-hook'
+;; runs once with no tty, so a startup-hook registration never reaches the
+;; `emacsclient -t' frames that need it. `tty-setup-hook' runs for each new
+;; tty frame (daemon and non-daemon alike), which is where the decodings belong.
+(add-hook 'tty-setup-hook #'cj/keyboard-compat-terminal-setup)
;; Icon-rendering functions return blank on terminal frames so unicode
;; artifacts don't show up. The check runs per call against the selected
@@ -95,8 +98,7 @@ Meta+Shift+letter triggers M-S-letter keybindings."
(define-key key-translation-map (kbd "M-D") (kbd "M-S-d"))
(define-key key-translation-map (kbd "M-I") (kbd "M-S-i"))
(define-key key-translation-map (kbd "M-C") (kbd "M-S-c"))
- (define-key key-translation-map (kbd "M-B") (kbd "M-S-b"))
- (define-key key-translation-map (kbd "M-K") (kbd "M-S-k"))))
+ (define-key key-translation-map (kbd "M-B") (kbd "M-S-b"))))
;; In daemon mode, no frame exists at startup so env-gui-p returns nil.
;; Use server-after-make-frame-hook to set up translations when the first
diff --git a/modules/keyboard-macros.el b/modules/keyboard-macros.el
index 4e801096..bca36eed 100644
--- a/modules/keyboard-macros.el
+++ b/modules/keyboard-macros.el
@@ -43,7 +43,7 @@
;;; Code:
(require 'subr-x) ;; for string-trim
-(eval-when-compile (require 'user-constants))
+(require 'user-constants) ;; for macros-file, read at runtime
(defvar cj/macros-loaded nil
"Whether saved keyboard macros have been loaded from file.")
@@ -130,15 +130,7 @@ With prefix arg, open the macros file for editing after saving."
(keymap-global-set "C-<f3>" #'cj/kbd-macro-start-or-end)
(keymap-global-set "<f3>" #'call-last-kbd-macro)
(keymap-global-set "M-<f3>" #'cj/save-maybe-edit-macro)
- (keymap-global-set "s-<f3>" #'cj/open-macros-file)
- (add-hook 'kill-emacs-hook #'cj/save-last-kbd-macro-on-exit))
-
-;; Add hook to save any unnamed macros on exit if desired
-(defun cj/save-last-kbd-macro-on-exit ()
- "Save the last keyboard macro before exiting Emacs if it's not saved."
- (when last-kbd-macro
- (when (y-or-n-p "Save last keyboard macro before exiting? ")
- (call-interactively #'cj/save-maybe-edit-macro))))
+ (keymap-global-set "s-<f3>" #'cj/open-macros-file))
;; Auto-call setup after init
(if after-init-time
diff --git a/modules/local-repository.el b/modules/local-repository.el
index e3c7a227..0f2c981c 100644
--- a/modules/local-repository.el
+++ b/modules/local-repository.el
@@ -6,72 +6,28 @@
;; Layer: 4 (Optional).
;; Category: O/D/P.
;; Load shape: eager.
-;; Eager reason: none; local package mirror commands can autoload.
+;; Eager reason: none; the mirror-refresh command can autoload.
;; Top-level side effects: none.
;; Runtime requires: elpa-mirror when updating the mirror.
;; Direct test load: yes.
;;
-;; Adds the checked-in local package archive to package-archives with high
-;; priority, and provides a command to refresh that archive from installed
-;; packages via elpa-mirror.
+;; Provides a command to refresh the checked-in local package archive from the
+;; installed packages via elpa-mirror. Adding that archive to package-archives
+;; is owned by early-init.el (see `localrepo-location'); this module only
+;; refreshes it.
;;; Code:
(require 'elpa-mirror nil t) ;; optional; cj/update-localrepo-repository fails at call-time if absent
(declare-function elpamr-create-mirror-for-installed "elpa-mirror")
-
-;; ------------------------------ Utility Function -----------------------------
-
-
-(defun localrepo--car-member (value list)
- "Check if VALUE exists as the car of any cons cell in LIST."
- (member value (mapcar #'car list)))
-
-;; ------------------------------- Customizations ------------------------------
-
-(defgroup localrepo nil
- "Local last-known-good package repository."
- :group 'package)
-
-(defcustom localrepo-repository-id "localrepo"
- "The name used to identify the local repository internally.
-
-Used for the package-archive and package-archive-priorities lists."
- :type 'string
- :group 'localrepo)
-
-(defcustom localrepo-repository-priority 100
- "The value for the local repository in the package-archive-priority list.
-
-A higher value means higher priority. If you want your local packages to be
-preferred, this must be a higher number than any other repositories."
- :type 'integer
- :group 'localrepo)
-
-(defcustom localrepo-repository-location
- (concat user-emacs-directory "/.localrepo")
- "The location of the local repository.
-
-It's a good idea to keep this with the rest of your configuration files and
-keep them in source control."
- :type 'directory
- :group 'localrepo)
+(defvar localrepo-location) ;; defconst in early-init.el: the archive path
(defun cj/update-localrepo-repository ()
- "Update the local repository with currently installed packages."
+ "Update the local repository with currently installed packages.
+Targets `localrepo-location', the archive path early-init.el sets up."
(interactive)
- (elpamr-create-mirror-for-installed localrepo-repository-location t))
-
-(defun localrepo-initialize ()
-"Add the repository to the package archives, then gives it a high priority."
- (unless (localrepo--car-member localrepo-repository-id package-archives)
- (add-to-list 'package-archives
- (cons localrepo-repository-id localrepo-repository-location)))
-
- (unless (localrepo--car-member localrepo-repository-id package-archive-priorities)
- (add-to-list 'package-archive-priorities
- (cons localrepo-repository-id localrepo-repository-priority))))
+ (elpamr-create-mirror-for-installed localrepo-location t))
(provide 'local-repository)
;;; local-repository.el ends here.
diff --git a/modules/lorem-optimum.el b/modules/lorem-optimum.el
index 8aa96345..14f1d666 100644
--- a/modules/lorem-optimum.el
+++ b/modules/lorem-optimum.el
@@ -219,8 +219,22 @@ Builds and caches the keys list lazily if not already cached."
(message "Lorem-optimum learned from file: %s" file))
(defun cj/lipsum (n)
- "Return N words of lorem ipsum."
- (cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum")))
+ "Return N words of lorem ipsum.
+Interactively, prompt for N and echo the generated words.
+
+Signal a `user-error' when the Markov chain is empty (for example when the
+training file `cj/lipsum-default-file' is missing). Without this, callers
+such as `cj/lipsum-insert' would insert nil and raise a cryptic wrong-type
+error far from the cause. Train the chain with `cj/lipsum-learn-file',
+`cj/lipsum-learn-buffer', or `cj/lipsum-learn-region', or restore the file."
+ (interactive "nNumber of words: ")
+ (let ((text (cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum"))))
+ (unless (and (stringp text) (not (string-empty-p text)))
+ (user-error "Lorem-optimum chain is empty; train it with cj/lipsum-learn-file or restore %s"
+ cj/lipsum-default-file))
+ (when (called-interactively-p 'any)
+ (message "%s" text))
+ text))
(defun cj/lipsum-insert (n)
"Insert N words of lorem ipsum at point."
diff --git a/modules/mail-config.el b/modules/mail-config.el
index 297e70d4..b410bf1b 100644
--- a/modules/mail-config.el
+++ b/modules/mail-config.el
@@ -127,8 +127,17 @@ transport details in debug buffers."
"mbsync" "mu4e mail synchronization" 'mail-config)))
(concat (shell-quote-argument mbsync) " -a")))
+(defun cj/mail--send-mail-unavailable (&rest _)
+ "Signal a descriptive error: no mail transport is configured.
+Installed as the send function when msmtp is absent, so a send attempt
+explains itself instead of dying with \"invalid function: nil\" (the
+top-level defvar pre-empts message.el's default)."
+ (user-error "Cannot send mail: msmtp not found -- install msmtp to enable sending"))
+
(defun cj/mail-configure-smtpmail ()
- "Configure SMTP mail transport when msmtp is available."
+ "Configure SMTP mail transport when msmtp is available.
+With msmtp absent, install `cj/mail--send-mail-unavailable' on both send
+variables so the failure at send time names the missing transport."
(setq smtpmail-debug-info cj/smtpmail-debug-enabled)
(if-let ((msmtp (cj/executable-find-or-warn
"msmtp" "SMTP mail sending" 'mail-config)))
@@ -136,7 +145,9 @@ transport details in debug buffers."
send-mail-function 'message-send-mail-with-sendmail
message-send-mail-function 'message-send-mail-with-sendmail
message-sendmail-envelope-from 'header)
- (setq sendmail-program nil)))
+ (setq sendmail-program nil
+ send-mail-function #'cj/mail--send-mail-unavailable
+ message-send-mail-function #'cj/mail--send-mail-unavailable)))
;; -------------------- HarfBuzz Crash Fix: Disable Composition ---------------
;; Disable auto-composition in mu4e headers to prevent SIGSEGV from HarfBuzz
@@ -229,9 +240,6 @@ Prompts user for the action when executing."
(setq mu4e-context-policy 'pick-first) ;; start with the first (default) context
(setq mu4e-headers-auto-update nil) ;; updating headers buffer on email is too jarring
(setq mu4e-root-maildir mail-dir) ;; root directory for all email accounts
- (with-suppressed-warnings ((obsolete mu4e-maildir)
- (free-vars mu4e-maildir))
- (setq mu4e-maildir mail-dir)) ;; same as above (for newer mu4e)
(setq mu4e-sent-messages-behavior 'delete) ;; don't save to "Sent", IMAP does this already
(setq mu4e-show-images t) ;; show embedded images
;; (setq mu4e-update-interval 600) ;; check for new mail every 10 minutes (600 seconds)
@@ -243,9 +251,6 @@ Prompts user for the action when executing."
;; This will be automatically disabled when org-msg is active
(setq mu4e-compose-format-flowed t)
- (with-suppressed-warnings ((obsolete mu4e-html2text-command)
- (free-vars mu4e-html2text-command))
- (setq mu4e-html2text-command 'mu4e-shr2text)) ;; email conversion to html via shr2text
(setq mu4e-mu-binary (executable-find "mu"))
(setq mu4e-get-mail-command (cj/mail--mbsync-command)) ;; command to sync mail
(with-suppressed-warnings ((obsolete mu4e-user-mail-address-list)
@@ -257,22 +262,15 @@ Prompts user for the action when executing."
;; ------------------------------ Mu4e Contexts ------------------------------
+ ;; cmail (cjennings.net) is listed first deliberately: `pick-first' makes it
+ ;; the startup context, matching cmail's primary role everywhere else in the
+ ;; config (shortcuts, bookmarks, refile). Gmail-first here made gmail the
+ ;; silent default for the first compose. (mu4e-starred-folder was dropped:
+ ;; it isn't a mu4e variable, so it never had an effect -- flagged searches
+ ;; use flag:flagged.)
(setq mu4e-contexts
(list
(make-mu4e-context
- :name "gmail.com"
- :match-func
- (lambda (msg)
- (when msg
- (string-prefix-p "/gmail" (mu4e-message-field msg :maildir))))
- :vars '((user-mail-address . "craigmartinjennings@gmail.com")
- (user-full-name . "Craig Jennings")
- (mu4e-drafts-folder . "/gmail/Drafts")
- (mu4e-sent-folder . "/gmail/Sent")
- (mu4e-starred-folder . "/gmail/Starred")
- (mu4e-trash-folder . "/gmail/Trash")))
-
- (make-mu4e-context
:name "cjennings.net"
:match-func
(lambda (msg)
@@ -285,6 +283,18 @@ Prompts user for the action when executing."
(mu4e-trash-folder . "/cmail/Trash")))
(make-mu4e-context
+ :name "gmail.com"
+ :match-func
+ (lambda (msg)
+ (when msg
+ (string-prefix-p "/gmail" (mu4e-message-field msg :maildir))))
+ :vars '((user-mail-address . "craigmartinjennings@gmail.com")
+ (user-full-name . "Craig Jennings")
+ (mu4e-drafts-folder . "/gmail/Drafts")
+ (mu4e-sent-folder . "/gmail/Sent")
+ (mu4e-trash-folder . "/gmail/Trash")))
+
+ (make-mu4e-context
:name "deepsat.com"
:match-func
(lambda (msg)
@@ -294,7 +304,6 @@ Prompts user for the action when executing."
(user-full-name . "Craig Jennings")
(mu4e-drafts-folder . "/dmail/Drafts")
(mu4e-sent-folder . "/dmail/Sent")
- (mu4e-starred-folder . "/dmail/Starred")
(mu4e-trash-folder . "/dmail/Trash")))))
;; Refile target is computed per message (see `cj/mu4e--refile-folder'), not
@@ -348,25 +357,11 @@ Prompts user for the action when executing."
;; ------------------------------ HTML Settings ------------------------------
;; also see org-msg below
-
- ;; Prefer HTML over plain text when both are available
- (with-suppressed-warnings ((obsolete mu4e-view-prefer-html)
- (free-vars mu4e-view-prefer-html))
- (setq mu4e-view-prefer-html t))
-
- ;; Use a better HTML renderer with more control
- (with-suppressed-warnings ((obsolete mu4e-html2text-command)
- (free-vars mu4e-html2text-command))
- (setq mu4e-html2text-command
- (cond
- ;; Best option: pandoc (if available)
- ((executable-find "pandoc")
- "pandoc -f html -t plain --reference-links")
- ;; Good option: w3m (better tables/formatting)
- ((executable-find "w3m")
- "w3m -dump -T text/html -cols 72 -o display_link_number=true")
- ;; Fallback: built-in shr
- (t 'mu4e-shr2text))))
+ ;;
+ ;; The view is shr-based since mu4e 1.7; the old knobs
+ ;; (mu4e-view-prefer-html, mu4e-html2text-command and its pandoc/w3m
+ ;; renderer selection) are obsolete and ignored on 1.14, so they were
+ ;; dropped. HTML display is governed by the shr settings below.
;; Configure shr (built-in HTML renderer) for better display
(setq shr-use-colors nil) ; Don't use colors in terminal
@@ -407,10 +402,12 @@ Echoes the effective state so there's no guessing what a refresh did."
(message "Remote images: %s (this message only)"
(if (equal gnus-blocked-images "http") "blocked" "shown")))
- ;; first letter is the keybinding
+ ;; first letter is the keybinding. No save-attachment action here:
+ ;; mu4e-view-save-attachments reads MIME parts from the view buffer (and
+ ;; takes no message argument), so it cannot work from headers -- open the
+ ;; message and save from the view instead.
(setq mu4e-headers-actions
- '(("asave attachment" . mu4e-view-save-attachments)
- ("csave contact" . mu4e-action-add-org-contact)
+ '(("csave contact" . mu4e-action-add-org-contact)
("ssearch for sender" . cj/search-for-sender)
("tshow this thread" . mu4e-action-show-thread)
("vview in browser" . mu4e-action-view-in-browser)))
@@ -483,12 +480,18 @@ INBOX maildir."
(defun cj/--mail-make-account-map (account)
"Build a mu4e navigation keymap for ACCOUNT (a maildir account name).
Keys i/u/s/l run the inbox/unread/flagged/large searches from
-`cj/--mail-account-search-queries', each scoped to ACCOUNT."
+`cj/--mail-account-search-queries', each scoped to ACCOUNT. Each command
+requires mu4e first: these maps register eagerly at startup, but
+`mu4e-search' has no autoload cookie, so a nav key pressed before mu4e's
+first launch would otherwise signal void-function. With the feature
+loaded, mu4e itself starts the server on demand."
(let ((map (make-sparse-keymap)))
(dolist (entry (cj/--mail-account-search-queries account) map)
(let ((query (cdr entry)))
(keymap-set map (car entry)
- (lambda () (interactive) (mu4e-search query))))))))
+ (lambda () (interactive)
+ (require 'mu4e)
+ (mu4e-search query))))))))
;; ---------------------------------- Org-Msg ----------------------------------
;; user composes org mode; recipient receives html
@@ -577,10 +580,12 @@ Keys i/u/s/l run the inbox/unread/flagged/large searches from
;; turn on org-msg in all compose buffers
(org-msg-mode +1))
-(advice-add #'mu4e-compose-reply
- :after (lambda (&rest _) (org-msg-edit-mode)))
-(advice-add #'mu4e-compose-wide-reply
- :after (lambda (&rest _) (org-msg-edit-mode)))
+;; No reply advice here: org-msg-post-setup runs on mu4e-compose-mode-hook for
+;; every compose (replies included) and applies `org-msg-default-alternatives'
+;; itself. The old unconditional org-msg-edit-mode :after advice on the two
+;; reply commands forced org-msg onto text-only replies, defeating the
+;; (reply-to-text . (text)) alternative above and re-running a major mode
+;; org-msg had already set up.
;; which-key labels
(with-eval-after-load 'which-key
diff --git a/modules/markdown-config.el b/modules/markdown-config.el
index d2cd7f86..1d4a8b74 100644
--- a/modules/markdown-config.el
+++ b/modules/markdown-config.el
@@ -60,9 +60,7 @@ Idempotent: re-running while the server is already up is a no-op."
;; the filter to apply to markdown before impatient-mode pushes it to the server
(defun cj/markdown-preview ()
"Open the current buffer as a live HTML preview at http://localhost:8080/imp.
-Starts the simple-httpd listener itself when it isn't already running
-\(per the 2026-07-01 decision; the earlier separate-start design
-signaled a `user-error' instead)."
+Starts the simple-httpd listener automatically when it isn't already running."
(interactive)
(cj/--markdown-preview-ensure-server)
(impatient-mode 1)
@@ -110,8 +108,21 @@ signaled a `user-error' instead)."
;; stub doesn't collide with this file's own definition of the command
;; (that collision is the "defined multiple times" byte-compile warning).
;; Same key as compile, for consistency.
+(defun cj/markdown-toggle-view ()
+ "Toggle the current Markdown buffer between edit and read-only view.
+Handles the gfm variants too. The cond checks the most-derived mode
+first, since the view modes derive from their edit modes."
+ (interactive)
+ (cond
+ ((derived-mode-p 'markdown-view-mode) (markdown-mode))
+ ((derived-mode-p 'gfm-view-mode) (gfm-mode))
+ ((derived-mode-p 'gfm-mode) (gfm-view-mode))
+ ((derived-mode-p 'markdown-mode) (markdown-view-mode))))
+
(with-eval-after-load 'markdown-mode
- (keymap-set markdown-mode-map "<f2>" #'cj/markdown-preview))
+ (keymap-set markdown-mode-map "<f2>" #'cj/markdown-preview)
+ (keymap-set markdown-mode-map "C-c C-x v" #'cj/markdown-toggle-view)
+ (keymap-set markdown-view-mode-map "C-c C-x v" #'cj/markdown-toggle-view))
(provide 'markdown-config)
;;; markdown-config.el ends here
diff --git a/modules/media-utils.el b/modules/media-utils.el
index 1abbc1b2..7047411f 100644
--- a/modules/media-utils.el
+++ b/modules/media-utils.el
@@ -119,8 +119,67 @@ stream URL (see the :needs-stream-url flag in `cj/media-players')."
;; ---------------------- Playing Via Default Media Player ---------------------
+(defun cj/media--yt-dlp-argv (url formats)
+ "The argv to resolve URL's stream address: yt-dlp [-f FORMATS] -g URL.
+FORMATS is a prioritized list of yt-dlp format codes, or nil for the
+default. URL stays one verbatim argv element, so it never meets a shell."
+ (append (list "yt-dlp")
+ (when formats (list "-f" (string-join formats "/")))
+ (list "-g" url)))
+
+(defun cj/media--stream-urls (output)
+ "The non-empty lines of yt-dlp -g OUTPUT, surrounding whitespace trimmed."
+ (split-string output "\n" t "[ \t\r]+"))
+
+(defun cj/media--play-argv (command args urls)
+ "The argv to play URLS with COMMAND.
+ARGS is the player's raw option string from `cj/media-players' (nil for
+none); it splits with `split-string-and-unquote' so a quoted option
+survives as one word."
+ (append (list command)
+ (and args (split-string-and-unquote args))
+ urls))
+
+(defun cj/media--resolve-stream-urls (url formats)
+ "Resolve URL to direct stream URLs with a synchronous yt-dlp -g capture.
+FORMATS is the player's format-preference list. Only stdout is parsed
+for URLs -- yt-dlp's warnings go to stderr, captured separately for the
+error message. Signals an error when yt-dlp exits non-zero or resolves
+nothing."
+ (let ((err-file (make-temp-file "yt-dlp-stderr")))
+ (unwind-protect
+ (with-temp-buffer
+ (let* ((argv (cj/media--yt-dlp-argv url formats))
+ (exit (apply #'call-process (car argv) nil
+ (list t err-file) nil (cdr argv))))
+ (unless (and (integerp exit) (zerop exit))
+ (error "yt-dlp failed (exit %s): %s" exit
+ (string-trim
+ (with-temp-buffer
+ (insert-file-contents err-file)
+ (buffer-string)))))
+ (or (cj/media--stream-urls (buffer-string))
+ (error "yt-dlp resolved no stream URL for %s" url))))
+ (delete-file err-file))))
+
+(defun cj/media--play-sentinel (url-display)
+ "A process sentinel reporting playback of URL-DISPLAY.
+Messages success or failure and reaps the process buffer once the
+player finishes or exits."
+ (lambda (proc event)
+ (cond
+ ((string-match-p "finished" event)
+ (message "✓ Finished playing: %s" url-display))
+ ((string-match-p "exited abnormally" event)
+ (message "✗ Playback failed: %s" url-display)))
+ (when (string-match-p "finished\\|exited" event)
+ (kill-buffer (process-buffer proc)))))
+
(defun cj/media-play-it (url)
- "Play the URL with the configured media player in an async process."
+ "Play the URL with the configured media player in an async process.
+A player flagged :needs-stream-url gets the URL resolved first via a
+synchronous yt-dlp -g capture (blocks briefly); the player then launches
+with a plain argv list -- no shell anywhere in the pipeline."
(let* ((player-config (alist-get cj/default-media-player cj/media-players))
(command (plist-get player-config :command))
(args (plist-get player-config :args))
@@ -131,56 +190,52 @@ stream URL (see the :needs-stream-url flag in `cj/media-players')."
(unless (executable-find command)
(error "%s is not installed or not in PATH" player-name))
+ (when needs-stream-url
+ (unless (executable-find "yt-dlp")
+ (error "The program yt-dlp is not installed or not in PATH")))
- (let* ((buffer-name (format "*%s: %s*" player-name url-display))
- (shell-command
- (if needs-stream-url
- ;; Use shell substitution with yt-dlp
- (let ((format-string (if yt-dlp-formats
- (format "-f %s"
- (mapconcat #'shell-quote-argument
- yt-dlp-formats
- "/"))
- "")))
- (format "%s %s $(%s %s -g %s)"
- command
- (or args "")
- "yt-dlp"
- format-string
- (shell-quote-argument url)))
- ;; Direct playback without yt-dlp
- (format "%s %s %s"
- command
- (or args "")
- (shell-quote-argument url)))))
+ (let* ((urls (if needs-stream-url
+ (progn
+ (message "Resolving stream URL: %s" url-display)
+ (cj/media--resolve-stream-urls url yt-dlp-formats))
+ (list url)))
+ (argv (cj/media--play-argv command args urls))
+ (buffer-name (format "*%s: %s*" player-name url-display)))
(message "Playing with %s: %s" player-name url-display)
- (cj/log-silently "DEBUG: Executing: %s" shell-command)
-
- (let ((process (start-process-shell-command
- player-name
- buffer-name
- shell-command)))
- (set-process-sentinel
- process
- (lambda (proc event)
- (cond
- ((string-match-p "finished" event)
- (message "✓ Finished playing: %s" url-display))
- ((string-match-p "exited abnormally" event)
- (message "✗ Playback failed: %s" url-display)
- (with-current-buffer (process-buffer proc)
- (goto-char (point-min))
- (when (re-search-forward "ERROR:" nil t)
- (cj/log-silently "DEBUG: yt-dlp error: %s"
- (buffer-substring-no-properties
- (line-beginning-position)
- (line-end-position)))))))
- (when (string-match-p "finished\\|exited" event)
- (kill-buffer (process-buffer proc)))))))))
+ (cj/log-silently "DEBUG: Executing: %s" (string-join argv " "))
+
+ (let ((process (apply #'start-process player-name buffer-name argv)))
+ (set-process-sentinel process (cj/media--play-sentinel url-display))))))
;; ------------------------- Media-Download Via yt-dlp -------------------------
+(defun cj/media--yt-dl-message (event url-display)
+ "Return the message for tsp EVENT on URL-DISPLAY, or nil when it reports nothing.
+
+Reports queueing, not completion, and the distinction is the point.
+`cj/yt-dl-it' launches \"tsp yt-dlp ...\", and tsp enqueues the job and
+exits immediately, so this sentinel fires on tsp's exit rather than
+yt-dlp's. A clean exit proves the job was accepted by the spooler and
+nothing more, so claiming the download finished would be a guess that is
+wrong whenever yt-dlp fails minutes later. Check the spooler with
+\"tsp\" for real download status."
+ (cond
+ ((string-match-p "finished" event)
+ (format "✓ Queued for download: %s" url-display))
+ ((string-match-p "exited abnormally" event)
+ (format "✗ Could not queue download: %s" url-display))))
+
+(defun cj/media--yt-dl-sentinel (url-display)
+ "A process sentinel reporting the queueing of URL-DISPLAY.
+Messages per `cj/media--yt-dl-message' and reaps the process buffer once
+tsp finishes or exits."
+ (lambda (proc event)
+ (when-let ((msg (cj/media--yt-dl-message event url-display)))
+ (message "%s" msg))
+ (when (string-match-p "finished\\|exited" event)
+ (kill-buffer (process-buffer proc)))))
+
(defun cj/yt-dl-it (url)
"Downloads the URL in an async shell."
(unless (executable-find "yt-dlp")
@@ -194,16 +249,8 @@ stream URL (see the :needs-stream-url flag in `cj/media-players')."
(process (start-process "yt-dlp" buffer-name
"tsp" "yt-dlp" "--add-metadata" "-ic"
"-o" output-template url)))
- (message "Started download: %s" url-display)
- (set-process-sentinel process
- (lambda (proc event)
- (cond
- ((string-match-p "finished" event)
- (message "✓ Finished downloading: %s" url-display))
- ((string-match-p "exited abnormally" event)
- (message "✗ Download failed: %s" url-display)))
- (when (string-match-p "finished\\|exited" event)
- (kill-buffer (process-buffer proc)))))))
+ (message "Queueing download: %s" url-display)
+ (set-process-sentinel process (cj/media--yt-dl-sentinel url-display))))
(provide 'media-utils)
;;; media-utils.el ends here.
diff --git a/modules/mu4e-attachments.el b/modules/mu4e-attachments.el
index 6c2be6fb..56b96b87 100644
--- a/modules/mu4e-attachments.el
+++ b/modules/mu4e-attachments.el
@@ -100,11 +100,21 @@ size; an unknown candidate annotates as nil so marginalia shows nothing."
(require 'mu4e-mime-parts)))
(defun cj/mu4e--save-attachment-part (part directory)
- "Save attachment PART to DIRECTORY and return the final path."
+ "Save attachment PART to DIRECTORY and return the final path.
+Signals a `user-error' when PART's MIME handle is stale: a handle's car
+is the buffer holding the part's bytes, and viewing another message kills
+it, so saving through it would error deep in mm-decode or write another
+message's content. The staleness check runs before
+`cj/mu4e--ensure-attachment-save-functions', like the no-handle check."
(let ((handle (plist-get part :handle)))
(unless handle
(user-error "Attachment has no MIME handle: %s"
(or (plist-get part :filename) "<unnamed>")))
+ (when (and (consp handle)
+ (bufferp (car handle))
+ (not (buffer-live-p (car handle))))
+ (user-error "Attachment %s is stale (the message view changed) -- reopen the message and save again"
+ (or (plist-get part :filename) "<unnamed>")))
(cj/mu4e--ensure-attachment-save-functions)
(let* ((path (funcall mu4e-uniquify-save-file-name-function
(mu4e-join-paths directory
diff --git a/modules/mu4e-org-contacts-integration.el b/modules/mu4e-org-contacts-integration.el
index 6062b8cf..a143bdc4 100644
--- a/modules/mu4e-org-contacts-integration.el
+++ b/modules/mu4e-org-contacts-integration.el
@@ -22,6 +22,7 @@
;; isolation doesn't warn about free variables / undefined functions; the
;; actual definitions live where named.
(eval-when-compile (defvar contacts-file)) ; user-constants.el
+(defvar mu4e-compose-complete-addresses) ; mu4e-compose.el (lazy)
(declare-function cj/get-all-contact-emails ; org-contacts-config.el
"org-contacts-config" ())
@@ -59,13 +60,10 @@ In email header fields (To, Cc, Bcc), complete using org-contacts.
Elsewhere, perform the default TAB action."
(interactive)
(cond
- ;; In email header fields, use completion-at-point
+ ;; In email header fields, use completion-at-point (it both starts a new
+ ;; completion and cycles an in-progress one, so no mode check is needed).
((mail-abbrev-in-expansion-header-p)
- (if (and (boundp 'completion-in-region-mode) completion-in-region-mode)
- ;; If we're already in completion mode, cycle through candidates
- (completion-at-point)
- ;; Start new completion
- (completion-at-point)))
+ (completion-at-point))
;; In org-msg-edit-mode body, use org-cycle
((and (eq major-mode 'org-msg-edit-mode)
(not (mail-abbrev-in-expansion-header-p)))
@@ -157,10 +155,10 @@ This bypasses the completion-at-point system for direct selection."
;; Setup hooks for org-msg-edit-mode (HTML email composition)
(with-eval-after-load 'org-msg
(add-hook 'org-msg-edit-mode-hook #'cj/mu4e-org-contacts-compose-setup))
-
- ;; Remove any existing mu4e completion setup
- (remove-hook 'mu4e-compose-mode-hook #'mu4e--compose-setup-completion)
-
+
+ ;; No hook surgery on mu4e--compose-setup-completion: mu4e 1.14 calls it
+ ;; directly during compose setup (never via this hook), and it is already
+ ;; gated by the `mu4e-compose-complete-addresses' nil above.
(message "mu4e org-contacts integration activated"))
(defun cj/deactivate-mu4e-org-contacts-integration ()
@@ -170,11 +168,11 @@ This bypasses the completion-at-point system for direct selection."
;; Remove our hooks
(remove-hook 'mu4e-compose-mode-hook #'cj/mu4e-org-contacts-compose-setup)
(remove-hook 'org-msg-edit-mode-hook #'cj/mu4e-org-contacts-compose-setup)
-
- ;; Re-enable mu4e's built-in completion if desired
+
+ ;; Re-enable mu4e's built-in completion: the var is enough, since mu4e's
+ ;; compose setup calls its completion function directly, gated on this.
(setq mu4e-compose-complete-addresses t)
- (add-hook 'mu4e-compose-mode-hook #'mu4e--compose-setup-completion)
-
+
(message "mu4e org-contacts integration deactivated"))
(provide 'mu4e-org-contacts-integration)
diff --git a/modules/music-config.el b/modules/music-config.el
index d16e2bb2..233bae72 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -16,13 +16,20 @@
;;
;; The playlist keymap intentionally follows ncmpcpp where it maps cleanly, with
;; EMMS-specific additions for M3U editing and consume mode.
+;;
+;; The player has two render paths. In a graphical frame with `cj/music-fancy-ui'
+;; on (the default), it draws the fancy hi-fi surface: a now-playing hero with
+;; cover art (station favicon / sibling album art / a shipped vinyl placeholder,
+;; cached under data/music-art/), a serif title, and a block progress bar that
+;; advances from mpv's percent-pos while a file plays. A TTY frame, or the
+;; toggle off, falls back to the plain text player (names, a dim glyph, a thin
+;; status line). `cj/music-clear-art-cache' empties the art cache.
;;; Code:
(require 'subr-x)
(require 'user-constants)
(require 'keybindings) ;; provides cj/custom-keymap
-(require 'cj-window-geometry-lib) ;; cj/preferred-dock-direction (F10 dock side)
(require 'cj-window-toggle-lib) ;; side-window size memory (F10 toggle)
(require 'system-lib) ;; cj/confirm-strong (overwrite confirms)
@@ -30,7 +37,6 @@
;; compiles as a dynamic bind, not a dead lexical local -- otherwise emms /
;; orderless never see the binding (the lexical-binding foreign-special-var trap).
(defvar orderless-smart-case)
-(defvar emms-source-playlist-ask-before-overwrite)
(defvar emms-playlist-buffer-p)
(defvar emms-playlist-buffer)
(defvar emms-random-playlist)
@@ -56,6 +62,46 @@
(defface cj/music-keyhint-face '((t :inherit shadow))
"Key hints in the playlist header.")
+;; Fancy-render faces (Phase 3). Amber comes from the themed `warning' face so
+;; the active theme (dupre) owns the color; the serif family is applied at
+;; render time from `cj/music-title-family'.
+(defface cj/music-title-face '((t :inherit cj/music-header-value-face :weight bold))
+ "Now-playing title in the fancy player.")
+(defface cj/music-subtitle-face '((t :inherit shadow))
+ "Now-playing subtitle (station or album) in the fancy player.")
+(defface cj/music-bar-fill-face '((t :inherit warning))
+ "Filled portion of the fancy progress bar (amber).")
+(defface cj/music-bar-empty-face '((t :inherit shadow))
+ "Empty portion of the fancy progress bar.")
+
+(defgroup cj/music nil
+ "Personal EMMS music-player tweaks."
+ :group 'emms)
+
+(defcustom cj/music-fancy-ui t
+ "When non-nil and the frame is graphical, render the fancy hi-fi player:
+cover art, a serif now-playing hero, and a progress bar. Nil, or a TTY frame,
+falls back to the plain text player (names, a dim glyph, a thin status line)."
+ :type 'boolean
+ :group 'cj/music)
+
+(defcustom cj/music-title-family
+ (if (fboundp 'cj/font-profile-properties)
+ (plist-get (cj/font-profile-properties 'reading) :default-family)
+ "Merriweather")
+ "Serif family for the fancy now-playing title, mirroring the nov reading view."
+ :type 'string
+ :group 'cj/music)
+
+(defvar cj/music-hero-size 96
+ "Pixel height of the now-playing hero cover image.")
+(defvar cj/music-thumb-size 22
+ "Pixel height of a playlist row's cover thumbnail.")
+(defvar cj/music-bar-width 24
+ "Cell width of the now-playing progress bar.")
+(defvar cj/music-bar-interval 1
+ "Seconds between progress-bar redraws while a track is playing and visible.")
+
;; Foreign functions used lazily after their packages load.
(declare-function emms-playlist-mode "emms-playlist-mode")
(declare-function emms-playlist-track-at "emms-playlist-mode")
@@ -63,11 +109,15 @@
(declare-function emms-track-name "emms")
(declare-function emms-track-type "emms")
(declare-function emms-track-get "emms")
+(declare-function emms-track "emms")
+(declare-function emms-track-set "emms")
(declare-function emms-track-simple-description "emms")
(declare-function emms-playlist-current-selected-track "emms")
(declare-function emms-playlist-select "emms")
+(declare-function emms-playlist-selected-track "emms")
(declare-function emms-playlist-clear "emms")
-(declare-function emms-playlist-save "emms-source-playlist")
+(declare-function emms-playlist-insert-track "emms")
+(declare-function emms-stop "emms")
(declare-function emms-start "emms")
(declare-function emms-random "emms")
(declare-function emms-next "emms")
@@ -82,7 +132,19 @@
"Root directory of your music collection.")
(defvar cj/music-m3u-root cj/music-root
- "Directory where M3U playlists are saved and loaded.")
+ "Directory M3U playlists are saved to (the single writable target).
+Reading and selection union `cj/music-m3u-roots'; only saving and radio-station
+creation write here.")
+
+(defvar cj/music-m3u-roots
+ (list cj/music-root
+ (expand-file-name "~/.local/share/mpd/playlists/"))
+ "Directories to source M3U playlists from, in precedence order.
+Both the local-library playlists (`cj/music-root') and the dotfiles-tracked
+internet-radio playlists (MPD's playlist_directory) surface together for
+selection and loading. Earlier directories win on a basename collision.
+Missing directories are skipped. Saving still targets the single
+`cj/music-m3u-root'.")
(defvar cj/music-file-extensions '("aac" "flac" "m4a" "mp3" "ogg" "opus" "wav")
"List of valid music file extensions.")
@@ -167,6 +229,30 @@ A no-op when nothing is playing or the socket is gone, so it never errors."
(accept-process-output proc 0.1))
(delete-process proc))))))
+(defun cj/music--mpv-get-property (prop)
+ "Query the mpv IPC socket for PROP and return its value, or nil.
+Reads the reply (unlike `cj/music--mpv-command', which only sends), so the
+progress bar can read percent-pos. Blocks briefly, so call it off redisplay."
+ (when (file-exists-p cj/music--mpv-socket)
+ (ignore-errors
+ (let ((out "") proc)
+ (setq proc (make-network-process
+ :name "cj-music-mpv-get" :family 'local
+ :service cj/music--mpv-socket :noquery t
+ :filter (lambda (_p s) (setq out (concat out s)))))
+ (unwind-protect
+ (progn
+ (process-send-string
+ proc (format "{\"command\":[\"get_property\",\"%s\"]}\n" prop))
+ (accept-process-output proc 0.2)
+ (cl-loop for line in (split-string out "\n" t)
+ for obj = (ignore-errors
+ (json-parse-string line :object-type 'plist
+ :null-object nil))
+ when (and obj (plist-member obj :data))
+ return (plist-get obj :data)))
+ (delete-process proc))))))
+
(defun cj/music-seek-forward ()
"Seek `cj/music-seek-seconds' seconds forward in the current track."
(interactive)
@@ -222,7 +308,9 @@ Directories are suffixed with /; files are plain. Hidden dirs/files skipped."
"Completion table for CANDIDATES preserving order and case-insensitive match.
Tags the `cj-music-file' category and annotates each candidate (a path relative
to `cj/music-root', with a trailing slash for directories) with its size and
-modification date so marginalia can show them."
+modification date so marginalia can show them. The category is registered
+with marginalia (builtin) so the annotations render right-aligned."
+ (cj/completion-ensure-marginalia-align 'cj-music-file)
(let ((annotate (cj/completion-file-annotator
(lambda (c)
(expand-file-name
@@ -238,19 +326,177 @@ modification date so marginalia can show them."
(completion-ignore-case . t))
(complete-with-action action candidates string pred)))))
+(defun cj/music--playlist-open-position (buffer)
+ "Return where point should land when the playlist BUFFER is displayed.
+The beginning of the playing track's line when a song is playing (during
+playback the selected track is the playing one), else the top of the
+list. Keying off the selected track alone is wrong: EMMS keeps a stale
+selection while stopped, which used to open the playlist deep in the list
+at whatever played last."
+ (with-current-buffer buffer
+ (if (and (boundp 'emms-player-playing-p) emms-player-playing-p
+ (boundp 'emms-playlist-selected-marker)
+ (markerp emms-playlist-selected-marker)
+ (marker-position emms-playlist-selected-marker)
+ (eq (marker-buffer emms-playlist-selected-marker) (current-buffer)))
+ (save-excursion
+ (goto-char emms-playlist-selected-marker)
+ (line-beginning-position))
+ (point-min))))
+
+(defun cj/music--playlist-land-point (win buffer)
+ "Move WIN's point in BUFFER per the open-position rule and settle the view.
+When a song is playing its row lands in the window's upper third, so the
+upcoming tracks fill the space below it. When stopped, the view starts at
+the top of the list. Point sits at the beginning of its line either way,
+so the row reads left-to-right from its number."
+ (let ((pos (cj/music--playlist-open-position buffer)))
+ (set-window-point win pos)
+ (if (> pos (with-current-buffer buffer (point-min)))
+ (with-selected-window win
+ (with-current-buffer buffer
+ (recenter (max 1 (/ (window-body-height) 3)))))
+ (set-window-start win pos))))
+
+(defun cj/music--pin-point-to-bol ()
+ "Keep the playlist cursor in the number gutter (column 0).
+The rows are rendered track lines, not editable text: the cursor's home is
+the number, and operations on a track (kill, shift, play) act on its row
+wherever point sits. Vertical motion over thumbnails and the stretch-space
+that right-aligns the metadata drifts point to arbitrary visual columns
+(usually line end), so this runs on the buffer-local `post-command-hook'
+and snaps every landing back to the row start. An active isearch owns
+point until it ends; the snap lands when the search exits."
+ (unless (or (bolp) (bound-and-true-p isearch-mode))
+ (beginning-of-line))
+ (cj/music--highlight-current-number))
+
+(defvar-local cj/music--renumber-timer nil
+ "Pending idle timer for the playlist row renumber, or nil.")
+
+;; Forward declaration: the real `defvar-local' is a few defuns below, next to
+;; the highlight helper that owns it. Declared special here so the setq in this
+;; function compiles as a dynamic binding, not a free-variable warning.
+(defvar cj/music--current-number-overlay)
+
+(defun cj/music--renumber-rows (&optional buffer)
+ "Number every playlist row in BUFFER (default: current buffer) via overlays.
+Each non-blank line gets an \"NNN \" before-string so the cursor stays
+visible when it sits on a cover-art thumbnail and the row's position in
+the list is readable at a glance. Overlays rebuild from scratch, so the
+numbering survives kills, inserts, and reorders; the buffer text itself is
+untouched (EMMS owns it). A dead BUFFER is a silent no-op, since the
+debounce timer can outlive the playlist buffer."
+ (let ((buf (or buffer (current-buffer))))
+ (when (buffer-live-p buf)
+ (with-current-buffer buf
+ (remove-overlays (point-min) (point-max) 'cj-music-row-number t)
+ (save-excursion
+ (goto-char (point-min))
+ (let ((n 0))
+ (while (not (eobp))
+ (unless (looking-at-p "[ \t]*$")
+ (setq n (1+ n))
+ ;; Span one char rather than zero: `overlays-in' (and so
+ ;; `remove-overlays') can miss an empty overlay sitting
+ ;; exactly at the region start.
+ (let ((ov (make-overlay (line-beginning-position)
+ (1+ (line-beginning-position)))))
+ (overlay-put ov 'cj-music-row-number t)
+ ;; Outrank the header overlay (priority 100): both anchor
+ ;; strings at position 1 on row 1, and without this the
+ ;; row's number renders above the header block instead of
+ ;; beside its own track.
+ (overlay-put ov 'priority 200)
+ (overlay-put ov 'before-string
+ (cj/music--number-string (format "%3d " n) nil))))
+ (forward-line 1))))
+ ;; The rebuild deleted the marked overlay; re-mark the current row.
+ (setq cj/music--current-number-overlay nil)
+ (cj/music--highlight-current-number)))))
+
+(defvar-local cj/music--current-number-overlay nil
+ "The number overlay currently rendered as the you-are-here mark, or nil.")
+
+(defun cj/music--number-string (text current)
+ "Build the number-gutter display string from TEXT.
+CURRENT non-nil renders it inverse video (the you-are-here mark). The
+single place the gutter string's properties live: the face, and the
+cursor property that makes redisplay draw the cursor on the number
+instead of invisibly on the album art after it."
+ (propertize text
+ 'face (if current
+ '(:inherit cj/music-keyhint-face :inverse-video t)
+ 'cj/music-keyhint-face)
+ 'cursor t))
+
+(defun cj/music--set-number-face (ov current)
+ "Re-render number overlay OV's string; CURRENT non-nil marks it inverse.
+Keeps the text, swaps only the rendering (see `cj/music--number-string')."
+ (let ((s (overlay-get ov 'before-string)))
+ (overlay-put ov 'before-string
+ (cj/music--number-string (substring-no-properties s) current))))
+
+(defun cj/music--highlight-current-number ()
+ "Render the current row's number in inverse video, restoring the last one.
+The block cursor draws only in the selected window, and the playlist dock
+is glanced at from other windows constantly, so the number itself carries
+the you-are-here mark -- visible whether or not the window has focus."
+ (let ((ov (seq-find (lambda (o) (overlay-get o 'cj-music-row-number))
+ (overlays-in (line-beginning-position)
+ (min (1+ (line-beginning-position)) (point-max))))))
+ (unless (eq ov cj/music--current-number-overlay)
+ (when (and (overlayp cj/music--current-number-overlay)
+ (overlay-buffer cj/music--current-number-overlay))
+ (cj/music--set-number-face cj/music--current-number-overlay nil))
+ (setq cj/music--current-number-overlay ov)
+ (when ov
+ (cj/music--set-number-face ov t)))))
+
+(defun cj/music--schedule-renumber (&rest _)
+ "Debounced renumber of the current playlist buffer after a text change.
+Wired buffer-locally into `after-change-functions' by
+`cj/music--ensure-playlist-buffer'; the idle delay coalesces a burst of
+inserts or kills into one renumber pass."
+ (when (timerp cj/music--renumber-timer)
+ (cancel-timer cj/music--renumber-timer))
+ (setq cj/music--renumber-timer
+ (run-with-idle-timer 0.2 nil #'cj/music--renumber-rows (current-buffer))))
+
(defun cj/music--ensure-playlist-buffer ()
"Ensure EMMS playlist buffer exists and is in playlist mode. Return buffer."
(let ((buffer (get-buffer-create cj/music-playlist-buffer-name)))
(with-current-buffer buffer
(unless (eq major-mode 'emms-playlist-mode)
(emms-playlist-mode))
- (setq emms-playlist-buffer-p t))
+ (setq emms-playlist-buffer-p t)
+ ;; Row numbering: renumber after every playlist change, debounced.
+ (add-hook 'after-change-functions #'cj/music--schedule-renumber nil t)
+ ;; The highlighted row stays findable even when the cursor sits on
+ ;; album art (pairs with the row-number prefixes).
+ (hl-line-mode 1)
+ ;; Gutter cursor: point lives at the row start (the number column).
+ (add-hook 'post-command-hook #'cj/music--pin-point-to-bol nil t)
+ ;; Logical-line motion: the multi-line header overlay string at
+ ;; position 1 otherwise absorbs next-line from the top row (vertical
+ ;; motion walks the header's screen lines, which all map back to the
+ ;; same buffer position, so arrows look dead). Rows are one logical
+ ;; line each; visual movement buys nothing here.
+ (setq-local line-move-visual nil)
+ ;; Sticky header: re-anchor the header block at the window start on
+ ;; every scroll, so it stays frozen while the list scrolls under it.
+ (add-hook 'window-scroll-functions #'cj/music--stick-header nil t))
+ (cj/music--renumber-rows buffer)
;; Set this as the current EMMS playlist buffer
(setq emms-playlist-buffer buffer)
buffer))
(defun cj/music--m3u-file-tracks (m3u-file)
- "Return list of absolute track paths from M3U-FILE. Ignore # comment lines."
+ "Return list of absolute track paths from M3U-FILE. Ignore # comment lines.
+Stream URLs pass through untouched; a local path must carry an accepted
+music extension (`cj/music--valid-file-p') -- old playlists saved before
+directory adds were filtered can carry cover.jpg lines, and loading one
+would put the cover right back in the playlist."
(when (and m3u-file (file-exists-p m3u-file))
(with-temp-buffer
(insert-file-contents m3u-file)
@@ -260,39 +506,56 @@ modification date so marginalia can show them."
(while (re-search-forward "^[^#].*$" nil t)
(let ((line (string-trim (match-string 0))))
(unless (string-empty-p line)
- (push (if (or (file-name-absolute-p line)
- (string-match-p "\\`\\(https?\\|mms\\)://" line))
- line
- (expand-file-name line dir))
- tracks))))
+ (let* ((url-p (string-match-p "\\`\\(https?\\|mms\\)://" line))
+ (path (cond (url-p line)
+ ((file-name-absolute-p line) line)
+ (t (expand-file-name line dir)))))
+ (when (or url-p (cj/music--valid-file-p path))
+ (push path tracks))))))
(nreverse tracks)))))
-(defun cj/music--playlist-tracks ()
- "Return list of track names from current EMMS playlist buffer."
+(defun cj/music--playlist-track-objects ()
+ "Return the track objects from the current EMMS playlist buffer, in order."
(let ((tracks '()))
(with-current-buffer (cj/music--ensure-playlist-buffer)
(save-excursion
(goto-char (point-min))
(while (not (eobp))
(when-let ((track (emms-playlist-track-at (point))))
- (push (emms-track-name track) tracks))
+ (push track tracks))
(forward-line 1))))
(nreverse tracks)))
+(defun cj/music--playlist-tracks ()
+ "Return list of track names from current EMMS playlist buffer."
+ (mapcar #'emms-track-name (cj/music--playlist-track-objects)))
+
+(defun cj/music--dedup-m3u-files (paths)
+ "Return (BASENAME . PATH) conses for PATHS, first occurrence of a basename winning.
+Pure helper: since `cj/music--get-m3u-files' scans `cj/music-m3u-roots' in order,
+an earlier directory shadows a same-named playlist in a later one."
+ (let ((seen (make-hash-table :test 'equal))
+ (result '()))
+ (dolist (p paths (nreverse result))
+ (let ((base (file-name-nondirectory p)))
+ (unless (gethash base seen)
+ (puthash base t seen)
+ (push (cons base p) result))))))
+
(defun cj/music--get-m3u-files ()
- "Return list of (BASENAME . FULLPATH) conses for M3Us in cj/music-m3u-root."
- (let ((files (directory-files cj/music-m3u-root t "\\.m3u\\'" t)))
- (mapcar (lambda (f) (cons (file-name-nondirectory f) f)) files)))
+ "Return (BASENAME . FULLPATH) conses for M3Us across `cj/music-m3u-roots'.
+Directories are scanned in order and missing ones skipped; on a basename
+collision the earlier directory wins."
+ (cj/music--dedup-m3u-files
+ (cl-loop for dir in cj/music-m3u-roots
+ when (file-directory-p dir)
+ append (directory-files dir t "\\.m3u\\'" t))))
(defun cj/music--get-m3u-basenames ()
- "Return list of M3U basenames (no extension) in cj/music-m3u-root."
+ "Return list of M3U basenames (no extension) across `cj/music-m3u-roots'."
(mapcar (lambda (pair) (file-name-sans-extension (car pair)))
(cj/music--get-m3u-files)))
-(defun cj/music--safe-filename (name)
- "Return NAME made filesystem-safe by replacing bad chars with underscores."
- (replace-regexp-in-string "[^a-zA-Z0-9_-]" "_" name))
-
(defun cj/music--playlist-modified-p ()
"Return non-nil if current playlist differs from its associated M3U file."
(and cj/music-playlist-file
@@ -312,11 +575,12 @@ Signals user-error if missing or deleted."
(file-name-nondirectory cj/music-playlist-file))))))
(defun cj/music--assert-m3u-files-exist ()
- "Assert that M3U files exist in cj/music-m3u-root.
+ "Assert that M3U files exist across `cj/music-m3u-roots'.
Returns the list of (BASENAME . FULLPATH) conses. Signals user-error if none."
(let ((files (cj/music--get-m3u-files)))
(when (null files)
- (user-error "No M3U files found in %s" cj/music-m3u-root))
+ (user-error "No M3U files found in %s"
+ (string-join cj/music-m3u-roots ", ")))
files))
(defun cj/music--sync-playlist-file (file-path)
@@ -334,17 +598,51 @@ Returns the full path to the selected file, or nil if cancelled."
(unless (string= choice "(Cancel)")
(cdr (assoc choice m3u-files)))))
+(defun cj/music--delete-playlist-file (path)
+ "Delete the playlist file at PATH.
+Signals a `user-error' when PATH is nil or missing. When the playlist
+buffer's associated file is PATH, the association is cleared (the in-memory
+queue is untouched). Refreshes the radio metadata cache since an .m3u just
+left the roots."
+ (unless (and path (file-exists-p path))
+ (user-error "Playlist file does not exist: %s"
+ (if path (file-name-nondirectory path) "nil")))
+ (delete-file path)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (when (equal cj/music-playlist-file path)
+ (setq cj/music-playlist-file nil)))
+ (cj/music--refresh-radio-name-map))
+
;;; Commands: add/select
+(defun cj/music--music-files-recursive (directory)
+ "Return sorted absolute paths of the music files under DIRECTORY.
+Only files passing `cj/music--valid-file-p' (the accepted extensions in
+`cj/music-file-extensions') come back; hidden files and hidden
+directories are skipped. This is the filter the directory-add commands
+route through -- handing the raw tree to EMMS added every file it found,
+so cover art and liner notes ended up as playlist rows."
+ (sort (seq-filter #'cj/music--valid-file-p
+ (directory-files-recursively
+ directory "\\`[^.]" nil
+ (lambda (dir)
+ (not (string-prefix-p "." (file-name-nondirectory dir))))))
+ #'string-lessp))
+
(defun cj/music-add-directory-recursive (directory)
- "Add all music files under DIRECTORY recursively to the EMMS playlist."
+ "Add all music files under DIRECTORY recursively to the EMMS playlist.
+Only files with accepted music extensions are added; cover art and other
+non-music files in album directories stay out."
(interactive
(list (read-directory-name "Add directory recursively: " cj/music-root nil t)))
(unless (file-directory-p directory)
(user-error "Not a directory: %s" directory))
(cj/music--ensure-playlist-buffer)
- (emms-add-directory-tree directory)
- (message "Added recursively: %s" directory))
+ (let ((files (cj/music--music-files-recursive directory)))
+ (dolist (f files)
+ (emms-add-file f))
+ (message "Added %d music file%s from %s"
+ (length files) (if (= (length files) 1) "" "s") directory)))
(defun cj/music-fuzzy-select-and-add ()
@@ -438,23 +736,107 @@ Replaces current playlist."
(message "Loaded playlist: %s" choice-name)))
+(defun cj/music--m3u-track-lines (track entries)
+ "The .m3u lines for TRACK.
+A file track is its bare absolute path. A url track carries its station
+metadata — an #EXTINF label plus #RADIOBROWSERUUID / #RADIOBROWSERFAVICON when
+known — read from the track's properties first, then its ENTRIES metadata (a
+loaded legacy playlist has entries but no properties), so a saved station
+keeps its display name and cover art on reload."
+ (let ((name (emms-track-name track)))
+ (if (not (eq (emms-track-type track) 'url))
+ (concat name "\n")
+ (let* ((meta (cdr (assoc name entries)))
+ (label (or (emms-track-get track 'info-title)
+ (plist-get meta :name)
+ (cj/music--tidy-host name)))
+ (uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get meta :uuid)))
+ (favicon (or (emms-track-get track 'radio-favicon)
+ (plist-get meta :favicon))))
+ (concat
+ (if (and (stringp uuid) (not (string-empty-p uuid)))
+ (format "#RADIOBROWSERUUID:%s\n" uuid)
+ "")
+ (if (and (stringp favicon) (not (string-empty-p favicon)))
+ (format "#RADIOBROWSERFAVICON:%s\n"
+ (replace-regexp-in-string "[\r\n]+" " " favicon))
+ "")
+ (format "#EXTINF:-1,%s\n"
+ (replace-regexp-in-string "[\r\n]+" " " label))
+ name "\n")))))
+
+(defun cj/music--m3u-text (tracks entries)
+ "The full .m3u file text for TRACKS, station metadata from ENTRIES.
+The stock EMMS m3u writer emits bare URLs; this emitter writes the comment
+lines `cj/music--m3u-entries' parses, so save -> load round-trips."
+ (concat "#EXTM3U\n"
+ (mapconcat (lambda (tr) (cj/music--m3u-track-lines tr entries))
+ tracks "")))
+
+(defun cj/music--write-playlist-file (path tracks entries)
+ "Write TRACKS to PATH as .m3u text, station metadata from ENTRIES.
+Refreshes the radio metadata cache since a new .m3u just landed."
+ (with-temp-file path
+ (insert (cj/music--m3u-text tracks entries)))
+ (cj/music--refresh-radio-name-map))
+
+(defun cj/music--save-default-name (tracks file entries)
+ "The name the save prompt should offer.
+FILE (the playlist's associated .m3u) wins when present. Otherwise the first
+url track's station name — its title property, else its #EXTINF label from
+ENTRIES. Nil when neither applies (the caller falls back to a timestamp)."
+ (if file
+ (file-name-sans-extension (file-name-nondirectory file))
+ (cl-loop for tr in tracks
+ when (eq (emms-track-type tr) 'url)
+ thereis (or (emms-track-get tr 'info-title)
+ (plist-get (cdr (assoc (emms-track-name tr) entries))
+ :name)))))
+
+;; Forward declaration: the real `defvar' lives with the radio config block far
+;; below. Declared special here so this reference compiles clean.
+(defvar cj/music-radio-save-dir)
+
+(defun cj/music--save-directory (tracks)
+ "Directory a saved playlist targets.
+An all-stream queue is a radio playlist and saves into
+`cj/music-radio-save-dir'; anything else saves into `cj/music-m3u-root'."
+ (if (and tracks
+ (cl-every (lambda (tr) (eq (emms-track-type tr) 'url)) tracks))
+ cj/music-radio-save-dir
+ cj/music-m3u-root))
+
(defun cj/music-playlist-save ()
- "Save current EMMS playlist to a file in cj/music-m3u-root.
-Offers completion over existing names but allows new names."
+ "Save the current EMMS playlist to an .m3u file.
+An all-stream queue saves into `cj/music-radio-save-dir' (the radio playlist
+home); anything else saves into `cj/music-m3u-root'. A queue of freshly
+looked-up stations pre-fills the first station's name in the prompt; a
+playlist with an associated file keeps that file's name as the default.
+Station metadata (name, uuid, favicon) is written with each stream so a
+reloaded playlist keeps its display name and cover art."
(interactive)
- (let* ((existing (cj/music--get-m3u-basenames))
- (default-name (if cj/music-playlist-file
- (file-name-sans-extension (file-name-nondirectory cj/music-playlist-file))
- (format-time-string "playlist-%Y%m%d-%H%M%S")))
- (chosen (completing-read "Save playlist as: " existing nil nil nil nil default-name))
+ (let* ((tracks (cj/music--playlist-track-objects))
+ (entries (cj/music--radio-metadata))
+ (existing (cj/music--get-m3u-basenames))
+ (assoc-file (with-current-buffer (cj/music--ensure-playlist-buffer)
+ cj/music-playlist-file))
+ (prefill (and (null assoc-file)
+ (cj/music--save-default-name tracks nil entries)))
+ (default-name (or (cj/music--save-default-name tracks assoc-file entries)
+ (format-time-string "playlist-%Y%m%d-%H%M%S")))
+ (chosen (completing-read "Save playlist as: " existing nil nil
+ prefill nil default-name))
(filename (if (string-suffix-p ".m3u" chosen) chosen (concat chosen ".m3u")))
- (full (expand-file-name filename cj/music-m3u-root)))
+ (dir (cj/music--save-directory tracks))
+ (full (expand-file-name filename dir)))
+ (when (string-empty-p (string-trim chosen))
+ (user-error "Playlist name cannot be empty"))
(when (and (file-exists-p full)
(not (cj/confirm-strong (format "Overwrite %s? " filename))))
(user-error "Aborted saving playlist"))
- (with-current-buffer (cj/music--ensure-playlist-buffer)
- (let ((emms-source-playlist-ask-before-overwrite nil))
- (emms-playlist-save 'm3u full)))
+ (make-directory dir t)
+ (cj/music--write-playlist-file full tracks entries)
(cj/music--sync-playlist-file full)
(message "Saved playlist: %s" filename)))
@@ -483,6 +865,23 @@ Offers completion over existing names but allows new names."
(message "Reloaded playlist: %s" name)))
+(defun cj/music-delete-playlist ()
+ "Delete an .m3u playlist file after strong confirmation.
+Candidates are the playlists `cj/music-playlist-load' offers -- every
+directory in `cj/music-m3u-roots' (the local library and MPD's playlist
+dir). Deleting the loaded playlist's file keeps the in-memory queue but
+clears its file association."
+ (interactive)
+ (let ((file (cj/music--select-m3u-file "Delete playlist: ")))
+ (if (not file)
+ (message "Playlist deletion cancelled")
+ (unless (cj/confirm-strong (format "Delete playlist %s? "
+ (file-name-nondirectory file)))
+ (user-error "Aborted deleting playlist"))
+ (cj/music--delete-playlist-file file)
+ (message "Deleted playlist: %s" (file-name-nondirectory file)))))
+
+
(defun cj/music-playlist-edit ()
"Open the playlist's M3U file in other window, prompting to save if modified."
(interactive)
@@ -491,8 +890,9 @@ Offers completion over existing names but allows new names."
(let ((path cj/music-playlist-file))
(when (cj/music--playlist-modified-p)
(when (yes-or-no-p "Playlist modified. Save before editing? ")
- (let ((emms-source-playlist-ask-before-overwrite nil))
- (emms-playlist-save 'm3u path))))
+ (cj/music--write-playlist-file path
+ (cj/music--playlist-track-objects)
+ (cj/music--radio-metadata))))
;; Re-validate existence before opening
(if (file-exists-p path)
(find-file-other-window path)
@@ -593,42 +993,26 @@ Intended for use on `emms-player-finished-hook'."
)
-(defvar cj/music-playlist-window-height 0.3
+(defvar cj/music-playlist-window-height 0.5
"Default fraction of frame height for the F10 music playlist side window.
-Used when the playlist docks at the bottom and hasn't been resized and
-toggled off this session; after that, the toggled-off height is remembered
-in `cj/--music-playlist-height'.")
-
-(defvar cj/music-playlist-window-width 0.4
- "Default fraction of frame width for the F10 music playlist side window.
-Used when the playlist docks as a right-side column (see
-`cj/--music-playlist-side') and hasn't been resized this session; after
-that the toggled-off width is remembered in `cj/--music-playlist-width'.")
+Half the frame, so a playlist of real length shows enough rows (a third
+still read too short in practice). Used when the playlist hasn't been
+resized and toggled off this session; after that, the toggled-off height
+is remembered in `cj/--music-playlist-height' -- but only when it's at
+least this default (see the discard in `cj/music-playlist-toggle').")
(defvar cj/--music-playlist-height nil
- "Last height fraction the playlist was toggled off at while docked bottom.
+ "Last height fraction the playlist was toggled off at.
nil means fall back to `cj/music-playlist-window-height'. In-memory only --
resets each Emacs session.")
-(defvar cj/--music-playlist-width nil
- "Last width fraction the playlist was toggled off at while docked right.
-nil means fall back to `cj/music-playlist-window-width'. In-memory only --
-resets each Emacs session.")
-
-(defun cj/--music-playlist-side ()
- "Return the side the F10 playlist should dock on: `right' or `bottom'.
-Docks as a right-side column only when a side-by-side split would leave
-both panes at least `cj/window-dock-min-columns' wide (the playlist's
-share is `cj/music-playlist-window-width'); otherwise docks at the bottom.
-See `cj/preferred-dock-direction'."
- (if (eq (cj/preferred-dock-direction (frame-width)
- cj/music-playlist-window-width)
- 'right)
- 'right
- 'bottom))
-
(defun cj/music-playlist-toggle ()
"Toggle the EMMS playlist buffer in a bottom side window.
+The playlist always docks at the bottom, whatever the frame's shape. It
+used to dock as a right-side column on a wide frame (via
+`cj/preferred-dock-direction'), which split a wide frame three ways --
+unexpected often enough that Craig retired the rule (2026-07-09).
+
The window opens at `cj/music-playlist-window-height'; if it has been
resized and toggled off this session, it reopens at that remembered height."
(interactive)
@@ -637,34 +1021,24 @@ resized and toggled off this session, it reopens at that remembered height."
(win (and buffer (get-buffer-window buffer))))
(if win
(progn
- ;; Capture the resized size into the var matching the window's
- ;; actual side, so width and height memories stay independent.
- ;; Guard the parameter lookup: a dead or non-window WIN (the
- ;; capture helpers tolerate one) must not error here.
- (let ((side (if (window-live-p win)
- (or (window-parameter win 'window-side) 'bottom)
- 'bottom)))
- (if (memq side '(left right))
- (cj/side-window-capture-size win side 'cj/--music-playlist-width)
- (cj/side-window-capture-size win 'bottom 'cj/--music-playlist-height)))
+ (cj/side-window-capture-size win 'bottom 'cj/--music-playlist-height)
+ ;; Remember enlargements only. Window churn (another side window
+ ;; opening) squeezes the dock, and remembering the squeeze reopens
+ ;; it too short on every later toggle. A deliberate shrink is the
+ ;; rare case; losing it costs one resize.
+ (when (and (numberp cj/--music-playlist-height)
+ (< cj/--music-playlist-height cj/music-playlist-window-height))
+ (setq cj/--music-playlist-height nil))
(delete-window win)
(message "Playlist window closed"))
(progn
(cj/emms--setup)
(setq buffer (cj/music--ensure-playlist-buffer))
- (let* ((side (cj/--music-playlist-side))
- (right (eq side 'right)))
- (setq win (cj/side-window-display
- buffer side
- (if right 'cj/--music-playlist-width 'cj/--music-playlist-height)
- (if right cj/music-playlist-window-width
- cj/music-playlist-window-height))))
+ (setq win (cj/side-window-display
+ buffer 'bottom 'cj/--music-playlist-height
+ cj/music-playlist-window-height))
(select-window win)
- (with-current-buffer buffer
- (if (and (fboundp 'emms-playlist-current-selected-track)
- (emms-playlist-current-selected-track))
- (emms-playlist-mode-center-current)
- (goto-char (point-min))))
+ (cj/music--playlist-land-point win buffer)
(let ((count (with-current-buffer buffer
(count-lines (point-min) (point-max)))))
(message (if (> count 0)
@@ -682,7 +1056,9 @@ Initializes EMMS if needed."
(when buffer-exists
(with-current-buffer cj/music-playlist-buffer-name
(setq has-content (> (point-max) (point-min)))))
- (switch-to-buffer (cj/music--ensure-playlist-buffer))
+ (let ((buffer (cj/music--ensure-playlist-buffer)))
+ (switch-to-buffer buffer)
+ (cj/music--playlist-land-point (selected-window) buffer))
(cond
((not emms-was-loaded) (message "EMMS started. Current playlist empty"))
((and buffer-exists has-content) (message "EMMS running. Displaying current playlist"))
@@ -697,9 +1073,10 @@ Dirs added recursively."
(unless (derived-mode-p 'dired-mode)
(user-error "This command must be run in a Dired buffer"))
(cj/music--ensure-playlist-buffer)
- (let ((files (if (use-region-p)
- (dired-get-marked-files)
- (list (dired-get-file-for-visit)))))
+ ;; dired-get-marked-files already honors m-marks, an active region, or the
+ ;; file at point; gating it behind use-region-p silently dropped all but
+ ;; the point file whenever files were marked without a region.
+ (let ((files (dired-get-marked-files)))
(when (null files)
(user-error "No files selected"))
(dolist (file files)
@@ -715,19 +1092,25 @@ Dirs added recursively."
;;; EMMS setup and keybindings
;; Music/EMMS keymap
+(defvar-keymap cj/music-radio-map
+ :doc "Radio prefix: mirrors the playlist buffer's n/t/m radio row."
+ "n" #'cj/music-radio-search-by-name
+ "t" #'cj/music-radio-search-by-tag
+ "m" #'cj/music-create-radio-station)
+
(defvar-keymap cj/music-map
- :doc "Keymap for music commands"
+ :doc "Keymap for music commands (all lowercase, chord-friendly)"
"m" #'cj/music-playlist-toggle
- "M" #'cj/music-playlist-show
+ "v" #'cj/music-playlist-show
"a" #'cj/music-fuzzy-select-and-add
- "R" #'cj/music-create-radio-station
+ "r" cj/music-radio-map
"SPC" #'emms-pause
"s" #'emms-stop
"n" #'cj/music-next
"p" #'cj/music-previous
"g" #'emms-playlist-mode-go
- "Z" #'emms-shuffle
- "r" #'emms-toggle-repeat-playlist
+ "u" #'emms-shuffle
+ "l" #'emms-toggle-repeat-playlist
"t" #'emms-toggle-repeat-track
"z" #'emms-toggle-random-playlist
"x" #'cj/music-toggle-consume)
@@ -737,16 +1120,19 @@ Dirs added recursively."
(which-key-add-key-based-replacements
"C-; m" "music menu"
"C-; m m" "toggle playlist"
- "C-; m M" "show playlist"
+ "C-; m v" "show playlist"
"C-; m a" "add music"
- "C-; m R" "create radio"
+ "C-; m r" "+radio"
+ "C-; m r n" "radio by name"
+ "C-; m r t" "radio by tag"
+ "C-; m r m" "radio manual entry"
"C-; m SPC" "pause"
"C-; m s" "stop"
"C-; m n" "next track"
"C-; m p" "previous track"
"C-; m g" "goto playlist"
- "C-; m Z" "shuffle"
- "C-; m r" "repeat playlist"
+ "C-; m u" "shuffle"
+ "C-; m l" "repeat playlist"
"C-; m t" "repeat track"
"C-; m z" "random"
"C-; m x" "consume"))
@@ -767,92 +1153,381 @@ Dirs added recursively."
(when (and seconds (numberp seconds) (> seconds 0))
(format "%d:%02d" (/ seconds 60) (mod seconds 60))))
-(defun cj/music--track-description (track)
- "Return a human-readable description of TRACK.
-For tagged tracks: \"Artist - Title [M:SS]\".
-For file tracks without tags: filename without path or extension.
-For URL tracks: decoded URL."
+;; ---------------------------- Display-name layer -----------------------------
+;; A track maps to a display NAME (shared by the header's Current line and the
+;; playlist row renderer) plus, on a row, a dim type glyph and right-aligned
+;; meta. The pure pieces (name resolution, #EXTINF-label extraction, host
+;; tidying, progress-bar fill) carry the tests; the glyph, the :align-to meta,
+;; and the disk-backed name-map are exercised live.
+
+(defun cj/music--tidy-host (url)
+ "Return a readable host label for URL: scheme and path dropped, a leading
+\"www.\" removed, and a multi-label host reduced to its last two labels
+\(ice6.somafm.com -> somafm.com). A string with no scheme://host is returned
+unchanged, so a non-URL name shows as-is instead of erroring."
+ (if (string-match "\\`[a-zA-Z]+://\\(?:[^@/]*@\\)?\\([^:/?#]+\\)" url)
+ (let* ((host (replace-regexp-in-string "\\`www\\." "" (match-string 1 url)))
+ (labels (split-string host "\\." t)))
+ (if (> (length labels) 2)
+ (string-join (last labels 2) ".")
+ host))
+ url))
+
+(defun cj/music--m3u-entries (text)
+ "Parse M3U TEXT into an alist of (STREAM-URL . PLIST).
+Each PLIST carries :name (the #EXTINF label), :uuid (#RADIOBROWSERUUID), and
+:favicon (#RADIOBROWSERFAVICON), read from the comment lines preceding the url.
+A url with no #EXTINF is skipped; fields reset after each url so nothing leaks
+between stations."
+ (let ((name nil) (uuid nil) (favicon nil) (entries '()))
+ (dolist (line (split-string text "[\r\n]+" t) (nreverse entries))
+ (cond
+ ((string-match "\\`#EXTINF:[^,]*,\\(.*\\)\\'" line)
+ (setq name (match-string 1 line)))
+ ((string-match "\\`#RADIOBROWSERUUID:\\(.*\\)\\'" line)
+ (setq uuid (match-string 1 line)))
+ ((string-match "\\`#RADIOBROWSERFAVICON:\\(.*\\)\\'" line)
+ (setq favicon (match-string 1 line)))
+ ((string-prefix-p "#" line)) ; other comment
+ (t (when name
+ (push (cons line (list :name name :uuid uuid :favicon favicon))
+ entries))
+ (setq name nil uuid nil favicon nil))))))
+
+(defun cj/music--m3u-labels (text)
+ "Alist of (STREAM-URL . #EXTINF-LABEL) parsed from M3U TEXT.
+A thin projection of `cj/music--m3u-entries' onto the label field."
+ (mapcar (lambda (e) (cons (car e) (plist-get (cdr e) :name)))
+ (cj/music--m3u-entries text)))
+
+(defvar cj/music--radio-metadata-cache nil
+ "Cached url->plist metadata (:name :uuid :favicon) across `cj/music-m3u-roots'.
+Built lazily so a row render never re-scans disk; cleared by
+`cj/music--refresh-radio-name-map' when a station is created or a playlist
+loads.")
+
+(defun cj/music--radio-metadata ()
+ "Alist of stream-url -> plist metadata, unioned across all playlist roots.
+Reads each .m3u once and caches the result; both name resolution and the
+cover-art layer read from it."
+ (or cj/music--radio-metadata-cache
+ (setq cj/music--radio-metadata-cache
+ (cl-loop for (_base . path) in (cj/music--get-m3u-files)
+ append (cj/music--m3u-entries
+ (with-temp-buffer
+ (insert-file-contents path)
+ (buffer-string)))))))
+
+(defun cj/music--radio-name-map ()
+ "Alist of stream-url -> station label, derived from the cached metadata."
+ (mapcar (lambda (e) (cons (car e) (plist-get (cdr e) :name)))
+ (cj/music--radio-metadata)))
+
+(defun cj/music--refresh-radio-name-map ()
+ "Clear the cached radio metadata so the next render rebuilds it."
+ (setq cj/music--radio-metadata-cache nil))
+
+(defun cj/music--display-name (track &optional name-map)
+ "Human display name for TRACK, name only (no duration).
+A tagged track (file or url) shows \"Artist - Title\" or the bare title; an
+untagged file shows its filename; a url track resolves to its #EXTINF label
+from NAME-MAP (an alist of url->label), else a tidied host. Unknown types
+fall back to `emms-track-simple-description'."
(let ((type (emms-track-type track))
(title (emms-track-get track 'info-title))
(artist (emms-track-get track 'info-artist))
- (duration (emms-track-get track 'info-playing-time))
(name (emms-track-name track)))
(cond
- ;; Tagged track with title
- (title
- (let ((dur-str (cj/music--format-duration duration))
- (parts '()))
- (when artist (push artist parts))
- (push title parts)
- (let ((desc (string-join (nreverse parts) " - ")))
- (if dur-str (format "%s [%s]" desc dur-str) desc))))
- ;; File without tags — show clean filename
- ((eq type 'file)
- (file-name-sans-extension (file-name-nondirectory name)))
- ;; URL — decode percent-encoded characters
- ((eq type 'url)
- (decode-coding-string (url-unhex-string name) 'utf-8))
- ;; Fallback
+ (title (if artist (format "%s - %s" artist title) title))
+ ((eq type 'file) (file-name-sans-extension (file-name-nondirectory name)))
+ ((eq type 'url) (or (cdr (assoc name name-map)) (cj/music--tidy-host name)))
(t (emms-track-simple-description track)))))
+(defun cj/music--format-meta (track)
+ "Right-aligned meta string for TRACK's row: a file's duration as \"[M:SS]\",
+empty when there's no duration (a live stream, or an untimed file)."
+ (let ((dur (cj/music--format-duration (emms-track-get track 'info-playing-time))))
+ (if dur (format "[%s]" dur) "")))
+
+(defun cj/music--bar-fill (elapsed total width)
+ "Filled-cell count for a WIDTH-cell progress bar at ELAPSED/TOTAL seconds.
+Returns 0..WIDTH, or the symbol `indeterminate' when TOTAL is nil or
+non-positive (a live stream). A nil ELAPSED counts as zero."
+ (if (or (null total) (<= total 0))
+ 'indeterminate
+ (let ((ratio (min 1.0 (max 0.0 (/ (float (or elapsed 0)) total)))))
+ (round (* ratio width)))))
+
+(defun cj/music--type-glyph (track)
+ "A leading glyph for TRACK: a broadcast icon for a stream, a note for a file.
+Uses nerd-icons when available; otherwise a plain marker, so a TTY or a
+fontless frame never shows a tofu box."
+ (let ((stream (eq (emms-track-type track) 'url)))
+ (or (and (fboundp 'nerd-icons-mdicon)
+ (ignore-errors
+ (nerd-icons-mdicon (if stream "nf-md-broadcast" "nf-md-music_note")
+ :face 'cj/music-header-face)))
+ (if stream "»" "•"))))
+
+(defun cj/music--row-string (track)
+ "Playlist row for TRACK: a lead glyph or cover thumbnail, the display name,
+and the meta right-aligned to the window edge with a resize-safe :align-to
+space. In the fancy render the lead is a thumbnail and the name is serif.
+This is `emms-track-description-function'."
+ (let* ((name (cj/music--display-name track (cj/music--radio-name-map)))
+ (meta (cj/music--format-meta track))
+ (fancy (cj/music--fancy-p))
+ (lead (if-let* ((fancy)
+ (img (cj/music--image (cj/music-art--for-track track)
+ cj/music-thumb-size)))
+ (propertize " " 'display img)
+ (cj/music--type-glyph track)))
+ (label (if fancy
+ (propertize name 'face (list :family cj/music-title-family
+ :inherit 'cj/music-title-face))
+ name)))
+ (if (string-empty-p meta)
+ (concat lead " " label)
+ (concat lead " " label
+ (propertize " " 'display
+ `(space :align-to (- right ,(1+ (length meta)))))
+ (propertize meta 'face 'cj/music-keyhint-face)))))
+
+(defun cj/music--now-playing-suffix (track)
+ "Trailing status for the header Current line: on-air for a stream, the
+duration for a timed file, empty otherwise."
+ (if (eq (emms-track-type track) 'url)
+ " ◉ on air"
+ (let ((d (cj/music--format-duration (emms-track-get track 'info-playing-time))))
+ (if d (format " %s" d) ""))))
+
+;; ------------------------------ Fancy render ---------------------------------
+;; The GUI hero (cover image + serif title + bar) and thumbnailed serif rows,
+;; gated on a graphical frame + `cj/music-fancy-ui'. Cover art comes from the
+;; non-blocking `cj/music-art--for-track' (defined with the art layer below).
+
+(defun cj/music--fancy-p ()
+ "Non-nil when the fancy render applies: a graphical frame with the
+`cj/music-fancy-ui' toggle on. Decided per redisplay, so a TTY frame and a GUI
+frame in the same session can differ."
+ (and cj/music-fancy-ui (display-graphic-p)))
+
+(defun cj/music--image (path height)
+ "Image spec for PATH scaled to HEIGHT px, or nil when it can't be displayed
+\(no image support, an unreadable file, an unavailable format)."
+ (when (and path (file-readable-p path))
+ (ignore-errors
+ (create-image path nil nil :height height :ascent 'center))))
+
+(defun cj/music--bar-string (fill width)
+ "Render a WIDTH-cell block progress bar with FILL filled cells.
+FILL `indeterminate' (a live stream) renders an on-air marker instead."
+ (if (eq fill 'indeterminate)
+ (propertize "◉ on air" 'face 'cj/music-subtitle-face)
+ (let ((n (max 0 (min fill width))))
+ (concat (propertize (make-string n ?█) 'face 'cj/music-bar-fill-face)
+ (propertize (make-string (- width n) ?░)
+ 'face 'cj/music-bar-empty-face)))))
+
+(defun cj/music--current-bar (track)
+ "The progress bar for TRACK: a stream is indeterminate; a file fills from
+mpv's percent-pos."
+ (if (eq (emms-track-type track) 'url)
+ (cj/music--bar-string 'indeterminate cj/music-bar-width)
+ (let ((pct (cj/music--mpv-get-property "percent-pos")))
+ (cj/music--bar-string
+ (cj/music--bar-fill (and (numberp pct) pct) 100 cj/music-bar-width)
+ cj/music-bar-width))))
+
+(defun cj/music--hero-header (track)
+ "Fancy now-playing hero for TRACK: cover image, serif amber title, subtitle,
+and the progress bar, stacked vertically."
+ (let* ((img (cj/music--image (cj/music-art--for-track track) cj/music-hero-size))
+ (title (cj/music--display-name track (cj/music--radio-name-map)))
+ (sub (if (eq (emms-track-type track) 'url)
+ "radio"
+ (or (emms-track-get track 'info-album) ""))))
+ (concat
+ (if img (concat (propertize " " 'display img) "\n") "")
+ (propertize title 'face (list :family cj/music-title-family
+ :inherit 'cj/music-title-face))
+ "\n"
+ (if (string-empty-p sub)
+ ""
+ (concat (propertize sub 'face 'cj/music-subtitle-face) "\n"))
+ (cj/music--current-bar track)
+ "\n")))
+
;; Multi-line header overlay
(defvar-local cj/music--header-overlay nil
"Overlay displaying the playlist header.")
-(defun cj/music--header-text ()
- "Build a multi-line header string for the playlist buffer overlay."
- (let* ((pl-name (if cj/music-playlist-file
- (file-name-sans-extension
- (file-name-nondirectory cj/music-playlist-file))
- "Untitled"))
- (track-count (count-lines (point-min) (point-max)))
- (now-playing (cond
- ((not emms-player-playing-p) "Stopped")
- (emms-player-paused-p "Paused")
- (t (let ((track (emms-playlist-current-selected-track)))
- (if track
- (cj/music--track-description track)
- "Playing")))))
- (mode-indicator
- (lambda (key label active)
- (let ((face (if active 'cj/music-mode-on-face 'cj/music-mode-off-face)))
- (propertize (format "[%s] %s" key label) 'face face)))))
+(defun cj/music--playlist-string ()
+ "The \"Playlist : NAME (N)\" header line."
+ (let ((pl-name (if cj/music-playlist-file
+ (file-name-sans-extension
+ (file-name-nondirectory cj/music-playlist-file))
+ "Untitled"))
+ (track-count (count-lines (point-min) (point-max))))
+ (concat (propertize "Playlist" 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize (format "%s (%d)" pl-name track-count)
+ 'face 'cj/music-header-value-face)
+ "\n")))
+
+(defun cj/music--controls-string ()
+ "The Mode / Keys / Radio control lines and the closing full-width rule.
+The rule uses a resize-safe :align-to span, not a hardcoded character count."
+ (let ((mode-indicator
+ (lambda (key label active)
+ (let ((face (if active 'cj/music-mode-on-face 'cj/music-mode-off-face)))
+ (propertize (format "[%s] %s" key label) 'face face)))))
(concat
- (propertize "Playlist" 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize (format "%s (%d)" pl-name track-count) 'face 'cj/music-header-value-face)
- "\n"
- (propertize "Current " 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize now-playing 'face 'cj/music-header-value-face)
- "\n"
(propertize "Mode " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
- (funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist))
- " "
- (funcall mode-indicator "t" "single" (bound-and-true-p emms-repeat-track))
- " "
- (funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist))
- " "
- (funcall mode-indicator "x" "consume" cj/music-consume-mode)
- "\n"
+ (funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist)) " "
+ (funcall mode-indicator "1" "single" (bound-and-true-p emms-repeat-track)) " "
+ (funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist)) " "
+ (funcall mode-indicator "x" "consume" cj/music-consume-mode) "\n"
(propertize "Keys " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
- (propertize "a:add c:clear L:load S:save SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
- 'face 'cj/music-keyhint-face)
+ (propertize "a:add c:clear L:load s:save D:delete SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ 'face 'cj/music-keyhint-face) "\n"
+ (propertize "Radio " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize "n:by name t:by tag m:enter manually"
+ 'face 'cj/music-keyhint-face) "\n"
+ (propertize " " 'face '(:strike-through t :inherit shadow)
+ 'display '(space :align-to right))
"\n\n")))
+(defun cj/music--current-track ()
+ "The selected track when one is playing or paused, else nil."
+ (and emms-player-playing-p
+ (ignore-errors (emms-playlist-current-selected-track))))
+
+(defun cj/music--text-header ()
+ "The plain text header: Playlist, Current, then the controls."
+ (let ((now (cond ((not emms-player-playing-p) "Stopped")
+ (emms-player-paused-p "Paused")
+ (t (let ((track (cj/music--current-track)))
+ (if track
+ (concat (cj/music--display-name
+ track (cj/music--radio-name-map))
+ (cj/music--now-playing-suffix track))
+ "Playing"))))))
+ (concat (cj/music--playlist-string)
+ (propertize "Current " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize now 'face 'cj/music-header-value-face) "\n"
+ (cj/music--controls-string))))
+
+(defun cj/music--fancy-header ()
+ "The fancy header: Playlist, the now-playing hero when a track plays, then
+the controls."
+ (let ((track (cj/music--current-track)))
+ (concat (cj/music--playlist-string)
+ (if track
+ (cj/music--hero-header track)
+ (concat (propertize "Current " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize (if emms-player-paused-p "Paused" "Stopped")
+ 'face 'cj/music-header-value-face)
+ "\n"))
+ (cj/music--controls-string))))
+
+(defun cj/music--header-text ()
+ "Build the playlist header overlay string: fancy in a graphical frame with
+`cj/music-fancy-ui' on, plain text otherwise."
+ (if (cj/music--fancy-p)
+ (cj/music--fancy-header)
+ (cj/music--text-header)))
+
+(defun cj/music--header-anchor-position ()
+ "Return the position the header overlay should anchor at right now.
+The start of the displaying window when the playlist is shown (so the
+header stays at the top of the window while the list scrolls under it),
+else the top of the buffer. Searches all frames -- the refresh timer can
+run with any frame selected, and missing a window on another frame would
+anchor at the buffer top and yank a scrolled header back."
+ (if-let ((win (get-buffer-window (current-buffer) t)))
+ (max (point-min) (min (window-start win) (point-max)))
+ (point-min)))
+
+(defun cj/music--stick-header (win start)
+ "Re-anchor the header overlay at START, WIN's new display start.
+Runs on the buffer-local `window-scroll-functions', so every scroll pins
+the header block to the top of the window and the track list scrolls
+beneath it. Converges: an already-anchored header is a no-op, so the
+redisplay this move triggers doesn't loop. Always returns nil."
+ (with-current-buffer (window-buffer win)
+ (when (and (overlayp cj/music--header-overlay)
+ (overlay-buffer cj/music--header-overlay)
+ (integer-or-marker-p start))
+ (let ((pos (max (point-min) (min start (point-max)))))
+ (unless (= (overlay-start cj/music--header-overlay) pos)
+ (move-overlay cj/music--header-overlay pos pos))))
+ nil))
+
+(defun cj/music--refresh-header-after-toggle (&rest _)
+ "Refresh the playlist header after a repeat/random/consume toggle.
+Named (not an anonymous lambda) so the :config reload can advice-remove
+it before re-adding -- anonymous advice stacks a copy per reload."
+ (cj/music--update-header))
+
(defun cj/music--update-header ()
- "Insert or update the multi-line header overlay in the playlist buffer."
+ "Insert or update the multi-line header overlay in the playlist buffer.
+Anchors at the displaying window's start (see
+`cj/music--header-anchor-position') -- the refresh timer calls this every
+second, and re-anchoring at the buffer top would yank the sticky header
+away whenever the list is scrolled."
(when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
(with-current-buffer buf
(unless cj/music--header-overlay
(setq cj/music--header-overlay (make-overlay (point-min) (point-min)))
(overlay-put cj/music--header-overlay 'priority 100))
- (move-overlay cj/music--header-overlay (point-min) (point-min))
+ (let ((pos (cj/music--header-anchor-position)))
+ (move-overlay cj/music--header-overlay pos pos))
(overlay-put cj/music--header-overlay 'before-string
(cj/music--header-text)))))
+;; Progress-bar redraw timer and cover-art pre-warm (Phase 3).
+(defvar cj/music--bar-timer nil
+ "Repeating timer redrawing the progress bar while a track plays.")
+
+(defun cj/music--bar-tick ()
+ "Redraw the header when the player buffer is visible and a track is playing.
+The timer keeps running while idle/paused; it just skips the redraw."
+ (when (and emms-player-playing-p (not emms-player-paused-p)
+ (get-buffer-window cj/music-playlist-buffer-name t))
+ (cj/music--update-header)))
+
+(defun cj/music--start-bar-timer (&rest _)
+ "Start the progress-bar redraw timer if it is not already running."
+ (unless cj/music--bar-timer
+ (setq cj/music--bar-timer
+ (run-at-time t cj/music-bar-interval #'cj/music--bar-tick))))
+
+(defun cj/music--stop-bar-timer (&rest _)
+ "Stop the progress-bar redraw timer."
+ (when cj/music--bar-timer
+ (cancel-timer cj/music--bar-timer)
+ (setq cj/music--bar-timer nil)))
+
+(defun cj/music--do-prewarm-art ()
+ "Fetch the current track's cover art, then refresh the header so the fetched
+art replaces the placeholder. Blocks on the network; runs off an idle timer."
+ (when-let ((track (cj/music--current-track)))
+ (when (cj/music-art--ensure track)
+ (cj/music--update-header))))
+
+(defun cj/music--prewarm-art (&rest _)
+ "Schedule a cover-art fetch for the current track during idle, so a slow
+fetch never blocks playback start (the emms-player-started-hook). A no-op
+unless fancy."
+ (when (cj/music--fancy-p)
+ (run-with-idle-timer 0.2 nil #'cj/music--do-prewarm-art)))
+
(defvar-local cj/music--bg-remap-cookie nil
"Cookie for the active-window background face remapping.")
@@ -915,7 +1590,7 @@ For URL tracks: decoded URL."
;;; Playlist display
;; Track description: show "Artist - Title [M:SS]" instead of file paths
- (setq emms-track-description-function #'cj/music--track-description)
+ (setq emms-track-description-function #'cj/music--row-string)
(add-hook 'emms-playlist-mode-hook #'cj/music--setup-playlist-display)
(add-hook 'emms-player-started-hook #'cj/music--record-random-history)
@@ -925,19 +1600,29 @@ For URL tracks: decoded URL."
(add-hook 'emms-player-finished-hook #'cj/music--update-header)
(add-hook 'emms-playlist-cleared-hook #'cj/music--update-header)
- ;; Refresh header immediately when toggling modes
+ ;; Fancy render: run the bar timer only across a playing span, and pre-warm
+ ;; the current track's cover art off the redisplay path.
+ (add-hook 'emms-player-started-hook #'cj/music--start-bar-timer)
+ (add-hook 'emms-player-started-hook #'cj/music--prewarm-art)
+ (add-hook 'emms-player-stopped-hook #'cj/music--stop-bar-timer)
+ (add-hook 'emms-player-finished-hook #'cj/music--stop-bar-timer)
+
+ ;; Refresh header immediately when toggling modes. Named advice with a
+ ;; remove-then-add guard (like the emms-playlist-clear advice above):
+ ;; an anonymous lambda can't be advice-removed and stacks a copy on every
+ ;; :config reload, firing the refresh N times per toggle.
(dolist (fn '(emms-toggle-repeat-playlist
emms-toggle-repeat-track
emms-toggle-random-playlist
cj/music-toggle-consume))
- (advice-add fn :after (lambda (&rest _) (cj/music--update-header))))
+ (advice-remove fn #'cj/music--refresh-header-after-toggle)
+ (advice-add fn :after #'cj/music--refresh-header-after-toggle))
:bind
(:map emms-playlist-mode-map
;; Playback
("p" . emms-playlist-mode-go)
("SPC" . emms-pause)
- ("s" . emms-stop)
("n" . cj/music-next)
(">" . cj/music-next)
("P" . cj/music-previous)
@@ -960,9 +1645,9 @@ For URL tracks: decoded URL."
("c" . cj/music-playlist-clear)
("C" . cj/music-playlist-clear)
("L" . cj/music-playlist-load)
+ ("D" . cj/music-delete-playlist)
("E" . cj/music-playlist-edit)
("g" . cj/music-playlist-reload)
- ("S" . cj/music-playlist-save)
;; Track reordering
("S-<up>" . emms-playlist-mode-shift-track-up)
("S-<down>" . emms-playlist-mode-shift-track-down)
@@ -978,7 +1663,10 @@ For URL tracks: decoded URL."
;;; Radio station creation
(defun cj/music-create-radio-station (name url)
- "Create a radio station M3U playlist with NAME and URL in cj/music-m3u-root."
+ "Queue and play a radio station from a hand-entered NAME and URL.
+The station becomes a url track in the playlist (NAME as its title) and
+playback starts. Nothing is written to disk — save the queue with the normal
+playlist save, where NAME pre-fills the prompt."
(interactive
(list (read-string "Radio station name: ")
(read-string "Stream URL: ")))
@@ -986,20 +1674,442 @@ For URL tracks: decoded URL."
(user-error "Radio station name cannot be empty"))
(when (string-empty-p url)
(user-error "Stream URL cannot be empty"))
- (let* ((safe (cj/music--safe-filename name))
- (file (expand-file-name (concat safe "_Radio.m3u") cj/music-m3u-root))
- (content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url)))
- (when (and (file-exists-p file)
- (not (cj/confirm-strong (format "Overwrite %s? " (file-name-nondirectory file)))))
- (user-error "Aborted creating radio station"))
- (with-temp-file file
- (insert content))
- (message "Created radio station: %s" (file-name-nondirectory file))))
-
-;; Bound here rather than in the emms `:bind' so use-package does not emit a
-;; redundant autoload that collides with this same-file definition.
+ (cj/emms--setup)
+ (cj/music-radio--enqueue-and-play
+ (list (cj/music-radio--station-track (list :name name :url url))))
+ (message "Queued radio station: %s" name))
+
+;; The manual name+URL creator is bound to m in the radio row below (see the
+;; with-eval-after-load block near the radio-browser lookup), not R.
+
+;; --------------------------- Radio-browser Lookup ----------------------------
+;; Search radio-browser.info and queue a selection as playing url tracks, each
+;; carrying its station metadata as track properties. Nothing is written at
+;; pick time; the playlist save writes the metadata back out as .m3u comment
+;; lines. Spec: docs/specs/2026-07-06-radio-browser-lookup-spec.org. The
+;; pure pieces (parse / track-build / format) carry the tests; the network GET
+;; and the interactive command are exercised live.
+
+(require 'url)
+
+(defvar cj/music-radio-server "de1.api.radio-browser.info"
+ "Default radio-browser API host.
+On a connection failure the client falls back to a host from /json/servers.")
+
+(defvar cj/music-radio-user-agent "cj-emacs-music/1.0 (radio-browser lookup)"
+ "User-Agent sent with radio-browser requests.
+The project asks clients to identify themselves.")
+
+(defvar cj/music-radio-tag-limit 500
+ "Maximum number of tags fetched from radio-browser for tag completion.
+The /json/tags endpoint is fetched ordered by station count, so the limit
+keeps the popular tags and drops the long tail of one-station noise tags.")
+
+(defvar cj/music-radio--tags-cache nil
+ "Session cache of radio-browser tag names, or nil before the first fetch.
+A failed fetch leaves it nil so the next tag search retries.")
+
+(defvar cj/music-radio-search-limit 30
+ "Maximum number of stations a radio-browser search returns.")
+
+(defvar cj/music-radio-save-dir (expand-file-name "~/.local/share/mpd/playlists/")
+ "Directory radio playlists are saved to (the radio home).
+The playlist save targets it when every track in the queue is a stream.")
+
+(defun cj/music-radio--parse-search (json-text)
+ "Parse a radio-browser JSON-TEXT array into a list of station plists.
+Signals a `user-error' rather than a raw parse error when JSON-TEXT is not
+JSON (a gateway HTML page or a rate-limit notice), so a bad response reads as a
+clear message instead of a stack trace."
+ (condition-case nil
+ (json-parse-string json-text :object-type 'plist :array-type 'list :null-object nil)
+ (error (user-error "radio-browser returned an unreadable response"))))
+
+(defun cj/music-radio--station-url (st)
+ "Best stream URL for station ST: url_resolved, then url, then nil."
+ (let ((r (plist-get st :url_resolved))
+ (u (plist-get st :url)))
+ (cond ((and (stringp r) (not (string-empty-p r))) r)
+ ((and (stringp u) (not (string-empty-p u))) u))))
+
+(defun cj/music-radio--station-track (st)
+ "Return an EMMS url track for station ST, or nil when it has no stream URL.
+The track carries the station name as `info-title' plus `radio-uuid' and
+`radio-favicon' properties, so display names and cover art need no .m3u on
+disk; the playlist save writes the same metadata back out as comment lines.
+Newlines in the external name/favicon are flattened so they can't inject
+extra .m3u lines at save time."
+ (when-let ((url (cj/music-radio--station-url st)))
+ (let ((track (emms-track 'url url))
+ (name (replace-regexp-in-string "[\r\n]+" " "
+ (or (plist-get st :name) "Radio")))
+ (uuid (plist-get st :stationuuid))
+ (favicon (plist-get st :favicon)))
+ (emms-track-set track 'info-title name)
+ (when (and (stringp uuid) (not (string-empty-p uuid)))
+ (emms-track-set track 'radio-uuid uuid))
+ (when (and (stringp favicon) (not (string-empty-p favicon)))
+ (emms-track-set track 'radio-favicon
+ (replace-regexp-in-string "[\r\n]+" " " favicon)))
+ track)))
+
+(defun cj/music-radio--enqueue-and-play (tracks)
+ "Append TRACKS to the playlist buffer and play the first of them.
+Interrupts whatever is playing; the rest of the queue is left in place. A nil
+TRACKS is a no-op."
+ (when tracks
+ (cj/emms--setup)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (let ((first-pos nil))
+ (save-excursion
+ (dolist (tr tracks)
+ (goto-char (point-max))
+ (unless first-pos (setq first-pos (point)))
+ (emms-playlist-insert-track tr)))
+ (emms-playlist-select first-pos)))
+ (when emms-player-playing-p (emms-stop))
+ (emms-start)))
+
+(defun cj/music-radio--tags-snippet (tags n)
+ "Return the first N comma-separated TAGS as a trimmed display string.
+TAGS is a comma-separated string or nil; nil or empty yields the empty string."
+ (if (and (stringp tags) (not (string-empty-p tags)))
+ (string-join (seq-take (split-string tags "," t "[ \t]*") n) ", ")
+ ""))
+
+(defun cj/music-radio--format-candidate (st)
+ "Marginalia annotation for station ST.
+Variant B: codec, bitrate, country, votes, and the first few tags. Every
+field pads to a fixed width (votes included) so the listing reads as
+aligned columns across stations."
+ (let ((codec (or (plist-get st :codec) ""))
+ (bitrate (let ((b (plist-get st :bitrate)))
+ (if (and (integerp b) (> b 0)) (format "%dk" b) "")))
+ (cc (or (plist-get st :countrycode) ""))
+ (votes (or (plist-get st :votes) 0))
+ (tags (cj/music-radio--tags-snippet (plist-get st :tags) 3)))
+ (format "%-4s %-5s %-2s %-7s %s" codec bitrate cc (format "♥%d" votes) tags)))
+
+(defun cj/music-radio--search-url (server query &optional field)
+ "Build the radio-browser station-search URL for QUERY against SERVER.
+FIELD is the search field: \"name\" (default) or \"tag\"."
+ (format "https://%s/json/stations/search?%s=%s&limit=%d&hidebroken=true&order=votes&reverse=true"
+ server (or field "name") (url-hexify-string query) cj/music-radio-search-limit))
+
+(defun cj/music-radio--http-get (url)
+ "GET URL with the radio-browser User-Agent; return the response body or nil."
+ (let ((url-request-extra-headers `(("User-Agent" . ,cj/music-radio-user-agent))))
+ (when-let* ((buf (url-retrieve-synchronously url t t 15)))
+ (with-current-buffer buf
+ (goto-char (point-min))
+ (prog1 (when (re-search-forward "\n\n" nil t)
+ (buffer-substring-no-properties (point) (point-max)))
+ (kill-buffer buf))))))
+
+(defun cj/music-radio--search-fallback (query &optional field)
+ "Fetch an alternate radio-browser host and retry the QUERY/FIELD search once."
+ (when-let* ((body (cj/music-radio--http-get
+ "https://all.api.radio-browser.info/json/servers"))
+ (servers (cj/music-radio--parse-search body))
+ (host (plist-get (car servers) :name)))
+ (cj/music-radio--http-get (cj/music-radio--search-url host query field))))
+
+(defun cj/music-radio--search (query &optional field)
+ "Search radio-browser for QUERY on FIELD; return a list of station plists.
+FIELD is \"name\" (default) or \"tag\". Tries `cj/music-radio-server' first,
+then falls back to a host from /json/servers once. Signals a `user-error' when
+nothing responds."
+ (let ((body (or (ignore-errors
+ (cj/music-radio--http-get
+ (cj/music-radio--search-url cj/music-radio-server query field)))
+ (ignore-errors (cj/music-radio--search-fallback query field)))))
+ (unless body (user-error "radio-browser: no response (network down?)"))
+ (cj/music-radio--parse-search body)))
+
+(defun cj/music-radio--candidates (stations)
+ "Return an alist of (DISPLAY . STATION) for STATIONS with unique display keys.
+DISPLAY is the station name; a repeated name gets its codec/bitrate appended,
+then a numeric suffix, so completing-read keys never collide and each maps back
+to one station. Pure helper."
+ (let ((seen (make-hash-table :test 'equal))
+ (out '()))
+ (dolist (st stations (nreverse out))
+ (let* ((name (string-trim (or (plist-get st :name) "(unnamed)")))
+ (disp name)
+ (n 2))
+ (when (gethash disp seen)
+ (setq disp (format "%s (%s%s)" name (or (plist-get st :codec) "")
+ (let ((b (plist-get st :bitrate)))
+ (if (and (integerp b) (> b 0)) (format " %dk" b) "")))))
+ (while (gethash disp seen)
+ (setq disp (format "%s #%d" name n))
+ (setq n (1+ n)))
+ (puthash disp t seen)
+ (push (cons disp st) out)))))
+
+(defun cj/music-radio--completion-table (candidates)
+ "Completion table over CANDIDATES carrying the Variant-B annotation.
+Tagged `cj-radio-station' and registered with marginalia (builtin), so the
+codec/bitrate/country/votes/tags annotation renders right-aligned like the
+stock categories. The \"[done]\" sentinel has no station and annotates as
+nil rather than a bogus zero row."
+ (cj/completion-ensure-marginalia-align 'cj-radio-station)
+ (lambda (string pred action)
+ (if (eq action 'metadata)
+ `(metadata
+ (category . cj-radio-station)
+ (annotation-function
+ . ,(lambda (c)
+ (when-let ((st (cdr (assoc c candidates))))
+ (concat " " (propertize (cj/music-radio--format-candidate st)
+ 'face 'completions-annotations))))))
+ (complete-with-action action (mapcar #'car candidates) string pred))))
+
+(defun cj/music-radio--pick-loop (candidates)
+ "Repeatedly prompt to pick from CANDIDATES until \"[done]\" is chosen.
+CANDIDATES is a (DISPLAY . STATION) alist. Returns the chosen station plists in
+selection order; each pick is removed from the pool so it can't be chosen twice."
+ (let ((pool (copy-sequence candidates))
+ (chosen '())
+ (done nil))
+ (while (and (not done) pool)
+ (let ((pick (completing-read
+ (format "Add station (%d picked, RET [done] to finish): "
+ (length chosen))
+ (cj/music-radio--completion-table (cons '("[done]") pool))
+ nil t)))
+ (if (equal pick "[done]")
+ (setq done t)
+ (when-let ((cell (assoc pick pool)))
+ (push (cdr cell) chosen)
+ (setq pool (delq cell pool))))))
+ (nreverse chosen)))
+
+(defun cj/music-radio--search-and-play (query field)
+ "Search radio-browser for QUERY on FIELD, pick stations, then queue and play.
+FIELD is \"name\" or \"tag\". Lists matching stations (annotated with codec,
+bitrate, country, votes, and tags), lets you pick several one at a time, adds
+each to the playlist as a url track carrying its station metadata, and plays
+the first pick (interrupting whatever was playing). Nothing is written to
+disk; save the queue with the normal playlist save, where the station name
+pre-fills the prompt. QUERY is trimmed of surrounding whitespace first --
+a stray trailing space otherwise reaches the API as %20 and matches nothing."
+ (setq query (string-trim query))
+ (when (string-empty-p query)
+ (user-error "Empty search"))
+ (cj/emms--setup)
+ (let* ((stations (cj/music-radio--search query field))
+ (candidates (cj/music-radio--candidates stations)))
+ (unless candidates
+ (user-error "No stations found for %s %S" field query))
+ (let ((chosen (cj/music-radio--pick-loop candidates)))
+ (unless chosen
+ (user-error "No stations selected"))
+ (let ((tracks (delq nil (mapcar #'cj/music-radio--station-track chosen)))
+ (skipped (cl-loop for st in chosen
+ unless (cj/music-radio--station-url st)
+ collect (or (plist-get st :name) "(unnamed)"))))
+ (cj/music-radio--enqueue-and-play tracks)
+ (message "Queued %d station%s%s%s"
+ (length tracks)
+ (if (= (length tracks) 1) "" "s")
+ (if skipped
+ (format ", skipped %d with no URL (%s)"
+ (length skipped) (string-join skipped ", "))
+ "")
+ (if tracks
+ (format " — playing %s"
+ (emms-track-get (car tracks) 'info-title))
+ ""))))))
+
+(defun cj/music-radio-search-by-name (query)
+ "Search radio-browser.info by station name, then queue and play a selection."
+ (interactive "sRadio search (name): ")
+ (cj/music-radio--search-and-play query "name"))
+
+(defun cj/music-radio--tags-url (server)
+ "Build the radio-browser tag-list URL against SERVER.
+Ordered by station count descending so the limit keeps the popular tags."
+ (format "https://%s/json/tags?order=stationcount&reverse=true&limit=%d"
+ server cj/music-radio-tag-limit))
+
+(defun cj/music-radio--parse-tags (json-text)
+ "Parse a radio-browser JSON-TEXT tag array into a clean list of tag names.
+Names come back whitespace-trimmed with empties and duplicates dropped --
+the source data is user-generated and carries all three. Signals
+`user-error' on unreadable JSON (via `cj/music-radio--parse-search')."
+ (let ((names '()))
+ (dolist (tag (cj/music-radio--parse-search json-text))
+ (let ((name (string-trim (or (plist-get tag :name) ""))))
+ (unless (or (string-empty-p name) (member name names))
+ (push name names))))
+ (nreverse names)))
+
+(defun cj/music-radio--available-tags ()
+ "Return cached radio-browser tag names, fetching once per session.
+Returns nil when the fetch or parse fails, leaving the cache empty so a
+later call retries; the tag prompt then falls back to free-form input."
+ (or cj/music-radio--tags-cache
+ (setq cj/music-radio--tags-cache
+ (ignore-errors
+ (when-let ((body (cj/music-radio--http-get
+ (cj/music-radio--tags-url cj/music-radio-server))))
+ (cj/music-radio--parse-tags body))))))
+
+(defun cj/music-radio-search-by-tag (tag)
+ "Search radio-browser.info by tag/genre, then queue and play a selection.
+The prompt completes over the popular tags fetched from radio-browser
+\(cached per session), so you pick from tags that exist instead of
+guessing. Free-form input still works for an unlisted tag, and the prompt
+degrades to plain input when the tag fetch fails."
+ (interactive
+ (list (completing-read "Radio search (tag): "
+ (cj/music-radio--available-tags))))
+ (cj/music-radio--search-and-play tag "tag"))
+
+;; ------------------------------- Cover art -----------------------------------
+;; A track maps to a local cover-image path: a cached favicon/album art, or a
+;; shipped vinyl placeholder. `cj/music-art--for-track' is non-blocking (it
+;; reads only the cache) so the row renderer can call it during redisplay;
+;; `cj/music-art--ensure' does the network fetch off the render path. The pure
+;; pieces (cache key, favicon URL, image validation) carry the tests; the fetch
+;; is a live smoke test. Consumed by the Phase 3 fancy render.
+
+(require 'image)
+
+(defvar cj/music-art-cache-dir
+ (expand-file-name "music-art/" (expand-file-name "data/" user-emacs-directory))
+ "Directory holding fetched or extracted cover art, keyed by station UUID or a
+file hash. Gitignored runtime state; `cj/music-clear-art-cache' empties it.")
+
+(defvar cj/music-art-placeholder
+ (expand-file-name "vinyl-placeholder.svg"
+ (expand-file-name "assets/" user-emacs-directory))
+ "Shipped vinyl-record placeholder shown when a track has no cover art.")
+
+(defun cj/music-art--cache-key (track &optional entries)
+ "Stable cache-file basename (no extension) for TRACK.
+A url with a station uuid — the track's `radio-uuid' property, else a
+#RADIOBROWSERUUID in ENTRIES — keys on the uuid so a station shares one cached
+logo; any other url keys on a hash of its address; a file keys on a hash of
+its path."
+ (let ((name (emms-track-name track)))
+ (if (eq (emms-track-type track) 'url)
+ (let ((uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get (cdr (assoc name entries)) :uuid))))
+ (if (and (stringp uuid) (not (string-empty-p uuid)))
+ uuid
+ (concat "url-" (sha1 name))))
+ (concat "file-" (sha1 name)))))
+
+(defun cj/music-art--favicon-url (track &optional entries)
+ "Direct favicon image URL for a url TRACK, or nil.
+The track's `radio-favicon' property wins, then its captured
+#RADIOBROWSERFAVICON from ENTRIES. A station with only a uuid resolves via a
+byuuid lookup elsewhere; a file track has no favicon URL."
+ (when (eq (emms-track-type track) 'url)
+ (let ((fav (or (emms-track-get track 'radio-favicon)
+ (plist-get (cdr (assoc (emms-track-name track) entries))
+ :favicon))))
+ (and (stringp fav) (not (string-empty-p fav)) fav))))
+
+(defun cj/music-art--valid-image-p (data)
+ "Non-nil when DATA looks like a displayable image (a recognizable image
+header), so an empty body, an HTML error page, or a text response is rejected
+before it is cached."
+ (and (stringp data) (not (string-empty-p data))
+ (image-type-from-data data) t))
+
+(defun cj/music-art--cached-file (key)
+ "Return an existing cached art file for KEY (any extension), or nil."
+ (car (file-expand-wildcards
+ (expand-file-name (concat key ".*") cj/music-art-cache-dir))))
+
+(defun cj/music-art--file-cover (track)
+ "Return a sibling cover image (cover/folder/front .jpg/.jpeg/.png) next to a
+file TRACK, or nil. Embedded-tag art extraction is deferred (vNext)."
+ (when (eq (emms-track-type track) 'file)
+ (when-let ((dir (file-name-directory (emms-track-name track))))
+ (cl-loop for base in '("cover" "folder" "front")
+ thereis (cl-loop for ext in '("jpg" "jpeg" "png")
+ for f = (expand-file-name (concat base "." ext) dir)
+ when (file-exists-p f) return f)))))
+
+(defun cj/music-art--fetch-to-cache (url key)
+ "Fetch URL and, if it is a valid image, write it into the art cache under KEY.
+Returns the cached path, or nil on a failed or non-image response. Blocks on
+the network, so call it off the redisplay path. Only http/https URLs are
+fetched, so an external favicon field can't point the reader at a file:// or
+other-scheme resource."
+ (when-let* (((string-match-p "\\`https?://" url))
+ (data (cj/music-radio--http-get url))
+ ((cj/music-art--valid-image-p data)))
+ (make-directory cj/music-art-cache-dir t)
+ (let ((path (expand-file-name
+ (concat key "." (symbol-name (image-type-from-data data)))
+ cj/music-art-cache-dir))
+ (coding-system-for-write 'binary))
+ (with-temp-file path
+ (set-buffer-multibyte nil)
+ (insert data))
+ path)))
+
+(defun cj/music-art--byuuid-favicon (uuid)
+ "Look up station UUID via radio-browser byuuid and return its favicon URL, or
+nil. The fallback for a legacy station that carries a uuid but no captured
+favicon. Blocks on the network."
+ (when-let* ((body (cj/music-radio--http-get
+ (format "https://%s/json/stations/byuuid/%s"
+ cj/music-radio-server uuid)))
+ (stations (ignore-errors (cj/music-radio--parse-search body)))
+ (fav (plist-get (car stations) :favicon)))
+ (and (stringp fav) (not (string-empty-p fav)) fav)))
+
+(defun cj/music-art--for-track (track)
+ "Local cover-art path for TRACK, WITHOUT any network: an already-cached file,
+a sibling cover for a local file, else the vinyl placeholder. Never blocks, so
+the row renderer can call it during redisplay; `cj/music-art--ensure' does the
+fetch off the render path."
+ (let ((key (cj/music-art--cache-key track (cj/music--radio-metadata))))
+ (or (cj/music-art--cached-file key)
+ (cj/music-art--file-cover track)
+ cj/music-art-placeholder)))
+
+(defun cj/music-art--ensure (track)
+ "Fetch and cache TRACK's cover art if it is not cached yet. Blocks on the
+network, so call it off the redisplay path. Returns the cached path, or nil
+when there is nothing to fetch."
+ (let* ((entries (cj/music--radio-metadata))
+ (key (cj/music-art--cache-key track entries)))
+ (unless (cj/music-art--cached-file key)
+ (when (eq (emms-track-type track) 'url)
+ (let ((fav (or (cj/music-art--favicon-url track entries)
+ (let ((uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get (cdr (assoc (emms-track-name track)
+ entries))
+ :uuid))))
+ (and (stringp uuid) (not (string-empty-p uuid))
+ (cj/music-art--byuuid-favicon uuid))))))
+ (and fav (cj/music-art--fetch-to-cache fav key)))))))
+
+(defun cj/music-clear-art-cache ()
+ "Delete every cached cover-art file so art is re-fetched on next need."
+ (interactive)
+ (when (file-directory-p cj/music-art-cache-dir)
+ (dolist (f (directory-files cj/music-art-cache-dir t "\\`[^.]"))
+ (delete-file f)))
+ (message "Cleared music art cache: %s" cj/music-art-cache-dir))
+
+;; Radio row in the playlist buffer: n = search by name, t = search by tag,
+;; m = enter a station by hand. Single-track mode is on 1 and s saves the
+;; playlist; stop was dropped (SPC/pause covers it). These run after
+;; use-package's :map, so they win (see the header's Mode/Keys/Radio rows).
(with-eval-after-load 'emms
- (keymap-set emms-playlist-mode-map "R" #'cj/music-create-radio-station))
+ (keymap-set emms-playlist-mode-map "n" #'cj/music-radio-search-by-name)
+ (keymap-set emms-playlist-mode-map "t" #'cj/music-radio-search-by-tag)
+ (keymap-set emms-playlist-mode-map "m" #'cj/music-create-radio-station)
+ (keymap-set emms-playlist-mode-map "1" #'emms-toggle-repeat-track)
+ (keymap-set emms-playlist-mode-map "s" #'cj/music-playlist-save))
(provide 'music-config)
;;; music-config.el ends here
diff --git a/modules/nov-reading.el b/modules/nov-reading.el
index 4134f497..3af8721c 100644
--- a/modules/nov-reading.el
+++ b/modules/nov-reading.el
@@ -10,7 +10,7 @@
;; keymap reference; the faces must exist for theme-studio's inventory too.
;; Top-level side effects: defface x9 (3 palettes + per-palette heading/link),
;; defcustoms, a defgroup, a defvar.
-;; Runtime requires: none (face-remap and text-scale are built in).
+;; Runtime requires: font-profiles (shared workflow profile data).
;; Direct test load: yes.
;;
;; A small theme layer on top of the stock `nov' package (no fork): how an EPUB
@@ -20,8 +20,9 @@
;; - Reading palette -- the background + foreground, as sepia / dark / light,
;; each a face the dupre theme / theme-studio own (registered as the
;; "nov-reading" bespoke app in theme-studio's face_data.py).
-;; - Typography -- a serif family and a base height, with +/-/= adjusting the
-;; page font size live via a buffer-local text-scale on top of the base.
+;; - Typography -- the shared Reading font profile and a nov-specific base
+;; height, with +/-/= adjusting the page font size live via a buffer-local
+;; text-scale on top of the base.
;; The live size is remembered globally, so every book opens where you left
;; it; "=" returns to the base height.
;;
@@ -31,6 +32,8 @@
;;; Code:
+(require 'font-profiles)
+
(defgroup cj/nov-reading nil
"Reading-view theming for nov-mode EPUBs."
:group 'cj)
@@ -93,12 +96,12 @@
:group 'cj/nov-reading)
(defcustom cj/nov-reading-palettes
- '(("sepia" :face cj/nov-reading-sepia
- :heading cj/nov-reading-sepia-heading
- :link cj/nov-reading-sepia-link)
- ("dark" :face cj/nov-reading-dark
+ '(("dark" :face cj/nov-reading-dark
:heading cj/nov-reading-dark-heading
:link cj/nov-reading-dark-link)
+ ("sepia" :face cj/nov-reading-sepia
+ :heading cj/nov-reading-sepia-heading
+ :link cj/nov-reading-sepia-link)
("light" :face cj/nov-reading-light
:heading cj/nov-reading-light-heading
:link cj/nov-reading-light-link))
@@ -114,7 +117,7 @@ palette; omit :heading or :link to leave that element at the theme's default."
(plist :options ((:face face) (:heading face) (:link face))))
:group 'cj/nov-reading)
-(defcustom cj/nov-reading-default-palette "sepia"
+(defcustom cj/nov-reading-default-palette "dark"
"Reading palette applied to a fresh nov-mode buffer.
A key in `cj/nov-reading-palettes', or nil for the theme's normal rendering."
:type '(choice (const :tag "None (theme default)" nil) string)
@@ -194,9 +197,9 @@ Interactively prompts among `cj/nov-reading-palettes' plus \"none\"."
;; ------------------------------- Typography ----------------------------------
-(defcustom cj/nov-reading-font-family "Merriweather"
- "Variable-pitch serif family for the EPUB reading view."
- :type 'string
+(defcustom cj/nov-reading-profile 'reading
+ "Shared font profile applied buffer-locally to the EPUB reading view."
+ :type 'symbol
:group 'cj/nov-reading)
(defcustom cj/nov-reading-text-height 180
@@ -214,6 +217,9 @@ returns to this base."
A single integer: the buffer-local `text-scale-mode-amount' the +/-/= keys
last set, applied on top of `cj/nov-reading-text-height' when a book opens.")
+(defvar-local cj/nov--typography-remap-cookies nil
+ "Face-remap cookies for the shared font profile in this nov buffer.")
+
(defun cj/nov-reading--parse-text-scale (s)
"Parse S (a string or nil) as an integer text-scale offset; 0 when invalid.
Surrounding whitespace is tolerated; non-integer content yields 0."
@@ -239,15 +245,11 @@ Creates the data directory when absent."
(insert (number-to-string amount))))
(defun cj/nov-reading-apply-typography ()
- "Apply the reading family and base height buffer-local.
-Remaps `variable-pitch', `default', and `fixed-pitch' so nov's shr output reads
-as a comfortably-sized serif page."
- (face-remap-add-relative 'variable-pitch
- :family cj/nov-reading-font-family :height 1.0)
- (face-remap-add-relative 'default
- :family cj/nov-reading-font-family
- :height cj/nov-reading-text-height)
- (face-remap-add-relative 'fixed-pitch :height cj/nov-reading-text-height))
+ "Apply the shared reading profile at nov's base height buffer-locally."
+ (mapc #'face-remap-remove-relative cj/nov--typography-remap-cookies)
+ (setq cj/nov--typography-remap-cookies
+ (cj/font-profile-remap-buffer
+ cj/nov-reading-profile cj/nov-reading-text-height)))
(defun cj/nov-reading-text-bigger ()
"Increase the page font size and remember it across books and sessions."
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el
index 207c286e..1e91fa48 100644
--- a/modules/org-agenda-config.el
+++ b/modules/org-agenda-config.el
@@ -50,10 +50,10 @@
:demand t
:config
(setq org-agenda-prefix-format '((agenda . " %i %-25:c%?-12t% s")
- (timeline . " % s")
(todo . " %i %-25:c")
(tags . " %i %-12:c")
(search . " %i %-12:c")))
+ (setq org-agenda-timegrid-use-ampm t) ;; show the agenda time grid in 12-hour am/pm
(setq org-agenda-dim-blocked-tasks 'invisible)
(setq org-agenda-skip-scheduled-if-done nil)
(setq org-agenda-remove-tags t)
@@ -123,8 +123,15 @@ the file keeps precedence."
;; Cache agenda file list to avoid expensive directory scanning on every view.
;; The TTL+building cache lifecycle is provided by `cj-cache.el'.
-(defvar cj/--org-agenda-files-cache (cj/cache-make :ttl 3600)
- "Cache state for the agenda files list. See `cj-cache.el'.")
+(defvar cj/--org-agenda-files-cache (cj/cache-make :ttl 86400)
+ "Cache state for the agenda files list. See `cj-cache.el'.
+
+TTL is 24h. The cache holds only the file *list* (which files are agenda
+files), never their contents -- task edits and completions are re-read on
+every agenda build/redo regardless of cache age. The list only changes when
+a project directory with a todo.org is created or removed, which is rare, so a
+long TTL costs little; use `cj/org-agenda-refresh-files' (S-<f8>) to force a
+re-scan the moment a new project is added.")
;; ------------------------ Add Files To Org Agenda List -----------------------
;; Checks immediate subdirectories of DIRECTORY for todo.org files and adds
@@ -216,9 +223,16 @@ improves performance from several seconds to instant."
"Force rebuild of agenda files cache.
Use this after adding new projects or todo.org files.
-Bypasses cache and scans directories from scratch."
+Bypasses cache and scans directories from scratch.
+
+Bound to C-M-<f8>, the force-rebuild sibling of the F8 agenda family
+\(<f8> display, s-<f8> all files, C-<f8> single project, M-<f8> this buffer).
+The binding lives in `org-agenda-frame.el', which took S-<f8> for the
+agenda-frame toggle and moved the force-rescan here."
(interactive)
(cj/build-org-agenda-list 'force-rebuild))
+;; S-<f8> and C-M-<f8> are bound by `org-agenda-frame.el' (cj/--agenda-frame-install-keys):
+;; S-<f8> toggles the dedicated agenda frame; C-M-<f8> runs the force-rescan above.
(defun cj/todo-list-all-agenda-files ()
"Displays an \\='org-agenda\\=' todo list.
@@ -383,11 +397,14 @@ The agenda is rebuilt from all sources before display, including:
;; ------------------------- Add Timestamp To Org Entry ------------------------
;; simply adds a timestamp to put the org entry on an agenda
+(defvar cj/timeformat "%Y-%m-%d %a"
+ "Date format for the stamp `cj/add-timestamp-to-org-entry' inserts.
+Must stay an org-readable date so the stamped line reaches the agenda.")
+
(defun cj/add-timestamp-to-org-entry (s)
"Add an event with time S to appear underneath the line-at-point.
This allows a line to show in an agenda without being scheduled or a deadline."
(interactive "sTime: ")
- (defvar cj/timeformat "%Y-%m-%d %a")
(org-end-of-line)
(save-excursion
(open-line 1)
diff --git a/modules/org-agenda-frame.el b/modules/org-agenda-frame.el
new file mode 100644
index 00000000..471b246b
--- /dev/null
+++ b/modules/org-agenda-frame.el
@@ -0,0 +1,874 @@
+;;; org-agenda-frame.el --- Dedicated agenda frame -*- lexical-binding: t; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Layer: 4 (Optional).
+;; Category: O/D.
+;; Load shape: eager (binds keys in Phase 2; Phase 1 defines helpers only).
+;; Top-level side effects: none yet (Phase 1 is private helpers).
+;; Runtime requires: none.
+;; Direct test load: yes.
+;;
+;; A dedicated Emacs frame of the running daemon that shows a today-anchored
+;; seven-day org-agenda, refreshing itself, kept read-only and focus-locked.
+;; A normal (non-fullscreen) frame, so a tiling WM places it side by side with
+;; the working frame. Spawned/raised/closed by one key. See the spec:
+;; docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org.
+;;
+;; Phase 1 (this pass) builds non-interactive helpers only: frame lookup,
+;; spawn/raise/delete, the dedicated view, the default-deny read-only policy,
+;; and working-frame routing. No interactive command or key is bound until
+;; Phase 2, so nothing user-visible changes yet.
+
+;;; Code:
+
+(require 'seq)
+
+;; Declared, not required: `org-agenda-config' pulls in vc packages that don't
+;; load under `make test' (no package-initialize). The frame module references
+;; org-agenda symbols through these declarations and does its real wiring inside
+;; `with-eval-after-load' so a batch test-load runs no org-agenda side effects.
+(defvar org-agenda-custom-commands)
+(defvar org-agenda-finalize-hook)
+(defvar org-agenda-mode-map)
+(defvar org-agenda-sticky)
+(defvar org-agenda-window-setup)
+(declare-function cj/build-org-agenda-list "org-agenda-config" (&optional force-rebuild))
+(declare-function cj/org-agenda-refresh-files "org-agenda-config" ())
+(declare-function org-agenda-redo "org-agenda" (&optional all))
+(declare-function org-agenda-next-line "org-agenda" ())
+(declare-function org-agenda-previous-line "org-agenda" ())
+(declare-function org-agenda-next-item "org-agenda" (n))
+(declare-function org-agenda-previous-item "org-agenda" (n))
+(declare-function org-agenda-open-link "org-agenda" (&optional arg))
+(declare-function org-agenda-priority-down "org-agenda" ())
+(declare-function org-agenda-priority-up "org-agenda" ())
+(declare-function org-agenda-todo "org-agenda" (&optional arg))
+(declare-function org-agenda-todo-nextset "org-agenda" ())
+(declare-function org-agenda-todo-previousset "org-agenda" ())
+(declare-function org-get-at-bol "org" (property))
+(declare-function org-fold-show-context "org-fold" (&optional key))
+(declare-function org-agenda "org-agenda" (&optional arg org-keys restriction))
+;; No declare-function for -safe-redo / -delete: they are defined later in THIS
+;; file, and the byte-compiler resolves same-file forward references at end of
+;; compilation. A declare-function for a same-file function instead counts as a
+;; second definition ("defined multiple times") and, worse, its declared arglist
+;; overrides the real one for arg-count checking -- the empty () shadowed
+;; -safe-redo's actual (&optional frame), disabling that check.
+
+(defconst cj/--agenda-frame-parameter 'cj/agenda-frame
+ "Frame parameter marking the dedicated agenda frame.
+Its presence (non-nil) is how `cj/--agenda-frame' locates the frame among
+all of the daemon's frames.")
+
+(defvar cj/--agenda-frame-launch-frame nil
+ "The frame selected when the agenda frame was last spawned.
+Preferred routing target for source files opened from the agenda (see
+`cj/--agenda-frame-working-frame'); ignored once it is dead or is itself
+the agenda frame.")
+
+(defun cj/--agenda-frame-p (frame)
+ "Return non-nil when FRAME is a live agenda frame.
+FRAME is an agenda frame when it is live and carries the
+`cj/--agenda-frame-parameter' marker; a dead frame is never one."
+ (and (frame-live-p frame)
+ (frame-parameter frame cj/--agenda-frame-parameter)))
+
+(defun cj/--agenda-frame ()
+ "Return the live agenda frame, or nil.
+The frame is identified by the `cj/--agenda-frame-parameter' marker; a
+dead frame is never returned even if it still carries the marker."
+ (seq-find #'cj/--agenda-frame-p (frame-list)))
+
+(defun cj/--agenda-frame-working-frame ()
+ "Return a live non-agenda frame to route source files into, or nil.
+Prefer `cj/--agenda-frame-launch-frame' when it is still live and not the
+agenda frame; otherwise the first live non-agenda frame among all frames.
+Return nil when the agenda frame is the only live frame -- the caller then
+creates a normal frame."
+ (or (and (frame-live-p cj/--agenda-frame-launch-frame)
+ (not (cj/--agenda-frame-p cj/--agenda-frame-launch-frame))
+ cj/--agenda-frame-launch-frame)
+ (seq-find (lambda (frame)
+ (and (frame-live-p frame)
+ (not (cj/--agenda-frame-p frame))))
+ (frame-list))))
+
+;;; The dedicated seven-day view (org-agenda-custom-commands key F)
+
+(defconst cj/--agenda-frame-command-key "F"
+ "The `org-agenda-custom-commands' key for the agenda-frame view.
+The existing top-level key is `d' (org-agenda-config.el:344), so `F' is
+collision-free. The sticky buffer derives its name from this key
+\(*Org Agenda(F)*).")
+
+(defvar cj/--agenda-frame-span 7
+ "Span, in days, of the agenda-frame view.
+The `F' custom command reads this via its `org-agenda-span' setting, which
+Org evaluates on every build and every redo, so `cj/--agenda-frame-day-view'
+\(span 1) and `cj/--agenda-frame-week-view' (span 7) change it and the next
+redo picks up the new span. Reset to 7 on each spawn so a fresh frame opens
+at the documented default.")
+
+(defun cj/--agenda-frame-command ()
+ "Return the `org-agenda-custom-commands' entry for the agenda frame.
+A one-block agenda: a `cj/--agenda-frame-span'-day span anchored to today
+rather than
+Monday (`org-agenda-list' otherwise anchors any seven-day span to the
+week start in Org 9.7.11), rendered in the frame's sole window
+\(`current-window', so Org's default `reorganize-frame' can't split it),
+as its own sticky *Org Agenda(F)* buffer, with follow-mode forced off so
+a non-nil global default can't open a second window at build time."
+ `(,cj/--agenda-frame-command-key "Agenda frame: 7-day today-anchored"
+ ((agenda ""
+ ((org-agenda-span cj/--agenda-frame-span)
+ (org-agenda-start-day "0d")
+ (org-agenda-start-on-weekday nil)
+ (org-agenda-start-with-follow-mode nil)
+ ;; The global daily agenda intentionally keeps scheduled done
+ ;; items visible. The Full Agenda is a live work surface, so
+ ;; completed items are noise regardless of schedule or priority.
+ (org-agenda-skip-function
+ '(org-agenda-skip-entry-if 'todo 'done))
+ ;; Narrow category column: the global agenda format pads the
+ ;; category to 25 chars, leaving a wide blank gutter between
+ ;; the source name (todo:, dcal:) and the item.
+ (org-agenda-prefix-format " %i %-10:c%?-12t% s"))))
+ ;; No `org-agenda-sticky' here, deliberately: these settings are baked
+ ;; into the buffer's series-redo-cmd and re-applied by every redo, and a
+ ;; sticky t mid-redo makes `org-agenda-use-sticky-p' true while the
+ ;; buffer exists -- `org-agenda-prepare' then throws \\='exit ("use `r'
+ ;; to refresh") with no catch, failing every refresh tick. Stickiness
+ ;; is bound in the spawn wrapper instead, where it names the buffer.
+ ((org-agenda-window-setup 'current-window))))
+
+(defun cj/--agenda-frame-register-command ()
+ "Register the agenda-frame view in `org-agenda-custom-commands'.
+Idempotent: any existing entry for `cj/--agenda-frame-command-key' is
+replaced, so a module reload never accumulates duplicate keys."
+ (setq org-agenda-custom-commands
+ (cons (cj/--agenda-frame-command)
+ (assoc-delete-all cj/--agenda-frame-command-key
+ org-agenda-custom-commands))))
+
+;;; Engage routing — open the item's source outside the agenda frame
+
+(defun cj/--agenda-frame-item-marker ()
+ "Return the source marker for the agenda item at point, or nil.
+Prefers the item's own marker, falling back to the heading marker. Reads
+the text property directly (like `org-get-at-bol') so point restoration
+is exercisable without loading org."
+ (or (get-text-property (line-beginning-position) 'org-marker)
+ (get-text-property (line-beginning-position) 'org-hd-marker)))
+
+(defun cj/--agenda-frame-target-frame ()
+ "Return the frame to open agenda source in, creating one when needed.
+The engage action never opens into the agenda frame: it targets the
+working frame (`cj/--agenda-frame-working-frame'), and when the agenda
+frame is the only live frame it creates a normal, non-fullscreen frame."
+ (or (cj/--agenda-frame-working-frame)
+ (make-frame)))
+
+(defun cj/--agenda-frame-engage-open ()
+ "Open the source of the agenda item at point in the working frame.
+Routes to the MRU non-agenda frame (or a new normal frame when the agenda
+frame is the only one), so the agenda frame keeps showing the agenda.
+Signals a `user-error' when point is not on an agenda item."
+ (interactive)
+ (let ((marker (cj/--agenda-frame-item-marker)))
+ (unless (and marker (marker-buffer marker))
+ (user-error "No agenda item on this line"))
+ (let ((buffer (marker-buffer marker))
+ (pos (marker-position marker))
+ (frame (cj/--agenda-frame-target-frame)))
+ (select-frame-set-input-focus frame)
+ (pop-to-buffer-same-window buffer)
+ (widen)
+ (goto-char pos)
+ (when (derived-mode-p 'org-mode)
+ (org-fold-show-context 'agenda))
+ (beginning-of-line))))
+
+(defun cj/--agenda-frame-engage-mouse (event)
+ "Open the agenda item clicked by EVENT in the working frame."
+ (interactive "e")
+ (mouse-set-point event)
+ (cj/--agenda-frame-engage-open))
+
+(defun cj/--agenda-frame-open-link ()
+ "Follow the link in the agenda item at point, in the working frame."
+ (interactive)
+ (select-frame-set-input-focus (cj/--agenda-frame-target-frame))
+ (org-agenda-open-link))
+
+(defun cj/--agenda-frame-close ()
+ "Close the agenda frame from within it.
+Bound to q, Q, and x so Org's own quit keys delete the whole frame
+\(and cancel its timer) rather than leaving a sole-window agenda
+frame stranded on a non-agenda buffer."
+ (interactive)
+ (cj/--agenda-frame-delete))
+
+;;; Default-deny read-only policy
+
+(defconst cj/--agenda-frame-readonly-message
+ "Agenda frame is read-only — press RET to edit in your working frame"
+ "Shown when a mutating or buffer-opening command is denied in the frame.")
+
+(defconst cj/--agenda-frame-fixed-view-message
+ "Agenda frame shows only the day (d) and week (w) views"
+ "Shown when a view-changing command is denied in the frame.")
+
+(defun cj/--agenda-frame-denied-readonly ()
+ "Deny a mutating or buffer-opening command in the agenda frame.
+The default binding for every key not on the allowlist."
+ (interactive)
+ (message "%s" cj/--agenda-frame-readonly-message))
+
+(defun cj/--agenda-frame-denied-fixed-view ()
+ "Deny a view-changing command that would break the today-anchored span."
+ (interactive)
+ (message "%s" cj/--agenda-frame-fixed-view-message))
+
+(defvar cj/agenda-frame-mode-map
+ (let ((map (make-sparse-keymap)))
+ ;; Default-deny: every key/mouse event not rebound below funnels through
+ ;; this one catch-all, so mutations (present and future) are read-only.
+ (define-key map [t] #'cj/--agenda-frame-denied-readonly)
+ ;; Hide the Org Agenda menu-bar entry so there is no menu path to a mutation.
+ (define-key map [menu-bar org-agenda] #'undefined)
+ ;; (a) Navigation — allowlisted to their org-agenda commands.
+ (define-key map (kbd "n") #'org-agenda-next-line)
+ (define-key map (kbd "p") #'org-agenda-previous-line)
+ (define-key map (kbd "<down>") #'org-agenda-next-line)
+ (define-key map (kbd "<up>") #'org-agenda-previous-line)
+ (define-key map (kbd "C-n") #'org-agenda-next-line)
+ (define-key map (kbd "C-p") #'org-agenda-previous-line)
+ (define-key map (kbd "N") #'org-agenda-next-item)
+ (define-key map (kbd "P") #'org-agenda-previous-item)
+ (define-key map (kbd "C-v") #'scroll-up-command)
+ (define-key map (kbd "M-v") #'scroll-down-command)
+ (define-key map (kbd "M-<") #'beginning-of-buffer)
+ (define-key map (kbd "M->") #'end-of-buffer)
+ ;; Read-only point motion and search within the agenda.
+ (define-key map (kbd "C-a") #'move-beginning-of-line)
+ (define-key map (kbd "C-e") #'move-end-of-line)
+ (define-key map (kbd "C-f") #'forward-char)
+ (define-key map (kbd "C-b") #'backward-char)
+ (define-key map (kbd "C-s") #'isearch-forward)
+ (define-key map (kbd "C-r") #'isearch-backward)
+ (define-key map (kbd "C-g") #'keyboard-quit)
+ ;; (b) Engage / open — routed to the working frame, never the agenda frame.
+ ;; Bind the GUI function-key events ([return]/[tab]) as well as the ASCII
+ ;; forms: the [t] catch-all otherwise gives `return'/`tab' a binding, which
+ ;; suppresses their function-key translation to RET/TAB, so a bare RET would
+ ;; hit the deny handler instead of engaging in a graphical frame.
+ (define-key map (kbd "RET") #'cj/--agenda-frame-engage-open)
+ (define-key map (kbd "TAB") #'cj/--agenda-frame-engage-open)
+ (define-key map [return] #'cj/--agenda-frame-engage-open)
+ (define-key map [tab] #'cj/--agenda-frame-engage-open)
+ (define-key map (kbd "<mouse-2>") #'cj/--agenda-frame-engage-mouse)
+ (define-key map (kbd "C-c C-o") #'cj/--agenda-frame-open-link)
+ ;; (c) The frame's own controls.
+ (define-key map (kbd "q") #'cj/--agenda-frame-close)
+ (define-key map (kbd "Q") #'cj/--agenda-frame-close)
+ (define-key map (kbd "x") #'cj/--agenda-frame-close)
+ (define-key map (kbd "r") #'cj/--agenda-frame-safe-redo)
+ ;; g is the muscle-memory agenda refresh; keep it working here (the
+ ;; frame-scoped safe redo, same as r) rather than denying it as a
+ ;; view-change. C-M-<f8> stays the force-rescan.
+ (define-key map (kbd "g") #'cj/--agenda-frame-safe-redo)
+ ;; d / w toggle the span (today's day vs the seven-day view) in place; the
+ ;; other view-changers stay denied to keep the today-anchored frame stable.
+ (define-key map (kbd "d") #'cj/--agenda-frame-day-view)
+ (define-key map (kbd "w") #'cj/--agenda-frame-week-view)
+ ;; (d) Controlled task mutations. The frame remains default-deny for
+ ;; scheduling, clocking, capture, notes, and file writes, but status and
+ ;; priority are deliberate in-place agenda operations. C-c t is Craig's
+ ;; requested status chord; C-c C-t keeps Org's standard agenda chord.
+ ;; This config uses Super arrows in ordinary Org agendas, while Meta arrows
+ ;; are the requested Full Agenda muscle memory, so support both.
+ (define-key map (kbd "C-c t") #'org-agenda-todo)
+ (define-key map (kbd "C-c C-t") #'org-agenda-todo)
+ (dolist (key '("M-<up>" "s-<up>"))
+ (define-key map (kbd key) #'org-agenda-priority-up))
+ (dolist (key '("M-<down>" "s-<down>"))
+ (define-key map (kbd key) #'org-agenda-priority-down))
+ (dolist (key '("M-<left>" "s-<left>"))
+ (define-key map (kbd key) #'org-agenda-todo-previousset))
+ (dolist (key '("M-<right>" "s-<right>"))
+ (define-key map (kbd key) #'org-agenda-todo-nextset))
+ (define-key map (kbd "S-<f8>") #'cj/agenda-frame-toggle)
+ (define-key map (kbd "C-M-<f8>") #'cj/org-agenda-refresh-files)
+ ;; C-x C-c means "close this frame" here. The global
+ ;; `save-buffers-kill-terminal' must never run in this frame: it was made
+ ;; by `make-frame', not emacsclient, so with no client to close it falls
+ ;; back to killing the daemon itself.
+ (define-key map (kbd "C-x C-c") #'cj/--agenda-frame-close)
+ ;; (e) Input machinery punched through the catch-all. An explicit nil
+ ;; shadows the [t] default in this map, so these fall through to their
+ ;; global bindings. Without the punches, every frame-focus change
+ ;; (switch-frame), every wheel scroll, and every mouse click hits the
+ ;; deny handler -- message spam and broken frame switching.
+ (dolist (key (list [switch-frame]
+ [wheel-up] [wheel-down] [wheel-left] [wheel-right]
+ [double-wheel-up] [double-wheel-down]
+ [triple-wheel-up] [triple-wheel-down]
+ [mouse-1] [down-mouse-1] [drag-mouse-1]
+ (kbd "C-h")))
+ (define-key map key nil))
+ ;; (f) Global chords that would pull focus out of the frame must be
+ ;; denied *explicitly*. The [t] catch-all can't reach them: a keymap's
+ ;; default binding does not shadow an *explicit* binding in a
+ ;; lower-priority map, and these are bound in the global map (M-SPC /
+ ;; M-S-SPC swap ai-term agents). Left to the catch-all, M-SPC follows
+ ;; its global binding and escapes the read-only frame into ai-term.
+ (dolist (key '("M-SPC" "M-S-SPC"))
+ (define-key map (kbd key) #'cj/--agenda-frame-denied-readonly))
+ ;; The remaining view-changers get the distinct fixed-view message, not the
+ ;; read-only one. d/w are handled above (they toggle the span in place).
+ (dolist (key '("y" "f" "b" "j"))
+ (define-key map (kbd key) #'cj/--agenda-frame-denied-fixed-view))
+ map)
+ "Keymap for `cj/agenda-frame-mode'.
+Shadows `org-agenda-mode-map' by default-deny: the `[t]' catch-all denies
+every key that is not explicitly allowlisted here. Status and priority are
+the only source-task mutations allowed; a future Org binding is denied by
+default and there is nothing to keep in sync.")
+
+(define-minor-mode cj/agenda-frame-mode
+ "Focus-locked, default-deny policy for the dedicated agenda frame.
+Only the allowlist in `cj/agenda-frame-mode-map' is permitted: navigation,
+the engage/open keys (routed to the working frame), and the frame's own
+controls, plus controlled task status and priority changes. Every other
+key/mouse command is denied. The enforcement boundary is keys and mouse;
+a direct \\[execute-extended-command] is out of contract."
+ :init-value nil
+ :lighter " AgendaFrame"
+ :keymap cj/agenda-frame-mode-map)
+
+(defun cj/--agenda-frame-shadow-mutations (&optional source-map prefix)
+ "Deny every SOURCE-MAP key sequence not on the frame map's allowlist.
+Walk SOURCE-MAP (default `org-agenda-mode-map') recursively. For each
+sequence it binds to a command, if `cj/agenda-frame-mode-map' doesn't already
+bind that sequence to a command or manage it as a prefix, add an explicit
+read-only deny.
+
+This closes the default-deny hole: a keymap's `[t]' default never shadows an
+explicit binding in a lower-priority map, so a single `[t]' catch-all denies
+only keys that are unbound everywhere. Every key `org-agenda-mode-map' binds
+\(t, I, k, z, s, ., the C-c mutators, C-x C-s, ...) would otherwise sail
+through the catch-all and mutate source files from the read-only frame.
+Explicitly denying each non-allowlisted sequence makes the catch-all's intent
+actually hold.
+
+PREFIX is the accumulated key vector during recursion (internal). Idempotent:
+re-running rebinds the same denials. Runs from `with-eval-after-load' once
+`org-agenda-mode-map' exists."
+ (let ((source (or source-map org-agenda-mode-map))
+ (prefix (or prefix [])))
+ (map-keymap
+ (lambda (event binding)
+ (unless (or (eq event t) (eq event 'menu-bar) (eq event 'remap)
+ (consp event))
+ (let ((seq (vconcat prefix (vector event))))
+ (cond
+ ((keymapp binding)
+ (cj/--agenda-frame-shadow-mutations binding seq))
+ ((commandp binding)
+ (let ((ours (lookup-key cj/agenda-frame-mode-map seq)))
+ ;; A command we allowlisted or a prefix we manage: leave it.
+ ;; Anything else (only the `[t]' default, or unbound under a
+ ;; shared prefix) escapes to org's command -- deny it here.
+ (unless (or (commandp ours) (keymapp ours))
+ (define-key cj/agenda-frame-mode-map seq
+ #'cj/--agenda-frame-denied-readonly))))))))
+ source)))
+
+;; The shadow walk is installed at the END of this file, not here: it reads
+;; `commandp' on each allowlisted binding to decide whether to keep it, and the
+;; view/redo handlers (day-view, week-view, safe-redo) are defined further down.
+;; If org-agenda is already loaded when this file loads (the normal startup order,
+;; and every reload), `with-eval-after-load' fires immediately -- so the walk must
+;; not run until those defuns exist, or it reads them as undefined, fails the
+;; commandp guard, and denies the very keys the allowlist grants. See the bottom
+;; of the file.
+
+(defun cj/--agenda-frame-maybe-enable-mode ()
+ "Re-enable `cj/agenda-frame-mode' after an agenda build in the agenda frame.
+Added to `org-agenda-finalize-hook'. `org-agenda-redo' rebuilds through
+`org-agenda-mode', whose `kill-all-local-variables' strips the buffer-local
+minor mode; this reinstates it whenever the just-built buffer is displayed
+in the frame carrying the `cj/agenda-frame' marker (a frame parameter, which
+survives the buffer reset). Ordinary agenda builds in working frames are
+left untouched.
+
+The same reset also strips the buffer-local `kill-buffer-hook' installed at
+spawn, so it is re-added here too -- otherwise, after the first refresh
+tick, killing the buffer would no longer delete the frame."
+ (let ((frame (cj/--agenda-frame)))
+ (when (and frame (get-buffer-window (current-buffer) frame))
+ (cj/agenda-frame-mode 1)
+ (add-hook 'kill-buffer-hook #'cj/--agenda-frame-on-kill-buffer nil t))))
+
+;;; Frame lifecycle — spawn, raise, delete, toggle, cleanup
+
+(defconst cj/--agenda-frame-timer-parameter 'cj/agenda-frame-timer
+ "Frame parameter holding the agenda frame's refresh timer (set in Phase 2).")
+
+(declare-function auto-dim-other-buffers-mode "auto-dim-other-buffers" (&optional arg))
+
+(defvar cj/--agenda-frame-dim-was-on nil
+ "Non-nil when the agenda frame's spawn turned `auto-dim-other-buffers-mode' off.
+The refresh tick's selection swing marks the working window non-selected,
+and auto-dim's debounced dim lands after the tick -- the working frame
+visibly dims every five minutes. Spawn suspends the mode and remembers it
+here; closing the frame restores it.")
+
+(defun cj/--agenda-frame-suspend-dim ()
+ "Turn auto-dim off for the agenda frame's lifetime, remembering it was on."
+ (when (and (bound-and-true-p auto-dim-other-buffers-mode)
+ (fboundp 'auto-dim-other-buffers-mode))
+ (setq cj/--agenda-frame-dim-was-on t)
+ (auto-dim-other-buffers-mode -1)))
+
+(defun cj/--agenda-frame-restore-dim ()
+ "Restore auto-dim if the agenda frame's spawn suspended it."
+ (when (and cj/--agenda-frame-dim-was-on
+ (fboundp 'auto-dim-other-buffers-mode))
+ (setq cj/--agenda-frame-dim-was-on nil)
+ (auto-dim-other-buffers-mode 1)))
+
+(defvar cj/--agenda-frame-tearing-down nil
+ "Non-nil while the agenda frame is being torn down.
+Breaks the `delete-frame' / `kill-buffer-hook' re-entrancy loop: deleting
+the frame kills its buffer and killing the buffer deletes the frame, so
+whichever fires first sets this to skip the other.")
+
+(defun cj/--agenda-frame-sticky-buffer ()
+ "Return the dedicated *Org Agenda(F)* sticky buffer, or nil if none."
+ (get-buffer (format "*Org Agenda(%s)*" cj/--agenda-frame-command-key)))
+
+(defun cj/--agenda-frame-cancel-timer (&optional frame)
+ "Cancel and clear the refresh timer on FRAME (default: the agenda frame).
+Safe when no timer is set or FRAME is dead. Returns nil."
+ (let* ((frame (or frame (cj/--agenda-frame)))
+ (timer (and (frame-live-p frame)
+ (frame-parameter frame cj/--agenda-frame-timer-parameter))))
+ (when (timerp timer)
+ (cancel-timer timer))
+ (when (frame-live-p frame)
+ (set-frame-parameter frame cj/--agenda-frame-timer-parameter nil))
+ nil))
+
+(defun cj/--agenda-frame-on-delete-frame (frame)
+ "Clean up when the agenda FRAME dies by any path.
+Registered on `delete-frame-functions': cancels the refresh timer and
+kills the dedicated sticky buffer, so the next spawn regenerates fresh
+rather than reusing stale sticky content. A non-agenda frame is ignored."
+ (when (cj/--agenda-frame-p frame)
+ (cj/--agenda-frame-cancel-timer frame)
+ (cj/--agenda-frame-restore-dim)
+ (let ((buffer (cj/--agenda-frame-sticky-buffer))
+ (cj/--agenda-frame-tearing-down t))
+ (when (buffer-live-p buffer)
+ (kill-buffer buffer)))))
+
+(defun cj/--agenda-frame-on-kill-buffer ()
+ "Delete the agenda frame when its dedicated buffer is killed.
+A buffer-local `kill-buffer-hook' on the sticky buffer, so killing it from
+anywhere takes the frame with it. Guarded against re-entry during a
+frame-initiated teardown."
+ (unless cj/--agenda-frame-tearing-down
+ (let ((frame (cj/--agenda-frame)))
+ (when (frame-live-p frame)
+ (delete-frame frame)))))
+
+(defun cj/--agenda-frame-delete ()
+ "Delete the agenda frame; a no-op when none exists.
+`delete-frame' fires `cj/--agenda-frame-on-delete-frame', which cancels
+the timer and kills the sticky buffer."
+ (let ((frame (cj/--agenda-frame)))
+ (when (frame-live-p frame)
+ (delete-frame frame))))
+
+(defun cj/--agenda-frame-raise (frame)
+ "Raise FRAME and give it input focus. Returns FRAME."
+ (select-frame-set-input-focus frame)
+ frame)
+
+(defun cj/--agenda-frame-make-parameters ()
+ "Return the frame parameters for the dedicated agenda frame.
+A normal frame -- not fullscreen -- so a tiling window manager places it
+side by side with the working frame rather than covering the whole output.
+It carries the `cj/agenda-frame' marker and a distinct, noticeable name
+\(\"Full Agenda\") so the frame is recognizable at a glance and
+window-manager rules can target it."
+ `((,cj/--agenda-frame-parameter . t)
+ (name . "Full Agenda")))
+
+(defun cj/--agenda-frame-spawn ()
+ "Create, display, and focus the dedicated agenda frame.
+Transactional: on any failure after `make-frame', delete the partial
+frame (which cleans up its buffer and timer via the delete hook), restore
+focus to the launching frame, and signal a `user-error' naming the cause.
+Returns the new agenda frame on success."
+ (let ((launch (selected-frame))
+ (frame nil))
+ (condition-case err
+ (progn
+ (setq cj/--agenda-frame-launch-frame launch)
+ ;; A fresh frame opens at the documented seven-day default, even if a
+ ;; prior session left the span on the day view (d).
+ (setq cj/--agenda-frame-span 7)
+ (setq frame (make-frame (cj/--agenda-frame-make-parameters)))
+ (select-frame-set-input-focus frame)
+ ;; Cached, non-forced: a frame spawned early after daemon startup
+ ;; still shows the full project agenda, not the base-files-only view.
+ (cj/build-org-agenda-list)
+ ;; Bind sticky + current-window dynamically around the render. The
+ ;; custom command's own settings apply too late to name the buffer;
+ ;; without these the buffer is plain *Org Agenda*, which matches the
+ ;; 0.75 below-selected display rule in org-agenda-config.el -- the
+ ;; new frame gets split with the launch buffer left in the top 25%.
+ ;; Sticky names it *Org Agenda(F)*, which no display rule matches.
+ (let ((org-agenda-sticky t)
+ (org-agenda-window-setup 'current-window))
+ (org-agenda "a" cj/--agenda-frame-command-key))
+ ;; Belt: whatever a display rule did, the frame is one agenda window.
+ (delete-other-windows)
+ (let ((buffer (cj/--agenda-frame-sticky-buffer)))
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (add-hook 'kill-buffer-hook
+ #'cj/--agenda-frame-on-kill-buffer nil t))))
+ (cj/--agenda-frame-start-timer frame)
+ (cj/--agenda-frame-suspend-dim)
+ frame)
+ (error
+ (when (frame-live-p frame)
+ (delete-frame frame))
+ (when (frame-live-p launch)
+ (select-frame-set-input-focus launch))
+ (user-error "Agenda frame: spawn failed: %s"
+ (error-message-string err))))))
+
+(defun cj/--agenda-frame-toggle ()
+ "Spawn, raise, or delete the dedicated agenda frame.
+Spawn when none exists, delete when the agenda frame is the selected
+frame, raise and focus it otherwise.
+
+Non-interactive by design in Phase 1: reachable only from ERT, never from
+\\[execute-extended-command] or a key. Phase 2 wraps this in the public
+`cj/agenda-frame-toggle' and binds it to S-<f8>."
+ (let ((frame (cj/--agenda-frame)))
+ (cond
+ ((null frame) (cj/--agenda-frame-spawn))
+ ((eq frame (selected-frame)) (cj/--agenda-frame-delete) nil)
+ (t (cj/--agenda-frame-raise frame)))))
+
+;;; Phase 2 — refresh timer, snapshot restore, and the public command
+
+(defconst cj/--agenda-frame-refresh-seconds 300
+ "Refresh cadence for the agenda frame, in seconds (five minutes).")
+
+(defconst cj/--agenda-frame-fail-count-parameter 'cj/agenda-frame-fail-count
+ "Frame parameter holding the consecutive-failure count for the refresh timer.")
+
+(defconst cj/--agenda-frame-overlay-property 'cj/agenda-frame-failure
+ "Overlay property tagging the refresh-failed banner.
+The banner is found by scanning for this property, never held in a
+buffer-local variable: `org-agenda-redo' runs `kill-all-local-variables',
+which would wipe the variable while the overlay object survives
+`erase-buffer' -- leaving a banner nothing could ever remove.")
+
+(defun cj/--agenda-frame-seconds-to-next-mark (time period)
+ "Return seconds from TIME to the next wall-clock multiple of PERIOD.
+TIME is any Emacs time value, PERIOD is seconds (300 gives the :00/:05
+marks). A TIME exactly on a mark returns a full PERIOD, so the timer
+never fires twice back-to-back."
+ (let ((rem (mod (floor (float-time time)) period)))
+ (if (zerop rem) period (- period rem))))
+
+;; -- Point restoration -------------------------------------------------------
+
+(defun cj/--agenda-frame-goto-first-item ()
+ "Move point to the first agenda item, or `point-min' when the view is empty."
+ (goto-char (point-min))
+ (let ((found nil))
+ (while (and (not found) (not (eobp)))
+ (if (get-text-property (line-beginning-position) 'org-marker)
+ (setq found t)
+ (forward-line 1)))
+ (unless found (goto-char (point-min)))))
+
+(defun cj/--agenda-frame-restore-point (old-marker old-line)
+ "Restore point in the rebuilt agenda buffer after a redo.
+Prefer the line whose org-marker points at the same source location as
+OLD-MARKER, choosing the occurrence nearest OLD-LINE when a source line
+appears twice. When the marker is gone, clamp OLD-LINE into range; if
+that lands on a header (no item), move to the first item; an item-less
+view leaves point at buffer start."
+ (let ((max-line (line-number-at-pos (point-max)))
+ (targets '()))
+ (when (and (markerp old-marker) (marker-buffer old-marker))
+ (let ((src-buf (marker-buffer old-marker))
+ (src-pos (marker-position old-marker)))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (let ((m (get-text-property (line-beginning-position) 'org-marker)))
+ (when (and (markerp m)
+ (eq (marker-buffer m) src-buf)
+ (eql (marker-position m) src-pos))
+ (push (line-number-at-pos) targets)))
+ (forward-line 1)))))
+ (cond
+ (targets
+ (let ((best (car (sort targets
+ (lambda (a b)
+ (< (abs (- a old-line)) (abs (- b old-line))))))))
+ (goto-char (point-min))
+ (forward-line (1- best))))
+ (t
+ (let ((line (max 1 (min old-line max-line))))
+ (goto-char (point-min))
+ (forward-line (1- line))
+ (unless (get-text-property (line-beginning-position) 'org-marker)
+ (cj/--agenda-frame-goto-first-item)))))))
+
+;; -- Snapshot with cloned markers --------------------------------------------
+
+(defun cj/--agenda-frame-snapshot-markers (buffer)
+ "Return a list of (POSITION . CLONE) for every org-marker in BUFFER.
+CLONE is an independent `copy-marker' into the same source location, so
+it survives `org-agenda-reset-markers' nulling BUFFER's own markers on a
+rebuild."
+ (with-current-buffer buffer
+ (let ((clones '())
+ (pos (point-min)))
+ (while (< pos (point-max))
+ (let ((m (get-text-property pos 'org-marker)))
+ (when (and (markerp m) (marker-buffer m))
+ (push (cons pos (copy-marker m)) clones)))
+ (setq pos (or (next-single-property-change pos 'org-marker buffer)
+ (point-max))))
+ (nreverse clones))))
+
+(defun cj/--agenda-frame-reinstall-markers (buffer clones)
+ "Reapply CLONES (from `cj/--agenda-frame-snapshot-markers') to BUFFER.
+Restores each cloned marker as the org-marker text property at its
+recorded position, so RET/TAB resolve to the right source line after a
+snapshot restore."
+ (with-current-buffer buffer
+ (dolist (entry clones)
+ (let ((pos (car entry)))
+ (when (and (>= pos (point-min)) (< pos (point-max)))
+ (put-text-property pos (1+ pos) 'org-marker (cdr entry)))))))
+
+(defun cj/--agenda-frame-snapshot (buffer window)
+ "Capture BUFFER's last-good state for restore after a failed redo.
+Returns a plist of the propertized :text (carrying org-redo-cmd/org-lprops),
+:point, :window-start, and :markers (cloned, source-owned)."
+ (with-current-buffer buffer
+ (list :text (buffer-substring (point-min) (point-max))
+ :point (point)
+ :window-start (and (window-live-p window) (window-start window))
+ :markers (cj/--agenda-frame-snapshot-markers buffer))))
+
+(defun cj/--agenda-frame-restore-snapshot (buffer snapshot window)
+ "Restore SNAPSHOT verbatim into BUFFER, reinstating cloned markers.
+Sets point and, when WINDOW is live, window-start from the snapshot."
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert (plist-get snapshot :text))
+ (cj/--agenda-frame-reinstall-markers buffer (plist-get snapshot :markers))
+ (goto-char (min (plist-get snapshot :point) (point-max))))
+ (when (and (window-live-p window) (plist-get snapshot :window-start))
+ (set-window-start window (min (plist-get snapshot :window-start)
+ (point-max))))))
+
+(defun cj/--agenda-frame-release-snapshot (snapshot)
+ "Release SNAPSHOT's cloned markers so repeated redoes don't leak markers.
+Called on a successful redo (the snapshot is discarded); never on the
+error path, where the clones become the buffer's live org-markers."
+ (dolist (entry (plist-get snapshot :markers))
+ (when (markerp (cdr entry))
+ (set-marker (cdr entry) nil))))
+
+;; -- Failure latch and overlay -----------------------------------------------
+
+(defun cj/--agenda-frame-record-failure (frame)
+ "Increment FRAME's consecutive-failure count; return non-nil to report.
+Reports only on the first failure of a run (the 0 -> 1 transition)."
+ (let ((n (1+ (or (frame-parameter frame cj/--agenda-frame-fail-count-parameter)
+ 0))))
+ (set-frame-parameter frame cj/--agenda-frame-fail-count-parameter n)
+ (= n 1)))
+
+(defun cj/--agenda-frame-clear-failure (frame)
+ "Reset FRAME's consecutive-failure count (the next tick reports again)."
+ (set-frame-parameter frame cj/--agenda-frame-fail-count-parameter 0))
+
+(defun cj/--agenda-frame-failure-overlays (buffer)
+ "Return the refresh-failed banner overlays in BUFFER (normally 0 or 1)."
+ (with-current-buffer buffer
+ (seq-filter (lambda (o) (overlay-get o cj/--agenda-frame-overlay-property))
+ (overlays-in (point-min) (point-max)))))
+
+(defun cj/--agenda-frame-show-failure-overlay (buffer)
+ "Show the refresh-failed notice as an overlay at the top of BUFFER.
+Idempotent: an existing banner is reused, so consecutive failures never
+stack a second one."
+ (with-current-buffer buffer
+ (let ((overlay (or (car (cj/--agenda-frame-failure-overlays buffer))
+ (make-overlay (point-min) (point-min)))))
+ (overlay-put overlay cj/--agenda-frame-overlay-property t)
+ (overlay-put overlay 'before-string
+ (propertize "Agenda frame: refresh failed (C-M-<f8> to force-rescan)\n"
+ 'face 'warning)))))
+
+(defun cj/--agenda-frame-remove-overlay (buffer)
+ "Remove the refresh-failed banner from BUFFER, if present."
+ (when (buffer-live-p buffer)
+ (mapc #'delete-overlay (cj/--agenda-frame-failure-overlays buffer))))
+
+;; -- The refresh itself ------------------------------------------------------
+
+(defun cj/--agenda-frame-do-redo (frame buffer window)
+ "Redo the agenda in BUFFER, degrading to the last-good snapshot on failure.
+On success: drop the failure overlay, restore point, clear the failure
+latch, and release the pre-redo snapshot. On error: restore the snapshot
+verbatim, re-enable the policy (the finalize hook runs only on success),
+show the failure overlay, and report once per consecutive-failure run.
+Either way the frame is never blank, unrestricted, or non-retryable."
+ (with-current-buffer buffer
+ ;; Clone the point marker: `org-agenda-redo' calls `org-agenda-reset-markers'
+ ;; which nulls the buffer's own org-markers, so the raw marker would be dead
+ ;; by the time `cj/--agenda-frame-restore-point' runs -- collapsing the
+ ;; "follow the same source item" restoration to the line-number clamp on
+ ;; every normal tick. An independent clone survives the reset.
+ (let ((old-marker (let ((m (cj/--agenda-frame-item-marker)))
+ (and (markerp m) (marker-buffer m) (copy-marker m))))
+ (old-line (line-number-at-pos))
+ (snapshot (cj/--agenda-frame-snapshot buffer window)))
+ (unwind-protect
+ (condition-case nil
+ ;; Never bind sticky here: `org-agenda-redo' handles the
+ ;; in-place rebuild itself (binds sticky nil, redirects the
+ ;; buffer name). A sticky t reaching `org-agenda-prepare'
+ ;; mid-redo makes it throw \\='exit with no catch, failing
+ ;; every tick. current-window is bound as a belt so a rule
+ ;; can't split the frame during the rebuild.
+ (let ((inhibit-message t)
+ (org-agenda-window-setup 'current-window))
+ (org-agenda-redo)
+ (cj/--agenda-frame-remove-overlay buffer)
+ (cj/--agenda-frame-restore-point old-marker old-line)
+ (cj/--agenda-frame-clear-failure frame)
+ (cj/--agenda-frame-release-snapshot snapshot))
+ (error
+ (cj/--agenda-frame-restore-snapshot buffer snapshot window)
+ (cj/agenda-frame-mode 1)
+ (cj/--agenda-frame-show-failure-overlay buffer)
+ (when (cj/--agenda-frame-record-failure frame)
+ (message "Agenda frame: refresh failed (C-M-<f8> to force-rescan)"))))
+ (when (markerp old-marker)
+ (set-marker old-marker nil))))))
+
+(defun cj/--agenda-frame-safe-redo (&optional frame)
+ "Refresh the agenda buffer in FRAME safely (the timer tick and manual `r').
+Runs with the dedicated window selected for the redo's dynamic extent and
+restores the prior window afterward, never calling an input-focus
+function, so a tick while another frame is active neither errors on an
+out-of-range window-start nor steals focus."
+ (interactive)
+ (let* ((frame (or frame (cj/--agenda-frame)))
+ (buffer (cj/--agenda-frame-sticky-buffer))
+ (window (and (frame-live-p frame) (buffer-live-p buffer)
+ (get-buffer-window buffer frame))))
+ (when (and (window-live-p window)
+ ;; Skip the tick while a minibuffer is active anywhere --
+ ;; reselecting windows under an active minibuffer session can
+ ;; break it, and the next tick catches up.
+ (not (active-minibuffer-window)))
+ (let ((prev-window (selected-window))
+ ;; The rebuild takes visible time, and for its duration the
+ ;; agenda window is the selected window. Without inhibiting
+ ;; redisplay the user's cursor visibly goes hollow for the whole
+ ;; rebuild every tick -- indistinguishable from focus theft.
+ ;; The rebuild blocks Emacs either way (it is synchronous), so
+ ;; this hides the selection flicker at no extra cost; redisplay
+ ;; resumes after the selection is restored.
+ (inhibit-redisplay t))
+ (unwind-protect
+ (progn
+ (select-window window t)
+ (cj/--agenda-frame-do-redo frame buffer window))
+ (when (window-live-p prev-window)
+ (select-window prev-window t)))))))
+
+(defun cj/--agenda-frame-day-view ()
+ "Shrink the Full Agenda frame to today's single-day view.
+Sets the span to 1 and refreshes. The redo re-evaluates the span, so the
+day view survives the wall-clock refresh tick until `w' widens it again."
+ (interactive)
+ (setq cj/--agenda-frame-span 1)
+ (cj/--agenda-frame-safe-redo))
+
+(defun cj/--agenda-frame-week-view ()
+ "Restore the Full Agenda frame to the seven-day today-anchored view.
+Sets the span back to 7 and refreshes."
+ (interactive)
+ (setq cj/--agenda-frame-span 7)
+ (cj/--agenda-frame-safe-redo))
+
+(defun cj/--agenda-frame-start-timer (frame)
+ "Start FRAME's five-minute wall-clock refresh timer, unless one exists.
+Idempotent: a frame already carrying a live timer keeps it (no duplicate).
+Returns the timer."
+ (unless (timerp (frame-parameter frame cj/--agenda-frame-timer-parameter))
+ (let* ((period cj/--agenda-frame-refresh-seconds)
+ (delay (cj/--agenda-frame-seconds-to-next-mark (current-time) period))
+ (timer (run-at-time delay period #'cj/--agenda-frame-safe-redo frame)))
+ (set-frame-parameter frame cj/--agenda-frame-timer-parameter timer)
+ timer)))
+
+;; -- Public command and key install ------------------------------------------
+
+(defun cj/agenda-frame-toggle ()
+ "Toggle the dedicated agenda frame.
+Spawn it when none exists, raise and focus it when it exists but is
+unfocused, and close it when it is the selected frame."
+ (interactive)
+ (cj/--agenda-frame-toggle))
+
+(defun cj/--agenda-frame-install-keys (&optional map)
+ "Bind the F8-family keys for the agenda frame in MAP (default: the global map).
+S-<f8> toggles the agenda frame; the force-rescan
+\(`cj/org-agenda-refresh-files') moves to C-M-<f8>, keeping the whole
+force-refresh idea in the F8 family."
+ (let ((map (or map (current-global-map))))
+ (define-key map (kbd "S-<f8>") #'cj/agenda-frame-toggle)
+ (define-key map (kbd "C-M-<f8>") #'cj/org-agenda-refresh-files)))
+
+;;; Wiring — registered once org-agenda is loaded (no batch side effects)
+
+(with-eval-after-load 'org-agenda
+ (cj/--agenda-frame-register-command)
+ (add-hook 'org-agenda-finalize-hook #'cj/--agenda-frame-maybe-enable-mode)
+ (add-hook 'delete-frame-functions #'cj/--agenda-frame-on-delete-frame))
+
+;; The public gesture appears only now that the feature is complete and live.
+(cj/--agenda-frame-install-keys)
+
+;; Install the read-only shadow now that every allowlist handler above is
+;; defined, so the walk's `commandp' guard recognizes them and preserves the
+;; allowlist regardless of whether org-agenda loaded before or after this file.
+(with-eval-after-load 'org-agenda
+ (cj/--agenda-frame-shadow-mutations))
+
+(provide 'org-agenda-frame)
+;;; org-agenda-frame.el ends here
diff --git a/modules/org-babel-config.el b/modules/org-babel-config.el
index 79661013..51919da1 100644
--- a/modules/org-babel-config.el
+++ b/modules/org-babel-config.el
@@ -173,8 +173,5 @@ session when working in trusted files, and back on when done."
;; requires ob-racket, not yet in repositories
;; (add-to-list 'org-structure-template-alist '("sicp" . "src racket :lang sicp"))
-;; drop Org’s default footnote list at the end
-(setq org-html-footnote-separator "")
-
(provide 'org-babel-config)
;;; org-babel-config.el ends here.
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
index 292e26a7..b7250f1e 100644
--- a/modules/org-capture-config.el
+++ b/modules/org-capture-config.el
@@ -157,6 +157,8 @@ re-scanning large target files after the first successful lookup."
(interactive)
(user-error "Key disabled during capture -- finalize with C-c C-c or abort with C-c C-k"))
+(defvar org-capture-mode-map)
+
(with-eval-after-load 'org-capture
(dolist (key '("<f1>" "<f10>" "<f11>" "<f12>" "M-SPC"))
(keymap-set org-capture-mode-map key #'cj/--org-capture-blocked-key)))
@@ -381,10 +383,10 @@ A popup still mid-capture has capture UI and is not reapable, so it is spared."
(defun cj/org-capture-reap-popup-frames ()
"Delete every quick-capture popup frame that no longer shows capture UI.
Reaps across ALL frames, not just the selected one: a capture that finalizes,
-aborts, or errors while the daemon's selected frame is something else (the common
-multi-frame case) still cleans up its \"org-capture\" popup, while a popup
-mid-capture is spared. Never deletes the last remaining frame. Safe to call
-anytime — bound to nothing, run via M-x when a stray popup needs clearing."
+aborts, or errors while the daemon's selected frame is something else (the
+common multi-frame case) still cleans up its \"org-capture\" popup, while a
+popup mid-capture is spared. Never deletes the last remaining frame. Safe to
+call anytime — bound to nothing, run via M-x when a stray popup needs clearing."
(interactive)
(dolist (f (frame-list))
(when (and (frame-live-p f)
@@ -395,9 +397,7 @@ anytime — bound to nothing, run via M-x when a stray popup needs clearing."
(window-list f 'no-minibuf))))
(delete-frame f))))
-;; Reap on every capture exit. `remove-hook' first so a live module reload swaps
-;; the retired narrow (selected-frame) handler for this one without leaving both.
-(remove-hook 'org-capture-after-finalize-hook #'cj/org-capture--delete-popup-frame)
+;; Reap on every capture exit.
(add-hook 'org-capture-after-finalize-hook #'cj/org-capture-reap-popup-frames)
;; The popup opens a fresh emacsclient frame still showing the daemon's last
@@ -437,6 +437,55 @@ never split the small floating frame."
'(cj/org-capture--popup-display-condition
cj/org-capture--display-sole-window))
+;; A fresh "org-capture" popup opens showing the daemon's last buffer (see the
+;; comment above), and only the capture UI + the reap-on-finalize hook clear it.
+;; If a capture aborts before its UI paints (a C-g, an erroring template, a path
+;; that skips `cj/quick-capture'), the popup lingers showing whatever was current
+;; -- and if that was a live terminal (an eat/vterm Claude Code buffer), eat
+;; sizes the terminal to that small popup window and clamps the real frame down
+;; to the popup's rows. These two guards keep the popup from ever holding a
+;; size-sensitive live buffer: it only ever shows capture UI or *scratch*.
+
+(defun cj/org-capture--neutralize-frame (frame)
+ "Point every non-capture-UI window of the \"org-capture\" popup FRAME at
+*scratch*. Capture UI (the *Org Select* menu, a CAPTURE-* buffer) is spared, so
+this never disturbs a live capture; it only evicts a stray live buffer (the
+daemon's last buffer on open, or a buffer restored on abort) that would
+otherwise mirror the popup's size onto its source buffer. Idempotent: a window
+already on *scratch* is left alone, so it can't loop through the
+`window-buffer-change-functions' it fires."
+ (when (and (frame-live-p frame)
+ (equal (frame-parameter frame 'name) "org-capture"))
+ (dolist (w (window-list frame 'no-minibuf))
+ (let ((name (buffer-name (window-buffer w))))
+ (unless (or (cj/org-capture--popup-sole-window-p "org-capture" name)
+ (equal name "*scratch*"))
+ (set-window-buffer w (get-buffer-create "*scratch*")))))))
+
+;; Guard 1 (root cause): neutralize the popup the instant it is created, before
+;; any capture UI paints, so it never opens mirroring the daemon's last buffer.
+(defun cj/org-capture--neutralize-new-frame (frame)
+ "Neutralize a freshly-made \"org-capture\" popup FRAME on creation.
+See `cj/org-capture--neutralize-frame'."
+ (cj/org-capture--neutralize-frame frame))
+
+(add-hook 'after-make-frame-functions #'cj/org-capture--neutralize-new-frame)
+
+;; Guard 2 (safety net): catch any path the finalize reap misses. If a live
+;; buffer is displayed in the popup after creation (an aborted capture restoring
+;; the previous buffer, a stray `switch-to-buffer'), evict it at once.
+(defun cj/org-capture--neutralize-on-buffer-change (frame-or-window)
+ "Neutralize the \"org-capture\" popup after any buffer change in FRAME-OR-WINDOW.
+`window-buffer-change-functions' passes a frame (global hook) or a window
+(buffer-local); handle both."
+ (let ((frame (if (windowp frame-or-window)
+ (window-frame frame-or-window)
+ frame-or-window)))
+ (cj/org-capture--neutralize-frame frame)))
+
+(add-hook 'window-buffer-change-functions
+ #'cj/org-capture--neutralize-on-buffer-change)
+
;; The desktop quick-capture popup is launched globally (no browser selection,
;; no mu4e message, no pdf/epub buffer), so the context-dependent templates make
;; no sense there. `cj/quick-capture' captures a single Task straight into the
diff --git a/modules/org-config.el b/modules/org-config.el
index 6f25752f..a9fc4811 100644
--- a/modules/org-config.el
+++ b/modules/org-config.el
@@ -263,6 +263,23 @@ whole row line."
;; ----------------------------- Org TODO Settings ---------------------------
+(defun cj/org--noop-state-log-p (purpose state prev-state)
+ "Return non-nil when a state-change log carries no information.
+PURPOSE is the `org-add-log-setup' purpose symbol; STATE and PREV-STATE
+are the new and previous TODO states. A \\='state transition whose new
+and previous states are identical (and non-nil) is a no-op worth
+suppressing."
+ (and (eq purpose 'state)
+ state prev-state
+ (equal state prev-state)))
+
+(defun cj/org--suppress-noop-state-log (orig-fn &optional purpose state prev-state how extra)
+ "Around-advice for `org-add-log-setup' that drops no-op state logs.
+Call ORIG-FN with PURPOSE STATE PREV-STATE HOW EXTRA unless the entry is
+a no-op identical-state transition (see `cj/org--noop-state-log-p')."
+ (unless (cj/org--noop-state-log-p purpose state prev-state)
+ (funcall orig-fn purpose state prev-state how extra)))
+
(defun cj/org-todo-settings ()
"All org-todo related settings are grouped and set in this function."
@@ -283,9 +300,16 @@ whole row line."
(setq org-enforce-todo-checkbox-dependencies t)
(setq org-deadline-warning-days 7) ;; warn me w/in a week of deadlines
(setq org-treat-insert-todo-heading-as-state-change nil) ;; log task creation
- (setq org-log-into-drawer nil) ;; don't log into drawer
+ ;; state changes log into :LOGBOOK: drawers, never inline: an inline log
+ ;; line can wedge between a heading and its planning line and break org's
+ ;; planning-line parser (dropping the entry from agenda views)
+ (setq org-log-into-drawer t)
(setq org-log-done 'time) ;; record a CLOSED timestamp on TODO->DONE
+ ;; drop no-op "State X from X" transitions (identical from/to) that carry
+ ;; no information; the advice dedups, so re-running this is safe
+ (advice-add 'org-add-log-setup :around #'cj/org--suppress-noop-state-log)
+
;; inherit parents properties (sadly not schedules or deadlines)
(setq org-use-property-inheritance t))
diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el
index 944d75c1..39ff9910 100644
--- a/modules/org-contacts-config.el
+++ b/modules/org-contacts-config.el
@@ -170,29 +170,40 @@ Added: %U"
(require 'system-lib)
+(defun cj/--org-contacts-collect (buffer)
+ "Return an alist of (NAME POSITION INFO) for the contact headings in BUFFER.
+NAME is the heading text, POSITION its buffer position, and INFO the
+EMAIL or PHONE property value (or nil)."
+ (with-current-buffer buffer
+ (org-map-entries
+ (lambda ()
+ (list (nth 4 (org-heading-components))
+ (point)
+ (or (org-entry-get nil "EMAIL")
+ (org-entry-get nil "PHONE"))))
+ nil nil)))
+
(defun cj/org-contacts-find ()
- "Find and open a contact."
+ "Find a contact and jump to its heading.
+Collect the contact headings before prompting, so cancelling the prompt
+leaves point where it was, and jump to the selected heading's stored
+position instead of a text search that could land inside another entry."
(interactive)
- (find-file contacts-file)
- (goto-char (point-min))
- (let* ((alist (org-map-entries
- (lambda ()
- (cons (nth 4 (org-heading-components))
- (or (org-entry-get nil "EMAIL")
- (org-entry-get nil "PHONE"))))
- nil (list contacts-file)))
+ (let* ((buf (find-file-noselect contacts-file))
+ (alist (cj/--org-contacts-collect buf))
(contact (completing-read
"Find contact: "
(cj/completion-table-annotated
'contact
(lambda (cand)
- (let ((info (cdr (assoc cand alist))))
+ (let ((info (nth 2 (assoc cand alist))))
(when (and info (> (length info) 0))
(concat " " (propertize info 'face
'completions-annotations)))))
- alist))))
- (goto-char (point-min))
- (search-forward contact)
+ alist)
+ nil t)))
+ (switch-to-buffer buf)
+ (goto-char (nth 1 (assoc contact alist)))
(org-fold-show-entry)
(org-reveal)))
diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el
index 29f6130a..f53f36b9 100644
--- a/modules/org-drill-config.el
+++ b/modules/org-drill-config.el
@@ -134,25 +134,42 @@ With a prefix arg OTHER-DIR, prompt for the directory instead of `drill-dir'."
;; --------------------------------- Org Drill ---------------------------------
-(use-package org-drill
- ;; :vc (:url "git@cjennings.net:org-drill.git"
- ;; :branch "main"
- ;; :rev :newest)
- :load-path "~/code/org-drill" ;; local dev checkout — switch back to :vc above when done
- :after (org org-capture)
- :demand t
- :commands (org-drill org-drill-resume)
- :custom
- (org-drill-leech-failure-threshold 50 "leech cards = 50 wrong answers")
- (org-drill-leech-method 'warn "leech cards show warnings")
- (org-drill-use-visible-cloze-face-p t "cloze text shows up in a different font")
- (org-drill-hide-item-headings-p t "don't show heading text")
- (org-drill-maximum-items-per-session 100 "drill sessions end after 100 cards")
- (org-drill-maximum-duration 30 "each drill session can last up to 30 mins")
- (org-drill-add-random-noise-to-intervals-p t "vary the days to repetition slightly")
- (org-drill-text-size-during-session 24 "24-point font for comfortable reading")
- (org-drill-use-variable-pitch t "variable-pitch font for readability")
- (org-drill-hide-modeline-during-session t "hide the modeline for a cleaner display"))
+(defconst cj/org-drill-dev-checkout (expand-file-name "org-drill" "~/code/")
+ "Local org-drill development checkout, preferred when it exists.")
+
+(defun cj/--org-drill-source-keywords (&optional checkout)
+ "Return the use-package source keywords for org-drill.
+With CHECKOUT (default `cj/org-drill-dev-checkout') an existing directory,
+load from it via :load-path. Otherwise install from upstream via :vc, so
+drill still loads on a machine without the dev checkout (bare :load-path +
+:demand t would fail to load there)."
+ (let ((dir (or checkout cj/org-drill-dev-checkout)))
+ (if (file-directory-p dir)
+ (list :load-path dir)
+ (list :vc '(:url "git@cjennings.net:org-drill.git"
+ :branch "main"
+ :rev :newest)))))
+
+;; `use-package' keywords must be literals at macro-expansion, so the
+;; source keyword is spliced in through `eval' at load time (same idiom as
+;; the computed flycheck checker path elsewhere in the config).
+(eval
+ `(use-package org-drill
+ ,@(cj/--org-drill-source-keywords)
+ :after (org org-capture)
+ :demand t
+ :commands (org-drill org-drill-resume)
+ :custom
+ (org-drill-leech-failure-threshold 50 "leech cards = 50 wrong answers")
+ (org-drill-leech-method 'warn "leech cards show warnings")
+ (org-drill-use-visible-cloze-face-p t "cloze text shows up in a different font")
+ (org-drill-hide-item-headings-p t "don't show heading text")
+ (org-drill-maximum-items-per-session 100 "drill sessions end after 100 cards")
+ (org-drill-maximum-duration 30 "each drill session can last up to 30 mins")
+ (org-drill-add-random-noise-to-intervals-p t "vary the days to repetition slightly")
+ (org-drill-text-size-during-session 24 "24-point font for comfortable reading")
+ (org-drill-use-variable-pitch t "variable-pitch font for readability")
+ (org-drill-hide-modeline-during-session t "hide the modeline for a cleaner display")))
(provide 'org-drill-config)
;;; org-drill-config.el ends here.
diff --git a/modules/org-export-config.el b/modules/org-export-config.el
index 5a6f09fc..c3d3294c 100644
--- a/modules/org-export-config.el
+++ b/modules/org-export-config.el
@@ -20,7 +20,6 @@
;; - HTML: Web publishing with HTML5 support
;; - Markdown: README files and web content
;; - ODT: Office documents for LibreOffice/MS Word
-;; - Texinfo: GNU documentation and Info files
;;
;; Extended via Pandoc:
;; - Additional formats: DOCX, self-contained HTML5
@@ -28,7 +27,7 @@
;;
;; Key features:
;; - UTF-8 encoding enforced across all backends
-;; - Subtree export as default scope
+;; - Buffer export as default scope
;;
;; Note: reveal.js presentations are handled by org-reveal-config.el (C-; p)
;;
@@ -68,17 +67,8 @@
:config
(setq org-html-postamble nil)
(setq org-html-html5-fancy t)
- (setq org-html-head-include-default-style nil))
-
-
-(use-package ox-texinfo
- :ensure nil ; Built into Org
- :defer t
- :after ox
- :config
- (setq org-texinfo-coding-system 'utf-8)
- (setq org-texinfo-default-class "info")
- (add-to-list 'org-export-backends 'texinfo))
+ (setq org-html-head-include-default-style nil)
+ (setq org-html-footnote-separator "")) ;; no separator between adjacent footnote refs
(use-package ox-pandoc
:defer t
diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el
index 5f826cac..d94e4965 100644
--- a/modules/org-refile-config.el
+++ b/modules/org-refile-config.el
@@ -185,6 +185,25 @@ ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile."
;; --------------------------------- Org Refile --------------------------------
+(declare-function org-save-all-org-buffers "org")
+
+(defun cj/org-refile--save-all-buffers (&rest _)
+ "Save every open Org buffer. Installed as `:after' advice on `org-refile'.
+Named (not an anonymous lambda) so the :config reload can `advice-remove'
+it by reference and a test can assert its installation."
+ (org-save-all-org-buffers))
+
+(defun cj/org-refile--ensure-targets-in-org-mode (&rest _)
+ "Put every string-named refile target buffer into `org-mode' first.
+Installed as `:before' advice on `org-refile-get-targets'. Fixes targets
+opened before Org loaded getting stuck in `fundamental-mode'. A non-string
+target car (a function or symbol spec) is skipped. Named for the same
+remove-by-reference and testability reasons as the save helper above."
+ (dolist (target org-refile-targets)
+ (let ((file (car target)))
+ (when (stringp file)
+ (cj/org-refile-ensure-org-mode file)))))
+
(use-package org-refile
:ensure nil ;; built-in
:defer .5
@@ -193,20 +212,14 @@ ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile."
("C-c C-w" . cj/org-refile)
("C-c w" . cj/org-refile-in-file))
:config
- ;; save all open org buffers after a refile is complete
- (advice-add 'org-refile :after
- (lambda (&rest _)
- (org-save-all-org-buffers)))
-
- ;; Ensure refile target buffers are in org-mode before processing
- ;; Fixes issue where buffers opened before org loaded get stuck in fundamental-mode
- (advice-add 'org-refile-get-targets :before
- (lambda (&rest _)
- "Ensure all refile target buffers are in org-mode."
- (dolist (target org-refile-targets)
- (let ((file (car target)))
- (when (stringp file)
- (cj/org-refile-ensure-org-mode file)))))))
+ ;; Install both advices by named-function reference with a remove-then-add
+ ;; guard. Anonymous lambdas here couldn't be `advice-remove'd (deleting the
+ ;; advice from source left a live daemon still running it) and couldn't be
+ ;; tested; the named helpers above are both.
+ (advice-remove 'org-refile #'cj/org-refile--save-all-buffers)
+ (advice-add 'org-refile :after #'cj/org-refile--save-all-buffers)
+ (advice-remove 'org-refile-get-targets #'cj/org-refile--ensure-targets-in-org-mode)
+ (advice-add 'org-refile-get-targets :before #'cj/org-refile--ensure-targets-in-org-mode))
(provide 'org-refile-config)
;;; org-refile-config.el ends here.
diff --git a/modules/org-reveal-config.el b/modules/org-reveal-config.el
index be702bf7..f842680b 100644
--- a/modules/org-reveal-config.el
+++ b/modules/org-reveal-config.el
@@ -8,9 +8,10 @@
;; Load shape: eager.
;; Eager reason: none; presentation export is a command-loaded deferral
;; candidate for Phase 4.
-;; Top-level side effects: package configuration via use-package.
-;; Runtime requires: none (configures packages via use-package).
-;; Direct test load: yes.
+;; Top-level side effects: registers a presentation prefix keymap under
+;; cj/custom-keymap; package configuration via use-package.
+;; Runtime requires: keybindings.
+;; Direct test load: yes (requires keybindings explicitly).
;;
;; Integrates ox-reveal for creating reveal.js presentations from Org files.
;;
@@ -28,6 +29,8 @@
;;; Code:
+(require 'keybindings) ;; cj/register-prefix-map, cj/custom-keymap
+
;; Forward declarations for byte-compiler (ox-reveal loaded via use-package)
(defvar org-reveal-root)
(defvar org-reveal-single-file)
@@ -238,17 +241,25 @@ reveal.js headers pre-filled."
;; -------------------------------- Keybindings --------------------------------
-(global-set-key (kbd "C-; p SPC") #'cj/reveal-present)
-(global-set-key (kbd "C-; p e") #'cj/reveal-export)
-(global-set-key (kbd "C-; p p") #'cj/reveal-preview-start)
-(global-set-key (kbd "C-; p s") #'cj/reveal-preview-stop)
-(global-set-key (kbd "C-; p h") #'cj/reveal-insert-header)
-(global-set-key (kbd "C-; p H") #'cj/reveal-remove-headers)
-(global-set-key (kbd "C-; p n") #'cj/reveal-new)
+;; A registered prefix keymap, not raw `global-set-key' chains: binding
+;; "C-; p ..." directly depends on keybindings.el having already made "C-;"
+;; a live prefix (otherwise "non-prefix key" errors), while
+;; `cj/register-prefix-map' binds into `cj/custom-keymap' with no load-order
+;; dependency beyond requiring keybindings.
+(defvar-keymap cj/reveal-map
+ :doc "Keymap for reveal.js presentation commands."
+ "SPC" #'cj/reveal-present
+ "e" #'cj/reveal-export
+ "p" #'cj/reveal-preview-start
+ "s" #'cj/reveal-preview-stop
+ "h" #'cj/reveal-insert-header
+ "H" #'cj/reveal-remove-headers
+ "n" #'cj/reveal-new)
+
+(cj/register-prefix-map "p" cj/reveal-map "presentations")
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
- "C-; p" "presentations"
"C-; p SPC" "present current buffer"
"C-; p e" "export & open"
"C-; p p" "start live preview"
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
index eca867df..e8d003e0 100644
--- a/modules/org-roam-config.el
+++ b/modules/org-roam-config.el
@@ -30,11 +30,14 @@
;; Declared special so the `let'-binding in `cj/org-roam-copy-todo-to-today'
;; compiles as a dynamic bind, not a dead lexical local -- otherwise the custom
;; capture template never reaches org-roam-dailies (the foreign-special-var trap).
+;; Declared special so cj/org-roam-node-insert-immediate's let-binding is
+;; dynamic under lexical-binding; without it the byte-compiled let is a dead
+;; lexical binding and :immediate-finish never reaches org-roam-node-insert.
+(defvar org-roam-capture-templates)
(defvar org-roam-dailies-capture-templates)
;; External variables, declared special so byte-compilation doesn't treat them
;; as free references/assignments. Owned by org and org-roam-dailies.
-(defvar org-agenda-timegrid-use-ampm)
(defvar org-roam-dailies-map)
(defvar org-last-state)
@@ -77,30 +80,27 @@ FILETAGS and TITLE must sit on separate lines so Org parses the
:unnarrowed t)
("v" "v2mom" plain
- (file ,(concat user-emacs-directory "org-roam-templates/v2mom.org"))
+ (file ,(concat roam-dir "templates/v2mom.org"))
:if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "")
:unnarrowed t)
("r" "recipe" plain
- (file ,(concat user-emacs-directory "org-roam-templates/recipe.org"))
+ (file ,(concat roam-dir "templates/recipe.org"))
:if-new (file+head "recipes/%<%Y%m%d%H%M%S>-${slug}.org" "")
:unnarrowed t)
("t" "topic" plain
- (file ,(concat user-emacs-directory "org-roam-templates/topic.org"))
+ (file ,(concat roam-dir "templates/topic.org"))
:if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "")
:unnarrowed t)))
:bind (("C-c n l" . org-roam-buffer-toggle)
("C-c n f" . org-roam-node-find)
- ("C-c n p" . cj/org-roam-find-node-project)
("C-c n i" . org-roam-node-insert)
- ("C-c n w" . cj/org-roam-find-node-webclip)
:map org-mode-map
("C-M-i" . completion-at-point))
:config
;; org-log-done is set once in org-config.el (cj/org-todo-settings).
- (setq org-agenda-timegrid-use-ampm t)
;; Don't build the org-refile targets cache here. org-refile-config.el
;; already schedules it on a 5s idle timer; doing it in org-roam's :config
@@ -209,10 +209,17 @@ created in that subdirectory of `org-roam-directory'."
(interactive)
(cj/org-roam-find-node "Recipe" "r" (concat roam-dir "templates/recipe.org") "recipes/"))
+
+(defun cj/org-roam-find-node-project ()
+ "List nodes of type \"Project\" in completing read for selection or creation."
+ (interactive)
+ (cj/org-roam-find-node "Project" "p" (concat roam-dir "templates/project.org")))
+
;; Bound after their defuns (not in the use-package :bind) so the byte-compiler
;; doesn't see both a :bind autoload and the real defun as two definitions.
(keymap-global-set "C-c n r" #'cj/org-roam-find-node-recipe)
(keymap-global-set "C-c n t" #'cj/org-roam-find-node-topic)
+(keymap-global-set "C-c n p" #'cj/org-roam-find-node-project)
;; ---------------------- Org Capture After Finalize Hook ----------------------
@@ -394,36 +401,6 @@ cut stays undoable. A confirmation prompt guards large subtrees (see
(org-roam-db-sync)
(message "'%s' moved to a new org-roam node (%s)." title filename))))
-;; TASK: Need to decide keybindings before implementation and testing
-;; (use-package consult-org-roam
-;; :ensure t
-;; :after org-roam
-;; :init
-;; (require 'consult-org-roam)
-;; ;; Activate the minor mode
-;; (consult-org-roam-mode 1)
-;; :custom
-;; ;; Use `ripgrep' for searching with `consult-org-roam-search'
-;; (consult-org-roam-grep-func #'consult-ripgrep)
-;; ;; Configure a custom narrow key for `consult-buffer'
-;; (consult-org-roam-buffer-narrow-key ?r)
-;; ;; Display org-roam buffers right after non-org-roam buffers
-;; ;; in consult-buffer (and not down at the bottom)
-;; (consult-org-roam-buffer-after-buffers t)
-;; :config
-;; ;; Eventually suppress previewing for certain functions
-;; (consult-customize
-;; consult-org-roam-forward-links
-;; :preview-key "M-.")
-;; :bind
-;; ;; Define some convenient keybindings as an addition
-;; ("C-c n e" . consult-org-roam-file-find)
-;; ("C-c n b" . consult-org-roam-backlinks)
-;; ("C-c n B" . consult-org-roam-backlinks-recursive)
-;; ("C-c n l" . consult-org-roam-forward-links)
-;; ("C-c n r" . consult-org-roam-search))
-
-
;; which-key labels
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
@@ -434,7 +411,6 @@ cut stays undoable. A confirmation prompt guards large subtrees (see
"C-c n r" "roam find recipe"
"C-c n t" "roam find topic"
"C-c n i" "roam insert node"
- "C-c n w" "roam find webclip"
"C-c n I" "roam insert immediate"
"C-c n d" "roam dailies menu"))
diff --git a/modules/org-webclipper.el b/modules/org-webclipper.el
index 40ceada7..217aecfa 100644
--- a/modules/org-webclipper.el
+++ b/modules/org-webclipper.el
@@ -186,22 +186,6 @@ Return the yanked content as a string so templates can insert it."
;; extract the webpage content from the kill ring
(car kill-ring)))
-;; ----------------------------- Webclipper Keymap -----------------------------
-
-;; keymaps shouldn't be required for webclipper
-;; Setup keymaps
-;;
-;; (defun cj/webclipper-setup-keymaps ()
-;; "Setup webclipper keymaps."
-;; (define-prefix-command 'cj/webclipper-map nil
-;; "Keymap for weblipper operations.")
-;; (define-key cj/custom-keymap "c" 'cj/webclipper-map)
-;; (define-key cj/webclipper-map "n" 'cj/move-org-branch-to-roam))
-
-;; ;; Call keymap setup if cj/custom-keymap is already defined
-;; (when (boundp 'cj/custom-keymap)
-;; (cj/webclipper-setup-keymaps))
-
;; Register protocol handler early for external calls
(with-eval-after-load 'org-protocol
(unless (assoc "webclip" org-protocol-protocol-alist)
@@ -211,9 +195,5 @@ Return the yanked content as a string so templates can insert it."
:function cj/org-protocol-webclip
:kill-client t))))
-;; (with-eval-after-load 'cj/custom-keymap
-;; (require 'org-webclipper)
-;; (cj/webclipper-setup-keymaps))
-
(provide 'org-webclipper)
;;; org-webclipper.el ends here
diff --git a/modules/pdf-config.el b/modules/pdf-config.el
index a5dc3c49..b5a3e217 100644
--- a/modules/pdf-config.el
+++ b/modules/pdf-config.el
@@ -8,10 +8,18 @@
;; Load shape: eager.
;; Eager reason: none; heavy PDF packages should load on PDF open, a file/mode
;; deferral candidate.
-;; Top-level side effects: package configuration via use-package.
+;; Top-level side effects: a defgroup, defcustoms, a defvar-local, and package
+;; configuration via use-package.
;; Runtime requires: none (configures packages via use-package).
;; Direct test load: yes.
;;
+;; A reading-view palette layer sits on top of pdf-tools: a fresh PDF opens in
+;; the "dark" tint, and `c' cycles dark -> sepia -> light -> none, mirroring the
+;; nov-mode reading view (see nov-reading.el). Each palette is a (FG . BG) cons
+;; driven through `pdf-view-midnight-minor-mode', which recolors the rendered
+;; page as a duotone -- so "sepia" and "light" are the same mechanism as the
+;; built-in midnight mode, just with warmer / lighter color pairs.
+;;
;;; Code:
;; ------------------------------- Declarations --------------------------------
@@ -30,13 +38,107 @@
(declare-function cj/open-file-with-command "system-utils")
(declare-function cj/org-noter-insert-note-dwim "org-noter-config")
+;; `pdf-view-midnight-colors' lives in pdf-view.el, which loads lazily on the
+;; first PDF open. Declare it special so the `setq-local' below compiles as a
+;; dynamic binding rather than a lexical no-op.
+(defvar pdf-view-midnight-colors)
+
+;; ------------------------------ Reading palettes -----------------------------
+;; pdf-view has no sepia/light of its own -- only a binary midnight toggle. This
+;; layer generalizes that toggle into a named palette cycle: `pdf-view-midnight-
+;; minor-mode' recolors the page from a (FG . BG) pair, so any tint is just a
+;; different pair. Colors mirror the nov-reading palettes for a consistent read
+;; across EPUBs and PDFs; tune them there and here together.
+
+(defgroup cj/pdf-reading nil
+ "Reading-view theming for pdf-view PDFs."
+ :group 'cj)
+
+(defcustom cj/pdf-reading-palettes
+ '(("dark" . ("#cfc8b8" . "#15140f"))
+ ("sepia" . ("#c9b187" . "#1f1b16"))
+ ("light" . ("#2a2622" . "#ece3cf")))
+ "Alist of reading-palette NAME -> (FOREGROUND . BACKGROUND) for pdf-view.
+Each value is the color pair `pdf-view-midnight-minor-mode' renders the page
+with (white maps to BACKGROUND, black to FOREGROUND). The selector and cycle
+commands choose among these names; add an entry to add a palette."
+ :type '(alist :key-type string
+ :value-type (cons (string :tag "Foreground")
+ (string :tag "Background")))
+ :group 'cj/pdf-reading)
+
+(defcustom cj/pdf-reading-default-palette "dark"
+ "Reading palette applied to a freshly opened PDF.
+A key in `cj/pdf-reading-palettes', or nil for the PDF's native colors."
+ :type '(choice (const :tag "None (native colors)" nil) string)
+ :group 'cj/pdf-reading)
+
+(defvar-local cj/pdf--reading-palette nil
+ "Name of the reading palette active in this pdf buffer, or nil for none.")
+
+(defun cj/pdf--reading-palette-colors (name)
+ "Return the (FOREGROUND . BACKGROUND) cons for palette NAME, or nil when unknown.
+NAME nil (the no-palette state) and unknown names both yield nil."
+ (cdr (assoc name cj/pdf-reading-palettes)))
+
+(defun cj/pdf--next-reading-palette (current names)
+ "Return the palette after CURRENT in the cycle NAMES then nil, wrapping.
+CURRENT nil is the no-palette state, and a returned nil means no palette. An
+unknown CURRENT falls back to the first palette."
+ (let* ((cycle (append names (list nil)))
+ (tail (cdr (member current cycle))))
+ (car (or tail cycle))))
+
+(defun cj/pdf--apply-reading-palette (name)
+ "Apply reading palette NAME to this pdf buffer; NAME nil removes any palette.
+Drives `pdf-view-midnight-minor-mode' with the palette's (FG . BG) colors so the
+page renders as a duotone in the reading tint. An unknown or nil NAME turns
+midnight mode off, restoring the PDF's native colors."
+ (let ((colors (cj/pdf--reading-palette-colors name)))
+ (if colors
+ (progn
+ (setq-local pdf-view-midnight-colors colors)
+ (pdf-view-midnight-minor-mode 1)
+ (setq cj/pdf--reading-palette name))
+ (pdf-view-midnight-minor-mode -1)
+ (setq cj/pdf--reading-palette nil))))
+
+(defun cj/pdf-set-reading-palette (name)
+ "Choose reading palette NAME for this pdf buffer; \"none\" clears it.
+Interactively prompts among `cj/pdf-reading-palettes' plus \"none\"."
+ (interactive
+ (list (completing-read "Reading palette: "
+ (cons "none" (mapcar #'car cj/pdf-reading-palettes))
+ nil t)))
+ (unless (derived-mode-p 'pdf-view-mode)
+ (user-error "Not in a pdf-view-mode buffer"))
+ (cj/pdf--apply-reading-palette (unless (equal name "none") name))
+ (message "Reading palette: %s" (or cj/pdf--reading-palette "none")))
+
+(defun cj/pdf-cycle-reading-palette ()
+ "Cycle to the next reading palette, then the no-palette state, wrapping."
+ (interactive)
+ (unless (derived-mode-p 'pdf-view-mode)
+ (user-error "Not in a pdf-view-mode buffer"))
+ (let ((next (cj/pdf--next-reading-palette
+ cj/pdf--reading-palette
+ (mapcar #'car cj/pdf-reading-palettes))))
+ (cj/pdf--apply-reading-palette next)
+ (message "Reading palette: %s" (or next "none"))))
+
+(defun cj/pdf-reading-setup ()
+ "Apply the default reading palette to a freshly opened PDF.
+Called from the `pdf-view-mode' launch hook."
+ (when cj/pdf-reading-default-palette
+ (cj/pdf--apply-reading-palette cj/pdf-reading-default-palette)))
+
;; --------------------------------- PDF Tools ---------------------------------
(use-package pdf-tools
:defer t
:mode (("\\.pdf\\'" . pdf-view-mode))
:hook
- (pdf-view-mode . pdf-view-midnight-minor-mode)
+ (pdf-view-mode . cj/pdf-reading-setup)
:custom
(pdf-view-display-size 'fit-page)
(pdf-view-resize-factor 1.1)
@@ -61,7 +163,11 @@
(with-current-buffer buf
(when (eq major-mode 'pdf-view-mode)
(revert-buffer nil t))))
- (define-key pdf-view-mode-map "M" #'pdf-view-midnight-minor-mode)
+ ;; Reading palette: c cycles dark -> sepia -> light -> none, C selects by name.
+ ;; M keeps the old midnight key working, now as the cycle.
+ (define-key pdf-view-mode-map "c" #'cj/pdf-cycle-reading-palette)
+ (define-key pdf-view-mode-map "C" #'cj/pdf-set-reading-palette)
+ (define-key pdf-view-mode-map "M" #'cj/pdf-cycle-reading-palette)
(define-key pdf-view-mode-map "m" #'bookmark-set)
(define-key pdf-view-mode-map (kbd "C-=") #'pdf-view-enlarge)
(define-key pdf-view-mode-map (kbd "C--") #'pdf-view-shrink)
diff --git a/modules/prog-c.el b/modules/prog-c.el
index 728df018..29a341d5 100644
--- a/modules/prog-c.el
+++ b/modules/prog-c.el
@@ -8,8 +8,9 @@
;; Load shape: eager.
;; Eager reason: none necessary; currently eager but should load by C major mode
;; (Phase 6 deferral candidate).
-;; Top-level side effects: six add-hook, package configuration via use-package.
-;; Runtime requires: none (configures packages via use-package).
+;; Top-level side effects: six add-hook, package configuration via use-package;
+;; warns at load if clangd or clang-format is missing.
+;; Runtime requires: system-lib.
;; Direct test load: yes.
;;
;; Modern C programming environment with LSP, tree-sitter, debugging, and formatting.
@@ -59,6 +60,14 @@
(defvar clang-format-path "clang-format"
"Path to clang-format executable.")
+;; Warn at load time when a C tool is missing. The clang-format block
+;; below gates on `:if (executable-find ...)', which evaluates once at
+;; startup — an absent binary silently disables the format key until the
+;; next restart, so this warn is the only visible trace.
+(require 'system-lib) ; for cj/executable-find-or-warn
+(cj/executable-find-or-warn clangd-path "clangd LSP" 'prog-c)
+(cj/executable-find-or-warn clang-format-path "C formatting" 'prog-c)
+
;; -------------------------------- C Mode Setup -------------------------------
;; preferences for C programming following common conventions
diff --git a/modules/prog-general.el b/modules/prog-general.el
index 831f43cb..77ff88a5 100644
--- a/modules/prog-general.el
+++ b/modules/prog-general.el
@@ -39,6 +39,7 @@
;;; Code:
(require 'user-constants) ;; code-dir, projects-dir, snippets-dir
+(require 'cl-lib)
(defvar display-line-numbers-type)
(defvar outline-minor-mode-map)
@@ -57,6 +58,7 @@
(declare-function dired-get-filename "dired")
(declare-function global-treesit-auto-mode "treesit-auto")
(declare-function treesit-auto-add-to-auto-mode-alist "treesit-auto")
+(declare-function treesit-auto-install-all "treesit-auto")
(declare-function treesit-auto-recipe-lang "treesit-auto")
(declare-function highlight-indent-guides-mode "highlight-indent-guides")
(declare-function electric-pair-default-inhibit "elec-pair")
@@ -120,19 +122,27 @@ REGEXP must be a string or an rx form."
;; build mid-edit. Batch/test runs never load treesit-auto (no package
;; init), so they can never install. Fresh-machine bootstrap is the
;; explicit `cj/install-treesit-grammars' command below.
+(defun cj/treesit-auto-pin-go-revision (recipes)
+ "Pin the Go grammar revision in treesit-auto RECIPES.
+Return the updated Go recipe, or nil when RECIPES has no Go entry.
+Discover the `revision' slot at runtime because treesit-auto is not loaded
+when this file's `use-package' form is macro-expanded."
+ (when-let ((go-recipe
+ (cl-find-if
+ (lambda (recipe)
+ (eq (treesit-auto-recipe-lang recipe) 'go))
+ recipes)))
+ (aset go-recipe
+ (cl-struct-slot-offset 'treesit-auto-recipe 'revision)
+ "v0.19.1")
+ go-recipe))
+
(use-package treesit-auto
:custom
(treesit-auto-install 'prompt)
:config
- (require 'cl-lib)
;; Pin Go grammar to v0.19.1 for compatibility with Emacs 30.2 font-lock queries
- (let* ((go-idx (cl-position-if (lambda (recipe)
- (eq (treesit-auto-recipe-lang recipe) 'go))
- treesit-auto-recipe-list))
- (go-recipe (and go-idx (nth go-idx treesit-auto-recipe-list))))
- (when go-recipe
- ;; Directly modify the slot value using aset (struct fields are vectors internally)
- (aset go-recipe 6 "v0.19.1"))) ; slot 6 is :revision
+ (cj/treesit-auto-pin-go-revision treesit-auto-recipe-list)
(treesit-auto-add-to-auto-mode-alist 'all)
(global-treesit-auto-mode))
@@ -409,46 +419,129 @@ defer to `electric-pair-default-inhibit' for any other CHAR."
(setq ws-butler-convert-leading-tabs-or-spaces t))
;; ------------------------------------ LSP ------------------------------------
-;; Language Server Protocol for intelligent code completion and navigation
-;; Works with multiple languages: C, Python, Go, Rust, JavaScript, etc.
-
-;; Forward declarations for LSP variables
+;; Language Server Protocol for intelligent code completion and navigation.
+;; Single owner of generic LSP policy (prog-lsp.el folded in and removed
+;; 2026-07-10). Language-specific server variables and the lsp-deferred mode
+;; hooks stay in the per-language modules. Reference for what to turn off:
+;; https://emacs-lsp.github.io/lsp-mode/tutorials/how-to-turn-off/
+
+;; Forward declarations for byte-compile. lsp-mode's defcustoms aren't loaded
+;; under `make test' (no package-initialize) and use-package defers the package
+;; via :commands, so these vars are unknown at compile time without declaring.
+(defvar lsp-mode-map)
+(defvar eldoc-documentation-functions)
+(defvar lsp-file-watch-ignored-directories)
+(defvar lsp-enable-remote)
+(defvar lsp-auto-guess-root)
+(defvar lsp-restart)
(defvar lsp-idle-delay)
(defvar lsp-log-io)
(defvar lsp-enable-folding)
+(defvar lsp-enable-imenu)
(defvar lsp-enable-snippet)
+(defvar lsp-enable-symbol-highlighting)
+(defvar lsp-enable-on-type-formatting)
+(defvar lsp-signature-auto-activate)
+(defvar lsp-signature-render-documentation)
+(defvar lsp-modeline-code-actions-enable)
+(defvar lsp-modeline-diagnostics-enable)
(defvar lsp-headerline-breadcrumb-enable)
+(defvar lsp-semantic-tokens-enable)
(defvar lsp-completion-provider)
(defvar lsp-completion-show-detail)
(defvar lsp-completion-show-kind)
+(declare-function lsp-eldoc-function "lsp-mode")
+
+;; File-watch ignore patterns. lsp-mode prompts when a workspace exceeds
+;; `lsp-file-watch-threshold' (1000) directories. Real source repos cross that
+;; once node_modules, build outputs, and language caches are counted. These
+;; extend the lsp-mode defaults (.git, .svn, .idea, ...) instead of replacing
+;; them. A buffer-local override via `.dir-locals.el' doesn't work: lsp-mode
+;; reads the global value at workspace init, not the buffer-local one, so the
+;; defaults live here globally.
+(defvar cj/lsp-file-watch-ignored-extras
+ '("[/\\\\]node_modules\\'"
+ "[/\\\\]\\.ruff_cache\\'"
+ "[/\\\\]dist\\'"
+ "[/\\\\]coverage\\'"
+ "[/\\\\]test-results\\'"
+ "[/\\\\]playwright-report\\'"
+ "[/\\\\]tf[/\\\\]\\.terraform\\'"
+ "[/\\\\]__pycache__\\'"
+ "[/\\\\]\\.venv\\'"
+ "[/\\\\]venv\\'"
+ "[/\\\\]\\.pytest_cache\\'"
+ "[/\\\\]\\.mypy_cache\\'"
+ "[/\\\\]target\\'")
+ "Build/cache directory patterns to add to `lsp-file-watch-ignored-directories'.
+Each entry is an Emacs regex matching a path ending in the named directory.")
+
+(defun cj/lsp--add-file-watch-ignored-extras ()
+ "Append `cj/lsp-file-watch-ignored-extras' to lsp-mode's ignore list.
+Idempotent — `add-to-list' skips patterns already present."
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (add-to-list 'lsp-file-watch-ignored-directories pattern)))
+
+(defun cj/lsp--remove-eldoc-provider-global ()
+ "Remove lsp-mode's provider from the global `eldoc-documentation-functions'.
+Run once after lsp-mode loads. The previous per-buffer removal raced
+lsp's own buffer-local add: the buffer-local remove fired before lsp
+populated the buffer-local hook (lsp inherits the global default and
+mutates from there), so the buffer-local hook ended up holding the
+provider anyway. Removing globally before lsp ever attaches a buffer
+makes the absence stick for every subsequent lsp-managed buffer."
+ (remove-hook 'eldoc-documentation-functions #'lsp-eldoc-function))
(use-package lsp-mode
:commands (lsp lsp-deferred)
+ :bind (:map lsp-mode-map
+ ("C-c d" . lsp-describe-thing-at-point)
+ ("C-c a" . lsp-execute-code-action))
:custom
(lsp-keymap-prefix "C-c l") ;; LSP commands under C-c l prefix
+ :init
+ (setq lsp-enable-remote nil) ;; Don't start LSP on TRAMP files (slow, prompts for root)
:config
- ;; Performance optimizations
- (setq lsp-idle-delay 0.1)
+ ;; Quiet, performance-first policy
+ (setq lsp-idle-delay 0.5)
(setq lsp-log-io nil)
+ (setq lsp-auto-guess-root t)
+ (setq lsp-restart 'auto-restart)
(setq lsp-enable-folding nil)
- (setq lsp-enable-snippet t)
+ (setq lsp-enable-imenu nil)
+ (setq lsp-enable-snippet nil)
+ (setq lsp-enable-symbol-highlighting nil)
+ (setq lsp-enable-on-type-formatting nil)
+ (setq lsp-signature-auto-activate nil)
+ (setq lsp-signature-render-documentation nil)
+ (setq lsp-modeline-code-actions-enable nil)
+ (setq lsp-modeline-diagnostics-enable nil)
(setq lsp-headerline-breadcrumb-enable nil)
-
- ;; Improve completion
+ (setq lsp-semantic-tokens-enable nil)
+ (setq read-process-output-max (* 1024 1024)) ;; 1MB
+ ;; Completion
(setq lsp-completion-provider :capf)
(setq lsp-completion-show-detail t)
- (setq lsp-completion-show-kind t))
+ (setq lsp-completion-show-kind t)
+ ;; Strip lsp's global eldoc provider once (see the helper for the race note).
+ (cj/lsp--remove-eldoc-provider-global)
+ (cj/lsp--add-file-watch-ignored-extras))
(use-package lsp-ui
:after lsp-mode
:commands lsp-ui-mode
:custom
- (lsp-ui-doc-enable t)
+ (lsp-ui-doc-enable nil)
(lsp-ui-doc-position 'at-point)
(lsp-ui-doc-delay 0.5)
+ (lsp-ui-doc-header t)
+ (lsp-ui-doc-include-signature t)
+ (lsp-ui-doc-border (face-foreground 'default))
(lsp-ui-sideline-enable t)
(lsp-ui-sideline-show-diagnostics t)
(lsp-ui-sideline-show-hover nil)
+ (lsp-ui-sideline-show-code-actions nil)
+ (lsp-ui-sideline-delay 0.05)
(lsp-ui-peek-enable t)
(lsp-ui-peek-show-directory t))
diff --git a/modules/prog-go.el b/modules/prog-go.el
index 7faf92a0..630b725c 100644
--- a/modules/prog-go.el
+++ b/modules/prog-go.el
@@ -8,8 +8,9 @@
;; Load shape: eager.
;; Eager reason: none necessary; currently eager but should load by Go major
;; mode (Phase 6 deferral candidate).
-;; Top-level side effects: package configuration via use-package (hooks via :hook).
-;; Runtime requires: none (configures packages via use-package).
+;; Top-level side effects: package configuration via use-package (hooks via
+;; :hook); adds ~/go/bin to exec-path; warns at load if gopls is missing.
+;; Runtime requires: system-lib.
;; Direct test load: yes.
;;
;; Configuration for Go programming using go-ts-mode (tree-sitter based).
@@ -29,14 +30,25 @@
;;; Code:
+(require 'system-lib) ; for cj/executable-find-or-warn
+
(defvar go-bin-path (expand-file-name "~/go/bin")
"Path to Go binaries directory.
This is where tools like goimports and staticcheck are installed.")
+;; Register the Go bin directory before the gopls check below: go tools
+;; install there, so probing PATH without it would warn about a gopls
+;; that is in fact present.
+(add-to-list 'exec-path go-bin-path)
+
(defvar gopls-path "gopls"
"Path to gopls (Go language server).
Install with: go install golang.org/x/tools/gopls@latest")
+;; Warn at load time if gopls is missing rather than waiting for the
+;; first Go buffer to silently skip the LSP attach.
+(cj/executable-find-or-warn gopls-path "gopls LSP" 'prog-go)
+
(defvar dlv-path "dlv"
"Path to Delve debugger.
Install with: go install github.com/go-delve/delve/cmd/dlv@latest")
@@ -113,11 +125,14 @@ Overrides default prog-mode keybindings with Go-specific commands."
;; never ran. Autoload gofmt so the first format pulls go-mode and its :config.
:commands (gofmt)
:hook ((go-ts-mode . cj/go-setup)
- (go-ts-mode . cj/go-mode-keybindings))
+ (go-ts-mode . cj/go-mode-keybindings)
+ ;; Classic-mode fallback: same setup when the Go grammar is
+ ;; unavailable and the buffer lands in go-mode.
+ (go-mode . cj/go-setup)
+ (go-mode . cj/go-mode-keybindings))
:mode (("\\.go\\'" . go-ts-mode) ;; .go files use go-ts-mode
("go\\.mod\\'" . go-mod-ts-mode)) ;; go.mod uses go-mod-ts-mode
:config
- (add-to-list 'exec-path go-bin-path)
;; Use goimports for formatting (adds/removes imports automatically)
(setq gofmt-command "goimports"))
diff --git a/modules/prog-json.el b/modules/prog-json.el
index e7abd182..66f4c5f2 100644
--- a/modules/prog-json.el
+++ b/modules/prog-json.el
@@ -62,9 +62,14 @@ back to the built-in `json-pretty-print-buffer-ordered'."
;; interactive jq queries against JSON buffers
(use-package jq-mode
- :defer t
- :bind (:map json-ts-mode-map
- ("C-c C-q" . jq-interactively)))
+ :defer t)
+
+;; Bind on json-ts-mode's own map, keyed to its load. The old
+;; :bind (:map json-ts-mode-map ...) inside the jq-mode use-package deferred
+;; the binding to jq-mode's load -- which nothing triggered, so the key was
+;; dead. jq-interactively is autoloaded, so pressing the key loads jq-mode.
+(with-eval-after-load 'json-ts-mode
+ (keymap-set json-ts-mode-map "C-c C-q" #'jq-interactively))
(provide 'prog-json)
;;; prog-json.el ends here.
diff --git a/modules/prog-lisp.el b/modules/prog-lisp.el
index ba568c9c..16740507 100644
--- a/modules/prog-lisp.el
+++ b/modules/prog-lisp.el
@@ -119,8 +119,12 @@
(use-package package-lint
:commands (package-lint-current-buffer package-lint-batch-and-exit))
+;; Load when flycheck does. The old `:after (flycheck package-lint)' never
+;; fired: nothing loads package-lint on its own (it is flycheck-package's
+;; dependency, pulled in by its require), so the setup silently never ran
+;; and elisp buffers never got the package-lint checker.
(use-package flycheck-package
- :after (flycheck package-lint)
+ :after flycheck
:config
(flycheck-package-setup))
diff --git a/modules/prog-lsp.el b/modules/prog-lsp.el
deleted file mode 100644
index 1c74bcc1..00000000
--- a/modules/prog-lsp.el
+++ /dev/null
@@ -1,110 +0,0 @@
-;;; prog-lsp.el --- Setup for LSP Mode -*- lexical-binding: t; coding: utf-8; -*-
-;; author: Craig Jennings <c@cjennings.net>
-
-;;; Commentary:
-
-;; good reference as to what to enable/disable in lsp-mode
-;; https://emacs-lsp.github.io/lsp-mode/tutorials/how-to-turn-off/
-
-;;; Code:
-
-;; Forward declarations for byte-compile and let-binding under lexical scope.
-;; Real definitions are lsp-mode's defcustoms.
-(defvar eldoc-documentation-functions)
-(defvar lsp-file-watch-ignored-directories)
-(defvar lsp-enable-remote)
-
-(declare-function lsp-eldoc-function "lsp-mode")
-
-;;;;; --------------------- File-Watch Ignore Patterns ---------------------
-;; lsp-mode prompts when a workspace exceeds `lsp-file-watch-threshold' (1000)
-;; directories. Real source repos cross that line easily once node_modules,
-;; build outputs, and language caches are counted. These patterns extend the
-;; lsp-mode defaults (.git, .svn, .idea, ...) instead of replacing them, so the
-;; built-in VC/IDE excludes still apply. Buffer-local overrides via
-;; `.dir-locals.el' don't work — lsp-mode reads the global value at workspace
-;; init, not the buffer-local one. Hence: global defaults here.
-
-(defvar cj/lsp-file-watch-ignored-extras
- '("[/\\\\]node_modules\\'"
- "[/\\\\]\\.ruff_cache\\'"
- "[/\\\\]dist\\'"
- "[/\\\\]coverage\\'"
- "[/\\\\]test-results\\'"
- "[/\\\\]playwright-report\\'"
- "[/\\\\]tf[/\\\\]\\.terraform\\'"
- "[/\\\\]__pycache__\\'"
- "[/\\\\]\\.venv\\'"
- "[/\\\\]venv\\'"
- "[/\\\\]\\.pytest_cache\\'"
- "[/\\\\]\\.mypy_cache\\'"
- "[/\\\\]target\\'")
- "Build/cache directory patterns to add to `lsp-file-watch-ignored-directories'.
-Each entry is an Emacs regex matching a path ending in the named directory.")
-
-(defun cj/lsp--add-file-watch-ignored-extras ()
- "Append `cj/lsp-file-watch-ignored-extras' to lsp-mode's ignore list.
-Idempotent — `add-to-list' skips patterns already present."
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (add-to-list 'lsp-file-watch-ignored-directories pattern)))
-
-(defun cj/lsp--remove-eldoc-provider-global ()
- "Remove lsp-mode's provider from the global `eldoc-documentation-functions'.
-Run once after lsp-mode loads. The previous per-buffer removal raced
-lsp's own buffer-local add: the buffer-local remove fired before lsp
-populated the buffer-local hook (lsp inherits the global default and
-mutates from there), so the buffer-local hook ended up holding the
-provider anyway. Removing globally before lsp ever attaches a buffer
-makes the absence stick for every subsequent lsp-managed buffer."
- (remove-hook 'eldoc-documentation-functions #'lsp-eldoc-function))
-
-;;;;; ---------------------------- LSP Mode ---------------------------
-
-(use-package lsp-mode
- :hook
- ((c-mode c++-mode go-mode js-mode js-jsx-mode typescript-mode python-mode web-mode) . lsp-deferred)
- :commands (lsp)
- :bind (:map lsp-mode-map
- ("C-c d" . lsp-describe-thing-at-point)
- ("C-c a" . lsp-execute-code-action))
- :bind-keymap ("C-c L" . lsp-command-map)
- :init
- (setq lsp-enable-remote nil) ;; Don't start LSP on TRAMP files (slow, prompts for project root)
- :config
- (setq lsp-auto-guess-root t)
- (setq lsp-log-io nil)
- (setq lsp-restart 'auto-restart)
- (setq lsp-enable-symbol-highlighting nil)
- (setq lsp-enable-on-type-formatting nil)
- (setq lsp-signature-auto-activate nil)
- (setq lsp-signature-render-documentation nil)
- ;; Strip lsp-mode's eldoc provider from the GLOBAL hook value once,
- ;; not per buffer. See `cj/lsp--remove-eldoc-provider-global' for
- ;; why per-buffer racing didn't stick.
- (cj/lsp--remove-eldoc-provider-global)
- (setq lsp-modeline-code-actions-enable nil)
- (setq lsp-modeline-diagnostics-enable nil)
- (setq lsp-headerline-breadcrumb-enable nil)
- (setq lsp-semantic-tokens-enable nil)
- (setq lsp-enable-folding nil)
- (setq lsp-enable-imenu nil)
- (setq lsp-enable-snippet nil)
- (setq read-process-output-max (* 1024 1024)) ;; 1MB
- (setq lsp-idle-delay 0.5)
- (cj/lsp--add-file-watch-ignored-extras))
-
-;;;;; ----------------------------- LSP UI ----------------------------
-
-(use-package lsp-ui
- :after lsp-mode
- :commands lsp-ui-mode
- :config
- (setq lsp-ui-doc-enable nil)
- (setq lsp-ui-doc-header t)
- (setq lsp-ui-doc-include-signature t)
- (setq lsp-ui-doc-border (face-foreground 'default))
- (setq lsp-ui-sideline-show-code-actions nil) ;; turn off code actions in sidebar
- (setq lsp-ui-sideline-delay 0.05))
-
-(provide 'prog-lsp)
-;;; prog-lsp.el ends here
diff --git a/modules/prog-python.el b/modules/prog-python.el
index 6354bd90..91670ec0 100644
--- a/modules/prog-python.el
+++ b/modules/prog-python.el
@@ -69,9 +69,12 @@ Install with: pip install mypy")
(setq-local indent-tabs-mode nil) ;; disable tab characters
(electric-pair-local-mode t) ;; match delimiters automatically (buffer-local)
- ;; Enable LSP if available
+ ;; Enable LSP if available. lsp-pyright is required here, inside the
+ ;; guard, so a pyright-less machine never loads it and never sees the
+ ;; LSP attach prompt the guard exists to prevent.
(when (and (fboundp 'lsp-deferred)
(executable-find pyright-path))
+ (require 'lsp-pyright nil t)
(lsp-deferred)))
(defun cj/--python-mypy-command (target)
@@ -111,8 +114,12 @@ Overrides default prog-mode keybindings with Python-specific commands."
(use-package python
:ensure nil ;; built-in
:hook
+ ;; Both variants: treesit-auto falls back to classic python-mode when the
+ ;; grammar is unavailable, and that fallback should keep the same setup.
((python-ts-mode . cj/python-setup)
- (python-ts-mode . cj/python-mode-keybindings))
+ (python-ts-mode . cj/python-mode-keybindings)
+ (python-mode . cj/python-setup)
+ (python-mode . cj/python-mode-keybindings))
:custom
(python-shell-interpreter "python3")
:config
@@ -126,10 +133,12 @@ Overrides default prog-mode keybindings with Python-specific commands."
;; Python-specific LSP configuration via pyright
;; Core LSP setup is in prog-general.el
+;; No :hook here: the old unguarded (require 'lsp-pyright) + (lsp-deferred)
+;; lambda ran on every python-ts buffer, so pyright-less machines got the
+;; LSP attach prompt cj/python-setup's guard exists to prevent. The guarded
+;; branch in cj/python-setup owns the require and the attach.
(use-package lsp-pyright
- :hook (python-ts-mode . (lambda ()
- (require 'lsp-pyright)
- (lsp-deferred))))
+ :defer t)
;; ----------------------------------- Poetry ----------------------------------
;; virtual environments and dependencies
diff --git a/modules/prog-shell.el b/modules/prog-shell.el
index 3ed51da1..7c0972f0 100644
--- a/modules/prog-shell.el
+++ b/modules/prog-shell.el
@@ -9,8 +9,9 @@
;; Eager reason: none necessary; currently eager but should load by shell major
;; mode (Phase 6 deferral candidate).
;; Top-level side effects: five add-hook, including an after-save executable hook
-;; the spec flags as needing opt-in/scoping; package config via use-package.
-;; Runtime requires: none (configures packages via use-package).
+;; the spec flags as needing opt-in/scoping; package config via use-package;
+;; warns at load for missing shell tools.
+;; Runtime requires: system-lib.
;; Direct test load: yes.
;;
;; Modern shell scripting environment with LSP, tree-sitter, linting, and formatting.
@@ -65,6 +66,15 @@ Install with: sudo pacman -S shfmt")
"Path to shellcheck executable.
Install with: sudo pacman -S shellcheck")
+;; Warn at load time when a shell tool is missing. The shfmt and
+;; flycheck blocks below gate on `:if (executable-find ...)', which
+;; evaluates once at startup — an absent tool silently disables that
+;; setup until the next restart, so this warn is the only visible trace.
+(require 'system-lib) ; for cj/executable-find-or-warn
+(cj/executable-find-or-warn bash-language-server-path "bash LSP" 'prog-shell)
+(cj/executable-find-or-warn shfmt-path "shell formatting" 'prog-shell)
+(cj/executable-find-or-warn shellcheck-path "shell linting" 'prog-shell)
+
;; ------------------------------- Shell Script Setup ------------------------------
;; preferences for shell scripting
diff --git a/modules/prog-training.el b/modules/prog-training.el
index 41f3053b..d38208ee 100644
--- a/modules/prog-training.el
+++ b/modules/prog-training.el
@@ -17,6 +17,7 @@
;;; Code:
+(defvar code-dir) ;; user-constants.el; read lazily in leetcode's :config
;; ----------------------------- Exercism ----------------------------
@@ -33,9 +34,9 @@
:defer t
:commands (leetcode)
:bind ("C-h L" . leetcode)
- :custom
- (url-debug t)
:config
+ ;; No (url-debug t) here: that was a debugging leftover, and it turned on
+ ;; GLOBAL url.el request logging for the whole session once leetcode loaded.
(setq leetcode-prefer-language "golang")
(setq leetcode-directory (concat code-dir "/leetcode"))
(setq leetcode-save-solutions t))
diff --git a/modules/prog-webdev.el b/modules/prog-webdev.el
index b228d0cc..f305c65d 100644
--- a/modules/prog-webdev.el
+++ b/modules/prog-webdev.el
@@ -18,6 +18,7 @@
;;
;; Installation:
;; sudo pacman -S typescript-language-server typescript prettier
+;; sudo pacman -S vscode-html-languageserver # LSP in web-mode buffers
;;
;; Features:
;; - Tree-sitter: Syntax highlighting for TS, TSX, JS (via treesit-auto)
@@ -52,6 +53,10 @@ Install with: sudo pacman -S typescript-language-server")
"Path to prettier executable.
Install with: sudo pacman -S prettier")
+(defvar html-language-server-path "vscode-html-language-server"
+ "Path to the HTML language server executable used in web-mode buffers.
+Install with: sudo pacman -S vscode-html-languageserver")
+
;; Warn at load time if prettier is missing rather than waiting for the
;; first format-on-save to fail mid-edit.
(cj/executable-find-or-warn prettier-path "prettier formatter" 'prog-webdev)
@@ -59,8 +64,8 @@ Install with: sudo pacman -S prettier")
;; ------------------------------ Web Dev Setup --------------------------------
;; shared setup for TypeScript, JavaScript, and TSX modes
-(defun cj/webdev-setup ()
- "Set up common preferences for web development buffers."
+(defun cj/--webdev-prefs ()
+ "Apply the shared buffer-local preferences for web development buffers."
(company-mode)
(flyspell-prog-mode)
(superword-mode)
@@ -68,13 +73,27 @@ Install with: sudo pacman -S prettier")
(setq-local tab-width 2)
(setq-local standard-indent 2)
(setq-local indent-tabs-mode nil)
- (electric-pair-local-mode t)
+ (electric-pair-local-mode t))
+
+(defun cj/webdev-setup ()
+ "Set up common preferences for TypeScript/JavaScript buffers."
+ (cj/--webdev-prefs)
;; Enable LSP if available
(when (and (fboundp 'lsp-deferred)
(executable-find ts-language-server-path))
(lsp-deferred)))
+(defun cj/web-mode-setup ()
+ "Set up preferences for web-mode (HTML template) buffers.
+Same shared preferences as the TS/JS modes, but the LSP attach is
+guarded on the HTML language server rather than the TypeScript one, so
+machines without it stay silent instead of prompting."
+ (cj/--webdev-prefs)
+ (when (and (fboundp 'lsp-deferred)
+ (executable-find html-language-server-path))
+ (lsp-deferred)))
+
(defun cj/--webdev-format-args (file)
"Return the prettier argv list that formats FILE's contents on stdin.
No shell quoting is needed: the args are passed to prettier directly
@@ -121,6 +140,11 @@ Detects the file type automatically from the filename."
((js-ts-mode . cj/webdev-setup)
(js-ts-mode . cj/webdev-keybindings)))
+;; Classic-mode fallback: when the JS grammar is unavailable the buffer
+;; lands in js-mode; give it the same setup as the tree-sitter modes.
+(add-hook 'js-mode-hook #'cj/webdev-setup)
+(add-hook 'js-mode-hook #'cj/webdev-keybindings)
+
;; ----------------------------------- LSP -------------------------------------
;; TypeScript/JavaScript LSP configuration
;; Core LSP setup is in prog-general.el
@@ -145,7 +169,8 @@ Detects the file type automatically from the filename."
(web-mode-code-indent-offset 2)
(web-mode-engines-alist '(("django" . "\\.html\\'")))
:mode ("\\.html?$" . web-mode)
- :hook (web-mode . cj/webdev-keybindings))
+ :hook ((web-mode . cj/web-mode-setup)
+ (web-mode . cj/webdev-keybindings)))
(provide 'prog-webdev)
;;; prog-webdev.el ends here.
diff --git a/modules/restclient-config.el b/modules/restclient-config.el
index 0511eddb..497f54d0 100644
--- a/modules/restclient-config.el
+++ b/modules/restclient-config.el
@@ -8,7 +8,7 @@
;; Load shape: eager.
;; Eager reason: none; API exploration, a command-loaded deferral candidate.
;; Top-level side effects: package configuration via use-package.
-;; Runtime requires: none (configures packages via use-package).
+;; Runtime requires: keybindings (C-; R prefix registration).
;; Direct test load: yes.
;;
;; Integrates restclient.el for interactive API exploration from within Emacs.
@@ -23,6 +23,8 @@
;;; Code:
+(require 'keybindings) ;; cj/register-prefix-map
+
;; --------------------------------- Constants ---------------------------------
(defvar cj/restclient-data-dir (expand-file-name "data/" user-emacs-directory)
@@ -61,12 +63,15 @@
;; -------------------------------- Keybindings --------------------------------
-(global-set-key (kbd "C-; R n") #'cj/restclient-new-buffer)
-(global-set-key (kbd "C-; R o") #'cj/restclient-open-file)
+(defvar-keymap cj/restclient-map
+ :doc "Keymap for restclient operations"
+ "n" #'cj/restclient-new-buffer
+ "o" #'cj/restclient-open-file)
+
+(cj/register-prefix-map "R" cj/restclient-map "REST client")
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
- "C-; R" "REST client"
"C-; R n" "new scratch buffer"
"C-; R o" "open .rest file"))
diff --git a/modules/selection-framework.el b/modules/selection-framework.el
index 7f7f9a47..47fbf7c7 100644
--- a/modules/selection-framework.el
+++ b/modules/selection-framework.el
@@ -41,7 +41,9 @@
(vertico-cycle t) ; Cycle through candidates
(vertico-count 10) ; Number of candidates to display
(vertico-resize nil) ; Don't resize the minibuffer
- (vertico-sort-function #'vertico-sort-history-alpha) ; History first, then alphabetical
+ ;; Sorting is owned by `vertico-prescient-mode' (frecency). A
+ ;; `vertico-sort-function' set here is overridden inside every vertico
+ ;; session, so it is omitted rather than left as dead config.
:bind (:map vertico-map
("C-j" . vertico-next)
("C-k" . vertico-previous)
@@ -223,7 +225,7 @@
("C-p" . company-select-previous))
:custom
(company-backends '(company-capf company-files company-keywords))
- (company-idle-delay 2)
+ (company-idle-delay 4)
(company-minimum-prefix-length 2)
(company-show-numbers t)
(company-tooltip-align-annotations t)
diff --git a/modules/slack-config.el b/modules/slack-config.el
index adf38804..e0ad5b75 100644
--- a/modules/slack-config.el
+++ b/modules/slack-config.el
@@ -52,6 +52,7 @@
(defvar slack-message-custom-notifier)
(defvar slack-teams)
+(declare-function notifications-notify "notifications")
(declare-function slack-buffer-add-reaction-to-message "slack-buffer")
(declare-function slack-buffer-latest-ts "slack-buffer")
(declare-function slack-buffer-team "slack-buffer")
@@ -196,7 +197,10 @@ so the Slack buffer stays usable."
"Add a reaction to the current Slack message using a curated shortlist.
Errors if called outside a Slack message buffer."
(interactive)
- (let ((buf (or slack-current-buffer
+ ;; boundp guard: the defvar above declares the var with no value, so it is
+ ;; void until slack.el loads -- a bare read on a cold call would signal
+ ;; void-variable instead of this friendly error.
+ (let ((buf (or (and (boundp 'slack-current-buffer) slack-current-buffer)
(user-error "Not in a Slack buffer"))))
(when-let* ((team (slack-buffer-team buf))
(reaction (cj/slack-select-reaction team)))
@@ -207,6 +211,46 @@ Errors if called outside a Slack message buffer."
:around #'cj/slack--safe-reaction-echo-description))
;; ----------------------------- Notifications ---------------------------------
+;; Mirrors signel's notification hardening (body truncation, sound gating,
+;; script-with-fallback delivery). The shared cj/messenger-notify extraction
+;; that collapses the two copies belongs to the messenger-unification task.
+
+(defcustom cj/slack-notify-sound nil
+ "When non-nil, Slack notifications play the notify script's sound.
+Nil (the default) passes --silent so the toast is visual only."
+ :type 'boolean
+ :group 'slack)
+
+(defconst cj/slack--notify-body-max 120
+ "Maximum character length of a desktop-notification body.
+Longer message text truncates to this length ending in an ellipsis;
+the full text is always in the Slack buffer.")
+
+(defun cj/slack--format-notify-body (text)
+ "Collapse whitespace in TEXT and truncate it for a notification body.
+Whitespace runs (including newlines) become single spaces, the result
+is trimmed, and anything over `cj/slack--notify-body-max' characters
+truncates to that length with a trailing ellipsis."
+ (let ((flat (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " text))))
+ (if (<= (length flat) cj/slack--notify-body-max)
+ flat
+ (concat (substring flat 0 (1- cj/slack--notify-body-max)) "…"))))
+
+(defun cj/slack--send-notification (title body)
+ "Deliver a desktop notification with TITLE and BODY.
+Routes through the external notify script when it is on PATH (type
+info, sound gated by `cj/slack-notify-sound'), falling back to
+`notifications-notify' otherwise. Previously a missing script made
+`start-process' error inside the caller's condition-case, so the
+notification silently vanished."
+ (let ((script (executable-find "notify")))
+ (if script
+ (apply #'start-process "slack-notify" nil script "info" title body
+ (unless cj/slack-notify-sound (list "--silent")))
+ ;; notifications.el is not autoloaded; load it on the first fallback.
+ (unless (fboundp 'notifications-notify)
+ (require 'notifications))
+ (notifications-notify :title title :body body))))
(defun cj/slack-notify (message room team)
"Send desktop notification for DMs and @mentions only.
@@ -218,18 +262,17 @@ swallows exceptions via `websocket-try-callback'."
(when (and (not (slack-message-minep message team))
(or (slack-im-p room)
(slack-message-mentioned-p message team)))
- (let ((title (format "Slack: %s" (slack-room-display-name room team)))
- (body (or (slack-message-body message team) "")))
- (start-process "slack-notify" nil
- "notify" "info" title body)))
+ (cj/slack--send-notification
+ (format "Slack: %s" (slack-room-display-name room team))
+ (cj/slack--format-notify-body
+ (or (slack-message-body message team) ""))))
(error (message "cj/slack-notify error: %S" err))))
(defun cj/slack-test-notify ()
"Send a test desktop notification to verify the notify pipeline works."
(interactive)
(condition-case err
- (start-process "slack-notify-test" nil
- "notify" "info" "Slack: Test" "Notification pipeline works")
+ (cj/slack--send-notification "Slack: Test" "Notification pipeline works")
(error (message "cj/slack-test-notify error: %S" err))))
(defun cj/slack-mark-read-and-bury ()
diff --git a/modules/system-commands.el b/modules/system-commands.el
index de5e8853..edc6339d 100644
--- a/modules/system-commands.el
+++ b/modules/system-commands.el
@@ -115,8 +115,21 @@ actions like shutdown and reboot), nil for no confirmation."
;; directly: logind emits the Lock signal, hypridle catches it and runs its
;; lock_cmd (hyprlock), the same path idle/before-sleep locking already uses.
;; X11 machines keep slock.
-(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd
- (if (env-wayland-p) "loginctl lock-session" "slock"))
+;;
+;; Unlike its siblings, the locker is resolved at COMMAND time, not baked into
+;; the defvar at load: a daemon started before WAYLAND_DISPLAY reaches its
+;; environment would freeze the locker to slock forever, and Lock would then
+;; fail silently on Wayland. `lockscreen-cmd' stays as an override knob.
+(defvar lockscreen-cmd nil
+ "Explicit lock command, overriding session-type resolution when non-nil.")
+
+(defun cj/system-cmd-lock ()
+ "Lock the session, resolving the locker from the live session type.
+Runs `lockscreen-cmd' when set; otherwise `loginctl lock-session' on
+Wayland and slock on X11, decided per call via `env-wayland-p'."
+ (interactive)
+ (cj/system-cmd (or lockscreen-cmd
+ (if (env-wayland-p) "loginctl lock-session" "slock"))))
(cj/defsystem-command cj/system-cmd-suspend suspend-cmd "systemctl suspend" t)
(cj/defsystem-command cj/system-cmd-shutdown shutdown-cmd "systemctl poweroff" strong)
(cj/defsystem-command cj/system-cmd-reboot reboot-cmd "systemctl reboot" strong)
diff --git a/modules/system-defaults.el b/modules/system-defaults.el
index 7f369a5e..9b4652e8 100644
--- a/modules/system-defaults.el
+++ b/modules/system-defaults.el
@@ -55,6 +55,10 @@
(expand-file-name "comp-warnings.log" user-emacs-directory)
"File where native-comp warnings will be appended.")
+(defvar cj/comp-warnings-log-max-bytes (* 512 1024)
+ "Cap on `comp-warnings-log' size. Once it exceeds this, the log is reset
+before the next write, so native-comp warnings can't grow it without bound.")
+
(defun cj/log-comp-warning (type message &rest args)
"Log native-comp warnings of TYPE with MESSAGE & ARGS.
Log to buffer `comp-warnings-log'. Suppress warnings from appearing in the
@@ -62,13 +66,24 @@ Log to buffer `comp-warnings-log'. Suppress warnings from appearing in the
timestamp to the file specified by `comp-warnings-log'. Return non-nil to
indicate the warning was handled."
(when (memq 'comp (if (listp type) type (list type)))
- (with-temp-buffer
- (insert (format-time-string "[%Y-%m-%d %H:%M:%S] "))
- (insert (if (stringp message)
- (apply #'format message args)
- (format "%S %S" message args)))
- (insert "\n")
- (append-to-file (point-min) (point-max) comp-warnings-log))
+ ;; Reset the log if it has grown past the cap, so async comp warnings can't
+ ;; grow it without bound.
+ (when (ignore-errors
+ (> (or (file-attribute-size (file-attributes comp-warnings-log)) 0)
+ cj/comp-warnings-log-max-bytes))
+ (ignore-errors (delete-file comp-warnings-log)))
+ ;; Guard the write: this runs as `:before-until' advice on `display-warning',
+ ;; so a signal here (an unwritable log path) would propagate out and break
+ ;; warning display for every async comp notice. Swallow the failure; the
+ ;; warning stays suppressed either way.
+ (ignore-errors
+ (with-temp-buffer
+ (insert (format-time-string "[%Y-%m-%d %H:%M:%S] "))
+ (insert (if (stringp message)
+ (apply #'format message args)
+ (format "%S %S" message args)))
+ (insert "\n")
+ (append-to-file (point-min) (point-max) comp-warnings-log)))
;; Return non-nil to tell `display-warning' “we handled it.”
t))
diff --git a/modules/system-lib.el b/modules/system-lib.el
index f1049c02..54e20b74 100644
--- a/modules/system-lib.el
+++ b/modules/system-lib.el
@@ -164,6 +164,22 @@ contributes its own modes regardless of load order."
(setq font-lock-global-modes
(cj/--font-lock-global-modes-excluding font-lock-global-modes mode))))
+;; Declared special here for the compiler; marginalia owns the defvar.
+(defvar marginalia-annotator-registry)
+
+(defun cj/completion-ensure-marginalia-align (category)
+ "Register CATEGORY with marginalia as builtin-annotated, once.
+A custom completion category bypasses marginalia entirely, so the table's
+own annotation function renders unaligned even with `marginalia-align'
+set. A builtin registry entry tells marginalia to use the table's
+annotation function inside its aligned field, so custom annotations line
+up like every stock category. A category that already has an entry is
+left alone (someone chose its annotators deliberately). Silent no-op
+when marginalia isn't loaded."
+ (when (and (boundp 'marginalia-annotator-registry)
+ (not (assq category marginalia-annotator-registry)))
+ (push (list category 'builtin 'none) marginalia-annotator-registry)))
+
(defun cj/completion-table (category collection)
"Return a completion table over COLLECTION tagged with completion CATEGORY.
COLLECTION is anything `completing-read' accepts (list, alist, obarray, hash
@@ -180,7 +196,9 @@ the candidates match one; marginalia then annotates them with no further work."
"Like `cj/completion-table' but also attach ANNOTATE as the annotation function.
ANNOTATE is called with a candidate string and returns its annotation suffix, or
nil. Use this for a custom CATEGORY that marginalia has no built-in annotator
-for: marginalia falls back to the table's own annotation function."
+for; the category is registered with marginalia (builtin) so ANNOTATE's output
+renders right-aligned like stock annotations."
+ (cj/completion-ensure-marginalia-align category)
(lambda (string predicate action)
(if (eq action 'metadata)
`(metadata (category . ,category)
diff --git a/modules/system-utils.el b/modules/system-utils.el
index e779026a..58325d06 100644
--- a/modules/system-utils.el
+++ b/modules/system-utils.el
@@ -92,7 +92,7 @@ detached from Emacs."
output-buffer
(format "%s %s" command (shell-quote-argument file)))
(message "Running %s on %s..."
- (file-name-nondirectory file) command)))))
+ command (file-name-nondirectory file))))))
;;; ------------------------------ Server Shutdown ------------------------------
diff --git a/modules/takuzu-config.el b/modules/takuzu-config.el
new file mode 100644
index 00000000..470aaec8
--- /dev/null
+++ b/modules/takuzu-config.el
@@ -0,0 +1,17 @@
+;;; takuzu-config.el --- Takuzu (Binairo) game configuration -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Wire the standalone takuzu game package (developed at ~/code/takuzu).
+;; Play with M-x takuzu. Switch to :vc once it is published on GitHub.
+
+;;; Code:
+
+(use-package takuzu
+ :load-path "~/code/takuzu"
+ :commands (takuzu)
+ :custom
+ (takuzu-default-size 6)
+ (takuzu-default-difficulty 'easy))
+
+(provide 'takuzu-config)
+;;; takuzu-config.el ends here
diff --git a/modules/telega-config.el b/modules/telega-config.el
index 0ae5116b..acc9e482 100644
--- a/modules/telega-config.el
+++ b/modules/telega-config.el
@@ -54,7 +54,14 @@
:ensure nil
:commands (telega)
:custom
- (telega-use-docker t))
+ (telega-use-docker t)
+ :config
+ ;; Without this, incoming Telegram messages are invisible unless their
+ ;; buffer is on screen -- telega ships desktop notifications but leaves
+ ;; the mode off by default. Runs at telega load (M-x telega), respects
+ ;; telega's own per-chat mute settings. From the 2026-06 config audit;
+ ;; routing through a shared messenger notifier is the unification task.
+ (telega-notifications-mode 1))
(defun cj/telega ()
"Launch telega.el with a helpful message when it isn't installed yet.
diff --git a/modules/test-runner.el b/modules/test-runner.el
index e05145e4..6cb35275 100644
--- a/modules/test-runner.el
+++ b/modules/test-runner.el
@@ -139,9 +139,11 @@ if not found or not in a project."
(t cj/test-global-directory))))))
(defun cj/test--get-test-files ()
- "Return list of test file names (without path) in test directory."
+ "Return list of test file names (without path) in test directory.
+Returns nil when no test directory is available (outside a project
+with `cj/test-global-directory' unset)."
(let ((dir (cj/test--get-test-directory)))
- (when (file-directory-p dir)
+ (when (and dir (file-directory-p dir))
(mapcar #'file-name-nondirectory
(directory-files dir t "^test-.*\\.el$")))))
@@ -169,6 +171,8 @@ Returns: (cons \\='success loaded-count) on success,
(interactive)
(cj/test--ensure-test-dir-in-load-path)
(let ((dir (cj/test--get-test-directory)))
+ (unless dir
+ (user-error "No test directory: not in a project and cj/test-global-directory is unset"))
(unless (file-directory-p dir)
(user-error "Test directory %s does not exist" dir))
(let ((test-files (directory-files dir t "^test-.*\\.el$")))
@@ -200,11 +204,12 @@ Returns: \\='success if added successfully,
(cj/test--ensure-test-dir-in-load-path)
(let* ((focused-files (cj/test--current-focused-files))
(dir (cj/test--get-test-directory))
- (available-files (when (file-directory-p dir)
+ (available-files (when (and dir (file-directory-p dir))
(mapcar #'file-name-nondirectory
(directory-files dir t "^test-.*\\.el$")))))
(if (null available-files)
- (user-error "No test files found in %s" dir)
+ (user-error "No test files found in %s"
+ (or dir "any test directory (not in a project)"))
(let* ((unfocused-files (cl-set-difference available-files
focused-files
:test #'string=))
@@ -239,7 +244,10 @@ Returns: \\='success if added successfully,
Second value is the relative filename if successful."
(cond
((null filepath) (cons 'no-file nil))
- ((not (string-prefix-p (file-truename testdir) (file-truename filepath)))
+ ;; Route through the helper: it appends the trailing slash, so a sibling
+ ;; sharing the directory's name prefix (tests-old/ against tests/) is
+ ;; rejected. A bare `string-prefix-p' on the truenames accepts it.
+ ((not (cj/test--file-in-directory-p filepath testdir))
(cons 'not-in-testdir nil))
(t
(let ((relative (file-relative-name filepath testdir)))
diff --git a/modules/text-config.el b/modules/text-config.el
index dd7bd3ca..a65002a8 100644
--- a/modules/text-config.el
+++ b/modules/text-config.el
@@ -69,7 +69,7 @@
;; edit selection in new buffer, C-c to finish; replaces with modifications
(use-package edit-indirect
- :bind ("M-S-i" . edit-indirect-region)) ;; was M-I
+ :bind ("M-I" . edit-indirect-region))
;; ------------------------------ Prettify Symbols -----------------------------
;; replacing the word l-a-m-b-d-a with a symbol, just because
@@ -118,8 +118,8 @@ everything else, such as `lambda', use the standard boundary check."
;; an easy way to enter diacritical marks
(use-package accent
- :commands accent-company
- :bind ("C-`" . accent-company))
+ :commands accent-menu
+ :bind ("C-`" . accent-menu))
(provide 'text-config)
;;; text-config.el ends here
diff --git a/modules/tramp-config.el b/modules/tramp-config.el
index f2bc8457..1c8a8ab9 100644
--- a/modules/tramp-config.el
+++ b/modules/tramp-config.el
@@ -26,6 +26,7 @@
;; Silence byte-compiler "assignment to free variable" warnings for vars
;; defined by lazily-loaded packages (tramp, dirtrack, magit). These are
;; only set inside the use-package :config block, after the package loads.
+(defvar ange-ftp-try-passive-mode)
(defvar tramp-copy-size-limit)
(defvar tramp-use-ssh-controlmaster-options)
(defvar tramp-cleanup-idle-time)
@@ -76,9 +77,10 @@
;; Cache remote file attributes for better performance
(setq remote-file-name-inhibit-cache nil)
- ;; Don't check for modified buffers before revert
- ;; to avoid unnecessary remote operations
- (setq revert-without-query '(".*"))
+ ;; Skip the revert confirmation for remote files only, to avoid
+ ;; unnecessary remote round-trips. Scoped to the TRAMP path regexp so
+ ;; local files keep their normal revert prompt.
+ (setq revert-without-query (list tramp-file-name-regexp))
;; Refresh buffers when needed rather than automatically
(setq auto-revert-remote-files nil)
@@ -119,21 +121,8 @@
;; Default transfer method (use scp for most efficient transfer)
(setq tramp-default-method "scp")
- ;; Use different methods based on host/domain patterns
- (add-to-list 'tramp-methods
- '("sshfast"
- (tramp-login-program "ssh")
- (tramp-login-args (("-l" "%u") ("-p" "%p") ("%c")
- ("-e" "none") ("-t" "-t") ("%h")))
- (tramp-async-args (("-q")))
- (tramp-remote-shell "/bin/sh")
- (tramp-remote-shell-login ("-l"))
- (tramp-remote-shell-args ("-c"))
- (tramp-connection-timeout 10)))
-
;; Remote shell and project settings
- ;; Support for Docker containers
- (add-to-list 'tramp-remote-path 'tramp-own-remote-path)
+ ;; Extend the remote PATH (tramp-own-remote-path already added above)
(add-to-list 'tramp-remote-path "/usr/local/bin")
(add-to-list 'tramp-remote-path "/usr/local/sbin")
@@ -147,5 +136,11 @@
;; Cleanup TRAMP buffers when idle (every 15 min)
(setq tramp-cleanup-idle-time 900))
+;; FTP (ange-ftp) settings — TRAMP's /ftp: method delegates to ange-ftp.
+;; Passive mode is required for servers that can't open a data connection
+;; back to this machine (NAT, phone FTP servers); active mode hangs on LIST.
+(with-eval-after-load 'ange-ftp
+ (setq ange-ftp-try-passive-mode t))
+
(provide 'tramp-config)
;;; tramp-config.el ends here
diff --git a/modules/ui-config.el b/modules/ui-config.el
index fbc3d91c..9ac6faec 100644
--- a/modules/ui-config.el
+++ b/modules/ui-config.el
@@ -29,9 +29,6 @@
;; (i.e., read-only, overwrite, normal)
;; – Option to customize cursor shape with 'cj/set-cursor-type'
-;; • Icons
-;; – Load and enable 'nerd-icons' for UI glyphs
-
;; Customize the transparency and cursor color options at the top of this file.
;;; Code:
@@ -116,11 +113,5 @@ When `cj/enable-transparency' is nil, reset alpha to fully opaque."
;; burst when entering read-only buffers like EPUBs or vterm).
(blink-cursor-mode -1)
-;; --------------------------------- Nerd Icons --------------------------------
-;; use icons from nerd fonts in the Emacs UI
-
-(use-package nerd-icons
- :defer t)
-
(provide 'ui-config)
;;; ui-config.el ends here
diff --git a/modules/ui-theme.el b/modules/ui-theme.el
index 499e71a4..b3cdc634 100644
--- a/modules/ui-theme.el
+++ b/modules/ui-theme.el
@@ -88,8 +88,14 @@ If FILENAME isn't readable, return nil."
(string-trim (buffer-string)))))
(defun cj/theme-write-file-contents (content filename)
- "Write CONTENT to FILENAME.
-If FILENAME isn't writeable, return nil. If successful, return t."
+ "Write CONTENT to FILENAME, creating its parent directory if absent.
+On a fresh machine the `persist/' directory doesn't exist yet, and
+`file-writable-p' returns nil for a file inside a missing directory, so the
+write would silently fail. If FILENAME still isn't writeable, return nil.
+If successful, return t."
+ (let ((dir (file-name-directory filename)))
+ (when (and dir (not (file-directory-p dir)))
+ (ignore-errors (make-directory dir t))))
(when (file-writable-p filename)
(condition-case err
(progn
diff --git a/modules/undead-buffers.el b/modules/undead-buffers.el
index cbd2c0d7..e5b8dc00 100644
--- a/modules/undead-buffers.el
+++ b/modules/undead-buffers.el
@@ -65,7 +65,10 @@ regexp in `cj/undead-buffer-regexps'."
cj/undead-buffer-regexps))))
(defun cj/kill-buffer-or-bury-alive (buffer)
- "Kill BUFFER or bury it if it's in `cj/undead-buffer-list'."
+ "Kill BUFFER, or bury it when it is in `cj/undead-buffer-list'.
+With a prefix argument (e.g. \\`C-u'), instead add BUFFER's name to
+`cj/undead-buffer-list' and report it, so the buffer is buried rather than
+killed on later kill attempts."
(interactive "bBuffer to kill or bury: ")
(with-current-buffer buffer
(if current-prefix-arg
@@ -93,17 +96,24 @@ Undead-buffers are buffers in `cj/undead-buffer-list'."
(let ((buf (current-buffer)))
(unless (one-window-p)
(delete-window))
- (cj/kill-buffer-or-bury-alive buf)))
+ ;; The delegate reads current-prefix-arg; a C-u meant for this wrapper
+ ;; must not flip it into add-to-undead-list mode.
+ (let ((current-prefix-arg nil))
+ (cj/kill-buffer-or-bury-alive buf))))
;; Keybinding moved to custom-buffer-file.el (C-; b k)
(defun cj/kill-other-window ()
- "Delete the next window and kill or bury its buffer."
+ "Delete the next window and kill or bury its buffer.
+Signal a `user-error' in a single-window frame, where there is no other
+window and acting would kill the buffer being viewed."
(interactive)
+ (when (one-window-p)
+ (user-error "No other window"))
(other-window 1)
(let ((buf (current-buffer)))
- (unless (one-window-p)
- (delete-window))
- (cj/kill-buffer-or-bury-alive buf)))
+ (delete-window)
+ (let ((current-prefix-arg nil))
+ (cj/kill-buffer-or-bury-alive buf))))
(keymap-global-set "M-S-o" #'cj/kill-other-window)
(defun cj/kill-other-window-buffer ()
@@ -117,7 +127,8 @@ split is preserved. Buffers in `cj/undead-buffer-list' are buried."
(if (one-window-p)
(user-error "No other window")
(with-selected-window (next-window)
- (cj/kill-buffer-or-bury-alive (current-buffer)))))
+ (let ((current-prefix-arg nil))
+ (cj/kill-buffer-or-bury-alive (current-buffer))))))
;; Keybinding in custom-buffer-file.el (C-; b K)
(defun cj/kill-all-other-buffers-and-windows ()
@@ -125,8 +136,9 @@ split is preserved. Buffers in `cj/undead-buffer-list' are buried."
(interactive)
(save-some-buffers nil #'cj/undead-buffer-p)
(delete-other-windows)
- (mapc #'cj/kill-buffer-or-bury-alive
- (delq (current-buffer) (buffer-list))))
+ (let ((current-prefix-arg nil))
+ (mapc #'cj/kill-buffer-or-bury-alive
+ (delq (current-buffer) (buffer-list)))))
(keymap-global-set "M-S-m" #'cj/kill-all-other-buffers-and-windows) ;; was M-M
(provide 'undead-buffers)
diff --git a/modules/vc-config.el b/modules/vc-config.el
index 60fcaeb8..3da9266f 100644
--- a/modules/vc-config.el
+++ b/modules/vc-config.el
@@ -37,9 +37,13 @@
(defvar forge-pull-notifications)
(defvar forge-topic-list-limit)
+;; External package variables (buffer-local hunk list from git-gutter).
+(defvar git-gutter:diffinfos)
+
;; External package functions (from lazily-loaded packages).
(declare-function git-gutter:next-hunk "git-gutter")
(declare-function git-gutter:previous-hunk "git-gutter")
+(declare-function git-gutter-hunk-start-line "git-gutter")
(declare-function git-timemachine--start "git-timemachine")
(declare-function git-timemachine--revisions "git-timemachine")
(declare-function git-timemachine-show-revision "git-timemachine")
@@ -100,8 +104,7 @@
(use-package git-timemachine
:commands (git-timemachine
- git-timemachine-show-revision
- git-timemachine-show-selected-revision)
+ git-timemachine-show-revision)
:init
(defun cj/git-timemachine-show-selected-revision ()
"Displays git revisions of file in chronological order adding metadata."
@@ -157,13 +160,33 @@
(forge-create-issue)
(user-error "Not in a forge repository")))
+(defun cj/--git-gutter-hunk-candidates (start-lines)
+ "Build completion candidates for hunk START-LINES in the current buffer.
+Each candidate is a cons of a \"LINE: text\" label and the line number."
+ (mapcar (lambda (line)
+ (cons (format "%4d: %s" line
+ (save-excursion
+ (goto-char (point-min))
+ (forward-line (1- line))
+ (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ line))
+ start-lines))
+
(defun cj/goto-git-gutter-diff-hunks ()
- "Jump to git-gutter diff hunks using consult.
-Searches for lines starting with + or - (diff markers) and allows
-interactive selection to jump to any changed line in the buffer."
+ "Jump to a git-gutter hunk in the current buffer chosen with completion."
(interactive)
(require 'git-gutter)
- (consult-line "^[+\\-]"))
+ (let ((candidates (cj/--git-gutter-hunk-candidates
+ (mapcar #'git-gutter-hunk-start-line
+ (and (boundp 'git-gutter:diffinfos)
+ git-gutter:diffinfos)))))
+ (unless candidates
+ (user-error "No git-gutter hunks in this buffer"))
+ (let ((line (cdr (assoc (completing-read "Hunk: " candidates nil t)
+ candidates))))
+ (goto-char (point-min))
+ (forward-line (1- line)))))
;; ------------------------------ Git Clone Clipboard -----------------------------
;; Quick git clone from clipboard URL
@@ -180,6 +203,33 @@ scp form."
(last (car (last (split-string trimmed "[/:]" t)))))
(and last (file-name-sans-extension last))))
+(defun cj/--git-clone-open (clone-dir)
+ "Open CLONE-DIR's README when one exists, else `dired' the directory."
+ (let ((readme (seq-find
+ (lambda (file)
+ (string-match-p "\\`README" (upcase file)))
+ (directory-files clone-dir))))
+ (if readme
+ (find-file (expand-file-name readme clone-dir))
+ (dired clone-dir))))
+
+(defun cj/--git-clone-make-sentinel (url clone-dir)
+ "Return a sentinel reporting the git clone of URL into CLONE-DIR.
+On a zero exit the sentinel announces success and opens the clone; on
+any other exit or a signal it surfaces the process buffer."
+ (lambda (process _event)
+ (when (memq (process-status process) '(exit signal))
+ (if (and (eq (process-status process) 'exit)
+ (zerop (process-exit-status process)))
+ (progn
+ (message "Cloned %s into %s" url clone-dir)
+ (cj/--git-clone-open clone-dir))
+ (let ((buf (process-buffer process)))
+ (when (buffer-live-p buf)
+ (pop-to-buffer buf))
+ (message "git clone of %s failed (status %s)"
+ url (process-exit-status process)))))))
+
(defun cj/git-clone-clipboard-url (url target-dir)
"Clone git repository from clipboard URL to TARGET-DIR.
@@ -187,11 +237,13 @@ With no prefix argument: uses first directory in `cj/git-clone-dirs'.
With \\[universal-argument]: choose from `cj/git-clone-dirs'.
With \\[universal-argument] \\[universal-argument]: choose any directory.
-Clones with a direct `git' process (no shell), into a path derived
-robustly from URL. Aborts with a clear message when the clipboard is
-empty, the target is not a writable directory, the destination already
-exists, or `git' exits non-zero. After a successful clone, opens the
-repository's README if found, else `dired's the clone."
+Clones with a direct asynchronous `git' process (no shell, no frozen
+frames), into a path derived robustly from URL. Aborts with a clear
+message when the clipboard is empty, the target is not a writable
+directory, or the destination already exists. The process sentinel
+reports the result: on success it opens the repository's README if
+found (else `dired's the clone); on failure it surfaces the process
+buffer."
(interactive
(list (current-kill 0) ;; Get URL from clipboard
(cond
@@ -219,21 +271,14 @@ repository's README if found, else `dired's the clone."
(when (file-exists-p clone-dir)
(user-error "Clone destination already exists: %s" clone-dir))
(message "Cloning %s into %s..." url clone-dir)
- ;; Direct process, no shell. `--' stops option parsing so a URL
- ;; beginning with `-' can't be read as a git flag.
- (let ((status (call-process "git" nil "*git-clone*" nil
- "clone" "--" url clone-dir)))
- (unless (zerop status)
- (pop-to-buffer "*git-clone*")
- (user-error "git clone failed (exit %d); see *git-clone*" status)))
- ;; Find and open README
- (let ((readme (seq-find
- (lambda (file)
- (string-match-p "\\`README" (upcase file)))
- (directory-files clone-dir))))
- (if readme
- (find-file (expand-file-name readme clone-dir))
- (dired clone-dir))))))
+ ;; Direct async process, no shell, so no emacsclient frame blocks
+ ;; for the duration of the clone. `--' stops option parsing so a
+ ;; URL beginning with `-' can't be read as a git flag.
+ (make-process
+ :name "git-clone"
+ :buffer "*git-clone*"
+ :command (list "git" "clone" "--" url clone-dir)
+ :sentinel (cj/--git-clone-make-sentinel url clone-dir)))))
;; -------------------------------- Difftastic ---------------------------------
;; Structural diffs for better git change visualization
@@ -243,7 +288,7 @@ repository's README if found, else `dired's the clone."
:defer t
:commands (difftastic-magit-diff difftastic-magit-show)
:bind (:map magit-blame-read-only-mode-map
- ("D" . difftastic-magit-show)
+ ("D" . difftastic-magit-diff)
("S" . difftastic-magit-show))
:config
(eval-after-load 'magit-diff
diff --git a/modules/video-audio-recording-capture.el b/modules/video-audio-recording-capture.el
index ea0d687c..a56a5906 100644
--- a/modules/video-audio-recording-capture.el
+++ b/modules/video-audio-recording-capture.el
@@ -56,6 +56,10 @@ Checks if process is actually alive, not just if variable is set."
;;; Process Lifecycle (Sentinel and Graceful Shutdown)
+;; Forward declaration: the real `defvar' is defined below with the other
+;; recording thresholds. Declared special here so this reference compiles clean.
+(defvar cj/recording-start-fail-threshold)
+
(defun cj/recording-process-sentinel (process event)
"Sentinel for recording processes — handles unexpected exits.
PROCESS is the ffmpeg shell process, EVENT describes what happened.
@@ -70,7 +74,20 @@ is killed externally."
(message "Audio recording stopped: %s" (string-trim event)))
((eq process cj/video-recording-ffmpeg-process)
(setq cj/video-recording-ffmpeg-process nil)
- (message "Video recording stopped: %s" (string-trim event))))
+ (let ((start (process-get process 'cj-start-time)))
+ (if (and start
+ (not (process-get process 'cj-stopping))
+ (cj/recording--start-failed-p (- (float-time) start)
+ cj/recording-start-fail-threshold))
+ ;; Died almost immediately and the user didn't stop it: wf-recorder
+ ;; couldn't grab the screen. Delete the ~0.5s stub file the failed
+ ;; start wrote (it would otherwise litter the recordings directory
+ ;; and get swept up by *.mkv globs downstream), then say so instead
+ ;; of silently clearing, so Craig isn't left blind-retrying.
+ (progn
+ (cj/recording--delete-failed-start-stub process)
+ (message "Video recording failed to start (wf-recorder couldn't grab the screen). Try again."))
+ (message "Video recording stopped: %s" (string-trim event))))))
(force-mode-line-update t)))
(defun cj/recording--wait-for-exit (process timeout-secs)
@@ -87,13 +104,54 @@ so a fixed 0.5s wait was causing zero-byte output files."
(accept-process-output process 0.1))
(not (process-live-p process))))
+(defvar cj/recording-wf-recorder-wait-timeout 2.0
+ "Seconds to wait for a dying wf-recorder to release the compositor capture.
+Bounds the start-path poll in `cj/ffmpeg-record-video' so it never hangs.")
+
+(defvar cj/recording-start-fail-threshold 1.5
+ "Seconds below which a video recording that exits is treated as a failed start.
+A wf-recorder that can't grab the screen dies almost immediately (~0.5s); a real
+recording runs far longer, so an exit sooner than this is a start failure, not a
+normal stop.")
+
+(defun cj/recording--wf-recorder-running-p ()
+ "Return non-nil if any wf-recorder process is currently running."
+ (eq 0 (call-process "pgrep" nil nil nil "-x" "wf-recorder")))
+
+(defun cj/recording--wait-for-no-wf-recorder (timeout-secs &optional running-p)
+ "Poll until no wf-recorder remains, or TIMEOUT-SECS elapse.
+Returns t if wf-recorder cleared within the timeout, nil on timeout. RUNNING-P
+is the predicate checked each poll (default `cj/recording--wf-recorder-running-p');
+tests inject a fake. This replaces a fixed `sit-for' after the start-path
+`pkill -INT wf-recorder': the kill signals the old recorder to finalize and
+exit, but releasing the compositor capture takes longer than a fixed wait, so
+launching too soon loses the grab and produces a ~0.5s fragment file."
+ (let ((check (or running-p #'cj/recording--wf-recorder-running-p))
+ (deadline (+ (float-time) timeout-secs)))
+ (while (and (funcall check) (< (float-time) deadline))
+ (sleep-for 0.05))
+ (not (funcall check))))
+
+(defun cj/recording--start-failed-p (elapsed threshold)
+ "Return non-nil when ELAPSED seconds since start is below THRESHOLD.
+A failed wf-recorder start exits almost immediately; a real recording does not."
+ (< elapsed threshold))
+
+(defun cj/recording--delete-failed-start-stub (process)
+ "Delete the stub output file a failed video start left behind.
+Reads the output path from PROCESS's `cj-output-file' property (stamped
+by `cj/ffmpeg-record-video'). A no-op when the property is absent (a
+process started before the property existed) or the file never hit disk."
+ (let ((file (process-get process 'cj-output-file)))
+ (when (and file (file-exists-p file))
+ (delete-file file))))
+
;;; Dependency Checks
(defun cj/recording-check-ffmpeg ()
"Check if ffmpeg is available. Error if not found."
(unless (executable-find "ffmpeg")
- (user-error "Ffmpeg not found. Install with: sudo pacman -S ffmpeg")
- nil)
+ (user-error "Ffmpeg not found. Install with: sudo pacman -S ffmpeg"))
t)
(defun cj/recording--wayland-p ()
@@ -104,8 +162,7 @@ so a fixed 0.5s wait was causing zero-byte output files."
"Check if wf-recorder is available (needed for Wayland video capture)."
(if (executable-find "wf-recorder")
t
- (user-error "wf-recorder not found. Install with: sudo pacman -S wf-recorder")
- nil))
+ (user-error "wf-recorder not found. Install with: sudo pacman -S wf-recorder")))
;;; Device Acquisition and Validation
@@ -199,7 +256,10 @@ On X11: ffmpeg captures screen directly via x11grab with PulseAudio audio."
(if on-wayland
(progn
(cj/recording--check-wf-recorder)
- (format (concat "wf-recorder -y -c libx264 -m matroska -f /dev/stdout 2>/dev/null | "
+ ;; wf-recorder stderr is NOT discarded: it flows to the process buffer
+ ;; (*ffmpeg-video-recording*) so a failed capture grab is diagnosable
+ ;; instead of silent.
+ (format (concat "wf-recorder -y -c libx264 -m matroska -f /dev/stdout | "
"ffmpeg -i pipe:0 "
"-f pulse -i %s "
"-f pulse -i %s "
@@ -264,9 +324,13 @@ Uses wf-recorder on Wayland, x11grab on X11."
;; kill on purpose: the orphans' launching shells are already dead, so
;; there is no live PID to scope to. The stop path, by contrast, scopes
;; to our own shell's child (see cj/recording--interrupt-child-wf-recorder).
+ ;; Wait for the signalled wf-recorder to actually exit and release the
+ ;; compositor capture before launching a new one. A fixed `sit-for' here
+ ;; raced the dying recorder and left ~0.5s fragment files (same class the
+ ;; stop path already fixed with `cj/recording--wait-for-exit').
(when (cj/recording--wayland-p)
(call-process "pkill" nil nil nil "-INT" "wf-recorder")
- (sit-for 0.1))
+ (cj/recording--wait-for-no-wf-recorder cj/recording-wf-recorder-wait-timeout))
(let* ((devices (cj/recording-get-devices))
(mic-device (car devices))
(system-device (cdr devices))
@@ -282,6 +346,11 @@ Uses wf-recorder on Wayland, x11grab on X11."
record-command))
(set-process-query-on-exit-flag cj/video-recording-ffmpeg-process nil)
(set-process-sentinel cj/video-recording-ffmpeg-process #'cj/recording-process-sentinel)
+ ;; Stamp the start time so the sentinel can tell a ~0.5s failed start
+ ;; (wf-recorder couldn't grab the screen) from a normal recording, and
+ ;; the output path so the failed-start branch can delete the stub file.
+ (process-put cj/video-recording-ffmpeg-process 'cj-start-time (float-time))
+ (process-put cj/video-recording-ffmpeg-process 'cj-output-file filename)
(force-mode-line-update t)
(message "Started video recording to %s (%s, mic: %.1fx, system: %.1fx)."
filename
@@ -339,6 +408,9 @@ for ffmpeg to write container metadata before giving up."
(if (not cj/video-recording-ffmpeg-process)
(message "No video recording in progress.")
(let ((proc cj/video-recording-ffmpeg-process))
+ ;; Mark this as a user stop so the sentinel's fail-fast check doesn't
+ ;; misread a quick intentional stop as a failed start.
+ (process-put proc 'cj-stopping t)
;; On Wayland, kill the producer (wf-recorder) FIRST so ffmpeg sees
;; a clean EOF on pipe:0. This triggers ffmpeg's orderly shutdown:
;; drain remaining frames, write container metadata, close file.
diff --git a/modules/video-audio-recording-devices.el b/modules/video-audio-recording-devices.el
index 375a81cf..8adcd347 100644
--- a/modules/video-audio-recording-devices.el
+++ b/modules/video-audio-recording-devices.el
@@ -272,54 +272,6 @@ Returns the selected device name, or signals user-error if cancelled."
(user-error "Device setup cancelled"))
device))
-(defun cj/recording-group-devices-by-hardware ()
- "Group audio sources by physical hardware device.
-Returns alist of (friendly-name . (mic-source . monitor-source)).
-Only includes devices that have BOTH a mic and a monitor source,
-since recording needs both to capture your voice and system audio."
- (let ((sources (cj/recording-parse-sources))
- (devices (make-hash-table :test 'equal))
- (result nil))
- ;; Group sources by base device name (hardware identifier)
- (dolist (source sources)
- (let* ((device (nth 0 source))
- ;; Extract hardware ID — the unique part identifying the physical device.
- ;; Different device types use different naming conventions in PulseAudio.
- (base-name (cond
- ;; USB devices: extract usb-XXXXX-XX part
- ((string-match "\\.\\(usb-[^.]+\\-[0-9]+\\)\\." device)
- (match-string 1 device))
- ;; Built-in (PCI) devices: extract pci-XXXXX part
- ((string-match "\\.\\(pci-[^.]+\\)\\." device)
- (match-string 1 device))
- ;; Bluetooth devices: extract and normalize MAC address
- ;; (input uses colons, output uses underscores)
- ((string-match "bluez_\\(?:input\\|output\\)\\.\\([^.]+\\)" device)
- (replace-regexp-in-string "_" ":" (match-string 1 device)))
- (t device)))
- (is-monitor (string-match-p "\\.monitor$" device))
- (device-entry (gethash base-name devices)))
- (unless device-entry
- (setf device-entry (cons nil nil))
- (puthash base-name device-entry devices))
- (if is-monitor
- (setcdr device-entry device)
- (setcar device-entry device))))
-
- ;; Convert hash table to alist with user-friendly names
- (maphash (lambda (base-name pair)
- (when (and (car pair) (cdr pair))
- (let ((friendly-name
- (cond
- ((string-match-p "usb.*[Jj]abra" base-name) "Jabra SPEAK 510 USB")
- ((string-match-p "^usb-" base-name) "USB Audio Device")
- ((string-match-p "^pci-" base-name) "Built-in Audio")
- ((string-match-p "^[0-9A-Fa-f:]+$" base-name) "Bluetooth Headset")
- (t base-name))))
- (push (cons friendly-name pair) result))))
- devices)
- (nreverse result)))
-
(defun cj/recording-select-device (prompt device-type)
"Interactively select an audio device.
PROMPT is shown to user. DEVICE-TYPE is \\='mic or \\='monitor for filtering.
diff --git a/modules/wrap-up.el b/modules/wrap-up.el
index e28ba845..6901901f 100644
--- a/modules/wrap-up.el
+++ b/modules/wrap-up.el
@@ -23,12 +23,12 @@
"Bury comint and compilation buffers."
(dolist (buf (buffer-list))
(with-current-buffer buf
+ ;; Byte-compilation output arrives in `emacs-lisp-compilation-mode',
+ ;; which derives from `compilation-mode' and so is covered by that clause.
(when (or (derived-mode-p 'comint-mode)
(derived-mode-p 'compilation-mode)
(derived-mode-p 'debugger-mode)
- (derived-mode-p 'elisp-compile-mode)
- (derived-mode-p 'messages-buffer-mode)
- ) ;; byte-compilations
+ (derived-mode-p 'messages-buffer-mode))
(bury-buffer)))))
(defun cj/bury-buffers-after-delay ()
diff --git a/org-roam-templates/recipe.org b/org-roam-templates/recipe.org
deleted file mode 100644
index ed395be1..00000000
--- a/org-roam-templates/recipe.org
+++ /dev/null
@@ -1,17 +0,0 @@
-#+TITLE: ${title}
-#+CATEGORY: ${title}
-#+FILETAGS: Recipe
-#+STARTUP: showall
-
-${title}
-
-* Mise En Place
-
-
-* Execution
-
-
-* Notes
-
-
-* Nutrition
diff --git a/org-roam-templates/topic.org b/org-roam-templates/topic.org
deleted file mode 100644
index 40a09f79..00000000
--- a/org-roam-templates/topic.org
+++ /dev/null
@@ -1,7 +0,0 @@
-#+TITLE: ${title}
-#+CATEGORY: ${title}
-#+FILETAGS: Topic
-
-* ${title}
-
-
diff --git a/org-roam-templates/v2mom.org b/org-roam-templates/v2mom.org
deleted file mode 100644
index 0316299d..00000000
--- a/org-roam-templates/v2mom.org
+++ /dev/null
@@ -1,40 +0,0 @@
-#+TITLE: ${title}
-#+CATEGORY: ${title}
-#+FILETAGS: V2MOM
-
-* Vision
-_What do you want to achieve?_
-
-
-* Values
-_What's important about it? What are the principles and beliefs that guide it?_
-
-1.
-2.
-3.
-
-* Methods
-_How will you get it done? What are the key initiatives and actions?_
-
-1.
-2.
-3.
-4.
-5.
-
-* Obstacles
-_What might prevent success? What challenges must be overcome?_
-
-1.
- - Mitigation:
-2.
- - Mitigation:
-3.
- - Mitigation:
-
-* Measures
-_How will you know when you've succeeded? What are the key metrics?_
-
-1.
-2.
-3.
diff --git a/scripts/remote-repository-reset.sh b/scripts/remote-repository-reset.sh
deleted file mode 100755
index e9a243a8..00000000
--- a/scripts/remote-repository-reset.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/sh
-# Craig Jennings
-# post archsetup step to reset remote upstream repositories on emacs
-# configuration and doftiles.
-
-cd ~/emacs.d/
-git remote remove origin
-git remote add github git@github.com:cjennings/dotemacs.git
-git remote add origin git@cjennings.net:dotemacs.git
-git branch -M main
-git push -u origin main
-
-
-cd ~/.dotfiles/
-git remote remove origin
-git remote add github git@github.com:cjennings/dotfiles.git
-git remote add origin git@cjennings.net:dotfiles.git
-git branch -M main
-git push -u origin main
diff --git a/scripts/theme-studio/WIP.json b/scripts/theme-studio/WIP.json
index 22abeb7f..ceb54b74 100644
--- a/scripts/theme-studio/WIP.json
+++ b/scripts/theme-studio/WIP.json
@@ -457,7 +457,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"p": {
"fg": "#bfc4d0",
@@ -473,7 +474,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"kw": {
"fg": "#67809c",
@@ -489,7 +491,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"bi": {
"fg": "#a9b2bb",
@@ -505,7 +508,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"pp": {
"fg": "#dce0e3",
@@ -521,7 +525,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"fnd": {
"fg": "#cbd0d6",
@@ -537,7 +542,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"fnc": {
"fg": "#bac1c8",
@@ -553,7 +559,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"dec": {
"fg": "#a9b2bb",
@@ -569,7 +576,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"ty": {
"fg": "#ab8d2e",
@@ -585,7 +593,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"prop": {
"fg": "#a9b2bb",
@@ -601,7 +610,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"con": {
"fg": "#dab53d",
@@ -617,7 +627,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"num": {
"fg": "#cb6b4d",
@@ -633,7 +644,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"str": {
"fg": "#74932f",
@@ -649,7 +661,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"esc": {
"fg": "#bfc4d0",
@@ -665,7 +678,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"re": {
"fg": "#74932f",
@@ -681,7 +695,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"rxgb": {
"fg": "#000000",
@@ -697,7 +712,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"rxgc": {
"fg": "#000000",
@@ -713,7 +729,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"doc": {
"fg": "#bfc4d0",
@@ -729,7 +746,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"dmark": {
"fg": "#d3d3d3",
@@ -745,7 +763,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"cm": {
"fg": "#a9b2bb",
@@ -761,7 +780,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"cmd": {
"fg": "#a9b2bb",
@@ -777,7 +797,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"var": {
"fg": "#dab53d",
@@ -793,7 +814,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"op": {
"fg": "#dce0e3",
@@ -809,7 +831,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"neg": {
"fg": "#000000",
@@ -825,7 +848,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"punc": {
"fg": "#dce0e3",
@@ -841,7 +865,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"warn": {
"fg": "#cb6b4d",
@@ -857,7 +882,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
}
},
"ui": {
@@ -875,7 +901,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"region": {
"fg": null,
@@ -891,7 +918,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"hl-line": {
"fg": null,
@@ -907,7 +935,8 @@
"inverse": false,
"extend": false,
"inherit": "highlight",
- "height": null
+ "height": null,
+ "heightMode": null
},
"highlight": {
"fg": "#dab53d",
@@ -923,11 +952,12 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"mode-line": {
"fg": "#cbd0d6",
- "bg": "#424f5e",
+ "bg": "#303842",
"distant-fg": null,
"family": null,
"weight": null,
@@ -938,7 +968,7 @@
"box": {
"style": "line",
"width": 1,
- "color": "#a9b2bb"
+ "color": "#7c838a"
},
"inverse": false,
"extend": false,
@@ -948,7 +978,7 @@
},
"mode-line-highlight": {
"fg": "#e6ce88",
- "bg": "#424f5e",
+ "bg": "#303842",
"distant-fg": null,
"family": null,
"weight": null,
@@ -960,12 +990,13 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"mode-line-inactive": {
"fg": "#100f0f",
- "bg": null,
- "distant-fg": null,
+ "bg": "#100f0f",
+ "distant-fg": "#100f0f",
"family": null,
"weight": null,
"slant": null,
@@ -975,12 +1006,64 @@
"box": {
"style": "line",
"width": 1,
- "color": "#54677d"
+ "color": "#7c838a"
},
"inverse": false,
"extend": false,
"inherit": "mode-line",
- "height": null
+ "height": 2,
+ "heightMode": "abs"
+ },
+ "header-line": {
+ "fg": null,
+ "bg": null,
+ "distant-fg": null,
+ "family": null,
+ "weight": null,
+ "slant": null,
+ "underline": null,
+ "strike": null,
+ "overline": null,
+ "box": null,
+ "inverse": false,
+ "extend": false,
+ "inherit": null,
+ "height": null,
+ "heightMode": null
+ },
+ "tab-bar": {
+ "fg": null,
+ "bg": null,
+ "distant-fg": null,
+ "family": null,
+ "weight": null,
+ "slant": null,
+ "underline": null,
+ "strike": null,
+ "overline": null,
+ "box": null,
+ "inverse": false,
+ "extend": false,
+ "inherit": null,
+ "height": 130,
+ "heightMode": "abs"
+ },
+ "tab-line": {
+ "fg": null,
+ "bg": null,
+ "distant-fg": null,
+ "family": null,
+ "weight": null,
+ "slant": null,
+ "underline": null,
+ "strike": null,
+ "overline": null,
+ "box": null,
+ "inverse": false,
+ "extend": false,
+ "inherit": null,
+ "height": 130,
+ "heightMode": "abs"
},
"fringe": {
"fg": "#f3e7c5",
@@ -996,7 +1079,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"line-number": {
"fg": "#54677d",
@@ -1046,7 +1130,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"isearch": {
"fg": null,
@@ -1062,7 +1147,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"lazy-highlight": {
"fg": null,
@@ -1078,7 +1164,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"isearch-fail": {
"fg": "#cb6b4d",
@@ -1094,7 +1181,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"show-paren-match": {
"fg": "#100f0f",
@@ -1110,7 +1198,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"show-paren-mismatch": {
"fg": "#edeff1",
@@ -1126,7 +1215,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"link": {
"fg": "#5178db",
@@ -1145,7 +1235,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"error": {
"fg": "#cb6b4d",
@@ -1161,7 +1252,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"warning": {
"fg": "#ab8d2e",
@@ -1177,7 +1269,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"success": {
"fg": "#74932f",
@@ -1193,7 +1286,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
+ "height": null,
+ "heightMode": null
},
"vertical-border": {
"fg": "#4a4b4f",
@@ -1209,41 +1303,8 @@
"inverse": false,
"extend": false,
"inherit": null,
- "height": null
- },
- "tab-bar": {
- "fg": null,
- "bg": null,
- "distant-fg": null,
- "family": null,
- "weight": null,
- "slant": null,
- "underline": null,
- "strike": null,
- "overline": null,
- "box": null,
- "inverse": false,
- "extend": false,
- "inherit": null,
- "height": 130,
- "heightMode": "abs"
- },
- "tab-line": {
- "fg": null,
- "bg": null,
- "distant-fg": null,
- "family": null,
- "weight": null,
- "slant": null,
- "underline": null,
- "strike": null,
- "overline": null,
- "box": null,
- "inverse": false,
- "extend": false,
- "inherit": null,
- "height": 130,
- "heightMode": "abs"
+ "height": null,
+ "heightMode": null
}
},
"locks": [
@@ -1617,9 +1678,6 @@
"ui:cursor",
"ui:hl-line",
"ui:highlight",
- "ui:mode-line",
- "ui:mode-line-highlight",
- "ui:mode-line-inactive",
"ui:fringe",
"ui:line-number",
"ui:line-number-current-line",
@@ -1761,16 +1819,16 @@
"weight": "bold",
"inherit": null,
"height": 1.2,
- "source": "user",
- "heightMode": "rel"
+ "heightMode": "rel",
+ "source": "user"
},
"org-document-info": {
"fg": "#ab8d2e",
"bg": "#100f0f",
"inherit": null,
"height": 1.15,
- "source": "user",
- "heightMode": "rel"
+ "heightMode": "rel",
+ "source": "user"
},
"org-document-info-keyword": {
"fg": "#7c838a",
@@ -3887,8 +3945,8 @@
"slant": "italic",
"inherit": "default",
"height": 1.25,
- "source": "user",
- "heightMode": "rel"
+ "heightMode": "rel",
+ "source": "user"
},
"dashboard-text-banner": {
"fg": "#dab53d",
@@ -4707,8 +4765,8 @@
"bg": null,
"inherit": "cj/nov-reading-sepia",
"height": 1.2,
- "source": "user",
- "heightMode": "rel"
+ "heightMode": "rel",
+ "source": "user"
},
"cj/nov-reading-sepia-link": {
"fg": "#5178db",
@@ -6076,8 +6134,8 @@
"weight": "bold",
"inherit": null,
"height": 1.4,
- "source": "default",
- "heightMode": "rel"
+ "heightMode": "rel",
+ "source": "default"
},
"shr-h2": {
"fg": "#bfc4d0",
@@ -6085,8 +6143,8 @@
"weight": "bold",
"inherit": null,
"height": 1.2,
- "source": "user",
- "heightMode": "rel"
+ "heightMode": "rel",
+ "source": "user"
},
"shr-h3": {
"fg": "#a6aab4",
@@ -7069,8 +7127,8 @@
"weight": "bold",
"inherit": null,
"height": 1.1,
- "source": "default",
- "heightMode": "rel"
+ "heightMode": "rel",
+ "source": "default"
}
},
"emms": {
@@ -9030,4 +9088,4 @@
}
}
}
-}
+} \ No newline at end of file
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index 44a01514..6a2daad2 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -40,6 +40,9 @@ APP_CORE_J
// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from
// app-util.js. textOn uses rl from the colormath core above.
APP_UTIL_J
+// The seeding engine (seed model + pure seed()), inlined from seed-core.js. Uses
+// oklchOf/oklch2hex from the colormath core above; the #seedtest gate runs seed().
+SEED_CORE_J
// Pure palette-generator planner and browser-side generator panel.
PALETTE_GENERATOR_CORE_J
PALETTE_GENERATOR_UI_J
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index 3ccec8ea..e0132bcd 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -77,6 +77,37 @@ function pkgSelftest(){
const d=document.createElement('div');d.id='selftest';d.textContent='SELFTEST '+verdict+' roundtrip='+roundtrip+' oldjson='+oldjson+' inherit='+inherited+' height='+height+' cleared='+cleared+' unknown='+unknown+' cycle='+cyc;document.body.appendChild(d);
}
if(location.hash==='#selftest')pkgSelftest();
+// Seeding-engine gate (open with #seedtest): the pure seed() projects the guide's
+// role table onto the three owned tiers. Assert representative faces land on the
+// right swatch/weight/channel, and that a non-org bespoke package (magit) keeps
+// its curated APPS seed (seed() owns org among packages, nothing else).
+if(location.hash==='#seedtest')gate('seedtest',A=>{
+ const m=buildModel();
+ const s=seed(m,{cats:CATS.map(c=>c[0])});
+ // syntax tier
+ A(s.syntax.bi.fg===m.swatch['blue-grey'],'bi=blue-grey');
+ A(s.syntax.fnd.fg===m.swatch.gold&&s.syntax.fnd.weight==='bold','fnd=gold+bold');
+ A(s.syntax.fnc.fg===m.swatch['gold-quiet']&&s.syntax.fnc.weight!=='bold','fnc=gold-quiet');
+ A(s.syntax.var.fg===m.swatch.fg,'var=base');
+ A(s.syntax.op.fg===m.swatch['muted-fg']&&s.syntax.punc.fg===m.swatch['muted-fg'],'op/punc=structure');
+ A(s.syntax.kw.fg===m.swatch.blue&&s.syntax.kw.weight==='bold','kw=control');
+ A(s.syntax.doc.slant==='italic','doc=italic');
+ A(s.syntax.bg.fg===m.swatch.ground,'bg=ground');
+ // UI tier
+ A(!!s.ui.region.bg&&!s.ui.region.fg,'region bg-only');
+ A(!!s.ui.link.underline&&s.ui.link.fg===m.swatch.blue,'link underlined');
+ A(s.ui.error.fg===m.swatch.red&&s.ui.warning.fg===m.swatch.amber&&s.ui.success.fg===m.swatch.green,'signals on convention hues');
+ A(s.ui['mode-line'].fg!==s.ui['mode-line-inactive'].fg,'active!=idle chrome');
+ // org tier
+ const org=s.packages['org-mode'];
+ A(Object.keys(s.packages).length===1,'packages=org-mode only');
+ A(oklchOf(org['org-level-1'].fg).L>oklchOf(org['org-level-2'].fg).L&&org['org-level-1'].weight==='bold','org-level-1 strongest+bold');
+ A(org['org-code'].fg===m.swatch.terracotta&&org['org-code'].inherit==='fixed-pitch','org-code literal lane');
+ A(!!org['org-done'].strike,'org-done struck');
+ // non-org bespoke package keeps its curated seed (untouched by seed())
+ const mag=seedPkgmap()['magit'];
+ A(!!mag&&!!mag['magit-section-heading']&&!!mag['magit-section-heading'].fg,'magit keeps curated seed');
+});
// Lock-mechanism gate (open with #locktest): two behaviors the refactor must
// preserve, across all three tiers. (1) Locking a row disables its controls via
// the shared mkLockCell. (2) reset/erase batch actions update editable rows but
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index b0fafefd..477407ee 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -146,6 +146,11 @@ APP_CORE_BODY=strip_exports(read_text('app-core.js'))
# test-app-util.mjs. Its `import rl` line is stripped on inline (rl is already in
# the page from the colormath core).
APP_UTIL_BODY=strip_exports(read_text('app-util.js'))
+# The seeding engine (seed-core.js): the seed model as data and the pure seed().
+# Inlined below the colormath core (its only dependency) so the browser #seedtest
+# runs the same code the Node tests import. Its `import` from colormath is stripped
+# on inline, where oklchOf/oklch2hex are already present.
+SEED_CORE_BODY=strip_exports(read_text('seed-core.js'))
# Pure palette-generator planner and its browser UI panel, split from the shared
# app core so generation behavior and panel wiring can evolve locally.
PALETTE_GENERATOR_CORE_BODY=strip_exports(read_text('palette-generator-core.js'))
@@ -422,6 +427,7 @@ def _build():
.replace("CONTROLS_J",CONTROLS_BODY)
.replace("PREVIEWS_J",PREVIEWS_BODY)
.replace("APP_UTIL_J",APP_UTIL_BODY)
+ .replace("SEED_CORE_J",SEED_CORE_BODY)
.replace("PALETTE_GENERATOR_CORE_J",PALETTE_GENERATOR_CORE_BODY)
.replace("PALETTE_GENERATOR_UI_J",PALETTE_GENERATOR_UI_BODY)
.replace("PALETTE_ACTIONS_J",PALETTE_ACTIONS_BODY)
diff --git a/scripts/theme-studio/samples.py b/scripts/theme-studio/samples.py
index ca568ed3..9b8c8a72 100644
--- a/scripts/theme-studio/samples.py
+++ b/scripts/theme-studio/samples.py
@@ -4,9 +4,11 @@ COLS={
'fnd':("#a9b2bb",True),'fnc':("#a9b2bb",False),'dec':("#e8bd30",False),
'ty':("#9b5fd0",False),'prop':("#838d97",False),
'con':("#cb6b4d",False),'num':("#cb6b4d",False),'esc':("#cb6b4d",False),
- 'str':("#2ba178",False),'re':("#5d9b86",False),'doc':("#5d9b86",False),
+ 'str':("#2ba178",False),'re':("#5d9b86",False),'rxgb':("#5d9b86",True),'rxgc':("#5d9b86",False),
+ 'doc':("#5d9b86",False),'dmark':("#5d9b86",False),
'cm':("#be9e74",False),'cmd':("#a9b2bb",False),
- 'var':("#e8bd30",False),'op':("#a9b2bb",False),'punc':("#a9b2bb",False),'p':("#ffffff",False),
+ 'var':("#e8bd30",False),'op':("#a9b2bb",False),'neg':("#cb6b4d",False),
+ 'punc':("#a9b2bb",False),'p':("#ffffff",False),'warn':("#cb6b4d",True),
}
NAMES={"#67809c":"blue","#e8bd30":"gold","#9b5fd0":"regal","#2ba178":"emerald","#cb6b4d":"terracotta","#be9e74":"tan","#5d9b86":"sage","#cdced1":"white","#a9b2bb":"silver","#838d97":"steel","#5e6770":"pewter","#2f343a":"gunmetal","#264364":"navy"}
def esc(t): return t.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
diff --git a/scripts/theme-studio/seed-core.js b/scripts/theme-studio/seed-core.js
new file mode 100644
index 00000000..fb7362d4
--- /dev/null
+++ b/scripts/theme-studio/seed-core.js
@@ -0,0 +1,249 @@
+// seed-core.js — the theme-studio seeding engine (Phase 1).
+//
+// The seed model as data and the pure seed() operation. This is
+// theme-coloring-guide.org made executable: a named palette (OKLCH-generated
+// shades over a handful of dupre anchor hues), a role-to-treatment table (the
+// guide's seed table), and a face-to-role map for each of the three owned tiers
+// (syntax, UI, org). seed(model) classifies every face and applies the table,
+// producing default assignments in the shape the import path already consumes.
+//
+// Pure: no DOM, no side effects. node imports this module for its unit tests;
+// generate.py strips the import line and inlines the body into the page (below
+// the colormath core, so oklchOf/oklch2hex are already present) so the browser
+// #seedtest runs the same code. One source of truth, like colormath.js.
+//
+// Scope (Package scope in the spec): seed() owns syntax, UI, and org among
+// packages. The other ~20 bespoke packages keep their curated APPS seeds, so
+// seed().packages carries only org-mode; the rest flow through seedPkgmap().
+import { oklchOf, oklch2hex } from './colormath.js';
+
+// --- anchors -------------------------------------------------------------
+// The base hues, taken from the bundled dupre palette. Each accent family is
+// anchored here; its quieter/brighter shades are OKLCH-derived (below). Neutrals
+// are taken directly — no ground is pure-white text, and pure black stays the
+// ground only (guide principle 5).
+const ANCHORS = {
+ ground: '#000000', 'bg-dim': '#1a1714',
+ fg: '#a9b2bb', // base identity (dupre silver): comfortable, not pure white
+ 'muted-fg': '#838d97', // structure lane (dupre steel)
+ comment: '#5e6770', // low-contrast comment lane (dupre pewter)
+ blue: '#67809c', gold: '#e8bd30', regal: '#9b5fd0',
+ sage: '#5d9b86', terracotta: '#cb6b4d',
+};
+
+// --- OKLCH shade helpers -------------------------------------------------
+// Step an anchor by a lightness delta and a chroma multiplier, hue held. A
+// quieter shade is darker + lower chroma; a brighter shade is the reverse.
+function shade(hex, dL, cMul) {
+ const { L, C, H } = oklchOf(hex);
+ return oklch2hex(clampL(L + dL), Math.max(0, C * cMul), H).hex;
+}
+// A color placed by absolute OKLCH — used for the signal hues, which are
+// conventional angles rather than shifts of a syntax accent.
+function atHue(L, C, H) { return oklch2hex(clampL(L), C, ((H % 360) + 360) % 360).hex; }
+function clampL(L) { return L < 0 ? 0 : L > 1 ? 1 : L; }
+
+// The heading ramp: one hue across four descending lightness steps (level 1
+// strongest). Deeper org levels cycle through these past level 4.
+function headingRamp(anchorHex) {
+ const { C, H } = oklchOf(anchorHex);
+ return [0.78, 0.68, 0.58, 0.48].map((L) => oklch2hex(L, C, H).hex);
+}
+
+// Build the named swatch set + heading ramp from the anchors. The blue-grey
+// builtin and gold-quiet call are the two shades dupre lacks and gains here.
+function buildModel(anchors = ANCHORS) {
+ const a = anchors;
+ const swatch = {
+ ground: a.ground, 'bg-dim': a['bg-dim'], fg: a.fg,
+ 'muted-fg': a['muted-fg'], comment: a.comment,
+ blue: a.blue,
+ 'blue-grey': shade(a.blue, -0.05, 0.5), // builtin: blue hue, lower chroma/lightness
+ gold: a.gold,
+ 'gold-quiet': shade(a.gold, -0.08, 0.6), // call: quieter same-hue gold
+ regal: a.regal,
+ sage: a.sage,
+ 'sage-muted': shade(a.sage, -0.03, 0.6), // docstring
+ 'sage-bright': shade(a.sage, 0.08, 1.15), // escape
+ teal: (() => { const { L, C, H } = oklchOf(a.sage); return oklch2hex(clampL(L + 0.05), C * 1.1, H - 25).hex; })(), // regexp
+ terracotta: a.terracotta,
+ red: atHue(0.62, 0.15, 29), // signal: error / deletion
+ amber: atHue(0.80, 0.14, 75), // signal: warning / modified
+ green: atHue(0.72, 0.14, 145),// signal: success / addition
+ tint: atHue(0.32, 0.03, oklchOf(a.blue).H), // transient state bg (quiet)
+ 'tint-strong': atHue(0.42, 0.06, oklchOf(a.blue).H), // active match chip
+ };
+ return { swatch, ramp: headingRamp(a.blue), roles: ROLES };
+}
+
+// --- the role-to-treatment table (the guide's seed table as data) --------
+// Each role maps to a swatch, an optional weight/slant/underline, and a channel
+// (fg is identity, bg is state). channel defaults to fg.
+const ROLES = {
+ base: { swatch: 'fg' },
+ structure: { swatch: 'muted-fg' },
+ control: { swatch: 'blue', weight: 'bold' },
+ builtin: { swatch: 'blue-grey' },
+ def: { swatch: 'gold', weight: 'bold' },
+ call: { swatch: 'gold-quiet' },
+ type: { swatch: 'regal' },
+ string: { swatch: 'sage' },
+ docstring: { swatch: 'sage-muted', slant: 'italic' },
+ escape: { swatch: 'sage-bright' },
+ regexp: { swatch: 'teal' },
+ literal: { swatch: 'terracotta' },
+ comment: { swatch: 'comment', slant: 'italic' },
+ sig_error: { swatch: 'red' },
+ sig_warn: { swatch: 'amber' },
+ sig_ok: { swatch: 'green' },
+ sig_link: { swatch: 'blue', underline: true },
+ state: { swatch: 'tint', channel: 'bg' },
+};
+
+// A blank full face spec; seed fills only the fields a role sets.
+function blankSpec() {
+ return { fg: null, bg: null, weight: null, slant: null, underline: null, strike: null, inherit: null, height: null };
+}
+
+// Resolve a ROLES role against the model into a face spec.
+function resolveRole(model, role) {
+ const r = model.roles[role];
+ const hex = model.swatch[r.swatch];
+ const s = blankSpec();
+ if (r.channel === 'bg') s.bg = hex; else s.fg = hex;
+ if (r.weight) s.weight = r.weight;
+ if (r.slant) s.slant = r.slant;
+ if (r.underline) s.underline = { style: 'line', color: null };
+ return s;
+}
+
+// --- face-to-role maps ---------------------------------------------------
+
+// Syntax: CATS key -> role. bg is handled specially (the ground).
+const SYNTAX_ROLES = {
+ p: 'base', var: 'base',
+ op: 'structure', punc: 'structure', neg: 'structure', cmd: 'structure',
+ kw: 'control', pp: 'control',
+ bi: 'builtin',
+ fnd: 'def', fnc: 'call',
+ dec: 'type', ty: 'type', prop: 'type',
+ con: 'literal', num: 'literal',
+ str: 'string', doc: 'docstring',
+ esc: 'escape', dmark: 'escape',
+ re: 'regexp', rxgb: 'regexp', rxgc: 'regexp',
+ cm: 'comment',
+ warn: 'sig_warn',
+};
+
+// UI: face -> either a ROLES role (state/signal/link/control) or an inline
+// chrome spec. Chrome is inherently multi-attribute (fg + bg, active vs idle),
+// so it does not force through the single-swatch role resolver.
+function uiSeed(model) {
+ const sw = model.swatch, out = {};
+ const role = (r) => resolveRole(model, r);
+ const spec = (o) => Object.assign(blankSpec(), o);
+ // Transient state: background tint, no foreground. lazy-highlight (other
+ // matches) shares the quiet tint; isearch (current match) gets a louder chip.
+ for (const f of ['region', 'hl-line', 'highlight', 'show-paren-match', 'lazy-highlight']) out[f] = role('state');
+ out.isearch = spec({ bg: sw['tint-strong'] }); // active match, louder chip
+ // Signals (convention hues) with a weight for redundancy.
+ out.error = spec({ fg: sw.red, weight: 'bold' });
+ out.warning = spec({ fg: sw.amber, weight: 'bold' });
+ out.success = spec({ fg: sw.green, weight: 'bold' });
+ out['isearch-fail'] = spec({ fg: sw.red, weight: 'bold' });
+ out['show-paren-mismatch'] = spec({ bg: sw.red }); // shape + color, not color alone
+ out.link = role('sig_link');
+ // Chrome: active brighter than idle (guide principle 3).
+ out['mode-line'] = spec({ fg: sw.fg, bg: sw['bg-dim'] });
+ out['mode-line-inactive'] = spec({ fg: sw['muted-fg'], bg: sw['bg-dim'] });
+ out['mode-line-highlight'] = spec({ fg: sw.fg });
+ for (const f of ['header-line', 'tab-bar', 'tab-line']) out[f] = spec({ fg: sw['muted-fg'], bg: sw['bg-dim'] });
+ out['line-number'] = spec({ fg: sw.comment });
+ out['line-number-current-line'] = spec({ fg: sw.fg });
+ out.fringe = spec({ fg: sw.comment });
+ out['vertical-border'] = spec({ fg: sw['bg-dim'] });
+ out['minibuffer-prompt'] = role('control');
+ out.cursor = spec({ bg: sw.fg });
+ return out;
+}
+
+// Org: face -> role/heading/inline. Faces not named here seed to base.
+const ORG_MARKUP = ['org-meta-line', 'org-drawer', 'org-special-keyword', 'org-property-value',
+ 'org-block-begin-line', 'org-block-end-line', 'org-ellipsis', 'org-tag', 'org-date',
+ 'org-document-info-keyword', 'org-macro', 'org-target', 'org-footnote-def'];
+const ORG_CODE = ['org-code', 'org-verbatim', 'org-inline-src-block'];
+const ORG_LINK = ['org-link', 'org-cite', 'org-cite-key', 'org-footnote'];
+const ORG_EMPHASIS = ['org-quote', 'org-verse'];
+
+function orgSeed(model, orgFaces) {
+ const sw = model.swatch, out = {};
+ const role = (r) => resolveRole(model, r);
+ const spec = (o) => Object.assign(blankSpec(), o);
+ for (const face of orgFaces) {
+ const lvl = /^org-level-([1-8])$/.exec(face);
+ if (lvl) {
+ const i = Number(lvl[1]);
+ out[face] = spec({ fg: model.ramp[(i - 1) % model.ramp.length], weight: i === 1 ? 'bold' : null });
+ } else if (face === 'org-document-title') {
+ out[face] = spec({ fg: sw.gold, weight: 'bold' });
+ } else if (ORG_CODE.includes(face)) {
+ out[face] = spec({ fg: sw.terracotta, inherit: 'fixed-pitch' });
+ } else if (face === 'org-block') {
+ out[face] = spec({ bg: sw['bg-dim'], inherit: 'fixed-pitch' });
+ } else if (ORG_LINK.includes(face)) {
+ out[face] = spec({ fg: sw.blue, underline: { style: 'line', color: null } });
+ } else if (ORG_MARKUP.includes(face)) {
+ out[face] = spec({ fg: sw['muted-fg'] });
+ } else if (face === 'org-todo' || face === 'org-imminent-deadline') {
+ out[face] = spec({ fg: sw.red, weight: 'bold' });
+ } else if (face === 'org-upcoming-deadline') {
+ out[face] = spec({ fg: sw.amber });
+ } else if (face === 'org-scheduled' || face === 'org-scheduled-today') {
+ out[face] = spec({ fg: sw.comment });
+ } else if (face === 'org-done' || face === 'org-headline-done' || face === 'org-agenda-done') {
+ out[face] = spec({ fg: sw.comment, strike: { color: null } });
+ } else if (ORG_EMPHASIS.includes(face)) {
+ out[face] = spec({ slant: 'italic' });
+ } else {
+ out[face] = role('base');
+ }
+ }
+ return out;
+}
+
+// The org faces the engine seeds. Kept in step with ORG_FACES in face_data.py;
+// a face present here but absent there (or the reverse) simply seeds/omits it.
+// The representative set the guide names is what matters for the tier.
+const ORG_FACES = ('org-document-title org-document-info org-document-info-keyword '
+ + 'org-level-1 org-level-2 org-level-3 org-level-4 org-level-5 org-level-6 org-level-7 org-level-8 '
+ + 'org-headline-done org-todo org-done org-priority org-tag org-special-keyword org-drawer '
+ + 'org-property-value org-warning org-link org-cite org-cite-key org-footnote org-date '
+ + 'org-macro org-target org-block org-block-begin-line org-block-end-line org-code org-verbatim '
+ + 'org-inline-src-block org-quote org-verse org-meta-line org-ellipsis '
+ + 'org-scheduled org-scheduled-today org-upcoming-deadline org-imminent-deadline '
+ + 'org-agenda-done org-table org-formula').split(' ');
+
+// --- seed(): apply the table through each tier's face-to-role map --------
+// Returns {syntax, ui, packages} default assignments. packages carries only
+// org-mode (Package scope); the non-org curated defaults flow through
+// seedPkgmap() over the APPS dicts, untouched by the engine.
+function seed(model, opts = {}) {
+ const cats = opts.cats || CATS_KEYS;
+ const orgFaces = opts.orgFaces || ORG_FACES;
+ const syntax = {};
+ for (const k of cats) {
+ if (k === 'bg') { syntax.bg = Object.assign(blankSpec(), { fg: model.swatch.ground }); continue; }
+ const rname = SYNTAX_ROLES[k] || 'base';
+ syntax[k] = resolveRole(model, rname);
+ }
+ return { syntax, ui: uiSeed(model), packages: { 'org-mode': orgSeed(model, orgFaces) } };
+}
+
+// The syntax categories the engine seeds, kept in step with CATS in generate.py.
+// Passed explicitly by the page (opts.cats) from the live CATS so the two never
+// drift; this literal is the standalone/test default.
+const CATS_KEYS = ['bg', 'p', 'kw', 'bi', 'pp', 'fnd', 'fnc', 'dec', 'ty', 'prop',
+ 'con', 'num', 'str', 'esc', 're', 'rxgb', 'rxgc', 'doc', 'dmark', 'cm', 'cmd',
+ 'var', 'op', 'neg', 'punc', 'warn'];
+
+export { ANCHORS, buildModel, seed, ROLES, SYNTAX_ROLES, ORG_FACES, CATS_KEYS };
diff --git a/scripts/theme-studio/test-seed-core.mjs b/scripts/theme-studio/test-seed-core.mjs
new file mode 100644
index 00000000..8ad6fc60
--- /dev/null
+++ b/scripts/theme-studio/test-seed-core.mjs
@@ -0,0 +1,173 @@
+// Unit tests for the seeding engine (seed-core.js): the seed model as data and
+// the pure seed() operation. seed() projects theme-coloring-guide.org's role/seed
+// table onto the three owned tiers (syntax, UI, org), reusing colormath.js OKLCH
+// generation for the palette shades and heading ramp.
+// Run: node --test scripts/theme-studio/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { buildModel, seed, ROLES } from './seed-core.js';
+import { oklchOf } from './colormath.js';
+
+const here = fileURLToPath(new URL('.', import.meta.url));
+const HEX = /^#[0-9a-f]{6}$/;
+
+// --- the model: OKLCH-generated palette shades ---------------------------
+
+test('buildModel: every swatch is a valid in-gamut hex', () => {
+ const m = buildModel();
+ for (const [name, hex] of Object.entries(m.swatch)) {
+ assert.match(hex, HEX, `swatch ${name} is not a hex: ${hex}`);
+ }
+});
+
+test('buildModel: builtin blue-grey is the blue hue at lower chroma and lightness', () => {
+ const m = buildModel();
+ const blue = oklchOf(m.swatch.blue), grey = oklchOf(m.swatch['blue-grey']);
+ assert.ok(grey.C < blue.C, 'blue-grey should have lower chroma than blue');
+ assert.ok(grey.L < blue.L, 'blue-grey should be darker than blue');
+ assert.ok(Math.abs(grey.H - blue.H) < 15, 'blue-grey should keep the blue hue');
+});
+
+test('buildModel: the heading ramp descends in lightness, level 1 strongest', () => {
+ const m = buildModel();
+ assert.equal(m.ramp.length, 4);
+ for (let i = 1; i < m.ramp.length; i++) {
+ assert.ok(oklchOf(m.ramp[i - 1]).L > oklchOf(m.ramp[i]).L,
+ `ramp step ${i} should be darker than step ${i - 1}`);
+ }
+});
+
+// --- seed(): syntax tier -------------------------------------------------
+
+test('seed: syntax builtin (bi) resolves to blue-grey', () => {
+ const m = buildModel(), s = seed(m).syntax;
+ assert.equal(s.bi.fg, m.swatch['blue-grey']);
+ assert.notEqual(s.bi.weight, 'bold');
+});
+
+test('seed: syntax definition (fnd) is gold and bold; call (fnc) is quieter gold, not bold', () => {
+ const m = buildModel(), s = seed(m).syntax;
+ assert.equal(s.fnd.fg, m.swatch.gold);
+ assert.equal(s.fnd.weight, 'bold');
+ assert.equal(s.fnc.fg, m.swatch['gold-quiet']);
+ assert.notEqual(s.fnc.weight, 'bold');
+});
+
+test('seed: syntax base + structure + keyword + literal + string land on their swatches', () => {
+ const m = buildModel(), s = seed(m).syntax;
+ assert.equal(s.var.fg, m.swatch.fg); // base identity
+ assert.equal(s.p.fg, m.swatch.fg);
+ assert.equal(s.op.fg, m.swatch['muted-fg']); // structure
+ assert.equal(s.punc.fg, m.swatch['muted-fg']);
+ assert.equal(s.kw.fg, m.swatch.blue); // control
+ assert.equal(s.kw.weight, 'bold');
+ assert.equal(s.num.fg, m.swatch.terracotta); // literal
+ assert.equal(s.con.fg, m.swatch.terracotta);
+ assert.equal(s.str.fg, m.swatch.sage); // string
+ assert.equal(s.ty.fg, m.swatch.regal); // type
+});
+
+test('seed: docstring and comment take italic; comment is the low-contrast lane', () => {
+ const m = buildModel(), s = seed(m).syntax;
+ assert.equal(s.doc.slant, 'italic');
+ assert.equal(s.cm.slant, 'italic');
+ assert.equal(s.cm.fg, m.swatch.comment);
+});
+
+test('seed: regexp and escape use the teal/bright-green lanes; bg is the ground', () => {
+ const m = buildModel(), s = seed(m).syntax;
+ assert.equal(s.re.fg, m.swatch.teal);
+ assert.equal(s.rxgb.fg, m.swatch.teal);
+ assert.equal(s.esc.fg, m.swatch['sage-bright']);
+ assert.equal(s.bg.fg, m.swatch.ground);
+});
+
+// --- seed(): UI tier -----------------------------------------------------
+
+test('seed: transient state faces are background-only (no foreground)', () => {
+ const m = buildModel(), u = seed(m).ui;
+ for (const f of ['region', 'hl-line', 'highlight', 'show-paren-match']) {
+ assert.ok(u[f].bg, `${f} should carry a background tint`);
+ assert.ok(!u[f].fg, `${f} should not set a foreground`);
+ }
+});
+
+test('seed: link is blue and underlined (redundant encoding)', () => {
+ const m = buildModel(), u = seed(m).ui;
+ assert.equal(u.link.fg, m.swatch.blue);
+ assert.ok(u.link.underline, 'link should be underlined');
+});
+
+test('seed: signal faces sit on the convention hues, with weight for redundancy', () => {
+ const m = buildModel(), u = seed(m).ui;
+ assert.equal(u.error.fg, m.swatch.red);
+ assert.equal(u.error.weight, 'bold');
+ assert.equal(u.warning.fg, m.swatch.amber);
+ assert.equal(u.success.fg, m.swatch.green);
+ assert.equal(u['isearch-fail'].fg, m.swatch.red);
+});
+
+test('seed: active chrome differs from idle chrome', () => {
+ const m = buildModel(), u = seed(m).ui;
+ assert.notEqual(u['mode-line'].fg, u['mode-line-inactive'].fg);
+ assert.notEqual(u['line-number-current-line'].fg, u['line-number'].fg);
+});
+
+// --- seed(): org package tier -------------------------------------------
+
+test('seed: packages carries only org-mode (non-org bespoke packages untouched)', () => {
+ const m = buildModel(), p = seed(m).packages;
+ assert.deepEqual(Object.keys(p), ['org-mode']);
+});
+
+test('seed: org headings ramp — level 1 strongest and bold, deeper levels quieter', () => {
+ const m = buildModel(), org = seed(m).packages['org-mode'];
+ assert.equal(org['org-level-1'].weight, 'bold');
+ assert.ok(oklchOf(org['org-level-1'].fg).L > oklchOf(org['org-level-2'].fg).L,
+ 'org-level-1 should be lighter (stronger) than org-level-2');
+});
+
+test('seed: org code-like faces reuse the syntax literal lane', () => {
+ const m = buildModel(), org = seed(m).packages['org-mode'];
+ assert.equal(org['org-code'].fg, m.swatch.terracotta);
+ assert.equal(org['org-code'].inherit, 'fixed-pitch');
+});
+
+test('seed: org link underlined; org-done receded with strikethrough; org-todo warm', () => {
+ const m = buildModel(), org = seed(m).packages['org-mode'];
+ assert.ok(org['org-link'].underline, 'org-link should be underlined');
+ assert.ok(org['org-done'].strike, 'org-done should be struck through');
+ assert.equal(org['org-todo'].fg, m.swatch.red);
+});
+
+// --- purity --------------------------------------------------------------
+
+test('seed: pure — two calls deep-equal and the model is not mutated', () => {
+ const m = buildModel();
+ const before = JSON.stringify(m);
+ const a = seed(m), b = seed(m);
+ assert.deepEqual(a, b);
+ assert.equal(JSON.stringify(m), before, 'seed() must not mutate the model');
+});
+
+test('ROLES: the table exposes the guide roles as data', () => {
+ assert.equal(ROLES.builtin.swatch, 'blue-grey');
+ assert.equal(ROLES.def.swatch, 'gold');
+ assert.equal(ROLES.def.weight, 'bold');
+ assert.equal(ROLES.state.channel, 'bg');
+ assert.equal(ROLES.sig_link.underline, true);
+});
+
+// --- inline-integrity ----------------------------------------------------
+// The page must carry seed-core.js's body (sans import/export) verbatim — the
+// same strip generate.py applies. Requires `python3 generate.py`.
+import { stripInlinedBody } from './inline-strip.mjs';
+
+test('inline-integrity: theme-studio.html contains the seed-core.js body verbatim', () => {
+ const body = stripInlinedBody(readFileSync(here + 'seed-core.js', 'utf8'));
+ const html = readFileSync(here + 'theme-studio.html', 'utf8');
+ assert.ok(html.includes(body), 'generated page is missing the seed-core.js body verbatim');
+});
diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py
index 0415d04f..ac225ce2 100644
--- a/scripts/theme-studio/test_generate.py
+++ b/scripts/theme-studio/test_generate.py
@@ -932,5 +932,20 @@ class BespokePreviewFaceCoverage(unittest.TestCase):
self.assertEqual(missing, [])
+class SyntaxCategoriesPersist(unittest.TestCase):
+ """Every editable syntax category (CATS) must have a persistable slot.
+
+ build_syntax keys SYNTAX off COLS (plus the specially-added bg), and both
+ export (dumps SYNTAX) and import (iterates CATS reading d.syntax[k]) rely on
+ that slot existing. A category in CATS but absent from COLS renders as an
+ editable row whose value never saves and never re-imports -- the drift that
+ dropped rxgb/rxgc/dmark/neg/warn from every saved theme."""
+
+ def test_every_cats_category_has_a_cols_slot(self):
+ cats = {c[0] for c in generate.CATS}
+ slots = set(generate.COLS) | {"bg"}
+ self.assertEqual(cats - slots, set())
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index cede7e1e..43c1af12 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -305,7 +305,7 @@
const SAMPLES={"Elisp": [[["cmd", ";;"], ["cm", " cache.el"]], [["cmd", ";;"], ["cm", " "], ["warn", "TODO"], ["cm", ": add an LRU eviction policy"]], [["punc", "("], ["kw", "require"], ["p", " "], ["con", "'cl-lib"], ["punc", ")"]], [], [["punc", "("], ["kw", "defvar"], ["p", " "], ["var", "cache--tbl"], ["p", " "], ["punc", "("], ["fnc", "make-hash-table"], ["p", " "], ["con", ":test"], ["p", " "], ["con", "'equal"], ["punc", "))"]], [["p", " "], ["doc", "\"Memo table.\")"]], [], [["punc", "("], ["kw", "defun"], ["p", " "], ["fnd", "cache-get"], ["p", " "], ["punc", "("], ["var", "key"], ["punc", ")"]], [["p", " "], ["doc", "\"Return cached value for KEY.\""]], [["p", " "], ["punc", "("], ["kw", "or"], ["p", " "], ["punc", "("], ["bi", "gethash"], ["p", " "], ["var", "key"], ["p", " "], ["var", "cache--tbl"], ["punc", ")"]], [["p", " "], ["punc", "("], ["kw", "let"], ["p", " "], ["punc", "(("], ["var", "v"], ["p", " "], ["punc", "("], ["fnc", "compute"], ["p", " "], ["var", "key"], ["p", " "], ["num", "42"], ["punc", "))) "]], [["p", " "], ["punc", "("], ["fnc", "puthash"], ["p", " "], ["var", "key"], ["p", " "], ["var", "v"], ["p", " "], ["var", "cache--tbl"], ["punc", ") "], ["var", "v"], ["punc", "))))"]], [], [["punc", "("], ["kw", "defun"], ["p", " "], ["fnd", "cache-clear"], ["p", " "], ["punc", "()"]], [["p", " "], ["doc", "\"Empty the memo table. See "], ["dmark", "\\\\[cache-get]"], ["doc", ".\""]], [["p", " "], ["punc", "("], ["kw", "interactive"], ["punc", ")"]], [["p", " "], ["punc", "("], ["fnc", "clrhash"], ["p", " "], ["var", "cache--tbl"], ["punc", ")"]], [["p", " "], ["punc", "("], ["fnc", "message"], ["p", " "], ["str", "\"cleared"], ["esc", "\\n"], ["str", "\""], ["punc", "))"]], [], [["punc", "("], ["kw", "defun"], ["p", " "], ["fnd", "cache-keys"], ["p", " "], ["punc", "()"]], [["p", " "], ["doc", "\"Return all keys.\""]], [["p", " "], ["punc", "("], ["kw", "let"], ["p", " "], ["punc", "(("], ["var", "acc"], ["p", " "], ["con", "nil"], ["punc", "))"]], [["p", " "], ["punc", "("], ["fnc", "maphash"], ["p", " "], ["punc", "("], ["kw", "lambda"], ["p", " "], ["punc", "("], ["var", "k"], ["p", " "], ["var", "_v"], ["punc", ")"], ["p", " "], ["punc", "("], ["fnc", "push"], ["p", " "], ["var", "k"], ["p", " "], ["var", "acc"], ["punc", "))"]], [["p", " "], ["var", "cache--tbl"], ["punc", ")"], ["p", " "], ["var", "acc"], ["punc", "))"]], [], [["punc", "("], ["kw", "defun"], ["p", " "], ["fnd", "cache--key-p"], ["p", " "], ["punc", "("], ["var", "s"], ["punc", ")"]], [["p", " "], ["punc", "("], ["fnc", "string-match"], ["p", " "], ["str", "\""], ["rxgb", "\\\\"], ["rxgc", "("], ["str", "key"], ["rxgb", "\\\\"], ["rxgc", ")"], ["str", "\""], ["p", " "], ["var", "s"], ["punc", "))"]], [], [["punc", "("], ["kw", "provide"], ["p", " "], ["con", "'cache"], ["punc", ")"]]], "Go": [[["cmd", "//"], ["cm", " queue.go"]], [["kw", "package"], ["p", " "], ["var", "main"]], [], [["kw", "import"], ["p", " "], ["str", "\"fmt\""]], [], [["kw", "const"], ["p", " "], ["con", "MaxItems"], ["p", " "], ["op", "="], ["p", " "], ["num", "100"]], [], [["kw", "type"], ["p", " "], ["ty", "Order"], ["p", " "], ["kw", "struct"], ["p", " "], ["punc", "{"]], [["p", " "], ["prop", "ID"], ["p", " "], ["ty", "int"]], [["p", " "], ["prop", "Name"], ["p", " "], ["ty", "string"]], [["punc", "}"]], [], [["kw", "func"], ["p", " "], ["punc", "("], ["var", "q"], ["p", " "], ["op", "*"], ["ty", "Queue"], ["punc", ")"], ["p", " "], ["fnd", "Push"], ["punc", "("], ["var", "o"], ["p", " "], ["op", "*"], ["ty", "Order"], ["punc", ")"], ["p", " "], ["ty", "error"], ["p", " "], ["punc", "{"]], [["p", " "], ["cmd", "//"], ["cm", " reject nil"]], [["p", " "], ["kw", "if"], ["p", " "], ["var", "o"], ["p", " "], ["op", "=="], ["p", " "], ["con", "nil"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "return"], ["p", " "], ["fnc", "fmt.Errorf"], ["punc", "("], ["str", "\"nil"], ["esc", "\\n"], ["str", "\""], ["punc", ")"]], [["p", " "], ["punc", "}"]], [["p", " "], ["var", "q"], ["op", "."], ["prop", "items"], ["p", " "], ["op", "="], ["p", " "], ["bi", "append"], ["punc", "("], ["var", "q"], ["op", "."], ["prop", "items"], ["punc", ","], ["p", " "], ["var", "o"], ["punc", ")"]], [["p", " "], ["kw", "return"], ["p", " "], ["con", "nil"]], [["punc", "}"]], [], [["kw", "func"], ["p", " "], ["fnd", "main"], ["punc", "()"], ["p", " "], ["punc", "{"]], [["p", " "], ["fnc", "fmt.Println"], ["punc", "("], ["op", "&"], ["ty", "Queue"], ["punc", "{}"], ["punc", ")"]], [["punc", "}"]]], "Python": [[["cmd", "#"], ["cm", " theme.py"]], [["kw", "from"], ["p", " "], ["var", "dataclasses"], ["p", " "], ["kw", "import"], ["p", " "], ["var", "dataclass"], ["punc", ","], ["p", " "], ["var", "field"]], [], [["con", "DEFAULT_PORT"], ["op", ":"], ["p", " "], ["ty", "int"], ["p", " "], ["op", "="], ["p", " "], ["num", "8080"]], [["con", "HEX"], ["p", " "], ["op", "="], ["p", " "], ["var", "re"], ["op", "."], ["fnc", "compile"], ["punc", "("], ["re", "r\"#[0-9a-f]{6}\""], ["punc", ")"]], [], [["dec", "@dataclass"]], [["kw", "class"], ["p", " "], ["ty", "Theme"], ["op", ":"]], [["p", " "], ["doc", "\"\"\"A color theme.\"\"\""]], [["p", " "], ["prop", "name"], ["op", ":"], ["p", " "], ["ty", "str"], ["p", " "], ["op", "="], ["p", " "], ["str", "\"dupre\""]], [["p", " "], ["prop", "colors"], ["op", ":"], ["p", " "], ["ty", "dict"], ["p", " "], ["op", "="], ["p", " "], ["fnc", "field"], ["punc", "("], ["prop", "default_factory"], ["op", "="], ["ty", "dict"], ["punc", ")"]], [], [["p", " "], ["kw", "def"], ["p", " "], ["fnd", "resolve"], ["punc", "("], ["var", "self"], ["punc", ","], ["p", " "], ["var", "key"], ["op", ":"], ["p", " "], ["ty", "str"], ["punc", ")"], ["p", " "], ["op", "->"], ["p", " "], ["ty", "str"], ["p", " "], ["op", "|"], ["p", " "], ["con", "None"], ["op", ":"]], [["p", " "], ["cmd", "#"], ["cm", " fallback to none"]], [["p", " "], ["var", "v"], ["p", " "], ["op", "="], ["p", " "], ["var", "self"], ["op", "."], ["prop", "colors"], ["op", "."], ["fnc", "get"], ["punc", "("], ["var", "key"], ["punc", ","], ["p", " "], ["str", "\""], ["esc", "\\t"], ["str", "none\""], ["punc", ")"]], [["p", " "], ["kw", "if"], ["p", " "], ["bi", "len"], ["punc", "("], ["var", "v"], ["punc", ")"], ["p", " "], ["op", "=="], ["p", " "], ["num", "0"], ["op", ":"], ["p", " "], ["kw", "return"], ["p", " "], ["con", "None"]], [["p", " "], ["kw", "return"], ["p", " "], ["var", "v"]], [], [["p", " "], ["dec", "@property"]], [["p", " "], ["kw", "def"], ["p", " "], ["fnd", "size"], ["punc", "("], ["var", "self"], ["punc", ")"], ["p", " "], ["op", "->"], ["p", " "], ["ty", "int"], ["op", ":"]], [["p", " "], ["kw", "return"], ["p", " "], ["bi", "len"], ["punc", "("], ["var", "self"], ["op", "."], ["prop", "colors"], ["punc", ")"]], [], [["var", "theme"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Theme"], ["punc", "("], ["str", "\"dupre\""], ["punc", ")"]], [["fnc", "print"], ["punc", "("], ["var", "theme"], ["op", "."], ["fnc", "resolve"], ["punc", "("], ["str", "\"bg\""], ["punc", "))"]]], "TypeScript": [[["cmd", "//"], ["cm", " orders.ts"]], [["kw", "import"], ["p", " "], ["punc", "{"], ["p", " "], ["ty", "Order"], ["p", " "], ["punc", "}"], ["p", " "], ["kw", "from"], ["p", " "], ["str", "\"./types\""]], [], [["kw", "export"], ["p", " "], ["kw", "interface"], ["p", " "], ["ty", "Queue"], ["p", " "], ["punc", "{"]], [["p", " "], ["prop", "max"], ["op", ":"], ["p", " "], ["ty", "number"], ["punc", ";"]], [["p", " "], ["prop", "items"], ["op", ":"], ["p", " "], ["ty", "Order"], ["punc", "[];"]], [["punc", "}"]], [], [["dec", "@Injectable"], ["punc", "()"]], [["kw", "export"], ["p", " "], ["kw", "class"], ["p", " "], ["ty", "OrderQueue"], ["p", " "], ["kw", "implements"], ["p", " "], ["ty", "Queue"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "private"], ["p", " "], ["prop", "re"], ["p", " "], ["op", "="], ["p", " "], ["re", "/^#[0-9a-f]{6}$/i"], ["punc", ";"]], [], [["p", " "], ["fnd", "push"], ["punc", "("], ["var", "o"], ["op", ":"], ["p", " "], ["ty", "Order"], ["punc", ")"], ["op", ":"], ["p", " "], ["ty", "boolean"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "("], ["var", "o"], ["p", " "], ["op", "==="], ["p", " "], ["con", "null"], ["punc", ")"], ["p", " "], ["kw", "return"], ["p", " "], ["con", "false"], ["punc", ";"]], [["p", " "], ["var", "console"], ["op", "."], ["fnc", "log"], ["punc", "("], ["str", "`id "], ["punc", "${"], ["var", "o"], ["op", "."], ["prop", "id"], ["punc", "}"], ["esc", "\\n"], ["str", "`"], ["punc", ");"]], [["p", " "], ["kw", "return"], ["p", " "], ["con", "true"], ["punc", ";"]], [["p", " "], ["punc", "}"]], [["punc", "}"]], [], [["kw", "const"], ["p", " "], ["con", "LIMIT"], ["op", ":"], ["p", " "], ["ty", "number"], ["p", " "], ["op", "="], ["p", " "], ["num", "50"], ["punc", ";"]], [["kw", "const"], ["p", " "], ["var", "q"], ["p", " "], ["op", "="], ["p", " "], ["kw", "new"], ["p", " "], ["ty", "OrderQueue"], ["punc", "()"], ["punc", ";"]], [["var", "q"], ["op", "."], ["fnd", "push"], ["punc", "("], ["punc", "{"], ["p", " "], ["prop", "id"], ["op", ":"], ["p", " "], ["num", "1"], ["p", " "], ["punc", "}"], ["p", " "], ["kw", "as"], ["p", " "], ["ty", "Order"], ["punc", ")"], ["punc", ";"]], [["var", "console"], ["op", "."], ["fnc", "log"], ["punc", "("], ["var", "q"], ["op", "."], ["prop", "max"], ["punc", ")"], ["punc", ";"]], [["kw", "const"], ["p", " "], ["var", "cap"], ["p", " "], ["op", "="], ["p", " "], ["var", "Math"], ["op", "."], ["bi", "max"], ["punc", "("], ["con", "LIMIT"], ["punc", ","], ["p", " "], ["num", "0"], ["punc", ")"], ["punc", ";"]]], "Java": [[["cmd", "/**"], ["doc", " A color theme. */"]], [["kw", "package"], ["p", " "], ["var", "com"], ["op", "."], ["var", "dupre"], ["punc", ";"]], [["kw", "import"], ["p", " "], ["var", "java"], ["op", "."], ["var", "util"], ["op", "."], ["var", "regex"], ["op", "."], ["ty", "Pattern"], ["punc", ";"]], [], [["dec", "@Deprecated"]], [["kw", "public"], ["p", " "], ["kw", "final"], ["p", " "], ["kw", "class"], ["p", " "], ["ty", "Theme"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "private"], ["p", " "], ["kw", "static"], ["p", " "], ["kw", "final"], ["p", " "], ["ty", "int"], ["p", " "], ["con", "MAX_PORT"], ["p", " "], ["op", "="], ["p", " "], ["num", "8080"], ["punc", ";"]], [["p", " "], ["kw", "private"], ["p", " "], ["kw", "final"], ["p", " "], ["ty", "String"], ["p", " "], ["prop", "name"], ["p", " "], ["op", "="], ["p", " "], ["str", "\"dupre\""], ["punc", ";"]], [["p", " "], ["kw", "private"], ["p", " "], ["kw", "static"], ["p", " "], ["kw", "final"], ["p", " "], ["ty", "Pattern"], ["p", " "], ["con", "HEX"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Pattern"], ["op", "."], ["fnc", "compile"], ["punc", "("], ["re", "\"#[0-9a-f]{6}\""], ["punc", ")"], ["punc", ";"]], [], [["p", " "], ["dec", "@Override"]], [["p", " "], ["kw", "public"], ["p", " "], ["ty", "String"], ["p", " "], ["fnd", "resolve"], ["punc", "("], ["ty", "String"], ["p", " "], ["var", "key"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["cmd", "//"], ["cm", " fall back to null"]], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "("], ["var", "key"], ["op", "."], ["fnc", "isEmpty"], ["punc", "()"], ["punc", ")"], ["p", " "], ["kw", "return"], ["p", " "], ["con", "null"], ["punc", ";"]], [["p", " "], ["kw", "return"], ["p", " "], ["var", "key"], ["op", "."], ["fnc", "strip"], ["punc", "("], ["punc", ")"], ["op", "+"], ["str", "\""], ["esc", "\\t"], ["str", "\""], ["punc", ";"]], [["p", " "], ["punc", "}"]], [], [["p", " "], ["kw", "public"], ["p", " "], ["kw", "static"], ["p", " "], ["ty", "void"], ["p", " "], ["fnd", "main"], ["punc", "("], ["ty", "String"], ["punc", "[]"], ["p", " "], ["var", "args"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["ty", "var"], ["p", " "], ["var", "t"], ["p", " "], ["op", "="], ["p", " "], ["kw", "new"], ["p", " "], ["ty", "Theme"], ["punc", "()"], ["punc", ";"]], [["p", " "], ["ty", "System"], ["op", "."], ["prop", "out"], ["op", "."], ["fnc", "println"], ["punc", "("], ["var", "t"], ["op", "."], ["fnc", "resolve"], ["punc", "("], ["str", "\"bg\""], ["punc", "))"], ["punc", ";"]], [["p", " "], ["punc", "}"]], [["punc", "}"]]], "C": [[["cmd", "/**"], ["doc", " Order queue. */"]], [["pp", "#include"], ["p", " "], ["str", "<stdio.h>"]], [["pp", "#include"], ["p", " "], ["str", "<stdlib.h>"]], [["pp", "#define"], ["p", " "], ["con", "MAX_PORT"], ["p", " "], ["num", "8080"]], [], [["kw", "typedef"], ["p", " "], ["kw", "struct"], ["p", " "], ["punc", "{"]], [["p", " "], ["ty", "int"], ["p", " "], ["prop", "id"], ["punc", ";"]], [["p", " "], ["kw", "const"], ["p", " "], ["ty", "char"], ["p", " "], ["op", "*"], ["prop", "name"], ["punc", ";"]], [["punc", "}"], ["p", " "], ["ty", "Order"], ["punc", ";"]], [], [["cmd", "//"], ["cm", " returns -1 on null input"]], [["ty", "int"], ["p", " "], ["fnd", "push"], ["punc", "("], ["ty", "Order"], ["p", " "], ["op", "*"], ["var", "o"], ["punc", ")"], ["p", " "], ["dec", "__attribute__"], ["punc", "(("], ["dec", "nonnull"], ["punc", "))"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "("], ["neg", "!"], ["var", "o"], ["punc", ")"], ["p", " "], ["kw", "return"], ["p", " "], ["num", "-1"], ["punc", ";"]], [["p", " "], ["fnc", "printf"], ["punc", "("], ["str", "\"id=%d"], ["esc", "\\n"], ["str", "\""], ["punc", ","], ["p", " "], ["var", "o"], ["op", "->"], ["prop", "id"], ["punc", ");"]], [["p", " "], ["kw", "return"], ["p", " "], ["num", "0"], ["punc", ";"]], [["punc", "}"]], [], [["ty", "int"], ["p", " "], ["fnd", "main"], ["punc", "("], ["ty", "void"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["ty", "Order"], ["p", " "], ["var", "o"], ["p", " "], ["op", "="], ["p", " "], ["punc", "{"], ["p", " "], ["op", "."], ["prop", "id"], ["p", " "], ["op", "="], ["p", " "], ["num", "1"], ["punc", ","], ["p", " "], ["op", "."], ["prop", "name"], ["p", " "], ["op", "="], ["p", " "], ["str", "\"dupre\""], ["p", " "], ["punc", "}"], ["punc", ";"]], [["p", " "], ["ty", "Order"], ["p", " "], ["op", "*"], ["var", "p2"], ["p", " "], ["op", "="], ["p", " "], ["bi", "malloc"], ["punc", "("], ["bi", "sizeof"], ["punc", "("], ["ty", "Order"], ["punc", "))"], ["punc", ";"]], [["p", " "], ["fnc", "push"], ["punc", "("], ["op", "&"], ["var", "o"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["bi", "free"], ["punc", "("], ["var", "p2"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["kw", "return"], ["p", " "], ["num", "0"], ["punc", ";"]], [["punc", "}"]]], "C++": [[["cmd", "/**"], ["doc", " A color theme. */"]], [["pp", "#include"], ["p", " "], ["str", "<string>"]], [["pp", "#include"], ["p", " "], ["str", "<regex>"]], [["pp", "#pragma"], ["p", " "], ["pp", "once"]], [], [["kw", "namespace"], ["p", " "], ["var", "dupre"], ["p", " "], ["punc", "{"]], [], [["kw", "template"], ["op", "<"], ["kw", "typename"], ["p", " "], ["ty", "T"], ["op", ">"], ["p", " "], ["kw", "class"], ["p", " "], ["ty", "Theme"], ["p", " "], ["punc", "{"]], [["kw", "public"], ["op", ":"]], [["p", " "], ["kw", "static"], ["p", " "], ["kw", "constexpr"], ["p", " "], ["ty", "int"], ["p", " "], ["con", "MAX"], ["p", " "], ["op", "="], ["p", " "], ["num", "0x20"], ["punc", ";"]], [["p", " "], ["ty", "std"], ["op", "::"], ["ty", "string"], ["p", " "], ["prop", "name_"], ["p", " "], ["op", "="], ["p", " "], ["str", "\"dupre\""], ["punc", ";"]], [], [["p", " "], ["dec", "[[nodiscard]]"], ["p", " "], ["ty", "T"], ["p", " "], ["fnd", "resolve"], ["punc", "("], ["kw", "const"], ["p", " "], ["ty", "std"], ["op", "::"], ["ty", "string"], ["op", "&"], ["p", " "], ["var", "key"], ["punc", ")"], ["p", " "], ["kw", "const"], ["p", " "], ["punc", "{"]], [["p", " "], ["cmd", "//"], ["cm", " validate against a hex pattern"]], [["p", " "], ["kw", "static"], ["p", " "], ["ty", "std"], ["op", "::"], ["ty", "regex"], ["p", " "], ["var", "re"], ["punc", "("], ["re", "R\"(#[0-9a-f]{6})\""], ["punc", ")"], ["punc", ";"]], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "("], ["var", "key"], ["op", "."], ["fnc", "empty"], ["punc", "()"], ["punc", ")"], ["p", " "], ["kw", "return"], ["p", " "], ["con", "nullptr"], ["punc", ";"]], [["p", " "], ["kw", "return"], ["p", " "], ["ty", "T"], ["punc", "{"], ["var", "key"], ["punc", "}"], ["punc", ";"]], [["p", " "], ["punc", "}"]], [["punc", "}"], ["punc", ";"]], [], [["ty", "int"], ["p", " "], ["fnd", "main"], ["punc", "()"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "auto"], ["p", " "], ["var", "t"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Theme"], ["op", "<"], ["ty", "int"], ["op", ">"], ["punc", "{}"], ["punc", ";"]], [["p", " "], ["bi", "static_cast"], ["op", "<"], ["ty", "int"], ["op", ">"], ["punc", "("], ["var", "t"], ["op", "."], ["prop", "name_"], ["op", "."], ["fnc", "size"], ["punc", "())"], ["punc", ";"]], [["p", " "], ["ty", "std"], ["op", "::"], ["fnc", "printf"], ["punc", "("], ["str", "\"%s"], ["esc", "\\n"], ["str", "\""], ["punc", ","], ["p", " "], ["var", "t"], ["op", "."], ["prop", "name_"], ["op", "."], ["fnc", "c_str"], ["punc", "())"], ["punc", ";"]], [["p", " "], ["kw", "return"], ["p", " "], ["num", "0"], ["punc", ";"]], [["punc", "}"]]], "Rust": [[["cmd", "//"], ["cm", " theme.rs"]], [["dec", "#![allow(dead_code)]"]], [["kw", "use"], ["p", " "], ["var", "std"], ["op", "::"], ["var", "fmt"], ["punc", ";"]], [], [["dec", "#[derive"], ["punc", "("], ["dec", "Debug"], ["punc", ","], ["p", " "], ["dec", "Clone"], ["punc", ")]"]], [["kw", "pub"], ["p", " "], ["kw", "trait"], ["p", " "], ["ty", "Theme"], ["op", "<"], ["var", "'a"], ["op", ">"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "const"], ["p", " "], ["con", "NAME"], ["op", ":"], ["p", " "], ["op", "&"], ["var", "'static"], ["p", " "], ["ty", "str"], ["punc", ";"]], [["p", " "], ["kw", "fn"], ["p", " "], ["fnd", "resolve"], ["punc", "("], ["op", "&"], ["var", "'a"], ["p", " "], ["var", "self"], ["punc", ","], ["p", " "], ["var", "key"], ["op", ":"], ["p", " "], ["op", "&"], ["var", "'a"], ["p", " "], ["ty", "str"], ["punc", ")"], ["p", " "], ["op", "->"], ["p", " "], ["ty", "Option"], ["op", "<"], ["op", "&"], ["var", "'a"], ["p", " "], ["ty", "str"], ["op", ">"], ["punc", ";"]], [["punc", "}"]], [], [["kw", "pub"], ["p", " "], ["kw", "struct"], ["p", " "], ["ty", "Palette"], ["op", "<"], ["var", "'a"], ["op", ">"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "pub"], ["p", " "], ["prop", "name"], ["op", ":"], ["p", " "], ["op", "&"], ["var", "'a"], ["p", " "], ["ty", "str"], ["punc", ","]], [["p", " "], ["kw", "pub"], ["p", " "], ["prop", "colors"], ["op", ":"], ["p", " "], ["ty", "Vec"], ["op", "<"], ["punc", "("], ["op", "&"], ["var", "'a"], ["p", " "], ["ty", "str"], ["punc", ","], ["p", " "], ["op", "&"], ["var", "'a"], ["p", " "], ["ty", "str"], ["punc", ")"], ["op", ">"], ["punc", ","]], [["punc", "}"]], [], [["kw", "impl"], ["op", "<"], ["var", "'a"], ["op", ">"], ["p", " "], ["ty", "Theme"], ["op", "<"], ["var", "'a"], ["op", ">"], ["p", " "], ["kw", "for"], ["p", " "], ["ty", "Palette"], ["op", "<"], ["var", "'a"], ["op", ">"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "const"], ["p", " "], ["con", "NAME"], ["op", ":"], ["p", " "], ["op", "&"], ["var", "'static"], ["p", " "], ["ty", "str"], ["p", " "], ["op", "="], ["p", " "], ["str", "\"dupre\""], ["punc", ";"]], [["p", " "], ["kw", "fn"], ["p", " "], ["fnd", "resolve"], ["punc", "("], ["op", "&"], ["var", "'a"], ["p", " "], ["var", "self"], ["punc", ","], ["p", " "], ["var", "key"], ["op", ":"], ["p", " "], ["op", "&"], ["var", "'a"], ["p", " "], ["ty", "str"], ["punc", ")"], ["p", " "], ["op", "->"], ["p", " "], ["ty", "Option"], ["op", "<"], ["op", "&"], ["var", "'a"], ["p", " "], ["ty", "str"], ["op", ">"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "if"], ["p", " "], ["var", "key"], ["op", "."], ["fnc", "is_empty"], ["punc", "()"], ["p", " "], ["punc", "{"], ["p", " "], ["kw", "return"], ["p", " "], ["con", "None"], ["punc", ";"], ["p", " "], ["punc", "}"]], [["p", " "], ["var", "self"], ["op", "."], ["prop", "colors"], ["op", "."], ["fnc", "iter"], ["punc", "()"], ["op", "."], ["fnc", "find"], ["punc", "("], ["op", "|"], ["punc", "("], ["var", "k"], ["punc", ","], ["p", " "], ["var", "_"], ["punc", ")"], ["op", "|"], ["p", " "], ["op", "*"], ["var", "k"], ["p", " "], ["op", "=="], ["p", " "], ["var", "key"], ["punc", ")"], ["op", "."], ["fnc", "map"], ["punc", "("], ["op", "|"], ["punc", "("], ["var", "_"], ["punc", ","], ["p", " "], ["var", "v"], ["punc", ")"], ["op", "|"], ["p", " "], ["op", "*"], ["var", "v"], ["punc", ")"]], [["p", " "], ["punc", "}"]], [["punc", "}"]], [], [["kw", "fn"], ["p", " "], ["fnd", "main"], ["punc", "()"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "let"], ["p", " "], ["var", "palette"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Palette"], ["p", " "], ["punc", "{"], ["p", " "], ["prop", "name"], ["op", ":"], ["p", " "], ["str", "\"dupre\""], ["punc", ","], ["p", " "], ["prop", "colors"], ["op", ":"], ["p", " "], ["bi", "vec!"], ["punc", "["], ["punc", "("], ["str", "\"bg\""], ["punc", ","], ["p", " "], ["str", "\"#0d0b0a\""], ["punc", ")"], ["punc", "]"], ["p", " "], ["punc", "}"], ["punc", ";"]], [["p", " "], ["bi", "println!"], ["punc", "("], ["str", "\"{:?}\""], ["punc", ","], ["p", " "], ["var", "palette"], ["op", "."], ["fnc", "resolve"], ["punc", "("], ["str", "\"bg\""], ["punc", "))"], ["punc", ";"]], [["punc", "}"]]], "Zig": [[["cmd", "//"], ["cm", " theme.zig"]], [["kw", "const"], ["p", " "], ["var", "std"], ["p", " "], ["op", "="], ["p", " "], ["bi", "@import"], ["punc", "("], ["str", "\"std\""], ["punc", ")"], ["punc", ";"]], [["kw", "const"], ["p", " "], ["ty", "Allocator"], ["p", " "], ["op", "="], ["p", " "], ["var", "std"], ["op", "."], ["var", "mem"], ["op", "."], ["ty", "Allocator"], ["punc", ";"]], [], [["kw", "pub"], ["p", " "], ["kw", "const"], ["p", " "], ["ty", "Theme"], ["p", " "], ["op", "="], ["p", " "], ["kw", "struct"], ["p", " "], ["punc", "{"]], [["p", " "], ["prop", "name"], ["op", ":"], ["p", " "], ["punc", "["], ["punc", "]"], ["kw", "const"], ["p", " "], ["ty", "u8"], ["punc", ","]], [["p", " "], ["prop", "colors"], ["op", ":"], ["p", " "], ["punc", "["], ["punc", "]"], ["kw", "const"], ["p", " "], ["ty", "Color"], ["punc", ","]], [], [["p", " "], ["kw", "pub"], ["p", " "], ["kw", "fn"], ["p", " "], ["fnd", "init"], ["punc", "("], ["var", "alloc"], ["op", ":"], ["p", " "], ["op", "*"], ["ty", "Allocator"], ["punc", ")"], ["p", " "], ["op", "!"], ["bi", "@This"], ["punc", "()"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "const"], ["p", " "], ["var", "colors"], ["p", " "], ["op", "="], ["p", " "], ["kw", "try"], ["p", " "], ["var", "alloc"], ["op", "."], ["fnc", "alloc"], ["punc", "("], ["ty", "Color"], ["punc", ","], ["p", " "], ["num", "2"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["var", "colors"], ["punc", "["], ["num", "0"], ["punc", "]"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Color"], ["punc", "{"], ["p", " "], ["prop", ".name"], ["p", " "], ["op", "="], ["p", " "], ["str", "\"bg\""], ["punc", ","], ["p", " "], ["prop", ".hex"], ["p", " "], ["op", "="], ["p", " "], ["str", "\"#0d0b0a\""], ["p", " "], ["punc", "}"], ["punc", ";"]], [["p", " "], ["kw", "return"], ["p", " "], ["bi", "@This"], ["punc", "()"], ["punc", "{"], ["p", " "], ["prop", ".name"], ["p", " "], ["op", "="], ["p", " "], ["str", "\"dupre\""], ["punc", ","], ["p", " "], ["prop", ".colors"], ["p", " "], ["op", "="], ["p", " "], ["var", "colors"], ["p", " "], ["punc", "}"], ["punc", ";"]], [["p", " "], ["punc", "}"]], [["punc", "}"], ["punc", ";"]], [], [["kw", "const"], ["p", " "], ["ty", "Color"], ["p", " "], ["op", "="], ["p", " "], ["kw", "struct"], ["p", " "], ["punc", "{"], ["p", " "], ["prop", "name"], ["op", ":"], ["p", " "], ["punc", "["], ["punc", "]"], ["kw", "const"], ["p", " "], ["ty", "u8"], ["punc", ","], ["p", " "], ["prop", "hex"], ["op", ":"], ["p", " "], ["punc", "["], ["punc", "]"], ["kw", "const"], ["p", " "], ["ty", "u8"], ["p", " "], ["punc", "}"], ["punc", ";"]], [], [["kw", "fn"], ["p", " "], ["fnd", "resolve"], ["punc", "("], ["var", "theme"], ["op", ":"], ["p", " "], ["ty", "Theme"], ["punc", ","], ["p", " "], ["kw", "comptime"], ["p", " "], ["var", "key"], ["op", ":"], ["p", " "], ["punc", "["], ["punc", ":"], ["num", "0"], ["punc", "]"], ["kw", "const"], ["p", " "], ["ty", "u8"], ["punc", ")"], ["p", " "], ["op", "!"], ["punc", "["], ["punc", "]"], ["kw", "const"], ["p", " "], ["ty", "u8"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "inline"], ["p", " "], ["kw", "for"], ["p", " "], ["punc", "("], ["var", "theme"], ["op", "."], ["prop", "colors"], ["punc", ")"], ["p", " "], ["op", "|"], ["var", "color"], ["op", "|"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "("], ["var", "std"], ["op", "."], ["var", "mem"], ["op", "."], ["fnc", "eql"], ["punc", "("], ["ty", "u8"], ["punc", ","], ["p", " "], ["var", "color"], ["op", "."], ["prop", "name"], ["punc", ","], ["p", " "], ["var", "key"], ["punc", ")"], ["punc", ")"], ["p", " "], ["kw", "return"], ["p", " "], ["var", "color"], ["op", "."], ["prop", "hex"], ["punc", ";"]], [["p", " "], ["punc", "}"]], [["p", " "], ["kw", "return"], ["p", " "], ["con", "error.MissingColor"], ["punc", ";"]], [["punc", "}"]], [], [["kw", "test"], ["p", " "], ["str", "\"resolve bg\""], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "var"], ["p", " "], ["var", "arena"], ["p", " "], ["op", "="], ["p", " "], ["var", "std"], ["op", "."], ["var", "heap"], ["op", "."], ["ty", "ArenaAllocator"], ["op", "."], ["fnc", "init"], ["punc", "("], ["var", "std"], ["op", "."], ["var", "testing"], ["op", "."], ["prop", "allocator"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["kw", "defer"], ["p", " "], ["var", "arena"], ["op", "."], ["fnc", "deinit"], ["punc", "()"], ["punc", ";"]], [["p", " "], ["kw", "try"], ["p", " "], ["var", "std"], ["op", "."], ["var", "testing"], ["op", "."], ["fnc", "expectEqualStrings"], ["punc", "("], ["str", "\"#0d0b0a\""], ["punc", ","], ["p", " "], ["kw", "try"], ["p", " "], ["fnc", "resolve"], ["punc", "("], ["kw", "try"], ["p", " "], ["ty", "Theme"], ["op", "."], ["fnc", "init"], ["punc", "("], ["op", "&"], ["var", "arena"], ["op", "."], ["prop", "allocator"], ["punc", ")"], ["punc", ","], ["p", " "], ["str", "\"bg\""], ["punc", "))"], ["punc", ";"]], [["punc", "}"]]], "Shell": [[["cmd", "#!"], ["cm", "/bin/bash"]], [["cmd", "#"], ["cm", " deploy.sh"]], [["bi", "set"], ["p", " "], ["op", "-"], ["var", "euo"], ["p", " "], ["var", "pipefail"]], [], [["var", "PORT"], ["op", "="], ["num", "8080"]], [["var", "NAME"], ["op", "="], ["str", "\"dupre\""]], [], [["fnd", "deploy"], ["punc", "()"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "local"], ["p", " "], ["var", "target"], ["op", "="], ["str", "\"$1\""]], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "[["], ["p", " "], ["op", "-z"], ["p", " "], ["str", "\"$target\""], ["p", " "], ["punc", "]]"], ["punc", ";"], ["p", " "], ["kw", "then"]], [["p", " "], ["bi", "echo"], ["p", " "], ["str", "\"no target\""]], [["p", " "], ["kw", "return"], ["p", " "], ["num", "1"]], [["p", " "], ["kw", "fi"]], [["p", " "], ["fnc", "rsync"], ["p", " "], ["op", "-az"], ["p", " "], ["str", "\"$NAME\""], ["p", " "], ["str", "\"$target\""]], [["punc", "}"]], [], [["fnd", "main"], ["punc", "()"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "for"], ["p", " "], ["var", "host"], ["p", " "], ["kw", "in"], ["p", " "], ["str", "\"$@\""], ["punc", ";"], ["p", " "], ["kw", "do"]], [["p", " "], ["fnc", "deploy"], ["p", " "], ["str", "\"$host\""], ["p", " "], ["op", "||"], ["p", " "], ["bi", "exit"], ["p", " "], ["num", "1"]], [["p", " "], ["kw", "done"]], [["p", " "], ["bi", "echo"], ["p", " "], ["op", "-e"], ["p", " "], ["str", "\"all done"], ["esc", "\\n"], ["str", "\""]], [["punc", "}"]], [], [["fnc", "main"], ["p", " "], ["str", "\"$@\""]]], "Racket": [[["pp", "#lang"], ["p", " "], ["pp", "racket"]], [], [["cmd", ";;"], ["p", " "], ["cm", "Compute Fibonacci numbers with memoization"]], [["punc", "("], ["kw", "require"], ["p", " "], ["var", "racket/list"], ["punc", ")"]], [], [["punc", "("], ["kw", "define"], ["p", " "], ["punc", "("], ["fnd", "fib"], ["p", " "], ["var", "n"], ["punc", ")"]], [["p", " "], ["punc", "("], ["kw", "cond"], ["p", " "]], [["p", " "], ["punc", "[("], ["bi", "<"], ["p", " "], ["var", "n"], ["p", " "], ["num", "2"], ["punc", ")"], ["p", " "], ["var", "n"], ["punc", "]"]], [["p", " "], ["punc", "["], ["con", "else"], ["p", " "]], [["p", " "], ["punc", "("], ["bi", "+"], ["p", " "], ["punc", "("], ["fnc", "fib"], ["p", " "], ["punc", "("], ["bi", "-"], ["p", " "], ["var", "n"], ["p", " "], ["num", "1"], ["punc", "))"], ["p", " "]], [["p", " "], ["punc", "("], ["fnc", "fib"], ["p", " "], ["punc", "("], ["bi", "-"], ["p", " "], ["var", "n"], ["p", " "], ["num", "2"], ["punc", ")))])]"]], [], [["cmd", ";;"], ["p", " "], ["cm", "A point struct with two fields"]], [["punc", "("], ["kw", "struct"], ["p", " "], ["ty", "point"], ["p", " "], ["punc", "("], ["prop", "x"], ["p", " "], ["prop", "y"], ["punc", ")"], ["p", " "], ["con", "#:transparent"], ["punc", ")"]], [], [["punc", "("], ["kw", "define"], ["p", " "], ["var", "origin"], ["p", " "], ["punc", "("], ["fnc", "point"], ["p", " "], ["num", "0"], ["p", " "], ["num", "0"], ["punc", "))"]], [], [["punc", "("], ["kw", "define"], ["p", " "], ["var", "nums"], ["p", " "], ["punc", "("], ["kw", "quote"], ["p", " "], ["punc", "("], ["num", "1"], ["p", " "], ["num", "2"], ["p", " "], ["num", "3"], ["p", " "], ["num", "4"], ["p", " "], ["num", "5"], ["punc", "))"]], [], [["punc", "("], ["kw", "define"], ["p", " "], ["var", "squared"], ["p", " "]], [["p", " "], ["punc", "("], ["bi", "map"], ["p", " "], ["punc", "("], ["kw", "lambda"], ["p", " "], ["punc", "("], ["var", "x"], ["punc", ")"], ["p", " "], ["punc", "("], ["bi", "*"], ["p", " "], ["var", "x"], ["p", " "], ["var", "x"], ["punc", "))"], ["p", " "], ["var", "nums"], ["punc", "))"]], [], [["punc", "("], ["bi", "printf"], ["p", " "], ["str", "\"squares: ~a\\n\""], ["p", " "], ["var", "squared"], ["punc", ")"]], [["punc", "("], ["bi", "displayln"], ["p", " "], ["punc", "("], ["fnc", "first"], ["p", " "], ["var", "squared"], ["punc", "))"]]], "Scheme": [[["cmd", ";;"], ["p", " "], ["cm", "Tail-recursive factorial in Scheme"]], [], [["punc", "("], ["kw", "define"], ["p", " "], ["punc", "("], ["fnd", "factorial"], ["p", " "], ["var", "n"], ["punc", ")"]], [["p", " "], ["punc", "("], ["kw", "let"], ["p", " "], ["fnd", "loop"], ["p", " "], ["punc", "(["], ["var", "acc"], ["p", " "], ["num", "1"], ["punc", "]"], ["p", " "], ["punc", "["], ["var", "i"], ["p", " "], ["var", "n"], ["punc", "])"]], [["p", " "], ["punc", "("], ["kw", "if"], ["p", " "], ["punc", "("], ["bi", "="], ["p", " "], ["var", "i"], ["p", " "], ["num", "0"], ["punc", ")"]], [["p", " "], ["var", "acc"], ["p", " "]], [["p", " "], ["punc", "("], ["fnc", "loop"], ["p", " "], ["punc", "("], ["bi", "*"], ["p", " "], ["var", "acc"], ["p", " "], ["var", "i"], ["punc", ")"], ["p", " "], ["punc", "("], ["bi", "-"], ["p", " "], ["var", "i"], ["p", " "], ["num", "1"], ["punc", "))))"]], [], [["cmd", ";;"], ["p", " "], ["cm", "Higher-order map over a quoted list"]], [["punc", "("], ["kw", "define"], ["p", " "], ["var", "primes"], ["p", " "], ["punc", "("], ["kw", "quote"], ["p", " "], ["punc", "("], ["num", "2"], ["p", " "], ["num", "3"], ["p", " "], ["num", "5"], ["p", " "], ["num", "7"], ["p", " "], ["num", "11"], ["punc", "))"]], [], [["punc", "("], ["kw", "define"], ["p", " "], ["punc", "("], ["fnd", "double"], ["p", " "], ["var", "x"], ["punc", ")"]], [["p", " "], ["punc", "("], ["bi", "*"], ["p", " "], ["var", "x"], ["p", " "], ["num", "2"], ["punc", ")"]], [], [["punc", "("], ["kw", "define"], ["p", " "], ["var", "doubled"], ["p", " "], ["punc", "("], ["bi", "map"], ["p", " "], ["var", "double"], ["p", " "], ["var", "primes"], ["punc", "))"]], [], [["cmd", ";;"], ["p", " "], ["cm", "Predicate using cond and recursion"]], [["punc", "("], ["kw", "define"], ["p", " "], ["punc", "("], ["fnd", "member?"], ["p", " "], ["var", "x"], ["p", " "], ["var", "lst"], ["punc", ")"]], [["p", " "], ["punc", "("], ["kw", "cond"], ["p", " "]], [["p", " "], ["punc", "[("], ["bi", "null?"], ["p", " "], ["var", "lst"], ["punc", ")"], ["p", " "], ["con", "#f"], ["punc", "]"]], [["p", " "], ["punc", "[("], ["bi", "equal?"], ["p", " "], ["punc", "("], ["bi", "car"], ["p", " "], ["var", "lst"], ["punc", ")"], ["p", " "], ["var", "x"], ["punc", ")"], ["p", " "], ["con", "#t"], ["punc", "]"]], [["p", " "], ["punc", "["], ["con", "else"], ["p", " "], ["punc", "("], ["fnc", "member?"], ["p", " "], ["var", "x"], ["p", " "], ["punc", "("], ["bi", "cdr"], ["p", " "], ["var", "lst"], ["punc", "))]"], ["punc", ")"]], [], [["punc", "("], ["bi", "display"], ["p", " "], ["punc", "("], ["fnc", "member?"], ["p", " "], ["num", "5"], ["p", " "], ["var", "primes"], ["punc", "))"]], [["punc", "("], ["bi", "newline"], ["punc", ")"]]], "Haskell": [[["cmd", "-- |"], ["cm", " Compute statistics over a stream of samples."]], [["pp", "{-# LANGUAGE ScopedTypeVariables #-}"]], [["kw", "module"], ["p", " "], ["ty", "Stats"], ["p", " "], ["punc", "("], ["var", "mean"], ["punc", ","], ["p", " "], ["var", "variance"], ["punc", ")"], ["p", " "], ["kw", "where"]], [], [["kw", "import"], ["p", " "], ["kw", "qualified"], ["p", " "], ["ty", "Data.List"], ["p", " "], ["kw", "as"], ["p", " "], ["ty", "L"]], [], [["cmd", "-- |"], ["cm", " A labelled measurement."]], [["kw", "data"], ["p", " "], ["ty", "Sample"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Sample"]], [["p", " "], ["p", " "], ["punc", "{"], ["p", " "], ["prop", "label"], ["p", " "], ["op", "::"], ["p", " "], ["ty", "String"]], [["p", " "], ["p", " "], ["punc", ","], ["p", " "], ["prop", "value"], ["p", " "], ["op", "::"], ["p", " "], ["ty", "Double"]], [["p", " "], ["p", " "], ["punc", "}"], ["p", " "], ["kw", "deriving"], ["p", " "], ["punc", "("], ["ty", "Show"], ["punc", ","], ["p", " "], ["ty", "Eq"], ["punc", ")"]], [], [["cmd", "-- |"], ["cm", " Arithmetic mean; returns 0 for an empty list."]], [["fnd", "mean"], ["p", " "], ["op", "::"], ["p", " "], ["punc", "["], ["ty", "Double"], ["punc", "]"], ["p", " "], ["op", "->"], ["p", " "], ["ty", "Double"]], [["fnd", "mean"], ["p", " "], ["con", "[]"], ["p", " "], ["op", "="], ["p", " "], ["num", "0"]], [["fnd", "mean"], ["p", " "], ["var", "xs"], ["p", " "], ["op", "="], ["p", " "], ["fnc", "sum"], ["p", " "], ["var", "xs"], ["p", " "], ["op", "/"], ["p", " "], ["fnc", "fromIntegral"], ["p", " "], ["punc", "("], ["fnc", "length"], ["p", " "], ["var", "xs"], ["punc", ")"]], [], [["fnd", "variance"], ["p", " "], ["op", "::"], ["p", " "], ["punc", "["], ["ty", "Double"], ["punc", "]"], ["p", " "], ["op", "->"], ["p", " "], ["ty", "Double"]], [["fnd", "variance"], ["p", " "], ["var", "xs"], ["p", " "], ["op", "="], ["p", " "], ["kw", "let"], ["p", " "], ["var", "m"], ["p", " "], ["op", "="], ["p", " "], ["fnc", "mean"], ["p", " "], ["var", "xs"]], [["p", " "], ["kw", "in"], ["p", " "], ["fnc", "mean"], ["p", " "], ["punc", "["], ["p", " "], ["punc", "("], ["var", "x"], ["p", " "], ["op", "-"], ["p", " "], ["var", "m"], ["punc", ")"], ["p", " "], ["op", "^"], ["p", " "], ["num", "2"], ["p", " "], ["op", "|"], ["p", " "], ["var", "x"], ["p", " "], ["op", "<-"], ["p", " "], ["var", "xs"], ["p", " "], ["punc", "]"]], [], [["cmd", "-- |"], ["cm", " Demo entry point."]], [["fnd", "main"], ["p", " "], ["op", "::"], ["p", " "], ["ty", "IO"], ["p", " "], ["punc", "("], ["punc", ")"]], [["fnd", "main"], ["p", " "], ["op", "="], ["p", " "], ["kw", "do"]], [["p", " "], ["kw", "let"], ["p", " "], ["var", "samples"], ["p", " "], ["op", "="], ["p", " "], ["punc", "["], ["num", "1.0"], ["punc", ","], ["p", " "], ["num", "2.5"], ["punc", ","], ["p", " "], ["num", "3.5"], ["punc", "]"]], [["p", " "], ["fnc", "putStrLn"], ["p", " "], ["punc", "("], ["str", "\"mean = \""], ["p", " "], ["op", "++"], ["p", " "], ["fnc", "show"], ["p", " "], ["punc", "("], ["fnc", "mean"], ["p", " "], ["var", "samples"], ["punc", "))"]]], "OCaml": [[["cmd", "(*"], ["cm", " Simple expression evaluator with variant types. "], ["cmd", "*)"]], [], [["kw", "type"], ["p", " "], ["ty", "expr"], ["p", " "], ["op", "="]], [["p", " "], ["p", " "], ["op", "|"], ["p", " "], ["ty", "Num"], ["p", " "], ["kw", "of"], ["p", " "], ["ty", "float"]], [["p", " "], ["p", " "], ["op", "|"], ["p", " "], ["ty", "Var"], ["p", " "], ["kw", "of"], ["p", " "], ["ty", "string"]], [["p", " "], ["p", " "], ["op", "|"], ["p", " "], ["ty", "Add"], ["p", " "], ["kw", "of"], ["p", " "], ["ty", "expr"], ["p", " "], ["op", "*"], ["p", " "], ["ty", "expr"]], [["p", " "], ["p", " "], ["op", "|"], ["p", " "], ["ty", "Mul"], ["p", " "], ["kw", "of"], ["p", " "], ["ty", "expr"], ["p", " "], ["op", "*"], ["p", " "], ["ty", "expr"]], [], [["cmd", "(**"], ["cm", " Evaluate [e] under environment [env]. "], ["cmd", "*)"]], [["kw", "let"], ["p", " "], ["kw", "rec"], ["p", " "], ["fnd", "eval"], ["p", " "], ["var", "env"], ["p", " "], ["var", "e"], ["p", " "], ["op", "="]], [["p", " "], ["kw", "match"], ["p", " "], ["var", "e"], ["p", " "], ["kw", "with"]], [["p", " "], ["op", "|"], ["p", " "], ["ty", "Num"], ["p", " "], ["var", "n"], ["p", " "], ["op", "->"], ["p", " "], ["var", "n"]], [["p", " "], ["op", "|"], ["p", " "], ["ty", "Var"], ["p", " "], ["var", "x"], ["p", " "], ["op", "->"], ["p", " "], ["ty", "List"], ["punc", "."], ["fnc", "assoc"], ["p", " "], ["var", "x"], ["p", " "], ["var", "env"]], [["p", " "], ["op", "|"], ["p", " "], ["ty", "Add"], ["p", " "], ["punc", "("], ["var", "a"], ["punc", ","], ["p", " "], ["var", "b"], ["punc", ")"], ["p", " "], ["op", "->"], ["p", " "], ["fnc", "eval"], ["p", " "], ["var", "env"], ["p", " "], ["var", "a"], ["p", " "], ["op", "+."], ["p", " "], ["fnc", "eval"], ["p", " "], ["var", "env"], ["p", " "], ["var", "b"]], [["p", " "], ["op", "|"], ["p", " "], ["ty", "Mul"], ["p", " "], ["punc", "("], ["var", "a"], ["punc", ","], ["p", " "], ["var", "b"], ["punc", ")"], ["p", " "], ["op", "->"], ["p", " "], ["fnc", "eval"], ["p", " "], ["var", "env"], ["p", " "], ["var", "a"], ["p", " "], ["op", "*."], ["p", " "], ["fnc", "eval"], ["p", " "], ["var", "env"], ["p", " "], ["var", "b"]], [], [["kw", "let"], ["p", " "], ["punc", "()"], ["p", " "], ["op", "="], ["p", " "], ["kw", "let"], ["p", " "], ["var", "env"], ["p", " "], ["op", "="], ["p", " "], ["punc", "["], ["p", " "], ["punc", "("], ["str", "\"x\""], ["punc", ","], ["p", " "], ["num", "3.0"], ["punc", ")"], ["p", " "], ["punc", "]"], ["p", " "], ["kw", "in"]], [["p", " "], ["kw", "let"], ["p", " "], ["var", "e"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Add"], ["p", " "], ["punc", "("], ["ty", "Var"], ["p", " "], ["str", "\"x\""], ["punc", ","], ["p", " "], ["ty", "Num"], ["p", " "], ["num", "4.0"], ["punc", ")"], ["p", " "], ["kw", "in"]], [["p", " "], ["ty", "Printf"], ["punc", "."], ["fnc", "printf"], ["p", " "], ["str", "\"result = %g\\n\""], ["p", " "], ["punc", "("], ["fnc", "eval"], ["p", " "], ["var", "env"], ["p", " "], ["var", "e"], ["punc", ")"]]], "Scala": [[["cmd", "//"], ["cm", " Geometry helpers for 2D shapes"]], [["kw", "package"], ["p", " "], ["var", "geometry"]], [], [["kw", "import"], ["p", " "], ["var", "scala"], ["op", "."], ["var", "math"], ["op", "."], ["fnc", "sqrt"]], [], [["dec", "@inline"], ["p", " "], ["kw", "final"], ["p", " "], ["kw", "case"], ["p", " "], ["kw", "class"], ["p", " "], ["ty", "Point"], ["punc", "("], ["kw", "val"], ["p", " "], ["prop", "x"], ["op", ":"], ["p", " "], ["ty", "Double"], ["punc", ","], ["p", " "], ["kw", "val"], ["p", " "], ["prop", "y"], ["op", ":"], ["p", " "], ["ty", "Double"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "def"], ["p", " "], ["fnd", "distanceTo"], ["punc", "("], ["var", "that"], ["op", ":"], ["p", " "], ["ty", "Point"], ["punc", ")"], ["op", ":"], ["p", " "], ["ty", "Double"], ["p", " "], ["op", "="], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "val"], ["p", " "], ["var", "dx"], ["p", " "], ["op", "="], ["p", " "], ["var", "x"], ["p", " "], ["op", "-"], ["p", " "], ["var", "that"], ["op", "."], ["prop", "x"]], [["p", " "], ["kw", "val"], ["p", " "], ["var", "dy"], ["p", " "], ["op", "="], ["p", " "], ["var", "y"], ["p", " "], ["op", "-"], ["p", " "], ["var", "that"], ["op", "."], ["prop", "y"]], [["p", " "], ["fnc", "sqrt"], ["punc", "("], ["var", "dx"], ["p", " "], ["op", "*"], ["p", " "], ["var", "dx"], ["p", " "], ["op", "+"], ["p", " "], ["var", "dy"], ["p", " "], ["op", "*"], ["p", " "], ["var", "dy"], ["punc", ")"]], [["p", " "], ["punc", "}"]], [["punc", "}"]], [], [["kw", "object"], ["p", " "], ["ty", "Geometry"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "val"], ["p", " "], ["var", "origin"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Point"], ["punc", "("], ["num", "0.0"], ["punc", ","], ["p", " "], ["num", "0.0"], ["punc", ")"]], [["p", " "], ["kw", "val"], ["p", " "], ["var", "pts"], ["p", " "], ["op", "="], ["p", " "], ["ty", "List"], ["punc", "("], ["ty", "Point"], ["punc", "("], ["num", "3.0"], ["punc", ","], ["p", " "], ["num", "4.0"], ["punc", "),"], ["p", " "], ["ty", "Point"], ["punc", "("], ["num", "1.0"], ["punc", ","], ["p", " "], ["num", "2.0"], ["punc", "))"]], [["p", " "], ["kw", "val"], ["p", " "], ["var", "dists"], ["p", " "], ["op", "="], ["p", " "], ["kw", "for"], ["p", " "], ["punc", "("], ["var", "p"], ["p", " "], ["op", "<-"], ["p", " "], ["var", "pts"], ["punc", ")"], ["p", " "], ["kw", "yield"], ["p", " "], ["var", "origin"], ["op", "."], ["fnc", "distanceTo"], ["punc", "("], ["var", "p"], ["punc", ")"]], [], [["p", " "], ["kw", "def"], ["p", " "], ["fnd", "main"], ["punc", "("], ["var", "args"], ["op", ":"], ["p", " "], ["ty", "Array"], ["punc", "["], ["ty", "String"], ["punc", "]"], ["punc", ")"], ["op", ":"], ["p", " "], ["ty", "Unit"], ["p", " "], ["op", "="], ["p", " "], ["punc", "{"]], [["p", " "], ["var", "dists"], ["op", "."], ["fnc", "foreach"], ["punc", "("], ["var", "d"], ["p", " "], ["op", "=>"], ["p", " "], ["fnc", "println"], ["punc", "("], ["str", "s\"dist = $d\""], ["punc", ")"], ["punc", ")"]], [["p", " "], ["kw", "val"], ["p", " "], ["var", "ok"], ["p", " "], ["op", "="], ["p", " "], ["var", "dists"], ["op", "."], ["fnc", "nonEmpty"], ["p", " "], ["op", "&&"], ["p", " "], ["con", "true"]], [["p", " "], ["punc", "}"]], [["punc", "}"]]], "Kotlin": [[["cmd", "//"], ["cm", " User repository with a simple cache"]], [["kw", "package"], ["p", " "], ["var", "com"], ["op", "."], ["var", "example"], ["op", "."], ["var", "data"]], [], [["kw", "import"], ["p", " "], ["var", "kotlin"], ["op", "."], ["var", "collections"], ["op", "."], ["var", "mutableMapOf"]], [], [["kw", "data"], ["p", " "], ["kw", "class"], ["p", " "], ["ty", "User"], ["punc", "("], ["kw", "val"], ["p", " "], ["prop", "id"], ["op", ":"], ["p", " "], ["ty", "Int"], ["punc", ","], ["p", " "], ["kw", "val"], ["p", " "], ["prop", "name"], ["op", ":"], ["p", " "], ["ty", "String"], ["punc", ")"]], [], [["kw", "class"], ["p", " "], ["ty", "UserRepo"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "private"], ["p", " "], ["kw", "val"], ["p", " "], ["var", "cache"], ["p", " "], ["op", "="], ["p", " "], ["bi", "mutableMapOf"], ["punc", "<"], ["ty", "Int"], ["punc", ","], ["p", " "], ["ty", "User"], ["punc", ">"], ["punc", "()"]], [], [["p", " "], ["dec", "@JvmStatic"], ["p", " "]], [["p", " "], ["kw", "fun"], ["p", " "], ["fnd", "findById"], ["punc", "("], ["var", "id"], ["op", ":"], ["p", " "], ["ty", "Int"], ["punc", ")"], ["op", ":"], ["p", " "], ["ty", "User"], ["op", "?"], ["p", " "], ["op", "="], ["p", " "], ["var", "cache"], ["punc", "["], ["var", "id"], ["punc", "]"]], [], [["p", " "], ["kw", "fun"], ["p", " "], ["fnd", "save"], ["punc", "("], ["var", "user"], ["op", ":"], ["p", " "], ["ty", "User"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["var", "cache"], ["punc", "["], ["var", "user"], ["op", "."], ["prop", "id"], ["punc", "]"], ["p", " "], ["op", "="], ["p", " "], ["var", "user"]], [["p", " "], ["bi", "println"], ["punc", "("], ["str", "\"saved "], ["esc", "\\n"], ["str", "\""], ["p", " "], ["op", "+"], ["p", " "], ["var", "user"], ["op", "."], ["prop", "name"], ["punc", ")"]], [["p", " "], ["punc", "}"]], [["punc", "}"]], [], [["kw", "fun"], ["p", " "], ["fnd", "main"], ["punc", "()"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "val"], ["p", " "], ["var", "repo"], ["p", " "], ["op", "="], ["p", " "], ["ty", "UserRepo"], ["punc", "()"]], [["p", " "], ["var", "repo"], ["op", "."], ["fnc", "save"], ["punc", "("], ["ty", "User"], ["punc", "("], ["num", "1"], ["punc", ","], ["p", " "], ["str", "\"Ada\""], ["punc", "))"]], [["p", " "], ["kw", "val"], ["p", " "], ["var", "found"], ["p", " "], ["op", "="], ["p", " "], ["var", "repo"], ["op", "."], ["fnc", "findById"], ["punc", "("], ["num", "1"], ["punc", ")"], ["p", " "], ["op", "?:"], ["p", " "], ["kw", "return"]], [["p", " "], ["bi", "println"], ["punc", "("], ["var", "found"], ["punc", ")"]], [["punc", "}"]]], "Swift": [[["cmd", "//"], ["cm", " Account model with balance guard"]], [["kw", "import"], ["p", " "], ["ty", "Foundation"]], [], [["dec", "@frozen"], ["p", " "]], [["kw", "struct"], ["p", " "], ["ty", "Account"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "let"], ["p", " "], ["prop", "id"], ["op", ":"], ["p", " "], ["ty", "Int"]], [["p", " "], ["kw", "var"], ["p", " "], ["prop", "balance"], ["op", ":"], ["p", " "], ["ty", "Double"], ["p", " "], ["op", "="], ["p", " "], ["num", "0.0"]], [], [["p", " "], ["kw", "func"], ["p", " "], ["fnd", "withdraw"], ["punc", "("], ["var", "amount"], ["op", ":"], ["p", " "], ["ty", "Double"], ["punc", ")"], ["p", " "], ["op", "->"], ["p", " "], ["ty", "Bool"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "guard"], ["p", " "], ["var", "amount"], ["p", " "], ["op", "<="], ["p", " "], ["prop", "balance"], ["p", " "], ["kw", "else"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "return"], ["p", " "], ["con", "false"]], [["p", " "], ["punc", "}"]], [["p", " "], ["prop", "balance"], ["p", " "], ["op", "-="], ["p", " "], ["var", "amount"]], [["p", " "], ["kw", "return"], ["p", " "], ["con", "true"]], [["p", " "], ["punc", "}"]], [["punc", "}"]], [], [["kw", "let"], ["p", " "], ["var", "acct"], ["p", " "], ["op", "="], ["p", " "], ["ty", "Account"], ["punc", "("], ["var", "id"], ["op", ":"], ["p", " "], ["num", "7"], ["punc", ","], ["p", " "], ["var", "balance"], ["op", ":"], ["p", " "], ["num", "100.0"], ["punc", ")"]], [["kw", "var"], ["p", " "], ["var", "copy"], ["p", " "], ["op", "="], ["p", " "], ["var", "acct"]], [["kw", "let"], ["p", " "], ["var", "ok"], ["p", " "], ["op", "="], ["p", " "], ["var", "copy"], ["op", "."], ["fnc", "withdraw"], ["punc", "("], ["var", "amount"], ["op", ":"], ["p", " "], ["num", "30.0"], ["punc", ")"]], [["bi", "print"], ["punc", "("], ["str", "\"acct ok=\""], ["punc", ","], ["p", " "], ["var", "ok"], ["punc", ")"]]], "Lua": [[["cmd", "--"], ["cm", " Account module: balances and transfers"]], [["kw", "local"], ["p", " "], ["ty", "Account"], ["op", "="], ["punc", "{}"]], [["ty", "Account"], ["punc", "."], ["prop", "__index"], ["op", "="], ["ty", "Account"]], [], [["kw", "local"], ["p", " "], ["var", "rates"], ["op", "="], ["p", " "], ["punc", "{"], ["str", "\"usd\""], ["op", "="], ["num", "1.0"], ["punc", ","], ["p", " "], ["str", "\"eur\""], ["op", "="], ["num", "0.92"], ["punc", "}"]], [], [["kw", "function"], ["p", " "], ["ty", "Account"], ["op", "."], ["fnd", "new"], ["punc", "("], ["var", "name"], ["punc", ","], ["p", " "], ["var", "balance"], ["punc", ")"]], [["p", " "], ["kw", "local"], ["p", " "], ["var", "self"], ["op", "="], ["p", " "], ["fnc", "setmetatable"], ["punc", "("], ["punc", "{}"], ["punc", ","], ["p", " "], ["ty", "Account"], ["punc", ")"]], [["p", " "], ["var", "self"], ["punc", "."], ["prop", "name"], ["op", "="], ["var", "name"]], [["p", " "], ["var", "self"], ["punc", "."], ["prop", "balance"], ["op", "="], ["p", " "], ["var", "balance"], ["p", " "], ["kw", "or"], ["p", " "], ["num", "0"]], [["p", " "], ["kw", "return"], ["p", " "], ["var", "self"]], [["kw", "end"]], [], [["kw", "function"], ["p", " "], ["ty", "Account"], ["op", ":"], ["fnd", "report"], ["punc", "()"]], [["p", " "], ["kw", "for"], ["p", " "], ["var", "code"], ["punc", ","], ["p", " "], ["var", "rate"], ["p", " "], ["kw", "in"], ["p", " "], ["bi", "pairs"], ["punc", "("], ["var", "rates"], ["punc", ")"], ["p", " "], ["kw", "do"]], [["p", " "], ["bi", "print"], ["punc", "("], ["var", "code"], ["punc", ","], ["p", " "], ["var", "self"], ["punc", "."], ["prop", "balance"], ["p", " "], ["op", "*"], ["p", " "], ["var", "rate"], ["punc", ")"]], [["p", " "], ["kw", "end"]], [["p", " "], ["kw", "if"], ["p", " "], ["var", "self"], ["punc", "."], ["prop", "balance"], ["p", " "], ["op", "=="], ["p", " "], ["num", "0"], ["p", " "], ["kw", "then"]], [["p", " "], ["kw", "return"], ["p", " "], ["con", "nil"]], [["p", " "], ["kw", "end"]], [["p", " "], ["kw", "return"], ["p", " "], ["con", "true"]], [["kw", "end"]]], "Ruby": [[["cmd", "#"], ["cm", " Inventory tracker with tagged items"]], [["kw", "class"], ["p", " "], ["ty", "Inventory"]], [["p", " "], ["kw", "def"], ["p", " "], ["fnd", "initialize"], ["punc", "("], ["var", "items"], ["p", " "], ["op", "="], ["p", " "], ["punc", "[]"], ["punc", ")"]], [["p", " "], ["var", "@items"], ["p", " "], ["op", "="], ["p", " "], ["var", "items"]], [["p", " "], ["var", "@tags"], ["p", " "], ["op", "="], ["p", " "], ["punc", "{"], ["prop", "sku:"], ["p", " "], ["con", "nil"], ["punc", "}"]], [["p", " "], ["kw", "end"]], [], [["p", " "], ["kw", "def"], ["p", " "], ["fnd", "add"], ["punc", "("], ["var", "name"], ["punc", ","], ["p", " "], ["var", "price"], ["punc", ")"]], [["p", " "], ["kw", "return"], ["p", " "], ["con", "false"], ["p", " "], ["kw", "unless"], ["p", " "], ["var", "name"], ["p", " "], ["op", "=~"], ["p", " "], ["re", "/\\A\\w+\\z/"]], [["p", " "], ["var", "@items"], ["p", " "], ["op", "<<"], ["p", " "], ["punc", "{"], ["p", " "], ["prop", "name:"], ["p", " "], ["var", "name"], ["punc", ","], ["p", " "], ["prop", "price:"], ["p", " "], ["var", "price"], ["p", " "], ["punc", "}"]], [["p", " "], ["kw", "end"]], [], [["p", " "], ["kw", "def"], ["p", " "], ["fnd", "total"], ["punc", "("], ["var", "tax"], ["p", " "], ["op", "="], ["p", " "], ["num", "0.08"], ["punc", ")"]], [["p", " "], ["var", "sum"], ["p", " "], ["op", "="], ["p", " "], ["num", "0"]], [["p", " "], ["var", "@items"], ["punc", "."], ["fnc", "each"], ["p", " "], ["kw", "do"], ["p", " "], ["punc", "|"], ["var", "item"], ["punc", "|"]], [["p", " "], ["var", "sum"], ["p", " "], ["op", "+="], ["p", " "], ["var", "item"], ["punc", "["], ["prop", ":price"], ["punc", "]"]], [["p", " "], ["kw", "end"]], [["p", " "], ["bi", "printf"], ["punc", "("], ["str", "\"total: %.2f\\n\""], ["punc", ","], ["p", " "], ["var", "sum"], ["p", " "], ["op", "*"], ["p", " "], ["punc", "("], ["num", "1"], ["p", " "], ["op", "+"], ["p", " "], ["var", "tax"], ["punc", "))"]], [["p", " "], ["kw", "end"]], [["kw", "end"]]], "Perl": [[["cmd", "#"], ["cm", "!/usr/bin/perl"]], [["kw", "use"], ["p", " "], ["pp", "strict"], ["punc", ";"]], [["kw", "use"], ["p", " "], ["pp", "warnings"], ["punc", ";"]], [], [["cmd", "#"], ["cm", " Parse a config line into a hash"]], [["kw", "sub"], ["p", " "], ["fnd", "parse_config"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "my"], ["p", " "], ["punc", "("], ["var", "$line"], ["punc", ")"], ["p", " "], ["op", "="], ["p", " "], ["var", "@_"], ["punc", ";"]], [["p", " "], ["kw", "my"], ["p", " "], ["var", "%conf"], ["p", " "], ["op", "="], ["p", " "], ["punc", "()"], ["punc", ";"]], [], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "("], ["var", "$line"], ["p", " "], ["op", "=~"], ["p", " "], ["re", "/^(\\w+)\\s*=\\s*(.+)$/"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["var", "$conf"], ["punc", "{"], ["var", "$1"], ["punc", "}"], ["p", " "], ["op", "="], ["p", " "], ["var", "$2"], ["punc", ";"]], [["p", " "], ["punc", "}"]], [], [["p", " "], ["kw", "return"], ["p", " "], ["op", "\\"], ["var", "%conf"], ["punc", ";"]], [["punc", "}"]], [], [["kw", "my"], ["p", " "], ["var", "$ref"], ["p", " "], ["op", "="], ["p", " "], ["fnc", "parse_config"], ["punc", "("], ["str", "\"host = localhost\""], ["punc", ")"], ["punc", ";"]], [["kw", "my"], ["p", " "], ["var", "@keys"], ["p", " "], ["op", "="], ["p", " "], ["bi", "keys"], ["p", " "], ["var", "%$ref"], ["punc", ";"]], [["bi", "print"], ["p", " "], ["var", "@keys"], ["punc", ";"]]], "R": [[["cmd", "#"], ["cm", " Summarize sales by region and fit a model"]], [["var", "library"], ["punc", "("], ["bi", "dplyr"], ["punc", ")"]], [], [["var", "sales"], ["p", " "], ["op", "<-"], ["p", " "], ["fnc", "read.csv"], ["punc", "("], ["str", "\"sales.csv\""], ["punc", ","], ["p", " "], ["prop", "stringsAsFactors"], ["p", " "], ["op", "="], ["p", " "], ["con", "FALSE"], ["punc", ")"]], [["var", "regions"], ["p", " "], ["op", "<-"], ["p", " "], ["bi", "c"], ["punc", "("], ["str", "\"North\""], ["punc", ","], ["p", " "], ["str", "\"South\""], ["punc", ","], ["p", " "], ["str", "\"East\""], ["punc", ","], ["p", " "], ["str", "\"West\""], ["punc", ")"]], [], [["cmd", "#"], ["cm", " Compute mean revenue per region"]], [["fnd", "summarize_region"], ["p", " "], ["op", "<-"], ["p", " "], ["kw", "function"], ["punc", "("], ["var", "df"], ["punc", ","], ["p", " "], ["var", "reg"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["var", "subset"], ["p", " "], ["op", "<-"], ["p", " "], ["var", "df"], ["punc", "["], ["var", "df"], ["op", "$"], ["prop", "region"], ["p", " "], ["op", "=="], ["p", " "], ["var", "reg"], ["punc", ","], ["p", " "], ["punc", "]"]], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "("], ["fnc", "nrow"], ["punc", "("], ["var", "subset"], ["punc", ")"], ["p", " "], ["op", "=="], ["p", " "], ["num", "0"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "return"], ["punc", "("], ["con", "NA"], ["punc", ")"]], [["p", " "], ["punc", "}"]], [["p", " "], ["fnc", "mean"], ["punc", "("], ["var", "subset"], ["op", "$"], ["prop", "revenue"], ["punc", ","], ["p", " "], ["prop", "na.rm"], ["p", " "], ["op", "="], ["p", " "], ["con", "TRUE"], ["punc", ")"]], [["punc", "}"]], [], [["var", "means"], ["p", " "], ["op", "<-"], ["p", " "], ["fnc", "sapply"], ["punc", "("], ["var", "regions"], ["punc", ","], ["p", " "], ["kw", "function"], ["punc", "("], ["var", "r"], ["punc", ")"], ["p", " "], ["fnc", "summarize_region"], ["punc", "("], ["var", "sales"], ["punc", ","], ["p", " "], ["var", "r"], ["punc", ")"], ["punc", ")"]], [["var", "sales"], ["p", " "], ["op", "%>%"], ["p", " "], ["fnc", "filter"], ["punc", "("], ["prop", "revenue"], ["p", " "], ["op", ">"], ["p", " "], ["num", "1000"], ["punc", ")"], ["p", " "], ["op", "%>%"], ["p", " "], ["fnc", "head"], ["punc", "("], ["num", "5"], ["punc", ")"]], [], [["var", "model"], ["p", " "], ["op", "<-"], ["p", " "], ["fnc", "lm"], ["punc", "("], ["prop", "revenue"], ["p", " "], ["op", "~"], ["p", " "], ["prop", "units"], ["p", " "], ["op", "+"], ["p", " "], ["prop", "region"], ["punc", ","], ["p", " "], ["prop", "data"], ["p", " "], ["op", "="], ["p", " "], ["var", "sales"], ["punc", ")"]], [["fnc", "print"], ["punc", "("], ["fnc", "summary"], ["punc", "("], ["var", "model"], ["punc", ")"], ["punc", ")"]]], "Erlang": [[["cmd", "%"], ["cm", " Bank account server with pattern matching"]], [["pp", "-module"], ["punc", "("], ["ty", "bank"], ["punc", ")."]], [["pp", "-export"], ["punc", "(["], ["fnc", "start"], ["op", "/"], ["num", "0"], ["punc", ","], ["p", " "], ["fnc", "balance"], ["op", "/"], ["num", "1"], ["punc", "])"], ["punc", "."]], [], [["fnd", "start"], ["punc", "()"], ["p", " "], ["op", "->"]], [["p", " "], ["fnc", "spawn"], ["punc", "("], ["kw", "fun"], ["punc", "()"], ["p", " "], ["op", "->"], ["p", " "], ["fnc", "loop"], ["punc", "("], ["num", "0"], ["punc", ")"], ["p", " "], ["kw", "end"], ["punc", ")."]], [], [["fnd", "loop"], ["punc", "("], ["var", "Balance"], ["punc", ")"], ["p", " "], ["op", "->"]], [["p", " "], ["kw", "receive"]], [["p", " "], ["punc", "{"], ["con", "deposit"], ["punc", ","], ["p", " "], ["var", "Amount"], ["punc", "}"], ["p", " "], ["kw", "when"], ["p", " "], ["var", "Amount"], ["p", " "], ["op", ">"], ["p", " "], ["num", "0"], ["p", " "], ["op", "->"]], [["p", " "], ["fnc", "loop"], ["punc", "("], ["var", "Balance"], ["p", " "], ["op", "+"], ["p", " "], ["var", "Amount"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["punc", "{"], ["con", "withdraw"], ["punc", ","], ["p", " "], ["var", "Amount"], ["punc", "}"], ["p", " "], ["op", "->"]], [["p", " "], ["fnc", "loop"], ["punc", "("], ["var", "Balance"], ["p", " "], ["op", "-"], ["p", " "], ["var", "Amount"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["punc", "{"], ["con", "balance"], ["punc", ","], ["p", " "], ["var", "From"], ["punc", "}"], ["p", " "], ["op", "->"]], [["p", " "], ["var", "From"], ["p", " "], ["op", "!"], ["p", " "], ["punc", "{"], ["con", "ok"], ["punc", ","], ["p", " "], ["var", "Balance"], ["punc", "}"], ["punc", ","], ["p", " "], ["fnc", "loop"], ["punc", "("], ["var", "Balance"], ["punc", ")"]], [["p", " "], ["kw", "end"], ["punc", "."]], [], [["fnd", "balance"], ["punc", "("], ["var", "Pid"], ["punc", ")"], ["p", " "], ["op", "->"]], [["p", " "], ["var", "Pid"], ["p", " "], ["op", "!"], ["p", " "], ["punc", "{"], ["con", "balance"], ["punc", ","], ["p", " "], ["fnc", "self"], ["punc", "()"], ["punc", "}"], ["punc", ","], ["p", " "], ["kw", "receive"], ["p", " "], ["punc", "{"], ["con", "ok"], ["punc", ","], ["p", " "], ["var", "B"], ["punc", "}"], ["p", " "], ["op", "->"], ["p", " "], ["var", "B"], ["p", " "], ["kw", "end"], ["punc", "."]]], "SQL": [[["cmd", "-- "], ["cm", "Monthly revenue by active customer"]], [["kw", "SELECT"], ["p", " "], ["prop", "c.id"], ["punc", ","], ["p", " "], ["prop", "c.name"], ["punc", ","]], [["p", " "], ["bi", "COUNT"], ["punc", "("], ["prop", "o.id"], ["punc", ")"], ["p", " "], ["kw", "AS"], ["p", " "], ["var", "order_count"], ["punc", ","]], [["p", " "], ["bi", "COALESCE"], ["punc", "("], ["bi", "SUM"], ["punc", "("], ["prop", "o.total"], ["punc", "),"], ["p", " "], ["num", "0"], ["punc", ")"], ["p", " "], ["kw", "AS"], ["p", " "], ["var", "revenue"]], [["kw", "FROM"], ["p", " "], ["prop", "customers"], ["p", " "], ["var", "c"]], [["kw", "JOIN"], ["p", " "], ["prop", "orders"], ["p", " "], ["var", "o"], ["p", " "], ["kw", "ON"], ["p", " "], ["prop", "o.customer_id"], ["p", " "], ["op", "="], ["p", " "], ["prop", "c.id"]], [["kw", "WHERE"], ["p", " "], ["prop", "c.active"], ["p", " "], ["op", "="], ["p", " "], ["con", "TRUE"]], [["p", " "], ["kw", "AND"], ["p", " "], ["prop", "o.created_at"], ["p", " "], ["op", ">="], ["p", " "], ["str", "'2024-01-01'"]], [["p", " "], ["kw", "AND"], ["p", " "], ["prop", "o.status"], ["p", " "], ["op", "<>"], ["p", " "], ["con", "NULL"]], [["kw", "GROUP BY"], ["p", " "], ["prop", "c.id"], ["punc", ","], ["p", " "], ["prop", "c.name"]], [["kw", "HAVING"], ["p", " "], ["bi", "COUNT"], ["punc", "("], ["prop", "o.id"], ["punc", ")"], ["p", " "], ["op", ">"], ["p", " "], ["num", "5"]], [["kw", "ORDER BY"], ["p", " "], ["var", "revenue"], ["p", " "], ["kw", "DESC"]], [["kw", "LIMIT"], ["p", " "], ["num", "25"], ["punc", ";"]], [], [["cmd", "-- "], ["cm", "Flag stale accounts for review"]], [["kw", "UPDATE"], ["p", " "], ["prop", "customers"]], [["kw", "SET"], ["p", " "], ["prop", "status"], ["p", " "], ["op", "="], ["p", " "], ["str", "'dormant'"]], [["kw", "WHERE"], ["p", " "], ["prop", "last_login"], ["p", " "], ["op", "<"], ["p", " "], ["bi", "CURRENT_DATE"], ["p", " "], ["op", "-"], ["p", " "], ["kw", "INTERVAL"], ["p", " "], ["str", "'90 days'"]], [["p", " "], ["kw", "AND"], ["p", " "], ["prop", "active"], ["p", " "], ["op", "="], ["p", " "], ["con", "FALSE"], ["punc", ";"]]], "PHP": [[["pp", "<?php"]], [["kw", "namespace"], ["p", " "], ["ty", "App\\Service"], ["punc", ";"]], [], [["cmd", "/** "], ["doc", "Computes invoice totals. */"]], [["dec", "#[Service]"]], [["kw", "class"], ["p", " "], ["ty", "InvoiceCalculator"]], [["punc", "{"]], [["p", " "], ["kw", "public"], ["p", " "], ["ty", "float"], ["p", " "], ["var", "$taxRate"], ["p", " "], ["op", "="], ["p", " "], ["num", "0.0825"], ["punc", ";"]], [], [["p", " "], ["kw", "public"], ["p", " "], ["kw", "function"], ["p", " "], ["fnd", "total"], ["punc", "("], ["kw", "array"], ["p", " "], ["var", "$items"], ["punc", ")"], ["op", ":"], ["p", " "], ["ty", "float"]], [["p", " "], ["punc", "{"]], [["p", " "], ["cmd", "// "], ["cm", "sum each line item"]], [["p", " "], ["var", "$prices"], ["p", " "], ["op", "="], ["p", " "], ["bi", "array_map"], ["punc", "("], ["kw", "fn"], ["punc", "("], ["var", "$i"], ["punc", ")"], ["p", " "], ["op", "=>"], ["p", " "], ["var", "$i"], ["op", "["], ["str", "'price'"], ["op", "]"], ["punc", ","], ["p", " "], ["var", "$items"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["var", "$subtotal"], ["p", " "], ["op", "="], ["p", " "], ["bi", "array_sum"], ["punc", "("], ["var", "$prices"], ["punc", ")"], ["punc", ";"]], [], [["p", " "], ["kw", "if"], ["p", " "], ["punc", "("], ["var", "$subtotal"], ["p", " "], ["op", "==="], ["p", " "], ["num", "0"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["kw", "return"], ["p", " "], ["num", "0.0"], ["punc", ";"]], [["p", " "], ["punc", "}"]], [], [["p", " "], ["var", "$total"], ["p", " "], ["op", "="], ["p", " "], ["var", "$subtotal"], ["p", " "], ["op", "*"], ["p", " "], ["punc", "("], ["num", "1"], ["p", " "], ["op", "+"], ["p", " "], ["var", "$this"], ["op", "->"], ["prop", "taxRate"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["fnc", "printf"], ["punc", "("], ["str", "\"Total: %.2f\\n\""], ["punc", ","], ["p", " "], ["var", "$total"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["kw", "return"], ["p", " "], ["var", "$total"], ["punc", ";"]], [["p", " "], ["punc", "}"]], [["punc", "}"]]], "Ada": [[["cmd", "-- "], ["cm", "Compute factorial and print the result"]], [["pp", "with"], ["p", " "], ["var", "Ada.Text_IO"], ["punc", ";"]], [["pp", "use"], ["p", " "], ["var", "Ada.Text_IO"], ["punc", ";"]], [], [["kw", "procedure"], ["p", " "], ["fnd", "Factorial_Demo"], ["p", " "], ["kw", "is"]], [["p", " "], ["var", "N"], ["p", " "], ["punc", ":"], ["p", " "], ["ty", "Integer"], ["p", " "], ["op", ":="], ["p", " "], ["num", "5"], ["punc", ";"]], [["p", " "], ["var", "Result"], ["p", " "], ["punc", ":"], ["p", " "], ["ty", "Integer"], ["p", " "], ["op", ":="], ["p", " "], ["num", "1"], ["punc", ";"]], [["kw", "begin"]], [["p", " "], ["kw", "for"], ["p", " "], ["var", "I"], ["p", " "], ["kw", "in"], ["p", " "], ["num", "1"], ["p", " "], ["op", ".."], ["p", " "], ["var", "N"], ["p", " "], ["kw", "loop"]], [["p", " "], ["var", "Result"], ["p", " "], ["op", ":="], ["p", " "], ["var", "Result"], ["p", " "], ["op", "*"], ["p", " "], ["var", "I"], ["punc", ";"]], [["p", " "], ["kw", "end"], ["p", " "], ["kw", "loop"], ["punc", ";"]], [], [["p", " "], ["kw", "if"], ["p", " "], ["var", "Result"], ["p", " "], ["op", ">"], ["p", " "], ["num", "0"], ["p", " "], ["kw", "then"]], [["p", " "], ["bi", "Put_Line"], ["punc", "("], ["str", "\"Factorial = \""], ["p", " "], ["op", "&"], ["p", " "], ["var", "Integer"], ["punc", "'"], ["var", "Image"], ["punc", "("], ["var", "Result"], ["punc", "))"], ["punc", ";"]], [["p", " "], ["kw", "end"], ["p", " "], ["kw", "if"], ["punc", ";"]], [["kw", "end"], ["p", " "], ["fnd", "Factorial_Demo"], ["punc", ";"]]], "Fortran": [[["cmd", "! "], ["cm", "Sum the elements of an array"]], [["kw", "program"], ["p", " "], ["fnd", "array_sum"]], [["p", " "], ["kw", "implicit none"]], [["p", " "], ["ty", "integer"], ["p", " "], ["punc", "::"], ["p", " "], ["var", "i"], ["punc", ","], ["p", " "], ["var", "n"]], [["p", " "], ["ty", "real"], ["punc", "("], ["var", "kind"], ["op", "="], ["num", "8"], ["punc", ")"], ["p", " "], ["punc", "::"], ["p", " "], ["var", "total"]], [["p", " "], ["ty", "real"], ["punc", "("], ["var", "kind"], ["op", "="], ["num", "8"], ["punc", ")"], ["punc", ","], ["p", " "], ["kw", "dimension"], ["punc", "("], ["num", "5"], ["punc", ")"], ["p", " "], ["punc", "::"], ["p", " "], ["var", "a"]], [], [["p", " "], ["var", "n"], ["p", " "], ["op", "="], ["p", " "], ["num", "5"]], [["p", " "], ["var", "total"], ["p", " "], ["op", "="], ["p", " "], ["num", "0.0"]], [["p", " "], ["var", "a"], ["p", " "], ["op", "="], ["p", " "], ["punc", "["], ["num", "1.0"], ["punc", ","], ["p", " "], ["num", "2.0"], ["punc", ","], ["p", " "], ["num", "3.0"], ["punc", ","], ["p", " "], ["num", "4.0"], ["punc", ","], ["p", " "], ["num", "5.0"], ["punc", "]"]], [], [["p", " "], ["kw", "do"], ["p", " "], ["var", "i"], ["p", " "], ["op", "="], ["p", " "], ["num", "1"], ["punc", ","], ["p", " "], ["var", "n"]], [["p", " "], ["var", "total"], ["p", " "], ["op", "="], ["p", " "], ["var", "total"], ["p", " "], ["op", "+"], ["p", " "], ["var", "a"], ["punc", "("], ["var", "i"], ["punc", ")"]], [["p", " "], ["kw", "end do"]], [], [["p", " "], ["bi", "print"], ["p", " "], ["op", "*"], ["punc", ","], ["p", " "], ["str", "\"Sum = \""], ["punc", ","], ["p", " "], ["var", "total"]], [["kw", "end program"], ["p", " "], ["fnd", "array_sum"]]], "MATLAB": [[["cmd", "% "], ["cm", "Normalize a vector and report its length"]], [["kw", "function"], ["p", " "], ["var", "out"], ["p", " "], ["op", "="], ["p", " "], ["fnd", "normalize_vec"], ["punc", "("], ["var", "v"], ["punc", ")"]], [["p", " "], ["var", "n"], ["p", " "], ["op", "="], ["p", " "], ["bi", "length"], ["punc", "("], ["var", "v"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["var", "acc"], ["p", " "], ["op", "="], ["p", " "], ["num", "0"], ["punc", ";"]], [], [["p", " "], ["kw", "for"], ["p", " "], ["var", "i"], ["p", " "], ["op", "="], ["p", " "], ["num", "1"], ["op", ":"], ["var", "n"]], [["p", " "], ["var", "acc"], ["p", " "], ["op", "="], ["p", " "], ["var", "acc"], ["p", " "], ["op", "+"], ["p", " "], ["var", "v"], ["punc", "("], ["var", "i"], ["punc", ")"], ["op", "^"], ["num", "2"], ["punc", ";"]], [["p", " "], ["kw", "end"]], [], [["p", " "], ["var", "mag"], ["p", " "], ["op", "="], ["p", " "], ["bi", "sqrt"], ["punc", "("], ["var", "acc"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["kw", "if"], ["p", " "], ["var", "mag"], ["p", " "], ["op", "=="], ["p", " "], ["num", "0"]], [["p", " "], ["var", "out"], ["p", " "], ["op", "="], ["p", " "], ["bi", "zeros"], ["punc", "("], ["bi", "size"], ["punc", "("], ["var", "v"], ["punc", ")"], ["punc", ")"], ["punc", ";"]], [["p", " "], ["kw", "else"]], [["p", " "], ["var", "out"], ["p", " "], ["op", "="], ["p", " "], ["var", "v"], ["p", " "], ["op", "/"], ["p", " "], ["var", "mag"], ["punc", ";"]], [["p", " "], ["kw", "end"]], [], [["p", " "], ["bi", "disp"], ["punc", "("], ["str", "\"vector length:\""], ["punc", ")"], ["punc", ";"]], [["p", " "], ["bi", "disp"], ["punc", "("], ["var", "n"], ["punc", ")"], ["punc", ";"]], [["kw", "end"]]], "Assembly": [[["cmd", ";"], ["cm", " print a greeting via the write syscall"]], [["pp", "section"], ["p", " "], ["pp", ".data"]], [["p", " "], ["var", "msg"], ["p", " "], ["pp", "db"], ["p", " "], ["str", "\"Hello, world!\""], ["punc", ","], ["p", " "], ["num", "0xA"]], [["p", " "], ["con", "msglen"], ["p", " "], ["pp", "equ"], ["p", " "], ["var", "$"], ["p", " "], ["op", "-"], ["p", " "], ["var", "msg"]], [], [["pp", "section"], ["p", " "], ["pp", ".text"]], [["p", " "], ["bi", "global"], ["p", " "], ["fnc", "_start"]], [], [["fnd", "_start"], ["punc", ":"]], [["p", " "], ["kw", "mov"], ["p", " "], ["var", "rax"], ["punc", ","], ["p", " "], ["num", "1"], ["p", " "], ["cmd", ";"], ["cm", " sys_write"]], [["p", " "], ["kw", "mov"], ["p", " "], ["var", "rdi"], ["punc", ","], ["p", " "], ["num", "1"], ["p", " "], ["cmd", ";"], ["cm", " stdout"]], [["p", " "], ["kw", "lea"], ["p", " "], ["var", "rsi"], ["punc", ","], ["p", " "], ["punc", "["], ["var", "rel"], ["p", " "], ["var", "msg"], ["punc", "]"]], [["p", " "], ["kw", "mov"], ["p", " "], ["var", "rdx"], ["punc", ","], ["p", " "], ["con", "msglen"]], [["p", " "], ["kw", "syscall"]], [], [["p", " "], ["kw", "mov"], ["p", " "], ["var", "rax"], ["punc", ","], ["p", " "], ["num", "60"], ["p", " "], ["cmd", ";"], ["cm", " sys_exit"]], [["p", " "], ["kw", "xor"], ["p", " "], ["var", "rdi"], ["punc", ","], ["p", " "], ["var", "rdi"], ["p", " "], ["cmd", ";"], ["cm", " status 0"]], [["p", " "], ["kw", "syscall"]]], "HTML": [[["cm", "<!doctype html>"]], [["cm", "<!-- storefront page -->"]], [["punc", "<"], ["fnd", "html"], ["p", " "], ["var", "lang"], ["op", "="], ["str", "\"en\""], ["punc", ">"]], [["punc", "<"], ["fnd", "head"], ["punc", ">"]], [["p", " "], ["punc", "<"], ["fnd", "style"], ["punc", ">"]], [["p", " "], ["pp", "@media"], ["p", " "], ["punc", "("], ["prop", "min-width"], ["op", ":"], ["p", " "], ["num", "40rem"], ["punc", ")"], ["p", " "], ["punc", "{"]], [["p", " "], ["ty", "body"], ["p", " "], ["fnc", ".card"], ["kw", ":hover"], ["p", " "], ["punc", "{"], ["p", " "], ["prop", "color"], ["op", ":"], ["p", " "], ["con", "#67809c"], ["punc", ";"], ["p", " "], ["punc", "}}"]], [["p", " "], ["punc", "</"], ["fnd", "style"], ["punc", ">"]], [["punc", "</"], ["fnd", "head"], ["punc", ">"]], [["punc", "<"], ["fnd", "body"], ["p", " "], ["var", "data-theme"], ["op", "="], ["str", "\"dupre\""], ["punc", ">"]], [["p", " "], ["punc", "<"], ["fnd", "h1"], ["punc", ">"], ["p", "Fish "], ["esc", "&amp;"], ["p", " Chips"], ["punc", "</"], ["fnd", "h1"], ["punc", ">"]], [["p", " "], ["punc", "<"], ["fnd", "script"], ["punc", ">"]], [["p", " "], ["kw", "const"], ["p", " "], ["var", "total"], ["p", " "], ["op", "="], ["p", " "], ["fnc", "round"], ["punc", "("], ["var", "qty"], ["p", " "], ["op", "*"], ["p", " "], ["var", "price"], ["punc", ");"], ["p", " "], ["cmd", "//"], ["cm", " inline js"]], [["p", " "], ["punc", "</"], ["fnd", "script"], ["punc", ">"]], [["punc", "</"], ["fnd", "body"], ["punc", ">"]], [["punc", "</"], ["fnd", "html"], ["punc", ">"]]]}, CATS=[["bg", "bg (ground)", "Aa Bb 123"], ["p", "fg", "other / whitespace"], ["kw", "keyword", "class def if return"], ["bi", "builtin", "len echo printf"], ["pp", "preprocessor", "#include #define"], ["fnd", "function \u00b7 def", "resolve push"], ["fnc", "function \u00b7 call", "printf rsync get"], ["dec", "decorator \u2192 type", "@dataclass"], ["ty", "type / class", "int str Order Queue"], ["prop", "property / field", "id name items"], ["con", "constant", "None nil NULL true"], ["num", "number", "8080 100 -1"], ["str", "string", "\"dupre\" \"fmt\""], ["esc", "escape", "\\n \\t"], ["re", "regexp", "/^#[0-9a-f]+/"], ["rxgb", "regexp backslash", "\\\\("], ["rxgc", "regexp construct", "\\( \\)"], ["doc", "docstring", "\"\"\"...\"\"\""], ["dmark", "doc mark", "\\[cmd] `sym'"], ["cm", "comment", "# reject nil"], ["cmd", "comment delim", "# // ;;"], ["var", "variable / use", "value key self"], ["op", "operator", ": = -> =="], ["neg", "negation char", "!"], ["punc", "punctuation", "{ } ( ) ;"], ["warn", "warn", "TODO FIXME"]], UI_FACES=[["cursor", "cursor", "Aa|"], ["region", "region (selection)", "selected text"], ["hl-line", "hl-line (current line)", "current line"], ["highlight", "highlight", "hover"], ["mode-line", "mode-line", "status active"], ["mode-line-highlight", "mode-line-highlight (hover)", "git:main"], ["mode-line-inactive", "mode-line-inactive", "status idle"], ["header-line", "header-line", "breadcrumb / doc info"], ["tab-bar", "tab-bar", "tabs"], ["tab-line", "tab-line", "buffer tabs"], ["fringe", "fringe", "| |"], ["line-number", "line-number", " 42"], ["line-number-current-line", "line-number-current-line", "> 42"], ["minibuffer-prompt", "minibuffer-prompt", "M-x "], ["isearch", "isearch (match)", "match"], ["lazy-highlight", "lazy-highlight", "other match"], ["isearch-fail", "isearch-fail", "no match"], ["show-paren-match", "show-paren-match", "( )"], ["show-paren-mismatch", "show-paren-mismatch", ") ("], ["link", "link", "https://"], ["error", "error", "error!"], ["warning", "warning", "warning"], ["success", "success", "ok"], ["vertical-border", "vertical-border", "|"]], APPS={"org-mode": {"label": "org-mode", "preview": "org", "hover": "", "faces": [["org-document-title", "document title", {}], ["org-document-info", "document info", {}], ["org-document-info-keyword", "document info keyword", {}], ["org-level-1", "level 1", {}], ["org-level-2", "level 2", {}], ["org-level-3", "level 3", {}], ["org-level-4", "level 4", {}], ["org-level-5", "level 5", {}], ["org-level-6", "level 6", {}], ["org-level-7", "level 7", {}], ["org-level-8", "level 8", {}], ["org-headline-todo", "headline todo", {}], ["org-headline-done", "headline done", {}], ["org-todo", "todo", {}], ["org-done", "done", {}], ["org-priority", "priority", {}], ["org-tag", "tag", {}], ["org-tag-group", "tag group", {}], ["org-special-keyword", "special keyword", {}], ["org-drawer", "drawer", {}], ["org-property-value", "property value", {}], ["org-checkbox", "checkbox", {}], ["org-checkbox-statistics-todo", "checkbox statistics todo", {}], ["org-checkbox-statistics-done", "checkbox statistics done", {}], ["org-warning", "warning", {}], ["org-link", "link", {}], ["org-footnote", "footnote", {}], ["org-date", "date", {}], ["org-sexp-date", "sexp date", {}], ["org-date-selected", "date selected", {}], ["org-target", "target", {}], ["org-macro", "macro", {}], ["org-cite", "cite", {}], ["org-cite-key", "cite key", {}], ["org-block", "block", {}], ["org-block-begin-line", "block begin line", {}], ["org-block-end-line", "block end line", {}], ["org-code", "code", {}], ["org-verbatim", "verbatim", {}], ["org-inline-src-block", "inline src block", {}], ["org-quote", "quote", {}], ["org-verse", "verse", {}], ["org-latex-and-related", "latex and related", {}], ["org-table", "table", {}], ["org-table-header", "table header", {}], ["org-table-row", "table row", {}], ["org-formula", "formula", {}], ["org-column", "column", {}], ["org-column-title", "column title", {}], ["org-list-dt", "list dt", {}], ["org-meta-line", "meta line", {}], ["org-ellipsis", "ellipsis", {}], ["org-hide", "hide", {}], ["org-indent", "indent", {}], ["org-archived", "archived", {}], ["org-default", "default", {}], ["org-dispatcher-highlight", "dispatcher highlight", {}], ["org-agenda-structure", "agenda structure", {}], ["org-agenda-structure-secondary", "agenda structure secondary", {}], ["org-agenda-structure-filter", "agenda structure filter", {}], ["org-agenda-date", "agenda date", {}], ["org-agenda-date-today", "agenda date today", {}], ["org-agenda-date-weekend", "agenda date weekend", {}], ["org-agenda-date-weekend-today", "agenda date weekend today", {}], ["org-agenda-current-time", "agenda current time", {}], ["org-agenda-done", "agenda done", {}], ["org-agenda-dimmed-todo-face", "agenda dimmed todo", {}], ["org-agenda-calendar-event", "agenda calendar event", {}], ["org-agenda-calendar-sexp", "agenda calendar sexp", {}], ["org-agenda-calendar-daterange", "agenda calendar daterange", {}], ["org-agenda-diary", "agenda diary", {}], ["org-agenda-clocking", "agenda clocking", {}], ["org-agenda-column-dateline", "agenda column dateline", {}], ["org-agenda-restriction-lock", "agenda restriction lock", {}], ["org-agenda-filter-category", "agenda filter category", {}], ["org-agenda-filter-effort", "agenda filter effort", {}], ["org-agenda-filter-regexp", "agenda filter regexp", {}], ["org-agenda-filter-tags", "agenda filter tags", {}], ["org-scheduled", "scheduled", {}], ["org-scheduled-today", "scheduled today", {}], ["org-scheduled-previously", "scheduled previously", {}], ["org-upcoming-deadline", "upcoming deadline", {}], ["org-upcoming-distant-deadline", "upcoming distant deadline", {}], ["org-imminent-deadline", "imminent deadline", {}], ["org-time-grid", "time grid", {}], ["org-clock-overlay", "clock overlay", {}], ["org-mode-line-clock", "mode line clock", {}], ["org-mode-line-clock-overrun", "mode line clock overrun", {}]]}, "magit": {"label": "magit", "preview": "magit", "hover": "", "faces": [["magit-section-heading", "section heading", {"fg": "#8b6508", "weight": "bold", "extend": true}], ["magit-section-secondary-heading", "section secondary heading", {"weight": "bold", "extend": true}], ["magit-section-heading-selection", "section heading selection", {"fg": "#8b4c39", "extend": true}], ["magit-section-highlight", "section highlight", {"bg": "#f2f2f2", "extend": true}], ["magit-section-child-count", "section child count", {}], ["magit-diff-added", "diff added", {"fg": "#22aa22", "bg": "#ddffdd", "extend": true}], ["magit-diff-added-highlight", "diff added highlight", {"fg": "#22aa22", "bg": "#cceecc", "extend": true}], ["magit-diff-removed", "diff removed", {"fg": "#aa2222", "bg": "#ffdddd", "extend": true}], ["magit-diff-removed-highlight", "diff removed highlight", {"fg": "#aa2222", "bg": "#eecccc", "extend": true}], ["magit-diff-context", "diff context", {"fg": "#7f7f7f", "extend": true}], ["magit-diff-context-highlight", "diff context highlight", {"fg": "#7f7f7f", "bg": "#f2f2f2", "extend": true}], ["magit-diff-file-heading", "diff file heading", {"weight": "bold", "extend": true}], ["magit-diff-file-heading-highlight", "diff file heading highlight", {"extend": true, "inherit": "magit-section-highlight"}], ["magit-diff-file-heading-selection", "diff file heading selection", {"fg": "#8b4c39", "extend": true, "inherit": "magit-diff-file-heading-highlight"}], ["magit-diff-hunk-heading", "diff hunk heading", {"fg": "#333333", "bg": "#e5e5e5", "extend": true}], ["magit-diff-hunk-heading-highlight", "diff hunk heading highlight", {"fg": "#333333", "bg": "#cccccc", "extend": true}], ["magit-diff-hunk-heading-selection", "diff hunk heading selection", {"fg": "#8b4c39", "extend": true, "inherit": "magit-diff-hunk-heading-highlight"}], ["magit-diff-hunk-region", "diff hunk region", {"inherit": "bold"}], ["magit-diff-lines-heading", "diff lines heading", {"bg": "#cd8162", "extend": true, "inherit": "magit-diff-hunk-heading-highlight"}], ["magit-diff-lines-boundary", "diff lines boundary", {"extend": true, "inherit": "magit-diff-lines-heading"}], ["magit-diff-base", "diff base", {"fg": "#aaaa11", "bg": "#ffffcc", "extend": true}], ["magit-diff-base-highlight", "diff base highlight", {"fg": "#aaaa11", "bg": "#eeeebb", "extend": true}], ["magit-diff-our", "diff our", {"inherit": "magit-diff-removed"}], ["magit-diff-our-highlight", "diff our highlight", {"inherit": "magit-diff-removed-highlight"}], ["magit-diff-their", "diff their", {"inherit": "magit-diff-added"}], ["magit-diff-their-highlight", "diff their highlight", {"inherit": "magit-diff-added-highlight"}], ["magit-diff-conflict-heading", "diff conflict heading", {"inherit": "magit-diff-hunk-heading"}], ["magit-diff-conflict-heading-highlight", "diff conflict heading highlight", {"inherit": "magit-diff-hunk-heading-highlight"}], ["magit-diff-revision-summary", "diff revision summary", {"inherit": "magit-diff-hunk-heading"}], ["magit-diff-revision-summary-highlight", "diff revision summary highlight", {"inherit": "magit-diff-hunk-heading-highlight"}], ["magit-diff-whitespace-warning", "diff whitespace warning", {"inherit": "trailing-whitespace"}], ["magit-diffstat-added", "diffstat added", {"fg": "#22aa22"}], ["magit-diffstat-removed", "diffstat removed", {"fg": "#aa2222"}], ["magit-branch-current", "branch current", {"inherit": "magit-branch-local"}], ["magit-branch-local", "branch local", {"fg": "#4a708b"}], ["magit-branch-remote", "branch remote", {"fg": "#6e8b3d"}], ["magit-branch-remote-head", "branch remote head", {"inherit": "magit-branch-remote"}], ["magit-branch-upstream", "branch upstream", {"slant": "italic"}], ["magit-branch-warning", "branch warning", {"inherit": "warning"}], ["magit-head", "head", {"inherit": "magit-branch-local"}], ["magit-tag", "tag", {"fg": "#8b6914"}], ["magit-hash", "hash", {"fg": "#999999"}], ["magit-filename", "filename", {}], ["magit-dimmed", "dimmed", {"fg": "#7f7f7f"}], ["magit-keyword", "keyword", {"inherit": "font-lock-string-face"}], ["magit-keyword-squash", "keyword squash", {"inherit": "font-lock-warning-face"}], ["magit-refname", "refname", {"fg": "#4d4d4d"}], ["magit-refname-stash", "refname stash", {"inherit": "magit-refname"}], ["magit-refname-wip", "refname wip", {"inherit": "magit-refname"}], ["magit-refname-pullreq", "refname pullreq", {"inherit": "magit-refname"}], ["magit-log-author", "log author", {"fg": "#b22222"}], ["magit-log-date", "log date", {"fg": "#4d4d4d"}], ["magit-log-graph", "log graph", {"fg": "#4d4d4d"}], ["magit-header-line", "header line", {"inherit": "magit-section-heading"}], ["magit-header-line-key", "header line key", {"inherit": "font-lock-builtin-face"}], ["magit-header-line-log-select", "header line log select", {"inherit": "bold"}], ["magit-process-ok", "process ok", {"fg": "#00ff00", "inherit": "magit-section-heading"}], ["magit-process-ng", "process ng", {"fg": "#ff0000", "inherit": "magit-section-heading"}], ["magit-mode-line-process", "mode line process", {"inherit": "mode-line-emphasis"}], ["magit-mode-line-process-error", "mode line process error", {"inherit": "error"}], ["magit-bisect-good", "bisect good", {"fg": "#556b2f"}], ["magit-bisect-bad", "bisect bad", {"fg": "#8b3a3a"}], ["magit-bisect-skip", "bisect skip", {"fg": "#b8860b"}], ["magit-blame-heading", "blame heading", {"extend": true, "inherit": "magit-blame-highlight"}], ["magit-blame-highlight", "blame highlight", {"fg": "#000000", "bg": "#cccccc", "extend": true}], ["magit-blame-hash", "blame hash", {}], ["magit-blame-name", "blame name", {}], ["magit-blame-date", "blame date", {}], ["magit-blame-summary", "blame summary", {}], ["magit-blame-dimmed", "blame dimmed", {"inherit": "magit-dimmed"}], ["magit-blame-margin", "blame margin", {"inherit": "magit-blame-highlight"}], ["magit-cherry-equivalent", "cherry equivalent", {"fg": "#ff00ff"}], ["magit-cherry-unmatched", "cherry unmatched", {"fg": "#00ffff"}], ["magit-signature-good", "signature good", {"fg": "#00ff00"}], ["magit-signature-bad", "signature bad", {"fg": "#ff0000", "weight": "bold"}], ["magit-signature-untrusted", "signature untrusted", {"fg": "#66cdaa"}], ["magit-signature-expired", "signature expired", {"fg": "#ffa500"}], ["magit-signature-expired-key", "signature expired key", {"inherit": "magit-signature-expired"}], ["magit-signature-revoked", "signature revoked", {"fg": "#d02090"}], ["magit-signature-error", "signature error", {"fg": "#add8e6"}], ["magit-reflog-commit", "reflog commit", {"fg": "#00ff00"}], ["magit-reflog-amend", "reflog amend", {"fg": "#ff00ff"}], ["magit-reflog-merge", "reflog merge", {"fg": "#00ff00"}], ["magit-reflog-checkout", "reflog checkout", {"fg": "#0000ff"}], ["magit-reflog-reset", "reflog reset", {"fg": "#ff0000"}], ["magit-reflog-rebase", "reflog rebase", {"fg": "#ff00ff"}], ["magit-reflog-cherry-pick", "reflog cherry pick", {"fg": "#00ff00"}], ["magit-reflog-remote", "reflog remote", {"fg": "#00ffff"}], ["magit-reflog-other", "reflog other", {"fg": "#00ffff"}], ["magit-sequence-pick", "sequence pick", {"inherit": "default"}], ["magit-sequence-stop", "sequence stop", {"fg": "#6e8b3d"}], ["magit-sequence-part", "sequence part", {"fg": "#8b6914"}], ["magit-sequence-head", "sequence head", {"fg": "#4a708b"}], ["magit-sequence-drop", "sequence drop", {"fg": "#cd5c5c"}], ["magit-sequence-done", "sequence done", {"inherit": "magit-hash"}], ["magit-sequence-onto", "sequence onto", {"inherit": "magit-sequence-done"}], ["magit-sequence-exec", "sequence exec", {"inherit": "magit-hash"}], ["magit-left-margin", "left margin", {"inherit": "default"}], ["git-commit-comment-action", "git commit comment action", {"inherit": "bold"}], ["git-commit-comment-branch-local", "git commit comment branch local", {"inherit": "magit-branch-local"}], ["git-commit-comment-branch-remote", "git commit comment branch remote", {"inherit": "magit-branch-remote"}], ["git-commit-comment-detached", "git commit comment detached", {"inherit": "git-commit-comment-branch-local"}], ["git-commit-comment-file", "git commit comment file", {"inherit": "git-commit-trailer-value"}], ["git-commit-comment-heading", "git commit comment heading", {"inherit": "git-commit-trailer-token"}], ["git-commit-keyword", "git commit keyword", {"inherit": "font-lock-string-face"}], ["git-commit-nonempty-second-line", "git commit nonempty second line", {"inherit": "font-lock-warning-face"}], ["git-commit-overlong-summary", "git commit overlong summary", {"inherit": "font-lock-warning-face"}], ["git-commit-summary", "git commit summary", {"inherit": "font-lock-type-face"}], ["git-commit-trailer-token", "git commit trailer token", {"inherit": "font-lock-keyword-face"}], ["git-commit-trailer-value", "git commit trailer value", {"inherit": "font-lock-string-face"}]]}, "elfeed": {"label": "elfeed", "preview": "elfeed", "hover": "", "faces": [["elfeed-search-date-face", "search date", {"fg": "#aaa"}], ["elfeed-search-title-face", "search title", {"fg": "#000"}], ["elfeed-search-unread-title-face", "search unread title", {"weight": "bold"}], ["elfeed-search-feed-face", "search feed", {"fg": "#aa0"}], ["elfeed-search-tag-face", "search tag", {"fg": "#070"}], ["elfeed-search-unread-count-face", "search unread count", {"fg": "#000"}], ["elfeed-search-filter-face", "search filter", {"inherit": "mode-line-buffer-id"}], ["elfeed-search-last-update-face", "search last update", {}], ["elfeed-log-date-face", "log date", {"inherit": "font-lock-type-face"}], ["elfeed-log-error-level-face", "log error level", {"fg": "#ff0000"}], ["elfeed-log-warn-level-face", "log warn level", {"fg": "#daa520"}], ["elfeed-log-info-level-face", "log info level", {"fg": "#00bfff"}], ["elfeed-log-debug-level-face", "log debug level", {"fg": "#ee00ee"}]]}, "mu4e": {"label": "mu4e", "preview": "mu4e", "hover": "", "faces": [["mu4e-title-face", "title", {}], ["mu4e-context-face", "context", {}], ["mu4e-modeline-face", "modeline", {}], ["mu4e-ok-face", "ok", {}], ["mu4e-warning-face", "warning", {}], ["mu4e-header-title-face", "header title", {}], ["mu4e-header-key-face", "header key", {}], ["mu4e-header-value-face", "header value", {}], ["mu4e-header-face", "header", {}], ["mu4e-header-highlight-face", "header highlight", {}], ["mu4e-header-marks-face", "header marks", {}], ["mu4e-unread-face", "unread", {}], ["mu4e-flagged-face", "flagged", {}], ["mu4e-replied-face", "replied", {}], ["mu4e-forwarded-face", "forwarded", {}], ["mu4e-draft-face", "draft", {}], ["mu4e-trashed-face", "trashed", {}], ["mu4e-related-face", "related", {}], ["mu4e-contact-face", "contact", {}], ["mu4e-special-header-value-face", "special header value", {}], ["mu4e-url-number-face", "url number", {}], ["mu4e-link-face", "link", {}], ["mu4e-footer-face", "footer", {}], ["mu4e-region-code", "region code", {}], ["mu4e-system-face", "system", {}], ["mu4e-highlight-face", "highlight", {}], ["mu4e-compose-separator-face", "compose separator", {}]]}, "gnus": {"label": "gnus", "preview": "gnus", "hover": "Article-view faces, reused by mu4e's article view.", "faces": [["gnus-header-name", "header name", {}], ["gnus-header-from", "header from", {}], ["gnus-header-subject", "header subject", {}], ["gnus-header-content", "header content", {}], ["gnus-header-newsgroups", "header newsgroups", {}], ["gnus-cite-1", "cite 1", {}], ["gnus-cite-2", "cite 2", {}], ["gnus-cite-3", "cite 3", {}], ["gnus-cite-4", "cite 4", {}], ["gnus-cite-5", "cite 5", {}], ["gnus-cite-6", "cite 6", {}], ["gnus-cite-7", "cite 7", {}], ["gnus-cite-8", "cite 8", {}], ["gnus-cite-9", "cite 9", {}], ["gnus-cite-10", "cite 10", {}], ["gnus-cite-11", "cite 11", {}], ["gnus-cite-attribution", "cite attribution", {}], ["gnus-signature", "signature", {}], ["gnus-button", "button", {}], ["gnus-emphasis-bold", "emphasis bold", {}], ["gnus-emphasis-italic", "emphasis italic", {}], ["gnus-emphasis-underline", "emphasis underline", {}], ["gnus-emphasis-strikethru", "emphasis strikethru", {}], ["gnus-emphasis-highlight-words", "emphasis highlight words", {}]]}, "org-faces": {"label": "org-faces", "preview": "orgfaces", "hover": "", "faces": [["org-faces-todo", "todo", {}], ["org-faces-project", "project", {}], ["org-faces-doing", "doing", {}], ["org-faces-waiting", "waiting", {}], ["org-faces-verify", "verify", {}], ["org-faces-stalled", "stalled", {}], ["org-faces-delegated", "delegated", {}], ["org-faces-failed", "failed", {}], ["org-faces-done", "done", {}], ["org-faces-cancelled", "cancelled", {}], ["org-faces-priority-a", "priority a", {}], ["org-faces-priority-b", "priority b", {}], ["org-faces-priority-c", "priority c", {}], ["org-faces-priority-d", "priority d", {}], ["org-faces-todo-dim", "todo dim", {}], ["org-faces-project-dim", "project dim", {}], ["org-faces-doing-dim", "doing dim", {}], ["org-faces-waiting-dim", "waiting dim", {}], ["org-faces-verify-dim", "verify dim", {}], ["org-faces-stalled-dim", "stalled dim", {}], ["org-faces-delegated-dim", "delegated dim", {}], ["org-faces-failed-dim", "failed dim", {}], ["org-faces-done-dim", "done dim", {}], ["org-faces-cancelled-dim", "cancelled dim", {}], ["org-faces-priority-a-dim", "priority a dim", {}], ["org-faces-priority-b-dim", "priority b dim", {}], ["org-faces-priority-c-dim", "priority c dim", {}], ["org-faces-priority-d-dim", "priority d dim", {}]]}, "ansi-color": {"label": "ansi-color", "preview": "ansicolor", "hover": "The 16 ANSI palette faces. Reused by vterm, eshell, compilation, and eat, whose own color faces inherit these.", "faces": [["ansi-color-black", "black", {}], ["ansi-color-red", "red", {}], ["ansi-color-green", "green", {}], ["ansi-color-yellow", "yellow", {}], ["ansi-color-blue", "blue", {}], ["ansi-color-magenta", "magenta", {}], ["ansi-color-cyan", "cyan", {}], ["ansi-color-white", "white", {}], ["ansi-color-bright-black", "bright black", {}], ["ansi-color-bright-red", "bright red", {}], ["ansi-color-bright-green", "bright green", {}], ["ansi-color-bright-yellow", "bright yellow", {}], ["ansi-color-bright-blue", "bright blue", {}], ["ansi-color-bright-magenta", "bright magenta", {}], ["ansi-color-bright-cyan", "bright cyan", {}], ["ansi-color-bright-white", "bright white", {}]]}, "eat": {"label": "emulate a terminal (eat)", "preview": "eat", "hover": "", "faces": [["eat-term-color-black", "term color black", {}], ["eat-term-color-red", "term color red", {}], ["eat-term-color-green", "term color green", {}], ["eat-term-color-yellow", "term color yellow", {}], ["eat-term-color-blue", "term color blue", {}], ["eat-term-color-magenta", "term color magenta", {}], ["eat-term-color-cyan", "term color cyan", {}], ["eat-term-color-white", "term color white", {}], ["eat-term-color-bright-black", "term color bright black", {}], ["eat-term-color-bright-red", "term color bright red", {}], ["eat-term-color-bright-green", "term color bright green", {}], ["eat-term-color-bright-yellow", "term color bright yellow", {}], ["eat-term-color-bright-blue", "term color bright blue", {}], ["eat-term-color-bright-magenta", "term color bright magenta", {}], ["eat-term-color-bright-cyan", "term color bright cyan", {}], ["eat-term-color-bright-white", "term color bright white", {}], ["eat-term-bold", "term bold", {}], ["eat-term-faint", "term faint", {}], ["eat-term-italic", "term italic", {}], ["eat-term-slow-blink", "term slow blink", {}], ["eat-term-fast-blink", "term fast blink", {}], ["eat-shell-prompt-annotation-success", "shell prompt annotation success", {}], ["eat-shell-prompt-annotation-running", "shell prompt annotation running", {}], ["eat-shell-prompt-annotation-failure", "shell prompt annotation failure", {}]]}, "auto-dim-other-buffers": {"label": "auto-dim", "preview": "autodim", "hover": "", "faces": [["auto-dim-other-buffers", "auto dim other buffers", {}], ["auto-dim-other-buffers-hide", "hide", {}]]}, "dashboard": {"label": "dashboard", "preview": "dashboard", "hover": "", "faces": [["dashboard-banner-logo-title", "banner logo title", {"inherit": "default"}], ["dashboard-text-banner", "text banner", {"inherit": "font-lock-keyword-face"}], ["dashboard-heading", "heading", {"inherit": "font-lock-keyword-face"}], ["dashboard-items-face", "items", {"inherit": "widget-button"}], ["dashboard-navigator", "navigator", {"inherit": "font-lock-keyword-face"}], ["dashboard-no-items-face", "no items", {"inherit": "widget-button"}], ["dashboard-footer-face", "footer", {"inherit": "font-lock-doc-face"}], ["dashboard-footer-icon-face", "footer icon", {"inherit": "dashboard-footer-face"}]]}, "lsp-mode": {"label": "language server protocol (lsp)", "preview": "lsp", "hover": "", "faces": [["lsp-signature-face", "signature", {"inherit": "lsp-details-face"}], ["lsp-signature-highlight-function-argument", "signature highlight function argument", {"inherit": "eldoc-highlight-function-argument"}], ["lsp-signature-posframe", "signature posframe", {"inherit": "tooltip"}], ["lsp-face-highlight-read", "face highlight read", {"underline": {"style": "line", "color": null}, "inherit": "highlight"}], ["lsp-face-highlight-write", "face highlight write", {"weight": "bold", "inherit": "highlight"}], ["lsp-face-highlight-textual", "face highlight textual", {"inherit": "highlight"}], ["lsp-face-rename", "face rename", {"underline": {"style": "line", "color": null}}], ["lsp-rename-placeholder-face", "rename placeholder", {"inherit": "font-lock-variable-name-face"}], ["lsp-inlay-hint-face", "inlay hint", {"inherit": "font-lock-comment-face"}], ["lsp-inlay-hint-parameter-face", "inlay hint parameter", {"inherit": "lsp-inlay-hint-face"}], ["lsp-inlay-hint-type-face", "inlay hint type", {"inherit": "lsp-inlay-hint-face"}], ["lsp-details-face", "details", {"inherit": "shadow", "height": 0.8}], ["lsp-installation-buffer-face", "installation buffer", {"fg": "#00ff00"}], ["lsp-installation-finished-buffer-face", "installation finished buffer", {"fg": "#ffa500"}]]}, "git-gutter": {"label": "git-gutter", "preview": "gitgutter", "hover": "", "faces": [["git-gutter:added", "added", {"fg": "#00ff00", "weight": "bold", "inherit": "default"}], ["git-gutter:modified", "modified", {"fg": "#ff00ff", "weight": "bold", "inherit": "default"}], ["git-gutter:deleted", "deleted", {"fg": "#ff0000", "weight": "bold", "inherit": "default"}], ["git-gutter:unchanged", "unchanged", {"bg": "#ffff00", "inherit": "default"}], ["git-gutter:separator", "separator", {"fg": "#00ffff", "weight": "bold", "inherit": "default"}]]}, "flycheck": {"label": "flycheck", "preview": "flycheck", "hover": "", "faces": [["flycheck-error", "error", {"underline": {"style": "line", "color": null}}], ["flycheck-warning", "warning", {"underline": {"style": "line", "color": null}}], ["flycheck-info", "info", {"underline": {"style": "line", "color": null}}], ["flycheck-fringe-error", "fringe error", {"inherit": "error"}], ["flycheck-fringe-warning", "fringe warning", {"inherit": "warning"}], ["flycheck-fringe-info", "fringe info", {"inherit": "success"}], ["flycheck-delimited-error", "delimited error", {}], ["flycheck-error-delimiter", "error delimiter", {}], ["flycheck-error-list-error", "error list error", {"inherit": "error"}], ["flycheck-error-list-warning", "error list warning", {"inherit": "warning"}], ["flycheck-error-list-info", "error list info", {"inherit": "success"}], ["flycheck-error-list-error-message", "error list error message", {}], ["flycheck-error-list-checker-name", "error list checker name", {"inherit": "font-lock-function-name-face"}], ["flycheck-error-list-column-number", "error list column number", {}], ["flycheck-error-list-line-number", "error list line number", {}], ["flycheck-error-list-filename", "error list filename", {"inherit": "mode-line-buffer-id"}], ["flycheck-error-list-id", "error list id", {"inherit": "font-lock-type-face"}], ["flycheck-error-list-id-with-explainer", "error list id with explainer", {"box": {"style": "released", "width": 1, "color": null}, "inherit": "flycheck-error-list-id"}], ["flycheck-error-list-highlight", "error list highlight", {"weight": "bold"}], ["flycheck-verify-select-checker", "verify select checker", {"box": {"style": "released", "width": 1, "color": null}}]]}, "dired": {"label": "dired", "preview": "dired", "hover": "Directory-listing faces, reused by dirvish (a dired frontend).", "faces": [["dired-header", "header", {}], ["dired-directory", "directory", {}], ["dired-symlink", "symlink", {}], ["dired-broken-symlink", "broken symlink", {}], ["dired-special", "special", {}], ["dired-set-id", "set id", {}], ["dired-perm-write", "perm write", {}], ["dired-mark", "mark", {}], ["dired-marked", "marked", {}], ["dired-flagged", "flagged", {}], ["dired-ignored", "ignored", {}], ["dired-warning", "warning", {}]]}, "dirvish": {"label": "dirvish", "preview": "dirvish", "hover": "", "faces": [["dirvish-inactive", "inactive", {"inherit": "shadow"}], ["dirvish-free-space", "free space", {"inherit": "font-lock-constant-face"}], ["dirvish-hl-line", "hl line", {"extend": true, "inherit": "highlight"}], ["dirvish-hl-line-inactive", "hl line inactive", {"extend": true, "inherit": "region"}], ["dirvish-file-modes", "file modes", {"fg": "#6b6b6b"}], ["dirvish-file-link-number", "file link number", {"inherit": "font-lock-constant-face"}], ["dirvish-file-user-id", "file user id", {"inherit": "font-lock-preprocessor-face"}], ["dirvish-file-group-id", "file group id", {"inherit": "dirvish-file-user-id"}], ["dirvish-file-size", "file size", {"underline": {"style": "line", "color": null}, "inherit": "completions-annotations"}], ["dirvish-file-time", "file time", {"fg": "#979797"}], ["dirvish-file-inode-number", "file inode number", {"inherit": "dirvish-file-link-number"}], ["dirvish-file-device-number", "file device number", {"inherit": "dirvish-file-link-number"}], ["dirvish-subtree-guide", "subtree guide", {"bg": "unspecified", "underline": {"style": "line", "color": null}, "inherit": "dired-ignored"}], ["dirvish-subtree-state", "subtree state", {"bg": "unspecified", "underline": {"style": "line", "color": null}, "inherit": "dired-ignored"}], ["dirvish-collapse-dir-face", "collapse dir", {"inherit": "dired-directory"}], ["dirvish-collapse-empty-dir-face", "collapse empty dir", {"inherit": "shadow"}], ["dirvish-collapse-file-face", "collapse file", {"inherit": "default"}], ["dirvish-emerge-group-title", "emerge group title", {"inherit": "dired-ignored"}], ["dirvish-media-info-heading", "media info heading", {"inherit": ["dired-header", "bold"]}], ["dirvish-media-info-property-key", "media info property key", {"inherit": ["italic"]}], ["dirvish-narrow-match-face-0", "narrow match 0", {"fg": "#223fbf", "weight": "bold"}], ["dirvish-narrow-match-face-1", "narrow match 1", {"fg": "#8f0075", "weight": "bold"}], ["dirvish-narrow-match-face-2", "narrow match 2", {"fg": "#145a00", "weight": "bold"}], ["dirvish-narrow-match-face-3", "narrow match 3", {"fg": "#804000", "weight": "bold"}], ["dirvish-narrow-split", "narrow split", {"inherit": "font-lock-negation-char-face"}], ["dirvish-proc-running", "proc running", {"inherit": "warning"}], ["dirvish-proc-finished", "proc finished", {"inherit": "success"}], ["dirvish-proc-failed", "proc failed", {"inherit": "error"}], ["dirvish-git-commit-message-face", "git commit message", {"bg": "unspecified", "underline": {"style": "line", "color": null}, "inherit": "dired-ignored"}], ["dirvish-vc-added-state", "vc added state", {"inherit": "vc-locally-added-state"}], ["dirvish-vc-edited-state", "vc edited state", {"inherit": "vc-edited-state"}], ["dirvish-vc-removed-state", "vc removed state", {"inherit": "vc-removed-state"}], ["dirvish-vc-conflict-state", "vc conflict state", {"inherit": "vc-conflict-state"}], ["dirvish-vc-locked-state", "vc locked state", {"inherit": "vc-locked-state"}], ["dirvish-vc-missing-state", "vc missing state", {"inherit": "vc-missing-state"}], ["dirvish-vc-needs-merge-face", "vc needs merge", {"bg": "#efcbcf"}], ["dirvish-vc-needs-update-state", "vc needs update state", {"inherit": "vc-needs-update-state"}], ["dirvish-vc-unregistered-face", "vc unregistered", {"inherit": "font-lock-constant-face"}]]}, "calibredb": {"label": "calibredb", "preview": "calibredb", "hover": "", "faces": [["calibredb-search-header-library-name-face", "search header library name", {}], ["calibredb-search-header-library-path-face", "search header library path", {}], ["calibredb-search-header-total-face", "search header total", {}], ["calibredb-search-header-filter-face", "search header filter", {}], ["calibredb-search-header-sort-face", "search header sort", {}], ["calibredb-search-header-highlight-face", "search header highlight", {}], ["calibredb-id-face", "id", {}], ["calibredb-title-face", "title", {}], ["calibredb-author-face", "author", {}], ["calibredb-format-face", "format", {}], ["calibredb-size-face", "size", {}], ["calibredb-tag-face", "tag", {}], ["calibredb-date-face", "date", {}], ["calibredb-mark-face", "mark", {}], ["calibredb-series-face", "series", {}], ["calibredb-publisher-face", "publisher", {}], ["calibredb-pubdate-face", "pubdate", {}], ["calibredb-language-face", "language", {}], ["calibredb-comment-face", "comment", {}], ["calibredb-archive-face", "archive", {}], ["calibredb-favorite-face", "favorite", {}], ["calibredb-file-face", "file", {}], ["calibredb-ids-face", "ids", {}], ["calibredb-highlight-face", "highlight", {}], ["calibredb-current-page-button-face", "current page button", {}], ["calibredb-mouse-face", "mouse", {}], ["calibredb-title-detailed-view-face", "title detailed view", {}], ["calibredb-edit-annotation-header-title-face", "edit annotation header title", {}]]}, "nov-reading": {"label": "nov reading view", "preview": "novreading", "hover": "", "faces": [["cj/nov-reading-sepia", "sepia", {}], ["cj/nov-reading-dark", "dark", {}], ["cj/nov-reading-light", "light", {}], ["cj/nov-reading-sepia-heading", "sepia heading", {}], ["cj/nov-reading-sepia-link", "sepia link", {}], ["cj/nov-reading-dark-heading", "dark heading", {}], ["cj/nov-reading-dark-link", "dark link", {}], ["cj/nov-reading-light-heading", "light heading", {}], ["cj/nov-reading-light-link", "light link", {}]]}, "ai-term": {"label": "ai-term agent colors", "preview": "aiterm", "hover": "Claude Code accents in agent terminals: the fixed bypass banner (accent) plus one face per /color session color. ai-term repaints each agent's xterm-256 palette entries with these, so a tune here recolors live agents on their next redraw.", "faces": [["cj/ai-term-accent", "accent", {}], ["cj/ai-term-color-red", "color red", {}], ["cj/ai-term-color-blue", "color blue", {}], ["cj/ai-term-color-green", "color green", {}], ["cj/ai-term-color-yellow", "color yellow", {}], ["cj/ai-term-color-purple", "color purple", {}], ["cj/ai-term-color-orange", "color orange", {}], ["cj/ai-term-color-pink", "color pink", {}], ["cj/ai-term-color-cyan", "color cyan", {}]]}, "erc": {"label": "erc", "preview": "erc", "hover": "", "faces": [["erc-header-line", "header line", {}], ["erc-timestamp-face", "timestamp", {}], ["erc-notice-face", "notice", {}], ["erc-default-face", "default", {}], ["erc-current-nick-face", "current nick", {}], ["erc-my-nick-face", "my nick", {}], ["erc-my-nick-prefix-face", "my nick prefix", {}], ["erc-nick-default-face", "nick default", {}], ["erc-nick-prefix-face", "nick prefix", {}], ["erc-button-nick-default-face", "button nick default", {}], ["erc-nick-msg-face", "nick msg", {}], ["erc-direct-msg-face", "direct msg", {}], ["erc-action-face", "action", {}], ["erc-keyword-face", "keyword", {}], ["erc-pal-face", "pal", {}], ["erc-fool-face", "fool", {}], ["erc-dangerous-host-face", "dangerous host", {}], ["erc-error-face", "error", {}], ["erc-input-face", "input", {}], ["erc-prompt-face", "prompt", {}], ["erc-command-indicator-face", "command indicator", {}], ["erc-information", "information", {}], ["erc-button", "button", {}], ["erc-bold-face", "bold", {}], ["erc-italic-face", "italic", {}], ["erc-underline-face", "underline", {}], ["erc-inverse-face", "inverse", {}], ["erc-spoiler-face", "spoiler", {}], ["erc-fill-wrap-merge-indicator-face", "fill wrap merge indicator", {}], ["erc-keep-place-indicator-arrow", "keep place indicator arrow", {}], ["erc-keep-place-indicator-line", "keep place indicator line", {}]]}, "org-drill": {"label": "org-drill", "preview": "orgdrill", "hover": "", "faces": [["org-drill-hidden-cloze-face", "hidden cloze", {}], ["org-drill-visible-cloze-face", "visible cloze", {}], ["org-drill-visible-cloze-hint-face", "visible cloze hint", {}]]}, "org-noter": {"label": "org-noter", "preview": "orgnoter", "hover": "", "faces": [["org-noter-notes-exist-face", "notes exist", {}], ["org-noter-no-notes-exist-face", "no notes exist", {}]]}, "signel": {"label": "signel", "preview": "signel", "hover": "", "faces": [["signel-timestamp-face", "timestamp", {}], ["signel-my-msg-face", "my msg", {}], ["signel-other-msg-face", "other msg", {}], ["signel-error-face", "error", {}]]}, "pearl": {"label": "pearl", "preview": "pearl", "hover": "", "faces": [["pearl-preamble-summary", "preamble summary", {}], ["pearl-editable-comment", "editable comment", {}], ["pearl-readonly-comment", "readonly comment", {}], ["pearl-modified-highlight", "modified highlight", {}], ["pearl-modified-local", "modified local", {}], ["pearl-modified-unknown", "modified unknown", {}]]}, "slack": {"label": "slack", "preview": "slack", "hover": "", "faces": [["slack-room-info-title-face", "room info title", {}], ["slack-room-info-title-room-name-face", "room info title room name", {}], ["slack-room-info-section-title-face", "room info section title", {}], ["slack-room-info-section-label-face", "room info section label", {}], ["slack-room-unread-face", "room unread", {}], ["slack-message-output-header", "message output header", {}], ["slack-message-output-text", "message output text", {}], ["slack-message-output-reaction", "message output reaction", {}], ["slack-message-output-reaction-pressed", "message output reaction pressed", {}], ["slack-message-deleted-face", "message deleted", {}], ["slack-new-message-marker-face", "new message marker", {}], ["slack-all-thread-buffer-thread-header-face", "all thread buffer thread header", {}], ["slack-message-mention-face", "message mention", {}], ["slack-message-mention-me-face", "message mention me", {}], ["slack-message-mention-keyword-face", "message mention keyword", {}], ["slack-channel-button-face", "channel button", {}], ["slack-mrkdwn-bold-face", "mrkdwn bold", {}], ["slack-mrkdwn-italic-face", "mrkdwn italic", {}], ["slack-mrkdwn-code-face", "mrkdwn code", {}], ["slack-mrkdwn-code-block-face", "mrkdwn code block", {}], ["slack-mrkdwn-strike-face", "mrkdwn strike", {}], ["slack-mrkdwn-blockquote-face", "mrkdwn blockquote", {}], ["slack-mrkdwn-list-face", "mrkdwn list", {}], ["slack-attachment-header", "attachment header", {}], ["slack-attachment-footer", "attachment footer", {}], ["slack-attachment-pad", "attachment pad", {}], ["slack-attachment-field-title", "attachment field title", {}], ["slack-message-attachment-preview-header-face", "message attachment preview header", {}], ["slack-preview-face", "preview", {}], ["slack-block-highlight-source-overlay-face", "block highlight source overlay", {}], ["slack-message-action-face", "message action", {}], ["slack-message-action-primary-face", "message action primary", {}], ["slack-message-action-danger-face", "message action danger", {}], ["slack-button-block-element-face", "button block element", {}], ["slack-button-primary-block-element-face", "button primary block element", {}], ["slack-button-danger-block-element-face", "button danger block element", {}], ["slack-select-block-element-face", "select block element", {}], ["slack-overflow-block-element-face", "overflow block element", {}], ["slack-date-picker-block-element-face", "date picker block element", {}], ["slack-dialog-title-face", "dialog title", {}], ["slack-dialog-element-label-face", "dialog element label", {}], ["slack-dialog-element-hint-face", "dialog element hint", {}], ["slack-dialog-element-placeholder-face", "dialog element placeholder", {}], ["slack-dialog-element-error-face", "dialog element error", {}], ["slack-dialog-submit-button-face", "dialog submit button", {}], ["slack-dialog-cancel-button-face", "dialog cancel button", {}], ["slack-dialog-select-element-input-face", "dialog select element input", {}], ["slack-user-active-face", "user active", {}], ["slack-user-dnd-face", "user dnd", {}], ["slack-user-profile-header-face", "user profile header", {}], ["slack-user-profile-property-name-face", "user profile property name", {}], ["slack-profile-image-face", "profile image", {}], ["slack-search-result-message-header-face", "search result message header", {}], ["slack-search-result-message-username-face", "search result message username", {}], ["slack-modeline-has-unreads-face", "modeline has unreads", {}], ["slack-modeline-channel-has-unreads-face", "modeline channel has unreads", {}], ["slack-modeline-thread-has-unreads-face", "modeline thread has unreads", {}]]}, "telega": {"label": "telega", "preview": "telega", "hover": "", "faces": [["telega-root-heading", "root heading", {}], ["telega-tracking", "tracking", {}], ["telega-unread-unmuted-modeline", "unread unmuted modeline", {}], ["telega-username", "username", {}], ["telega-user-online-status", "user online status", {}], ["telega-user-non-online-status", "user non online status", {}], ["telega-secret-title", "secret title", {}], ["telega-contact-birthdays-today", "contact birthdays today", {}], ["telega-muted-count", "muted count", {}], ["telega-unmuted-count", "unmuted count", {}], ["telega-mention-count", "mention count", {}], ["telega-has-chatbuf-brackets", "has chatbuf brackets", {}], ["telega-delim-face", "delim", {}], ["telega-shadow", "shadow", {}], ["telega-link", "link", {}], ["telega-blue", "blue", {}], ["telega-red", "red", {}], ["telega-msg-heading", "msg heading", {}], ["telega-msg-user-title", "msg user title", {}], ["telega-msg-self-title", "msg self title", {}], ["telega-msg-deleted", "msg deleted", {}], ["telega-msg-sponsored", "msg sponsored", {}], ["telega-msg-inline-reply", "msg inline reply", {}], ["telega-msg-inline-forward", "msg inline forward", {}], ["telega-msg-inline-other", "msg inline other", {}], ["telega-entity-type-bold", "entity type bold", {}], ["telega-entity-type-italic", "entity type italic", {}], ["telega-entity-type-underline", "entity type underline", {}], ["telega-entity-type-strikethrough", "entity type strikethrough", {}], ["telega-entity-type-code", "entity type code", {}], ["telega-entity-type-pre", "entity type pre", {}], ["telega-entity-type-blockquote", "entity type blockquote", {}], ["telega-entity-type-mention", "entity type mention", {}], ["telega-entity-type-hashtag", "entity type hashtag", {}], ["telega-entity-type-cashtag", "entity type cashtag", {}], ["telega-entity-type-botcommand", "entity type botcommand", {}], ["telega-entity-type-texturl", "entity type texturl", {}], ["telega-entity-type-spoiler", "entity type spoiler", {}], ["telega-reaction", "reaction", {}], ["telega-reaction-chosen", "reaction chosen", {}], ["telega-reaction-paid", "reaction paid", {}], ["telega-reaction-paid-chosen", "reaction paid chosen", {}], ["telega-highlight-text-face", "highlight text", {}], ["telega-button-highlight", "button highlight", {}], ["telega-chat-prompt", "chat prompt", {}], ["telega-chat-prompt-aux", "chat prompt aux", {}], ["telega-chat-input-attachment", "chat input attachment", {}], ["telega-topic-button", "topic button", {}], ["telega-filter-active", "filter active", {}], ["telega-filter-button-active", "filter button active", {}], ["telega-filter-button-inactive", "filter button inactive", {}], ["telega-checklist-stats-done", "checklist stats done", {}], ["telega-checklist-stats-todo", "checklist stats todo", {}], ["telega-box-button", "box button", {}], ["telega-box-button-active", "box button active", {}], ["telega-box-button-default-active", "box button default active", {}], ["telega-box-button-default-passive", "box button default passive", {}], ["telega-box-button-primary-active", "box button primary active", {}], ["telega-box-button-primary-passive", "box button primary passive", {}], ["telega-box-button-success-active", "box button success active", {}], ["telega-box-button-success-passive", "box button success passive", {}], ["telega-box-button-danger-active", "box button danger active", {}], ["telega-box-button-danger-passive", "box button danger passive", {}], ["telega-box-button-ui-active", "box button ui active", {}], ["telega-box-button-ui-passive", "box button ui passive", {}], ["telega-box-button2-active", "box button2 active", {}], ["telega-box-button2-passive", "box button2 passive", {}], ["telega-box-button2-white-foreground", "box button2 white foreground", {}], ["telega-describe-item-title", "describe item title", {}], ["telega-describe-section-title", "describe section title", {}], ["telega-describe-subsection-title", "describe subsection title", {}], ["telega-enckey-00", "enckey 00", {}], ["telega-enckey-01", "enckey 01", {}], ["telega-enckey-10", "enckey 10", {}], ["telega-enckey-11", "enckey 11", {}], ["telega-palette-builtin-blue", "palette builtin blue", {}], ["telega-palette-builtin-green", "palette builtin green", {}], ["telega-palette-builtin-orange", "palette builtin orange", {}], ["telega-palette-builtin-purple", "palette builtin purple", {}], ["telega-webpage-title", "webpage title", {}], ["telega-webpage-subtitle", "webpage subtitle", {}], ["telega-webpage-header", "webpage header", {}], ["telega-webpage-subheader", "webpage subheader", {}], ["telega-webpage-outline", "webpage outline", {}], ["telega-webpage-fixed", "webpage fixed", {}], ["telega-webpage-preformatted", "webpage preformatted", {}], ["telega-webpage-marked", "webpage marked", {}], ["telega-webpage-strike-through", "webpage strike through", {}], ["telega-webpage-chat-link", "webpage chat link", {}], ["telega-link-preview-sitename", "link preview sitename", {}], ["telega-link-preview-title", "link preview title", {}]]}, "shr": {"label": "simple html renderer (shr)", "preview": "shr", "hover": "Simple HTML Renderer. Reused by eww, nov (epub reading), and mu4e / message for HTML mail.", "faces": [["shr-h1", "h1", {}], ["shr-h2", "h2", {}], ["shr-h3", "h3", {}], ["shr-h4", "h4", {}], ["shr-h5", "h5", {}], ["shr-h6", "h6", {}], ["shr-text", "text", {}], ["shr-link", "link", {}], ["shr-selected-link", "selected link", {}], ["shr-code", "code", {}], ["shr-mark", "mark", {}], ["shr-strike-through", "strike through", {}], ["shr-sup", "sup", {}], ["shr-abbreviation", "abbreviation", {}], ["shr-sliced-image", "sliced image", {}]]}, "nerd-icons": {"label": "nerd-icons", "preview": "nerdicons", "faces": [["nerd-icons-blue", "blue", {"fg": "#6a9fb5"}], ["nerd-icons-blue-alt", "blue alt", {"fg": "#2188b6"}], ["nerd-icons-cyan", "cyan", {"fg": "#75b5aa"}], ["nerd-icons-cyan-alt", "cyan alt", {"fg": "#0595bd"}], ["nerd-icons-dblue", "dblue", {"fg": "#446674"}], ["nerd-icons-dcyan", "dcyan", {"fg": "#48746d"}], ["nerd-icons-dgreen", "dgreen", {"fg": "#6d8143"}], ["nerd-icons-dmaroon", "dmaroon", {"fg": "#72584b"}], ["nerd-icons-dorange", "dorange", {"fg": "#915b2d"}], ["nerd-icons-dpink", "dpink", {"fg": "#7e5d5f"}], ["nerd-icons-dpurple", "dpurple", {"fg": "#694863"}], ["nerd-icons-dred", "dred", {"fg": "#843031"}], ["nerd-icons-dsilver", "dsilver", {"fg": "#838484"}], ["nerd-icons-dyellow", "dyellow", {"fg": "#b48d56"}], ["nerd-icons-green", "green", {"fg": "#90a959"}], ["nerd-icons-lblue", "lblue", {"fg": "#677174"}], ["nerd-icons-lcyan", "lcyan", {"fg": "#2c7d6e"}], ["nerd-icons-lgreen", "lgreen", {"fg": "#3d6837"}], ["nerd-icons-lmaroon", "lmaroon", {"fg": "#ce7a4e"}], ["nerd-icons-lorange", "lorange", {"fg": "#ffa500"}], ["nerd-icons-lpink", "lpink", {"fg": "#ff505b"}], ["nerd-icons-lpurple", "lpurple", {"fg": "#e69dd6"}], ["nerd-icons-lred", "lred", {"fg": "#eb595a"}], ["nerd-icons-lsilver", "lsilver", {"fg": "#7f7869"}], ["nerd-icons-lyellow", "lyellow", {"fg": "#ff9300"}], ["nerd-icons-maroon", "maroon", {"fg": "#8f5536"}], ["nerd-icons-orange", "orange", {"fg": "#d4843e"}], ["nerd-icons-pink", "pink", {"fg": "#fc505b"}], ["nerd-icons-purple", "purple", {"fg": "#68295b"}], ["nerd-icons-purple-alt", "purple alt", {"fg": "#5d54e1"}], ["nerd-icons-red", "red", {"fg": "#ac4142"}], ["nerd-icons-red-alt", "red alt", {"fg": "#843031"}], ["nerd-icons-silver", "silver", {"fg": "#716e68"}], ["nerd-icons-yellow", "yellow", {"fg": "#ffcc0e"}]], "legend": [{"key": "ext:el", "label": "init.el", "face": "nerd-icons-purple", "category": "extension", "glyph": "\ue632"}, {"key": "ext:py", "label": "app.py", "face": "nerd-icons-dblue", "category": "extension", "glyph": "\ue73c"}, {"key": "ext:org", "label": "notes.org", "face": "nerd-icons-lgreen", "category": "extension", "glyph": "\ue633"}, {"key": "ext:md", "label": "README.md", "face": "nerd-icons-lblue", "category": "extension", "glyph": "\uf48a"}, {"key": "ext:ts", "label": "main.ts", "face": "nerd-icons-blue-alt", "category": "extension", "glyph": "\udb81\udee6"}, {"key": "ext:html", "label": "index.html", "face": "nerd-icons-orange", "category": "extension", "glyph": "\ue736"}, {"key": "ext:rs", "label": "lib.rs", "face": "nerd-icons-maroon", "category": "extension", "glyph": "\ue7a8"}, {"key": "ext:js", "label": "app.js", "face": "nerd-icons-yellow", "category": "extension", "glyph": "\ue781"}, {"key": "ext:yml", "label": "ci.yml", "face": "nerd-icons-dyellow", "category": "extension", "glyph": "\ueb52"}, {"key": "ext:c", "label": "main.c", "face": "nerd-icons-blue", "category": "extension", "glyph": "\ue61e"}, {"key": "dir", "label": "src/", "face": "nerd-icons-yellow", "category": "dir", "glyph": "\ue6ad"}, {"key": "cmd", "label": "M-x command", "face": "nerd-icons-blue", "category": "command", "glyph": "\uea8c"}, {"key": "buf", "label": "*scratch*", "face": "nerd-icons-purple", "category": "buffer", "glyph": "\ue632"}], "gallery": [{"face": "nerd-icons-dpink", "hue": 5, "glyphs": [{"glyph": "\udb84\udd83", "name": "nf-md-bash"}, {"glyph": "\udb82\udc77", "name": "nf-md-graphql"}, {"glyph": "\udb81\udfec", "name": "nf-md-sass"}, {"glyph": "\ue662", "name": "nf-seti-graphql"}, {"glyph": "\ue67a", "name": "nf-seti-ocaml"}]}, {"face": "nerd-icons-pink", "hue": 5, "glyphs": [{"glyph": "\ue711", "name": "nf-dev-apple"}, {"glyph": "\udb81\udfec", "name": "nf-md-sass"}, {"glyph": "\uf4ae", "name": "nf-oct-code_of_conduct"}]}, {"face": "nerd-icons-dorange", "hue": 13, "glyphs": [{"glyph": "\ueb52", "name": "nf-cod-settings"}, {"glyph": "\ue779", "name": "nf-dev-gnu"}, {"glyph": "\ue7a8", "name": "nf-dev-rust"}, {"glyph": "\uf43d", "name": "nf-oct-key"}, {"glyph": "\ue673", "name": "nf-seti-makefile"}]}, {"face": "nerd-icons-lorange", "hue": 13, "glyphs": [{"glyph": "\ue6b0", "name": "nf-custom-common_lisp"}, {"glyph": "\ue62d", "name": "nf-custom-elixir"}, {"glyph": "\ue74d", "name": "nf-dev-bower"}, {"glyph": "\uf1c9", "name": "nf-fa-file_code_o"}, {"glyph": "\uf143", "name": "nf-fa-rss_square"}, {"glyph": "\ue62d", "name": "nf-seti-elixir"}, {"glyph": "\ue67e", "name": "nf-seti-perl"}]}, {"face": "nerd-icons-lred", "hue": 13, "glyphs": [{"glyph": "\ueb9c", "name": "nf-cod-library"}, {"glyph": "\ueb48", "name": "nf-cod-ruby"}, {"glyph": "\ue763", "name": "nf-dev-gulp"}, {"glyph": "\ue736", "name": "nf-dev-html5"}, {"glyph": "\ue807", "name": "nf-dev-jest"}, {"glyph": "\ue755", "name": "nf-dev-swift"}, {"glyph": "\uf022", "name": "nf-fa-list_alt"}, {"glyph": "\uf200", "name": "nf-fa-pie_chart"}, {"glyph": "\udb80\ude19", "name": "nf-md-file_document"}, {"glyph": "\uf4d2", "name": "nf-oct-file_diff"}, {"glyph": "\ue62d", "name": "nf-seti-elixir"}, {"glyph": "\ue65d", "name": "nf-seti-git"}, {"glyph": "\ue69b", "name": "nf-seti-tex"}]}, {"face": "nerd-icons-orange", "hue": 13, "glyphs": [{"glyph": "\ueb52", "name": "nf-cod-settings"}, {"glyph": "\ue6b0", "name": "nf-custom-common_lisp"}, {"glyph": "\ue632", "name": "nf-custom-emacs"}, {"glyph": "\ue634", "name": "nf-custom-kotlin"}, {"glyph": "\ue6b1", "name": "nf-custom-scheme"}, {"glyph": "\ue6b2", "name": "nf-custom-toml"}, {"glyph": "\ue7ad", "name": "nf-dev-aws"}, {"glyph": "\ue7bc", "name": "nf-dev-d3js"}, {"glyph": "\ue7eb", "name": "nf-dev-gitlab"}, {"glyph": "\ue736", "name": "nf-dev-html5"}, {"glyph": "\ue80f", "name": "nf-dev-jupyter"}, {"glyph": "\ue82a", "name": "nf-dev-matlab"}, {"glyph": "\uf143", "name": "nf-fa-rss_square"}, {"glyph": "\udb81\uddee", "name": "nf-md-disc"}, {"glyph": "\udb83\ude2d", "name": "nf-md-file_png_box"}, {"glyph": "\udb80\ude27", "name": "nf-md-file_powerpoint"}, {"glyph": "\udb81\uddc4", "name": "nf-md-zip_box"}, {"glyph": "\uf43d", "name": "nf-oct-key"}, {"glyph": "\ue666", "name": "nf-seti-haxe"}, {"glyph": "\ue634", "name": "nf-seti-kotlin"}, {"glyph": "\ue6a9", "name": "nf-seti-zig"}, {"glyph": "\ue6aa", "name": "nf-seti-zip"}]}, {"face": "nerd-icons-red", "hue": 14, "glyphs": [{"glyph": "\ueb48", "name": "nf-cod-ruby"}, {"glyph": "\ue6b1", "name": "nf-custom-scheme"}, {"glyph": "\ue794", "name": "nf-dev-cmake"}, {"glyph": "\ue7b1", "name": "nf-dev-erlang"}, {"glyph": "\ue725", "name": "nf-dev-git_branch"}, {"glyph": "\ue728", "name": "nf-dev-git_compare"}, {"glyph": "\ue777", "name": "nf-dev-haskell"}, {"glyph": "\ue71e", "name": "nf-dev-npm"}, {"glyph": "\ue737", "name": "nf-dev-scala"}, {"glyph": "\uf269", "name": "nf-fa-firefox"}, {"glyph": "\uf1fc", "name": "nf-fa-paint_brush"}, {"glyph": "\uf303", "name": "nf-linux-archlinux"}, {"glyph": "\udb81\ude1a", "name": "nf-md-chip"}, {"glyph": "\udb86\ude9d", "name": "nf-md-file_document_plus"}, {"glyph": "\uf417", "name": "nf-oct-git_commit"}, {"glyph": "\uf419", "name": "nf-oct-git_merge"}, {"glyph": "\uf456", "name": "nf-oct-lock"}, {"glyph": "\ue65d", "name": "nf-seti-git"}, {"glyph": "\ue66c", "name": "nf-seti-jade"}, {"glyph": "\ue686", "name": "nf-seti-pug"}, {"glyph": "\ue68d", "name": "nf-seti-sbt"}, {"glyph": "\ue697", "name": "nf-seti-svelte"}]}, {"face": "nerd-icons-red-alt", "hue": 14, "glyphs": [{"glyph": "\ue687", "name": "nf-seti-reasonml"}]}, {"face": "nerd-icons-maroon", "hue": 15, "glyphs": [{"glyph": "\ue751", "name": "nf-dev-coffeescript"}, {"glyph": "\ue7a8", "name": "nf-dev-rust"}, {"glyph": "\uf456", "name": "nf-oct-lock"}, {"glyph": "\uf4ed", "name": "nf-oct-log"}]}, {"face": "nerd-icons-lmaroon", "hue": 15, "glyphs": [{"glyph": "\ue751", "name": "nf-dev-coffeescript"}, {"glyph": "\ue7a1", "name": "nf-dev-prolog"}, {"glyph": "\udb80\udeea", "name": "nf-md-image_album"}, {"glyph": "\uf471", "name": "nf-oct-file_binary"}, {"glyph": "\uf410", "name": "nf-oct-file_zip"}, {"glyph": "\ue685", "name": "nf-seti-prolog"}]}, {"face": "nerd-icons-dred", "hue": 15, "glyphs": [{"glyph": "\ueaeb", "name": "nf-cod-file_pdf"}, {"glyph": "\ueb48", "name": "nf-cod-ruby"}, {"glyph": "\ue7b1", "name": "nf-dev-erlang"}, {"glyph": "\ue71e", "name": "nf-dev-npm"}, {"glyph": "\uf031", "name": "nf-fa-font"}, {"glyph": "\uf001", "name": "nf-fa-music"}, {"glyph": "\udb81\uded3", "name": "nf-md-feather"}, {"glyph": "\udb81\udff5", "name": "nf-md-form_textbox_password"}, {"glyph": "\udb80\udee9", "name": "nf-md-image"}, {"glyph": "\udb83\udcb9", "name": "nf-md-playlist_music_outline"}, {"glyph": "\ue687", "name": "nf-seti-reasonml"}]}, {"face": "nerd-icons-dmaroon", "hue": 15, "glyphs": [{"glyph": "\ue7a8", "name": "nf-dev-rust"}]}, {"face": "nerd-icons-lyellow", "hue": 45, "glyphs": [{"glyph": "\ueb52", "name": "nf-cod-settings"}, {"glyph": "\ue768", "name": "nf-dev-clojure"}, {"glyph": "\ue76a", "name": "nf-dev-clojure_alt"}, {"glyph": "\ue74c", "name": "nf-dev-grunt"}, {"glyph": "\uf249", "name": "nf-fa-sticky_note"}, {"glyph": "\uf45e", "name": "nf-oct-checklist"}, {"glyph": "\ue62d", "name": "nf-seti-elixir"}, {"glyph": "\ue664", "name": "nf-seti-haml"}, {"glyph": "\ue695", "name": "nf-seti-stylelint"}]}, {"face": "nerd-icons-dyellow", "hue": 45, "glyphs": [{"glyph": "\ueb52", "name": "nf-cod-settings"}, {"glyph": "\ue758", "name": "nf-dev-less"}, {"glyph": "\ue7a8", "name": "nf-dev-rust"}]}, {"face": "nerd-icons-yellow", "hue": 45, "glyphs": [{"glyph": "\ueacd", "name": "nf-cod-dashboard"}, {"glyph": "\ueb52", "name": "nf-cod-settings"}, {"glyph": "\ue62f", "name": "nf-custom-crystal"}, {"glyph": "\ue632", "name": "nf-custom-emacs"}, {"glyph": "\ue741", "name": "nf-dev-awk"}, {"glyph": "\ue749", "name": "nf-dev-css3"}, {"glyph": "\ue781", "name": "nf-dev-javascript"}, {"glyph": "\ue87d", "name": "nf-dev-qt"}, {"glyph": "\ue7a8", "name": "nf-dev-rust"}, {"glyph": "\ue8d9", "name": "nf-dev-vitest"}, {"glyph": "\uf0e7", "name": "nf-fa-bolt"}, {"glyph": "\uf143", "name": "nf-fa-rss_square"}, {"glyph": "\uf249", "name": "nf-fa-sticky_note"}, {"glyph": "\udb82\ude25", "name": "nf-md-babel"}, {"glyph": "\udb81\ude26", "name": "nf-md-code_json"}, {"glyph": "\uf4ed", "name": "nf-oct-log"}, {"glyph": "\ue639", "name": "nf-seti-babel"}, {"glyph": "\ue62f", "name": "nf-seti-crystal"}, {"glyph": "\ue677", "name": "nf-seti-nim"}, {"glyph": "\ue631", "name": "nf-seti-puppet"}]}, {"face": "nerd-icons-green", "hue": 79, "glyphs": [{"glyph": "\ueacd", "name": "nf-cod-dashboard"}, {"glyph": "\ue6b5", "name": "nf-custom-ada"}, {"glyph": "\ue61e", "name": "nf-custom-c"}, {"glyph": "\ue768", "name": "nf-dev-clojure"}, {"glyph": "\ue76a", "name": "nf-dev-clojure_alt"}, {"glyph": "\ue718", "name": "nf-dev-nodejs_small"}, {"glyph": "\ue795", "name": "nf-dev-terminal"}, {"glyph": "\uee36", "name": "nf-fa-file_arrow_down"}, {"glyph": "\uf0fd", "name": "nf-fa-h_square"}, {"glyph": "\uf001", "name": "nf-fa-music"}, {"glyph": "\uf1ea", "name": "nf-fa-newspaper"}, {"glyph": "\uf1fc", "name": "nf-fa-paint_brush"}, {"glyph": "\udb80\udcbd", "name": "nf-md-book_open"}, {"glyph": "\udb83\udd78", "name": "nf-md-file_gif_box"}, {"glyph": "\udb84\udce2", "name": "nf-md-file_table_box_multiple"}, {"glyph": "\udb80\udf1b", "name": "nf-md-language_csharp"}, {"glyph": "\uf472", "name": "nf-oct-database"}, {"glyph": "\uf43d", "name": "nf-oct-key"}, {"glyph": "\ue65d", "name": "nf-seti-git"}]}, {"face": "nerd-icons-dgreen", "hue": 79, "glyphs": [{"glyph": "\ue62b", "name": "nf-custom-vim"}, {"glyph": "\ue72b", "name": "nf-dev-apache"}, {"glyph": "\ue776", "name": "nf-dev-nginx"}, {"glyph": "\udb80\ude1b", "name": "nf-md-file_excel"}, {"glyph": "\uf4d2", "name": "nf-oct-file_diff"}, {"glyph": "\uf40f", "name": "nf-oct-file_media"}, {"glyph": "\uf4ed", "name": "nf-oct-log"}]}, {"face": "nerd-icons-lgreen", "hue": 83, "glyphs": [{"glyph": "\ue633", "name": "nf-custom-orgmode"}, {"glyph": "\ue794", "name": "nf-dev-cmake"}, {"glyph": "\ue769", "name": "nf-dev-perl"}, {"glyph": "\ue759", "name": "nf-dev-stylus"}, {"glyph": "\uf1ea", "name": "nf-fa-newspaper"}, {"glyph": "\udb82\udd84", "name": "nf-md-map_search"}, {"glyph": "\udb80\udf99", "name": "nf-md-nodejs"}, {"glyph": "\uf45e", "name": "nf-oct-checklist"}, {"glyph": "\uf4d2", "name": "nf-oct-file_diff"}, {"glyph": "\ue698", "name": "nf-seti-svg"}, {"glyph": "\ue6a0", "name": "nf-seti-vue"}]}, {"face": "nerd-icons-cyan", "hue": 189, "glyphs": [{"glyph": "\ue775", "name": "nf-dev-groovy"}, {"glyph": "\uf080", "name": "nf-fa-bar_chart"}, {"glyph": "\uf15c", "name": "nf-fa-file_text"}, {"glyph": "\uf031", "name": "nf-fa-font"}, {"glyph": "\uf303", "name": "nf-linux-archlinux"}, {"glyph": "\udb85\udd17", "name": "nf-md-file_document_multiple"}, {"glyph": "\udb80\ude27", "name": "nf-md-file_powerpoint"}, {"glyph": "\udb82\udce8", "name": "nf-md-gentoo"}, {"glyph": "\udb82\udfc2", "name": "nf-md-script_text"}, {"glyph": "\udb81\udcce", "name": "nf-md-star"}, {"glyph": "\ue650", "name": "nf-seti-docker"}]}, {"face": "nerd-icons-dcyan", "hue": 190, "glyphs": [{"glyph": "\uf031", "name": "nf-fa-font"}]}, {"face": "nerd-icons-cyan-alt", "hue": 192, "glyphs": [{"glyph": "\ue775", "name": "nf-dev-groovy"}]}, {"face": "nerd-icons-lcyan", "hue": 197, "glyphs": [{"glyph": "\ueb9c", "name": "nf-cod-library"}, {"glyph": "\ue795", "name": "nf-dev-terminal"}, {"glyph": "\uf405", "name": "nf-oct-book"}]}, {"face": "nerd-icons-lsilver", "hue": 210, "glyphs": [{"glyph": "\uebc4", "name": "nf-cod-terminal_cmd"}, {"glyph": "\ue73d", "name": "nf-dev-php"}, {"glyph": "\ue795", "name": "nf-dev-terminal"}, {"glyph": "\uf0fc", "name": "nf-fa-beer"}, {"glyph": "\uf013", "name": "nf-fa-cog"}, {"glyph": "\udb84\udc7b", "name": "nf-md-file_cog"}, {"glyph": "\udb83\udd13", "name": "nf-md-fountain_pen_tip"}, {"glyph": "\uf425", "name": "nf-oct-tools"}, {"glyph": "\ue673", "name": "nf-seti-makefile"}]}, {"face": "nerd-icons-silver", "hue": 210, "glyphs": [{"glyph": "\ue6b4", "name": "nf-custom-prettier"}, {"glyph": "\ue74d", "name": "nf-dev-bower"}, {"glyph": "\ue706", "name": "nf-dev-database"}, {"glyph": "\uf187", "name": "nf-fa-archive"}, {"glyph": "\uf085", "name": "nf-fa-cogs"}, {"glyph": "\uf001", "name": "nf-fa-music"}, {"glyph": "\uf472", "name": "nf-oct-database"}, {"glyph": "\ue652", "name": "nf-seti-editorconfig"}, {"glyph": "\ue660", "name": "nf-seti-gradle"}, {"glyph": "\ue66f", "name": "nf-seti-jinja"}]}, {"face": "nerd-icons-dsilver", "hue": 210, "glyphs": [{"glyph": "\ueae8", "name": "nf-cod-file_binary"}, {"glyph": "\ue632", "name": "nf-custom-emacs"}, {"glyph": "\ue73c", "name": "nf-dev-python"}, {"glyph": "\uf1c9", "name": "nf-fa-file_code"}, {"glyph": "\uf0c5", "name": "nf-fa-files_o"}, {"glyph": "\uf369", "name": "nf-linux-xorg"}, {"glyph": "\uf471", "name": "nf-oct-file_binary"}, {"glyph": "\uf42f", "name": "nf-oct-mail"}, {"glyph": "\uf487", "name": "nf-oct-package"}]}, {"face": "nerd-icons-lblue", "hue": 211, "glyphs": [{"glyph": "\ueb9c", "name": "nf-cod-library"}, {"glyph": "\ue632", "name": "nf-custom-emacs"}, {"glyph": "\ue7d2", "name": "nf-dev-eslint"}, {"glyph": "\ue728", "name": "nf-dev-git_compare"}, {"glyph": "\ue7ba", "name": "nf-dev-react"}, {"glyph": "\ue8e3", "name": "nf-dev-webpack"}, {"glyph": "\uf1c9", "name": "nf-fa-file_code_o"}, {"glyph": "\uf120", "name": "nf-fa-terminal"}, {"glyph": "\udb80\udcba", "name": "nf-md-book"}, {"glyph": "\udb81\ude70", "name": "nf-md-file_restore"}, {"glyph": "\uf43d", "name": "nf-oct-key"}, {"glyph": "\uf48a", "name": "nf-oct-markdown"}, {"glyph": "\ue650", "name": "nf-seti-docker"}, {"glyph": "\ue62d", "name": "nf-seti-elixir"}, {"glyph": "\ue68a", "name": "nf-seti-r"}]}, {"face": "nerd-icons-blue", "hue": 212, "glyphs": [{"glyph": "\ue6b5", "name": "nf-custom-ada"}, {"glyph": "\ue61e", "name": "nf-custom-c"}, {"glyph": "\ue61d", "name": "nf-custom-cpp"}, {"glyph": "\ue62c", "name": "nf-custom-elm"}, {"glyph": "\ue632", "name": "nf-custom-emacs"}, {"glyph": "\ue6b1", "name": "nf-custom-scheme"}, {"glyph": "\ue768", "name": "nf-dev-clojure"}, {"glyph": "\ue76a", "name": "nf-dev-clojure_alt"}, {"glyph": "\ue794", "name": "nf-dev-cmake"}, {"glyph": "\ue798", "name": "nf-dev-dart"}, {"glyph": "\ue7a7", "name": "nf-dev-fsharp"}, {"glyph": "\ue767", "name": "nf-dev-jenkins"}, {"glyph": "\ue795", "name": "nf-dev-terminal"}, {"glyph": "\uf293", "name": "nf-fa-bluetooth"}, {"glyph": "\uf02d", "name": "nf-fa-book"}, {"glyph": "\uf268", "name": "nf-fa-chrome"}, {"glyph": "\uf008", "name": "nf-fa-film"}, {"glyph": "\uf129", "name": "nf-fa-info"}, {"glyph": "\uf0d0", "name": "nf-fa-magic"}, {"glyph": "\uf1fc", "name": "nf-fa-paint_brush"}, {"glyph": "\ue217", "name": "nf-fae-telegram"}, {"glyph": "\udb80\udcba", "name": "nf-md-book"}, {"glyph": "\udb81\udde6", "name": "nf-md-copyright"}, {"glyph": "\udb80\ude2c", "name": "nf-md-file_word"}, {"glyph": "\udb82\udce8", "name": "nf-md-gentoo"}, {"glyph": "\udb81\udee6", "name": "nf-md-language_typescript"}, {"glyph": "\udb82\uded1", "name": "nf-md-mastodon"}, {"glyph": "\udb84\udd05", "name": "nf-md-nix"}, {"glyph": "\udb82\ude0a", "name": "nf-md-powershell"}, {"glyph": "\uf4bc", "name": "nf-oct-cpu"}, {"glyph": "\uf4d1", "name": "nf-oct-file_badge"}, {"glyph": "\uf40f", "name": "nf-oct-file_media"}, {"glyph": "\uf43d", "name": "nf-oct-key"}, {"glyph": "\uf412", "name": "nf-oct-tag"}, {"glyph": "\ue637", "name": "nf-seti-asm"}, {"glyph": "\ue642", "name": "nf-seti-clojure"}, {"glyph": "\ue650", "name": "nf-seti-docker"}, {"glyph": "\ue62c", "name": "nf-seti-elm"}, {"glyph": "\ue65e", "name": "nf-seti-go2"}, {"glyph": "\ue65f", "name": "nf-seti-godot"}]}, {"face": "nerd-icons-dblue", "hue": 212, "glyphs": [{"glyph": "\ueb9c", "name": "nf-cod-library"}, {"glyph": "\ue7b0", "name": "nf-dev-docker"}, {"glyph": "\ue73c", "name": "nf-dev-python"}, {"glyph": "\uf008", "name": "nf-fa-film"}, {"glyph": "\uf1fc", "name": "nf-fa-paint_brush"}, {"glyph": "\udb80\ude25", "name": "nf-md-file_jpg_box"}, {"glyph": "\udb80\udf1b", "name": "nf-md-language_csharp"}, {"glyph": "\uf472", "name": "nf-oct-database"}, {"glyph": "\uf40f", "name": "nf-oct-file_media"}, {"glyph": "\uf437", "name": "nf-oct-graph"}, {"glyph": "\ue620", "name": "nf-seti-lua"}]}, {"face": "nerd-icons-blue-alt", "hue": 213, "glyphs": [{"glyph": "\ue7a7", "name": "nf-dev-fsharp"}, {"glyph": "\udb81\udee6", "name": "nf-md-language_typescript"}, {"glyph": "\udb81\udf08", "name": "nf-md-react"}, {"glyph": "\ue615", "name": "nf-seti-config"}, {"glyph": "\ue6a7", "name": "nf-seti-yarn"}]}, {"face": "nerd-icons-lpurple", "hue": 265, "glyphs": [{"glyph": "\udb83\udc7a", "name": "nf-md-eslint"}, {"glyph": "\udb80\udf1e", "name": "nf-md-language_javascript"}, {"glyph": "\ue62d", "name": "nf-seti-elixir"}]}, {"face": "nerd-icons-purple", "hue": 272, "glyphs": [{"glyph": "\ue61d", "name": "nf-custom-cpp"}, {"glyph": "\ue632", "name": "nf-custom-emacs"}, {"glyph": "\ue738", "name": "nf-dev-java"}, {"glyph": "\ue795", "name": "nf-dev-terminal"}, {"glyph": "\uf0fd", "name": "nf-fa-h_square"}, {"glyph": "\uf129", "name": "nf-fa-info"}, {"glyph": "\uf1fc", "name": "nf-fa-paint_brush"}, {"glyph": "\ue217", "name": "nf-fae-telegram"}, {"glyph": "\udb83\udc7a", "name": "nf-md-eslint"}, {"glyph": "\udb84\ude1a", "name": "nf-md-language_fortran"}, {"glyph": "\udb81\ude10", "name": "nf-md-microsoft_visual_studio"}, {"glyph": "\ue624", "name": "nf-seti-julia"}, {"glyph": "\ue673", "name": "nf-seti-makefile"}]}, {"face": "nerd-icons-purple-alt", "hue": 272, "glyphs": [{"glyph": "\ue8e0", "name": "nf-dev-wasm"}, {"glyph": "\udb84\udc62", "name": "nf-md-terraform"}, {"glyph": "\ue6a1", "name": "nf-seti-wasm"}]}, {"face": "nerd-icons-dpurple", "hue": 272, "glyphs": [{"glyph": "\ue738", "name": "nf-dev-java"}, {"glyph": "\uf0e6", "name": "nf-fa-comments_o"}, {"glyph": "\uf01c", "name": "nf-fa-inbox"}, {"glyph": "\uf129", "name": "nf-fa-info"}, {"glyph": "\uf03a", "name": "nf-fa-list"}, {"glyph": "\uf1fc", "name": "nf-fa-paint_brush"}, {"glyph": "\uf002", "name": "nf-fa-search"}, {"glyph": "\uf0ce", "name": "nf-fa-table"}]}, {"face": "nerd-icons-lpink", "hue": 356, "glyphs": [{"glyph": "\ue795", "name": "nf-dev-terminal"}, {"glyph": "\uf461", "name": "nf-oct-bookmark"}, {"glyph": "\ue67a", "name": "nf-seti-ocaml"}]}]}, "2048-game": {"label": "2048-game", "preview": "generic", "faces": [["twentyfortyeight-face-1024", "twentyfortyeight 1024", {"fg": "#000000", "bg": "#ffd700"}], ["twentyfortyeight-face-128", "twentyfortyeight 128", {"fg": "#ffffff", "bg": "#8b0000"}], ["twentyfortyeight-face-16", "twentyfortyeight 16", {"fg": "#000000", "bg": "#ffa500"}], ["twentyfortyeight-face-2", "twentyfortyeight 2", {"fg": "#000000", "bg": "#f0e68c"}], ["twentyfortyeight-face-2048", "twentyfortyeight 2048", {"fg": "#000000", "bg": "#ffff00"}], ["twentyfortyeight-face-256", "twentyfortyeight 256", {"fg": "#ffffff", "bg": "#8b008b"}], ["twentyfortyeight-face-32", "twentyfortyeight 32", {"fg": "#000000", "bg": "#ff4500"}], ["twentyfortyeight-face-4", "twentyfortyeight 4", {"fg": "#000000", "bg": "#deb887"}], ["twentyfortyeight-face-512", "twentyfortyeight 512", {"fg": "#000000", "bg": "#ff00ff"}], ["twentyfortyeight-face-64", "twentyfortyeight 64", {"fg": "#ffffff", "bg": "#b22222"}], ["twentyfortyeight-face-8", "twentyfortyeight 8", {"fg": "#000000", "bg": "#cd8500"}]]}, "alert": {"label": "alert", "preview": "alert", "faces": [["alert-high-face", "high", {"fg": "#ff8c00", "weight": "bold"}], ["alert-low-face", "low", {"fg": "#00008b"}], ["alert-moderate-face", "moderate", {"fg": "#ffd700", "weight": "bold"}], ["alert-normal-face", "normal", {}], ["alert-trivial-face", "trivial", {"fg": "#9400d3"}], ["alert-urgent-face", "urgent", {"fg": "#ff0000", "weight": "bold"}]]}, "all-the-icons": {"label": "all-the-icons \u00b7 not loaded", "preview": "alltheicons", "unloaded": true, "hover": "Retired from this config; its faces are pinned so ecosystem themes still cover it. The live preview is the only place its theming can be seen.", "faces": [["all-the-icons-blue", "blue", {"fg": "#6a9fb5"}], ["all-the-icons-blue-alt", "blue alt", {"fg": "#2188b6"}], ["all-the-icons-cyan", "cyan", {"fg": "#75b5aa"}], ["all-the-icons-cyan-alt", "cyan alt", {"fg": "#0595bd"}], ["all-the-icons-dblue", "dblue", {"fg": "#446674"}], ["all-the-icons-dcyan", "dcyan", {"fg": "#48746d"}], ["all-the-icons-dgreen", "dgreen", {"fg": "#6d8143"}], ["all-the-icons-dmaroon", "dmaroon", {"fg": "#72584b"}], ["all-the-icons-dorange", "dorange", {"fg": "#915b2d"}], ["all-the-icons-dpink", "dpink", {"fg": "#7e5d5f"}], ["all-the-icons-dpurple", "dpurple", {"fg": "#694863"}], ["all-the-icons-dred", "dred", {"fg": "#843031"}], ["all-the-icons-dsilver", "dsilver", {"fg": "#838484"}], ["all-the-icons-dyellow", "dyellow", {"fg": "#b48d56"}], ["all-the-icons-green", "green", {"fg": "#90a959"}], ["all-the-icons-lblue", "lblue", {"fg": "#677174"}], ["all-the-icons-lcyan", "lcyan", {"fg": "#2c7d6e"}], ["all-the-icons-lgreen", "lgreen", {"fg": "#3d6837"}], ["all-the-icons-lmaroon", "lmaroon", {"fg": "#ce7a4e"}], ["all-the-icons-lorange", "lorange", {"fg": "#ffa500"}], ["all-the-icons-lpink", "lpink", {"fg": "#ff505b"}], ["all-the-icons-lpurple", "lpurple", {"fg": "#e69dd6"}], ["all-the-icons-lred", "lred", {"fg": "#eb595a"}], ["all-the-icons-lsilver", "lsilver", {"fg": "#7f7869"}], ["all-the-icons-lyellow", "lyellow", {"fg": "#ff9300"}], ["all-the-icons-maroon", "maroon", {"fg": "#8f5536"}], ["all-the-icons-orange", "orange", {"fg": "#d4843e"}], ["all-the-icons-pink", "pink", {"fg": "#fc505b"}], ["all-the-icons-purple", "purple", {"fg": "#68295b"}], ["all-the-icons-purple-alt", "purple alt", {"fg": "#5d54e1"}], ["all-the-icons-red", "red", {"fg": "#ac4142"}], ["all-the-icons-red-alt", "red alt", {"fg": "#843031"}], ["all-the-icons-silver", "silver", {"fg": "#716e68"}], ["all-the-icons-yellow", "yellow", {"fg": "#ffcc0e"}]]}, "company": {"label": "company", "preview": "company", "faces": [["company-echo", "echo", {}], ["company-echo-common", "echo common", {"fg": "#8b1a1a"}], ["company-preview", "preview", {"inherit": ["company-tooltip-selection", "company-tooltip"]}], ["company-preview-common", "preview common", {"inherit": "company-tooltip-common-selection"}], ["company-preview-search", "preview search", {"inherit": "company-tooltip-common-selection"}], ["company-tooltip", "tooltip", {"fg": "#000000", "bg": "#fff8dc"}], ["company-tooltip-annotation", "tooltip annotation", {"fg": "#8b1a1a"}], ["company-tooltip-annotation-selection", "tooltip annotation selection", {"inherit": "company-tooltip-annotation"}], ["company-tooltip-common", "tooltip common", {"fg": "#8b0000"}], ["company-tooltip-common-selection", "tooltip common selection", {"inherit": "company-tooltip-common"}], ["company-tooltip-deprecated", "tooltip deprecated", {"strike": {"color": null}}], ["company-tooltip-mouse", "tooltip mouse", {"inherit": "highlight"}], ["company-tooltip-quick-access", "tooltip quick access", {"inherit": "company-tooltip-annotation"}], ["company-tooltip-quick-access-selection", "tooltip quick access selection", {"inherit": "company-tooltip-annotation-selection"}], ["company-tooltip-scrollbar-thumb", "tooltip scrollbar thumb", {"bg": "#cd5c5c"}], ["company-tooltip-scrollbar-track", "tooltip scrollbar track", {"bg": "#f5deb3"}], ["company-tooltip-search", "tooltip search", {"inherit": "highlight"}], ["company-tooltip-search-selection", "tooltip search selection", {"inherit": "highlight"}], ["company-tooltip-selection", "tooltip selection", {"bg": "#add8e6"}]]}, "company-box": {"label": "company-box", "preview": "companybox", "faces": [["company-box-annotation", "annotation", {"inherit": "company-tooltip-annotation"}], ["company-box-background", "background", {"inherit": "company-tooltip"}], ["company-box-candidate", "candidate", {"fg": "#000000"}], ["company-box-numbers", "numbers", {"inherit": "company-box-candidate"}], ["company-box-scrollbar", "scrollbar", {"inherit": "company-tooltip-selection"}], ["company-box-selection", "selection", {"extend": true, "inherit": "company-tooltip-selection"}]]}, "consult": {"label": "consult", "preview": "consult", "faces": [["consult-async-failed", "async failed", {"inherit": "error"}], ["consult-async-finished", "async finished", {"inherit": "success"}], ["consult-async-running", "async running", {"inherit": "consult-narrow-indicator"}], ["consult-async-split", "async split", {"inherit": "font-lock-negation-char-face"}], ["consult-bookmark", "bookmark", {"inherit": "font-lock-constant-face"}], ["consult-buffer", "buffer", {}], ["consult-file", "file", {"inherit": "font-lock-function-name-face"}], ["consult-grep-context", "grep context", {"inherit": "shadow"}], ["consult-help", "help", {"inherit": "shadow"}], ["consult-highlight-mark", "highlight mark", {"inherit": "consult-highlight-match"}], ["consult-highlight-match", "highlight match", {"inherit": "match"}], ["consult-key", "key", {"inherit": "font-lock-keyword-face"}], ["consult-line-number", "line number", {"inherit": "consult-key"}], ["consult-line-number-prefix", "line number prefix", {"inherit": "line-number"}], ["consult-line-number-wrapped", "line number wrapped", {"inherit": "warning"}], ["consult-narrow-indicator", "narrow indicator", {"inherit": "warning"}], ["consult-preview-insertion", "preview insertion", {"inherit": "region"}], ["consult-preview-line", "preview line", {"extend": true, "inherit": "consult-preview-insertion"}], ["consult-preview-match", "preview match", {"inherit": "isearch"}], ["consult-separator", "separator", {}]]}, "embark": {"label": "embark", "preview": "embark", "faces": [["embark-collect-annotation", "collect annotation", {"inherit": "completions-annotations"}], ["embark-collect-candidate", "collect candidate", {"inherit": "default"}], ["embark-collect-group-separator", "collect group separator", {"slant": "italic", "strike": {"color": null}, "inherit": "shadow"}], ["embark-collect-group-title", "collect group title", {"slant": "italic", "inherit": "shadow"}], ["embark-keybinding", "keybinding", {"inherit": "success"}], ["embark-keybinding-repeat", "keybinding repeat", {"inherit": "font-lock-builtin-face"}], ["embark-keymap", "keymap", {"slant": "italic"}], ["embark-selected", "selected", {"inherit": "match"}], ["embark-target", "target", {"inherit": "highlight"}], ["embark-verbose-indicator-documentation", "verbose indicator documentation", {"inherit": "completions-annotations"}], ["embark-verbose-indicator-shadowed", "verbose indicator shadowed", {"inherit": "shadow"}], ["embark-verbose-indicator-title", "verbose indicator title", {"weight": "bold", "height": 1.1}]]}, "emms": {"label": "emacs multimedia system (emms)", "preview": "emms", "faces": [["emms-browser-album-face", "browser album", {}], ["emms-browser-albumartist-face", "browser albumartist", {}], ["emms-browser-artist-face", "browser artist", {}], ["emms-browser-composer-face", "browser composer", {}], ["emms-browser-performer-face", "browser performer", {}], ["emms-browser-track-face", "browser track", {}], ["emms-browser-year/genre-face", "browser year/genre", {}], ["emms-metaplaylist-mode-current-face", "metaplaylist mode current", {"fg": "#ffffff", "bg": "#cd0000"}], ["emms-metaplaylist-mode-face", "metaplaylist mode", {"fg": "#cd0000"}], ["emms-playlist-selected-face", "playlist selected", {"fg": "#ffffff", "bg": "#0000cd"}], ["emms-playlist-track-face", "playlist track", {"fg": "#0000ff"}]]}, "flyspell-correct": {"label": "flyspell-correct", "preview": "flyspellcorrect", "faces": [["flyspell-correct-highlight-face", "highlight", {"inherit": "isearch"}]]}, "ghostel": {"label": "ghostel \u00b7 not loaded", "preview": "ghostel", "unloaded": true, "hover": "Retired from this config; its faces are pinned so ecosystem themes still cover it. The live preview is the only place its theming can be seen.", "faces": [["ghostel-color-black", "color black", {"inherit": "ansi-color-black"}], ["ghostel-color-blue", "color blue", {"inherit": "ansi-color-blue"}], ["ghostel-color-bright-black", "color bright black", {"inherit": "ansi-color-bright-black"}], ["ghostel-color-bright-blue", "color bright blue", {"inherit": "ansi-color-bright-blue"}], ["ghostel-color-bright-cyan", "color bright cyan", {"inherit": "ansi-color-bright-cyan"}], ["ghostel-color-bright-green", "color bright green", {"inherit": "ansi-color-bright-green"}], ["ghostel-color-bright-magenta", "color bright magenta", {"inherit": "ansi-color-bright-magenta"}], ["ghostel-color-bright-red", "color bright red", {"inherit": "ansi-color-bright-red"}], ["ghostel-color-bright-white", "color bright white", {"inherit": "ansi-color-bright-white"}], ["ghostel-color-bright-yellow", "color bright yellow", {"inherit": "ansi-color-bright-yellow"}], ["ghostel-color-cyan", "color cyan", {"inherit": "ansi-color-cyan"}], ["ghostel-color-green", "color green", {"inherit": "ansi-color-green"}], ["ghostel-color-magenta", "color magenta", {"inherit": "ansi-color-magenta"}], ["ghostel-color-red", "color red", {"inherit": "ansi-color-red"}], ["ghostel-color-white", "color white", {"inherit": "ansi-color-white"}], ["ghostel-color-yellow", "color yellow", {"inherit": "ansi-color-yellow"}], ["ghostel-default", "default", {"inherit": "default"}], ["ghostel-fake-cursor", "fake cursor", {"box": {"style": "line", "width": 1, "color": null}}], ["ghostel-fake-cursor-box", "fake cursor box", {"inherit": "cursor"}]]}, "highlight-indent-guides": {"label": "highlight-indent-guides", "preview": "indentguides", "faces": [["highlight-indent-guides-character-face", "character", {}], ["highlight-indent-guides-even-face", "even", {}], ["highlight-indent-guides-odd-face", "odd", {}], ["highlight-indent-guides-stack-character-face", "stack character", {}], ["highlight-indent-guides-stack-even-face", "stack even", {}], ["highlight-indent-guides-stack-odd-face", "stack odd", {}], ["highlight-indent-guides-top-character-face", "top character", {}], ["highlight-indent-guides-top-even-face", "top even", {}], ["highlight-indent-guides-top-odd-face", "top odd", {}]]}, "hl-todo": {"label": "hl-todo", "preview": "hltodo", "faces": [["hl-todo", "hl todo", {"fg": "#cc9393", "weight": "bold"}], ["hl-todo-flymake-type", "flymake type", {"inherit": "font-lock-keyword-face"}]]}, "json-mode": {"label": "json-mode", "preview": "generic", "faces": [["json-mode-object-name-face", "object name", {}]]}, "llama": {"label": "llama", "preview": "generic", "faces": [["llama-##-macro", "## macro", {"inherit": "font-lock-function-call-face"}], ["llama-deleted-argument", "deleted argument", {"box": {"style": "line", "width": 1, "color": "#ff0000"}}], ["llama-llama-macro", "llama macro", {"inherit": "font-lock-keyword-face"}], ["llama-mandatory-argument", "mandatory argument", {"inherit": "font-lock-variable-use-face"}], ["llama-optional-argument", "optional argument", {"inherit": "font-lock-type-face"}]]}, "lv": {"label": "lv", "preview": "generic", "faces": [["lv-separator", "separator", {"bg": "#cccccc"}]]}, "magit-section": {"label": "magit-section", "preview": "magitsection", "faces": [["magit-left-margin", "magit left margin", {"inherit": "default"}], ["magit-section-child-count", "child count", {}], ["magit-section-heading", "heading", {"fg": "#8b6508", "weight": "bold", "extend": true}], ["magit-section-heading-selection", "heading selection", {"fg": "#8b4c39", "extend": true}], ["magit-section-highlight", "highlight", {"bg": "#f2f2f2", "extend": true}], ["magit-section-secondary-heading", "secondary heading", {"weight": "bold", "extend": true}]]}, "malyon": {"label": "malyon", "preview": "generic", "faces": [["malyon-face-bold", "face bold", {"inherit": "bold"}], ["malyon-face-error", "face error", {"inherit": "error"}], ["malyon-face-italic", "face italic", {"inherit": "italic"}], ["malyon-face-plain", "face plain", {"inherit": "default"}], ["malyon-face-reverse", "face reverse", {"inverse": true, "inherit": "default"}]]}, "marginalia": {"label": "marginalia", "preview": "marginalia", "faces": [["marginalia-archive", "archive", {"inherit": "warning"}], ["marginalia-char", "char", {"inherit": "marginalia-key"}], ["marginalia-date", "date", {"inherit": "marginalia-key"}], ["marginalia-documentation", "documentation", {"inherit": "completions-annotations"}], ["marginalia-file-name", "file name", {"inherit": "marginalia-documentation"}], ["marginalia-file-owner", "file owner", {"inherit": "font-lock-preprocessor-face"}], ["marginalia-file-priv-dir", "file priv dir", {"inherit": "font-lock-keyword-face"}], ["marginalia-file-priv-exec", "file priv exec", {"inherit": "font-lock-function-name-face"}], ["marginalia-file-priv-link", "file priv link", {"inherit": "font-lock-keyword-face"}], ["marginalia-file-priv-no", "file priv no", {"inherit": "shadow"}], ["marginalia-file-priv-other", "file priv other", {"inherit": "font-lock-constant-face"}], ["marginalia-file-priv-rare", "file priv rare", {"inherit": "font-lock-variable-name-face"}], ["marginalia-file-priv-read", "file priv read", {"inherit": "font-lock-type-face"}], ["marginalia-file-priv-write", "file priv write", {"inherit": "font-lock-builtin-face"}], ["marginalia-function", "function", {"inherit": "font-lock-function-name-face"}], ["marginalia-installed", "installed", {"inherit": "success"}], ["marginalia-key", "key", {"inherit": "font-lock-keyword-face"}], ["marginalia-lighter", "lighter", {"inherit": "marginalia-size"}], ["marginalia-list", "list", {"inherit": "font-lock-constant-face"}], ["marginalia-mode", "mode", {"inherit": "marginalia-key"}], ["marginalia-modified", "modified", {"inherit": "font-lock-negation-char-face"}], ["marginalia-null", "null", {"inherit": "font-lock-comment-face"}], ["marginalia-number", "number", {"inherit": "font-lock-constant-face"}], ["marginalia-off", "off", {"inherit": "error"}], ["marginalia-on", "on", {"inherit": "success"}], ["marginalia-size", "size", {"inherit": "marginalia-number"}], ["marginalia-string", "string", {"inherit": "font-lock-string-face"}], ["marginalia-symbol", "symbol", {"inherit": "font-lock-type-face"}], ["marginalia-true", "true", {"inherit": "font-lock-builtin-face"}], ["marginalia-type", "type", {"inherit": "marginalia-key"}], ["marginalia-value", "value", {"inherit": "marginalia-key"}], ["marginalia-version", "version", {"inherit": "marginalia-number"}]]}, "markdown-mode": {"label": "markdown-mode", "preview": "markdown", "faces": [["markdown-blockquote-face", "markdown blockquote", {"inherit": "font-lock-doc-face"}], ["markdown-bold-face", "markdown bold", {"inherit": "bold"}], ["markdown-code-face", "markdown code", {"inherit": "fixed-pitch"}], ["markdown-comment-face", "markdown comment", {"inherit": "font-lock-comment-face"}], ["markdown-footnote-marker-face", "markdown footnote marker", {"inherit": "markdown-markup-face"}], ["markdown-footnote-text-face", "markdown footnote text", {"inherit": "font-lock-comment-face"}], ["markdown-gfm-checkbox-face", "markdown gfm checkbox", {"inherit": "font-lock-builtin-face"}], ["markdown-header-delimiter-face", "markdown header delimiter", {"inherit": "markdown-markup-face"}], ["markdown-header-face", "markdown header", {"weight": "bold", "inherit": ["font-lock-function-name-face"]}], ["markdown-header-face-1", "markdown header 1", {"inherit": "markdown-header-face"}], ["markdown-header-face-2", "markdown header 2", {"inherit": "markdown-header-face"}], ["markdown-header-face-3", "markdown header 3", {"inherit": "markdown-header-face"}], ["markdown-header-face-4", "markdown header 4", {"inherit": "markdown-header-face"}], ["markdown-header-face-5", "markdown header 5", {"inherit": "markdown-header-face"}], ["markdown-header-face-6", "markdown header 6", {"inherit": "markdown-header-face"}], ["markdown-header-rule-face", "markdown header rule", {"inherit": "markdown-markup-face"}], ["markdown-highlight-face", "markdown highlight", {"inherit": "highlight"}], ["markdown-highlighting-face", "markdown highlighting", {"fg": "#000000", "bg": "#ffff00"}], ["markdown-hr-face", "markdown hr", {"inherit": "markdown-markup-face"}], ["markdown-html-attr-name-face", "markdown html attr name", {"inherit": "font-lock-variable-name-face"}], ["markdown-html-attr-value-face", "markdown html attr value", {"inherit": "font-lock-string-face"}], ["markdown-html-entity-face", "markdown html entity", {"inherit": "font-lock-variable-name-face"}], ["markdown-html-tag-delimiter-face", "markdown html tag delimiter", {"inherit": "markdown-markup-face"}], ["markdown-html-tag-name-face", "markdown html tag name", {"inherit": "font-lock-type-face"}], ["markdown-inline-code-face", "markdown inline code", {"inherit": ["markdown-code-face", "font-lock-constant-face"]}], ["markdown-italic-face", "markdown italic", {"inherit": "italic"}], ["markdown-language-info-face", "markdown language info", {"inherit": "font-lock-string-face"}], ["markdown-language-keyword-face", "markdown language keyword", {"inherit": "font-lock-type-face"}], ["markdown-line-break-face", "markdown line break", {"underline": {"style": "line", "color": null}, "inherit": "font-lock-constant-face"}], ["markdown-link-face", "markdown link", {"inherit": "link"}], ["markdown-link-title-face", "markdown link title", {"inherit": "font-lock-comment-face"}], ["markdown-list-face", "markdown list", {"inherit": "markdown-markup-face"}], ["markdown-markup-face", "markdown markup", {"inherit": "shadow"}], ["markdown-math-face", "markdown math", {"inherit": "font-lock-string-face"}], ["markdown-metadata-key-face", "markdown metadata key", {"inherit": "font-lock-variable-name-face"}], ["markdown-metadata-value-face", "markdown metadata value", {"inherit": "font-lock-string-face"}], ["markdown-missing-link-face", "markdown missing link", {"inherit": "font-lock-warning-face"}], ["markdown-plain-url-face", "markdown plain url", {"inherit": "markdown-link-face"}], ["markdown-pre-face", "markdown pre", {"inherit": ["markdown-code-face", "font-lock-constant-face"]}], ["markdown-reference-face", "markdown reference", {"inherit": "markdown-markup-face"}], ["markdown-strike-through-face", "markdown strike through", {"strike": {"color": null}}], ["markdown-table-face", "markdown table", {"inherit": ["markdown-code-face"]}], ["markdown-url-face", "markdown url", {"inherit": "font-lock-string-face"}]]}, "nerd-icons-completion": {"label": "nerd-icons-completion", "preview": "nerdiconscompletion", "faces": [["nerd-icons-completion-dir-face", "dir", {}]]}, "orderless": {"label": "orderless", "preview": "orderless", "faces": [["orderless-match-face-0", "match 0", {"fg": "#223fbf", "weight": "bold"}], ["orderless-match-face-1", "match 1", {"fg": "#8f0075", "weight": "bold"}], ["orderless-match-face-2", "match 2", {"fg": "#145a00", "weight": "bold"}], ["orderless-match-face-3", "match 3", {"fg": "#804000", "weight": "bold"}]]}, "org-roam": {"label": "org-roam", "preview": "orgroam", "faces": [["org-roam-dailies-calendar-note", "dailies calendar note", {"underline": {"style": "line", "color": null}, "inherit": ["org-link"]}], ["org-roam-dim", "dim", {"fg": "#999999"}], ["org-roam-header-line", "header line", {"fg": "#8b6508", "weight": "bold", "extend": true}], ["org-roam-olp", "olp", {"fg": "#999999"}], ["org-roam-preview-heading", "preview heading", {"fg": "#4d4d4d", "bg": "#cccccc", "extend": true}], ["org-roam-preview-heading-highlight", "preview heading highlight", {"fg": "#4d4d4d", "bg": "#bfbfbf", "extend": true}], ["org-roam-preview-heading-selection", "preview heading selection", {"fg": "#8b4c39", "extend": true, "inherit": "org-roam-preview-heading-highlight"}], ["org-roam-preview-region", "preview region", {"inherit": "bold"}], ["org-roam-title", "title", {"weight": "bold"}]]}, "org-superstar": {"label": "org-superstar", "preview": "orgsuperstar", "faces": [["org-superstar-first", "first", {"inherit": "org-warning"}], ["org-superstar-header-bullet", "header bullet", {}], ["org-superstar-item", "item", {"inherit": "default"}], ["org-superstar-leading", "leading", {"fg": "#bebebe", "inherit": "default"}]]}, "prescient": {"label": "prescient", "preview": "prescient", "faces": [["prescient-primary-highlight", "primary highlight", {"weight": "bold"}], ["prescient-secondary-highlight", "secondary highlight", {"underline": {"style": "line", "color": null}, "inherit": "prescient-primary-highlight"}]]}, "rainbow-delimiters": {"label": "rainbow-delimiters", "preview": "rainbowdelims", "faces": [["rainbow-delimiters-base-error-face", "base error", {"fg": "#88090b", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-base-face", "base", {"inherit": "unspecified"}], ["rainbow-delimiters-depth-1-face", "depth 1", {"fg": "#707183", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-depth-2-face", "depth 2", {"fg": "#7388d6", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-depth-3-face", "depth 3", {"fg": "#909183", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-depth-4-face", "depth 4", {"fg": "#709870", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-depth-5-face", "depth 5", {"fg": "#907373", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-depth-6-face", "depth 6", {"fg": "#6276ba", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-depth-7-face", "depth 7", {"fg": "#858580", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-depth-8-face", "depth 8", {"fg": "#80a880", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-depth-9-face", "depth 9", {"fg": "#887070", "inherit": "rainbow-delimiters-base-face"}], ["rainbow-delimiters-mismatched-face", "mismatched", {"inherit": "rainbow-delimiters-unmatched-face"}], ["rainbow-delimiters-unmatched-face", "unmatched", {"inherit": "rainbow-delimiters-base-error-face"}]]}, "symbol-overlay": {"label": "symbol-overlay", "preview": "symboloverlay", "faces": [["symbol-overlay-default-face", "default", {"inherit": "highlight"}], ["symbol-overlay-face-1", "face 1", {"fg": "#000000", "bg": "#1e90ff"}], ["symbol-overlay-face-2", "face 2", {"fg": "#000000", "bg": "#ff69b4"}], ["symbol-overlay-face-3", "face 3", {"fg": "#000000", "bg": "#ffff00"}], ["symbol-overlay-face-4", "face 4", {"fg": "#000000", "bg": "#da70d6"}], ["symbol-overlay-face-5", "face 5", {"fg": "#000000", "bg": "#ff0000"}], ["symbol-overlay-face-6", "face 6", {"fg": "#000000", "bg": "#fa8072"}], ["symbol-overlay-face-7", "face 7", {"fg": "#000000", "bg": "#00ff7f"}], ["symbol-overlay-face-8", "face 8", {"fg": "#000000", "bg": "#40e0d0"}]]}, "tmr": {"label": "tmr", "preview": "tmr", "faces": [["tmr-description", "description", {"inherit": "bold"}], ["tmr-duration", "duration", {}], ["tmr-end-time", "end time", {"inherit": "error"}], ["tmr-finished", "finished", {"inherit": "error"}], ["tmr-is-acknowledged", "is acknowledged", {"inherit": "success"}], ["tmr-must-be-acknowledged", "must be acknowledged", {"inherit": "warning"}], ["tmr-start-time", "start time", {"inherit": "success"}], ["tmr-tabulated-acknowledgement", "tabulated acknowledgement", {"inherit": "bold"}], ["tmr-tabulated-description", "tabulated description", {"inherit": "font-lock-doc-face"}], ["tmr-tabulated-end-time", "tabulated end time", {"fg": "#800040"}], ["tmr-tabulated-remaining-time", "tabulated remaining time", {"fg": "#603f00"}], ["tmr-tabulated-start-time", "tabulated start time", {"fg": "#004476"}]]}, "transient": {"label": "transient", "preview": "transient", "faces": [["transient-active-infix", "active infix", {"inherit": "highlight"}], ["transient-argument", "argument", {"weight": "bold", "inherit": "font-lock-string-face"}], ["transient-delimiter", "delimiter", {"inherit": "shadow"}], ["transient-disabled-suffix", "disabled suffix", {"fg": "#000000", "bg": "#ff0000", "weight": "bold"}], ["transient-enabled-suffix", "enabled suffix", {"fg": "#000000", "bg": "#00ff00", "weight": "bold"}], ["transient-heading", "heading", {"inherit": "font-lock-keyword-face"}], ["transient-higher-level", "higher level", {"box": {"style": "line", "width": 1, "color": "#999999"}}], ["transient-inactive-argument", "inactive argument", {"inherit": "shadow"}], ["transient-inactive-value", "inactive value", {"inherit": "shadow"}], ["transient-inapt-argument", "inapt argument", {"weight": "bold", "inherit": "shadow"}], ["transient-inapt-suffix", "inapt suffix", {"slant": "italic", "inherit": "shadow"}], ["transient-key", "key", {"inherit": "font-lock-builtin-face"}], ["transient-key-exit", "key exit", {"fg": "#aa2222", "inherit": "transient-key"}], ["transient-key-noop", "key noop", {"fg": "#cccccc", "inherit": "transient-key"}], ["transient-key-recurse", "key recurse", {"fg": "#2266ff", "inherit": "transient-key"}], ["transient-key-return", "key return", {"fg": "#aaaa11", "inherit": "transient-key"}], ["transient-key-stack", "key stack", {"fg": "#dd4488", "inherit": "transient-key"}], ["transient-key-stay", "key stay", {"fg": "#22aa22", "inherit": "transient-key"}], ["transient-mismatched-key", "mismatched key", {"box": {"style": "line", "width": 1, "color": "#ff00ff"}}], ["transient-nonstandard-key", "nonstandard key", {"box": {"style": "line", "width": 1, "color": "#00ffff"}}], ["transient-unreachable", "unreachable", {"inherit": "shadow"}], ["transient-unreachable-key", "unreachable key", {"inherit": ["shadow", "transient-key"]}], ["transient-value", "value", {"weight": "bold", "inherit": "font-lock-string-face"}]]}, "vertico": {"label": "vertico", "preview": "vertico", "faces": [["vertico-current", "current", {"extend": true, "inherit": "highlight"}], ["vertico-group-separator", "group separator", {"strike": {"color": null}, "inherit": "vertico-group-title"}], ["vertico-group-title", "group title", {"slant": "italic", "inherit": "shadow"}], ["vertico-multiline", "multiline", {"inherit": "shadow"}]]}, "web-mode": {"label": "web-mode", "preview": "webmode", "faces": [["web-mode-annotation-face", "annotation", {"inherit": "web-mode-comment-face"}], ["web-mode-annotation-html-face", "annotation html", {"slant": "italic", "inherit": "web-mode-annotation-face"}], ["web-mode-annotation-tag-face", "annotation tag", {"underline": {"style": "line", "color": null}, "inherit": "web-mode-annotation-face"}], ["web-mode-annotation-type-face", "annotation type", {"weight": "bold", "inherit": "web-mode-annotation-face"}], ["web-mode-annotation-value-face", "annotation value", {"slant": "italic", "inherit": "web-mode-annotation-face"}], ["web-mode-block-attr-name-face", "block attr name", {"fg": "#8fbc8f"}], ["web-mode-block-attr-value-face", "block attr value", {"fg": "#5f9ea0"}], ["web-mode-block-comment-face", "block comment", {"inherit": "web-mode-comment-face"}], ["web-mode-block-control-face", "block control", {"inherit": "font-lock-preprocessor-face"}], ["web-mode-block-delimiter-face", "block delimiter", {"inherit": "font-lock-preprocessor-face"}], ["web-mode-block-face", "block", {"bg": "#ffffe0"}], ["web-mode-block-string-face", "block string", {"inherit": "web-mode-string-face"}], ["web-mode-bold-face", "bold", {"weight": "bold"}], ["web-mode-builtin-face", "builtin", {"inherit": "font-lock-builtin-face"}], ["web-mode-comment-face", "comment", {"inherit": "font-lock-comment-face"}], ["web-mode-comment-keyword-face", "comment keyword", {"weight": "bold"}], ["web-mode-constant-face", "constant", {"inherit": "font-lock-constant-face"}], ["web-mode-css-at-rule-face", "css at rule", {"inherit": "font-lock-constant-face"}], ["web-mode-css-color-face", "css color", {"inherit": "font-lock-builtin-face"}], ["web-mode-css-comment-face", "css comment", {"inherit": "web-mode-comment-face"}], ["web-mode-css-function-face", "css function", {"inherit": "font-lock-builtin-face"}], ["web-mode-css-priority-face", "css priority", {"inherit": "font-lock-builtin-face"}], ["web-mode-css-property-name-face", "css property name", {"inherit": "font-lock-variable-name-face"}], ["web-mode-css-pseudo-class-face", "css pseudo class", {"inherit": "font-lock-builtin-face"}], ["web-mode-css-selector-class-face", "css selector class", {"inherit": "font-lock-keyword-face"}], ["web-mode-css-selector-face", "css selector", {"inherit": "font-lock-keyword-face"}], ["web-mode-css-selector-tag-face", "css selector tag", {"inherit": "font-lock-keyword-face"}], ["web-mode-css-string-face", "css string", {"inherit": "web-mode-string-face"}], ["web-mode-css-variable-face", "css variable", {"slant": "italic", "inherit": "web-mode-variable-name-face"}], ["web-mode-current-column-highlight-face", "current column highlight", {"bg": "#3e3c36"}], ["web-mode-current-element-highlight-face", "current element highlight", {"fg": "#ffffff", "bg": "#000000"}], ["web-mode-doctype-face", "doctype", {"fg": "#bebebe"}], ["web-mode-error-face", "error", {"bg": "#ff0000"}], ["web-mode-filter-face", "filter", {"inherit": "font-lock-function-name-face"}], ["web-mode-folded-face", "folded", {"underline": {"style": "line", "color": null}}], ["web-mode-function-call-face", "function call", {"inherit": "font-lock-function-name-face"}], ["web-mode-function-name-face", "function name", {"inherit": "font-lock-function-name-face"}], ["web-mode-html-attr-custom-face", "html attr custom", {"inherit": "web-mode-html-attr-name-face"}], ["web-mode-html-attr-engine-face", "html attr engine", {"inherit": "web-mode-block-delimiter-face"}], ["web-mode-html-attr-equal-face", "html attr equal", {"inherit": "web-mode-html-attr-name-face"}], ["web-mode-html-attr-name-face", "html attr name", {"fg": "#8b8989"}], ["web-mode-html-attr-value-face", "html attr value", {"inherit": "font-lock-string-face"}], ["web-mode-html-entity-face", "html entity", {"slant": "italic"}], ["web-mode-html-tag-bracket-face", "html tag bracket", {"fg": "#242424"}], ["web-mode-html-tag-custom-face", "html tag custom", {"inherit": "web-mode-html-tag-face"}], ["web-mode-html-tag-face", "html tag", {"fg": "#8b8989"}], ["web-mode-html-tag-namespaced-face", "html tag namespaced", {"inherit": "web-mode-block-control-face"}], ["web-mode-html-tag-unclosed-face", "html tag unclosed", {"underline": {"style": "line", "color": null}, "inherit": "web-mode-html-tag-face"}], ["web-mode-inlay-face", "inlay", {"bg": "#ffffe0"}], ["web-mode-interpolate-color1-face", "interpolate color1", {"inherit": "web-mode-string-face"}], ["web-mode-interpolate-color2-face", "interpolate color2", {"inherit": "web-mode-string-face"}], ["web-mode-interpolate-color3-face", "interpolate color3", {"inherit": "web-mode-string-face"}], ["web-mode-interpolate-color4-face", "interpolate color4", {"inherit": "web-mode-string-face"}], ["web-mode-italic-face", "italic", {"slant": "italic"}], ["web-mode-javascript-comment-face", "javascript comment", {"inherit": "web-mode-comment-face"}], ["web-mode-javascript-string-face", "javascript string", {"inherit": "web-mode-string-face"}], ["web-mode-json-comment-face", "json comment", {"inherit": "web-mode-comment-face"}], ["web-mode-json-context-face", "json context", {"fg": "#cd69c9"}], ["web-mode-json-key-face", "json key", {"fg": "#dda0dd"}], ["web-mode-json-string-face", "json string", {"inherit": "web-mode-string-face"}], ["web-mode-jsx-depth-1-face", "jsx depth 1", {"bg": "#000053"}], ["web-mode-jsx-depth-2-face", "jsx depth 2", {"bg": "#001970"}], ["web-mode-jsx-depth-3-face", "jsx depth 3", {"bg": "#002984"}], ["web-mode-jsx-depth-4-face", "jsx depth 4", {"bg": "#49599a"}], ["web-mode-jsx-depth-5-face", "jsx depth 5", {"bg": "#9499b7"}], ["web-mode-keyword-face", "keyword", {"inherit": "font-lock-keyword-face"}], ["web-mode-param-name-face", "param name", {"fg": "#cdc9c9"}], ["web-mode-part-comment-face", "part comment", {"inherit": "web-mode-comment-face"}], ["web-mode-part-face", "part", {"inherit": "web-mode-block-face"}], ["web-mode-part-string-face", "part string", {"inherit": "web-mode-string-face"}], ["web-mode-preprocessor-face", "preprocessor", {"inherit": "font-lock-preprocessor-face"}], ["web-mode-script-face", "script", {"inherit": "web-mode-part-face"}], ["web-mode-sql-keyword-face", "sql keyword", {"weight": "bold", "slant": "italic"}], ["web-mode-string-face", "string", {"inherit": "font-lock-string-face"}], ["web-mode-style-face", "style", {"inherit": "web-mode-part-face"}], ["web-mode-symbol-face", "symbol", {"fg": "#eeb422"}], ["web-mode-type-face", "type", {"inherit": "font-lock-type-face"}], ["web-mode-underline-face", "underline", {"underline": {"style": "line", "color": null}}], ["web-mode-variable-name-face", "variable name", {"inherit": "font-lock-variable-name-face"}], ["web-mode-warning-face", "warning", {"inherit": "font-lock-warning-face"}], ["web-mode-whitespace-face", "whitespace", {"bg": "#68228b"}]]}, "wttrin": {"label": "wttrin", "preview": "wttrin", "faces": [["wttrin-instructions", "instructions", {}], ["wttrin-key", "key", {}], ["wttrin-mode-line-stale", "mode line stale", {}], ["wttrin-staleness-header", "staleness header", {}]]}, "yasnippet": {"label": "yasnippet", "preview": "yasnippet", "faces": [["yas--field-debug-face", "yas field debug", {}], ["yas-field-highlight-face", "yas field highlight", {"inherit": "region"}]]}};
const COLOR_NAMES=[["alice-blue", "#f0f8ff"], ["antique-white", "#faebd7"], ["aquamarine", "#7fffd4"], ["azure", "#f0ffff"], ["beige", "#f5f5dc"], ["bisque", "#ffe4c4"], ["black", "#000000"], ["blanched-almond", "#ffebcd"], ["blue", "#0000ff"], ["blue-violet", "#8a2be2"], ["brown", "#a52a2a"], ["burlywood", "#deb887"], ["cadet-blue", "#5f9ea0"], ["chartreuse", "#7fff00"], ["chocolate", "#d2691e"], ["coral", "#ff7f50"], ["cornflower-blue", "#6495ed"], ["cornsilk", "#fff8dc"], ["cyan", "#00ffff"], ["dark-blue", "#00008b"], ["dark-cyan", "#008b8b"], ["dark-goldenrod", "#b8860b"], ["dark-green", "#006400"], ["dark-grey", "#a9a9a9"], ["dark-khaki", "#bdb76b"], ["dark-magenta", "#8b008b"], ["dark-olive", "#556b2f"], ["dark-orange", "#ff8c00"], ["dark-orchid", "#9932cc"], ["dark-red", "#8b0000"], ["dark-salmon", "#e9967a"], ["dark-sea", "#8fbc8f"], ["dark-slate", "#2f4f4f"], ["dark-slate", "#483d8b"], ["dark-turquoise", "#00ced1"], ["dark-violet", "#9400d3"], ["deep-pink", "#ff1493"], ["deep-sky", "#00bfff"], ["dim-gray", "#696969"], ["dodger-blue", "#1e90ff"], ["firebrick", "#b22222"], ["floral-white", "#fffaf0"], ["forest-green", "#228b22"], ["gainsboro", "#dcdcdc"], ["ghost-white", "#f8f8ff"], ["gold", "#ffd700"], ["goldenrod", "#daa520"], ["gray", "#bebebe"], ["green", "#00ff00"], ["green-yellow", "#adff2f"], ["honeydew", "#f0fff0"], ["hot-pink", "#ff69b4"], ["indian-red", "#cd5c5c"], ["ivory", "#fffff0"], ["khaki", "#f0e68c"], ["lavender", "#e6e6fa"], ["lavender-blush", "#fff0f5"], ["lawn-green", "#7cfc00"], ["lemon-chiffon", "#fffacd"], ["light-blue", "#add8e6"], ["light-coral", "#f08080"], ["light-cyan", "#e0ffff"], ["light-goldenrod", "#eedd82"], ["light-goldenrod", "#fafad2"], ["light-green", "#90ee90"], ["light-grey", "#d3d3d3"], ["light-pink", "#ffb6c1"], ["light-salmon", "#ffa07a"], ["light-sea", "#20b2aa"], ["light-sky", "#87cefa"], ["light-slate", "#778899"], ["light-slate", "#8470ff"], ["light-steel", "#b0c4de"], ["light-yellow", "#ffffe0"], ["lime-green", "#32cd32"], ["linen", "#faf0e6"], ["magenta", "#ff00ff"], ["maroon", "#b03060"], ["medium-aquamarine", "#66cdaa"], ["medium-blue", "#0000cd"], ["medium-orchid", "#ba55d3"], ["medium-purple", "#9370db"], ["medium-sea", "#3cb371"], ["medium-slate", "#7b68ee"], ["medium-spring", "#00fa9a"], ["medium-turquoise", "#48d1cc"], ["medium-violet", "#c71585"], ["midnight-blue", "#191970"], ["mint-cream", "#f5fffa"], ["misty-rose", "#ffe4e1"], ["moccasin", "#ffe4b5"], ["navajo-white", "#ffdead"], ["navy", "#000080"], ["old-lace", "#fdf5e6"], ["olive-drab", "#6b8e23"], ["orange", "#ffa500"], ["orange-red", "#ff4500"], ["orchid", "#da70d6"], ["pale-goldenrod", "#eee8aa"], ["pale-green", "#98fb98"], ["pale-turquoise", "#afeeee"], ["pale-violet", "#db7093"], ["papaya-whip", "#ffefd5"], ["peach-puff", "#ffdab9"], ["peru", "#cd853f"], ["pink", "#ffc0cb"], ["plum", "#dda0dd"], ["powder-blue", "#b0e0e6"], ["purple", "#a020f0"], ["red", "#ff0000"], ["rosy-brown", "#bc8f8f"], ["royal-blue", "#4169e1"], ["saddle-brown", "#8b4513"], ["salmon", "#fa8072"], ["sandy-brown", "#f4a460"], ["sea-green", "#2e8b57"], ["seashell", "#fff5ee"], ["sienna", "#a0522d"], ["sky-blue", "#87ceeb"], ["slate-blue", "#6a5acd"], ["slate-gray", "#708090"], ["snow", "#fffafa"], ["spring-green", "#00ff7f"], ["steel-blue", "#4682b4"], ["tan", "#d2b48c"], ["thistle", "#d8bfd8"], ["tomato", "#ff6347"], ["turquoise", "#40e0d0"], ["violet", "#ee82ee"], ["violet-red", "#d02090"], ["wheat", "#f5deb3"], ["white", "#ffffff"], ["white-smoke", "#f5f5f5"], ["yellow", "#ffff00"], ["yellow-green", "#9acd32"]];
const FACE_DOCS={"flyspell-duplicate": "Flyspell face for words that appear twice in a row.", "flyspell-incorrect": "Flyspell face for misspelled words.", "hl-line": "Default face for highlighting the current line in Hl-Line mode.", "ghostel-default": "Base face used to derive ghostel terminal default fg/bg colors.", "ghostel-fake-cursor-box": "Face for the solid hint cursor drawn for box-style cursors.", "ghostel-fake-cursor": "Face for the hollow hint cursor drawn in copy and Emacs modes.", "ghostel-color-bright-white": "Face used to render bright white color code.", "ghostel-color-bright-cyan": "Face used to render bright cyan color code.", "ghostel-color-bright-magenta": "Face used to render bright magenta color code.", "ghostel-color-bright-blue": "Face used to render bright blue color code.", "ghostel-color-bright-yellow": "Face used to render bright yellow color code.", "ghostel-color-bright-green": "Face used to render bright green color code.", "ghostel-color-bright-red": "Face used to render bright red color code.", "ghostel-color-bright-black": "Face used to render bright black color code.", "ghostel-color-white": "Face used to render white color code.", "ghostel-color-cyan": "Face used to render cyan color code.", "ghostel-color-magenta": "Face used to render magenta color code.", "ghostel-color-blue": "Face used to render blue color code.", "ghostel-color-yellow": "Face used to render yellow color code.", "ghostel-color-green": "Face used to render green color code.", "ghostel-color-red": "Face used to render red color code.", "ghostel-color-black": "Face used to render black color code.", "apropos-misc-button": "Button face indicating a miscellaneous object type in Apropos.", "apropos-user-option-button": "Button face indicating a user option in Apropos.", "apropos-variable-button": "Button face indicating a variable in Apropos.", "apropos-function-button": "Button face indicating a function, macro, or command in Apropos.", "apropos-button": "Face for buttons that indicate a face in Apropos.", "apropos-property": "Face for property name in Apropos output, or nil for none.", "apropos-keybinding": "Face for lists of keybinding in Apropos output.", "apropos-symbol": "Face for the symbol name in Apropos output.", "hl-todo-flymake-type": "Face used for the Flymake diagnostics type \u2018hl-todo-flymake\u2019.", "hl-todo": "Base face used to highlight TODO and similar keywords.", "org-roam-dailies-calendar-note": "Face for dates with a daily-note in the calendar.", "org-roam-dim": "Face for the dimmer part of the widgets.", "org-roam-preview-region": "Face used by \u2018org-roam-highlight-preview-region-using-face\u2019.", "org-roam-preview-heading-selection": "Face for selected preview headings.", "org-roam-preview-heading-highlight": "Face for current preview headings.", "org-roam-preview-heading": "Face for preview headings.", "org-roam-olp": "Face for the OLP of the node.", "org-roam-title": "Face for Org-roam titles.", "org-roam-header-line": "Face for the \u2018header-line\u2019 in some Org-roam modes.", "malyon-face-reverse": "Face for reverse-video text.", "malyon-face-italic": "Italic face for game text.", "malyon-face-error": "Face for game errors.", "malyon-face-bold": "Bold face for game text.", "malyon-face-plain": "Basic face for game text.", "twentyfortyeight-face-2048": "Face for the tile 2048.", "twentyfortyeight-face-1024": "Face for the tile 1024.", "twentyfortyeight-face-512": "Face for the tile 512.", "twentyfortyeight-face-256": "Face for the tile 256.", "twentyfortyeight-face-128": "Face for the tile 128.", "twentyfortyeight-face-64": "Face for the tile 64.", "twentyfortyeight-face-32": "Face for the tile 32.", "twentyfortyeight-face-16": "Face for the tile 16.", "twentyfortyeight-face-8": "Face for the tile 8.", "twentyfortyeight-face-4": "Face for the tile 4.", "twentyfortyeight-face-2": "Face for the tile 2.", "tmr-mode-line-urgent": "Face for timers that will expire in the next 30 seconds.", "tmr-mode-line-soon": "Face for timers that will expire in the next 2 minutes.", "tmr-mode-line-active": "Face for active timers in the mode-line.", "tmr-tabulated-description": "Description of timer in the \u2018tmr-tabulated-view\u2019.", "tmr-tabulated-acknowledgement": "Acknowledgement indicator in the \u2018tmr-tabulated-view\u2019.", "tmr-tabulated-paused": "Face for styling the description of a paused timer.", "tmr-tabulated-remaining-time": "Remaining time in the \u2018tmr-tabulated-view\u2019.", "tmr-tabulated-end-time": "End time in the \u2018tmr-tabulated-view\u2019.", "tmr-tabulated-start-time": "Start time in the \u2018tmr-tabulated-view\u2019.", "tmr-paused": "Face for styling the description of a paused timer.", "tmr-finished": "Face for styling the description of a finished timer.", "tmr-must-be-acknowledged": "Face for styling the acknowledgment confirmation.", "tmr-is-acknowledged": "Face for styling the acknowledgment confirmation.", "tmr-end-time": "Face for styling the start time of a timer.", "tmr-start-time": "Face for styling the start time of a timer.", "tmr-description": "Face for styling the description of a timer.", "tmr-duration": "Face for styling the duration of a timer.", "magit-blame-date": "Face used for dates when blaming.", "magit-blame-name": "Face used for author and committer names when blaming.", "magit-blame-hash": "Face used for commit hashes when blaming.", "magit-blame-summary": "Face used for commit summaries when blaming.", "magit-blame-heading": "Face used for blame headings by default when blaming.", "magit-blame-dimmed": "Face used for the blame margin in some cases when blaming.", "magit-blame-margin": "Face used for the blame margin by default when blaming.", "magit-blame-highlight": "Face used for highlighting when blaming.", "magit-reflog-other": "Face for other commands in reflogs.", "magit-reflog-remote": "Face for pull and clone commands in reflogs.", "magit-reflog-cherry-pick": "Face for cherry-pick commands in reflogs.", "magit-reflog-rebase": "Face for rebase commands in reflogs.", "magit-reflog-reset": "Face for reset commands in reflogs.", "magit-reflog-checkout": "Face for checkout commands in reflogs.", "magit-reflog-merge": "Face for merge, checkout and branch commands in reflogs.", "magit-reflog-amend": "Face for amend commands in reflogs.", "magit-reflog-commit": "Face for commit commands in reflogs.", "magit-bisect-bad": "Face for bad bisect revisions.", "magit-bisect-skip": "Face for skipped bisect revisions.", "magit-bisect-good": "Face for good bisect revisions.", "magit-sequence-exec": "Face used in sequence sections.", "magit-sequence-onto": "Face used in sequence sections.", "magit-sequence-done": "Face used in sequence sections.", "magit-sequence-drop": "Face used in sequence sections.", "magit-sequence-head": "Face used in sequence sections.", "magit-sequence-part": "Face used in sequence sections.", "magit-sequence-stop": "Face used in sequence sections.", "magit-sequence-pick": "Face used in sequence sections.", "magit-filename": "Face for filenames.", "magit-cherry-equivalent": "Face for equivalent cherry commits.", "magit-cherry-unmatched": "Face for unmatched cherry commits.", "magit-signature-error": "Face for signatures that cannot be checked (e.g., missing key).", "magit-signature-revoked": "Face for signatures made by a revoked key.", "magit-signature-expired-key": "Face for signatures made by an expired key.", "magit-signature-expired": "Face for signatures that have expired.", "magit-signature-untrusted": "Face for good untrusted signatures.", "magit-signature-bad": "Face for bad signatures.", "magit-signature-good": "Face for good signatures.", "magit-keyword-squash": "Face for squash! and similar keywords in commit messages.", "magit-keyword": "Face for parts of commit messages inside brackets.", "magit-refname-pullreq": "Face for pullreq refnames.", "magit-refname-wip": "Face for wip refnames.", "magit-refname-stash": "Face for stash refnames.", "magit-refname": "Face for refnames without a dedicated face.", "magit-head": "Face for the symbolic ref \u2018HEAD\u2019.", "magit-branch-warning": "Face for warning about (missing) branch.", "magit-branch-upstream": "Face for upstream branch.", "magit-branch-current": "Face for current branch.", "magit-branch-local": "Face for local branches.", "magit-branch-remote-head": "Face for current branch.", "magit-branch-remote": "Face for remote branch head labels shown in log buffer.", "magit-tag": "Face for tag labels shown in log buffer.", "magit-hash": "Face for the commit object name in the log output.", "magit-dimmed": "Face for text that shouldn\u2019t stand out.", "magit-header-line-key": "Face for keys in the \u2018header-line\u2019.", "magit-header-line": "Face for the \u2018header-line\u2019 in some Magit modes.", "magit-header-line-log-select": "Face for the \u2018header-line\u2019 in \u2018magit-log-select-mode\u2019.", "magit-log-date": "Face for the date part of the log output.", "magit-log-author": "Face for the author part of the log output.", "magit-log-graph": "Face for the graph part of the log output.", "magit-diffstat-removed": "Face for removal indicator in diffstat.", "magit-diffstat-added": "Face for addition indicator in diffstat.", "magit-diff-whitespace-warning": "Face for highlighting whitespace errors added lines.", "magit-diff-context-highlight": "Face for lines in the current context in a diff.", "magit-diff-their-highlight": "Face for lines in a diff for their side in a conflict.", "magit-diff-base-highlight": "Face for lines in a diff for the base side in a conflict.", "magit-diff-our-highlight": "Face for lines in a diff for our side in a conflict.", "magit-diff-removed-highlight": "Face for lines in a diff that have been removed.", "magit-diff-added-highlight": "Face for lines in a diff that have been added.", "magit-diff-context": "Face for lines in a diff that are unchanged.", "magit-diff-their": "Face for lines in a diff for their side in a conflict.", "magit-diff-base": "Face for lines in a diff for the base side in a conflict.", "magit-diff-our": "Face for lines in a diff for our side in a conflict.", "magit-diff-removed": "Face for lines in a diff that have been removed.", "magit-diff-added": "Face for lines in a diff that have been added.", "magit-diff-conflict-heading": "Face for conflict markers.", "magit-diff-lines-boundary": "Face for boundary of marked lines in diff hunk.", "magit-diff-lines-heading": "Face for diff hunk heading when lines are marked.", "magit-diff-revision-summary-highlight": "Face for highlighted commit message summaries.", "magit-diff-revision-summary": "Face for commit message summaries.", "magit-diff-conflict-heading-highlight": "Face for conflict markers.", "magit-diff-hunk-region": "Face used by \u2018magit-diff-highlight-hunk-region-using-face\u2019.", "magit-diff-hunk-heading-selection": "Face for selected diff hunk headings.", "magit-diff-hunk-heading-highlight": "Face for current diff hunk headings.", "magit-diff-hunk-heading": "Face for diff hunk headings.", "magit-diff-file-heading-selection": "Face for selected diff file headings.", "magit-diff-file-heading-highlight": "Face for current diff file headings.", "magit-diff-file-heading": "Face for diff file headings.", "smerge-refined-added": "Face used for added characters shown by \u2018smerge-refine\u2019.", "smerge-refined-removed": "Face used for removed characters shown by \u2018smerge-refine\u2019.", "smerge-refined-changed": "Face used for char-based changes shown by \u2018smerge-refine\u2019.", "smerge-markers": "Face for the conflict markers.", "smerge-base": "Face for the base code.", "smerge-lower": "Face for the \u2018lower\u2019 version of a conflict.", "smerge-upper": "Face for the \u2018upper\u2019 version of a conflict.", "git-commit-comment-action": "Face used for actions in commit message comments.", "git-commit-comment-file": "Face used for file names in commit message comments.", "git-commit-comment-heading": "Face used for headings in commit message comments.", "git-commit-comment-detached": "Face used for detached \u2018HEAD\u2019 in commit message comments.", "git-commit-comment-branch-remote": "Face used for names of remote branches in commit message comments.", "git-commit-comment-branch-local": "Face used for names of local branches in commit message comments.", "git-commit-trailer-value": "Face used for Git trailer values in commit messages.", "git-commit-trailer-token": "Face used for Git trailer tokens in commit messages.", "git-commit-keyword": "Face used for keywords in commit messages.", "git-commit-nonempty-second-line": "Face used for non-whitespace on the second line of commit messages.", "git-commit-overlong-summary": "Face used for the tail of overlong commit message summaries.", "git-commit-summary": "Face used for the summary in commit messages.", "log-edit-unknown-header": "Face for unknown headers in \u2018log-edit-mode\u2019 buffers.", "log-edit-header": "Face for the headers in \u2018log-edit-mode\u2019 buffers.", "log-edit-headers-separator": "Face for the separator line in \u2018log-edit-mode\u2019 buffers.", "log-edit-summary": "Face for the summary in \u2018log-edit-mode\u2019 buffers.", "change-log-acknowledgment": "Face for highlighting acknowledgments.", "change-log-function": "Face for highlighting items of the form \u2018<....>\u2019.", "change-log-conditionals": "Face for highlighting conditionals of the form \u2018[...]\u2019.", "change-log-list": "Face for highlighting parenthesized lists of functions or variables.", "change-log-file": "Face for highlighting file names.", "change-log-email": "Face for highlighting author email addresses.", "change-log-name": "Face for highlighting author names.", "change-log-date": "Face used to highlight dates in date lines.", "magit-mode-line-process-error": "Face for \u2018mode-line-process\u2019 error status.", "magit-mode-line-process": "Face for \u2018mode-line-process\u2019 status when Git is running for side-effects.", "magit-process-ng": "Face for non-zero exit-status.", "magit-process-ok": "Face for zero exit-status.", "which-func": "Face used to highlight mode line function names.", "magit-left-margin": "Face used for the left margin.", "magit-section-child-count": "Face used for child counts at the end of some section headings.", "magit-section-heading-selection": "Face for selected section headings.", "magit-section-secondary-heading": "Face for section headings of some secondary headings.", "magit-section-heading": "Face for section headings.", "magit-section-highlight": "Face for highlighting the current section.", "llama-deleted-argument": "Face used for deleted arguments \u2018_%1\u2019...\u2018_%9\u2019, \u2018_&1\u2019...\u2018_&9\u2019 and \u2018_&*\u2019.", "llama-optional-argument": "Face used for optional arguments \u2018&1\u2019 through \u2018&9\u2019, \u2018&\u2019 and \u2018&*\u2019.", "llama-mandatory-argument": "Face used for mandatory arguments \u2018%1\u2019 through \u2018%9\u2019 and \u2018%\u2019.", "llama-llama-macro": "Face used for the name of the \u2018llama\u2019 macro.", "llama-##-macro": "Face used for the name of the \u2018##\u2019 macro.", "table-cell": "Face used for table cell contents.", "which-key-docstring-face": "Face for docstrings.", "which-key-special-key-face": "Face for special keys (SPC, TAB, RET).", "which-key-group-description-face": "Face for the key description when it is a group or prefix.", "which-key-highlighted-command-face": "Default face for highlighted command descriptions.", "which-key-local-map-description-face": "Face for the key description when it is found in \u2018current-local-map\u2019.", "which-key-command-description-face": "Face for the key description when it is a command.", "which-key-note-face": "Face for notes or hints occasionally provided.", "which-key-separator-face": "Face for the separator (default separator is an arrow).", "which-key-key-face": "Face for which-key keys.", "org-superstar-first": "Face used to display the first bullet of an inline task.", "org-superstar-ordered-item": "Face used to display ordered list item bullets.", "org-superstar-item": "Face used to display prettified item bullets.", "org-superstar-header-bullet": "Face containing distinguishing features headline bullets.", "org-superstar-leading": "Face used to display prettified leading stars in a headline.", "org-indent": "Face for outline indentation.", "company-box-numbers": "company-box-numbers is an alias for the face `company-tooltip'.", "company-box-scrollbar": "Face used for the scrollbar.", "company-box-background": "company-box-background is an alias for the face `company-tooltip'.", "company-box-selection": "company-box-selection is an alias for the face `company-tooltip-selection'.", "company-box-annotation": "company-box-annotation is an alias for the face `company-tooltip-annotation'.", "company-box-candidate": "company-box-candidate is an alias for the face `company-tooltip'.", "makefile-makepp-perl": "Face to use for additionally highlighting Perl code in Font-Lock mode.", "makefile-shell": "Face to use for additionally highlighting Shell commands in Font-Lock mode.", "makefile-targets": "Face to use for additionally highlighting rule targets in Font-Lock mode.", "makefile-space": "Face to use for highlighting leading spaces in Font-Lock mode.", "grep-heading": "Face of headings when \u2018grep-use-headings\u2019 is non-nil.", "ibuffer-locked-buffer": "Face used for locked buffers in Ibuffer.", "org-drill-hidden-cloze-face": "The face used to hide the contents of cloze phrases.", "org-drill-visible-cloze-hint-face": "The face used to hide the contents of cloze phrases.", "org-drill-visible-cloze-face": "The face used to hide the contents of cloze phrases.", "alert-trivial-face": "Trivial alert face.", "alert-low-face": "Low alert face.", "alert-normal-face": "Normal alert face.", "alert-moderate-face": "Moderate alert face.", "alert-high-face": "High alert face.", "alert-urgent-face": "Urgent alert face.", "org-faces-priority-d-dim": "Dimmed [#D] priority cookie for non-selected windows.", "org-faces-priority-c-dim": "Dimmed [#C] priority cookie for non-selected windows.", "org-faces-priority-b-dim": "Dimmed [#B] priority cookie for non-selected windows.", "org-faces-priority-a-dim": "Dimmed [#A] priority cookie for non-selected windows.", "org-faces-cancelled-dim": "Dimmed CANCELLED keyword for non-selected windows.", "org-faces-done-dim": "Dimmed DONE keyword for non-selected windows.", "org-faces-failed-dim": "Dimmed FAILED keyword for non-selected windows.", "org-faces-delegated-dim": "Dimmed DELEGATED keyword for non-selected windows.", "org-faces-stalled-dim": "Dimmed STALLED keyword for non-selected windows.", "org-faces-verify-dim": "Dimmed VERIFY keyword for non-selected windows.", "org-faces-waiting-dim": "Dimmed WAITING keyword for non-selected windows.", "org-faces-doing-dim": "Dimmed DOING keyword for non-selected windows.", "org-faces-project-dim": "Dimmed PROJECT keyword for non-selected windows.", "org-faces-todo-dim": "Dimmed TODO keyword for non-selected windows.", "org-faces-priority-d": "Face for the [#D] priority cookie.", "org-faces-priority-c": "Face for the [#C] priority cookie.", "org-faces-priority-b": "Face for the [#B] priority cookie.", "org-faces-priority-a": "Face for the [#A] priority cookie.", "org-faces-cancelled": "Face for the CANCELLED keyword.", "org-faces-done": "Face for the DONE keyword.", "org-faces-failed": "Face for the FAILED keyword.", "org-faces-delegated": "Face for the DELEGATED keyword.", "org-faces-stalled": "Face for the STALLED keyword.", "org-faces-verify": "Face for the VERIFY keyword.", "org-faces-waiting": "Face for the WAITING keyword.", "org-faces-doing": "Face for the DOING keyword.", "org-faces-project": "Face for the PROJECT keyword.", "org-faces-todo": "Face for the TODO keyword.", "eww-valid-certificate": "Face for web pages with valid certificates.", "eww-invalid-certificate": "Face for web pages with invalid certificates.", "eww-form-textarea": "Face for eww textarea inputs.", "eww-form-text": "Face for eww text inputs.", "eww-form-select": "Face for eww buffer buttons.", "eww-form-checkbox": "Face for eww buffer buttons.", "eww-form-file": "Face for eww buffer buttons.", "eww-form-submit": "Face for eww buffer buttons.", "gnus-header-content": "Face used for displaying header content.", "gnus-header-name": "Face used for displaying header names.", "gnus-header-newsgroups": "Face used for displaying newsgroups headers.", "gnus-header-subject": "Face used for displaying subject headers.", "gnus-header-from": "Face used for displaying from headers.", "gnus-header": "Base face used for all Gnus header faces.", "gnus-signature": "Face used for highlighting a signature in the article buffer.", "gnus-button": "Face used for highlighting a button in the article buffer.", "gnus-emphasis-highlight-words": "Face used for displaying highlighted words.", "gnus-emphasis-strikethru": "Face used for displaying strike-through text (-word-).", "gnus-emphasis-underline-bold-italic": "Face used for displaying underlined bold italic emphasized text.", "gnus-emphasis-bold-italic": "Face used for displaying bold italic emphasized text (/*word*/).", "gnus-emphasis-underline-italic": "Face used for displaying underlined italic emphasized text (_/word/_).", "gnus-emphasis-underline-bold": "Face used for displaying underlined bold emphasized text (_*word*_).", "gnus-emphasis-underline": "Face used for displaying underlined emphasized text (_word_).", "gnus-emphasis-italic": "Face used for displaying italic emphasized text (/word/).", "gnus-emphasis-bold": "Face used for displaying strong emphasized text (*word*).", "mm-uu-extract": "Face for extracted buffers.", "shr-sliced-image": "Face used for sliced images.", "shr-mark": "Face used for <mark> elements.", "shr-code": "Face used for rendering <code> blocks.", "shr-h6": "Face for <h6> elements.", "shr-h5": "Face for <h5> elements.", "shr-h4": "Face for <h4> elements.", "shr-h3": "Face for <h3> elements.", "shr-h2": "Face for <h2> elements.", "shr-h1": "Face for <h1> elements.", "shr-sup": "Face for <sup> and <sub> elements.", "shr-abbreviation": "Face for <abbr> elements.", "shr-selected-link": "Temporary face for externally visited link elements.", "shr-link": "Face for link elements.", "shr-strike-through": "Face for <s> elements.", "shr-text": "Face used for rendering text.", "message-signature-separator": "Face used for displaying the signature separator.", "message-mml": "Face used for displaying MML.", "message-cited-text-4": "Face used for displaying 4th-level cited text.", "message-cited-text-3": "Face used for displaying 3rd-level cited text.", "message-cited-text-2": "Face used for displaying 2nd-level cited text.", "message-cited-text-1": "Face used for displaying 1st-level cited text.", "message-separator": "Face used for displaying the separator.", "message-header-xheader": "Face used for displaying X-Header headers.", "message-header-name": "Face used for displaying header names.", "message-header-other": "Face used for displaying other headers.", "message-header-newsgroups": "Face used for displaying Newsgroups headers.", "message-header-subject": "Face used for displaying Subject headers.", "message-header-cc": "Face used for displaying Cc headers.", "message-header-to": "Face used for displaying To headers.", "gnus-splash": "Face for the splash screen.", "gnus-summary-low-read": "Face used for low interest read articles.", "gnus-summary-high-read": "Face used for high interest read articles.", "gnus-summary-normal-read": "Face used for normal interest read articles.", "gnus-summary-low-unread": "Face used for low interest unread articles.", "gnus-summary-high-unread": "Face used for high interest unread articles.", "gnus-summary-normal-unread": "Face used for normal interest unread articles.", "gnus-summary-low-undownloaded": "Face used for low interest uncached articles.", "gnus-summary-high-undownloaded": "Face used for high interest uncached articles.", "gnus-summary-normal-undownloaded": "Face used for normal interest uncached articles.", "gnus-summary-low-ancient": "Face used for low interest ancient articles.", "gnus-summary-high-ancient": "Face used for high interest ancient articles.", "gnus-summary-normal-ancient": "Face used for normal interest ancient articles.", "gnus-summary-low-ticked": "Face used for low interest ticked articles.", "gnus-summary-high-ticked": "Face used for high interest ticked articles.", "gnus-summary-normal-ticked": "Face used for normal interest ticked articles.", "gnus-summary-cancelled": "Face used for canceled articles.", "gnus-summary-selected": "Face used for selected articles.", "gnus-group-mail-low": "Low level mailgroup face.", "gnus-group-mail-low-empty": "Low level empty mailgroup face.", "gnus-group-mail-3": "Level 3 mailgroup face.", "gnus-group-mail-3-empty": "Level 3 empty mailgroup face.", "gnus-group-mail-2": "Level 2 mailgroup face.", "gnus-group-mail-2-empty": "Level 2 empty mailgroup face.", "gnus-group-mail-1": "Level 1 mailgroup face.", "gnus-group-mail-1-empty": "Level 1 empty mailgroup face.", "gnus-group-news-low": "Low level newsgroup face.", "gnus-group-news-low-empty": "Low level empty newsgroup face.", "gnus-group-news-6": "Level 6 newsgroup face.", "gnus-group-news-6-empty": "Level 6 empty newsgroup face.", "gnus-group-news-5": "Level 5 newsgroup face.", "gnus-group-news-5-empty": "Level 5 empty newsgroup face.", "gnus-group-news-4": "Level 4 newsgroup face.", "gnus-group-news-4-empty": "Level 4 empty newsgroup face.", "gnus-group-news-3": "Level 3 newsgroup face.", "gnus-group-news-3-empty": "Level 3 empty newsgroup face.", "gnus-group-news-2": "Level 2 newsgroup face.", "gnus-group-news-2-empty": "Level 2 empty newsgroup face.", "gnus-group-news-1": "Level 1 newsgroup face.", "gnus-group-news-1-empty": "Level 1 empty newsgroup face.", "doc-view-svg-face": "Face used for SVG images.", "sh-escaped-newline": "Face used for (non-escaped) backslash at end of a line in Shell-script mode.", "sh-quoted-exec": "Face to show quoted execs like `blabla`.", "sh-heredoc": "Face to show a here-document.", "org-mode-line-clock-overrun": "Face used for clock display for overrun tasks in mode line.", "org-mode-line-clock": "Face used for clock display in mode line.", "org-tag-group": "Face for group tags.", "org-macro": "Face for macros.", "org-latex-and-related": "Face used to highlight LaTeX data, entities and sub/superscript.", "org-agenda-calendar-sexp": "Face used to show events computed from a S-expression.", "org-agenda-calendar-event": "Face used to show events and appointments in the agenda.", "org-agenda-calendar-daterange": "Face used to show entries with a date range in the agenda.", "org-agenda-diary": "Face used for agenda entries that come from the Emacs diary.", "org-agenda-current-time": "Face used to show the current time in the time grid.", "org-time-grid": "Face used for time grids.", "org-agenda-filter-regexp": "Face for regexp(s) in the mode-line when filtering the agenda.", "org-agenda-filter-effort": "Face for effort in the mode-line when filtering the agenda.", "org-agenda-filter-category": "Face for categories in the mode-line when filtering the agenda.", "org-agenda-filter-tags": "Face for tag(s) in the mode-line when filtering the agenda.", "org-agenda-restriction-lock": "Face for showing the agenda restriction lock.", "org-upcoming-distant-deadline": "Face for items scheduled previously, not done, and have a distant deadline.", "org-upcoming-deadline": "Face for items scheduled previously, and not yet done.", "org-imminent-deadline": "Face for current deadlines in the agenda.", "org-scheduled-previously": "Face for items scheduled previously, and not yet done.", "org-agenda-dimmed-todo-face": "Face used to dim blocked tasks in the agenda.", "org-scheduled-today": "Face for items scheduled for a certain day.", "org-scheduled": "Face for items scheduled for a certain day.", "org-agenda-date-weekend": "Face used in agenda for weekend days.", "org-agenda-clocking": "Face marking the current clock item in the agenda.", "org-agenda-date-weekend-today": "Face used in agenda for today during weekends.", "org-agenda-date-today": "Face used in agenda for today.", "org-agenda-date": "Face used in agenda for normal days.", "org-agenda-structure-filter": "Face used for the current type of task filter in the agenda.", "org-agenda-structure-secondary": "Face used for secondary information in agenda block headers.", "org-agenda-structure": "Face used in agenda for captions and dates.", "org-clock-overlay": "Basic face for displaying the secondary selection.", "org-verse": "Face for #+BEGIN_VERSE ... #+END_VERSE blocks.", "org-quote": "Face for #+BEGIN_QUOTE ... #+END_QUOTE blocks.", "org-verbatim": "Face for fixed-with text like code snippets.", "org-inline-src-block": "Face used for inline source blocks as a whole.", "org-block-end-line": "Face used for the line delimiting the end of source blocks.", "org-block-begin-line": "Face used for the line delimiting the begin of source blocks.", "org-block": "Face used for text inside various blocks.", "org-document-info-keyword": "Face for document information keywords.", "org-document-info": "Face for document information such as the author and date.", "org-document-title": "Face for document title, i.e. that which follows the #+TITLE: keyword.", "org-meta-line": "Face for meta lines starting with \"#+\".", "org-code": "Face for fixed-width text like code snippets.", "org-formula": "Face for formulas.", "org-table-header": "Face for table header.", "org-table-row": "Face used to fontify whole table rows (including newlines and indentation).", "org-table": "Face used for tables.", "org-checkbox-statistics-done": "Face used for finished checkbox statistics.", "org-checkbox-statistics-todo": "Face used for unfinished checkbox statistics.", "org-checkbox": "Face for checkboxes.", "org-priority": "Face used for priority cookies.", "org-headline-done": "Face used to indicate that a headline is DONE.", "org-headline-todo": "Face used to indicate that a headline is marked as TODO.", "org-agenda-done": "Face used in agenda, to indicate lines switched to DONE.", "org-done": "Face used for todo keywords that indicate DONE items.", "org-todo": "Face for TODO keywords.", "org-list-dt": "Default face for definition terms in lists.", "org-tag": "Default face for tags.", "org-sexp-date": "Face for diary-like sexp date specifications.", "org-date-selected": "Face for highlighting the calendar day when using \u2018org-read-date\u2019.", "org-date": "Face for date/time stamps.", "org-target": "Face for link targets.", "org-ellipsis": "Face for the ellipsis in folded text.", "org-footnote": "Face for footnotes.", "org-link": "Face for links.", "org-cite-key": "Face for citation keys.", "org-cite": "Face for citations.", "org-archived": "Face for headline with the ARCHIVE tag.", "org-warning": "Face for deadlines and TODO keywords.", "org-agenda-column-dateline": "Face used in agenda column view for datelines with summaries.", "org-column-title": "Face for column display of entry properties.", "org-column": "Face for column display of entry properties.", "org-property-value": "Face used for the value of a property.", "org-drawer": "Face used for drawers.", "org-special-keyword": "Face used for special keywords.", "org-level-8": "Face used for level 8 headlines.", "org-level-7": "Face used for level 7 headlines.", "org-level-6": "Face used for level 6 headlines.", "org-level-5": "Face used for level 5 headlines.", "org-level-4": "Face used for level 4 headlines.", "org-level-3": "Face used for level 3 headlines.", "org-level-2": "Face used for level 2 headlines.", "org-level-1": "Face used for level 1 headlines.", "org-dispatcher-highlight": "Face for highlighted keys in the dispatcher.", "org-hide": "Face used to hide leading stars in headlines.", "org-default": "Face used for default text.", "calendar-month-header": "Face used for month headers in the calendar.", "calendar-weekend-header": "Face used for weekend column headers in the calendar.", "calendar-weekday-header": "Face used for weekday column headers in the calendar.", "holiday": "Face for indicating in the calendar dates that have holidays.", "diary": "Face for highlighting diary entries.", "calendar-today": "Face for indicating today\u2019s date in the calendar.", "lsp-inlay-hint-parameter-face": "Face for inlay parameter hints (e.g. function parameter names at", "lsp-inlay-hint-type-face": "Face for inlay type hints (e.g. inferred variable types).", "lsp-inlay-hint-face": "The face to use for the JavaScript inlays.", "lsp-installation-buffer-face": "Face used for installation buffers still in progress.", "lsp-installation-finished-buffer-face": "Face used for finished installation buffers.", "lsp-signature-face": "Used to display signatures in \u2018imenu\u2019, ....", "lsp-details-face": "Used to display additional information throughout \u2018lsp\u2019.", "lsp-rename-placeholder-face": "Face used to display the rename placeholder in.", "lsp-face-rename": "Face used to highlight the identifier being renamed.", "lsp-signature-highlight-function-argument": "The face to use to highlight function arguments in signatures.", "lsp-signature-posframe": "Background and foreground for \u2018lsp-signature-posframe\u2019.", "lsp-face-highlight-write": "Face used for highlighting symbols being written to.", "lsp-face-highlight-read": "Face used for highlighting symbols being read.", "lsp-face-highlight-textual": "Face used for textual occurrences of symbols.", "diff-refine-added": "Face used for added characters shown by \u2018diff-refine-hunk\u2019.", "diff-refine-removed": "Face used for removed characters shown by \u2018diff-refine-hunk\u2019.", "diff-refine-changed": "Face used for char-based changes shown by \u2018diff-refine-hunk\u2019.", "diff-error": "\u2018diff-mode\u2019 face for error messages from diff.", "diff-nonexistent": "\u2018diff-mode\u2019 face used to highlight nonexistent files in recursive diffs.", "diff-context": "\u2018diff-mode\u2019 face used to highlight context and other side-information.", "diff-function": "\u2018diff-mode\u2019 face used to highlight function names produced by \"diff -p\".", "diff-indicator-changed": "\u2018diff-mode\u2019 face used to highlight indicator of changed lines.", "diff-indicator-added": "\u2018diff-mode\u2019 face used to highlight indicator of added lines (+, >).", "diff-indicator-removed": "\u2018diff-mode\u2019 face used to highlight indicator of removed lines (-, <).", "diff-changed": "\u2018diff-mode\u2019 face used to highlight changed lines.", "diff-changed-unspecified": "\u2018diff-mode\u2019 face used to highlight changed lines.", "diff-added": "\u2018diff-mode\u2019 face used to highlight added lines.", "diff-removed": "\u2018diff-mode\u2019 face used to highlight removed lines.", "diff-hunk-header": "\u2018diff-mode\u2019 face used to highlight hunk header lines.", "diff-index": "\u2018diff-mode\u2019 face used to highlight index header lines.", "diff-file-header": "\u2018diff-mode\u2019 face used to highlight file header lines.", "diff-header": "\u2018diff-mode\u2019 face inherited by hunk and index header faces.", "vc-git-log-edit-summary-max-warning": "Face for Git commit summary lines beyond the maximum length.", "vc-git-log-edit-summary-target-warning": "Face for Git commit summary lines beyond the target length.", "xref-match": "Face used to highlight matches in the xref buffer.", "xref-line-number": "Face for displaying line numbers in the xref buffer.", "xref-file-header": "Face used to highlight file header in the xref buffer.", "edit-indirect-edited-region": "Face used to highlight an indirectly edited region.", "markdown-header-face-6": "Face for level 6 headers.", "markdown-header-face-5": "Face for level 5 headers.", "markdown-header-face-4": "Face for level 4 headers.", "markdown-header-face-3": "Face for level 3 headers.", "markdown-header-face-2": "Face for level 2 headers.", "markdown-header-face-1": "Face for level 1 headers.", "markdown-header-face": "Base face for headers.", "markdown-highlighting-face": "Face for highlighting.", "markdown-html-entity-face": "Face for HTML entities.", "markdown-html-attr-value-face": "Face for HTML attribute values.", "markdown-html-attr-name-face": "Face for HTML attribute names.", "markdown-html-tag-delimiter-face": "Face for HTML tag delimiters.", "markdown-html-tag-name-face": "Face for HTML tag names.", "markdown-hr-face": "Face for horizontal rules.", "markdown-highlight-face": "Face for mouse highlighting.", "markdown-gfm-checkbox-face": "Face for GFM checkboxes.", "markdown-metadata-value-face": "Face for metadata values.", "markdown-metadata-key-face": "Face for metadata keys.", "markdown-math-face": "Face for LaTeX expressions.", "markdown-comment-face": "Face for HTML comments.", "markdown-line-break-face": "Face for hard line breaks.", "markdown-link-title-face": "Face for reference link titles.", "markdown-plain-url-face": "Face for URLs that are also links.", "markdown-url-face": "Face for URLs that are part of markup.", "markdown-footnote-text-face": "Face for footnote text.", "markdown-footnote-marker-face": "Face for footnote markers.", "markdown-reference-face": "Face for link references.", "markdown-missing-link-face": "Face for the link text if the link points to a missing file.", "markdown-link-face": "Face for link text, ie the alias portion of a link.", "markdown-language-info-face": "Face for programming language info strings.", "markdown-language-keyword-face": "Face for programming language identifiers.", "markdown-table-face": "Face for tables.", "markdown-pre-face": "Face for preformatted text.", "markdown-inline-code-face": "Face for inline code.", "markdown-code-face": "Face for inline code, pre blocks, and fenced code blocks.", "markdown-blockquote-face": "Face for blockquote sections.", "markdown-list-face": "Face for list item markers.", "markdown-header-delimiter-face": "Base face for headers hash delimiter.", "markdown-header-rule-face": "Base face for headers rules.", "markdown-markup-face": "Face for markup elements.", "markdown-strike-through-face": "Face for strike-through text.", "markdown-bold-face": "Face for bold text.", "markdown-italic-face": "Face for italic text.", "outline-8": "Level 8.", "outline-7": "Level 7.", "outline-6": "Level 6.", "outline-5": "Level 5.", "outline-4": "Level 4.", "outline-3": "Level 3.", "outline-2": "Level 2.", "outline-1": "Level 1.", "lv-separator": "Face used to draw line between the lv window and the echo area.", "compilation-column-number": "Face for displaying column numbers in compiler messages.", "compilation-line-number": "Face for displaying line numbers in compiler messages.", "compilation-mode-line-exit": "Face for Compilation mode\u2019s \"exit\" mode line indicator.", "compilation-mode-line-run": "Face for Compilation mode\u2019s \"running\" mode line indicator.", "compilation-mode-line-fail": "Face for Compilation mode\u2019s \"error\" mode line indicator.", "compilation-info": "Face used to highlight compiler information.", "compilation-warning": "Face used to highlight compiler warnings.", "compilation-error": "Face used to highlight compiler errors.", "breakpoint-disabled": "Face for disabled breakpoint icon in fringe.", "breakpoint-enabled": "Face for enabled breakpoint icon in fringe.", "gud-highlight-current-line-face": "Face for highlighting the source code line being executed.", "ert-test-result-unexpected": "Face used for unexpected results in the ERT results buffer.", "ert-test-result-expected": "Face used for expected results in the ERT results buffer.", "yas--field-debug-face": "The face used for debugging some overlays normally hidden", "yas-field-highlight-face": "The face used to highlight the currently active field of a snippet", "treesit-explorer-field-name": "Face for field names in tree-sitter explorer.", "treesit-explorer-anonymous-node": "Face for anonymous nodes in tree-sitter explorer.", "dirvish-vc-needs-update-state": "Face used for \u2018needs-update\u2019 vc state in the Dirvish buffer.", "dirvish-vc-locked-state": "Face used for \u2018locked\u2019 vc state in the Dirvish buffer.", "dirvish-vc-conflict-state": "Face used for \u2018conflict\u2019 vc state in the Dirvish buffer.", "dirvish-vc-missing-state": "Face used for \u2018missing\u2019 vc state in the Dirvish buffer.", "dirvish-vc-removed-state": "Face used for \u2018removed\u2019 vc state in the Dirvish buffer.", "dirvish-vc-added-state": "Face used for \u2018added\u2019 vc state in the Dirvish buffer.", "dirvish-vc-edited-state": "Face used for \u2018edited\u2019 vc state in the Dirvish buffer.", "dirvish-git-commit-message-face": "Face for commit message overlays.", "dirvish-vc-unregistered-face": "Face used for \u2018unregistered\u2019 vc state in the Dirvish buffer.", "dirvish-vc-needs-merge-face": "Face used for \u2018needs-merge\u2019 vc state in the Dirvish buffer.", "shell-highlight-undef-alias-face": "Face used for shell command aliases.", "shell-highlight-undef-undefined-face": "Face used for non-existent shell commands.", "shell-highlight-undef-defined-face": "Face used for existing shell commands.", "dirvish-collapse-file-face": "Face used for files in \u2018collapse\u2019 attribute.", "dirvish-collapse-empty-dir-face": "Face used for empty directories in \u2018collapse\u2019 attribute.", "dirvish-collapse-dir-face": "Face used for directories in \u2018collapse\u2019 attribute.", "dirvish-narrow-split": "Face used to highlight punctuation character.", "dirvish-narrow-match-face-3": "Face for matches of components numbered 3 mod 4.", "dirvish-narrow-match-face-2": "Face for matches of components numbered 2 mod 4.", "dirvish-narrow-match-face-1": "Face for matches of components numbered 1 mod 4.", "dirvish-narrow-match-face-0": "Face for matches of components numbered 0 mod 4.", "dirvish-subtree-guide": "Face used for \u2018expanded-state\u2019 attribute.", "dirvish-subtree-state": "Face used for \u2018expanded-state\u2019 attribute.", "dirvish-emerge-group-title": "Face used for emerge group title.", "dirvish-proc-failed": "Face used if asynchronous process has failed.", "dirvish-proc-finished": "Face used if asynchronous process has finished.", "dirvish-proc-running": "Face used if asynchronous process is running.", "dirvish-inactive": "Face used for mode-line segments in unfocused Dirvish windows.", "dirvish-hl-line-inactive": "Face used for Dirvish line highlighting in unfocused Dirvish windows.", "dirvish-hl-line": "Face used for Dirvish line highlighting in focused Dirvish window.", "dashboard-footer-icon-face": "Face used for icon in footer.", "dashboard-footer-face": "Face used for footer text.", "dashboard-no-items-face": "Face used for no items.", "dashboard-items-face": "Face used for items.", "dashboard-heading": "Face used for widget headings.", "dashboard-navigator": "Face used for the navigator.", "dashboard-banner-logo-title": "Face used for the banner title.", "dashboard-text-banner": "Face used for text banners.", "rectangle-preview": "The face to use for the \u2018string-rectangle\u2019 preview.", "transient-mismatched-key": "Face optionally used to highlight keys without a short-argument.", "transient-nonstandard-key": "Face optionally used to highlight keys conflicting with short-argument.", "transient-unreachable-key": "Face used for keys unreachable from the current prefix sequence.", "transient-key-exit": "Face used for keys of suffixes that exit the menu.", "transient-key-stack": "Face used for keys of sub-menus that exit the parent menu.", "transient-key-recurse": "Face used for keys of sub-menus whose suffixes return to the parent menu.", "transient-key-return": "Face used for keys of suffixes that return to the parent menu.", "transient-key-noop": "Face used for keys of suffixes that currently cannot be invoked.", "transient-key-stay": "Face used for keys of suffixes that don\u2019t exit the menu.", "transient-key": "Face used for keys.", "transient-delimiter": "Face used for delimiters and separators.", "transient-higher-level": "Face optionally used to highlight suffixes on higher levels.", "transient-disabled-suffix": "Face used for disabled levels while editing suffix levels.", "transient-enabled-suffix": "Face used for enabled levels while editing suffix levels.", "transient-active-infix": "Face used for the infix for which the value is being read.", "transient-inapt-suffix": "Face used for suffixes that are inapt at this time.", "transient-unreachable": "Face used for suffixes unreachable from the current prefix sequence.", "transient-inactive-value": "Face used for inactive values.", "transient-value": "Face used for values.", "transient-inapt-argument": "Face used for inapt arguments with a (currently ignored) value.", "transient-inactive-argument": "Face used for inactive arguments.", "transient-argument": "Face used for enabled arguments.", "transient-heading": "Face used for headings.", "image-dired-thumb-flagged": "Face for images flagged for deletion in thumbnail buffer.", "image-dired-thumb-mark": "Face for marked images in thumbnail buffer.", "image-dired-thumb-header-image-count": "Face for the image count in the header line of the thumbnail buffer.", "image-dired-thumb-header-file-size": "Face for the file size in the header line of the thumbnail buffer.", "image-dired-thumb-header-directory-name": "Face for the directory name in the header line of the thumbnail buffer.", "image-dired-thumb-header-file-name": "Face for the file name in the header line of the thumbnail buffer.", "erc-keyword-face": "ERC face for your keywords.", "erc-fool-face": "ERC face for fools on the channel.", "erc-pal-face": "ERC face for your pals.", "erc-dangerous-host-face": "ERC face for people on dangerous hosts.", "erc-current-nick-face": "ERC face for occurrences of your current nickname.", "bg:erc-color-face15": "ERC face.", "bg:erc-color-face14": "ERC face.", "bg:erc-color-face13": "ERC face.", "bg:erc-color-face12": "ERC face.", "bg:erc-color-face11": "ERC face.", "bg:erc-color-face10": "ERC face.", "bg:erc-color-face9": "ERC face.", "bg:erc-color-face8": "ERC face.", "bg:erc-color-face7": "ERC face.", "bg:erc-color-face6": "ERC face.", "bg:erc-color-face5": "ERC face.", "bg:erc-color-face4": "ERC face.", "bg:erc-color-face3": "ERC face.", "bg:erc-color-face2": "ERC face.", "bg:erc-color-face1": "ERC face.", "bg:erc-color-face0": "ERC face.", "fg:erc-color-face15": "ERC face.", "fg:erc-color-face14": "ERC face.", "fg:erc-color-face13": "ERC face.", "fg:erc-color-face12": "ERC face.", "fg:erc-color-face11": "ERC face.", "fg:erc-color-face10": "ERC face.", "fg:erc-color-face9": "ERC face.", "fg:erc-color-face8": "ERC face.", "fg:erc-color-face7": "ERC face.", "fg:erc-color-face6": "ERC face.", "fg:erc-color-face5": "ERC face.", "fg:erc-color-face4": "ERC face.", "fg:erc-color-face3": "ERC face.", "fg:erc-color-face2": "ERC face.", "fg:erc-color-face1": "ERC face.", "fg:erc-color-face0": "ERC face.", "erc-underline-face": "ERC underline face.", "erc-spoiler-face": "ERC spoiler face.", "erc-inverse-face": "ERC inverse face.", "erc-italic-face": "ERC italic face.", "erc-bold-face": "ERC bold face.", "erc-command-indicator-face": "Face for echoed command lines, including the prompt.", "erc-keep-place-indicator-arrow": "Face for arrow value of option \u2018erc-keep-place-indicator-style\u2019.", "erc-keep-place-indicator-line": "Face for option \u2018erc-keep-place-indicator-style\u2019.", "comint-highlight-prompt": "Face to use to highlight prompts.", "comint-highlight-input": "Face to use to highlight user input.", "ansi-color-bright-white": "Face used to render bright white color code.", "ansi-color-bright-cyan": "Face used to render bright cyan color code.", "ansi-color-bright-magenta": "Face used to render bright magenta color code.", "ansi-color-bright-blue": "Face used to render bright blue color code.", "ansi-color-bright-yellow": "Face used to render bright yellow color code.", "ansi-color-bright-green": "Face used to render bright green color code.", "ansi-color-bright-red": "Face used to render bright red color code.", "ansi-color-bright-black": "Face used to render bright black color code.", "ansi-color-white": "Face used to render white color code.", "ansi-color-cyan": "Face used to render cyan color code.", "ansi-color-magenta": "Face used to render magenta color code.", "ansi-color-blue": "Face used to render blue color code.", "ansi-color-yellow": "Face used to render yellow color code.", "ansi-color-green": "Face used to render green color code.", "ansi-color-red": "Face used to render red color code.", "ansi-color-black": "Face used to render black color code.", "ansi-color-inverse": "Face used to render inverted video text.", "ansi-color-fast-blink": "Face used to render rapidly blinking text.", "ansi-color-slow-blink": "Face used to render slowly blinking text.", "ansi-color-underline": "Face used to render underlined text.", "ansi-color-italic": "Face used to render italic text.", "ansi-color-faint": "Face used to render faint text.", "ansi-color-bold": "Face used to render bold text.", "erc-button-nick-default-face": "Default face for a buttonized nickname.", "erc-button": "ERC button face.", "erc-fill-wrap-merge-indicator-face": "ERC \u2018fill-wrap\u2019 merge-indicator face.", "erc-timestamp-face": "ERC timestamp face.", "erc-nick-msg-face": "ERC nickname face for private messages.", "erc-nick-default-face": "ERC nickname default face.", "erc-my-nick-face": "ERC face for your current nickname in messages sent by you.", "erc-information": "Face for local administrative messages of low to moderate importance.", "erc-error-face": "ERC face for errors.", "erc-action-face": "ERC face for actions generated by /ME.", "erc-notice-face": "ERC face for notices.", "erc-prompt-face": "ERC face for the prompt.", "erc-input-face": "ERC face used for your input.", "erc-header-line": "ERC face used for the header line.", "erc-direct-msg-face": "ERC face used for messages you receive in the main erc buffer.", "erc-my-nick-prefix-face": "ERC face used for my user mode prefix.", "erc-nick-prefix-face": "ERC face used for user mode prefix.", "erc-default-face": "ERC default face.", "prescient-secondary-highlight": "Additional face used to highlight parts of candidates.", "prescient-primary-highlight": "Face used to highlight the parts of candidates that match the input.", "company-echo-common": "Face used for the common part of completions in the echo area.", "company-echo": "Face used for completions in the echo area.", "company-preview-search": "Face used for the search string in the completion preview.", "company-preview-common": "Face used for the common part of the completion preview.", "company-preview": "Face used for the completion preview.", "company-tooltip-scrollbar-track": "Face used for the tooltip scrollbar track (trough).", "company-tooltip-scrollbar-thumb": "Face used for the tooltip scrollbar thumb (bar).", "company-tooltip-quick-access-selection": "Face used for the selected quick-access hints shown in the tooltip.", "company-tooltip-quick-access": "Face used for the quick-access hints shown in the tooltip.", "company-tooltip-annotation-selection": "Face used for the selected completion annotation in the tooltip.", "company-tooltip-annotation": "Face used for the completion annotation in the tooltip.", "company-tooltip-common-selection": "Face used for the selected common completion in the tooltip.", "company-tooltip-common": "Face used for the common completion in the tooltip.", "company-tooltip-mouse": "Face used for the tooltip item under the mouse.", "company-tooltip-search-selection": "Face used for the search string inside the selection in the tooltip.", "company-tooltip-search": "Face used for the search string in the tooltip.", "company-tooltip-deprecated": "Face used for the deprecated items.", "company-tooltip-selection": "Face used for the selection in the tooltip.", "company-tooltip": "Face used for the tooltip.", "embark-selected": "Face for selected candidates.", "embark-collect-annotation": "Face for annotations in Embark Collect.", "embark-collect-group-separator": "Face for group titles in Embark Collect buffers.", "embark-collect-group-title": "Face for group titles in Embark Collect buffers.", "embark-collect-candidate": "Face for candidates in Embark Collect buffers.", "embark-verbose-indicator-shadowed": "Face used by the verbose action indicator for the shadowed targets.", "embark-verbose-indicator-title": "Face used by the verbose action indicator for the title.", "embark-verbose-indicator-documentation": "Face used by the verbose action indicator to display binding descriptions.", "embark-target": "Face used to highlight the target at point during \u2018embark-act\u2019.", "embark-keymap": "Face used to display keymaps.", "embark-keybinding": "Face used to display key bindings.", "embark-keybinding-repeat": "Face used to indicate keybindings as repeatable.", "ffap": "Face used to highlight the current buffer substring.", "orderless-match-face-3": "Face for matches of components numbered 3 mod 4.", "orderless-match-face-2": "Face for matches of components numbered 2 mod 4.", "orderless-match-face-1": "Face for matches of components numbered 1 mod 4.", "orderless-match-face-0": "Face for matches of components numbered 0 mod 4.", "consult-line-number-wrapped": "Face used to highlight line number prefixes after wrap around.", "consult-line-number-prefix": "Face used to highlight line number prefixes.", "consult-buffer": "Face used to highlight buffers in \u2018consult-buffer\u2019.", "consult-bookmark": "Face used to highlight bookmarks in \u2018consult-buffer\u2019.", "consult-grep-context": "Face used to highlight grep context in \u2018consult-grep\u2019.", "consult-file": "Face used to highlight files in \u2018consult-buffer\u2019.", "consult-line-number": "Face used to highlight location line in \u2018consult-global-mark\u2019.", "consult-key": "Face used to highlight keys, e.g., in \u2018consult-register\u2019.", "consult-help": "Face used to highlight help, e.g., in \u2018consult-register-store\u2019.", "consult-async-option": "Face used to highlight asynchronous command options.", "consult-async-split": "Face used to highlight punctuation character.", "consult-async-failed": "Face used if asynchronous process has failed.", "consult-async-finished": "Face used if asynchronous process has finished.", "consult-async-running": "Face used if asynchronous process is running.", "consult-narrow-indicator": "Face used for the narrowing indicator.", "consult-preview-insertion": "Face used for previews of text to be inserted.", "consult-preview-match": "Face used for match previews, e.g., in \u2018consult-line\u2019.", "consult-highlight-mark": "Face used for mark positions in completion candidates.", "consult-highlight-match": "Face used to highlight matches in the completion candidates.", "consult-preview-line": "Face used for line previews.", "nerd-icons-completion-dir-face": "Face for the directory icon.", "marginalia-file-priv-rare": "Face used to highlight a rare file privilege attribute.", "marginalia-file-priv-other": "Face used to highlight some other file privilege attribute.", "marginalia-file-priv-exec": "Face used to highlight the exec file privilege attribute.", "marginalia-file-priv-write": "Face used to highlight the write file privilege attribute.", "marginalia-file-priv-read": "Face used to highlight the read file privilege attribute.", "marginalia-file-priv-link": "Face used to highlight the link file privilege attribute.", "marginalia-file-priv-dir": "Face used to highlight the dir file privilege attribute.", "marginalia-file-priv-no": "Face used to highlight the no file privilege attribute.", "marginalia-file-owner": "Face used to highlight file owner and group names.", "marginalia-file-name": "Face used to highlight file names.", "marginalia-modified": "Face used to highlight buffer modification indicators.", "marginalia-string": "Face used to highlight string values.", "marginalia-number": "Face used to highlight numeric values.", "marginalia-size": "Face used to highlight sizes.", "marginalia-installed": "Face used to highlight the status of packages.", "marginalia-archive": "Face used to highlight package archives.", "marginalia-version": "Face used to highlight package versions.", "marginalia-date": "Face used to highlight dates.", "marginalia-mode": "Face used to highlight buffer major modes.", "marginalia-list": "Face used to highlight list expressions.", "marginalia-symbol": "Face used to highlight general symbols.", "marginalia-function": "Face used to highlight function symbols.", "marginalia-true": "Face used to highlight true variable values.", "marginalia-null": "Face used to highlight null or unbound variable values.", "marginalia-value": "Face used to highlight general variable values.", "marginalia-documentation": "Face used to highlight documentation strings.", "marginalia-off": "Face used to signal disabled modes.", "marginalia-on": "Face used to signal enabled modes.", "marginalia-lighter": "Face used to highlight minor mode lighters.", "marginalia-char": "Face used to highlight character annotations.", "marginalia-type": "Face used to highlight types.", "marginalia-key": "Face used to highlight keys.", "vertico-current": "Face used to highlight the currently selected candidate.", "vertico-group-separator": "Face used for the separator lines of the candidate groups.", "vertico-group-title": "Face used for the title text of the candidate group headlines.", "vertico-multiline": "Face used to highlight multiline replacement characters.", "nerd-icons-dsilver": "Face for dsilver icons.", "nerd-icons-lsilver": "Face for lsilver icons.", "nerd-icons-silver": "Face for silver icons.", "nerd-icons-dpink": "Face for dpink icons.", "nerd-icons-lpink": "Face for lpink icons.", "nerd-icons-pink": "Face for pink icons.", "nerd-icons-dcyan": "Face for dcyan icons.", "nerd-icons-lcyan": "Face for lcyan icons.", "nerd-icons-cyan-alt": "Face for cyan icons.", "nerd-icons-cyan": "Face for cyan icons.", "nerd-icons-dorange": "Face for dorange icons.", "nerd-icons-lorange": "Face for lorange icons.", "nerd-icons-orange": "Face for orange icons.", "nerd-icons-dpurple": "Face for dpurple icons.", "nerd-icons-lpurple": "Face for lpurple icons.", "nerd-icons-purple-alt": "Face for purple icons.", "nerd-icons-purple": "Face for purple icons.", "nerd-icons-dmaroon": "Face for dmaroon icons.", "nerd-icons-lmaroon": "Face for lmaroon icons.", "nerd-icons-maroon": "Face for maroon icons.", "nerd-icons-dblue": "Face for dblue icons.", "nerd-icons-lblue": "Face for lblue icons.", "nerd-icons-blue-alt": "Face for blue icons.", "nerd-icons-blue": "Face for blue icons.", "nerd-icons-dyellow": "Face for dyellow icons.", "nerd-icons-lyellow": "Face for lyellow icons.", "nerd-icons-yellow": "Face for yellow icons.", "nerd-icons-dgreen": "Face for dgreen icons.", "nerd-icons-lgreen": "Face for lgreen icons.", "nerd-icons-green": "Face for green icons.", "nerd-icons-red-alt": "Face for dred icons.", "nerd-icons-dred": "Face for dred icons.", "nerd-icons-lred": "Face for lred icons.", "nerd-icons-red": "Face for red icons.", "all-the-icons-dsilver": "Face for dsilver icons", "all-the-icons-lsilver": "Face for lsilver icons", "all-the-icons-silver": "Face for silver icons", "all-the-icons-dpink": "Face for dpink icons", "all-the-icons-lpink": "Face for lpink icons", "all-the-icons-pink": "Face for pink icons", "all-the-icons-dcyan": "Face for dcyan icons", "all-the-icons-lcyan": "Face for lcyan icons", "all-the-icons-cyan-alt": "Face for cyan icons", "all-the-icons-cyan": "Face for cyan icons", "all-the-icons-dorange": "Face for dorange icons", "all-the-icons-lorange": "Face for lorange icons", "all-the-icons-orange": "Face for orange icons", "all-the-icons-dpurple": "Face for dpurple icons", "all-the-icons-lpurple": "Face for lpurple icons", "all-the-icons-purple-alt": "Face for purple icons", "all-the-icons-purple": "Face for purple icons", "all-the-icons-dmaroon": "Face for dmaroon icons", "all-the-icons-lmaroon": "Face for lmaroon icons", "all-the-icons-maroon": "Face for maroon icons", "all-the-icons-dblue": "Face for dblue icons", "all-the-icons-lblue": "Face for lblue icons", "all-the-icons-blue-alt": "Face for blue icons", "all-the-icons-blue": "Face for blue icons", "all-the-icons-dyellow": "Face for dyellow icons", "all-the-icons-lyellow": "Face for lyellow icons", "all-the-icons-yellow": "Face for yellow icons", "all-the-icons-dgreen": "Face for dgreen icons", "all-the-icons-lgreen": "Face for lgreen icons", "all-the-icons-green": "Face for green icons", "all-the-icons-red-alt": "Face for dred icons", "all-the-icons-dred": "Face for dred icons", "all-the-icons-lred": "Face for lred icons", "all-the-icons-red": "Face for red icons", "adob--hack": "A hack to make fringe refresh work. Do not use.", "auto-dim-other-buffers-hide": "Face with a (presumably) dimmed background and matching foreground.", "auto-dim-other-buffers": "Face with a (presumably) dimmed background for non-selected window.", "epa-field-body": "Face for the body of the attribute field.", "epa-field-name": "Face for the name of the attribute field.", "epa-mark": "Face used for displaying the high validity.", "epa-string": "Face used for displaying the string.", "epa-validity-disabled": "Face used for displaying the disabled validity.", "epa-validity-low": "Face used for displaying the low validity.", "epa-validity-medium": "Face for medium validity EPA information.", "epa-validity-high": "Face for high validity EPA information.", "mm-command-output": "Face used for displaying output from commands.", "edmacro-label": "Face used for labels in \u2018edit-kbd-macro\u2019.", "kmacro-menu-marked": "Face used for keyboard macros marked for duplication.", "kmacro-menu-flagged": "Face used for keyboard macros flagged for deletion.", "kmacro-menu-mark": "Face used for the Keyboard Macro Menu marks.", "custom-group-subtitle": "Face for the \"Subgroups:\" subtitle in Custom buffers.", "custom-group-tag": "Face for low level group tags.", "custom-group-tag-1": "Face for group tags.", "custom-face-tag": "Face used for face tags.", "custom-visibility": "Face for the \u2018custom-visibility\u2019 widget.", "custom-variable-button": "Face used for pushable variable tags.", "custom-variable-tag": "Face used for unpushable variable tags.", "custom-variable-obsolete": "Face used for obsolete variables.", "custom-comment-tag": "Face used for the comment tag on variables or faces.", "custom-comment": "Face used for comments on variables or faces.", "custom-link": "Face for links in customization buffers.", "custom-state": "Face used for State descriptions in the customize buffer.", "custom-documentation": "Face used for documentation strings in customization buffers.", "custom-button-pressed-unraised": "Face for pressed custom buttons if \u2018custom-raised-buttons\u2019 is nil.", "custom-button-pressed": "Face for pressed custom buttons if \u2018custom-raised-buttons\u2019 is non-nil.", "custom-button-unraised": "Face for custom buffer buttons if \u2018custom-raised-buttons\u2019 is nil.", "custom-button-mouse": "Mouse face for custom buffer buttons if \u2018custom-raised-buttons\u2019 is non-nil.", "custom-button": "Face for custom buffer buttons if \u2018custom-raised-buttons\u2019 is non-nil.", "custom-saved": "Face used when the customize item has been saved.", "custom-themed": "Face used when the customize item has been set by a theme.", "custom-changed": "Face used when the customize item has been changed.", "custom-set": "Face used when the customize item has been set.", "custom-modified": "Face used when the customize item has been modified.", "custom-rogue": "Face used when the customize item is not defined for customization.", "custom-invalid": "Face used when the customize item is invalid.", "widget-button-pressed": "Face used for pressed buttons.", "widget-unselected": "Face used for unselected widgets.", "widget-inactive": "Face used for inactive widgets.", "widget-single-line-field": "Face used for editable fields spanning only a single line.", "widget-field": "Face used for editable fields.", "widget-button": "Face used for widget buttons.", "widget-documentation": "Face used for documentation text.", "bookmark-face": "Face used to highlight current line.", "bookmark-menu-bookmark": "Face used to highlight bookmark names in bookmark menu buffers.", "dired-ignored": "Face used for files suffixed with \u2018completion-ignored-extensions\u2019.", "dired-special": "Face used for sockets, pipes, block devices and char devices.", "dired-broken-symlink": "Face used for broken symbolic links.", "dired-symlink": "Face used for symbolic links.", "dired-directory": "Face used for subdirectories.", "dired-set-id": "Face used to highlight permissions of suid and guid files.", "dired-perm-write": "Face used to highlight permissions of group- and world-writable files.", "dired-warning": "Face used to highlight a part of a buffer that needs user attention.", "dired-flagged": "Face used for files flagged for deletion.", "dired-marked": "Face used for marked files.", "dired-mark": "Face used for Dired marks.", "dired-header": "Face used for directory headers.", "Info-quoted": "Face used for quoted elements.", "info-index-match": "Face used to highlight matches in an index entry.", "info-header-node": "Face for Info nodes in a node header.", "info-header-xref": "Face for Info cross-references in a node header.", "info-xref-visited": "Face for visited Info cross-references.", "info-xref": "Face for unvisited Info cross-references.", "info-menu-star": "Face used to emphasize \u2018*\u2019 in an Info menu.", "info-menu-header": "Face for headers in Info menus.", "info-title-4": "Face for info titles at level 4.", "info-title-3": "Face for info titles at level 3.", "info-title-2": "Face for info titles at level 2.", "info-title-1": "Face for info titles at level 1.", "info-node": "Face for Info node names.", "package-status-avail-obso": "Face used on the status and version of avail-obso packages.", "package-status-incompat": "Face used on the status and version of incompat packages.", "package-status-unsigned": "Face used on the status and version of unsigned packages.", "package-status-dependency": "Face used on the status and version of dependency packages.", "package-status-from-source": "Face used on the status and version of installed packages.", "package-status-installed": "Face used on the status and version of installed packages.", "package-status-disabled": "Face used on the status and version of disabled packages.", "package-status-held": "Face used on the status and version of held packages.", "package-status-new": "Face used on the status and version of new packages.", "package-status-available": "Face used on the status and version of available packages.", "package-status-external": "Face used on the status and version of external packages.", "package-status-built-in": "Face used on the status and version of built-in packages.", "package-description": "Face used on package description summaries in the package menu.", "package-name": "Face used on package names in the package menu.", "package-help-section-name": "Face used on section names in package description buffers.", "browse-url-button": "Face for \u2018browse-url\u2019 buttons (i.e., links).", "icon-button": "Face for buttons.", "icon": "Face for buttons.", "tooltip": "Face for tooltips.", "eldoc-highlight-function-argument": "Face used for the argument at point in a function\u2019s argument list.", "elisp-shorthand-font-lock-face": "Face for highlighting shorthands in Emacs Lisp.", "vc-ignored-state": "Face for VC modeline state when the file is registered, but ignored.", "vc-edited-state": "Face for VC modeline state when the file is edited.", "vc-missing-state": "Face for VC modeline state when the file is missing from the file system.", "vc-removed-state": "Face for VC modeline state when the file was removed from the VC system.", "vc-conflict-state": "Face for VC modeline state when the file contains merge conflicts.", "vc-locally-added-state": "Face for VC modeline state when the file is locally added.", "vc-locked-state": "Face for VC modeline state when the file locked.", "vc-needs-update-state": "Face for VC modeline state when the file needs update.", "vc-up-to-date-state": "Face for VC modeline state when the file is up to date.", "vc-state-base": "Base face for VC state indicator.", "buffer-menu-buffer": "Face for buffer names in the Buffer Menu.", "tabulated-list-fake-header": "Face used on fake header lines.", "match": "Face used to highlight matches permanently.", "query-replace": "Face for highlighting query replacement matches.", "tab-bar-tab-ungrouped": "Tab bar face for ungrouped tab when tab groups are used.", "tab-bar-tab-group-inactive": "Tab bar face for inactive group tab.", "tab-bar-tab-group-current": "Tab bar face for current group tab.", "tab-bar-tab-inactive": "Tab bar face for non-selected tab.", "tab-bar-tab": "Tab bar face for selected tab.", "file-name-shadow": "Face used by \u2018file-name-shadow-mode\u2019 for the shadow.", "isearch-group-2": "Face for highlighting Isearch the even group matches.", "isearch-group-1": "Face for highlighting Isearch the odd group matches.", "lazy-highlight": "Face for lazy highlighting of matches other than the current one.", "isearch-fail": "Face for highlighting failed part in Isearch echo-area message.", "isearch": "Face for highlighting Isearch matches.", "mouse-drag-and-drop-region": "Face to highlight original text during dragging.", "font-lock-misc-punctuation-face": "Font Lock mode face used to highlight miscellaneous punctuation.", "font-lock-delimiter-face": "Font Lock mode face used to highlight delimiters.", "font-lock-bracket-face": "Font Lock mode face used to highlight brackets, braces, and parens.", "font-lock-punctuation-face": "Font Lock mode face used to highlight punctuation characters.", "font-lock-property-use-face": "Font Lock mode face used to highlight property references.", "font-lock-property-name-face": "Font Lock mode face used to highlight properties of an object.", "font-lock-operator-face": "Font Lock mode face used to highlight operators.", "font-lock-number-face": "Font Lock mode face used to highlight numbers.", "font-lock-escape-face": "Font Lock mode face used to highlight escape sequences in strings.", "font-lock-regexp-grouping-construct": "Font Lock mode face used to highlight grouping constructs in Lisp regexps.", "font-lock-regexp-grouping-backslash": "Font Lock mode face for backslashes in Lisp regexp grouping constructs.", "font-lock-regexp-face": "Font Lock mode face used to highlight regexp literals.", "font-lock-preprocessor-face": "Font Lock mode face used to highlight preprocessor directives.", "font-lock-negation-char-face": "Font Lock mode face used to highlight easy to overlook negation.", "font-lock-warning-face": "Font Lock mode face used to highlight warnings.", "font-lock-constant-face": "Font Lock mode face used to highlight constants and labels.", "font-lock-type-face": "Font Lock mode face used to highlight type and class names.", "font-lock-variable-use-face": "Font Lock mode face used to highlight variable references.", "font-lock-variable-name-face": "Font Lock mode face used to highlight variable names.", "font-lock-function-call-face": "Font Lock mode face used to highlight function calls.", "font-lock-function-name-face": "Font Lock mode face used to highlight function names.", "font-lock-builtin-face": "Font Lock mode face used to highlight builtins.", "font-lock-keyword-face": "Font Lock mode face used to highlight keywords.", "font-lock-doc-markup-face": "Font Lock mode face used to highlight embedded documentation mark-up.", "font-lock-doc-face": "Font Lock mode face used to highlight documentation embedded in program code.", "font-lock-string-face": "Font Lock mode face used to highlight strings.", "font-lock-comment-delimiter-face": "Font Lock mode face used to highlight comment delimiters.", "font-lock-comment-face": "Font Lock mode face used to highlight comments.", "completions-common-part": "Face for the parts of completions which matched the pattern.", "completions-first-difference": "Face for the first character after point in completions.", "completions-highlight": "Default face for highlighting the current completion candidate.", "completions-annotations": "Face to use for annotations in the *Completions* buffer.", "completions-group-separator": "Face used for the separator lines between the candidate groups.", "completions-group-title": "Face used for the title text of the candidate group headlines.", "blink-matching-paren-offscreen": "Face for showing in the echo area matched open paren that is off-screen.", "separator-line": "Face for separator lines.", "next-error-message": "Face used to highlight the current error message in the \u2018next-error\u2019 buffer.", "next-error": "Face used to highlight next error locus.", "confusingly-reordered": "Face for highlighting text that was bidi-reordered in confusing ways.", "help-for-help-header": "Face used for headers in the \u2018help-for-help\u2019 buffer.", "abbrev-table-name": "Face used for displaying the abbrev table name in \u2018edit-abbrevs-mode\u2019.", "button": "Default face used for buttons.", "show-paren-mismatch": "Face used for a mismatching paren.", "show-paren-match-expression": "Face used for a matching paren when highlighting the whole expression.", "show-paren-match": "Face used for a matching paren.", "tty-menu-selected-face": "Face for displaying the currently selected item in TTY menus.", "tty-menu-disabled-face": "Face for displaying disabled items in TTY menus.", "tty-menu-enabled-face": "Face for displaying enabled items in TTY menus.", "read-multiple-choice-face": "Face for the symbol name in \u2018read-multiple-choice\u2019 output.", "success": "Basic face used to indicate successful operation.", "warning": "Basic face used to highlight warnings.", "error": "Basic face used to highlight errors and to denote failure.", "glyphless-char": "Face for displaying non-graphic characters (e.g. U+202A (LRE)).", "help-key-binding": "Face for keybindings in *Help* buffers.", "help-argument-name": "Face to highlight argument names in *Help* buffers.", "menu": "Basic face for the font and colors of the menu bar and popup menus.", "tab-line": "Tab line face.", "tab-bar": "Tab bar face.", "tool-bar": "Basic tool-bar face.", "mouse": "Basic face for the mouse color under X.", "cursor": "Basic face for the cursor color under X.", "border": "Basic face for the frame border under X.", "scroll-bar": "Basic face for the scroll bar colors under X.", "fringe": "Basic face for the fringes to the left and right of windows under X.", "minibuffer-prompt": "Face for minibuffer prompts.", "child-frame-border": "Basic face for the internal border of child frames.", "internal-border": "Basic face for the internal border.", "window-divider-last-pixel": "Basic face for last pixel line/column of window dividers.", "window-divider-first-pixel": "Basic face for first pixel line/column of window dividers.", "window-divider": "Basic face for window dividers.", "vertical-border": "Face used for vertical window dividers on ttys.", "header-line-highlight": "Basic header line face for highlighting.", "header-line": "Basic header-line face.", "mode-line-buffer-id": "Face used for buffer identification parts of the mode line.", "mode-line-emphasis": "Face used to emphasize certain mode line features.", "mode-line-highlight": "Basic mode line face for highlighting.", "mode-line-inactive": "Basic mode line face for non-selected windows.", "mode-line-active": "Face for the selected mode line.", "mode-line": "Face for the mode lines as well as header lines.", "nobreak-hyphen": "Face for displaying nobreak hyphens.", "nobreak-space": "Face for displaying nobreak space.", "homoglyph": "Face for lookalike characters.", "escape-glyph": "Face for characters displayed as sequences using \u2018^\u2019 or \u2018\\\u2019.", "fill-column-indicator": "Face for displaying fill column indicator.", "line-number-minor-tick": "Face for highlighting \"minor ticks\" (as in a ruler).", "line-number-major-tick": "Face for highlighting \"major ticks\" (as in a ruler).", "line-number-current-line": "Face for displaying the current line number.", "line-number": "Face for displaying line numbers.", "trailing-whitespace": "Basic face for highlighting trailing whitespace.", "secondary-selection": "Basic face for displaying the secondary selection.", "region": "Basic face for highlighting the region.", "highlight": "Basic face for highlighting.", "link-visited": "Basic face for visited links.", "link": "Basic face for unvisited links.", "shadow": "Basic face for shadowed text.", "variable-pitch-text": "The proportional face used for longer texts.", "variable-pitch": "The basic variable-pitch face.", "fixed-pitch-serif": "The basic fixed-pitch face with serifs.", "fixed-pitch": "The basic fixed-pitch face.", "underline": "Basic underlined face.", "bold-italic": "Basic bold-italic face.", "italic": "Basic italic face.", "bold": "Basic bold face.", "default": "Basic default face.", "eat-term-color-black": "Face used to render black color text.", "eat-term-color-red": "Face used to render red color text.", "eat-term-color-green": "Face used to render green color text.", "eat-term-color-yellow": "Face used to render yellow color text.", "eat-term-color-blue": "Face used to render blue color text.", "eat-term-color-magenta": "Face used to render magenta color text.", "eat-term-color-cyan": "Face used to render cyan color text.", "eat-term-color-white": "Face used to render white color text.", "eat-term-color-bright-black": "Face used to render bright black color text.", "eat-term-color-bright-red": "Face used to render bright red color text.", "eat-term-color-bright-green": "Face used to render bright green color text.", "eat-term-color-bright-yellow": "Face used to render bright yellow color text.", "eat-term-color-bright-blue": "Face used to render bright blue color text.", "eat-term-color-bright-magenta": "Face used to render bright magenta color text.", "eat-term-color-bright-cyan": "Face used to render bright cyan color text.", "eat-term-color-bright-white": "Face used to render bright white color text.", "eat-term-bold": "Face used to render bold text.", "eat-term-faint": "Face used to render faint text.", "eat-term-italic": "Face used to render italic text.", "eat-term-slow-blink": "Face used to render slowly blinking text.", "eat-term-fast-blink": "Face used to render rapidly blinking text.", "eat-shell-prompt-annotation-success": "Face used in annotation to indicate the command has succeeded.", "eat-shell-prompt-annotation-running": "Face used in annotation to indicate the command is running.", "eat-shell-prompt-annotation-failure": "Face used in annotation to indicate the command has failed."}, SYNTAX_DOCS={"bg": "Basic default face.", "p": "Basic default face.", "kw": "Font Lock mode face used to highlight keywords.", "bi": "Font Lock mode face used to highlight builtins.", "pp": "Font Lock mode face used to highlight preprocessor directives.", "fnd": "Font Lock mode face used to highlight function names.", "fnc": "Font Lock mode face used to highlight function calls.", "ty": "Font Lock mode face used to highlight type and class names.", "prop": "Font Lock mode face used to highlight properties of an object.", "con": "Font Lock mode face used to highlight constants and labels.", "num": "Font Lock mode face used to highlight numbers.", "str": "Font Lock mode face used to highlight strings.", "esc": "Font Lock mode face used to highlight escape sequences in strings.", "re": "Font Lock mode face used to highlight regexp literals.", "doc": "Font Lock mode face used to highlight documentation embedded in program code.", "cm": "Font Lock mode face used to highlight comments.", "cmd": "Font Lock mode face used to highlight comment delimiters.", "var": "Font Lock mode face used to highlight variable names.", "op": "Font Lock mode face used to highlight operators.", "punc": "Font Lock mode face used to highlight punctuation characters."}; // face/category -> docstring first line, for element hovers
-let MAP={"kw": "#d3d3d3", "bi": "#d3d3d3", "pp": "#d3d3d3", "fnd": "#0000ff", "fnc": "#0000ff", "dec": "", "ty": "#e5e5e5", "prop": "#e5e5e5", "con": "#d3d3d3", "num": "#000000", "esc": "#000000", "str": "#696969", "re": "#696969", "doc": "#696969", "cm": "#696969", "cmd": "#696969", "var": "#e5e5e5", "op": "#000000", "punc": "#000000", "p": "#000000", "bg": "#ffffff", "dmark": "#d3d3d3", "neg": "#000000", "rxgb": "#000000", "rxgc": "#000000", "warn": "#ff0000"}, PALETTE=[["#ffffff", "bg", "ground"], ["#000000", "fg", "ground"], ["#d3d3d3", "lightgray", "lightgray"], ["#0000ff", "blue1", "blue"], ["#e5e5e5", "gray90", "gray"], ["#696969", "dimgray", "dimgray"], ["#ff0000", "red", "red"], ["#eedc82", "lightgoldenrod2", "lightgoldenrod"], ["#b4eeb4", "darkseagreen2", "darkseagreen"], ["#bfbfbf", "grey75", "grey"], ["#333333", "grey20", "grey"], ["#f2f2f2", "grey95", "grey"], ["#ff00ff", "magenta", "magenta"], ["#b0e2ff", "lightskyblue1", "lightskyblue"], ["#cd00cd", "magenta3", "magenta"], ["#afeeee", "paleturquoise", "paleturquoise"], ["#ffc1c1", "rosybrown1", "rosybrown"], ["#40e0d0", "turquoise", "turquoise"], ["#a020f0", "purple", "purple"], ["#3a5fcd", "royalblue3", "royalblue"], ["#ff8c00", "dark-orange", "dark-orange"], ["#228b22", "forestgreen", "forestgreen"], ["#8b6508", "darkgoldenrod4", "darkgoldenrod"], ["#8b4c39", "salmon4", "salmon"], ["#22aa22", "color-24", "color-24"], ["#ddffdd", "color-25", "color-25"], ["#cceecc", "color-26", "color-26"], ["#aa2222", "color-27", "color-27"], ["#ffdddd", "color-28", "color-28"], ["#eecccc", "color-29", "color-29"], ["#7f7f7f", "grey50", "grey"], ["#cccccc", "grey80", "grey"], ["#cd8162", "lightsalmon3", "lightsalmon"], ["#aaaa11", "color-33", "color-33"], ["#ffffcc", "color-34", "color-34"], ["#eeeebb", "color-35", "color-35"], ["#4a708b", "skyblue4", "skyblue"], ["#6e8b3d", "darkolivegreen4", "darkolivegreen"], ["#8b6914", "goldenrod4", "goldenrod"], ["#999999", "grey60", "grey"], ["#4d4d4d", "grey30", "grey"], ["#b22222", "firebrick", "firebrick"], ["#00ff00", "green", "green"], ["#556b2f", "darkolivegreen", "darkolivegreen"], ["#8b3a3a", "indianred4", "indianred"], ["#b8860b", "darkgoldenrod", "darkgoldenrod"], ["#00ffff", "cyan", "cyan"], ["#66cdaa", "medium-aquamarine", "medium-aquamarine"], ["#ffa500", "orange", "orange"], ["#d02090", "violet-red", "violet-red"], ["#add8e6", "light-blue", "light-blue"], ["#cd5c5c", "indian-red", "indian-red"], ["#aaa", "color-52", "color-52"], ["#000", "color-53", "color-53"], ["#aa0", "color-54", "color-54"], ["#070", "color-55", "color-55"], ["#daa520", "goldenrod", "goldenrod"], ["#00bfff", "deep-sky-blue", "deep-sky-blue"], ["#ee00ee", "magenta2", "magenta"], ["#ffff00", "yellow", "yellow"], ["#6b6b6b", "color-60", "color-60"], ["#979797", "color-61", "color-61"], ["unspecified", "color-62", "color-62"], ["#223fbf", "color-63", "color-63"], ["#8f0075", "color-64", "color-64"], ["#145a00", "color-65", "color-65"], ["#804000", "color-66", "color-66"], ["#efcbcf", "color-67", "color-67"], ["#6a9fb5", "color-68", "color-68"], ["#2188b6", "color-69", "color-69"], ["#75b5aa", "color-70", "color-70"], ["#0595bd", "color-71", "color-71"], ["#446674", "color-72", "color-72"], ["#48746d", "color-73", "color-73"], ["#6d8143", "color-74", "color-74"], ["#72584b", "color-75", "color-75"], ["#915b2d", "color-76", "color-76"], ["#7e5d5f", "color-77", "color-77"], ["#694863", "color-78", "color-78"], ["#843031", "color-79", "color-79"], ["#838484", "color-80", "color-80"], ["#b48d56", "color-81", "color-81"], ["#90a959", "color-82", "color-82"], ["#677174", "color-83", "color-83"], ["#2c7d6e", "color-84", "color-84"], ["#3d6837", "color-85", "color-85"], ["#ce7a4e", "color-86", "color-86"], ["#ff505b", "color-87", "color-87"], ["#e69dd6", "color-88", "color-88"], ["#eb595a", "color-89", "color-89"], ["#7f7869", "color-90", "color-90"], ["#ff9300", "color-91", "color-91"], ["#8f5536", "color-92", "color-92"], ["#d4843e", "color-93", "color-93"], ["#fc505b", "color-94", "color-94"], ["#68295b", "color-95", "color-95"], ["#5d54e1", "color-96", "color-96"], ["#ac4142", "color-97", "color-97"], ["#716e68", "color-98", "color-98"], ["#ffcc0e", "color-99", "color-99"], ["#ffd700", "gold", "gold"], ["#8b0000", "darkred", "darkred"], ["#f0e68c", "khaki", "khaki"], ["#8b008b", "dark-magenta", "dark-magenta"], ["#ff4500", "orange-red", "orange-red"], ["#deb887", "burlywood", "burlywood"], ["#cd8500", "orange3", "orange"], ["#00008b", "dark-blue", "dark-blue"], ["#9400d3", "dark-violet", "dark-violet"], ["#8b1a1a", "firebrick4", "firebrick"], ["#fff8dc", "cornsilk", "cornsilk"], ["#f5deb3", "wheat", "wheat"], ["#cd0000", "red3", "red"], ["#0000cd", "blue3", "blue"], ["#cc9393", "color-114", "color-114"], ["#bebebe", "gray", "gray"], ["#88090b", "color-116", "color-116"], ["#707183", "color-117", "color-117"], ["#7388d6", "color-118", "color-118"], ["#909183", "color-119", "color-119"], ["#709870", "color-120", "color-120"], ["#907373", "color-121", "color-121"], ["#6276ba", "color-122", "color-122"], ["#858580", "color-123", "color-123"], ["#80a880", "color-124", "color-124"], ["#887070", "color-125", "color-125"], ["#1e90ff", "dodger-blue", "dodger-blue"], ["#ff69b4", "hot-pink", "hot-pink"], ["#da70d6", "orchid", "orchid"], ["#fa8072", "salmon", "salmon"], ["#00ff7f", "spring-green", "spring-green"], ["#800040", "color-131", "color-131"], ["#603f00", "color-132", "color-132"], ["#004476", "color-133", "color-133"], ["#2266ff", "color-134", "color-134"], ["#dd4488", "color-135", "color-135"], ["#8fbc8f", "color-136", "color-136"], ["#5f9ea0", "color-137", "color-137"], ["#ffffe0", "lightyellow1", "lightyellow"], ["#3e3c36", "color-139", "color-139"], ["#8b8989", "snow4", "snow"], ["#242424", "grey14", "grey"], ["#cd69c9", "orchid3", "orchid"], ["#dda0dd", "plum", "plum"], ["#000053", "color-144", "color-144"], ["#001970", "color-145", "color-145"], ["#002984", "color-146", "color-146"], ["#49599a", "color-147", "color-147"], ["#9499b7", "color-148", "color-148"], ["#cdc9c9", "snow3", "snow"], ["#eeb422", "goldenrod2", "goldenrod"], ["#68228b", "darkorchid4", "darkorchid"]], SYNTAX={"kw": {"fg": "#d3d3d3", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "bi": {"fg": "#d3d3d3", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "pp": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-builtin-face", "height": null, "heightMode": null}, "fnd": {"fg": "#0000ff", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "fnc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-function-name-face", "height": null, "heightMode": null}, "dec": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "ty": {"fg": "#e5e5e5", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "prop": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-variable-name-face", "height": null, "heightMode": null}, "con": {"fg": "#d3d3d3", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": {"style": "line", "color": null}, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "num": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "esc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-regexp-grouping-backslash", "height": null, "heightMode": null}, "str": {"fg": "#696969", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": "italic", "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "re": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-string-face", "height": null, "heightMode": null}, "doc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-string-face", "height": null, "heightMode": null}, "cm": {"fg": "#696969", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": "italic", "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "cmd": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-comment-face", "height": null, "heightMode": null}, "var": {"fg": "#e5e5e5", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": "italic", "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "op": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "punc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "p": {"fg": "#000000", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "bg": {"fg": "#ffffff", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}}, UIMAP={"cursor": {"fg": null, "bg": "#000000", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "region": {"fg": null, "bg": "#eedc82", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": true, "inherit": null, "height": null, "heightMode": null}, "hl-line": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": true, "inherit": "highlight", "height": null, "heightMode": null}, "highlight": {"fg": null, "bg": "#b4eeb4", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "mode-line": {"fg": "#000000", "bg": "#bfbfbf", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": {"style": "released", "width": 1, "color": null}, "inverse": false, "extend": false, "inherit": null, "height": 130, "heightMode": "abs"}, "mode-line-highlight": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": {"style": "released", "width": 1, "color": null}, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "mode-line-inactive": {"fg": "#333333", "bg": "#e5e5e5", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": {"style": "line", "width": 1, "color": "#bfbfbf"}, "inverse": false, "extend": false, "inherit": "mode-line", "height": null, "heightMode": null}, "header-line": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "tab-bar": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": 130, "heightMode": "abs"}, "tab-line": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": 130, "heightMode": "abs"}, "fringe": {"fg": null, "bg": "#f2f2f2", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "line-number": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": ["shadow", "default"], "height": 130, "heightMode": "abs"}, "line-number-current-line": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "line-number", "height": 130, "heightMode": "abs"}, "minibuffer-prompt": {"fg": "#ff00ff", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "isearch": {"fg": "#b0e2ff", "bg": "#cd00cd", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "lazy-highlight": {"fg": null, "bg": "#afeeee", "distant-fg": "#000000", "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "isearch-fail": {"fg": null, "bg": "#ffc1c1", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "show-paren-match": {"fg": null, "bg": "#40e0d0", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "show-paren-mismatch": {"fg": "#ffffff", "bg": "#a020f0", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "link": {"fg": "#3a5fcd", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": {"style": "line", "color": null}, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "error": {"fg": "#ff0000", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "warning": {"fg": "#ff8c00", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "success": {"fg": "#228b22", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "vertical-border": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}};
+let MAP={"kw": "#d3d3d3", "bi": "#d3d3d3", "pp": "#d3d3d3", "fnd": "#0000ff", "fnc": "#0000ff", "dec": "", "ty": "#e5e5e5", "prop": "#e5e5e5", "con": "#d3d3d3", "num": "#000000", "esc": "#000000", "str": "#696969", "re": "#696969", "rxgb": "#000000", "rxgc": "#000000", "doc": "#696969", "dmark": "#d3d3d3", "cm": "#696969", "cmd": "#696969", "var": "#e5e5e5", "op": "#000000", "neg": "#000000", "punc": "#000000", "p": "#000000", "warn": "#ff0000", "bg": "#ffffff"}, PALETTE=[["#ffffff", "bg", "ground"], ["#000000", "fg", "ground"], ["#d3d3d3", "lightgray", "lightgray"], ["#0000ff", "blue1", "blue"], ["#e5e5e5", "gray90", "gray"], ["#696969", "dimgray", "dimgray"], ["#ff0000", "red", "red"], ["#eedc82", "lightgoldenrod2", "lightgoldenrod"], ["#b4eeb4", "darkseagreen2", "darkseagreen"], ["#bfbfbf", "grey75", "grey"], ["#333333", "grey20", "grey"], ["#f2f2f2", "grey95", "grey"], ["#ff00ff", "magenta", "magenta"], ["#b0e2ff", "lightskyblue1", "lightskyblue"], ["#cd00cd", "magenta3", "magenta"], ["#afeeee", "paleturquoise", "paleturquoise"], ["#ffc1c1", "rosybrown1", "rosybrown"], ["#40e0d0", "turquoise", "turquoise"], ["#a020f0", "purple", "purple"], ["#3a5fcd", "royalblue3", "royalblue"], ["#ff8c00", "dark-orange", "dark-orange"], ["#228b22", "forestgreen", "forestgreen"], ["#8b6508", "darkgoldenrod4", "darkgoldenrod"], ["#8b4c39", "salmon4", "salmon"], ["#22aa22", "color-24", "color-24"], ["#ddffdd", "color-25", "color-25"], ["#cceecc", "color-26", "color-26"], ["#aa2222", "color-27", "color-27"], ["#ffdddd", "color-28", "color-28"], ["#eecccc", "color-29", "color-29"], ["#7f7f7f", "grey50", "grey"], ["#cccccc", "grey80", "grey"], ["#cd8162", "lightsalmon3", "lightsalmon"], ["#aaaa11", "color-33", "color-33"], ["#ffffcc", "color-34", "color-34"], ["#eeeebb", "color-35", "color-35"], ["#4a708b", "skyblue4", "skyblue"], ["#6e8b3d", "darkolivegreen4", "darkolivegreen"], ["#8b6914", "goldenrod4", "goldenrod"], ["#999999", "grey60", "grey"], ["#4d4d4d", "grey30", "grey"], ["#b22222", "firebrick", "firebrick"], ["#00ff00", "green", "green"], ["#556b2f", "darkolivegreen", "darkolivegreen"], ["#8b3a3a", "indianred4", "indianred"], ["#b8860b", "darkgoldenrod", "darkgoldenrod"], ["#00ffff", "cyan", "cyan"], ["#66cdaa", "medium-aquamarine", "medium-aquamarine"], ["#ffa500", "orange", "orange"], ["#d02090", "violet-red", "violet-red"], ["#add8e6", "light-blue", "light-blue"], ["#cd5c5c", "indian-red", "indian-red"], ["#aaa", "color-52", "color-52"], ["#000", "color-53", "color-53"], ["#aa0", "color-54", "color-54"], ["#070", "color-55", "color-55"], ["#daa520", "goldenrod", "goldenrod"], ["#00bfff", "deep-sky-blue", "deep-sky-blue"], ["#ee00ee", "magenta2", "magenta"], ["#ffff00", "yellow", "yellow"], ["#6b6b6b", "color-60", "color-60"], ["#979797", "color-61", "color-61"], ["unspecified", "color-62", "color-62"], ["#223fbf", "color-63", "color-63"], ["#8f0075", "color-64", "color-64"], ["#145a00", "color-65", "color-65"], ["#804000", "color-66", "color-66"], ["#efcbcf", "color-67", "color-67"], ["#6a9fb5", "color-68", "color-68"], ["#2188b6", "color-69", "color-69"], ["#75b5aa", "color-70", "color-70"], ["#0595bd", "color-71", "color-71"], ["#446674", "color-72", "color-72"], ["#48746d", "color-73", "color-73"], ["#6d8143", "color-74", "color-74"], ["#72584b", "color-75", "color-75"], ["#915b2d", "color-76", "color-76"], ["#7e5d5f", "color-77", "color-77"], ["#694863", "color-78", "color-78"], ["#843031", "color-79", "color-79"], ["#838484", "color-80", "color-80"], ["#b48d56", "color-81", "color-81"], ["#90a959", "color-82", "color-82"], ["#677174", "color-83", "color-83"], ["#2c7d6e", "color-84", "color-84"], ["#3d6837", "color-85", "color-85"], ["#ce7a4e", "color-86", "color-86"], ["#ff505b", "color-87", "color-87"], ["#e69dd6", "color-88", "color-88"], ["#eb595a", "color-89", "color-89"], ["#7f7869", "color-90", "color-90"], ["#ff9300", "color-91", "color-91"], ["#8f5536", "color-92", "color-92"], ["#d4843e", "color-93", "color-93"], ["#fc505b", "color-94", "color-94"], ["#68295b", "color-95", "color-95"], ["#5d54e1", "color-96", "color-96"], ["#ac4142", "color-97", "color-97"], ["#716e68", "color-98", "color-98"], ["#ffcc0e", "color-99", "color-99"], ["#ffd700", "gold", "gold"], ["#8b0000", "darkred", "darkred"], ["#f0e68c", "khaki", "khaki"], ["#8b008b", "dark-magenta", "dark-magenta"], ["#ff4500", "orange-red", "orange-red"], ["#deb887", "burlywood", "burlywood"], ["#cd8500", "orange3", "orange"], ["#00008b", "dark-blue", "dark-blue"], ["#9400d3", "dark-violet", "dark-violet"], ["#8b1a1a", "firebrick4", "firebrick"], ["#fff8dc", "cornsilk", "cornsilk"], ["#f5deb3", "wheat", "wheat"], ["#cd0000", "red3", "red"], ["#0000cd", "blue3", "blue"], ["#cc9393", "color-114", "color-114"], ["#bebebe", "gray", "gray"], ["#88090b", "color-116", "color-116"], ["#707183", "color-117", "color-117"], ["#7388d6", "color-118", "color-118"], ["#909183", "color-119", "color-119"], ["#709870", "color-120", "color-120"], ["#907373", "color-121", "color-121"], ["#6276ba", "color-122", "color-122"], ["#858580", "color-123", "color-123"], ["#80a880", "color-124", "color-124"], ["#887070", "color-125", "color-125"], ["#1e90ff", "dodger-blue", "dodger-blue"], ["#ff69b4", "hot-pink", "hot-pink"], ["#da70d6", "orchid", "orchid"], ["#fa8072", "salmon", "salmon"], ["#00ff7f", "spring-green", "spring-green"], ["#800040", "color-131", "color-131"], ["#603f00", "color-132", "color-132"], ["#004476", "color-133", "color-133"], ["#2266ff", "color-134", "color-134"], ["#dd4488", "color-135", "color-135"], ["#8fbc8f", "color-136", "color-136"], ["#5f9ea0", "color-137", "color-137"], ["#ffffe0", "lightyellow1", "lightyellow"], ["#3e3c36", "color-139", "color-139"], ["#8b8989", "snow4", "snow"], ["#242424", "grey14", "grey"], ["#cd69c9", "orchid3", "orchid"], ["#dda0dd", "plum", "plum"], ["#000053", "color-144", "color-144"], ["#001970", "color-145", "color-145"], ["#002984", "color-146", "color-146"], ["#49599a", "color-147", "color-147"], ["#9499b7", "color-148", "color-148"], ["#cdc9c9", "snow3", "snow"], ["#eeb422", "goldenrod2", "goldenrod"], ["#68228b", "darkorchid4", "darkorchid"]], SYNTAX={"kw": {"fg": "#d3d3d3", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "bi": {"fg": "#d3d3d3", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "pp": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-builtin-face", "height": null, "heightMode": null}, "fnd": {"fg": "#0000ff", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "fnc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-function-name-face", "height": null, "heightMode": null}, "dec": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "ty": {"fg": "#e5e5e5", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "prop": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-variable-name-face", "height": null, "heightMode": null}, "con": {"fg": "#d3d3d3", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": {"style": "line", "color": null}, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "num": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "esc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-regexp-grouping-backslash", "height": null, "heightMode": null}, "str": {"fg": "#696969", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": "italic", "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "re": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-string-face", "height": null, "heightMode": null}, "rxgb": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "bold", "height": null, "heightMode": null}, "rxgc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "bold", "height": null, "heightMode": null}, "doc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-string-face", "height": null, "heightMode": null}, "dmark": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-constant-face", "height": null, "heightMode": null}, "cm": {"fg": "#696969", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": "italic", "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "cmd": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "font-lock-comment-face", "height": null, "heightMode": null}, "var": {"fg": "#e5e5e5", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": "italic", "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "op": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "neg": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "punc": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "p": {"fg": "#000000", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "warn": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "error", "height": null, "heightMode": null}, "bg": {"fg": "#ffffff", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}}, UIMAP={"cursor": {"fg": null, "bg": "#000000", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "region": {"fg": null, "bg": "#eedc82", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": true, "inherit": null, "height": null, "heightMode": null}, "hl-line": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": true, "inherit": "highlight", "height": null, "heightMode": null}, "highlight": {"fg": null, "bg": "#b4eeb4", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "mode-line": {"fg": "#000000", "bg": "#bfbfbf", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": {"style": "released", "width": 1, "color": null}, "inverse": false, "extend": false, "inherit": null, "height": 130, "heightMode": "abs"}, "mode-line-highlight": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": {"style": "released", "width": 1, "color": null}, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "mode-line-inactive": {"fg": "#333333", "bg": "#e5e5e5", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": {"style": "line", "width": 1, "color": "#bfbfbf"}, "inverse": false, "extend": false, "inherit": "mode-line", "height": null, "heightMode": null}, "header-line": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "tab-bar": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": 130, "heightMode": "abs"}, "tab-line": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": 130, "heightMode": "abs"}, "fringe": {"fg": null, "bg": "#f2f2f2", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "line-number": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": ["shadow", "default"], "height": 130, "heightMode": "abs"}, "line-number-current-line": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": "line-number", "height": 130, "heightMode": "abs"}, "minibuffer-prompt": {"fg": "#ff00ff", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "isearch": {"fg": "#b0e2ff", "bg": "#cd00cd", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "lazy-highlight": {"fg": null, "bg": "#afeeee", "distant-fg": "#000000", "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "isearch-fail": {"fg": null, "bg": "#ffc1c1", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "show-paren-match": {"fg": null, "bg": "#40e0d0", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "show-paren-mismatch": {"fg": "#ffffff", "bg": "#a020f0", "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "link": {"fg": "#3a5fcd", "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": {"style": "line", "color": null}, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "error": {"fg": "#ff0000", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "warning": {"fg": "#ff8c00", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "success": {"fg": "#228b22", "bg": null, "distant-fg": null, "family": null, "weight": "bold", "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}, "vertical-border": {"fg": null, "bg": null, "distant-fg": null, "family": null, "weight": null, "slant": null, "underline": null, "strike": null, "overline": null, "box": null, "inverse": false, "extend": false, "inherit": null, "height": null, "heightMode": null}};
let LOCKED=new Set([]); // rows whose choice is decided (controls disabled, skipped by erase/reset batch actions)
const DELTAE_MIN=0.02; // OKLab ΔE below this = colors too close to tell apart (perceptual-metrics spec)
const DEFAULT_UIMAP=JSON.parse(JSON.stringify(UIMAP));
@@ -1355,6 +1355,254 @@ function contrastTitle(r){
if(r>=4.5) return n+' (grey): passes WCAG AA, not AAA';
return n+' (red): fails WCAG AA';
}
+// The seeding engine (seed model + pure seed()), inlined from seed-core.js. Uses
+// oklchOf/oklch2hex from the colormath core above; the #seedtest gate runs seed().
+// seed-core.js — the theme-studio seeding engine (Phase 1).
+//
+// The seed model as data and the pure seed() operation. This is
+// theme-coloring-guide.org made executable: a named palette (OKLCH-generated
+// shades over a handful of dupre anchor hues), a role-to-treatment table (the
+// guide's seed table), and a face-to-role map for each of the three owned tiers
+// (syntax, UI, org). seed(model) classifies every face and applies the table,
+// producing default assignments in the shape the import path already consumes.
+//
+// Pure: no DOM, no side effects. node imports this module for its unit tests;
+// generate.py strips the import line and inlines the body into the page (below
+// the colormath core, so oklchOf/oklch2hex are already present) so the browser
+// #seedtest runs the same code. One source of truth, like colormath.js.
+//
+// Scope (Package scope in the spec): seed() owns syntax, UI, and org among
+// packages. The other ~20 bespoke packages keep their curated APPS seeds, so
+// seed().packages carries only org-mode; the rest flow through seedPkgmap().
+
+// --- anchors -------------------------------------------------------------
+// The base hues, taken from the bundled dupre palette. Each accent family is
+// anchored here; its quieter/brighter shades are OKLCH-derived (below). Neutrals
+// are taken directly — no ground is pure-white text, and pure black stays the
+// ground only (guide principle 5).
+const ANCHORS = {
+ ground: '#000000', 'bg-dim': '#1a1714',
+ fg: '#a9b2bb', // base identity (dupre silver): comfortable, not pure white
+ 'muted-fg': '#838d97', // structure lane (dupre steel)
+ comment: '#5e6770', // low-contrast comment lane (dupre pewter)
+ blue: '#67809c', gold: '#e8bd30', regal: '#9b5fd0',
+ sage: '#5d9b86', terracotta: '#cb6b4d',
+};
+
+// --- OKLCH shade helpers -------------------------------------------------
+// Step an anchor by a lightness delta and a chroma multiplier, hue held. A
+// quieter shade is darker + lower chroma; a brighter shade is the reverse.
+function shade(hex, dL, cMul) {
+ const { L, C, H } = oklchOf(hex);
+ return oklch2hex(clampL(L + dL), Math.max(0, C * cMul), H).hex;
+}
+// A color placed by absolute OKLCH — used for the signal hues, which are
+// conventional angles rather than shifts of a syntax accent.
+function atHue(L, C, H) { return oklch2hex(clampL(L), C, ((H % 360) + 360) % 360).hex; }
+function clampL(L) { return L < 0 ? 0 : L > 1 ? 1 : L; }
+
+// The heading ramp: one hue across four descending lightness steps (level 1
+// strongest). Deeper org levels cycle through these past level 4.
+function headingRamp(anchorHex) {
+ const { C, H } = oklchOf(anchorHex);
+ return [0.78, 0.68, 0.58, 0.48].map((L) => oklch2hex(L, C, H).hex);
+}
+
+// Build the named swatch set + heading ramp from the anchors. The blue-grey
+// builtin and gold-quiet call are the two shades dupre lacks and gains here.
+function buildModel(anchors = ANCHORS) {
+ const a = anchors;
+ const swatch = {
+ ground: a.ground, 'bg-dim': a['bg-dim'], fg: a.fg,
+ 'muted-fg': a['muted-fg'], comment: a.comment,
+ blue: a.blue,
+ 'blue-grey': shade(a.blue, -0.05, 0.5), // builtin: blue hue, lower chroma/lightness
+ gold: a.gold,
+ 'gold-quiet': shade(a.gold, -0.08, 0.6), // call: quieter same-hue gold
+ regal: a.regal,
+ sage: a.sage,
+ 'sage-muted': shade(a.sage, -0.03, 0.6), // docstring
+ 'sage-bright': shade(a.sage, 0.08, 1.15), // escape
+ teal: (() => { const { L, C, H } = oklchOf(a.sage); return oklch2hex(clampL(L + 0.05), C * 1.1, H - 25).hex; })(), // regexp
+ terracotta: a.terracotta,
+ red: atHue(0.62, 0.15, 29), // signal: error / deletion
+ amber: atHue(0.80, 0.14, 75), // signal: warning / modified
+ green: atHue(0.72, 0.14, 145),// signal: success / addition
+ tint: atHue(0.32, 0.03, oklchOf(a.blue).H), // transient state bg (quiet)
+ 'tint-strong': atHue(0.42, 0.06, oklchOf(a.blue).H), // active match chip
+ };
+ return { swatch, ramp: headingRamp(a.blue), roles: ROLES };
+}
+
+// --- the role-to-treatment table (the guide's seed table as data) --------
+// Each role maps to a swatch, an optional weight/slant/underline, and a channel
+// (fg is identity, bg is state). channel defaults to fg.
+const ROLES = {
+ base: { swatch: 'fg' },
+ structure: { swatch: 'muted-fg' },
+ control: { swatch: 'blue', weight: 'bold' },
+ builtin: { swatch: 'blue-grey' },
+ def: { swatch: 'gold', weight: 'bold' },
+ call: { swatch: 'gold-quiet' },
+ type: { swatch: 'regal' },
+ string: { swatch: 'sage' },
+ docstring: { swatch: 'sage-muted', slant: 'italic' },
+ escape: { swatch: 'sage-bright' },
+ regexp: { swatch: 'teal' },
+ literal: { swatch: 'terracotta' },
+ comment: { swatch: 'comment', slant: 'italic' },
+ sig_error: { swatch: 'red' },
+ sig_warn: { swatch: 'amber' },
+ sig_ok: { swatch: 'green' },
+ sig_link: { swatch: 'blue', underline: true },
+ state: { swatch: 'tint', channel: 'bg' },
+};
+
+// A blank full face spec; seed fills only the fields a role sets.
+function blankSpec() {
+ return { fg: null, bg: null, weight: null, slant: null, underline: null, strike: null, inherit: null, height: null };
+}
+
+// Resolve a ROLES role against the model into a face spec.
+function resolveRole(model, role) {
+ const r = model.roles[role];
+ const hex = model.swatch[r.swatch];
+ const s = blankSpec();
+ if (r.channel === 'bg') s.bg = hex; else s.fg = hex;
+ if (r.weight) s.weight = r.weight;
+ if (r.slant) s.slant = r.slant;
+ if (r.underline) s.underline = { style: 'line', color: null };
+ return s;
+}
+
+// --- face-to-role maps ---------------------------------------------------
+
+// Syntax: CATS key -> role. bg is handled specially (the ground).
+const SYNTAX_ROLES = {
+ p: 'base', var: 'base',
+ op: 'structure', punc: 'structure', neg: 'structure', cmd: 'structure',
+ kw: 'control', pp: 'control',
+ bi: 'builtin',
+ fnd: 'def', fnc: 'call',
+ dec: 'type', ty: 'type', prop: 'type',
+ con: 'literal', num: 'literal',
+ str: 'string', doc: 'docstring',
+ esc: 'escape', dmark: 'escape',
+ re: 'regexp', rxgb: 'regexp', rxgc: 'regexp',
+ cm: 'comment',
+ warn: 'sig_warn',
+};
+
+// UI: face -> either a ROLES role (state/signal/link/control) or an inline
+// chrome spec. Chrome is inherently multi-attribute (fg + bg, active vs idle),
+// so it does not force through the single-swatch role resolver.
+function uiSeed(model) {
+ const sw = model.swatch, out = {};
+ const role = (r) => resolveRole(model, r);
+ const spec = (o) => Object.assign(blankSpec(), o);
+ // Transient state: background tint, no foreground. lazy-highlight (other
+ // matches) shares the quiet tint; isearch (current match) gets a louder chip.
+ for (const f of ['region', 'hl-line', 'highlight', 'show-paren-match', 'lazy-highlight']) out[f] = role('state');
+ out.isearch = spec({ bg: sw['tint-strong'] }); // active match, louder chip
+ // Signals (convention hues) with a weight for redundancy.
+ out.error = spec({ fg: sw.red, weight: 'bold' });
+ out.warning = spec({ fg: sw.amber, weight: 'bold' });
+ out.success = spec({ fg: sw.green, weight: 'bold' });
+ out['isearch-fail'] = spec({ fg: sw.red, weight: 'bold' });
+ out['show-paren-mismatch'] = spec({ bg: sw.red }); // shape + color, not color alone
+ out.link = role('sig_link');
+ // Chrome: active brighter than idle (guide principle 3).
+ out['mode-line'] = spec({ fg: sw.fg, bg: sw['bg-dim'] });
+ out['mode-line-inactive'] = spec({ fg: sw['muted-fg'], bg: sw['bg-dim'] });
+ out['mode-line-highlight'] = spec({ fg: sw.fg });
+ for (const f of ['header-line', 'tab-bar', 'tab-line']) out[f] = spec({ fg: sw['muted-fg'], bg: sw['bg-dim'] });
+ out['line-number'] = spec({ fg: sw.comment });
+ out['line-number-current-line'] = spec({ fg: sw.fg });
+ out.fringe = spec({ fg: sw.comment });
+ out['vertical-border'] = spec({ fg: sw['bg-dim'] });
+ out['minibuffer-prompt'] = role('control');
+ out.cursor = spec({ bg: sw.fg });
+ return out;
+}
+
+// Org: face -> role/heading/inline. Faces not named here seed to base.
+const ORG_MARKUP = ['org-meta-line', 'org-drawer', 'org-special-keyword', 'org-property-value',
+ 'org-block-begin-line', 'org-block-end-line', 'org-ellipsis', 'org-tag', 'org-date',
+ 'org-document-info-keyword', 'org-macro', 'org-target', 'org-footnote-def'];
+const ORG_CODE = ['org-code', 'org-verbatim', 'org-inline-src-block'];
+const ORG_LINK = ['org-link', 'org-cite', 'org-cite-key', 'org-footnote'];
+const ORG_EMPHASIS = ['org-quote', 'org-verse'];
+
+function orgSeed(model, orgFaces) {
+ const sw = model.swatch, out = {};
+ const role = (r) => resolveRole(model, r);
+ const spec = (o) => Object.assign(blankSpec(), o);
+ for (const face of orgFaces) {
+ const lvl = /^org-level-([1-8])$/.exec(face);
+ if (lvl) {
+ const i = Number(lvl[1]);
+ out[face] = spec({ fg: model.ramp[(i - 1) % model.ramp.length], weight: i === 1 ? 'bold' : null });
+ } else if (face === 'org-document-title') {
+ out[face] = spec({ fg: sw.gold, weight: 'bold' });
+ } else if (ORG_CODE.includes(face)) {
+ out[face] = spec({ fg: sw.terracotta, inherit: 'fixed-pitch' });
+ } else if (face === 'org-block') {
+ out[face] = spec({ bg: sw['bg-dim'], inherit: 'fixed-pitch' });
+ } else if (ORG_LINK.includes(face)) {
+ out[face] = spec({ fg: sw.blue, underline: { style: 'line', color: null } });
+ } else if (ORG_MARKUP.includes(face)) {
+ out[face] = spec({ fg: sw['muted-fg'] });
+ } else if (face === 'org-todo' || face === 'org-imminent-deadline') {
+ out[face] = spec({ fg: sw.red, weight: 'bold' });
+ } else if (face === 'org-upcoming-deadline') {
+ out[face] = spec({ fg: sw.amber });
+ } else if (face === 'org-scheduled' || face === 'org-scheduled-today') {
+ out[face] = spec({ fg: sw.comment });
+ } else if (face === 'org-done' || face === 'org-headline-done' || face === 'org-agenda-done') {
+ out[face] = spec({ fg: sw.comment, strike: { color: null } });
+ } else if (ORG_EMPHASIS.includes(face)) {
+ out[face] = spec({ slant: 'italic' });
+ } else {
+ out[face] = role('base');
+ }
+ }
+ return out;
+}
+
+// The org faces the engine seeds. Kept in step with ORG_FACES in face_data.py;
+// a face present here but absent there (or the reverse) simply seeds/omits it.
+// The representative set the guide names is what matters for the tier.
+const ORG_FACES = ('org-document-title org-document-info org-document-info-keyword '
+ + 'org-level-1 org-level-2 org-level-3 org-level-4 org-level-5 org-level-6 org-level-7 org-level-8 '
+ + 'org-headline-done org-todo org-done org-priority org-tag org-special-keyword org-drawer '
+ + 'org-property-value org-warning org-link org-cite org-cite-key org-footnote org-date '
+ + 'org-macro org-target org-block org-block-begin-line org-block-end-line org-code org-verbatim '
+ + 'org-inline-src-block org-quote org-verse org-meta-line org-ellipsis '
+ + 'org-scheduled org-scheduled-today org-upcoming-deadline org-imminent-deadline '
+ + 'org-agenda-done org-table org-formula').split(' ');
+
+// --- seed(): apply the table through each tier's face-to-role map --------
+// Returns {syntax, ui, packages} default assignments. packages carries only
+// org-mode (Package scope); the non-org curated defaults flow through
+// seedPkgmap() over the APPS dicts, untouched by the engine.
+function seed(model, opts = {}) {
+ const cats = opts.cats || CATS_KEYS;
+ const orgFaces = opts.orgFaces || ORG_FACES;
+ const syntax = {};
+ for (const k of cats) {
+ if (k === 'bg') { syntax.bg = Object.assign(blankSpec(), { fg: model.swatch.ground }); continue; }
+ const rname = SYNTAX_ROLES[k] || 'base';
+ syntax[k] = resolveRole(model, rname);
+ }
+ return { syntax, ui: uiSeed(model), packages: { 'org-mode': orgSeed(model, orgFaces) } };
+}
+
+// The syntax categories the engine seeds, kept in step with CATS in generate.py.
+// Passed explicitly by the page (opts.cats) from the live CATS so the two never
+// drift; this literal is the standalone/test default.
+const CATS_KEYS = ['bg', 'p', 'kw', 'bi', 'pp', 'fnd', 'fnc', 'dec', 'ty', 'prop',
+ 'con', 'num', 'str', 'esc', 're', 'rxgb', 'rxgc', 'doc', 'dmark', 'cm', 'cmd',
+ 'var', 'op', 'neg', 'punc', 'warn'];
// Pure palette-generator planner and browser-side generator panel.
// Pure palette-generator planner. It depends on the shared palette-column model
// from app-core.js, but owns candidate hue selection, naming, contrast filtering,
@@ -4241,6 +4489,37 @@ function pkgSelftest(){
const d=document.createElement('div');d.id='selftest';d.textContent='SELFTEST '+verdict+' roundtrip='+roundtrip+' oldjson='+oldjson+' inherit='+inherited+' height='+height+' cleared='+cleared+' unknown='+unknown+' cycle='+cyc;document.body.appendChild(d);
}
if(location.hash==='#selftest')pkgSelftest();
+// Seeding-engine gate (open with #seedtest): the pure seed() projects the guide's
+// role table onto the three owned tiers. Assert representative faces land on the
+// right swatch/weight/channel, and that a non-org bespoke package (magit) keeps
+// its curated APPS seed (seed() owns org among packages, nothing else).
+if(location.hash==='#seedtest')gate('seedtest',A=>{
+ const m=buildModel();
+ const s=seed(m,{cats:CATS.map(c=>c[0])});
+ // syntax tier
+ A(s.syntax.bi.fg===m.swatch['blue-grey'],'bi=blue-grey');
+ A(s.syntax.fnd.fg===m.swatch.gold&&s.syntax.fnd.weight==='bold','fnd=gold+bold');
+ A(s.syntax.fnc.fg===m.swatch['gold-quiet']&&s.syntax.fnc.weight!=='bold','fnc=gold-quiet');
+ A(s.syntax.var.fg===m.swatch.fg,'var=base');
+ A(s.syntax.op.fg===m.swatch['muted-fg']&&s.syntax.punc.fg===m.swatch['muted-fg'],'op/punc=structure');
+ A(s.syntax.kw.fg===m.swatch.blue&&s.syntax.kw.weight==='bold','kw=control');
+ A(s.syntax.doc.slant==='italic','doc=italic');
+ A(s.syntax.bg.fg===m.swatch.ground,'bg=ground');
+ // UI tier
+ A(!!s.ui.region.bg&&!s.ui.region.fg,'region bg-only');
+ A(!!s.ui.link.underline&&s.ui.link.fg===m.swatch.blue,'link underlined');
+ A(s.ui.error.fg===m.swatch.red&&s.ui.warning.fg===m.swatch.amber&&s.ui.success.fg===m.swatch.green,'signals on convention hues');
+ A(s.ui['mode-line'].fg!==s.ui['mode-line-inactive'].fg,'active!=idle chrome');
+ // org tier
+ const org=s.packages['org-mode'];
+ A(Object.keys(s.packages).length===1,'packages=org-mode only');
+ A(oklchOf(org['org-level-1'].fg).L>oklchOf(org['org-level-2'].fg).L&&org['org-level-1'].weight==='bold','org-level-1 strongest+bold');
+ A(org['org-code'].fg===m.swatch.terracotta&&org['org-code'].inherit==='fixed-pitch','org-code literal lane');
+ A(!!org['org-done'].strike,'org-done struck');
+ // non-org bespoke package keeps its curated seed (untouched by seed())
+ const mag=seedPkgmap()['magit'];
+ A(!!mag&&!!mag['magit-section-heading']&&!!mag['magit-section-heading'].fg,'magit keeps curated seed');
+});
// Lock-mechanism gate (open with #locktest): two behaviors the refactor must
// preserve, across all three tiers. (1) Locking a row disables its controls via
// the shared mkLockCell. (2) reset/erase batch actions update editable rows but
diff --git a/tests/test-ai-term--attached-agent-dirs.el b/tests/test-ai-term--attached-agent-dirs.el
new file mode 100644
index 00000000..bdd69544
--- /dev/null
+++ b/tests/test-ai-term--attached-agent-dirs.el
@@ -0,0 +1,58 @@
+;;; test-ai-term--attached-agent-dirs.el --- Tests for cj/--ai-term-attached-agent-dirs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The queue `cj/ai-term-next-attached' (M-SPC) steps through: project dirs
+;; with a live agent BUFFER only -- attached sessions. Unlike
+;; `cj/--ai-term-active-agent-dirs', detached tmux sessions with no Emacs
+;; buffer are excluded; those are reachable only via `cj/ai-term-next'
+;; (M-S-SPC). Candidates / buffers / sessions are mocked so the enumeration
+;; logic is exercised without a real tmux server.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(ert-deftest test-ai-term--attached-agent-dirs-excludes-detached ()
+ "Normal: only dirs with a live buffer are attached; a detached session is
+excluded even though it is active."
+ (let ((buf (get-buffer-create (cj/--ai-term-buffer-name "/p/alpha"))))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates)
+ (lambda (&rest _) '("/p/alpha" "/p/beta" "/p/gamma" "/p/delta")))
+ ((symbol-function 'cj/--ai-term-agent-buffers)
+ (lambda (&rest _) (list buf)))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) (list (cj/--ai-term-tmux-session-name "/p/gamma")))))
+ ;; alpha attached (buffer), gamma detached (session): only alpha.
+ (should (equal '("/p/alpha") (cj/--ai-term-attached-agent-dirs))))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--attached-agent-dirs-multiple-sorted ()
+ "Normal: multiple attached dirs come back sorted by agent buffer name."
+ (let ((a (get-buffer-create (cj/--ai-term-buffer-name "/p/delta")))
+ (b (get-buffer-create (cj/--ai-term-buffer-name "/p/alpha"))))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates)
+ (lambda (&rest _) '("/p/alpha" "/p/beta" "/p/delta")))
+ ((symbol-function 'cj/--ai-term-agent-buffers)
+ (lambda (&rest _) (list a b)))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) nil)))
+ (should (equal '("/p/alpha" "/p/delta") (cj/--ai-term-attached-agent-dirs))))
+ (kill-buffer a)
+ (kill-buffer b))))
+
+(ert-deftest test-ai-term--attached-agent-dirs-empty-when-only-detached ()
+ "Boundary: sessions exist but no live buffers -> no attached dirs."
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda (&rest _) '("/p/solo")))
+ ((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) (list (cj/--ai-term-tmux-session-name "/p/solo")))))
+ (should (null (cj/--ai-term-attached-agent-dirs)))))
+
+(provide 'test-ai-term--attached-agent-dirs)
+;;; test-ai-term--attached-agent-dirs.el ends here
diff --git a/tests/test-ai-term--buffer-name.el b/tests/test-ai-term--buffer-name.el
index b241977d..e728bc82 100644
--- a/tests/test-ai-term--buffer-name.el
+++ b/tests/test-ai-term--buffer-name.el
@@ -38,5 +38,26 @@
(should (equal (cj/--ai-term-buffer-name "/a/b/c/d/e/leaf")
"agent [leaf]")))
+;;; Basename extraction (inverse transform)
+
+(ert-deftest test-ai-term--buffer-basename-normal-round-trip ()
+ "Normal: extracts the basename embedded in an agent buffer's name."
+ (let ((buf (get-buffer-create "agent [proj]")))
+ (unwind-protect
+ (should (equal (cj/--ai-term-buffer-basename buf) "proj"))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--buffer-basename-boundary-dotted ()
+ "Boundary: dotted basenames (.emacs.d) survive extraction intact."
+ (let ((buf (get-buffer-create "agent [.emacs.d]")))
+ (unwind-protect
+ (should (equal (cj/--ai-term-buffer-basename buf) ".emacs.d"))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--buffer-basename-error-non-agent-nil ()
+ "Error: a non-agent buffer yields nil."
+ (with-temp-buffer
+ (should (null (cj/--ai-term-buffer-basename (current-buffer))))))
+
(provide 'test-ai-term--buffer-name)
;;; test-ai-term--buffer-name.el ends here
diff --git a/tests/test-ai-term--close.el b/tests/test-ai-term--close.el
index 242bfd74..8b028351 100644
--- a/tests/test-ai-term--close.el
+++ b/tests/test-ai-term--close.el
@@ -36,8 +36,22 @@
(lambda (&rest _) (error "no tmux"))))
(should (null (cj/--ai-term-kill-tmux-session "aiv-foo")))))
+(ert-deftest test-ai-term--close-buffer-session-from-name-after-cd ()
+ "Regression: the session name comes from the immutable buffer name.
+ghostel retargets `default-directory' via OSC 7 as the shell cds, so
+deriving from it after a cd kills the wrong aiv- session (or misses,
+orphaning the agent). The buffer name's basename never changes."
+ (let ((buf (get-buffer-create "agent [proj]"))
+ captured-session)
+ (with-current-buffer buf (setq-local default-directory "/tmp/elsewhere/"))
+ (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session)
+ (lambda (s) (setq captured-session s) 0)))
+ (cj/--ai-term-close-buffer buf))
+ (should (equal captured-session "aiv-proj"))
+ (should-not (buffer-live-p buf))))
+
(ert-deftest test-ai-term--close-buffer-kills-session-and-buffer ()
- "Normal: derives the session from default-directory, kills it and the buffer."
+ "Normal: derives the session from the buffer name, kills it and the buffer."
(let ((buf (get-buffer-create "agent [foo]"))
captured-session)
(with-current-buffer buf (setq-local default-directory "/tmp/foo/"))
diff --git a/tests/test-ai-term--keybindings.el b/tests/test-ai-term--keybindings.el
index 6f7f53a5..8b1f8fa5 100644
--- a/tests/test-ai-term--keybindings.el
+++ b/tests/test-ai-term--keybindings.el
@@ -33,14 +33,18 @@
(should (eq (keymap-lookup cj/custom-keymap "a") cj/ai-term-keymap)))
(ert-deftest test-ai-term-next-bound-to-meta-space-globally ()
- "Normal: M-SPC runs `cj/ai-term-next' (the fast swap chord)."
- (should (eq (lookup-key (current-global-map) (kbd "M-SPC")) #'cj/ai-term-next)))
+ "Normal: M-SPC runs `cj/ai-term-next-attached' (cycle attached only) and
+M-S-SPC runs `cj/ai-term-next' (cycle all, including detached)."
+ (should (eq (lookup-key (current-global-map) (kbd "M-SPC")) #'cj/ai-term-next-attached))
+ (should (eq (lookup-key (current-global-map) (kbd "M-S-SPC")) #'cj/ai-term-next)))
(ert-deftest test-ai-term-meta-space-bound-in-eat-semi-char-mode-map ()
- "Normal: M-SPC is bound in `eat-semi-char-mode-map' so swap works inside an
-agent. EAT forwards unbound keys to the pty, so the bind is what lets it reach
-Emacs -- no ghostel-style exception list or rebuild is needed."
- (should (eq (keymap-lookup eat-semi-char-mode-map "M-SPC") #'cj/ai-term-next)))
+ "Normal: both swap chords are bound in `eat-semi-char-mode-map' so they work
+inside an agent. EAT forwards unbound keys to the pty, so the bind is what lets
+them reach Emacs -- no ghostel-style exception list or rebuild is needed. M-SPC
+cycles attached only; M-S-SPC cycles all."
+ (should (eq (keymap-lookup eat-semi-char-mode-map "M-SPC") #'cj/ai-term-next-attached))
+ (should (eq (keymap-lookup eat-semi-char-mode-map "M-S-SPC") #'cj/ai-term-next)))
(ert-deftest test-ai-term-f9-family-removed-globally ()
"Regression: the old F9 family no longer binds the ai-term commands globally."
diff --git a/tests/test-ai-term--quit.el b/tests/test-ai-term--quit.el
index 55ace81d..64b8a5d4 100644
--- a/tests/test-ai-term--quit.el
+++ b/tests/test-ai-term--quit.el
@@ -43,6 +43,22 @@
(should-not (buffer-live-p buf)))
(when (buffer-live-p buf) (kill-buffer buf)))))
+(ert-deftest test-ai-term-quit-nil-project-from-drifted-agent-buffer ()
+ "Regression: nil PROJECT inside an agent buffer keys off the buffer name.
+After a cd in the agent shell, ghostel's OSC 7 tracking moves the buffer's
+`default-directory' away from the project, so keying off it would kill the
+wrong session and miss the buffer."
+ (let ((buf (get-buffer-create "agent [realproj]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (with-current-buffer buf
+ (setq-local default-directory "/tmp/elsewhere/")
+ (cj/ai-term-quit))
+ (should (member '("kill-session" "-t" "aiv-realproj") calls))
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
(ert-deftest test-ai-term-quit-idempotent-when-gone ()
"Error/Boundary: a second quit (session + buffer already gone) does not error."
(let ((calls nil))
diff --git a/tests/test-ai-term--runtime.el b/tests/test-ai-term--runtime.el
new file mode 100644
index 00000000..7644e468
--- /dev/null
+++ b/tests/test-ai-term--runtime.el
@@ -0,0 +1,136 @@
+;;; test-ai-term--runtime.el --- Tests for the ai-term runtime selection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Multi-backend launch: a fresh agent session can run Claude, Codex, or a
+;; local model through codex --oss (ollama). The runtime names and launch
+;; strings mirror the rulesets bin/ai launcher so the two stay one mental
+;; model: "claude", "codex", and "local:<model>".
+;;
+;; Pure pieces tested here:
+;; - `cj/--ai-term-runtime-command' maps a runtime name to the full shell
+;; command (agent CLI + the shared opening prompt).
+;; - `cj/--ai-term-parse-runtime-lines' parses `ai --print-runtimes' output
+;; into (NAME . LABEL) choices.
+;; - `cj/--ai-term-runtime-choices' shells out to `ai' at its boundary
+;; (mocked here) and falls back to the static list when `ai' is absent.
+;; The interactive picker is a thin completing-read wrapper and is not
+;; tested (Interactive vs Internal split).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+;;; ------------------------- runtime -> command ------------------------------
+
+(ert-deftest test-ai-term-runtime-command-claude-is-agent-command ()
+ "Normal: \"claude\" (and nil) return `cj/ai-term-agent-command' verbatim."
+ (let ((cj/ai-term-agent-command "claude \"do the thing\""))
+ (should (equal (cj/--ai-term-runtime-command "claude")
+ "claude \"do the thing\""))
+ (should (equal (cj/--ai-term-runtime-command nil)
+ "claude \"do the thing\""))))
+
+(ert-deftest test-ai-term-runtime-command-codex-composes-prompt ()
+ "Normal: \"codex\" is the codex CLI plus the shared opening prompt."
+ (let ((cj/ai-term-agent-prompt "read the protocols"))
+ (should (equal (cj/--ai-term-runtime-command "codex")
+ (concat "codex " (shell-quote-argument "read the protocols"))))))
+
+(ert-deftest test-ai-term-runtime-command-local-model ()
+ "Normal: \"local:<model>\" runs codex --oss against that ollama model.
+The model is shell-quoted, and POSIX `shell-quote-argument' backslash-escapes
+the colons, so the assertion matches the quoted form."
+ (let ((cj/ai-term-agent-prompt "read the protocols"))
+ (let ((cmd (cj/--ai-term-runtime-command "local:gpt-oss:120b")))
+ (should (string-prefix-p "codex --oss --local-provider=ollama -m " cmd))
+ (should (string-match-p
+ (regexp-quote (shell-quote-argument "gpt-oss:120b")) cmd))
+ (should (string-suffix-p (shell-quote-argument "read the protocols") cmd)))))
+
+(ert-deftest test-ai-term-runtime-command-boundary-empty-model ()
+ "Boundary: \"local:\" with no model is rejected, not launched half-formed."
+ (should-error (cj/--ai-term-runtime-command "local:") :type 'user-error))
+
+(ert-deftest test-ai-term-runtime-command-error-unknown-runtime ()
+ "Error: an unknown runtime name signals a `user-error' naming it."
+ (should-error (cj/--ai-term-runtime-command "gemini") :type 'user-error)
+ (condition-case err
+ (cj/--ai-term-runtime-command "gemini")
+ (user-error (should (string-match-p "gemini" (cadr err))))))
+
+;;; --------------------------- choice-list parsing ---------------------------
+
+(ert-deftest test-ai-term-parse-runtime-lines-normal ()
+ "Normal: `ai --print-runtimes' lines parse into (NAME . LABEL) pairs."
+ (should (equal (cj/--ai-term-parse-runtime-lines
+ "claude — Claude Code\ncodex — ChatGPT (Codex CLI)\nlocal:gpt-oss:120b — ollama\n")
+ '(("claude" . "Claude Code")
+ ("codex" . "ChatGPT (Codex CLI)")
+ ("local:gpt-oss:120b" . "ollama")))))
+
+(ert-deftest test-ai-term-parse-runtime-lines-boundary-junk ()
+ "Boundary: blank lines and lines without a separator are dropped."
+ (should (equal (cj/--ai-term-parse-runtime-lines
+ "\nclaude — Claude Code\nwarning: something\n\n")
+ '(("claude" . "Claude Code")))))
+
+(ert-deftest test-ai-term-parse-runtime-lines-boundary-empty ()
+ "Boundary: empty output parses to nil."
+ (should (null (cj/--ai-term-parse-runtime-lines ""))))
+
+;;; ------------------------------ choice list --------------------------------
+
+(ert-deftest test-ai-term-runtime-choices-uses-ai-launcher ()
+ "Normal: when the `ai' launcher exists, its runtime list is the choice list."
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai")))
+ ((symbol-function 'process-file)
+ (lambda (_prog _infile buffer _display &rest _args)
+ (with-current-buffer (cond ((eq buffer t) (current-buffer))
+ ((consp buffer) (car buffer))
+ (t buffer))
+ (insert "claude — Claude Code\nlocal:q — ollama\n"))
+ 0)))
+ (should (equal (cj/--ai-term-runtime-choices)
+ '(("claude" . "Claude Code") ("local:q" . "ollama"))))))
+
+(ert-deftest test-ai-term-runtime-choices-fallback-without-ai ()
+ "Boundary: with no `ai' launcher, the static claude-first list stands."
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil)))
+ (let ((choices (cj/--ai-term-runtime-choices)))
+ (should (equal (caar choices) "claude"))
+ (should (assoc "codex" choices)))))
+
+(ert-deftest test-ai-term-runtime-choices-error-ai-fails ()
+ "Error: a nonzero exit from `ai' falls back instead of erroring."
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai")))
+ ((symbol-function 'process-file) (lambda (&rest _) 1)))
+ (should (equal (caar (cj/--ai-term-runtime-choices)) "claude"))))
+
+;;; --------------------- launch command takes an override --------------------
+
+(ert-deftest test-ai-term-launch-command-runtime-override ()
+ "Normal: an explicit agent command is embedded instead of the default.
+The inner command is shell-quoted by the launch builder, so the assertion
+matches the quoted form."
+ (let ((cj/ai-term-agent-command "claude default"))
+ (let ((cmd (cj/--ai-term-launch-command "/tmp/proj" "codex prompted")))
+ (should (string-match-p
+ (regexp-quote (shell-quote-argument "codex prompted; exec bash"))
+ cmd))
+ (should-not (string-match-p "claude" cmd)))))
+
+(ert-deftest test-ai-term-launch-command-no-override-falls-back ()
+ "Boundary: without an override the configured agent command is used."
+ (let ((cj/ai-term-agent-command "claude default"))
+ (should (string-match-p
+ (regexp-quote (shell-quote-argument "claude default; exec bash"))
+ (cj/--ai-term-launch-command "/tmp/proj")))))
+
+(provide 'test-ai-term--runtime)
+;;; test-ai-term--runtime.el ends here
diff --git a/tests/test-ai-term--session-threading.el b/tests/test-ai-term--session-threading.el
new file mode 100644
index 00000000..984f76d7
--- /dev/null
+++ b/tests/test-ai-term--session-threading.el
@@ -0,0 +1,40 @@
+;;; test-ai-term--session-threading.el --- Session-list threading tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The launch path used to call cj/--ai-term-live-tmux-sessions (a tmux
+;; subprocess) once in the project picker, again for the launcher's fresh
+;; check, and a third time inside show-or-create. One fetch per launch,
+;; threaded through, is the contract pinned here.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(ert-deftest test-ai-term-launch-fetches-tmux-sessions-once ()
+ "Normal: one tmux session fetch per launch, threaded to the picker and
+show-or-create rather than re-spawned by each."
+ (let ((fetches 0) received-sessions)
+ (cl-letf (((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda () (setq fetches (1+ fetches)) '("other")))
+ ((symbol-function 'cj/--ai-term-candidates)
+ (lambda () '("/tmp/proj/")))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt table &rest _)
+ (car (all-completions "" table))))
+ ((symbol-function 'get-buffer) (lambda (_n) nil))
+ ((symbol-function 'cj/--ai-term-pick-runtime) (lambda () 'claude))
+ ((symbol-function 'cj/--ai-term-runtime-command) (lambda (_r) "cmd"))
+ ((symbol-function 'cj/--ai-term-show-or-create)
+ (lambda (_dir _name _cmd &optional sessions)
+ (setq received-sessions sessions)
+ (current-buffer)))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) nil)))
+ (cj/ai-term-pick-project t)
+ (should (= fetches 1))
+ (should (equal received-sessions '("other"))))))
+
+(provide 'test-ai-term--session-threading)
+;;; test-ai-term--session-threading.el ends here
diff --git a/tests/test-ai-term--show-or-create.el b/tests/test-ai-term--show-or-create.el
index 9574b8a6..64e37653 100644
--- a/tests/test-ai-term--show-or-create.el
+++ b/tests/test-ai-term--show-or-create.el
@@ -122,7 +122,8 @@ dashboard put and letting the alist place agent into a fresh split.
This test stubs `eat' to mimic the same-window side-effect and asserts the
originally-selected window still shows its original buffer afterward."
(let ((agent-name "agent [preserve-window-test]")
- (orig-name "*test-original-buffer*"))
+ (orig-name "*test-original-buffer*")
+ (subprocess-calls nil))
(test-ai-term--cleanup agent-name)
(when (get-buffer orig-name) (kill-buffer orig-name))
(unwind-protect
@@ -138,9 +139,21 @@ originally-selected window still shows its original buffer afterward."
(set-window-buffer (selected-window) buf)
buf)))
((symbol-function 'cj/--ai-term-send-string)
- (lambda (_buf _s) nil)))
+ (lambda (_buf _s) nil))
+ ;; Same hermetic seams the shared macro mocks: no tmux
+ ;; subprocess for the fresh-session check, no /color timer.
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda () nil))
+ ((symbol-function 'cj/--ai-term-schedule-color)
+ (lambda (_buffer _color) nil))
+ ;; Leak guard: any subprocess attempt is recorded, not run.
+ ;; `cj/--ai-term-live-tmux-sessions' swallows errors, so a
+ ;; signaling barrier would pass silently -- record and assert.
+ ((symbol-function 'process-file)
+ (lambda (&rest _) (push 'process-file subprocess-calls) -1)))
(cj/--ai-term-show-or-create "/tmp/preserve" agent-name)
- (should (eq (window-buffer orig-win) orig-buf)))))
+ (should (eq (window-buffer orig-win) orig-buf))
+ (should-not subprocess-calls))))
(test-ai-term--cleanup agent-name)
(when (get-buffer orig-name) (kill-buffer orig-name)))))
diff --git a/tests/test-auto-dim-config.el b/tests/test-auto-dim-config.el
index 2686b88f..dcab7eff 100644
--- a/tests/test-auto-dim-config.el
+++ b/tests/test-auto-dim-config.el
@@ -30,11 +30,220 @@
(progn
(should (bound-and-true-p auto-dim-other-buffers-mode))
(should (null auto-dim-other-buffers-dim-on-focus-out))
- (should (eq t auto-dim-other-buffers-dim-on-switch-to-minibuffer))
+ ;; Entering the minibuffer must not change what is dimmed: a dim window
+ ;; stays dim, a lit one stays lit. The fork's `adob--update' returns
+ ;; early when this is nil and the selected window is the minibuffer, so
+ ;; nil is what keeps a minibuffer prompt from re-dimming the window the
+ ;; user was just in.
+ (should (null auto-dim-other-buffers-dim-on-switch-to-minibuffer))
(should-not (assq 'fringe auto-dim-other-buffers-affected-faces)))
(when (fboundp 'auto-dim-other-buffers-mode)
(auto-dim-other-buffers-mode -1))))
+(defconst test-auto-dim--flat-dimmed-org-faces
+ (append (mapcar (lambda (n) (intern (format "org-level-%d" n)))
+ (number-sequence 1 8))
+ '(org-link org-tag
+ ;; Document header: #+TITLE:, #+AUTHOR:, #+ARCHIVE: and their values.
+ org-document-title org-document-info org-document-info-keyword
+ org-meta-line
+ ;; Inline markup and blocks.
+ org-code org-verbatim org-block-begin-line org-block-end-line
+ ;; Drawers, properties, planning lines.
+ org-drawer org-special-keyword org-property-value org-date
+ ;; Tables and the fold indicator.
+ org-table org-table-row org-ellipsis))
+ "Org faces that must flat-dim to the `auto-dim-other-buffers' face.
+These carry structure, not status: nothing about them needs to stay
+readable in a window the user is not looking at. Excluded on purpose are
+`org-todo' and `org-priority' (keyword class -- see the -dim variant test
+below) and `org-hide' (needs `auto-dim-other-buffers-hide' so folded text
+stays hidden).")
+
+(defconst test-auto-dim--flat-dimmed-link-faces
+ '(link link-visited)
+ "Built-in link faces that must flat-dim, distinct from `org-link'.
+They fontify links in help, info, and customize buffers. Both carry
+`:underline t', which survives the relative remap, so a dimmed link still
+reads as a link.")
+
+(defconst test-auto-dim--flat-dimmed-superstar-faces
+ '(org-superstar-header-bullet org-superstar-item org-superstar-first)
+ "org-superstar faces that must flat-dim.
+org-superstar puts its own face ahead of the org face beneath, so a heading
+star renders as (org-superstar-header-bullet org-level-1) and wins over the
+dimmed org-level-1. Without these, bullets stay lit in an unfocused window
+even though every face under them dims.
+`org-superstar-leading' is excluded on purpose -- see the test below.")
+
+(defconst test-auto-dim--hide-class-faces
+ '(org-hide org-superstar-leading org-indent)
+ "Faces whose foreground IS the background colour.
+That is what makes them invisible. They take `auto-dim-other-buffers-hide',
+never the flat dim, which would paint them visible grey.")
+
+(defconst test-auto-dim--no-foreground-faces
+ '(bold italic underline)
+ "Faces that carry no foreground, even through inheritance.
+They set weight, slant or underline only, so text wearing them takes its
+colour from `default', which is already remapped. They need no entry and
+must not gain one, or the alist grows entries that do nothing.")
+
+(defconst test-auto-dim--keyword-dim-variants
+ '((org-faces-todo . org-faces-todo-dim)
+ (org-faces-doing . org-faces-doing-dim)
+ (org-faces-priority-a . org-faces-priority-a-dim))
+ "Sample of keyword faces that must keep dedicated -dim variants.")
+
+(ert-deftest test-auto-dim-config-org-structure-faces-flat-dim ()
+ "Normal: org heading, link, and tag faces remap to the flat dim face."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face test-auto-dim--flat-dimmed-org-faces)
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers (car (cdr entry))))
+ (should (null (cdr (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-link-faces-flat-dim ()
+ "Normal: the built-in `link' and `link-visited' faces flat-dim.
+Without these, links in help, info, and customize buffers stay lit while
+the rest of an unfocused window fades. `org-link' is a separate face and
+is covered above."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face test-auto-dim--flat-dimmed-link-faces)
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers (car (cdr entry))))
+ (should (null (cdr (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-link-underline-survives-the-remap ()
+ "Boundary: the dim face sets no `:underline', so the link cue survives.
+A relative remap layers the dim face over the base face, so an underline
+the dim face does not specify falls through from `link'. If the theme ever
+gives `auto-dim-other-buffers' an `:underline', dimmed links stop looking
+like links and this test says so."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (should (eq 'unspecified
+ (face-attribute 'auto-dim-other-buffers :underline nil nil))))
+
+(ert-deftest test-auto-dim-config-superstar-bullets-flat-dim ()
+ "Normal: org-superstar's heading stars and list bullets flat-dim.
+They were the last thing left lit in an unfocused org window."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face test-auto-dim--flat-dimmed-superstar-faces)
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers (car (cdr entry))))
+ (should (null (cdr (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-superstar-leading-uses-hide-face ()
+ "Error: `org-superstar-leading' takes the -hide face, never the flat dim.
+Its foreground is the background colour, which is what keeps hidden leading
+stars invisible. Flat-dimming it would give them the dim face's visible grey
+and reveal stars the user chose to hide. Same contract as `org-hide'."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (should-not (memq 'org-superstar-leading
+ test-auto-dim--flat-dimmed-superstar-faces))
+ (let ((entry (assq 'org-superstar-leading auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers-hide (car (cdr entry))))))
+
+(ert-deftest test-auto-dim-config-hide-class-faces-use-hide-face ()
+ "Error: every background-coloured face takes the -hide face.
+`org-hide', `org-superstar-leading' and `org-indent' all resolve to the
+background colour, which is what keeps folded text, leading stars and indent
+prefixes invisible. Flat-dimming any of them reveals what the user hid."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face test-auto-dim--hide-class-faces)
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers-hide (car (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-no-org-face-left-unmapped ()
+ "Boundary: a fontified org buffer uses no face we forgot to handle.
+Four rounds of this bug all had the same shape: a face nobody enumerated,
+sitting ahead of a mapped face in a face list and outranking it. This walks
+a representative buffer, collects every face it actually uses (including the
+`line-prefix' and `wrap-prefix' org-indent hangs its faces on), and fails on
+anything that is neither mapped nor deliberately excluded.
+
+Built-in org only. org-superstar and org-drill are elpa packages, and the
+test run has no `package-initialize', so their faces are pinned by name in
+the tests above instead."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (require 'org)
+ (let ((used (make-hash-table :test #'eq))
+ (allowed (append test-auto-dim--no-foreground-faces
+ ;; Keyword class: deliberately unmapped so status stays
+ ;; readable in an unfocused window. Pinned by
+ ;; test-auto-dim-config-todo-priority-faces-not-flat-dimmed.
+ '(org-todo org-priority)
+ (mapcar #'car auto-dim-other-buffers-affected-faces))))
+ (with-temp-buffer
+ (insert "#+TITLE: T\n#+AUTHOR: A\n\n* H1 :tag:\n** TODO [#A] task\n"
+ "DEADLINE: <2026-07-10 Fri>\n:PROPERTIES:\n:K: v\n:END:\n"
+ "Body ~verbatim~ =code= [[https://x.org][link]].\n"
+ "| a | b |\n|---+---|\n| 1 | 2 |\n"
+ "#+begin_src sh\necho hi\n#+end_src\n"
+ "- [X] done item\n")
+ (org-mode)
+ (font-lock-ensure)
+ (let ((p (point-min)))
+ (while (< p (point-max))
+ (dolist (f (let ((v (get-text-property p 'face)))
+ (if (listp v) v (list v))))
+ (when (and f (symbolp f)) (puthash f t used)))
+ (dolist (prop '(line-prefix wrap-prefix))
+ (let ((s (get-text-property p prop)))
+ (when (stringp s)
+ (dolist (f (let ((v (get-text-property 0 'face s)))
+ (if (listp v) v (list v))))
+ (when (and f (symbolp f)) (puthash f t used))))))
+ (setq p (1+ p)))))
+ (let (unmapped)
+ (maphash (lambda (face _v)
+ (unless (memq face allowed) (push face unmapped)))
+ used)
+ (should (equal nil (sort unmapped #'string<))))))
+
+(ert-deftest test-auto-dim-config-keyword-faces-keep-dim-variants ()
+ "Boundary: org TODO-keyword faces keep dedicated -dim variants, not flat dim.
+Keyword status is scanned across unfocused windows, so it earns a variant;
+heading colour does not. Guards the flat-dim change from over-reaching."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (pair test-auto-dim--keyword-dim-variants)
+ (let ((entry (assq (car pair) auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq (cdr pair) (car (cdr entry)))))))
+
+(ert-deftest test-auto-dim-config-todo-priority-faces-not-flat-dimmed ()
+ "Boundary: `org-todo' and `org-priority' are never flat-dimmed.
+They are keyword class. Dimming them would erase the status colour the
+-dim variants exist to preserve, so they stay out of the flat-dim set."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (dolist (face '(org-todo org-priority))
+ (should-not (memq face test-auto-dim--flat-dimmed-org-faces))
+ (let ((entry (assq face auto-dim-other-buffers-affected-faces)))
+ (should-not (and entry (eq 'auto-dim-other-buffers (car (cdr entry))))))))
+
+(ert-deftest test-auto-dim-config-org-hide-uses-hide-face ()
+ "Boundary: `org-hide' remaps to the -hide face, not the flat dim face.
+Flat-dimming it would give folded text a visible foreground."
+ (skip-unless (file-directory-p test-auto-dim--fork))
+ (require 'auto-dim-config)
+ (let ((entry (assq 'org-hide auto-dim-other-buffers-affected-faces)))
+ (should entry)
+ (should (eq 'auto-dim-other-buffers-hide (car (cdr entry))))))
+
(ert-deftest test-auto-dim-config-never-dim-dashboard-exempts-dashboard ()
"Normal: the *dashboard* buffer is exempt from dimming."
(skip-unless (file-directory-p test-auto-dim--fork))
diff --git a/tests/test-browser-config--preferred-default.el b/tests/test-browser-config--preferred-default.el
new file mode 100644
index 00000000..113ad540
--- /dev/null
+++ b/tests/test-browser-config--preferred-default.el
@@ -0,0 +1,89 @@
+;;; test-browser-config--preferred-default.el --- Tests for the first-run browser pick -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/--preferred-default-browser, the pure helper that picks
+;; the first-run default when no saved choice exists.
+;;
+;; The behavior it fixes: EWW is listed first in `cj/browser-definitions' and
+;; carries a nil executable, so `cj/discover-browsers' always reports it as
+;; available and it sorted first. A fresh machine therefore opened every org
+;; link in the Emacs text browser until the user found cj/choose-browser, even
+;; with Chrome or Firefox installed. The helper prefers a real external
+;; browser and keeps EWW as the genuine last resort.
+;;
+;; The helper takes the discovered list as an argument rather than calling
+;; `cj/discover-browsers' itself, so these tests drive real data structures
+;; and never stub executable-find.
+;;
+;; Test organization:
+;; - Normal Cases: external browser preferred over a leading built-in
+;; - Boundary Cases: only built-ins, only externals, single entry, empty list
+;; - Error Cases: entries missing the :executable key
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'browser-config)
+
+(defconst test-browser--eww
+ '(:executable nil :function eww-browse-url :name "EWW (Emacs Browser)"
+ :path nil :program-var nil))
+
+(defconst test-browser--chrome
+ '(:executable "google-chrome" :function browse-url-chrome :name "Google Chrome"
+ :path "/usr/bin/google-chrome" :program-var browse-url-chrome-program))
+
+(defconst test-browser--firefox
+ '(:executable "firefox" :function browse-url-firefox :name "Firefox"
+ :path "/usr/bin/firefox" :program-var browse-url-firefox-program))
+
+;;; Normal Cases
+
+(ert-deftest test-browser-config-preferred-default-skips-leading-builtin ()
+ "Normal: an installed external browser wins over a built-in listed first."
+ (should (equal (cj/--preferred-default-browser
+ (list test-browser--eww test-browser--chrome))
+ test-browser--chrome)))
+
+(ert-deftest test-browser-config-preferred-default-keeps-external-order ()
+ "Normal: the FIRST external in list order wins, not merely any external."
+ (should (equal (cj/--preferred-default-browser
+ (list test-browser--eww test-browser--chrome test-browser--firefox))
+ test-browser--chrome)))
+
+;;; Boundary Cases
+
+(ert-deftest test-browser-config-preferred-default-builtin-only-falls-back ()
+ "Boundary: with no external installed, the built-in is still chosen."
+ (should (equal (cj/--preferred-default-browser (list test-browser--eww))
+ test-browser--eww)))
+
+(ert-deftest test-browser-config-preferred-default-external-only ()
+ "Boundary: a list of externals returns the first one."
+ (should (equal (cj/--preferred-default-browser
+ (list test-browser--firefox test-browser--chrome))
+ test-browser--firefox)))
+
+(ert-deftest test-browser-config-preferred-default-empty-list-is-nil ()
+ "Boundary: an empty discovery result yields nil, never an error."
+ (should (null (cj/--preferred-default-browser '()))))
+
+(ert-deftest test-browser-config-preferred-default-single-builtin ()
+ "Boundary: a one-element built-in list returns that element."
+ (should (equal (cj/--preferred-default-browser (list test-browser--eww))
+ test-browser--eww)))
+
+;;; Error Cases
+
+(ert-deftest test-browser-config-preferred-default-missing-executable-key ()
+ "Error: a plist with no :executable key counts as built-in, not a crash."
+ (let ((malformed '(:name "Odd" :function ignore)))
+ (should (equal (cj/--preferred-default-browser
+ (list malformed test-browser--chrome))
+ test-browser--chrome))
+ (should (equal (cj/--preferred-default-browser (list malformed))
+ malformed))))
+
+(provide 'test-browser-config--preferred-default)
+;;; test-browser-config--preferred-default.el ends here
diff --git a/tests/test-calendar-sync--apply-recurrence-exceptions.el b/tests/test-calendar-sync--apply-recurrence-exceptions.el
index 7711c5cb..999296fb 100644
--- a/tests/test-calendar-sync--apply-recurrence-exceptions.el
+++ b/tests/test-calendar-sync--apply-recurrence-exceptions.el
@@ -153,5 +153,36 @@ NEW-* values are the rescheduled time."
(and (= 1 (length result))
(= 9 (nth 3 (plist-get (car result) :start)))))))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-normal-cancelled-removes ()
+ "Normal: a cancelled exception removes its occurrence instead of overriding it."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 10 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 17 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Feb 10 is cancelled.
+ (puthash "test-event@google.com"
+ (list (append (test-make-exception-data 2026 2 10 9 0 2026 2 10 9 0)
+ '(:cancelled t)))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 2 (length result)))
+ ;; Feb 3 and Feb 17 remain; Feb 10 is gone.
+ (should (equal '(3 17)
+ (mapcar (lambda (occ) (nth 2 (plist-get occ :start))) result))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-boundary-cancelled-no-match-keeps-all ()
+ "Boundary: a cancelled exception that matches nothing removes nothing."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Cancelled exception targets a different date.
+ (puthash "test-event@google.com"
+ (list (append (test-make-exception-data 2026 3 3 9 0 2026 3 3 9 0)
+ '(:cancelled t)))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 1 (length result))))))
+
(provide 'test-calendar-sync--apply-recurrence-exceptions)
;;; test-calendar-sync--apply-recurrence-exceptions.el ends here
diff --git a/tests/test-calendar-sync--expand-daily.el b/tests/test-calendar-sync--expand-daily.el
index 43b93664..0db9f345 100644
--- a/tests/test-calendar-sync--expand-daily.el
+++ b/tests/test-calendar-sync--expand-daily.el
@@ -176,5 +176,52 @@
(occurrences (calendar-sync--expand-daily base-event rrule range)))
(should (= (length occurrences) 5))))
+;;; UNTIL is inclusive (RFC 5545 3.3.10)
+
+(ert-deftest test-calendar-sync--expand-daily-until-includes-the-until-date ()
+ "Boundary: an occurrence falling ON the UNTIL date is kept.
+RFC 5545 3.3.10: UNTIL bounds the recurrence \"in an inclusive manner\", and
+when it lines up with the recurrence that date \"becomes the last instance\".
+A strict before-comparison drops it, so the last meeting of every bounded
+series silently vanishes from the agenda."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-date (test-calendar-sync-time-date-only 5))
+ (base-event (list :summary "Bounded Standup" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range))
+ (last-start (plist-get (car (last occurrences)) :start)))
+ ;; day+1 through day+5 inclusive = 5 occurrences.
+ (should (= (length occurrences) 5))
+ ;; The final occurrence is the UNTIL date itself.
+ (should (equal (seq-take last-start 3) until-date))))
+
+(ert-deftest test-calendar-sync--expand-daily-until-excludes-dates-after-it ()
+ "Boundary: inclusivity stops at UNTIL; the next day is not generated.
+Guards the fix against over-correcting into an off-by-one the other way."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-date (test-calendar-sync-time-date-only 5))
+ (day-after (test-calendar-sync-time-date-only 6))
+ (base-event (list :summary "Bounded Standup" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range))
+ (dates (mapcar (lambda (o) (seq-take (plist-get o :start) 3)) occurrences)))
+ (should (member until-date dates))
+ (should-not (member day-after dates))))
+
+(ert-deftest test-calendar-sync--expand-daily-until-on-start-date-yields-one ()
+ "Boundary: UNTIL equal to the start date yields exactly that one occurrence.
+The degenerate single-instance series -- a strict comparison returns nothing
+at all here, which is the same defect at its smallest."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-date (test-calendar-sync-time-date-only 1))
+ (base-event (list :summary "One Shot" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (should (= (length occurrences) 1))
+ (should (equal (seq-take (plist-get (car occurrences) :start) 3) until-date))))
+
(provide 'test-calendar-sync--expand-daily)
;;; test-calendar-sync--expand-daily.el ends here
diff --git a/tests/test-calendar-sync--expand-monthly.el b/tests/test-calendar-sync--expand-monthly.el
index 3dc1f2dc..41b196a7 100644
--- a/tests/test-calendar-sync--expand-monthly.el
+++ b/tests/test-calendar-sync--expand-monthly.el
@@ -170,5 +170,124 @@
(occurrences (calendar-sync--expand-monthly base-event rrule range)))
(should (= (length occurrences) 3))))
+;;; BYDAY (nth weekday) Cases
+;;
+;; Fixed dates are deterministic here: the expansion range is an explicit
+;; parameter, not derived from the current time, so these never age out.
+
+(defun test-calendar-sync--expand-monthly-range-2026 ()
+ "Fixed expansion range covering calendar year 2026."
+ (list (encode-time 0 0 0 1 1 2026) (encode-time 0 0 0 31 12 2026)))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-second-wednesday ()
+ "Normal: BYDAY=2WE lands on the 2nd Wednesday of each month, not the
+day-of-month of DTSTART. This is the live Craig/Ryan series shape."
+ (let* ((base-event (list :summary "2nd Wednesday"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE")))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 14) (2 11) (3 11) (4 8) (5 13) (6 10)
+ (7 8) (8 12) (9 9) (10 14) (11 11) (12 9))))
+ ;; Every occurrence is a Wednesday (weekday 3), never a fixed day-of-month.
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= 3 (calendar-sync--date-weekday
+ (list (nth 0 s) (nth 1 s) (nth 2 s)))))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-last-tuesday ()
+ "Normal: BYDAY=-1TU lands on the last Tuesday of each month."
+ (let* ((base-event (list :summary "Last Tuesday"
+ :start '(2026 1 27 9 0)
+ :end '(2026 1 27 10 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("-1TU") :count 3))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 27) (2 24) (3 31))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-bysetpos-second-sunday ()
+ "Normal: BYDAY=SU with BYSETPOS=2 lands on the 2nd Sunday (Proton shape)."
+ (let* ((base-event (list :summary "2nd Sunday"
+ :start '(2026 1 11 8 0)
+ :end '(2026 1 11 9 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("SU") :bysetpos 2 :count 3))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 11) (2 8) (3 8))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-until-inclusive-and-reached ()
+ "Boundary: with BYDAY, UNTIL is inclusive and the series reaches it.
+Upper bound alone can't catch a dropped final occurrence -- assert both."
+ (let* ((base-event (list :summary "Bounded"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE")
+ :until '(2026 5 13)))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (last-start (plist-get (car (last occurrences)) :start)))
+ (should (= (length occurrences) 5))
+ ;; Reach: the occurrence landing exactly on UNTIL is kept.
+ (should (equal (list (nth 0 last-start) (nth 1 last-start) (nth 2 last-start))
+ '(2026 5 13)))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-count-limits ()
+ "Boundary: COUNT caps a BYDAY series."
+ (let* ((base-event (list :summary "Counted"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE") :count 4))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (= (length occurrences) 4))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-fifth-weekday-skips-short-months ()
+ "Boundary: BYDAY=5WE only lands in months that have a 5th Wednesday."
+ (let* ((base-event (list :summary "5th Wednesday"
+ :start '(2026 4 29 10 0)
+ :end '(2026 4 29 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("5WE")))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ ;; 2026 months (Apr on) with a 5th Wednesday: Apr 29, Jul 29, Sep 30, Dec 30.
+ (should (equal days '((4 29) (7 29) (9 30) (12 30))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-on-31st-skips-short-months ()
+ "Boundary: a plain monthly rule on the 31st skips months without a 31st.
+The stepper kept day-of-month verbatim, so Jan 31 stepped to Feb 31,
+which encode-time normalizes to Mar 3 -- phantom mis-dated occurrences
+instead of the RFC 5545 skip."
+ (let* ((base-event (list :summary "Monthly on the 31st"
+ :start '(2030 1 31 10 0)
+ :end '(2030 1 31 11 0)))
+ (rrule (list :freq 'monthly :interval 1))
+ ;; End the range past Dec 31: the range end is midnight, so ending
+ ;; ON the 31st would exclude that day's 10:00 occurrence.
+ (range (list (calendar-sync--date-to-time '(2030 1 1))
+ (calendar-sync--date-to-time '(2031 1 1))))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (months (mapcar (lambda (o) (nth 1 (plist-get o :start))) occurrences))
+ (days (mapcar (lambda (o) (nth 2 (plist-get o :start))) occurrences)))
+ ;; Only the seven 31-day months of 2030, each on the 31st.
+ (should (equal months '(1 3 5 7 8 10 12)))
+ (should (equal days '(31 31 31 31 31 31 31)))))
+
(provide 'test-calendar-sync--expand-monthly)
;;; test-calendar-sync--expand-monthly.el ends here
diff --git a/tests/test-calendar-sync--expand-weekly.el b/tests/test-calendar-sync--expand-weekly.el
index a6143bce..0639ca07 100644
--- a/tests/test-calendar-sync--expand-weekly.el
+++ b/tests/test-calendar-sync--expand-weekly.el
@@ -270,5 +270,33 @@
(should (= (length occurrences) 0)))
(test-calendar-sync--expand-weekly-teardown)))
+;;; UNTIL is inclusive (RFC 5545 3.3.10)
+
+(ert-deftest test-calendar-sync--expand-weekly-until-includes-the-until-date ()
+ "Boundary: a weekly occurrence landing ON the UNTIL date is kept.
+The weekly loop checks UNTIL in two places -- the outer week-stepping guard
+and the per-weekday check inside it -- so it needs its own guard rather than
+inheriting the daily one. Fixing the loop without this test left weekly
+silently unprotected: reverting the fix failed three daily tests and zero
+weekly ones."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ ;; Omitting :byday makes the series recur on the start date's own weekday,
+ ;; which anchors UNTIL exactly on an occurrence without hardcoding a date.
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 9 0))
+ (week-2 (test-calendar-sync-time-date-only 8))
+ (until-date (test-calendar-sync-time-date-only 15))
+ (week-4 (test-calendar-sync-time-date-only 22))
+ (base-event (list :summary "Bounded Weekly" :start start-date))
+ (rrule (list :freq 'weekly :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range))
+ (dates (mapcar (lambda (o) (seq-take (plist-get o :start) 3)) occurrences)))
+ ;; day+1, +8, +15 are consecutive same-weekday dates; UNTIL is the third.
+ (should (equal dates (list (seq-take start-date 3) week-2 until-date)))
+ ;; Inclusivity stops at UNTIL -- the following week is not generated.
+ (should-not (member week-4 dates)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
(provide 'test-calendar-sync--expand-weekly)
;;; test-calendar-sync--expand-weekly.el ends here
diff --git a/tests/test-calendar-sync--expand-yearly.el b/tests/test-calendar-sync--expand-yearly.el
index ad9b8f27..c636e54a 100644
--- a/tests/test-calendar-sync--expand-yearly.el
+++ b/tests/test-calendar-sync--expand-yearly.el
@@ -175,5 +175,31 @@
(occurrences (calendar-sync--expand-yearly base-event rrule range)))
(should (= (length occurrences) 2))))
+;;; BYMONTH + BYDAY (nth weekday) Cases
+;;
+;; Fixed dates are deterministic here: the expansion range is an explicit
+;; parameter, not derived from the current time.
+
+(ert-deftest test-calendar-sync--expand-yearly-bymonth-byday-nth-weekday ()
+ "Normal: FREQ=YEARLY;BYMONTH=3;BYDAY=2SU tracks the 2nd Sunday of March
+each year (the DST clock-change shape), not DTSTART's calendar date."
+ (let* ((base-event (list :summary "Clocks change"
+ :start '(2026 3 8 2 0)
+ :end '(2026 3 8 3 0)))
+ (rrule (list :freq 'yearly :interval 1 :bymonth 3 :byday '("2SU")))
+ (range (list (encode-time 0 0 0 1 1 2026)
+ (encode-time 0 0 0 31 12 2027)))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 0 s) (nth 1 s) (nth 2 s))))
+ occurrences)))
+ ;; 2nd Sunday of March: 2026-03-08, 2027-03-14 -- different day-of-month.
+ (should (equal days '((2026 3 8) (2027 3 14))))
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= 7 (calendar-sync--date-weekday
+ (list (nth 0 s) (nth 1 s) (nth 2 s)))))))))
+
(provide 'test-calendar-sync--expand-yearly)
;;; test-calendar-sync--expand-yearly.el ends here
diff --git a/tests/test-calendar-sync--format-timestamp.el b/tests/test-calendar-sync--format-timestamp.el
index 5b8a6d02..b84e625d 100644
--- a/tests/test-calendar-sync--format-timestamp.el
+++ b/tests/test-calendar-sync--format-timestamp.el
@@ -56,5 +56,59 @@
;; start-hour is nil so time-str should be nil
(should-not (string-match-p "[0-9][0-9]:[0-9][0-9]-" result))))
+;;; Multi-day spans (org range syntax)
+
+(ert-deftest test-calendar-sync--format-timestamp-multi-day-timed-spans-dates ()
+ "A timed event ending on a later date renders as an org range.
+The end DATE used to be discarded and only its time kept, so a four-day
+conference produced the same timestamp as a same-day meeting and the agenda
+showed it on day one only."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 14 0) '(2026 3 18 15 30))))
+ (should (equal result "<2026-03-15 Sun 14:00>--<2026-03-18 Wed 15:30>"))))
+
+(ert-deftest test-calendar-sync--format-timestamp-multi-day-all-day-excludes-dtend ()
+ "An all-day span ends the day before DTEND, which is non-inclusive.
+RFC 5545 3.6.1: DTEND is \"the non-inclusive end of the event\", so an
+all-day event running Mar 15-17 carries DTEND 2026-03-18. Rendering DTEND
+verbatim would add a phantom fourth day."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 nil nil) '(2026 3 18 nil nil))))
+ (should (equal result "<2026-03-15 Sun>--<2026-03-17 Tue>"))))
+
+(ert-deftest test-calendar-sync--format-timestamp-single-all-day-stays-single ()
+ "A one-day all-day event stays a single stamp, never a degenerate range.
+Its DTEND is the next day (non-inclusive), so a naive range would turn every
+single all-day event into a two-day one -- a worse regression than the
+collapse this range support fixes."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 nil nil) '(2026 3 16 nil nil))))
+ (should (equal result "<2026-03-15 Sun>"))
+ (should-not (string-match-p "--" result))))
+
+(ert-deftest test-calendar-sync--format-timestamp-same-day-timed-stays-compact ()
+ "A same-day timed event keeps the compact HH:MM-HH:MM form, not a range.
+Guards the common case against the range branch."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 14 0) '(2026 3 15 15 30))))
+ (should (equal result "<2026-03-15 Sun 14:00-15:30>"))
+ (should-not (string-match-p "--" result))))
+
+(ert-deftest test-calendar-sync--format-timestamp-multi-day-all-day-two-days ()
+ "Boundary: the shortest real all-day span (two days) renders as a range.
+DTEND 2026-03-17 means the event covers Mar 15-16; one day fewer and it
+collapses to the single-stamp case above."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 15 nil nil) '(2026 3 17 nil nil))))
+ (should (equal result "<2026-03-15 Sun>--<2026-03-16 Mon>"))))
+
+(ert-deftest test-calendar-sync--format-timestamp-multi-day-span-crosses-month ()
+ "Boundary: an all-day span crossing a month boundary decrements correctly.
+DTEND 2026-04-01 means the event's last day is 2026-03-31, which exercises
+the borrow in `calendar-sync--add-days'."
+ (let ((result (calendar-sync--format-timestamp
+ '(2026 3 30 nil nil) '(2026 4 1 nil nil))))
+ (should (equal result "<2026-03-30 Mon>--<2026-03-31 Tue>"))))
+
(provide 'test-calendar-sync--format-timestamp)
;;; test-calendar-sync--format-timestamp.el ends here
diff --git a/tests/test-calendar-sync--get-exdates.el b/tests/test-calendar-sync--get-exdates.el
index 3283bbae..981a1857 100644
--- a/tests/test-calendar-sync--get-exdates.el
+++ b/tests/test-calendar-sync--get-exdates.el
@@ -103,6 +103,35 @@ END:VEVENT"))
(should (= 1 (length result)))
(should (string= "20260210T130000" (car result))))))
+(ert-deftest test-calendar-sync--get-exdates-boundary-comma-separated-returns-all ()
+ "Boundary: comma-separated EXDATE values on one line are each returned.
+RFC 5545 permits multiple datetimes per EXDATE line; missing the split
+drops those exclusions, so cancelled instances resurrect in the agenda."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T130000,20260217T130000,20260224T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 3 (length result)))
+ (should (member "20260210T130000" result))
+ (should (member "20260217T130000" result))
+ (should (member "20260224T130000" result)))))
+
+(ert-deftest test-calendar-sync--get-exdates-boundary-comma-separated-with-tzid ()
+ "Boundary: comma-separated EXDATE values sharing a TZID are each returned."
+ (let ((event "BEGIN:VEVENT
+DTSTART;TZID=America/New_York:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE;TZID=America/New_York:20260210T130000,20260217T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 2 (length result)))
+ (should (member "20260210T130000" result))
+ (should (member "20260217T130000" result)))))
+
;;; Error Cases
(ert-deftest test-calendar-sync--get-exdates-error-empty-string-returns-nil ()
diff --git a/tests/test-calendar-sync--nth-weekday-of-month.el b/tests/test-calendar-sync--nth-weekday-of-month.el
new file mode 100644
index 00000000..afb0bd35
--- /dev/null
+++ b/tests/test-calendar-sync--nth-weekday-of-month.el
@@ -0,0 +1,67 @@
+;;; test-calendar-sync--nth-weekday-of-month.el --- Tests for calendar-sync--nth-weekday-of-month -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the nth-weekday-of-month helper backing monthly/yearly BYDAY
+;; expansion. Fixed dates are safe here: the function is pure calendar
+;; arithmetic with no relation to the current time.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-second-wednesday ()
+ "Normal: 2nd Wednesday of Jan 2026 is the 14th."
+ ;; Jan 2026: Jan 1 is a Thursday; Wednesdays fall on 7, 14, 21, 28.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 1 3 2) 14)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-first-monday ()
+ "Normal: 1st Monday of Feb 2026 is the 2nd."
+ ;; Feb 2026: Feb 1 is a Sunday; Mondays fall on 2, 9, 16, 23.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 2 1 1) 2)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-last-tuesday ()
+ "Normal: last Tuesday of Mar 2026 is the 31st (negative ordinal)."
+ ;; Mar 2026: Tuesdays fall on 3, 10, 17, 24, 31.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 3 2 -1) 31)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-second-to-last-friday ()
+ "Normal: -2 ordinal picks the second-to-last Friday."
+ ;; May 2026: Fridays fall on 1, 8, 15, 22, 29.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 5 5 -2) 22)))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-fifth-occurrence-exists ()
+ "Boundary: 5th Friday exists in May 2026."
+ (should (= (calendar-sync--nth-weekday-of-month 2026 5 5 5) 29)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-fifth-occurrence-missing ()
+ "Boundary: 5th Wednesday of Feb 2026 does not exist -- returns nil."
+ ;; Feb 2026 has four Wednesdays (4, 11, 18, 25).
+ (should (null (calendar-sync--nth-weekday-of-month 2026 2 3 5))))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-first-day-is-target ()
+ "Boundary: the 1st of the month itself is the 1st occurrence."
+ ;; Apr 2026: Apr 1 is a Wednesday.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 4 3 1) 1)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-leap-february ()
+ "Boundary: leap-year February (2028) handled -- last Tuesday is the 29th."
+ ;; Feb 2028: Feb 29 exists and is a Tuesday.
+ (should (= (calendar-sync--nth-weekday-of-month 2028 2 2 -1) 29)))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-error-zero-ordinal-nil ()
+ "Error: ordinal 0 is meaningless -- returns nil."
+ (should (null (calendar-sync--nth-weekday-of-month 2026 1 3 0))))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-error-out-of-range-negative-nil ()
+ "Error: -6th occurrence never exists in a month -- returns nil."
+ (should (null (calendar-sync--nth-weekday-of-month 2026 1 3 -6))))
+
+(provide 'test-calendar-sync--nth-weekday-of-month)
+;;; test-calendar-sync--nth-weekday-of-month.el ends here
diff --git a/tests/test-calendar-sync--parse-byday-entry.el b/tests/test-calendar-sync--parse-byday-entry.el
new file mode 100644
index 00000000..4a9b4ef5
--- /dev/null
+++ b/tests/test-calendar-sync--parse-byday-entry.el
@@ -0,0 +1,41 @@
+;;; test-calendar-sync--parse-byday-entry.el --- Tests for calendar-sync--parse-byday-entry -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for parsing a single RRULE BYDAY entry ("2WE", "-1TU", "SU") into
+;; an (ordinal . weekday-number) cons. Ordinal is nil for a bare weekday.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-positive-ordinal ()
+ "Normal: \"2WE\" parses to ordinal 2, Wednesday (3)."
+ (should (equal (calendar-sync--parse-byday-entry "2WE") '(2 . 3))))
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-negative-ordinal ()
+ "Normal: \"-1TU\" parses to ordinal -1, Tuesday (2)."
+ (should (equal (calendar-sync--parse-byday-entry "-1TU") '(-1 . 2))))
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-bare-weekday ()
+ "Normal: \"SU\" parses to nil ordinal, Sunday (7)."
+ (should (equal (calendar-sync--parse-byday-entry "SU") '(nil . 7))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-boundary-double-digit-ordinal ()
+ "Boundary: \"53MO\" (yearly-scale ordinal) parses without truncation."
+ (should (equal (calendar-sync--parse-byday-entry "53MO") '(53 . 1))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-error-garbage-nil ()
+ "Error: an unrecognizable entry returns nil."
+ (should (null (calendar-sync--parse-byday-entry "XX")))
+ (should (null (calendar-sync--parse-byday-entry "")))
+ (should (null (calendar-sync--parse-byday-entry nil))))
+
+(provide 'test-calendar-sync--parse-byday-entry)
+;;; test-calendar-sync--parse-byday-entry.el ends here
diff --git a/tests/test-calendar-sync--parse-event.el b/tests/test-calendar-sync--parse-event.el
index 9c343db2..b3f58ba2 100644
--- a/tests/test-calendar-sync--parse-event.el
+++ b/tests/test-calendar-sync--parse-event.el
@@ -78,5 +78,37 @@
(let ((vevent "BEGIN:VEVENT\nSUMMARY:Orphan\nEND:VEVENT"))
(should (null (calendar-sync--parse-event vevent)))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--parse-event-error-cancelled-returns-nil ()
+ "Error: a STATUS:CANCELLED event returns nil -- cancelled events don't render."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Cancelled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:CANCELLED\n"
+ "END:VEVENT")))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(ert-deftest test-calendar-sync--parse-event-boundary-cancelled-case-insensitive ()
+ "Boundary: STATUS value matching is case-insensitive."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Cancelled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:Cancelled\n"
+ "END:VEVENT")))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(ert-deftest test-calendar-sync--parse-event-normal-confirmed-still-parses ()
+ "Normal: STATUS:CONFIRMED events still parse."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Confirmed Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:CONFIRMED\n"
+ "END:VEVENT")))
+ (should (calendar-sync--parse-event vevent))))
+
(provide 'test-calendar-sync--parse-event)
;;; test-calendar-sync--parse-event.el ends here
diff --git a/tests/test-calendar-sync--parse-exception-event.el b/tests/test-calendar-sync--parse-exception-event.el
index a26a7418..1c9411f3 100644
--- a/tests/test-calendar-sync--parse-exception-event.el
+++ b/tests/test-calendar-sync--parse-exception-event.el
@@ -82,5 +82,33 @@ than a half-built plist."
"END:VEVENT")))
(should-not (calendar-sync--parse-exception-event event))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-normal-cancelled-flag ()
+ "Normal: a STATUS:CANCELLED override carries :cancelled t, so the
+matching occurrence can be removed rather than overridden."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (event (concat "BEGIN:VEVENT\n"
+ "UID:override@google.com\n"
+ "RECURRENCE-ID:20260203T090000Z\n"
+ "SUMMARY:Craig / Ryan\n"
+ "STATUS:CANCELLED\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "END:VEVENT"))
+ (plist (calendar-sync--parse-exception-event event)))
+ (should plist)
+ (should (plist-get plist :cancelled))))
+
+(ert-deftest test-calendar-sync--parse-exception-event-boundary-no-status-not-cancelled ()
+ "Boundary: an override without STATUS is not cancelled."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (plist (calendar-sync--parse-exception-event
+ (test-cs-parse-exc--override-event start end))))
+ (should plist)
+ (should-not (plist-get plist :cancelled))))
+
(provide 'test-calendar-sync--parse-exception-event)
;;; test-calendar-sync--parse-exception-event.el ends here
diff --git a/tests/test-calendar-sync--parse-rrule.el b/tests/test-calendar-sync--parse-rrule.el
index 099e4e44..2668c1ac 100644
--- a/tests/test-calendar-sync--parse-rrule.el
+++ b/tests/test-calendar-sync--parse-rrule.el
@@ -206,5 +206,26 @@
(should (= (plist-get result :count) 10)))
(test-calendar-sync--parse-rrule-teardown)))
+;;; BYSETPOS / BYMONTH Cases
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-bysetpos-returns-number ()
+ "Normal: BYSETPOS parses to a number (Proton emits BYDAY=SU;BYSETPOS=2)."
+ (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY;BYDAY=SU;BYSETPOS=2")))
+ (should (eq (plist-get result :freq) 'monthly))
+ (should (equal (plist-get result :byday) '("SU")))
+ (should (= (plist-get result :bysetpos) 2))))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-bymonth-returns-number ()
+ "Normal: BYMONTH parses to a number (yearly nth-weekday rules carry it)."
+ (let ((result (calendar-sync--parse-rrule "FREQ=YEARLY;BYMONTH=3;BYDAY=2SU")))
+ (should (eq (plist-get result :freq) 'yearly))
+ (should (= (plist-get result :bymonth) 3))
+ (should (equal (plist-get result :byday) '("2SU")))))
+
+(ert-deftest test-calendar-sync--parse-rrule-boundary-negative-bysetpos ()
+ "Boundary: negative BYSETPOS (last matching day) parses."
+ (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1")))
+ (should (= (plist-get result :bysetpos) -1))))
+
(provide 'test-calendar-sync--parse-rrule)
;;; test-calendar-sync--parse-rrule.el ends here
diff --git a/tests/test-calendar-sync--syncing-p.el b/tests/test-calendar-sync--syncing-p.el
index b346bf77..df8bcd52 100644
--- a/tests/test-calendar-sync--syncing-p.el
+++ b/tests/test-calendar-sync--syncing-p.el
@@ -4,81 +4,111 @@
;; Unit tests for `calendar-sync--syncing-p' (the per-calendar in-flight check
;; that lets the dispatcher skip an overlapping timer tick) and for the
;; load-state sanitize that clears a stale `syncing' status in a fresh process.
+;;
+;; Every test runs inside `test-cs-syncing--with-fresh-state', which let-binds
+;; a private state hash. These tests previously cleared the module's global
+;; hash on entry and left whatever they wrote in it on exit, which leaked:
+;; `...-sync-calendar-skips-when-in-flight' marks "proton" as syncing to
+;; exercise the guard, and `test-calendar-sync--sync-dispatch-normal-ics-fetcher'
+;; in the sibling dispatch file dispatches a calendar also named "proton".
+;; ERT runs them in that order, so the leftover in-flight status made the
+;; dispatch a no-op and the sibling failed -- but only when the calendar-sync
+;; files ran in one process. `make test' runs each file separately and the
+;; editor hook skipped this family for being over its file cap, so nothing
+;; caught it. Let-binding is what the sibling files already do
+;; (test-calendar-sync.el, test-calendar-sync-async-worker.el); this file was
+;; the odd one out.
;;; Code:
(require 'ert)
(require 'calendar-sync)
-(defun test-cs-syncing--reset ()
- "Clear the module's per-calendar state hash."
- (clrhash calendar-sync--calendar-states))
+(defmacro test-cs-syncing--with-fresh-state (&rest body)
+ "Run BODY with a private, empty per-calendar state hash.
+Let-bound rather than cleared in place, so nothing this test writes can
+reach a later test."
+ (declare (indent 0))
+ `(let ((calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ ,@body))
;;; calendar-sync--syncing-p
(ert-deftest test-calendar-sync--syncing-p-normal-true-when-syncing ()
"Normal: a calendar whose status is `syncing' reads as in-flight."
- (test-cs-syncing--reset)
- (calendar-sync--set-calendar-state "google" '(:status syncing))
- (should (calendar-sync--syncing-p "google")))
+ (test-cs-syncing--with-fresh-state
+ (calendar-sync--set-calendar-state "google" '(:status syncing))
+ (should (calendar-sync--syncing-p "google"))))
(ert-deftest test-calendar-sync--syncing-p-boundary-nil-when-no-state ()
"Boundary: a calendar with no recorded state is not in-flight."
- (test-cs-syncing--reset)
- (should-not (calendar-sync--syncing-p "never-seen")))
+ (test-cs-syncing--with-fresh-state
+ (should-not (calendar-sync--syncing-p "never-seen"))))
(ert-deftest test-calendar-sync--syncing-p-error-nil-for-terminal-status ()
"Error: a terminal status (ok / error) is not in-flight."
- (test-cs-syncing--reset)
- (calendar-sync--set-calendar-state "google" '(:status ok))
- (should-not (calendar-sync--syncing-p "google"))
- (calendar-sync--set-calendar-state "proton" '(:status error))
- (should-not (calendar-sync--syncing-p "proton")))
+ (test-cs-syncing--with-fresh-state
+ (calendar-sync--set-calendar-state "google" '(:status ok))
+ (should-not (calendar-sync--syncing-p "google"))
+ (calendar-sync--set-calendar-state "proton" '(:status error))
+ (should-not (calendar-sync--syncing-p "proton"))))
;;; Dispatcher guard: an in-flight calendar skips both leaf syncers
(ert-deftest test-calendar-sync--sync-calendar-skips-when-in-flight ()
"Normal: `calendar-sync--sync-calendar' does not launch a second sync for a
calendar already marked syncing, so an overlapping timer tick is a no-op."
- (test-cs-syncing--reset)
- (let ((api-calls '()) (ics-calls '()))
- (cl-letf (((symbol-function 'calendar-sync--sync-calendar-api)
- (lambda (cal) (push cal api-calls)))
- ((symbol-function 'calendar-sync--sync-calendar-ics)
- (lambda (cal) (push cal ics-calls))))
- (calendar-sync--set-calendar-state "proton" '(:status syncing))
- (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics"
- :file "/tmp/c.org"))
- (should (null api-calls))
- (should (null ics-calls)))))
+ (test-cs-syncing--with-fresh-state
+ (let ((api-calls '()) (ics-calls '()))
+ (cl-letf (((symbol-function 'calendar-sync--sync-calendar-api)
+ (lambda (cal) (push cal api-calls)))
+ ((symbol-function 'calendar-sync--sync-calendar-ics)
+ (lambda (cal) (push cal ics-calls))))
+ (calendar-sync--set-calendar-state "proton" '(:status syncing))
+ (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics"
+ :file "/tmp/c.org"))
+ (should (null api-calls))
+ (should (null ics-calls))))))
(ert-deftest test-calendar-sync--sync-calendar-dispatches-when-idle ()
"Boundary: an idle calendar (no in-flight status) still dispatches normally."
- (test-cs-syncing--reset)
- (let ((ics-calls '()))
- (cl-letf (((symbol-function 'calendar-sync--sync-calendar-ics)
- (lambda (cal) (push cal ics-calls))))
- (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics"
- :file "/tmp/c.org"))
- (should (= 1 (length ics-calls))))))
+ (test-cs-syncing--with-fresh-state
+ (let ((ics-calls '()))
+ (cl-letf (((symbol-function 'calendar-sync--sync-calendar-ics)
+ (lambda (cal) (push cal ics-calls))))
+ (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics"
+ :file "/tmp/c.org"))
+ (should (= 1 (length ics-calls)))))))
+
+;;; Isolation guard
+
+(ert-deftest test-calendar-sync--syncing-state-does-not-leak ()
+ "Error: state written inside the macro is gone once it returns.
+Pins the isolation itself. Without it a test marking a calendar syncing
+leaves that status set for every later test in the same process, which is
+exactly what broke the sibling dispatch test."
+ (test-cs-syncing--with-fresh-state
+ (calendar-sync--set-calendar-state "leak-probe" '(:status syncing))
+ (should (calendar-sync--syncing-p "leak-probe")))
+ (should-not (calendar-sync--syncing-p "leak-probe")))
;;; load-state sanitize: a persisted `syncing' status is cleared on load
(ert-deftest test-calendar-sync--load-state-clears-stale-syncing ()
"Error: a `syncing' status persisted before a crash is reset on load, so the
in-flight guard cannot skip that calendar forever in the new session."
- (test-cs-syncing--reset)
- (let* ((dir (make-temp-file "cs-state-" t))
- (calendar-sync--state-file (expand-file-name "state.el" dir)))
- (unwind-protect
- (progn
- (with-temp-file calendar-sync--state-file
- (prin1 '((timezone-offset . nil)
- (calendar-states . (("google" . (:status syncing)))))
- (current-buffer)))
- (calendar-sync--load-state)
- (should-not (calendar-sync--syncing-p "google")))
- (delete-directory dir t))))
+ (test-cs-syncing--with-fresh-state
+ (let* ((dir (make-temp-file "cs-state-" t))
+ (calendar-sync--state-file (expand-file-name "state.el" dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file calendar-sync--state-file
+ (prin1 '((timezone-offset . nil)
+ (calendar-states . (("google" . (:status syncing)))))
+ (current-buffer)))
+ (calendar-sync--load-state)
+ (should-not (calendar-sync--syncing-p "google")))
+ (delete-directory dir t)))))
(provide 'test-calendar-sync--syncing-p)
;;; test-calendar-sync--syncing-p.el ends here
diff --git a/tests/test-calendar-sync-properties.el b/tests/test-calendar-sync-properties.el
index c25bb99f..0b01cbd9 100644
--- a/tests/test-calendar-sync-properties.el
+++ b/tests/test-calendar-sync-properties.el
@@ -77,12 +77,21 @@ For any COUNT value N, expansion never produces more than N occurrences."
;;; Property 2: UNTIL Boundary
+;; These two asserted the wrong invariant until 2026-07-16: they required every
+;; occurrence to fall strictly BEFORE UNTIL, which is the exclusive reading RFC
+;; 5545 3.3.10 contradicts ("bounds the recurrence rule in an inclusive manner";
+;; a UNTIL synchronized with the recurrence "becomes the last instance"). They
+;; were written against the expansion loop's strict `before-date-p' guard and so
+;; pinned the very defect that dropped the last instance of every bounded series.
+;; The property is on-or-before; the upper bound is what UNTIL is for.
+
(ert-deftest test-calendar-sync-property-until-bounds-daily ()
- "Property: No daily occurrence starts on or after UNTIL date."
+ "Property: no daily occurrence starts after the UNTIL date.
+UNTIL is an inclusive bound (RFC 5545 3.3.10), so landing exactly on it is
+correct and only a later date violates the property."
(dotimes (_ test-calendar-sync-property-trials)
(let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
(until-days (+ 10 (random 60)))
- ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p
(until-date (test-calendar-sync-time-date-only until-days))
(base-event (list :summary "Until Test" :start start-date))
(rrule (list :freq 'daily :interval 1 :until until-date))
@@ -90,16 +99,35 @@ For any COUNT value N, expansion never produces more than N occurrences."
(occurrences (calendar-sync--expand-daily base-event rrule range)))
(dolist (occ occurrences)
(let ((occ-start (plist-get occ :start)))
- (should (calendar-sync--before-date-p
+ (should (calendar-sync--date-on-or-before-p
(list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start))
until-date)))))))
+(ert-deftest test-calendar-sync-property-until-bounds-daily-reaches-until ()
+ "Property: a daily series stepping by one day always reaches its UNTIL date.
+With interval 1 the recurrence is synchronized with any UNTIL, so the last
+instance must be UNTIL itself. This is the half the old exclusive property
+could never have caught -- it only bounded from above, so silently dropping
+the final occurrence satisfied it."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-days (+ 10 (random 60)))
+ (until-date (test-calendar-sync-time-date-only until-days))
+ (base-event (list :summary "Until Test" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range))
+ (last-start (plist-get (car (last occurrences)) :start)))
+ (should occurrences)
+ (should (equal (seq-take last-start 3) until-date)))))
+
(ert-deftest test-calendar-sync-property-until-bounds-weekly ()
- "Property: No weekly occurrence starts on or after UNTIL date."
+ "Property: no weekly occurrence starts after the UNTIL date.
+UNTIL is an inclusive bound (RFC 5545 3.3.10), so landing exactly on it is
+correct and only a later date violates the property."
(dotimes (_ test-calendar-sync-property-trials)
(let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
(until-days (+ 14 (random 60)))
- ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p
(until-date (test-calendar-sync-time-date-only until-days))
(weekdays (test-calendar-sync-random-weekday-subset))
(base-event (list :summary "Until Test" :start start-date))
@@ -108,7 +136,7 @@ For any COUNT value N, expansion never produces more than N occurrences."
(occurrences (calendar-sync--expand-weekly base-event rrule range)))
(dolist (occ occurrences)
(let ((occ-start (plist-get occ :start)))
- (should (calendar-sync--before-date-p
+ (should (calendar-sync--date-on-or-before-p
(list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start))
until-date)))))))
diff --git a/tests/test-calendar-sync-source-fetch-sentinel.el b/tests/test-calendar-sync-source-fetch-sentinel.el
new file mode 100644
index 00000000..0b7ba1cf
--- /dev/null
+++ b/tests/test-calendar-sync-source-fetch-sentinel.el
@@ -0,0 +1,72 @@
+;;; test-calendar-sync-source-fetch-sentinel.el --- Tests for the fetch sentinel -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; calendar-sync--fetch-sentinel-finish is the extracted tail of the async
+;; .ics fetch sentinel. The async-worker tests stub the whole fetch, so its
+;; success, failure, and temp-file-cleanup branches were never exercised.
+;; These tests drive the helper directly with fake success/failure inputs,
+;; no live curl process.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'calendar-sync)
+
+(ert-deftest test-calendar-sync-fetch-sentinel-success-passes-temp-file ()
+ "Normal: on success the callback gets the temp file and it is not deleted."
+ (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics"))
+ (buffer (generate-new-buffer " *cs-test*"))
+ (got 'unset))
+ (unwind-protect
+ (progn
+ (calendar-sync--fetch-sentinel-finish
+ t "finished\n" temp-file buffer (lambda (r) (setq got r)))
+ (should (equal got temp-file))
+ (should (file-exists-p temp-file))
+ (should-not (buffer-live-p buffer)))
+ (when (file-exists-p temp-file) (delete-file temp-file))
+ (when (buffer-live-p buffer) (kill-buffer buffer)))))
+
+(ert-deftest test-calendar-sync-fetch-sentinel-failure-deletes-and-passes-nil ()
+ "Error: on failure the temp file is deleted and the callback gets nil."
+ (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics"))
+ (buffer (generate-new-buffer " *cs-test*"))
+ (got 'unset)
+ (logged nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'calendar-sync--log-silently)
+ (lambda (&rest _) (setq logged t))))
+ (calendar-sync--fetch-sentinel-finish
+ nil "exited abnormally with code 22\n" temp-file buffer
+ (lambda (r) (setq got r)))
+ (should (null got))
+ (should-not (file-exists-p temp-file))
+ (should logged)
+ (should-not (buffer-live-p buffer)))
+ (when (file-exists-p temp-file) (delete-file temp-file))
+ (when (buffer-live-p buffer) (kill-buffer buffer)))))
+
+(ert-deftest test-calendar-sync-fetch-sentinel-failure-tolerates-missing-temp-file ()
+ "Boundary: failure with the temp file already gone does not error."
+ (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics"))
+ (got 'unset))
+ (delete-file temp-file)
+ (cl-letf (((symbol-function 'calendar-sync--log-silently) #'ignore))
+ (calendar-sync--fetch-sentinel-finish
+ nil "failed\n" temp-file nil (lambda (r) (setq got r)))
+ (should (null got)))))
+
+(ert-deftest test-calendar-sync-fetch-sentinel-tolerates-dead-buffer ()
+ "Boundary: a already-dead process buffer is not touched on success."
+ (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics"))
+ (got 'unset))
+ (unwind-protect
+ (progn
+ (calendar-sync--fetch-sentinel-finish
+ t "finished\n" temp-file nil (lambda (r) (setq got r)))
+ (should (equal got temp-file)))
+ (when (file-exists-p temp-file) (delete-file temp-file)))))
+
+(provide 'test-calendar-sync-source-fetch-sentinel)
+;;; test-calendar-sync-source-fetch-sentinel.el ends here
diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el
index f562cfc6..8a7c2549 100644
--- a/tests/test-calendar-sync.el
+++ b/tests/test-calendar-sync.el
@@ -713,5 +713,50 @@ Valid events should be parsed, invalid ones skipped."
(should-not (and org-content
(string-match-p "OutOfRangeEvent" org-content)))))
+;;; calendar-sync--sync-timer-function — hourly-timer body hygiene
+
+(ert-deftest test-calendar-sync-timer-function-does-not-propagate-a-signal ()
+ "Error: a signal in the timer body is caught, not propagated.
+The function runs from an hourly `run-at-time' timer. An unguarded signal
+in the timezone check or the sync fan-out would error on every tick — the
+same error, once an hour, forever. It must swallow-and-log instead."
+ (cl-letf (((symbol-function 'calendar-sync--timezone-changed-p)
+ (lambda (&rest _) (error "boom from the timezone check")))
+ ((symbol-function 'calendar-sync--sync-all-calendars) #'ignore)
+ ((symbol-function 'calendar-sync--log-silently) #'ignore))
+ ;; Must return normally rather than signal.
+ (should (progn (calendar-sync--sync-timer-function) t))))
+
+(ert-deftest test-calendar-sync-timer-function-signal-in-sync-is-caught ()
+ "Error: a signal from the sync fan-out is also caught, not propagated."
+ (cl-letf (((symbol-function 'calendar-sync--timezone-changed-p) #'ignore)
+ ((symbol-function 'calendar-sync--sync-all-calendars)
+ (lambda (&rest _) (error "boom from sync-all")))
+ ((symbol-function 'calendar-sync--log-silently) #'ignore))
+ (should (progn (calendar-sync--sync-timer-function) t))))
+
+(ert-deftest test-calendar-sync-timer-function-timezone-change-is-not-echoed ()
+ "Normal: a detected timezone change is logged silently, not echoed.
+An hourly timer that calls `message' spams the echo area; the notice belongs
+in the silent log like the module's other timer-path notices."
+ (let (silent-logged echoed)
+ (cl-letf (((symbol-function 'calendar-sync--timezone-changed-p)
+ (lambda (&rest _) t))
+ ((symbol-function 'calendar-sync--format-timezone-offset)
+ (lambda (&rest _) "UTC+0"))
+ ((symbol-function 'calendar-sync--current-timezone-offset)
+ (lambda (&rest _) 0))
+ ((symbol-function 'calendar-sync--sync-all-calendars) #'ignore)
+ ((symbol-function 'calendar-sync--log-silently)
+ (lambda (fmt &rest _) (when (string-match-p "Timezone" fmt)
+ (setq silent-logged t))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest _) (when (and (stringp fmt)
+ (string-match-p "Timezone" fmt))
+ (setq echoed t)))))
+ (calendar-sync--sync-timer-function)
+ (should silent-logged)
+ (should-not echoed))))
+
(provide 'test-calendar-sync)
;;; test-calendar-sync.el ends here
diff --git a/tests/test-calibredb-epub-config--epub-mode.el b/tests/test-calibredb-epub-config--epub-mode.el
new file mode 100644
index 00000000..a65bdabf
--- /dev/null
+++ b/tests/test-calibredb-epub-config--epub-mode.el
@@ -0,0 +1,70 @@
+;;; test-calibredb-epub-config--epub-mode.el --- Tests for epub mode resolution -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests that .epub files reach nov-mode through `auto-mode-alist' alone, with
+;; no advice on `set-auto-mode'.
+;;
+;; Background: the module used to carry an :around advice on `set-auto-mode'
+;; forcing nov-mode for .epub, added to keep `magic-fallback-mode-alist' from
+;; opening the zip container in archive-mode. It was never needed.
+;; `set-auto-mode' consults `auto-mode-alist' before `magic-fallback-mode-alist',
+;; and nov's use-package :mode registers "\\.epub\\'" there, so the alist
+;; already won. Verified live on the daemon: a real zip-format .epub opened in
+;; nov-mode both with the advice and with it removed.
+;;
+;; The advice was not free. `set-auto-mode' runs on every file visit, so the
+;; advice put a redundant frame and an extra failure surface on the path for
+;; every file of every type.
+;;
+;; The second test is a regression guard: it fails if the advice is ever
+;; reinstated, which is the mistake this cleanup exists to prevent.
+;;
+;; Test organization:
+;; - Normal Cases: .epub resolves to nov-mode; no advice on set-auto-mode
+;; - Boundary Cases: a path merely containing "epub", and a bare "epub" name
+;; - Error Cases: an unrelated extension does not resolve to nov-mode
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calibredb-epub-config)
+
+(defun test-epub-mode--resolve (filename)
+ "Return the major mode `auto-mode-alist' assigns to FILENAME."
+ (assoc-default filename auto-mode-alist 'string-match))
+
+;;; Normal Cases
+
+(ert-deftest test-calibredb-epub-config-epub-resolves-to-nov-mode ()
+ "Normal: auto-mode-alist maps a .epub file to nov-mode on its own."
+ (should (eq 'nov-mode (test-epub-mode--resolve "book.epub"))))
+
+(ert-deftest test-calibredb-epub-config-no-set-auto-mode-advice ()
+ "Normal: nothing advises set-auto-mode to force nov-mode.
+Regression guard. auto-mode-alist already wins over
+magic-fallback-mode-alist, so an advice here would be redundant work on
+every file visit."
+ (should-not (advice-member-p 'cj/force-nov-mode-for-epub 'set-auto-mode))
+ (should-not (fboundp 'cj/force-nov-mode-for-epub)))
+
+;;; Boundary Cases
+
+(ert-deftest test-calibredb-epub-config-epub-in-directory-name ()
+ "Boundary: the extension anchors at the end, so a directory named epub
+does not by itself select nov-mode."
+ (should-not (eq 'nov-mode (test-epub-mode--resolve "/home/user/epub/notes.txt"))))
+
+(ert-deftest test-calibredb-epub-config-epub-with-path ()
+ "Boundary: a full path with directories still resolves on the extension."
+ (should (eq 'nov-mode (test-epub-mode--resolve "/home/user/books/a b.epub"))))
+
+;;; Error Cases
+
+(ert-deftest test-calibredb-epub-config-other-extension-not-nov ()
+ "Error: an unrelated extension must not resolve to nov-mode."
+ (should-not (eq 'nov-mode (test-epub-mode--resolve "archive.zip")))
+ (should-not (eq 'nov-mode (test-epub-mode--resolve "notes.org"))))
+
+(provide 'test-calibredb-epub-config--epub-mode)
+;;; test-calibredb-epub-config--epub-mode.el ends here
diff --git a/tests/test-calibredb-epub-config.el b/tests/test-calibredb-epub-config.el
index 71581d4c..7afc58f3 100644
--- a/tests/test-calibredb-epub-config.el
+++ b/tests/test-calibredb-epub-config.el
@@ -285,57 +285,6 @@ so the search buffer rebuilds against the now-unfiltered set."
(cj/calibredb-clear-filters))
(should (equal "" passed))))
-;;; --------------------------- cj/force-nov-mode-for-epub ---------------------
-
-(ert-deftest test-calibredb-epub-force-nov-mode-on-epub-calls-nov-mode ()
- "Normal: a .epub buffer with nov-mode bound dispatches to `nov-mode' and
-does not fall through to the original mode dispatcher."
- (skip-unless (fboundp 'nov-mode))
- (let (orig-called nov-called)
- (cl-letf (((symbol-function 'nov-mode)
- (lambda () (setq nov-called t))))
- (with-temp-buffer
- (setq buffer-file-name "/tmp/sample.epub")
- (cj/force-nov-mode-for-epub
- (lambda (&rest _) (setq orig-called t)))))
- (should nov-called)
- (should-not orig-called)))
-
-(ert-deftest test-calibredb-epub-force-nov-mode-passes-through-non-epub ()
- "Boundary: a non-epub buffer falls through to the original mode dispatcher."
- (let (orig-called)
- (with-temp-buffer
- (setq buffer-file-name "/tmp/sample.txt")
- (cj/force-nov-mode-for-epub
- (lambda (&rest _) (setq orig-called t))))
- (should orig-called)))
-
-(ert-deftest test-calibredb-epub-force-nov-mode-passes-through-no-filename ()
- "Boundary: a buffer with no associated filename falls through to the
-original mode dispatcher."
- (let (orig-called)
- (with-temp-buffer
- (cj/force-nov-mode-for-epub
- (lambda (&rest _) (setq orig-called t))))
- (should orig-called)))
-
-(ert-deftest test-calibredb-epub-force-nov-mode-passes-through-when-nov-missing ()
- "Error: a .epub buffer falls through to the original dispatcher when nov-mode
-is not defined (the require failed and there is nothing to dispatch to)."
- (let ((saved (and (fboundp 'nov-mode) (symbol-function 'nov-mode)))
- orig-called)
- (when saved (fmakunbound 'nov-mode))
- (unwind-protect
- (cl-letf (((symbol-function 'require)
- ;; Pretend the (require 'nov nil t) call fails too.
- (lambda (&rest _) nil)))
- (with-temp-buffer
- (setq buffer-file-name "/tmp/sample.epub")
- (cj/force-nov-mode-for-epub
- (lambda (&rest _) (setq orig-called t)))))
- (when saved (fset 'nov-mode saved)))
- (should orig-called)))
-
;;; ---------------------------- cj/nov--metadata-get --------------------------
(ert-deftest test-calibredb-epub-metadata-get-symbol-key ()
diff --git a/tests/test-config-utilities--recompile-emacs-home.el b/tests/test-config-utilities--recompile-emacs-home.el
index 18d17f96..da364e24 100644
--- a/tests/test-config-utilities--recompile-emacs-home.el
+++ b/tests/test-config-utilities--recompile-emacs-home.el
@@ -81,20 +81,29 @@ Returns the temp dir path."
(should-not (file-exists-p (expand-file-name "sub/c.elc" dir))))
(delete-directory dir t))))
-(ert-deftest test-config-utilities-recompile-removes-eln-dir-on-native-path ()
- "Boundary: the native path removes the eln cache directory when present."
+(ert-deftest test-config-utilities-recompile-removes-eln-cache-dir-on-native-path ()
+ "Boundary: the native path removes the eln-cache directory when present.
+The native cache is eln-cache/, not eln/, so that is the directory to clear."
(let ((dir (test-config-utilities--make-recompile-fixture))
- (eln-dir nil))
+ (eln-cache-dir nil))
(unwind-protect
(progn
- (setq eln-dir (expand-file-name "eln" dir))
- (make-directory eln-dir)
- (with-temp-file (expand-file-name "stale.eln" eln-dir) (insert ""))
+ (setq eln-cache-dir (expand-file-name "eln-cache" dir))
+ (make-directory eln-cache-dir)
+ (with-temp-file (expand-file-name "stale.eln" eln-cache-dir) (insert ""))
(cl-letf (((symbol-function 'native-compile-async) (lambda (&rest _) nil)))
(cj/--recompile-emacs-home dir t))
- (should-not (file-exists-p eln-dir)))
+ (should-not (file-exists-p eln-cache-dir)))
(delete-directory dir t))))
+(ert-deftest test-config-utilities-native-comp-detection-not-boundp ()
+ "Regression: native-comp detection must not test `boundp' of the async
+function -- native-compile-async is a function, so `boundp' is always nil and
+native compilation would never be selected. On a native-comp build, detection
+returns non-nil."
+ (when (and (fboundp 'native-comp-available-p) (native-comp-available-p))
+ (should (cj/--native-comp-p))))
+
(ert-deftest test-config-utilities-recompile-removes-elc-dir-on-byte-path ()
"Boundary: the byte path removes the elc cache directory when present."
(let ((dir (test-config-utilities--make-recompile-fixture))
diff --git a/tests/test-custom-buffer-file--view-email-in-buffer.el b/tests/test-custom-buffer-file--view-email-in-buffer.el
index 99e0e44d..b0209b78 100644
--- a/tests/test-custom-buffer-file--view-email-in-buffer.el
+++ b/tests/test-custom-buffer-file--view-email-in-buffer.el
@@ -13,6 +13,7 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
(require 'testutil-general)
(require 'custom-buffer-file)
@@ -233,5 +234,24 @@ Note: shr may insert newlines between words for wrapping."
(kill-buffer)))
(test-email--teardown)))
+(ert-deftest test-custom-buffer-file--view-email-no-displayable-destroys-handle ()
+ "Error: the MIME handle is destroyed even when no displayable part is found.
+The `user-error' fires before cleanup, so without `unwind-protect' the dissected
+handle leaks."
+ (test-email--setup)
+ (unwind-protect
+ (let ((eml-file (test-email--create-eml-file test-email--image-only))
+ (destroy-called nil))
+ (with-current-buffer (find-file-noselect eml-file)
+ (let ((real (symbol-function 'mm-destroy-parts)))
+ (cl-letf (((symbol-function 'mm-destroy-parts)
+ (lambda (handle)
+ (setq destroy-called t)
+ (funcall real handle))))
+ (should-error (cj/view-email-in-buffer) :type 'user-error)))
+ (should destroy-called)
+ (kill-buffer)))
+ (test-email--teardown)))
+
(provide 'test-custom-buffer-file--view-email-in-buffer)
;;; test-custom-buffer-file--view-email-in-buffer.el ends here
diff --git a/tests/test-custom-buffer-file-copy-link-to-buffer-file.el b/tests/test-custom-buffer-file-copy-link-to-buffer-file.el
index 262968d6..5ee57b3a 100644
--- a/tests/test-custom-buffer-file-copy-link-to-buffer-file.el
+++ b/tests/test-custom-buffer-file-copy-link-to-buffer-file.el
@@ -4,7 +4,8 @@
;; Tests for the cj/copy-link-to-buffer-file function from custom-buffer-file.el
;;
;; This function copies the full file:// path of the current buffer's file to
-;; the kill ring. For non-file buffers, it does nothing (no error).
+;; the kill ring. For non-file buffers, it signals a user-error, matching its
+;; sibling copy commands.
;;; Code:
@@ -58,12 +59,12 @@
(test-copy-link-teardown)))
(ert-deftest test-copy-link-non-file-buffer ()
- "Should do nothing for non-file buffer without error."
+ "Error: a non-file buffer signals `user-error' and leaves the kill ring alone."
(test-copy-link-setup)
(unwind-protect
(with-temp-buffer
(setq kill-ring nil)
- (cj/copy-link-to-buffer-file)
+ (should-error (cj/copy-link-to-buffer-file) :type 'user-error)
(should (null kill-ring)))
(test-copy-link-teardown)))
@@ -195,13 +196,13 @@
(test-copy-link-teardown)))
(ert-deftest test-copy-link-scratch-buffer ()
- "Should do nothing for *scratch* buffer."
+ "Error: the *scratch* buffer (no file) signals `user-error'."
(test-copy-link-setup)
(unwind-protect
(progn
(setq kill-ring nil)
(with-current-buffer "*scratch*"
- (cj/copy-link-to-buffer-file)
+ (should-error (cj/copy-link-to-buffer-file) :type 'user-error)
(should (null kill-ring))))
(test-copy-link-teardown)))
diff --git a/tests/test-custom-case-title-case-region.el b/tests/test-custom-case-title-case-region.el
index 383ae927..82b4966a 100644
--- a/tests/test-custom-case-title-case-region.el
+++ b/tests/test-custom-case-title-case-region.el
@@ -60,16 +60,17 @@ an active region, and return the result."
"The Art of War")))
(ert-deftest test-custom-case-title-case-region-normal-all-minor-words ()
- "All minor words in the skip list should be lowercased in mid-sentence."
+ "All minor words in the skip list should be lowercased in mid-sentence.
+\"is\" is a linking verb, so it is a major word and is capitalized."
(should (equal (test-title-case--on-string
"go a an and as at but by for if in is nor of on or so the to yet go")
- "Go a an and as at but by for if in is nor of on or so the to yet Go")))
+ "Go a an and as at but by for if in Is nor of on or so the to yet Go")))
(ert-deftest test-custom-case-title-case-region-normal-four-letter-words-capitalized ()
"Words of four or more letters should always be capitalized.
-Note: 'is' is explicitly in the minor word list, so it stays lowercase."
+\"is\" is a linking verb (a major word), so it is capitalized too."
(should (equal (test-title-case--on-string "this is from that with over")
- "This is From That With Over")))
+ "This Is From That With Over")))
(ert-deftest test-custom-case-title-case-region-normal-allcaps-input ()
"All-caps input should be downcased first, then title-cased."
@@ -94,7 +95,16 @@ Note: 'is' is explicitly in the minor word list, so it stays lowercase."
(ert-deftest test-custom-case-title-case-region-normal-question-resets ()
"Word immediately after a question mark should be capitalized, even if minor."
(should (equal (test-title-case--on-string "really? the answer is no")
- "Really? The Answer is No")))
+ "Really? The Answer Is No")))
+
+(ert-deftest test-custom-case-title-case-region-normal-last-word-capitalized ()
+ "The last word is always capitalized, even a minor one."
+ (should (equal (test-title-case--on-string "the art of") "The Art Of")))
+
+(ert-deftest test-custom-case-title-case-region-normal-period-restarts ()
+ "A word after a sentence-ending period should be capitalized."
+ (should (equal (test-title-case--on-string "one. the next sentence")
+ "One. The Next Sentence")))
(ert-deftest test-custom-case-title-case-region-normal-hyphenated-word ()
"Second part of a hyphenated word should NOT be capitalized."
@@ -141,7 +151,7 @@ Note: 'is' is explicitly in the minor word list, so it stays lowercase."
(ert-deftest test-custom-case-title-case-region-boundary-unicode-words ()
"Unicode characters should pass through without error."
(should (equal (test-title-case--on-string "the café is nice")
- "The Café is Nice")))
+ "The Café Is Nice")))
(ert-deftest test-custom-case-title-case-region-boundary-numbers-in-text ()
"Numbers mixed with text should not break title casing."
diff --git a/tests/test-custom-comments-comment-inline-border.el b/tests/test-custom-comments-comment-inline-border.el
index 78e86035..305a2c7a 100644
--- a/tests/test-custom-comments-comment-inline-border.el
+++ b/tests/test-custom-comments-comment-inline-border.el
@@ -120,6 +120,16 @@ Returns the buffer string for assertions."
(let ((result (test-inline-border-at-column 0 ";;" "" "=" "" 10)))
(should (string-match-p ";" result))))
+(ert-deftest test-inline-border-elisp-fills-exact-width-all-parities ()
+ "Boundary: even, odd, and empty text all fill LENGTH exactly.
+Even-length and empty text used to come out two columns short because the
+right decoration count keyed off text-length parity instead of the remaining
+width, so stacked dividers of differing text lengths misaligned."
+ (dolist (text '("" "X" "EVEN" "ODD" "Header"))
+ (let* ((result (test-inline-border-at-column 0 ";;" "" "=" text 50))
+ (line (string-trim-right result "\n")))
+ (should (= 50 (length line))))))
+
(ert-deftest test-inline-border-elisp-text-centering-even ()
"Should center text properly with even length."
(let ((result (test-inline-border-at-column 0 ";;" "" "=" "EVEN" 70)))
diff --git a/tests/test-custom-comments-comment-padded-divider.el b/tests/test-custom-comments-comment-padded-divider.el
index d4c18905..9f2b4fd7 100644
--- a/tests/test-custom-comments-comment-padded-divider.el
+++ b/tests/test-custom-comments-comment-padded-divider.el
@@ -246,5 +246,23 @@ Returns the buffer string for assertions."
;; Should include comment-end
(should (string-match-p "\\*/" result))))
+;;; Rendered width honors LENGTH exactly
+
+(ert-deftest test-padded-divider-width-matches-length-exactly ()
+ "Normal: each decoration line renders exactly LENGTH wide.
+available-width forgot the doubled semicolon (elisp) and the space after
+comment-start that the emit path adds, so dividers rendered LENGTH+2
+(elisp) or LENGTH+1 wide, contradicting the docstring."
+ ;; elisp: lone ";" doubles to ";;" plus a space
+ (let* ((result (test-padded-divider-at-column 0 ";" "" "-" "x" 40 1))
+ (lines (split-string result "\n" t)))
+ (should (= 40 (length (car lines))))
+ (should (= 40 (length (car (last lines))))))
+ ;; c-style with an end delimiter and no doubling
+ (let* ((result (test-padded-divider-at-column 0 "/*" "*/" "-" "x" 40 1))
+ (lines (split-string result "\n" t)))
+ (should (= 40 (length (car lines))))
+ (should (= 40 (length (car (last lines)))))))
+
(provide 'test-custom-comments-comment-padded-divider)
;;; test-custom-comments-comment-padded-divider.el ends here
diff --git a/tests/test-custom-comments-comment-reformat.el b/tests/test-custom-comments-comment-reformat.el
index 83248aee..91b7dfe3 100644
--- a/tests/test-custom-comments-comment-reformat.el
+++ b/tests/test-custom-comments-comment-reformat.el
@@ -146,19 +146,12 @@ Insert CONTENT-BEFORE, select all, run cj/comment-reformat, verify EXPECTED-AFTE
(should (string-match-p ";; Start line 1.*Start line 2" (buffer-string)))))
(ert-deftest test-comment-reformat-elisp-no-region-active ()
- "Should show message when no region selected."
+ "Should signal `user-error' when no region is selected."
(with-temp-buffer
(emacs-lisp-mode)
(insert ";; Comment line")
(deactivate-mark)
- (let ((message-log-max nil)
- (messages '()))
- ;; Capture messages
- (cl-letf (((symbol-function 'message)
- (lambda (format-string &rest args)
- (push (apply #'format format-string args) messages))))
- (cj/comment-reformat)
- (should (string-match-p "No region was selected" (car messages)))))))
+ (should-error (cj/comment-reformat) :type 'user-error)))
(ert-deftest test-comment-reformat-elisp-read-only-buffer ()
"Should signal error in read-only buffer."
diff --git a/tests/test-custom-comments-public-wrappers.el b/tests/test-custom-comments-public-wrappers.el
index 42842649..2eda9d94 100644
--- a/tests/test-custom-comments-public-wrappers.el
+++ b/tests/test-custom-comments-public-wrappers.el
@@ -188,5 +188,30 @@ text via `read-from-minibuffer'."
(cj/comment-block-banner)
(should (string-match-p "Banner" (buffer-string)))))
+;;; cj/--comment-read-syntax — the shared comment-syntax resolution
+
+(ert-deftest test-comment-read-syntax-uses-buffer-syntax ()
+ "Normal: a buffer with comment syntax resolves without prompting."
+ (with-temp-buffer
+ (setq-local comment-start ";")
+ (setq-local comment-end "")
+ (cl-letf (((symbol-function 'read-string)
+ (lambda (&rest _) (error "should not prompt"))))
+ (should (equal (cj/--comment-read-syntax) '(";" . ""))))))
+
+(ert-deftest test-comment-read-syntax-nil-end-falls-back-to-empty ()
+ "Boundary: a nil comment-end resolves to the empty string."
+ (with-temp-buffer
+ (setq-local comment-start "#")
+ (setq-local comment-end nil)
+ (should (equal (cj/--comment-read-syntax) '("#" . "")))))
+
+(ert-deftest test-comment-read-syntax-prompts-when-unset ()
+ "Error: no buffer comment-start falls back to the prompt."
+ (with-temp-buffer
+ (setq-local comment-start nil)
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "//")))
+ (should (equal (car (cj/--comment-read-syntax)) "//")))))
+
(provide 'test-custom-comments-public-wrappers)
;;; test-custom-comments-public-wrappers.el ends here
diff --git a/tests/test-custom-datetime-all-methods.el b/tests/test-custom-datetime-all-methods.el
index 62b421bd..1f31c6c4 100644
--- a/tests/test-custom-datetime-all-methods.el
+++ b/tests/test-custom-datetime-all-methods.el
@@ -54,9 +54,10 @@
(should (string-match-p "14:30:45" result))))
(ert-deftest test-custom-datetime-all-methods-normal-sortable-time ()
- "cj/insert-sortable-time should insert time with AM/PM and timezone."
+ "cj/insert-sortable-time should insert 24-hour time so it sorts lexically."
(let ((result (test-datetime--run #'cj/insert-sortable-time)))
- (should (string-match-p "02:30:45 PM" result))))
+ (should (string-match-p "14:30:45" result))
+ (should-not (string-match-p "PM" result))))
(ert-deftest test-custom-datetime-all-methods-normal-readable-time ()
"cj/insert-readable-time should insert short time with AM/PM."
diff --git a/tests/test-custom-line-paragraph-duplicate-line-or-region.el b/tests/test-custom-line-paragraph-duplicate-line-or-region.el
index 84f5bc2d..9e501086 100644
--- a/tests/test-custom-line-paragraph-duplicate-line-or-region.el
+++ b/tests/test-custom-line-paragraph-duplicate-line-or-region.el
@@ -327,6 +327,40 @@
(should (> (length (buffer-string)) (length "line one\nline two\nline three"))))
(test-duplicate-line-or-region-teardown)))
+(ert-deftest test-duplicate-line-or-region-mid-line-bounds-duplicate-whole-lines ()
+ "A region ending mid-line duplicates every whole line it touches, no splits.
+The old open-line loop split the mid-line-ending line instead."
+ (test-duplicate-line-or-region-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "aaa\nbbb\nccc")
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (forward-char 1) ; mid first line
+ (set-mark (point))
+ (forward-line 1)
+ (forward-char 2) ; mid second line
+ (activate-mark)
+ (cj/duplicate-line-or-region)
+ (should (string= "aaa\nbbb\naaa\nbbb\nccc" (buffer-string))))
+ (test-duplicate-line-or-region-teardown)))
+
+(ert-deftest test-duplicate-line-or-region-ends-at-bol-no-extra-empty-line ()
+ "A region ending at beginning-of-line duplicates only the fully-included lines.
+The old open-line loop duplicated a stray empty line here."
+ (test-duplicate-line-or-region-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "aaa\nbbb\nccc")
+ (transient-mark-mode 1)
+ (goto-char (point-min))
+ (set-mark (point))
+ (forward-line 2) ; region "aaa\nbbb\n", ends at bol of ccc
+ (activate-mark)
+ (cj/duplicate-line-or-region)
+ (should (string= "aaa\nbbb\naaa\nbbb\nccc" (buffer-string))))
+ (test-duplicate-line-or-region-teardown)))
+
(ert-deftest test-duplicate-line-or-region-trailing-whitespace ()
"Should preserve trailing whitespace."
(test-duplicate-line-or-region-setup)
diff --git a/tests/test-custom-line-paragraph-join-line-or-region.el b/tests/test-custom-line-paragraph-join-line-or-region.el
index f8738910..5d421683 100644
--- a/tests/test-custom-line-paragraph-join-line-or-region.el
+++ b/tests/test-custom-line-paragraph-join-line-or-region.el
@@ -62,8 +62,8 @@
(should (string-match-p "line one line two" (buffer-string))))
(test-join-line-or-region-teardown)))
-(ert-deftest test-join-line-or-region-no-region-adds-newline-after-join ()
- "Without region, should add newline after joining."
+(ert-deftest test-join-line-or-region-no-region-adds-newline-at-end-of-buffer ()
+ "Without region, joining the last line adds a trailing newline at end of buffer."
(test-join-line-or-region-setup)
(unwind-protect
(with-temp-buffer
@@ -73,6 +73,20 @@
(should (string-suffix-p "\n" (buffer-string))))
(test-join-line-or-region-teardown)))
+(ert-deftest test-join-line-or-region-no-region-mid-buffer-no-blank-line ()
+ "Without region, joining a non-last line must not insert a blank line.
+The trailing newline belongs only at end of buffer; adding it unconditionally
+left a stray blank line between the joined line and the rest of the buffer."
+ (test-join-line-or-region-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line one\nline two\nline three")
+ (goto-char (point-min))
+ (forward-line 1) ; point on "line two", not the last line
+ (cj/join-line-or-region)
+ (should (string= "line one line two\nline three" (buffer-string))))
+ (test-join-line-or-region-teardown)))
+
(ert-deftest test-join-line-or-region-with-region-joins-all-lines ()
"With region, should join all lines in region."
(test-join-line-or-region-setup)
diff --git a/tests/test-custom-line-paragraph-jump-to-matching-paren.el b/tests/test-custom-line-paragraph-jump-to-matching-paren.el
index 31853da6..bd24faed 100644
--- a/tests/test-custom-line-paragraph-jump-to-matching-paren.el
+++ b/tests/test-custom-line-paragraph-jump-to-matching-paren.el
@@ -83,11 +83,11 @@ POINT-POSITION is 1-indexed (1 = first character)."
;;; Normal Cases - Backward Jump (Closing to Opening)
(ert-deftest test-jump-paren-backward-simple ()
- "Should jump backward from closing paren to opening paren."
+ "Should jump from a closing paren to its matching opening paren."
;; Text: "(hello)"
;; Start at position 7 (on closing paren)
- ;; Should end at position 2 (after opening paren)
- (should (= 2 (test-jump-to-matching-paren "(hello)" 7))))
+ ;; Should end at position 1 (the matching opening paren)
+ (should (= 1 (test-jump-to-matching-paren "(hello)" 7))))
(ert-deftest test-jump-paren-backward-nested ()
"Should jump backward over nested parens from after outer closing."
@@ -97,11 +97,11 @@ POINT-POSITION is 1-indexed (1 = first character)."
(should (= 1 (test-jump-to-matching-paren "(foo (bar))" 12))))
(ert-deftest test-jump-paren-backward-inner-nested ()
- "Should jump backward from inner closing paren."
+ "Should jump from an inner closing paren to its matching inner opener."
;; Text: "(foo (bar))"
;; Start at position 10 (on inner closing paren)
- ;; Should end at position 7 (after inner opening paren)
- (should (= 7 (test-jump-to-matching-paren "(foo (bar))" 10))))
+ ;; Should end at position 6 (the matching inner opening paren)
+ (should (= 6 (test-jump-to-matching-paren "(foo (bar))" 10))))
(ert-deftest test-jump-bracket-backward ()
"Should jump backward from after closing bracket."
@@ -145,11 +145,11 @@ POINT-POSITION is 1-indexed (1 = first character)."
(should (= 1 (test-jump-to-matching-paren "(hello" 1))))
(ert-deftest test-jump-paren-unmatched-closing ()
- "Should move to beginning from unmatched closing paren."
+ "Should stay put on an unmatched closing paren (no matching opener)."
;; Text: "hello)"
;; Start at position 6 (on closing paren with no opening)
- ;; backward-sexp with unmatched closing paren goes to beginning
- (should (= 1 (test-jump-to-matching-paren "hello)" 6))))
+ ;; There is no matching opener, so point is restored and stays at 6
+ (should (= 6 (test-jump-to-matching-paren "hello)" 6))))
;;; Boundary Cases - Empty Delimiters
@@ -161,11 +161,11 @@ POINT-POSITION is 1-indexed (1 = first character)."
(should (= 3 (test-jump-to-matching-paren "()" 1))))
(ert-deftest test-jump-paren-empty-backward ()
- "Should stay put when on closing paren of empty parens."
+ "Should jump from the closing paren of empty parens to its opener."
;; Text: "()"
;; Start at position 2 (on closing paren)
- ;; backward-sexp from closing of empty parens gives an error, so stays at 2
- (should (= 2 (test-jump-to-matching-paren "()" 2))))
+ ;; Should end at position 1 (the matching opening paren)
+ (should (= 1 (test-jump-to-matching-paren "()" 2))))
;;; Boundary Cases - Multiple Delimiter Types
diff --git a/tests/test-custom-ordering-number-lines.el b/tests/test-custom-ordering-number-lines.el
index adda84f0..142e5561 100644
--- a/tests/test-custom-ordering-number-lines.el
+++ b/tests/test-custom-ordering-number-lines.el
@@ -122,9 +122,10 @@ Returns the transformed string."
(should (string= result "1. "))))
(ert-deftest test-number-lines-empty-lines ()
- "Should number empty lines."
+ "Should number empty lines, treating the final newline as a terminator.
+The old split counted the trailing newline as a spurious third line."
(let ((result (test-number-lines "\n\n" "N. " nil)))
- (should (string= result "1. \n2. \n3. "))))
+ (should (string= result "1. \n2. \n"))))
(ert-deftest test-number-lines-with-existing-numbers ()
"Should number lines that already have content."
diff --git a/tests/test-custom-ordering-reverse-lines.el b/tests/test-custom-ordering-reverse-lines.el
index 3c71362d..5b8c01ac 100644
--- a/tests/test-custom-ordering-reverse-lines.el
+++ b/tests/test-custom-ordering-reverse-lines.el
@@ -86,9 +86,11 @@ Returns the transformed string."
(should (string= result "b\n\na"))))
(ert-deftest test-reverse-lines-trailing-newline ()
- "Should handle trailing newline."
+ "Should reverse the lines and preserve the trailing newline.
+The old split dropped the trailing newline into a leading empty line,
+producing \"\\nline2\\nline1\"."
(let ((result (test-reverse-lines "line1\nline2\n")))
- (should (string= result "\nline2\nline1"))))
+ (should (string= result "line2\nline1\n"))))
(ert-deftest test-reverse-lines-only-newlines ()
"Should reverse lines that are only newlines."
diff --git a/tests/test-custom-text-enclose-indent.el b/tests/test-custom-text-enclose-indent.el
index e9042d35..f37d1800 100644
--- a/tests/test-custom-text-enclose-indent.el
+++ b/tests/test-custom-text-enclose-indent.el
@@ -43,6 +43,35 @@ Returns the transformed string."
Returns the transformed string."
(cj/--dedent-lines text count))
+;;; Interactive default resolution (the prefix-arg decoupling fix)
+
+(ert-deftest test-indent-lines-interactive-no-prefix-is-four-spaces ()
+ "Interactive: no prefix indents by 4, spaces when `indent-tabs-mode' is nil.
+The old \"p\\nP\" spec defaulted count to 1 and forced tabs on any prefix."
+ (with-temp-buffer
+ (setq-local indent-tabs-mode nil)
+ (insert "line")
+ (let ((current-prefix-arg nil))
+ (call-interactively #'cj/indent-lines-in-region-or-buffer))
+ (should (string= " line" (buffer-string)))))
+
+(ert-deftest test-indent-lines-interactive-follows-indent-tabs-mode ()
+ "Interactive: tabs-vs-spaces follows `indent-tabs-mode', not the prefix arg."
+ (with-temp-buffer
+ (setq-local indent-tabs-mode t)
+ (insert "line")
+ (let ((current-prefix-arg nil))
+ (call-interactively #'cj/indent-lines-in-region-or-buffer))
+ (should (string= "\t\t\t\tline" (buffer-string)))))
+
+(ert-deftest test-dedent-lines-interactive-no-prefix-is-four ()
+ "Interactive: no prefix removes up to 4 leading whitespace characters."
+ (with-temp-buffer
+ (insert " line") ; eight leading spaces
+ (let ((current-prefix-arg nil))
+ (call-interactively #'cj/dedent-lines-in-region-or-buffer))
+ (should (string= " line" (buffer-string)))))
+
;;; Indent Tests - Normal Cases with Spaces
(ert-deftest test-indent-single-line-4-spaces ()
diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el
index 53c46caa..76fbcc42 100644
--- a/tests/test-dashboard-config-launchers.el
+++ b/tests/test-dashboard-config-launchers.el
@@ -28,20 +28,21 @@
;; Telegram moved from "g" to "G" so "g" is free for dashboard refresh.
;; Signal ("S") added as the 14th launcher.
;; Weather ("w") added after Agenda as the 15th launcher (top-row daily glance).
-(defconst test-dash--keys '("c" "d" "t" "a" "w" "r" "b" "f" "m" "e" "i" "G" "s" "l" "S"))
+(defconst test-dash--keys '("c" "d" "t" "a" "w" "r" "b" "f" "m" "e" "i" "G" "s" "l"))
;; ----------------------------- launcher table --------------------------------
(ert-deftest test-dashboard-launchers-keys-in-order ()
- "Normal: 15 launchers with the expected keys in display order."
- (should (= 15 (length cj/dashboard--launchers)))
+ "Normal: 14 launchers with the expected keys in display order.
+(Signal left the table when the signel client was retired to archive/.)"
+ (should (= 14 (length cj/dashboard--launchers)))
(should (equal test-dash--keys (mapcar (lambda (l) (nth 0 l)) cj/dashboard--launchers))))
(ert-deftest test-dashboard-launchers-labels-in-order ()
"Normal: labels in display order (Telegram and Slack reordered so Slack sits
next to Linear on the last navigator row)."
(should (equal '("Code" "Files" "Terminal" "Agenda" "Weather" "Feeds" "Books"
- "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear" "Signal")
+ "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear")
(mapcar (lambda (l) (nth 3 l)) cj/dashboard--launchers))))
(ert-deftest test-dashboard-row-sizes-cover-all-launchers ()
@@ -51,9 +52,9 @@ next to Linear on the last navigator row)."
;; --------------------------- navigator rows ----------------------------------
-(ert-deftest test-dashboard-navigator-rows-grouped-5-4-3-3 ()
- "Normal: navigator derives rows per `cj/dashboard--row-sizes' (5 4 3 3), with
-Weather joining the top row and Slack, Linear, and Signal sharing the last row."
+(ert-deftest test-dashboard-navigator-rows-grouped-5-4-3-2 ()
+ "Normal: navigator derives rows per `cj/dashboard--row-sizes' (5 4 3 2), with
+Weather joining the top row and Slack and Linear pairing on the last row."
(cl-letf (((symbol-function 'nerd-icons-faicon) (lambda (n &rest _) (concat "I:" n)))
((symbol-function 'nerd-icons-devicon) (lambda (n &rest _) (concat "I:" n)))
((symbol-function 'nerd-icons-mdicon) (lambda (n &rest _) (concat "I:" n)))
@@ -62,10 +63,10 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row."
((symbol-function 'nerd-icons-wicon) (lambda (n &rest _) (concat "I:" n))))
(let ((rows (cj/dashboard--navigator-rows)))
(should (= 4 (length rows)))
- (should (equal '(5 4 3 3) (mapcar #'length rows)))
+ (should (equal '(5 4 3 2) (mapcar #'length rows)))
(should (equal '("Code" "Files" "Terminal" "Agenda" "Weather")
(mapcar (lambda (b) (nth 1 b)) (nth 0 rows))))
- (should (equal '("Slack" "Linear" "Signal")
+ (should (equal '("Slack" "Linear")
(mapcar (lambda (b) (nth 1 b)) (nth 3 rows))))
(let ((btn (car (car rows)))) ; (icon label tooltip action nil " " "")
(should (string= "I:nf-fa-code" (nth 0 btn)))
@@ -100,7 +101,6 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row."
((symbol-function 'cj/slack-start) (lambda (&rest _) (push 'slack calls)))
((symbol-function 'cj/telega) (lambda (&rest _) (push 'tg calls)))
((symbol-function 'pearl-list-issues) (lambda (&rest _) (push 'linear calls)))
- ((symbol-function 'cj/signel-message) (lambda (&rest _) (push 'signal calls)))
;; wttrin is invoked via `call-interactively', so the stub must be
;; a command -- a plain variadic lambda masked the real arity bug.
((symbol-function 'wttrin) (lambda (&rest _) (interactive) (push 'weather calls))))
@@ -112,9 +112,8 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row."
(should (memq 'linear calls))
(should (memq 'm-toggle calls))
(should (memq 'm-load calls))
- (should (memq 'signal calls))
(should (memq 'weather calls))
- (should (= 16 (length calls)))))) ; 15 keys, Music fires two
+ (should (= 15 (length calls)))))) ; 14 keys, Music fires two
(provide 'test-dashboard-config-launchers)
;;; test-dashboard-config-launchers.el ends here
diff --git a/tests/test-dashboard-config.el b/tests/test-dashboard-config.el
index 2dbcd4f4..3a48ee56 100644
--- a/tests/test-dashboard-config.el
+++ b/tests/test-dashboard-config.el
@@ -56,5 +56,15 @@ start at the top. Without `set-window-start', batch redisplay leaves
(when (buffer-live-p dash)
(kill-buffer dash)))))
+(ert-deftest test-dashboard-config-bookmark-override-deferred-to-package-load ()
+ "Normal: the bookmarks override is defined exactly when dashboard-widgets is.
+A bare top-level defun would exist even without the package (and be
+clobbered when the package loads); the deferred registration means the
+function tracks the package's own load state. Holds in both runners:
+the hook env loads dashboard, the make-test env can't."
+ (if (featurep 'dashboard-widgets)
+ (should (fboundp 'dashboard-insert-bookmarks))
+ (should-not (fboundp 'dashboard-insert-bookmarks))))
+
(provide 'test-dashboard-config)
;;; test-dashboard-config.el ends here
diff --git a/tests/test-dev-fkeys--f4-clean-rebuild-impl.el b/tests/test-dev-fkeys--f4-clean-rebuild-impl.el
index 27c7c56a..bed51d79 100644
--- a/tests/test-dev-fkeys--f4-clean-rebuild-impl.el
+++ b/tests/test-dev-fkeys--f4-clean-rebuild-impl.el
@@ -3,7 +3,11 @@
;;; Commentary:
;; Tests for the "Clean + Rebuild" action handler. Runs the heuristic clean
;; command via `compile' from the project root, then chains
-;; `projectile-compile-project' on success via the one-shot finish hook.
+;; `projectile-compile-project' on success via a one-shot finish hook
+;; installed buffer-locally in the compilation buffer `compile' returns.
+;; The global `compilation-finish-functions' is never touched, so a quit
+;; before the compile starts or an unrelated concurrent compile can never
+;; fire the chained rebuild.
;;; Code:
@@ -24,6 +28,18 @@ Bind the dir path to ROOT in BODY. Cleans up on exit."
,@body)
(delete-directory root t))))
+(defmacro test-dev-fkeys-cr--with-compilation-buffer (buf &rest body)
+ "Run BODY with BUF bound to a temp buffer standing in for a compilation buffer."
+ (declare (indent 1))
+ `(let ((,buf (generate-new-buffer " *test-compilation*")))
+ (unwind-protect
+ (progn ,@body)
+ (kill-buffer ,buf))))
+
+(defun test-dev-fkeys-cr--local-hooks (buf)
+ "Return the buffer-local finish hooks of BUF, without the t marker."
+ (remq t (buffer-local-value 'compilation-finish-functions buf)))
+
;;; Normal Cases
(ert-deftest test-dev-fkeys-clean-rebuild-impl-runs-derived-clean-cmd ()
@@ -34,48 +50,50 @@ Components integrated:
- `cj/--f4-clean-rebuild-impl' (unit under test)
- `cj/--f4-derive-clean-cmd' (real)
- `compile' (MOCKED — captures the command string)
-- `projectile-compile-project' (MOCKED — no-op)
-- `compilation-finish-functions' (real, scoped via let)"
+- `projectile-compile-project' (MOCKED — no-op)"
(test-dev-fkeys-cr--with-project '("Makefile")
- (let ((compile-calls nil)
- (compilation-finish-functions nil))
+ (let ((compile-calls nil))
(cl-letf (((symbol-function 'compile)
- (lambda (cmd) (push cmd compile-calls)))
+ (lambda (cmd) (push cmd compile-calls) nil))
((symbol-function 'projectile-compile-project)
(lambda (_arg) nil)))
(cj/--f4-clean-rebuild-impl root)
(should (equal compile-calls '("make clean")))))))
-(ert-deftest test-dev-fkeys-clean-rebuild-impl-installs-finish-hook ()
- "Normal: handler installs exactly one hook in `compilation-finish-functions'."
+(ert-deftest test-dev-fkeys-clean-rebuild-impl-installs-hook-in-compilation-buffer ()
+ "Normal: the one-shot hook lands buffer-locally in the buffer `compile'
+returns; the global `compilation-finish-functions' stays untouched."
(test-dev-fkeys-cr--with-project '("go.mod")
- (let ((compilation-finish-functions nil))
- (cl-letf (((symbol-function 'compile) (lambda (_cmd) nil))
- ((symbol-function 'projectile-compile-project)
- (lambda (_arg) nil)))
- (cj/--f4-clean-rebuild-impl root)
- (should (= (length compilation-finish-functions) 1))))))
+ (test-dev-fkeys-cr--with-compilation-buffer buf
+ (let ((compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) buf))
+ ((symbol-function 'projectile-compile-project)
+ (lambda (_arg) nil)))
+ (cj/--f4-clean-rebuild-impl root)
+ (should (null compilation-finish-functions))
+ (should (= 1 (length (test-dev-fkeys-cr--local-hooks buf)))))))))
(ert-deftest test-dev-fkeys-clean-rebuild-impl-hook-runs-projectile-compile-on-success ()
- "Normal: when the clean step finishes successfully, the installed hook
+ "Normal: when the clean step finishes successfully, the buffer-local hook
calls `projectile-compile-project' to do the rebuild."
(test-dev-fkeys-cr--with-project '("Cargo.toml")
- (let ((compile-calls 0)
- (compilation-finish-functions nil))
- (cl-letf (((symbol-function 'compile) (lambda (_cmd) nil))
- ((symbol-function 'projectile-compile-project)
- (lambda (_arg) (cl-incf compile-calls))))
- (cj/--f4-clean-rebuild-impl root)
- (run-hook-with-args 'compilation-finish-functions nil "finished\n")
- (should (= compile-calls 1))))))
+ (test-dev-fkeys-cr--with-compilation-buffer buf
+ (let ((compile-calls 0)
+ (compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) buf))
+ ((symbol-function 'projectile-compile-project)
+ (lambda (_arg) (cl-incf compile-calls))))
+ (cj/--f4-clean-rebuild-impl root)
+ (with-current-buffer buf
+ (run-hook-with-args 'compilation-finish-functions buf "finished\n"))
+ (should (= compile-calls 1)))))))
(ert-deftest test-dev-fkeys-clean-rebuild-impl-runs-clean-from-project-root ()
"Normal: the clean compile runs with default-directory bound to ROOT."
(test-dev-fkeys-cr--with-project '("Eask")
- (let ((seen-dir nil)
- (compilation-finish-functions nil))
+ (let ((seen-dir nil))
(cl-letf (((symbol-function 'compile)
- (lambda (_cmd) (setq seen-dir default-directory)))
+ (lambda (_cmd) (setq seen-dir default-directory) nil))
((symbol-function 'projectile-compile-project)
(lambda (_arg) nil)))
(cj/--f4-clean-rebuild-impl root)
@@ -87,14 +105,28 @@ calls `projectile-compile-project' to do the rebuild."
(ert-deftest test-dev-fkeys-clean-rebuild-impl-hook-skips-rebuild-on-failure ()
"Boundary: when the clean step fails, projectile-compile-project does not run."
(test-dev-fkeys-cr--with-project '("Makefile")
- (let ((compile-calls 0)
- (compilation-finish-functions nil))
+ (test-dev-fkeys-cr--with-compilation-buffer buf
+ (let ((compile-calls 0)
+ (compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) buf))
+ ((symbol-function 'projectile-compile-project)
+ (lambda (_arg) (cl-incf compile-calls))))
+ (cj/--f4-clean-rebuild-impl root)
+ (with-current-buffer buf
+ (run-hook-with-args 'compilation-finish-functions
+ buf "exited abnormally\n"))
+ (should (= compile-calls 0)))))))
+
+(ert-deftest test-dev-fkeys-clean-rebuild-impl-dead-compile-buffer-no-global-hook ()
+ "Boundary: when `compile' returns no live buffer, nothing is installed
+anywhere — the global hook list stays empty."
+ (test-dev-fkeys-cr--with-project '("Makefile")
+ (let ((compilation-finish-functions nil))
(cl-letf (((symbol-function 'compile) (lambda (_cmd) nil))
((symbol-function 'projectile-compile-project)
- (lambda (_arg) (cl-incf compile-calls))))
+ (lambda (_arg) nil)))
(cj/--f4-clean-rebuild-impl root)
- (run-hook-with-args 'compilation-finish-functions nil "exited abnormally\n")
- (should (= compile-calls 0))))))
+ (should (null compilation-finish-functions))))))
;;; Error Cases
diff --git a/tests/test-dev-fkeys--f4-compile-and-run-impl.el b/tests/test-dev-fkeys--f4-compile-and-run-impl.el
index d59a6cd6..34e5bdf3 100644
--- a/tests/test-dev-fkeys--f4-compile-and-run-impl.el
+++ b/tests/test-dev-fkeys--f4-compile-and-run-impl.el
@@ -2,8 +2,11 @@
;;; Commentary:
;; Tests for the "Compile + Run" action handler. After kicking off the
-;; compile, attaches a one-shot `compilation-finish-functions' hook that
-;; runs the project on success.
+;; compile, attaches a one-shot finish hook buffer-locally in the
+;; compilation buffer projectile returns, so the global
+;; `compilation-finish-functions' is never touched. A quit at
+;; projectile's compile prompt therefore can never leave an armed hook
+;; that a later unrelated compile would fire.
;;; Code:
@@ -12,6 +15,14 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'dev-fkeys)
+(defmacro test-dev-fkeys-car--with-buffer (buf &rest body)
+ "Run BODY with BUF bound to a temp buffer standing in for a compilation buffer."
+ (declare (indent 1))
+ `(let ((,buf (generate-new-buffer " *test-compilation*")))
+ (unwind-protect
+ (progn ,@body)
+ (kill-buffer ,buf))))
+
;;; Normal Cases
(ert-deftest test-dev-fkeys-compile-and-run-impl-invokes-projectile-compile ()
@@ -19,57 +30,77 @@
Components integrated:
- `cj/--f4-compile-and-run-impl' (unit under test)
-- `projectile-compile-project' (MOCKED via cl-letf)
-- `compilation-finish-functions' (real, scoped via let)"
- (let ((compile-calls 0)
- (compilation-finish-functions nil))
+- `projectile-compile-project' (MOCKED via cl-letf)"
+ (let ((compile-calls 0))
(cl-letf (((symbol-function 'projectile-compile-project)
- (lambda (_arg) (cl-incf compile-calls))))
+ (lambda (_arg) (cl-incf compile-calls) nil)))
(cj/--f4-compile-and-run-impl)
(should (= compile-calls 1)))))
-(ert-deftest test-dev-fkeys-compile-and-run-impl-installs-finish-hook ()
- "Normal: handler installs exactly one hook in `compilation-finish-functions'."
- (let ((compilation-finish-functions nil))
- (cl-letf (((symbol-function 'projectile-compile-project)
- (lambda (_arg) nil)))
- (cj/--f4-compile-and-run-impl)
- (should (= (length compilation-finish-functions) 1)))))
+(ert-deftest test-dev-fkeys-compile-and-run-impl-installs-hook-in-compilation-buffer ()
+ "Normal: the one-shot hook lands buffer-locally in the compilation buffer;
+the global `compilation-finish-functions' stays untouched."
+ (test-dev-fkeys-car--with-buffer buf
+ (let ((compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'projectile-compile-project)
+ (lambda (_arg) buf)))
+ (cj/--f4-compile-and-run-impl)
+ (should (null compilation-finish-functions))
+ (should (= 1 (length (remq t (buffer-local-value
+ 'compilation-finish-functions buf)))))))))
(ert-deftest test-dev-fkeys-compile-and-run-impl-hook-runs-projectile-run-on-success ()
- "Normal: when the compile finishes successfully, the installed hook calls
-`projectile-run-project'.
+ "Normal: when the compile finishes successfully, the buffer-local hook
+calls `projectile-run-project'.
Components integrated:
- `cj/--f4-compile-and-run-impl' (unit under test)
-- `projectile-compile-project' (MOCKED — no-op)
+- `projectile-compile-project' (MOCKED — returns the compilation buffer)
- `projectile-run-project' (MOCKED — counts calls)
-- `compilation-finish-functions' (real)
+- `compilation-finish-functions' (real, buffer-local)
- `run-hook-with-args' (real — simulates compile.el firing the hook)"
- (let ((run-calls 0)
- (compilation-finish-functions nil))
- (cl-letf (((symbol-function 'projectile-compile-project)
- (lambda (_arg) nil))
- ((symbol-function 'projectile-run-project)
- (lambda (_arg) (cl-incf run-calls))))
- (cj/--f4-compile-and-run-impl)
- (run-hook-with-args 'compilation-finish-functions nil "finished\n")
- (should (= run-calls 1)))))
+ (test-dev-fkeys-car--with-buffer buf
+ (let ((run-calls 0)
+ (compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'projectile-compile-project)
+ (lambda (_arg) buf))
+ ((symbol-function 'projectile-run-project)
+ (lambda (_arg) (cl-incf run-calls))))
+ (cj/--f4-compile-and-run-impl)
+ (with-current-buffer buf
+ (run-hook-with-args 'compilation-finish-functions buf "finished\n"))
+ (should (= run-calls 1))))))
;;; Boundary Cases
(ert-deftest test-dev-fkeys-compile-and-run-impl-hook-skips-projectile-run-on-failure ()
"Boundary: when the compile fails, projectile-run-project must not run.
The hook still self-removes (covered in the make-once-hook tests)."
- (let ((run-calls 0)
- (compilation-finish-functions nil))
+ (test-dev-fkeys-car--with-buffer buf
+ (let ((run-calls 0)
+ (compilation-finish-functions nil))
+ (cl-letf (((symbol-function 'projectile-compile-project)
+ (lambda (_arg) buf))
+ ((symbol-function 'projectile-run-project)
+ (lambda (_arg) (cl-incf run-calls))))
+ (cj/--f4-compile-and-run-impl)
+ (with-current-buffer buf
+ (run-hook-with-args 'compilation-finish-functions
+ buf "exited abnormally\n"))
+ (should (= run-calls 0))))))
+
+(ert-deftest test-dev-fkeys-compile-and-run-impl-quit-leaves-no-global-hook ()
+ "Boundary: a quit at projectile's prompt leaves no armed hook anywhere.
+This is the regression the buffer-local install exists to prevent: the
+old shape armed a global hook before the prompt, so C-g left it live and
+the next unrelated compile fired the chained run."
+ (let ((compilation-finish-functions nil))
(cl-letf (((symbol-function 'projectile-compile-project)
- (lambda (_arg) nil))
- ((symbol-function 'projectile-run-project)
- (lambda (_arg) (cl-incf run-calls))))
- (cj/--f4-compile-and-run-impl)
- (run-hook-with-args 'compilation-finish-functions nil "exited abnormally\n")
- (should (= run-calls 0)))))
+ (lambda (_arg) (signal 'quit nil))))
+ (condition-case nil
+ (cj/--f4-compile-and-run-impl)
+ (quit nil))
+ (should (null compilation-finish-functions)))))
(provide 'test-dev-fkeys--f4-compile-and-run-impl)
;;; test-dev-fkeys--f4-compile-and-run-impl.el ends here
diff --git a/tests/test-dev-fkeys--f4-make-once-hook.el b/tests/test-dev-fkeys--f4-make-once-hook.el
index b6c71dd7..4fc84e63 100644
--- a/tests/test-dev-fkeys--f4-make-once-hook.el
+++ b/tests/test-dev-fkeys--f4-make-once-hook.el
@@ -95,6 +95,21 @@ hook exactly once per compile, so the practical contract is one-shot."
(funcall hook nil "interrupt\n"))
(should (= called 0))))
+(ert-deftest test-dev-fkeys-make-once-hook-removes-itself-buffer-locally ()
+ "Boundary: a hook installed buffer-locally removes its local entry when
+run in that buffer — the shape used by the F4 chained-compile handlers."
+ (let ((buf (generate-new-buffer " *test-once-hook*"))
+ (called 0))
+ (unwind-protect
+ (let ((hook (cj/--f4-make-once-hook (lambda () (cl-incf called)))))
+ (with-current-buffer buf
+ (add-hook 'compilation-finish-functions hook nil t)
+ (funcall hook buf "finished\n")
+ (should-not (memq hook (buffer-local-value
+ 'compilation-finish-functions buf))))
+ (should (= called 1)))
+ (kill-buffer buf))))
+
;;; Error Cases
(ert-deftest test-dev-fkeys-make-once-hook-then-fn-error-still-removes-hook ()
diff --git a/tests/test-dev-fkeys--f6-test-runner-cmd-for.el b/tests/test-dev-fkeys--f6-test-runner-cmd-for.el
index d7b6a059..59d0ba42 100644
--- a/tests/test-dev-fkeys--f6-test-runner-cmd-for.el
+++ b/tests/test-dev-fkeys--f6-test-runner-cmd-for.el
@@ -138,10 +138,15 @@ rather than a silent nil that F6's outer wrapper interprets as
'typescript t "src/foo.test.ts" "foo" "src")
"npx --no-install vitest src/foo.test.ts"))))
-(ert-deftest test-dev-fkeys-f6-cmd-for-javascript-returns-nil ()
- "Error: JavaScript is punted for v1 and returns nil."
- (should (null (cj/--f6-test-runner-cmd-for
- 'javascript t "src/foo.test.js" "foo" "src"))))
+(ert-deftest test-dev-fkeys-f6-cmd-for-javascript-uses-npx-runner ()
+ "Normal: javascript gets the same npx runner command as typescript.
+The language detector classifies js/jsx and the test-file detector
+recognizes JS test files, but the dispatch had no javascript arm, so
+C-F6 on a JS test errored even though the npx path would run it."
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil)))
+ (should (equal (cj/--f6-test-runner-cmd-for
+ 'javascript t "src/foo.test.js" "foo" "src")
+ "npx --no-install jest src/foo.test.js"))))
(ert-deftest test-dev-fkeys-f6-cmd-for-unknown-returns-nil ()
"Error: an unknown language returns nil."
diff --git a/tests/test-diff-config--ediff-options.el b/tests/test-diff-config--ediff-options.el
new file mode 100644
index 00000000..a43637d9
--- /dev/null
+++ b/tests/test-diff-config--ediff-options.el
@@ -0,0 +1,27 @@
+;;; test-diff-config--ediff-options.el --- Tests for ediff diff options -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Pins the removal of the global "-w" default for `ediff-diff-options'.
+;; With "-w", every ediff session ignores ALL whitespace, so
+;; indentation-only changes (significant in Python, Makefiles, YAML)
+;; compare as identical. Whitespace-ignoring is a per-session toggle
+;; (ediff's `##'), not a global default.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'diff-config)
+
+;;; Normal Cases
+
+(ert-deftest test-diff-config-ediff-options-no-global-whitespace-ignore ()
+ "Normal: after ediff loads, no global -w sits in ediff-diff-options.
+The use-package :custom values apply when the deferred package loads,
+so the assertion must run with ediff actually loaded."
+ (require 'ediff)
+ (should (not (string-match-p "-w" (or ediff-diff-options "")))))
+
+(provide 'test-diff-config--ediff-options)
+;;; test-diff-config--ediff-options.el ends here
diff --git a/tests/test-dirvish-config--quantize-thumb-size.el b/tests/test-dirvish-config--quantize-thumb-size.el
new file mode 100644
index 00000000..a26ef090
--- /dev/null
+++ b/tests/test-dirvish-config--quantize-thumb-size.el
@@ -0,0 +1,60 @@
+;;; test-dirvish-config--quantize-thumb-size.el --- thumbnail width-bucket tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--dirvish-quantize-thumb-size' rounds dirvish's computed thumbnail size to
+;; a coarse pixel bucket so small preview-window jitter maps to one stable cache
+;; key instead of a fresh miss + regenerate (the webm thumbnail-flash bug). Pure
+;; math; the :filter-return advice that wires it onto `dirvish-media--img-size'
+;; is verified live.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dirvish-config)
+
+(declare-function cj/--dirvish-quantize-thumb-size "dirvish-config" (size bucket))
+
+;;; ------------------------------- rounding -----------------------------------
+
+(ert-deftest test-dirvish-quantize-rounds-down ()
+ "Normal: a size below the bucket midpoint rounds down to the bucket."
+ (should (= (cj/--dirvish-quantize-thumb-size 931 100) 900)))
+
+(ert-deftest test-dirvish-quantize-rounds-up ()
+ "Normal: a size above the bucket midpoint rounds up to the next bucket."
+ (should (= (cj/--dirvish-quantize-thumb-size 552 100) 600)))
+
+(ert-deftest test-dirvish-quantize-on-bucket-unchanged ()
+ "Normal: a size already on a bucket boundary is returned unchanged."
+ (should (= (cj/--dirvish-quantize-thumb-size 600 100) 600)))
+
+(ert-deftest test-dirvish-quantize-jitter-maps-to-one-key ()
+ "Boundary: nearby sizes within a bucket collapse to the same cache key."
+ (should (= (cj/--dirvish-quantize-thumb-size 931 100)
+ (cj/--dirvish-quantize-thumb-size 949 100))))
+
+(ert-deftest test-dirvish-quantize-float-input ()
+ "Boundary: a float size (pre-floor) still yields an integer bucket."
+ (let ((r (cj/--dirvish-quantize-thumb-size 931.4 100)))
+ (should (integerp r))
+ (should (= r 900))))
+
+;;; ------------------------------- clamping -----------------------------------
+
+(ert-deftest test-dirvish-quantize-tiny-clamps-to-bucket ()
+ "Boundary: a size that would round to 0 clamps up to one bucket, never 0."
+ (should (= (cj/--dirvish-quantize-thumb-size 1 100) 100)))
+
+;;; ------------------------------ disabled path -------------------------------
+
+(ert-deftest test-dirvish-quantize-zero-bucket-passthrough ()
+ "Error/disabled: a zero bucket returns the size unchanged."
+ (should (= (cj/--dirvish-quantize-thumb-size 931 0) 931)))
+
+(ert-deftest test-dirvish-quantize-nil-bucket-passthrough ()
+ "Error/disabled: a nil bucket returns the size unchanged."
+ (should (= (cj/--dirvish-quantize-thumb-size 931 nil) 931)))
+
+(provide 'test-dirvish-config--quantize-thumb-size)
+;;; test-dirvish-config--quantize-thumb-size.el ends here
diff --git a/tests/test-dirvish-config-runtime-requires.el b/tests/test-dirvish-config-runtime-requires.el
index 34fb67ac..ca94e4b5 100644
--- a/tests/test-dirvish-config-runtime-requires.el
+++ b/tests/test-dirvish-config-runtime-requires.el
@@ -3,15 +3,13 @@
;;; Commentary:
;; dirvish-config.el builds `dirvish-quick-access-entries' from `code-dir',
;; `music-dir', `pix-dir' (and friends) at load time and binds keys to
-;; `cj/xdg-open' / `cj/open-file-with-command', so it depends on user-constants
-;; and system-utils at runtime. Those were declared with `eval-when-compile',
-;; which leaves the compiled module without the requires at load — fragile
-;; under init order. This is a dependency-contract smoke test: requiring
-;; dirvish-config in isolation must pull both features in, so it fails if the
-;; requires are dropped entirely. (It can't catch a downgrade back to
-;; `eval-when-compile', since that form still runs when the file loads as
-;; source, which the test harness does — that regression is guarded by keeping
-;; the plain requires in review, not by this test.)
+;; `cj/xdg-open' (external-open) and `cj/open-file-with-command'
+;; (system-utils), so it depends on user-constants, system-utils, and
+;; external-open at runtime. This is a dependency-contract smoke test:
+;; requiring dirvish-config in isolation must pull those features in, so it
+;; fails if the requires are dropped entirely. Run it with `make test-file'
+;; for a clean signal: in the full suite another file may already have loaded
+;; external-open, masking a regression here.
;;; Code:
@@ -26,5 +24,13 @@
"Normal: requiring dirvish-config pulls in system-utils at runtime."
(should (featurep 'system-utils)))
+(ert-deftest test-dirvish-config-loads-external-open ()
+ "Normal: requiring dirvish-config pulls in external-open at runtime.
+The keys `o' and the OS-handler fallback call `cj/xdg-open', which lives
+in external-open; without the require the binding works only when init
+order happens to load external-open first."
+ (should (featurep 'external-open))
+ (should (fboundp 'cj/xdg-open)))
+
(provide 'test-dirvish-config-runtime-requires)
;;; test-dirvish-config-runtime-requires.el ends here
diff --git a/tests/test-dwim-shell-config-runtime-requires.el b/tests/test-dwim-shell-config-runtime-requires.el
new file mode 100644
index 00000000..ab53e7d4
--- /dev/null
+++ b/tests/test-dwim-shell-config-runtime-requires.el
@@ -0,0 +1,24 @@
+;;; test-dwim-shell-config-runtime-requires.el --- dwim-shell-config declares its deps -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; dwim-shell-config.el calls `cj/xdg-open' (external-open) to open a
+;; conversion's output file, but declared it only with `declare-function'
+;; and never required external-open. The binding works at runtime only
+;; because init.el happens to load external-open first — fragile under init
+;; order, and the "Direct test load: yes" header claims otherwise. This is
+;; a dependency-contract smoke test: requiring dwim-shell-config in isolation
+;; must pull external-open in. Run with `make test-file' for a clean signal;
+;; in the full suite another file may already have loaded external-open.
+
+;;; Code:
+
+(require 'ert)
+(require 'dwim-shell-config)
+
+(ert-deftest test-dwim-shell-config-loads-external-open ()
+ "Normal: requiring dwim-shell-config pulls in external-open at runtime."
+ (should (featurep 'external-open))
+ (should (fboundp 'cj/xdg-open)))
+
+(provide 'test-dwim-shell-config-runtime-requires)
+;;; test-dwim-shell-config-runtime-requires.el ends here
diff --git a/tests/test-eat-config--xtwinops.el b/tests/test-eat-config--xtwinops.el
new file mode 100644
index 00000000..29f87f2f
--- /dev/null
+++ b/tests/test-eat-config--xtwinops.el
@@ -0,0 +1,120 @@
+;;; test-eat-config--xtwinops.el --- Tests for the EAT XTWINOPS window-size reply -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the XTWINOPS (CSI <n> t) window-size responder. eat 0.9.4
+;; has no CSI <n> t handler, so it silently drops the window-size requests
+;; tmux 3.7b sends to learn the cell pixel size it needs before it will emit
+;; Sixel -- images then never render inside EAT. The module answers three
+;; requests: 14 (text area in pixels), 16 (cell size in pixels), 18 (text area
+;; in characters).
+;;
+;; Two pure pieces carry the logic and are tested here directly:
+;; - `cj/--eat-xtwinops-report' computes the reply string from the display and
+;; cell dimensions.
+;; - `cj/--eat-xtwinops-queries' extracts the request numbers from a chunk of
+;; terminal output.
+;; The thin accessor glue (`cj/--eat-send-window-size-report', which reads the
+;; live `eat--t-term' struct) is verified in the running daemon, since eat's
+;; structs are not loadable under `make test' (no package-initialize). The
+;; detector's dispatch is tested against a recording stub of the responder.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub keymap dep before loading the module (matches the other module tests).
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'eat-config)
+
+;;; --------------------------- reply computation ----------------------------
+
+(ert-deftest test-eat-config-xtwinops-report-normal-14-text-area-pixels ()
+ "Normal: request 14 reports text-area size in pixels (rows*ch by cols*cw)."
+ ;; 80x24 chars, 10x20 px cells -> height 24*20=480, width 80*10=800.
+ (should (equal (cj/--eat-xtwinops-report 14 80 24 10 20)
+ "\e[4;480;800t")))
+
+(ert-deftest test-eat-config-xtwinops-report-normal-16-cell-pixels ()
+ "Normal: request 16 reports the cell size in pixels (height then width)."
+ (should (equal (cj/--eat-xtwinops-report 16 80 24 10 20)
+ "\e[6;20;10t")))
+
+(ert-deftest test-eat-config-xtwinops-report-normal-18-text-area-chars ()
+ "Normal: request 18 reports the text-area size in characters (rows then cols)."
+ (should (equal (cj/--eat-xtwinops-report 18 80 24 10 20)
+ "\e[8;24;80t")))
+
+(ert-deftest test-eat-config-xtwinops-report-boundary-unit-cells ()
+ "Boundary: with eat's default 1x1 px cells, pixel dims equal the char dims."
+ (should (equal (cj/--eat-xtwinops-report 14 80 24 1 1) "\e[4;24;80t"))
+ (should (equal (cj/--eat-xtwinops-report 16 80 24 1 1) "\e[6;1;1t")))
+
+(ert-deftest test-eat-config-xtwinops-report-error-unknown-request-is-nil ()
+ "Error: any request number other than 14/16/18 returns nil (unanswered)."
+ (should (null (cj/--eat-xtwinops-report 15 80 24 10 20)))
+ (should (null (cj/--eat-xtwinops-report 24 80 24 10 20)))
+ (should (null (cj/--eat-xtwinops-report nil 80 24 10 20))))
+
+;;; ----------------------------- query detection ----------------------------
+
+(ert-deftest test-eat-config-xtwinops-queries-normal-single ()
+ "Normal: a lone CSI 14 t query is detected."
+ (should (equal (cj/--eat-xtwinops-queries "\e[14t") '(14))))
+
+(ert-deftest test-eat-config-xtwinops-queries-normal-embedded-multiple ()
+ "Normal: several queries embedded in other output are returned in order."
+ (should (equal (cj/--eat-xtwinops-queries "foo\e[14tbar\e[18tbaz\e[16t")
+ '(14 18 16))))
+
+(ert-deftest test-eat-config-xtwinops-queries-boundary-none ()
+ "Boundary: output with no XTWINOPS query returns nil, including empty."
+ (should (null (cj/--eat-xtwinops-queries "")))
+ (should (null (cj/--eat-xtwinops-queries "hello\e[0m\e[2J"))))
+
+(ert-deftest test-eat-config-xtwinops-queries-error-unhandled-ops-ignored ()
+ "Error: CSI t ops we do not answer (and parametrized forms) are not matched.
+`\\e[24t' is a resize op, `\\e[3;14t' carries a leading param -- neither is the
+bare CSI 14/16/18 t we answer, so both are ignored."
+ (should (null (cj/--eat-xtwinops-queries "\e[24t")))
+ (should (null (cj/--eat-xtwinops-queries "\e[3;14t")))
+ (should (null (cj/--eat-xtwinops-queries "\e[114t"))))
+
+;;; --------------------------- advice-needed guard --------------------------
+
+(ert-deftest test-eat-config-xtwinops-advice-needed-normal-eat-0-9-4 ()
+ "Normal: on eat 0.9.4 (no upstream CSI t clause) the advice is needed."
+ ;; The upstream parser clause defines `eat--t-send-window-size-report';
+ ;; 0.9.4 does not, so it must be unbound here.
+ (should-not (fboundp 'eat--t-send-window-size-report))
+ (should (cj/--eat-xtwinops-advice-needed-p)))
+
+(ert-deftest test-eat-config-xtwinops-advice-needed-boundary-upstream-ships ()
+ "Boundary: once upstream defines the parser clause, the advice must NOT
+install -- both would answer and tmux gets a double reply."
+ (cl-letf (((symbol-function 'eat--t-send-window-size-report) #'ignore))
+ (should-not (cj/--eat-xtwinops-advice-needed-p))))
+
+;;; ------------------------- detector dispatch (seam) -----------------------
+
+(ert-deftest test-eat-config-xtwinops-answer-dispatches-once-per-query ()
+ "Integration: the detector calls the responder once per query, in order.
+Mocks only our own responder seam (`cj/--eat-send-window-size-report'); the
+detector under test does the real query extraction."
+ (let ((calls '()))
+ (cl-letf (((symbol-function 'cj/--eat-send-window-size-report)
+ (lambda (n) (push n calls))))
+ (cj/--eat-answer-xtwinops "\e[14t\e[16t\e[18t"))
+ (should (equal (nreverse calls) '(14 16 18)))))
+
+(ert-deftest test-eat-config-xtwinops-answer-no-query-no-call ()
+ "Boundary: output with no query never calls the responder."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'cj/--eat-send-window-size-report)
+ (lambda (_n) (setq called t))))
+ (cj/--eat-answer-xtwinops "no query here\e[2J"))
+ (should-not called)))
+
+(provide 'test-eat-config--xtwinops)
+;;; test-eat-config--xtwinops.el ends here
diff --git a/tests/test-elfeed-config-helpers.el b/tests/test-elfeed-config-helpers.el
index 16cbb744..95a98e83 100644
--- a/tests/test-elfeed-config-helpers.el
+++ b/tests/test-elfeed-config-helpers.el
@@ -1,10 +1,7 @@
;;; test-elfeed-config-helpers.el --- Tests for elfeed stream/process helpers -*- lexical-binding: t; -*-
;;; Commentary:
-;; Coverage for two elfeed-config helpers that were untested:
-;; - cj/extract-stream-url: runs yt-dlp -g to resolve a direct stream URL,
-;; returning the URL, nil on non-URL / nonzero exit, or signalling when
-;; yt-dlp is absent.
+;; Coverage for the elfeed-config entry-processing helper:
;; - cj/elfeed-process-entries: applies an action to each selected entry,
;; marking them read; errors when nothing is selected, skips entries with
;; no link, and (by default) catches per-entry action errors.
@@ -34,40 +31,6 @@
(require 'elfeed-config)
(require 'elfeed nil t)
-;;; cj/extract-stream-url
-
-(ert-deftest test-elfeed-extract-stream-url-normal-returns-url ()
- "Normal: a successful yt-dlp run returns the trimmed https stream URL."
- (cl-letf (((symbol-function 'executable-find)
- (lambda (p &rest _) (and (equal p "yt-dlp") "/usr/bin/yt-dlp")))
- ((symbol-function 'cj/log-silently) #'ignore)
- ((symbol-function 'call-process)
- (lambda (_prog _infile _dest _disp &rest _args)
- (insert "https://stream.example/abc\n") 0)))
- (should (equal "https://stream.example/abc"
- (cj/extract-stream-url "https://youtube.com/watch?v=x" "best")))))
-
-(ert-deftest test-elfeed-extract-stream-url-boundary-non-url-output-is-nil ()
- "Boundary: output that is not an http(s) URL yields nil, not the raw text."
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/yt-dlp"))
- ((symbol-function 'cj/log-silently) #'ignore)
- ((symbol-function 'call-process)
- (lambda (_p _i _d _disp &rest _) (insert "ERROR: unavailable\n") 0)))
- (should (null (cj/extract-stream-url "u" nil)))))
-
-(ert-deftest test-elfeed-extract-stream-url-boundary-nonzero-exit-is-nil ()
- "Boundary: a nonzero yt-dlp exit code yields nil."
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/yt-dlp"))
- ((symbol-function 'cj/log-silently) #'ignore)
- ((symbol-function 'call-process)
- (lambda (_p _i _d _disp &rest _) (insert "boom") 1)))
- (should (null (cj/extract-stream-url "u" nil)))))
-
-(ert-deftest test-elfeed-extract-stream-url-error-without-yt-dlp ()
- "Error: a missing yt-dlp signals before attempting the call."
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
- (should-error (cj/extract-stream-url "u" "best") :type 'error)))
-
;;; cj/elfeed-process-entries
(defun cj/test--elfeed-entry (link)
diff --git a/tests/test-external-open--open-with-argv.el b/tests/test-external-open--open-with-argv.el
new file mode 100644
index 00000000..27a7e811
--- /dev/null
+++ b/tests/test-external-open--open-with-argv.el
@@ -0,0 +1,59 @@
+;;; test-external-open--open-with-argv.el --- Tests for cj/--open-with-argv -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/--open-with-argv, the pure builder that turns the
+;; user-typed "open with" command plus a file path into an argv list.
+;; The argv shape is the hardening: the file is one list element, so paths
+;; with spaces or shell metacharacters never meet a shell.
+;;
+;; Test organization:
+;; - Normal Cases: bare program, program with args, quoted argument
+;; - Boundary Cases: file with spaces and metacharacters stays one element
+;; - Error Cases: empty and whitespace-only commands
+;;
+;;; Code:
+
+(require 'ert)
+(require 'external-open)
+
+;;; Normal Cases
+
+(ert-deftest test-external-open--open-with-argv-normal-bare-program ()
+ "Normal: a bare program name yields (PROGRAM FILE)."
+ (should (equal (cj/--open-with-argv "vlc" "/tmp/foo.mp4")
+ '("vlc" "/tmp/foo.mp4"))))
+
+(ert-deftest test-external-open--open-with-argv-normal-program-with-args ()
+ "Normal: a command typed with arguments splits into argv words."
+ (should (equal (cj/--open-with-argv "mpv --fs --loop" "/tmp/foo.mp4")
+ '("mpv" "--fs" "--loop" "/tmp/foo.mp4"))))
+
+(ert-deftest test-external-open--open-with-argv-normal-quoted-arg-survives ()
+ "Normal: a double-quoted argument stays one word."
+ (should (equal (cj/--open-with-argv "player \"two words\"" "/tmp/foo.mp4")
+ '("player" "two words" "/tmp/foo.mp4"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-external-open--open-with-argv-boundary-file-with-spaces ()
+ "Boundary: a path with spaces is one argv element, untouched."
+ (let ((file "/tmp/my file (draft).mp4"))
+ (should (equal (car (last (cj/--open-with-argv "vlc" file))) file))))
+
+(ert-deftest test-external-open--open-with-argv-boundary-file-with-metacharacters ()
+ "Boundary: shell metacharacters in the path arrive verbatim."
+ (let ((file "/tmp/a;b&c$(d)'e.mp4"))
+ (should (equal (car (last (cj/--open-with-argv "vlc" file))) file))))
+
+;;; Error Cases
+
+(ert-deftest test-external-open--open-with-argv-error-empty-command ()
+ "Error: an empty command signals user-error."
+ (should-error (cj/--open-with-argv "" "/tmp/foo.mp4") :type 'user-error))
+
+(ert-deftest test-external-open--open-with-argv-error-whitespace-command ()
+ "Error: a whitespace-only command signals user-error."
+ (should-error (cj/--open-with-argv " " "/tmp/foo.mp4") :type 'user-error))
+
+(provide 'test-external-open--open-with-argv)
+;;; test-external-open--open-with-argv.el ends here
diff --git a/tests/test-external-open-commands.el b/tests/test-external-open-commands.el
index 3d8adc15..5cab1196 100644
--- a/tests/test-external-open-commands.el
+++ b/tests/test-external-open-commands.el
@@ -64,22 +64,41 @@
(should-error (cj/open-this-file-with "vlc") :type 'user-error)))
(ert-deftest test-external-open-open-this-file-with-spawns-detached-process ()
- "Normal: posix path invokes `call-process-shell-command' with nohup + bg."
- (let ((cmd nil))
+ "Normal: posix path launches an argv `call-process' with DESTINATION 0."
+ (let ((captured nil))
(with-temp-buffer
- (setq buffer-file-name "/tmp/foo.mp4")
+ (setq buffer-file-name "/tmp/my file.mp4")
(cl-letf (((symbol-function 'env-windows-p) (lambda () nil))
- ((symbol-function 'call-process-shell-command)
- (lambda (c _infile _buf &rest _)
- (setq cmd c))))
- (cj/open-this-file-with "vlc"))
+ ((symbol-function 'executable-find)
+ (lambda (&rest _) "/usr/bin/vlc"))
+ ((symbol-function 'call-process)
+ (lambda (&rest args) (setq captured args) 0)))
+ (cj/open-this-file-with "vlc --fs"))
(setq buffer-file-name nil))
- (should (string-match-p "^nohup vlc " cmd))
- (should (string-match-p "&$" cmd))
- (should (string-match-p ">/dev/null" cmd))))
+ (should (equal captured '("vlc" nil 0 nil "--fs" "/tmp/my file.mp4")))))
+
+(ert-deftest test-external-open-open-this-file-with-errors-missing-program ()
+ "Error: a program not on PATH signals user-error before launching."
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/foo.mp4")
+ (cl-letf (((symbol-function 'env-windows-p) (lambda () nil))
+ ((symbol-function 'executable-find) (lambda (&rest _) nil)))
+ (should-error (cj/open-this-file-with "no-such-program")
+ :type 'user-error))
+ (setq buffer-file-name nil)))
;;; cj/find-file-auto
+(ert-deftest test-external-open-video-looping-errors-missing-program ()
+ "Error: a missing video player gives a clear user-error, not an opaque crash.
+The command fires via the find-file advice, so visiting a video on a
+machine without mpv must fail with a message naming the program."
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'call-process)
+ (lambda (&rest _) (error "call-process should not run"))))
+ (should-error (cj/open-video-looping "/tmp/some-video.mp4")
+ :type 'user-error)))
+
(ert-deftest test-external-open-find-file-auto-routes-media-externally ()
"Normal: a non-video external extension (`.docx', in
`default-open-extensions') triggers `cj/xdg-open' instead of the original
diff --git a/tests/test-flycheck-config-ledger-hook.el b/tests/test-flycheck-config-ledger-hook.el
new file mode 100644
index 00000000..e9444b71
--- /dev/null
+++ b/tests/test-flycheck-config-ledger-hook.el
@@ -0,0 +1,31 @@
+;;; test-flycheck-config-ledger-hook.el --- flycheck reaches ledger buffers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `flycheck-ledger' registers a `ledger' checker, but a checker only runs where
+;; `flycheck-mode' is on. Until 2026-07-10 flycheck-config enabled the mode in
+;; `sh-mode' and `emacs-lisp-mode' only, and no `global-flycheck-mode' existed, so
+;; an unbalanced transaction in a ledger file produced no warning at all.
+;;
+;; These tests pin the hook, not the checker. Whether the `ledger' checker itself
+;; works is flycheck-ledger's problem; whether it ever gets a chance to run is ours.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'flycheck-config)
+
+(ert-deftest test-flycheck-config-enables-flycheck-in-ledger-buffers ()
+ "Normal: opening a ledger buffer turns `flycheck-mode' on.
+Without this, `flycheck-ledger' is loaded, its checker is registered, and
+nothing ever lints a financial file."
+ (should (memq #'flycheck-mode (default-value 'ledger-mode-hook))))
+
+(ert-deftest test-flycheck-config-keeps-its-existing-mode-hooks ()
+ "Boundary: adding ledger doesn't displace the modes flycheck already covered."
+ (should (memq #'flycheck-mode (default-value 'sh-mode-hook)))
+ (should (memq #'flycheck-mode (default-value 'emacs-lisp-mode-hook))))
+
+(provide 'test-flycheck-config-ledger-hook)
+;;; test-flycheck-config-ledger-hook.el ends here
diff --git a/tests/test-flyspell-and-abbrev.el b/tests/test-flyspell-and-abbrev.el
index ef8cc637..b4be6ab3 100644
--- a/tests/test-flyspell-and-abbrev.el
+++ b/tests/test-flyspell-and-abbrev.el
@@ -17,6 +17,7 @@
(require 'ert)
(require 'cl-lib)
+(require 'user-constants) ;; org-dir, read by the ispell :config below
(require 'flyspell)
(require 'flyspell-and-abbrev)
@@ -27,6 +28,23 @@
(overlay-put o 'face 'flyspell-incorrect)
o))
+;; ------------------------- org src-block skip entry ---------------------------
+
+(ert-deftest test-flyspell-ispell-skip-entry-matches-src-block-lines ()
+ "Normal: the ispell skip entry matches real org src-block delimiters.
+The old entry used \"#+\" (one-or-more #), which matches no real
+begin_src line, so ispell spell-checked inside every org code block."
+ (require 'ispell)
+ (let ((entry (seq-find (lambda (e)
+ (and (consp e) (stringp (car e))
+ (string-match-p "BEGIN_SRC" (car e))))
+ ispell-skip-region-alist)))
+ (should entry)
+ (let ((case-fold-search t))
+ (should (string-match-p (car entry) "#+BEGIN_SRC emacs-lisp"))
+ (should (string-match-p (car entry) "#+begin_src python"))
+ (should (string-match-p (cdr entry) "#+end_src")))))
+
;; ------------------------ cj/--require-spell-checker -------------------------
(ert-deftest test-flyspell-require-spell-checker-present ()
@@ -97,5 +115,27 @@
(cj/flyspell-on-for-buffer-type)))
(should mode-called)))
+;; --------------------------- cj/flyspell-then-abbrev -------------------------
+
+(ert-deftest test-flyspell-then-abbrev-enables-mode-not-bare-rescan ()
+ "Regression: cj/flyspell-then-abbrev routes the initial scan through
+cj/flyspell-on-for-buffer-type, which enables flyspell-mode so it sticks.
+The bare flyspell-buffer it replaced never turned the mode on, so the guard
+never tripped and every C-' press re-scanned the whole buffer (O(buffer) per
+keypress in large files)."
+ (let (on-called scan-called)
+ (cl-letf (((symbol-function 'cj/--require-spell-checker) #'ignore)
+ ((symbol-function 'cj/flyspell-on-for-buffer-type)
+ (lambda () (setq on-called t)))
+ ((symbol-function 'flyspell-buffer)
+ (lambda (&rest _) (setq scan-called t)))
+ ((symbol-function 'cj/flyspell-goto-previous-misspelling)
+ (lambda (&rest _) nil)))
+ (with-temp-buffer
+ (text-mode)
+ (cj/flyspell-then-abbrev nil)))
+ (should on-called)
+ (should-not scan-called)))
+
(provide 'test-flyspell-and-abbrev)
;;; test-flyspell-and-abbrev.el ends here
diff --git a/tests/test-font-config--frame-lifecycle.el b/tests/test-font-config--frame-lifecycle.el
deleted file mode 100644
index 8f338b99..00000000
--- a/tests/test-font-config--frame-lifecycle.el
+++ /dev/null
@@ -1,75 +0,0 @@
-;;; test-font-config--frame-lifecycle.el --- Tests for the lifted font frame helpers -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; cj/apply-font-settings-to-frame, cj/cleanup-frame-list, and
-;; cj/maybe-install-nerd-icons-fonts were defined inside use-package
-;; :config / with-eval-after-load (unreachable under `make test'). Lifting
-;; them to top level makes their branching unit-testable; env-gui-p and the
-;; package side-effect calls are mocked at the boundary.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'font-config)
-
-(defvar cj/fontaine-configured-frames)
-
-(ert-deftest test-font-cleanup-frame-list-removes-frame ()
- "Normal: cleanup drops the given frame from the configured list."
- (let ((cj/fontaine-configured-frames '(fr1 fr2 fr3)))
- (cj/cleanup-frame-list 'fr2)
- (should (equal cj/fontaine-configured-frames '(fr1 fr3)))))
-
-(ert-deftest test-font-apply-gui-unconfigured-sets-preset ()
- "Normal: a GUI frame not yet configured gets the preset and is tracked."
- (let ((cj/fontaine-configured-frames nil)
- (called nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
- ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
- (cj/apply-font-settings-to-frame (selected-frame)))
- (should called)
- (should (member (selected-frame) cj/fontaine-configured-frames))))
-
-(ert-deftest test-font-apply-already-configured-is-noop ()
- "Boundary: an already-configured frame is not re-preset."
- (let ((cj/fontaine-configured-frames (list (selected-frame)))
- (called nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
- ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
- (cj/apply-font-settings-to-frame (selected-frame)))
- (should-not called)))
-
-(ert-deftest test-font-apply-non-gui-is-noop ()
- "Boundary: without a GUI nothing is applied or tracked."
- (let ((cj/fontaine-configured-frames nil)
- (called nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () nil))
- ((symbol-function 'fontaine-set-preset) (lambda (_p) (setq called t))))
- (cj/apply-font-settings-to-frame (selected-frame)))
- (should-not called)
- (should-not (member (selected-frame) cj/fontaine-configured-frames))))
-
-(ert-deftest test-font-maybe-install-icons-gui-missing-installs ()
- "Normal: GUI present and font missing triggers the install."
- (let ((installed nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
- ((symbol-function 'cj/font-installed-p) (lambda (_n) nil))
- ((symbol-function 'nerd-icons-install-fonts) (lambda (&rest _) (setq installed t)))
- ((symbol-function 'remove-hook) #'ignore))
- (cj/maybe-install-nerd-icons-fonts))
- (should installed)))
-
-(ert-deftest test-font-maybe-install-icons-already-present-skips ()
- "Boundary: an installed font means no install attempt."
- (let ((installed nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
- ((symbol-function 'cj/font-installed-p) (lambda (_n) t))
- ((symbol-function 'nerd-icons-install-fonts) (lambda (&rest _) (setq installed t))))
- (cj/maybe-install-nerd-icons-fonts))
- (should-not installed)))
-
-(provide 'test-font-config--frame-lifecycle)
-;;; test-font-config--frame-lifecycle.el ends here
diff --git a/tests/test-font-config.el b/tests/test-font-config.el
index 393a7758..06ea226b 100644
--- a/tests/test-font-config.el
+++ b/tests/test-font-config.el
@@ -4,11 +4,10 @@
;; font-config.el is mostly top-level font/package setup. These smoke tests
;; cover the logic that should stay correct regardless of which fonts are
-;; installed: the install check, and the daemon-frame font applier (env-gui-p
-;; guard plus idempotency). The module :demand's fontaine and references
-;; nerd-icons, so the tests skip when those packages are absent rather than
-;; failing on a bare checkout. GUI and font lookups are stubbed so the run
-;; stays headless.
+;; installed: the install check, task-oriented Fontaine picker, persistence,
+;; and emoji setup. The module :demand's fontaine and references nerd-icons, so
+;; the tests skip when those packages are absent rather than failing on a bare
+;; checkout. GUI and font lookups are stubbed so the run stays headless.
;;; Code:
@@ -41,34 +40,32 @@
(cl-letf (((symbol-function 'find-font) (lambda (&rest _) nil)))
(should (null (cj/font-installed-p "No Such Font 12345")))))
-;;; cj/apply-font-settings-to-frame
+;;; cj/maybe-install-nerd-icons-fonts
-(ert-deftest test-font-config-apply-font-settings-noop-without-gui ()
- "Boundary: on a non-GUI frame the applier does nothing and does not error."
+(ert-deftest test-font-config-nerd-icons-missing-font-installs-on-gui ()
+ "Normal: a missing Nerd Icons font is installed on a GUI frame."
(skip-unless test-font-config--available)
(require 'font-config)
- (let ((cj/fontaine-configured-frames nil)
- (applied nil))
- (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) nil))
- ((symbol-function 'fontaine-set-preset)
- (lambda (&rest _) (setq applied t))))
- (cj/apply-font-settings-to-frame (selected-frame))
- (should-not applied)
- (should-not cj/fontaine-configured-frames))))
-
-(ert-deftest test-font-config-apply-font-settings-applies-once-per-frame ()
- "Normal: on a GUI frame the applier sets the preset once and is idempotent."
- (skip-unless test-font-config--available)
- (require 'font-config)
- (let ((cj/fontaine-configured-frames nil)
- (calls 0))
- (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) t))
- ((symbol-function 'fontaine-set-preset)
- (lambda (&rest _) (setq calls (1+ calls)))))
- (cj/apply-font-settings-to-frame (selected-frame))
- (cj/apply-font-settings-to-frame (selected-frame))
- (should (= calls 1))
- (should (memq (selected-frame) cj/fontaine-configured-frames)))))
+ (let ((installed nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
+ ((symbol-function 'cj/font-installed-p) (lambda (_name) nil))
+ ((symbol-function 'nerd-icons-install-fonts)
+ (lambda (&rest _) (setq installed t)))
+ ((symbol-function 'remove-hook) #'ignore))
+ (cj/maybe-install-nerd-icons-fonts))
+ (should installed)))
+
+(ert-deftest test-font-config-nerd-icons-installed-font-skips-install ()
+ "Boundary: an installed Nerd Icons font needs no install attempt."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((installed nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda () t))
+ ((symbol-function 'cj/font-installed-p) (lambda (_name) t))
+ ((symbol-function 'nerd-icons-install-fonts)
+ (lambda (&rest _) (setq installed t))))
+ (cj/maybe-install-nerd-icons-fonts))
+ (should-not installed)))
;;; cj/setup-emoji-fontset
@@ -93,5 +90,248 @@
((symbol-function 'set-fontset-font) (lambda (&rest _) t)))
(should (progn (cj/setup-emoji-fontset) t))))
+;;; cj/set-emojify-display-style
+
+(defvar emojify-display-style)
+
+(ert-deftest test-font-config-emojify-display-style-image-on-gui ()
+ "Normal: on a GUI frame the emoji display style is `image'."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((emojify-display-style nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) t)))
+ (cj/set-emojify-display-style)
+ (should (eq emojify-display-style 'image)))))
+
+(ert-deftest test-font-config-emojify-display-style-unicode-without-gui ()
+ "Boundary: without a GUI the emoji display style is `unicode'."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((emojify-display-style nil))
+ (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) nil)))
+ (cj/set-emojify-display-style)
+ (should (eq emojify-display-style 'unicode)))))
+
+;;; cj/display-available-fonts
+
+(ert-deftest test-font-config-display-available-fonts-second-call-no-error ()
+ "Error: a second invocation does not signal on the read-only buffer."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (cl-letf (((symbol-function 'font-family-list)
+ (lambda (&rest _) '("Fixture Font A" "Fixture Font B"))))
+ (unwind-protect
+ (progn
+ (cj/display-available-fonts)
+ ;; The first call ends in `special-mode' (read-only); the second must
+ ;; not signal when it erases and rewrites the buffer.
+ (cj/display-available-fonts)
+ (with-current-buffer "*Available Fonts*"
+ (should (> (buffer-size) 0))
+ (should buffer-read-only)))
+ (when (get-buffer "*Available Fonts*")
+ (kill-buffer "*Available Fonts*")))))
+
+;;; Fontaine workflow profiles
+
+(ert-deftest test-font-config-fontaine-presets-are-task-oriented ()
+ "Normal: the picker exposes eight complete workflow destinations."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (should (equal (delq t (mapcar #'car fontaine-presets))
+ '(everyday writing reading coding-xs coding-m coding-l coding-xl
+ presentation))))
+
+(ert-deftest test-font-config-fontaine-candidates-describe-end-state ()
+ "Normal: every profile label names its purpose, fonts, and point size."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((candidates (cj/fontaine-profile-candidates)))
+ (should (= (length candidates) 8))
+ (should (member (nth 0 candidates)
+ '("Everyday — Berkeley Mono + Lexend · 13 pt"
+ "Everyday — Berkeley Mono + Lexend · 14 pt")))
+ (should (equal (nth 1 candidates)
+ "Writing — Berkeley Mono + Merriweather · 14 pt"))
+ (should (equal (nth 2 candidates)
+ "Reading — Merriweather · 14 pt"))
+ (should (equal (nth 3 candidates)
+ "Coding XS — Berkeley Mono · 11 pt"))
+ (should (equal (nth 4 candidates)
+ "Coding M — Berkeley Mono · 13 pt"))
+ (should (equal (nth 5 candidates)
+ "Coding L — Berkeley Mono · 14 pt"))
+ (should (equal (nth 6 candidates)
+ "Coding XL — Berkeley Mono · 16 pt"))
+ (should (equal (nth 7 candidates)
+ "Presentation — Berkeley Mono + Lexend · 20 pt"))))
+
+(ert-deftest test-font-config-fontaine-candidate-round-trips-to-profile ()
+ "Boundary: a displayed destination maps back to its Fontaine symbol."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (dolist (profile '(everyday writing reading coding-xs coding-m coding-l coding-xl
+ presentation))
+ (let ((label (cj/fontaine-profile-label profile)))
+ (should (eq (cj/fontaine-profile-from-label label) profile)))))
+
+(ert-deftest test-font-config-fontaine-uses-one-monospace-family ()
+ "Normal: every workflow profile uses Berkeley Mono for fixed pitch."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (dolist (profile '(everyday writing coding-xs coding-m coding-l coding-xl presentation))
+ (let ((properties (fontaine--get-preset-properties profile)))
+ (should (equal (plist-get properties :default-family)
+ "BerkeleyMono Nerd Font")))))
+
+(ert-deftest test-font-config-fontaine-reading-is-merriweather-only ()
+ "Normal: Reading uses Merriweather for every primary face family."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((properties (fontaine--get-preset-properties 'reading)))
+ (dolist (property '(:default-family
+ :fixed-pitch-family
+ :fixed-pitch-serif-family
+ :variable-pitch-family))
+ (should (equal (plist-get properties property) "Merriweather")))))
+
+(ert-deftest test-font-config-fontaine-reading-properties-are-public ()
+ "Normal: consumers can resolve Reading without Fontaine private functions."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((properties (cj/fontaine-profile-properties 'reading)))
+ (should (equal (plist-get properties :default-family) "Merriweather"))
+ (should (= (plist-get properties :default-height) 140))))
+
+(ert-deftest test-font-config-fontaine-remaps-reading-buffer-locally ()
+ "Normal: the local adapter applies all Reading families at an override height."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((calls nil))
+ (cl-letf (((symbol-function 'face-remap-add-relative)
+ (lambda (face &rest properties)
+ (push (cons face properties) calls)
+ face)))
+ (should (equal (cj/fontaine-remap-buffer-to-profile 'reading 180)
+ '(default fixed-pitch fixed-pitch-serif variable-pitch))))
+ (dolist (face '(default fixed-pitch fixed-pitch-serif variable-pitch))
+ (should (member (list face :family "Merriweather" :height 180)
+ calls)))))
+
+(ert-deftest test-font-config-fontaine-ui-buffer-remaps-default-to-berkeley ()
+ "Normal: minibuffer and echo buffers remap their default face to Berkeley."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((base nil))
+ (cl-letf (((symbol-function 'face-remap-set-base)
+ (lambda (face &rest specs) (setq base (cons face specs)))))
+ (cj/fontaine-remap-ui-buffer))
+ (should (equal base
+ '(default (:family "BerkeleyMono Nerd Font"))))))
+
+(ert-deftest test-font-config-fontaine-ui-chrome-stays-berkeley ()
+ "Normal: Fontaine reasserts Berkeley on chrome faces and echo buffers."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((faces nil)
+ (remap-count 0))
+ (cl-letf (((symbol-function 'facep) (lambda (_face) t))
+ ((symbol-function 'set-face-attribute)
+ (lambda (face _frame &rest properties)
+ (push (cons face properties) faces)))
+ ((symbol-function 'get-buffer) (lambda (_name) (current-buffer)))
+ ((symbol-function 'cj/fontaine-remap-ui-buffer)
+ (lambda () (setq remap-count (1+ remap-count)))))
+ (cj/fontaine-keep-ui-chrome-monospace))
+ (dolist (face '(mode-line mode-line-active mode-line-inactive
+ minibuffer-prompt))
+ (should (member (list face :family "BerkeleyMono Nerd Font") faces)))
+ (should (= remap-count 2))))
+
+(ert-deftest test-font-config-fontaine-ui-chrome-hooks-are-installed ()
+ "Boundary: profile, theme, and minibuffer changes restore UI typography."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (should (memq 'cj/fontaine-keep-ui-chrome-monospace
+ fontaine-set-preset-hook))
+ (should (memq 'cj/fontaine-keep-ui-chrome-monospace
+ enable-theme-functions))
+ (should (memq 'cj/fontaine-remap-ui-buffer minibuffer-setup-hook)))
+
+(ert-deftest test-font-config-fontaine-unknown-label-has-no-profile ()
+ "Error: an unknown destination label does not select a preset."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (should-not (cj/fontaine-profile-from-label "Missing profile")))
+
+(ert-deftest test-font-config-fontaine-annotation-marks-current-profile ()
+ "Normal: completion marks only the active workflow destination."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((fontaine-current-preset 'writing))
+ (should (equal (cj/fontaine-profile-annotation
+ (cj/fontaine-profile-label 'writing))
+ " current"))
+ (should (equal (cj/fontaine-profile-annotation
+ (cj/fontaine-profile-label 'coding-m))
+ ""))))
+
+(ert-deftest test-font-config-fontaine-selector-applies-picked-profile ()
+ "Normal: the one-prompt picker maps its complete label before applying."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((picked (cj/fontaine-profile-label 'presentation))
+ (applied nil))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_prompt _choices &rest _) picked))
+ ((symbol-function 'cj/fontaine-apply-profile)
+ (lambda (profile) (setq applied profile))))
+ (cj/fontaine-select-profile)
+ (should (eq applied 'presentation)))))
+
+(ert-deftest test-font-config-fontaine-restores-valid-profile ()
+ "Normal: startup restores a persisted workflow profile."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (cl-letf (((symbol-function 'fontaine-restore-latest-preset)
+ (lambda () 'writing)))
+ (should (eq (cj/fontaine-restored-or-default-profile) 'writing))))
+
+(ert-deftest test-font-config-fontaine-rejects-obsolete-restored-profile ()
+ "Boundary: an old brand or point-size preset falls back to everyday."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (cl-letf (((symbol-function 'fontaine-restore-latest-preset)
+ (lambda () '13-point-font)))
+ (should (eq (cj/fontaine-restored-or-default-profile) 'everyday))))
+
+(ert-deftest test-font-config-fontaine-has-no-per-frame-reset-hook ()
+ "Boundary: creating a daemon frame cannot overwrite the active profile."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (should-not (memq 'cj/apply-font-settings-to-frame
+ server-after-make-frame-hook))
+ (should-not (memq 'cj/cleanup-frame-list delete-frame-functions)))
+
+(ert-deftest test-font-config-fontaine-apply-records-profile-for-persistence ()
+ "Normal: applying a profile updates Fontaine history before setting it."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (let ((fontaine-preset-history nil)
+ (applied nil))
+ (cl-letf (((symbol-function 'fontaine-set-preset)
+ (lambda (profile) (setq applied profile))))
+ (cj/fontaine-apply-profile 'coding-m)
+ (should (eq applied 'coding-m))
+ (should (equal (car fontaine-preset-history) "coding-m")))))
+
+(ert-deftest test-font-config-fontaine-apply-rejects-unknown-profile ()
+ "Error: applying an unknown workflow profile signals a user error."
+ (skip-unless test-font-config--available)
+ (require 'font-config)
+ (cl-letf (((symbol-function 'fontaine-set-preset)
+ (lambda (_profile) (ert-fail "must not apply"))))
+ (should-error (cj/fontaine-apply-profile 'missing) :type 'user-error)))
+
(provide 'test-font-config)
;;; test-font-config.el ends here
diff --git a/tests/test-help-utils--arch-wiki-search.el b/tests/test-help-utils--arch-wiki-search.el
new file mode 100644
index 00000000..d02dd041
--- /dev/null
+++ b/tests/test-help-utils--arch-wiki-search.el
@@ -0,0 +1,124 @@
+;;; test-help-utils--arch-wiki-search.el --- Tests for the ArchWiki search guard -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--arch-wiki-topics and the cj/local-arch-wiki-search command.
+;;
+;; The bug: the command read "/usr/share/doc/arch-wiki/html/en" with
+;; `directory-files' before checking the directory existed. On a machine
+;; without arch-wiki-docs -- the exact state the command's own error text is
+;; written for -- that signaled file-missing on the first line, so the friendly
+;; "Is arch-wiki-docs installed?" message below it was unreachable. The user
+;; got a raw Lisp error naming a path instead of the install hint.
+;;
+;; Two things had to change to make this testable. The directory was
+;; hardcoded inside the command, so a test could only ever exercise whatever
+;; the developer's own machine happened to have installed; it is now
+;; `cj/arch-wiki-html-dir'. And the directory read is now the pure helper
+;; cj/--arch-wiki-topics, which takes a directory and returns an alist, so the
+;; interesting cases are driven with real temporary directories instead of
+;; mocking `directory-files'.
+;;
+;; Test organization:
+;; - Normal Cases: topics found and returned; the command opens the choice
+;; - Boundary Cases: empty dir, single topic, a name matching no topic
+;; - Error Cases: missing dir returns nil and reports the install hint
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'help-utils)
+
+(defmacro test-arch-wiki--with-topics (dir-var topics &rest body)
+ "Bind DIR-VAR to a temp dir holding TOPICS (a list of html basenames).
+The directory is removed after BODY."
+ (declare (indent 2))
+ `(let ((,dir-var (make-temp-file "arch-wiki-test" t)))
+ (unwind-protect
+ (progn
+ (dolist (name ,topics)
+ (write-region "" nil (expand-file-name (concat name ".html") ,dir-var)))
+ ,@body)
+ (delete-directory ,dir-var t))))
+
+;;; Normal Cases — the pure helper
+
+(ert-deftest test-help-utils-arch-wiki-topics-lists-html-basenames ()
+ "Normal: each .html file becomes a (basename . fullpath) pair."
+ (test-arch-wiki--with-topics dir '("Systemd" "Pacman")
+ (let ((topics (cj/--arch-wiki-topics dir)))
+ (should (equal '("Pacman" "Systemd") (sort (mapcar #'car topics) #'string<)))
+ (should (string-suffix-p "Systemd.html" (cdr (assoc "Systemd" topics)))))))
+
+(ert-deftest test-help-utils-arch-wiki-topics-ignores-non-html ()
+ "Normal: files without the .html extension are not topics."
+ (test-arch-wiki--with-topics dir '("Systemd")
+ (write-region "" nil (expand-file-name "README.txt" dir))
+ (should (equal '("Systemd") (mapcar #'car (cj/--arch-wiki-topics dir))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-help-utils-arch-wiki-topics-empty-dir-is-nil ()
+ "Boundary: an existing but empty directory yields no topics."
+ (test-arch-wiki--with-topics dir '()
+ (should (null (cj/--arch-wiki-topics dir)))))
+
+(ert-deftest test-help-utils-arch-wiki-topics-single-topic ()
+ "Boundary: one topic returns a one-element alist."
+ (test-arch-wiki--with-topics dir '("Systemd")
+ (should (= 1 (length (cj/--arch-wiki-topics dir))))))
+
+;;; Error Cases — the missing-install path
+
+(ert-deftest test-help-utils-arch-wiki-topics-missing-dir-returns-nil ()
+ "Error: an absent directory returns nil rather than signaling file-missing."
+ (let ((missing (expand-file-name "definitely-absent-arch-wiki"
+ temporary-file-directory)))
+ (should-not (file-directory-p missing))
+ (should (null (cj/--arch-wiki-topics missing)))))
+
+(ert-deftest test-help-utils-arch-wiki-search-missing-dir-reports-hint ()
+ "Error: the command reports the install hint and opens nothing."
+ (let ((cj/arch-wiki-html-dir (expand-file-name "definitely-absent-arch-wiki"
+ temporary-file-directory))
+ (said nil)
+ (opened nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq said (apply #'format fmt args)) nil))
+ ((symbol-function 'eww-browse-url)
+ (lambda (url &rest _) (setq opened url))))
+ ;; Must not signal: this is the case that used to raise file-missing.
+ (cj/local-arch-wiki-search))
+ (should-not opened)
+ (should (string-match-p "arch-wiki-docs" said))))
+
+;;; Normal Cases — the command
+
+(ert-deftest test-help-utils-arch-wiki-search-opens-chosen-topic ()
+ "Normal: the chosen topic is opened as a file URL in EWW."
+ (test-arch-wiki--with-topics dir '("Systemd")
+ (let ((cj/arch-wiki-html-dir dir)
+ (opened nil))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Systemd"))
+ ((symbol-function 'eww-browse-url)
+ (lambda (url &rest _) (setq opened url))))
+ (cj/local-arch-wiki-search))
+ (should (string-prefix-p "file://" opened))
+ (should (string-suffix-p "Systemd.html" opened))
+ ;; The opened path is the one in the temp dir, not a system copy.
+ (should (string-match-p (regexp-quote dir) opened)))))
+
+(ert-deftest test-help-utils-arch-wiki-search-unknown-topic-opens-nothing ()
+ "Boundary: a name matching no topic reports rather than opening."
+ (test-arch-wiki--with-topics dir '("Systemd")
+ (let ((cj/arch-wiki-html-dir dir)
+ (opened nil))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "NotATopic"))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'eww-browse-url)
+ (lambda (url &rest _) (setq opened url))))
+ (cj/local-arch-wiki-search))
+ (should-not opened))))
+
+(provide 'test-help-utils--arch-wiki-search)
+;;; test-help-utils--arch-wiki-search.el ends here
diff --git a/tests/test-host-environment--detect-system-timezone.el b/tests/test-host-environment--detect-system-timezone.el
index 209283d1..0d76c206 100644
--- a/tests/test-host-environment--detect-system-timezone.el
+++ b/tests/test-host-environment--detect-system-timezone.el
@@ -17,12 +17,23 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'host-environment)
-(ert-deftest test-host-environment-detect-tz-match-localtime-wins ()
- "Normal: when match-localtime-to-zoneinfo returns a value, that wins."
+(ert-deftest test-host-environment-detect-tz-env-wins-without-content-scan ()
+ "Normal: an explicit TZ wins and the exhaustive zoneinfo scan never runs.
+The scan reads hundreds of files; it used to run first on every call even
+when a cheap O(1) method would answer."
(cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
- (lambda () "America/Los_Angeles"))
+ (lambda () (error "content scan should not have run")))
((symbol-function 'getenv)
- (lambda (_ &rest _) (error "TZ should not have been consulted"))))
+ (lambda (name &rest _) (when (string= name "TZ") "America/Chicago"))))
+ (should (equal (cj/detect-system-timezone) "America/Chicago"))))
+
+(ert-deftest test-host-environment-detect-tz-content-scan-is-last-resort ()
+ "Boundary: with every cheap method empty, the content scan still answers."
+ (cl-letf (((symbol-function 'cj/match-localtime-to-zoneinfo)
+ (lambda () "America/Los_Angeles"))
+ ((symbol-function 'getenv) (lambda (&rest _) nil))
+ ((symbol-function 'file-exists-p) (lambda (&rest _) nil))
+ ((symbol-function 'file-symlink-p) (lambda (&rest _) nil)))
(should (equal (cj/detect-system-timezone) "America/Los_Angeles"))))
(ert-deftest test-host-environment-detect-tz-env-var-wins-when-match-nil ()
diff --git a/tests/test-httpd-config--defer.el b/tests/test-httpd-config--defer.el
new file mode 100644
index 00000000..1a1fbbed
--- /dev/null
+++ b/tests/test-httpd-config--defer.el
@@ -0,0 +1,34 @@
+;;; test-httpd-config--defer.el --- Tests for httpd-config lazy loading -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Pins httpd-config's load-time behavior: merely loading the module must
+;; not create the www directory (that belongs to the moment simple-httpd
+;; actually loads) and must not pull in simple-httpd itself —
+;; impatient-mode requires it on demand.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;;; Boundary Cases
+
+(ert-deftest test-httpd-config-load-creates-no-www-dir ()
+ "Boundary: loading httpd-config does not create www/ in user-emacs-directory."
+ (let* ((sandbox (make-temp-file "httpd-config-test-" t))
+ (user-emacs-directory (file-name-as-directory sandbox)))
+ (unwind-protect
+ (progn
+ (require 'httpd-config)
+ (should-not (file-directory-p
+ (expand-file-name "www" user-emacs-directory))))
+ (delete-directory sandbox t))))
+
+(ert-deftest test-httpd-config-load-does-not-load-simple-httpd ()
+ "Boundary: loading httpd-config leaves simple-httpd unloaded."
+ (require 'httpd-config)
+ (should-not (featurep 'simple-httpd)))
+
+(provide 'test-httpd-config--defer)
+;;; test-httpd-config--defer.el ends here
diff --git a/tests/test-hugo-config--keymap.el b/tests/test-hugo-config--keymap.el
new file mode 100644
index 00000000..0f8df257
--- /dev/null
+++ b/tests/test-hugo-config--keymap.el
@@ -0,0 +1,71 @@
+;;; test-hugo-config--keymap.el --- Tests for the Hugo prefix keymap -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Pins the eight Hugo commands reachable under the "C-; h" prefix.
+;;
+;; The module used to install these with eight raw `global-set-key' calls plus
+;; a hand-written which-key block, writing into the global map directly instead
+;; of going through `cj/register-prefix-map' the way its siblings
+;; (erc-config, custom-ordering, org-reveal-config) do. These tests were added
+;; alongside that conversion so the refactor is checkable: every key must still
+;; reach the same command afterward.
+;;
+;; Test organization:
+;; - Normal Cases: each of the eight keys resolves to its command
+;; - Boundary Cases: case-distinct pairs stay distinct; the map is a prefix map
+;; - Error Cases: an unbound key in the map resolves to nothing
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(provide 'ox-hugo)
+(require 'hugo-config)
+(require 'keybindings)
+
+(defconst test-hugo--expected-bindings
+ '(("n" . cj/hugo-new-post)
+ ("e" . cj/hugo-export-post)
+ ("o" . cj/hugo-open-blog-dir)
+ ("O" . cj/hugo-open-blog-dir-external)
+ ("d" . cj/hugo-open-draft)
+ ("D" . cj/hugo-toggle-draft)
+ ("p" . cj/hugo-preview)
+ ("P" . cj/hugo-publish))
+ "Every key the Hugo prefix map must carry, and the command it runs.")
+
+;;; Normal Cases
+
+(ert-deftest test-hugo-config-keymap-binds-every-command ()
+ "Normal: each Hugo key resolves to its command inside the prefix map."
+ (dolist (pair test-hugo--expected-bindings)
+ (should (eq (cdr pair) (keymap-lookup cj/hugo-keymap (car pair))))))
+
+(ert-deftest test-hugo-config-keymap-registered-under-custom-prefix ()
+ "Normal: the map is reachable at \"h\" within `cj/custom-keymap'."
+ (should (eq cj/hugo-keymap (keymap-lookup cj/custom-keymap "h"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-hugo-config-keymap-case-pairs-stay-distinct ()
+ "Boundary: the shifted variants run different commands than their lowercase
+counterparts, which a case-folding binding would silently collapse."
+ (should-not (eq (keymap-lookup cj/hugo-keymap "o")
+ (keymap-lookup cj/hugo-keymap "O")))
+ (should-not (eq (keymap-lookup cj/hugo-keymap "d")
+ (keymap-lookup cj/hugo-keymap "D")))
+ (should-not (eq (keymap-lookup cj/hugo-keymap "p")
+ (keymap-lookup cj/hugo-keymap "P"))))
+
+(ert-deftest test-hugo-config-keymap-is-a-keymap ()
+ "Boundary: the value registered as a prefix is an actual keymap."
+ (should (keymapp cj/hugo-keymap)))
+
+;;; Error Cases
+
+(ert-deftest test-hugo-config-keymap-unbound-key-is-nil ()
+ "Error: a key the map does not define resolves to nothing."
+ (should-not (keymap-lookup cj/hugo-keymap "z")))
+
+(provide 'test-hugo-config--keymap)
+;;; test-hugo-config--keymap.el ends here
diff --git a/tests/test-integration-calendar-sync-timezone.el b/tests/test-integration-calendar-sync-timezone.el
index 304d3233..a3f65146 100644
--- a/tests/test-integration-calendar-sync-timezone.el
+++ b/tests/test-integration-calendar-sync-timezone.el
@@ -187,12 +187,21 @@ Components integrated:
Validates:
- Org timestamp format is correct (<YYYY-MM-DD Day HH:MM-HH:MM>)
-- Hour in timestamp is the converted local hour"
- (let* ((source-time (list 2026 2 2 19 0))
+- Hour in timestamp is the converted local hour
+
+The date is generated relative to now because `calendar-sync--parse-ics'
+drops events outside `calendar-sync--get-date-range' (today minus
+`calendar-sync-past-months', plus `calendar-sync-future-months'). This test
+used a hardcoded 2026-02-02, which sat inside that window when it was written
+and fell out of it once three months had passed -- the event was filtered
+before rendering and the assertion failed against an empty org buffer. The
+sibling tests survived hardcoded dates only because they call
+`calendar-sync--parse-event' directly, which applies no range filter."
+ (let* ((source-time (test-calendar-sync-time-days-from-now 7 19 0))
(ics (test-integration-tz--make-ics-with-tzid-event
"Test Event" source-time "Europe/Lisbon"))
- (expected-local (test-calendar-sync-convert-tz-via-date
- 2026 2 2 19 0 "Europe/Lisbon"))
+ (expected-local (apply #'test-calendar-sync-convert-tz-via-date
+ (append source-time (list "Europe/Lisbon"))))
(expected-hour (nth 3 expected-local))
(org-output (calendar-sync--parse-ics ics)))
(should org-output)
diff --git a/tests/test-integration-org-agenda-frame-load-order.el b/tests/test-integration-org-agenda-frame-load-order.el
new file mode 100644
index 00000000..a8eeaa47
--- /dev/null
+++ b/tests/test-integration-org-agenda-frame-load-order.el
@@ -0,0 +1,89 @@
+;;; test-integration-org-agenda-frame-load-order.el --- Frame allowlist survives load order -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Regression test for a load-order bug in the Full Agenda frame's read-only
+;; shadow.
+;;
+;; Components integrated:
+;; - org-agenda-frame (real, loaded in a subprocess)
+;; - org-agenda (real, loaded BEFORE the frame module to reproduce the bug)
+;;
+;; The bug: cj/--agenda-frame-shadow-mutations walks org-agenda-mode-map and
+;; keeps a key only when the frame map already binds it to a `commandp' value.
+;; The view/redo handlers (day-view, week-view, safe-redo) were defined LOWER in
+;; the file than the `with-eval-after-load' that ran the walk. When org-agenda
+;; was already loaded at frame-load time -- the normal startup order and every
+;; reload -- the walk fired before those defuns existed, read them as not-yet
+;; commands, and denied d/w/g/r, the very keys the allowlist grants. Moving the
+;; walk to the end of the file (after the defuns) fixed it.
+;;
+;; This test can't reproduce the ordering in-process (the module is already
+;; loaded), so it drives a fresh Emacs that requires org-agenda first, then the
+;; frame module, and inspects the resulting keymap.
+;;
+;; Validates:
+;; - d/w/g/r keep their allowlist commands in the org-first load order
+;; - the controlled status/priority mutations survive the shadow walk
+;; - a non-allowlisted mutation key (t) is still denied
+;;
+;;; Code:
+
+(require 'ert)
+
+(defconst test-oaf--repo-root
+ (file-name-directory (directory-file-name
+ (file-name-directory (or load-file-name buffer-file-name))))
+ "Repo root, one level up from tests/.")
+
+(defun test-oaf--lookup-in-subprocess (keys)
+ "Load org-agenda then org-agenda-frame in a fresh Emacs, return KEYS' bindings.
+Returns an alist of (KEY . BINDING-SYMBOL-NAME-OR-nil)."
+ (let* ((root test-oaf--repo-root)
+ (form
+ (prin1-to-string
+ `(progn
+ (setq load-prefer-newer t)
+ (package-initialize)
+ (require 'org-agenda) ; the bad order: org first
+ (require 'org-agenda-frame)
+ (princ (prin1-to-string
+ (mapcar
+ (lambda (k)
+ (cons k (let ((b (lookup-key cj/agenda-frame-mode-map (kbd k))))
+ (and (symbolp b) (symbol-name b)))))
+ ',keys))))))
+ (out (with-output-to-string
+ (with-current-buffer standard-output
+ (call-process
+ (expand-file-name invocation-name invocation-directory)
+ nil t nil
+ "--batch" "--no-site-file" "--no-site-lisp"
+ "-L" root
+ "-L" (expand-file-name "modules" root)
+ "-L" (expand-file-name "themes" root)
+ "--eval" form)))))
+ (car (read-from-string out))))
+
+(ert-deftest test-integration-org-agenda-frame-allowlist-survives-org-first-load ()
+ "Integration: with org-agenda loaded before the frame module, the allowlisted
+view/redo and controlled task-mutation keys survive while t stays denied."
+ (skip-unless (file-exists-p (expand-file-name "modules/org-agenda-frame.el"
+ test-oaf--repo-root)))
+ (let ((got (test-oaf--lookup-in-subprocess
+ '("d" "w" "g" "r" "C-c t" "C-c C-t"
+ "M-<up>" "M-<right>" "s-<down>" "s-<left>" "t"))))
+ (should (equal "cj/--agenda-frame-day-view" (cdr (assoc "d" got))))
+ (should (equal "cj/--agenda-frame-week-view" (cdr (assoc "w" got))))
+ (should (equal "cj/--agenda-frame-safe-redo" (cdr (assoc "g" got))))
+ (should (equal "cj/--agenda-frame-safe-redo" (cdr (assoc "r" got))))
+ (should (equal "org-agenda-todo" (cdr (assoc "C-c t" got))))
+ (should (equal "org-agenda-todo" (cdr (assoc "C-c C-t" got))))
+ (should (equal "org-agenda-priority-up" (cdr (assoc "M-<up>" got))))
+ (should (equal "org-agenda-todo-nextset" (cdr (assoc "M-<right>" got))))
+ (should (equal "org-agenda-priority-down" (cdr (assoc "s-<down>" got))))
+ (should (equal "org-agenda-todo-previousset" (cdr (assoc "s-<left>" got))))
+ ;; t is a real org mutation key; it must be denied, not allowlisted.
+ (should (equal "cj/--agenda-frame-denied-readonly" (cdr (assoc "t" got))))))
+
+(provide 'test-integration-org-agenda-frame-load-order)
+;;; test-integration-org-agenda-frame-load-order.el ends here
diff --git a/tests/test-integration-recording-device-workflow.el b/tests/test-integration-recording-device-workflow.el
index 3ef631f3..27ffac56 100644
--- a/tests/test-integration-recording-device-workflow.el
+++ b/tests/test-integration-recording-device-workflow.el
@@ -1,26 +1,24 @@
;;; test-integration-recording-device-workflow.el --- Integration tests for recording device workflow -*- lexical-binding: t; -*-
;;; Commentary:
-;; Integration tests covering the complete device detection and grouping workflow.
-;;
-;; This tests the full pipeline from raw pactl output through parsing, grouping,
-;; and friendly name assignment. The workflow enables users to select audio devices
-;; for recording calls/meetings.
+;; Integration test covering the device detection path that recording actually
+;; uses: raw pactl output through parsing and into friendly state names.
;;
;; Components integrated:
;; - cj/recording--parse-pactl-output (parse raw pactl output into structured data)
-;; - cj/recording-parse-sources (shell command wrapper)
-;; - cj/recording-group-devices-by-hardware (group inputs/monitors by device)
+;; - cj/recording-parse-sources (shell command wrapper, MOCKED at
+;; shell-command-to-string so no pactl runs)
;; - cj/recording-friendly-state (convert technical state names)
-;; - Bluetooth MAC address normalization (colons → underscores)
-;; - Device name pattern matching (USB, PCI, Bluetooth)
-;; - Friendly name assignment (user-facing device names)
;;
;; Critical integration points:
-;; - Parse output must produce data that group-devices can process
-;; - Bluetooth MAC normalization must work across parse→group boundary
-;; - Incomplete devices (only mic OR only monitor) must be filtered
-;; - Friendly names must correctly identify device types
+;; - Parse output must carry device state through to the friendly-name conversion
+;;
+;; This file once covered a parse-to-group pipeline as well. That half tested
+;; cj/recording-group-devices-by-hardware, a second device-grouping
+;; implementation nothing ever called -- cj/recording-select-device is the live
+;; selection path and reaches parse-sources directly. The function and its
+;; tests were removed rather than left as coverage that proved an unused code
+;; path worked.
;;; Code:
@@ -46,58 +44,6 @@
;;; Normal Cases - Complete Workflow
-(ert-deftest test-integration-recording-device-workflow-parse-to-group-all-devices ()
- "Test complete workflow from pactl output to grouped devices.
-
-When pactl output contains all three device types (built-in, USB, Bluetooth),
-the workflow should parse, group, and assign friendly names to all devices.
-
-Components integrated:
-- cj/recording--parse-pactl-output (parsing)
-- cj/recording-group-devices-by-hardware (grouping + MAC normalization)
-- Device pattern matching (USB/PCI/Bluetooth detection)
-- Friendly name assignment
-
-Validates:
-- All three device types are detected
-- Bluetooth MAC addresses normalized (colons → underscores)
-- Each device has both mic and monitor
-- Friendly names correctly assigned
-- Complete data flow: raw output → parsed list → grouped pairs"
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- ;; Test parse step
- (let ((parsed (cj/recording-parse-sources)))
- (should (= 6 (length parsed)))
-
- ;; Test group step (receives parsed data)
- (let ((grouped (cj/recording-group-devices-by-hardware)))
- (should (= 3 (length grouped)))
-
- ;; Validate built-in device
- (let ((built-in (assoc "Built-in Audio" grouped)))
- (should built-in)
- (should (string-prefix-p "alsa_input.pci" (cadr built-in)))
- (should (string-prefix-p "alsa_output.pci" (cddr built-in))))
-
- ;; Validate USB device
- (let ((usb (assoc "Jabra SPEAK 510 USB" grouped)))
- (should usb)
- (should (string-match-p "Jabra" (cadr usb)))
- (should (string-match-p "Jabra" (cddr usb))))
-
- ;; Validate Bluetooth device (CRITICAL: MAC normalization)
- (let ((bluetooth (assoc "Bluetooth Headset" grouped)))
- (should bluetooth)
- ;; Input has colons
- (should (string-match-p "00:1B:66:C0:91:6D" (cadr bluetooth)))
- ;; Output has underscores
- (should (string-match-p "00_1B_66_C0_91_6D" (cddr bluetooth)))
- ;; But they're grouped together!
- (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth)))
- (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth)))))))))
-
(ert-deftest test-integration-recording-device-workflow-friendly-states-in-list ()
"Test that friendly state names appear in device list output.
@@ -128,105 +74,5 @@ Validates:
;;; Boundary Cases - Incomplete Devices
-(ert-deftest test-integration-recording-device-workflow-incomplete-devices-filtered ()
- "Test that devices with only mic OR only monitor are filtered out.
-
-For call recording, we need BOTH mic and monitor from the same device.
-Incomplete devices should not appear in the grouped output.
-
-Components integrated:
-- cj/recording-parse-sources (parsing all devices)
-- cj/recording-group-devices-by-hardware (filtering incomplete pairs)
-
-Validates:
-- Device with only mic is filtered
-- Device with only monitor is filtered
-- Only complete devices (both mic and monitor) are returned
-- Filtering happens at group stage, not parse stage"
- (let ((output (concat
- ;; Complete device
- "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
- "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
- ;; Incomplete: USB mic with no monitor
- "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n"
- ;; Incomplete: Bluetooth monitor with no mic
- "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- ;; Parse sees all 4 devices
- (let ((parsed (cj/recording-parse-sources)))
- (should (= 4 (length parsed)))
-
- ;; Group returns only 1 complete device
- (let ((grouped (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length grouped)))
- (should (equal "Built-in Audio" (caar grouped))))))))
-
-;;; Edge Cases - Bluetooth MAC Normalization
-
-(ert-deftest test-integration-recording-device-workflow-bluetooth-mac-variations ()
- "Test Bluetooth MAC normalization with different formats.
-
-Bluetooth devices use colons in input names but underscores in output names.
-The grouping must normalize these to match devices correctly.
-
-Components integrated:
-- cj/recording-parse-sources (preserves original MAC format)
-- cj/recording-group-devices-by-hardware (normalizes MAC for matching)
-- Base name extraction (regex patterns)
-- MAC address transformation (underscores → colons)
-
-Validates:
-- Input with colons (bluez_input.AA:BB:CC:DD:EE:FF) parsed correctly
-- Output with underscores (bluez_output.AA_BB_CC_DD_EE_FF) parsed correctly
-- Normalization happens during grouping
-- Devices paired despite format difference
-- Original device names preserved (not mutated)"
- (let ((output (concat
- "79\tbluez_input.11:22:33:44:55:66\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"
- "81\tbluez_output.11_22_33_44_55_66.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((parsed (cj/recording-parse-sources)))
- ;; Original formats preserved in parse
- (should (string-match-p "11:22:33" (caar parsed)))
- (should (string-match-p "11_22_33" (caadr parsed)))
-
- ;; But grouping matches them
- (let ((grouped (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length grouped)))
- (should (equal "Bluetooth Headset" (caar grouped)))
- ;; Original names preserved
- (should (equal "bluez_input.11:22:33:44:55:66" (cadar grouped)))
- (should (equal "bluez_output.11_22_33_44_55_66.1.monitor" (cddar grouped))))))))
-
-;;; Error Cases - Malformed Data
-
-(ert-deftest test-integration-recording-device-workflow-malformed-output-handled ()
- "Test that malformed pactl output is handled gracefully.
-
-When pactl output is malformed or unparseable, the workflow should not crash.
-It should return empty results at appropriate stages.
-
-Components integrated:
-- cj/recording--parse-pactl-output (malformed line handling)
-- cj/recording-group-devices-by-hardware (empty input handling)
-
-Validates:
-- Malformed lines are silently skipped during parse
-- Empty parse results don't crash grouping
-- Workflow degrades gracefully
-- No exceptions thrown"
- (let ((output (test-load-fixture "pactl-output-malformed.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((parsed (cj/recording-parse-sources)))
- ;; Malformed output produces empty parse
- (should (null parsed))
-
- ;; Empty parse produces empty grouping (no crash)
- (let ((grouped (cj/recording-group-devices-by-hardware)))
- (should (null grouped)))))))
-
(provide 'test-integration-recording-device-workflow)
;;; test-integration-recording-device-workflow.el ends here
diff --git a/tests/test-integration-recording-toggle-workflow.el b/tests/test-integration-recording-toggle-workflow.el
index e73ef87e..6258fc58 100644
--- a/tests/test-integration-recording-toggle-workflow.el
+++ b/tests/test-integration-recording-toggle-workflow.el
@@ -43,6 +43,24 @@
;;; Setup and Teardown
+(defun test-integration-toggle--fake-pactl (cmd &rest _)
+ "Answer pactl queries for CMD from fixture devices, never the real machine.
+
+`cj/recording-get-devices' runs `cj/recording--validate-system-audio', which
+shells out to pactl, decides a fixture device name is not a real source, and
+\"auto-fixes\" the configured device to the default sink's monitor. Left
+unmocked that clobbers the test's device with whatever hardware the developer
+has plugged in, so the assertions compare against a JDS Labs DAC rather than
+the fixture. Answering at the shell boundary keeps the real validation logic
+under test while the machine stays out of it."
+ (cond
+ ((string-match-p "get-default-sink" cmd) "fixture-sink\n")
+ ((string-match-p "list sources short" cmd)
+ "0\ttest-monitor\tmodule-x\ts16le 2ch 44100Hz\tIDLE\n1\tcached-monitor\tmodule-x\ts16le 2ch 44100Hz\tIDLE\n")
+ ((string-match-p "list sinks short" cmd)
+ "0\tfixture-sink\tmodule-x\ts16le 2ch 44100Hz\tRUNNING\n")
+ (t "")))
+
(defun test-integration-toggle-setup ()
"Reset all variables before each test."
(setq cj/video-recording-ffmpeg-process nil)
@@ -102,6 +120,8 @@ Validates:
(setq setup-called t)
(setq cj/recording-mic-device "test-mic")
(setq cj/recording-system-device "test-monitor")))
+ ((symbol-function 'shell-command-to-string)
+ #'test-integration-toggle--fake-pactl)
((symbol-function 'file-directory-p)
(lambda (_dir) t))
((symbol-function 'start-process-shell-command)
@@ -168,6 +188,8 @@ Validates:
(ffmpeg-cmd nil))
(cl-letf (((symbol-function 'cj/recording-quick-setup)
(lambda () (setq setup-called t)))
+ ((symbol-function 'shell-command-to-string)
+ #'test-integration-toggle--fake-pactl)
((symbol-function 'file-directory-p)
(lambda (_dir) t))
((symbol-function 'start-process-shell-command)
diff --git a/tests/test-integration-recurring-events.el b/tests/test-integration-recurring-events.el
index 3cae1a20..8339d167 100644
--- a/tests/test-integration-recurring-events.el
+++ b/tests/test-integration-recurring-events.el
@@ -34,53 +34,78 @@
;;; Test Data
-(defconst test-integration-recurring-events--weekly-ics
- "BEGIN:VCALENDAR
-VERSION:2.0
-PRODID:-//Test//Test//EN
-BEGIN:VEVENT
-DTSTART;TZID=America/Chicago:20251118T103000
-DTEND;TZID=America/Chicago:20251118T110000
-RRULE:FREQ=WEEKLY;BYDAY=SA
-SUMMARY:GTFO
-UID:test-weekly@example.com
-END:VEVENT
-END:VCALENDAR"
- "Test ICS with weekly recurring event (GTFO use case).")
+;; Fixtures that reach `calendar-sync--parse-ics' must carry dates relative to
+;; now. That entry point drops any event outside
+;; `calendar-sync--get-date-range' (today minus `calendar-sync-past-months',
+;; plus `calendar-sync-future-months'), so a hardcoded DTSTART works only until
+;; the rolling window moves past it. Three tests here rotted exactly that way:
+;; their November-2025 fixtures aged out of the window, the events were filtered
+;; before rendering, and the assertions failed against an empty org buffer. The
+;; weekly fixtures survived only because an unbounded RRULE keeps generating
+;; occurrences into the window no matter how old its DTSTART is.
+;;
+;; Fixtures given straight to `calendar-sync--parse-event' can stay static --
+;; that path applies no range filter.
-(defconst test-integration-recurring-events--daily-with-count-ics
- "BEGIN:VCALENDAR
+(defun test-integration-recurring-events--ics-stamp (offset-days hour minute)
+ "Return an ICS UTC datetime OFFSET-DAYS from today at HOUR:MINUTE."
+ (let ((d (test-calendar-sync-time-days-from-now offset-days hour minute)))
+ (format "%04d%02d%02dT%02d%02d00Z" (nth 0 d) (nth 1 d) (nth 2 d) hour minute)))
+
+(defun test-integration-recurring-events--daily-with-count-ics ()
+ "ICS with a COUNT=5 daily series starting inside the sync window."
+ (format "BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
-DTSTART:20251120T090000Z
-DTEND:20251120T100000Z
+DTSTART:%s
+DTEND:%s
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Daily Standup
UID:test-daily@example.com
END:VEVENT
END:VCALENDAR"
- "Test ICS with daily recurring event limited by COUNT.")
+ (test-integration-recurring-events--ics-stamp 2 9 0)
+ (test-integration-recurring-events--ics-stamp 2 10 0)))
-(defconst test-integration-recurring-events--mixed-ics
- "BEGIN:VCALENDAR
+(defun test-integration-recurring-events--mixed-ics ()
+ "ICS mixing a one-time event and a recurring one, both inside the window."
+ (format "BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
-DTSTART:20251125T140000Z
-DTEND:20251125T150000Z
+DTSTART:%s
+DTEND:%s
SUMMARY:One-time Meeting
UID:test-onetime@example.com
END:VEVENT
BEGIN:VEVENT
-DTSTART;TZID=America/Chicago:20251201T093000
-DTEND;TZID=America/Chicago:20251201T103000
+DTSTART;TZID=America/Chicago:%s
+DTEND;TZID=America/Chicago:%s
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
SUMMARY:Recurring Standup
UID:test-recurring@example.com
END:VEVENT
END:VCALENDAR"
- "Test ICS with mix of recurring and non-recurring events.")
+ (test-integration-recurring-events--ics-stamp 3 14 0)
+ (test-integration-recurring-events--ics-stamp 3 15 0)
+ ;; TZID form carries no Z suffix.
+ (string-remove-suffix "Z" (test-integration-recurring-events--ics-stamp 5 9 30))
+ (string-remove-suffix "Z" (test-integration-recurring-events--ics-stamp 5 10 30))))
+
+(defconst test-integration-recurring-events--weekly-ics
+ "BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+BEGIN:VEVENT
+DTSTART;TZID=America/Chicago:20251118T103000
+DTEND;TZID=America/Chicago:20251118T110000
+RRULE:FREQ=WEEKLY;BYDAY=SA
+SUMMARY:GTFO
+UID:test-weekly@example.com
+END:VEVENT
+END:VCALENDAR"
+ "Test ICS with weekly recurring event (GTFO use case).")
;;; Normal Cases - Complete Workflow
@@ -133,7 +158,7 @@ Validates:
- Exactly 5 occurrences created"
(test-integration-recurring-events-setup)
(unwind-protect
- (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--daily-with-count-ics)))
+ (let ((org-output (calendar-sync--parse-ics (test-integration-recurring-events--daily-with-count-ics))))
(should (stringp org-output))
;; Should generate exactly 5 Daily Standup entries
@@ -160,7 +185,7 @@ Validates:
- Events are sorted chronologically"
(test-integration-recurring-events-setup)
(unwind-protect
- (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--mixed-ics)))
+ (let ((org-output (calendar-sync--parse-ics (test-integration-recurring-events--mixed-ics))))
(should (stringp org-output))
;; Should have one-time meeting
@@ -197,12 +222,19 @@ Validates:
(test-integration-recurring-events-setup)
(unwind-protect
(let* ((org-output (calendar-sync--parse-ics test-integration-recurring-events--weekly-ics))
- (now (current-time))
- (three-months-ago (time-subtract now (* 90 24 3600)))
- (twelve-months-future (time-add now (* 365 24 3600))))
+ (range (calendar-sync--get-date-range))
+ (window-start (nth 0 range))
+ (window-end (nth 1 range)))
(should (stringp org-output))
- ;; Parse all dates from output
+ ;; Every emitted occurrence must fall within the pipeline's own window
+ ;; (-3/+12 calendar months at day granularity, from
+ ;; `calendar-sync--get-date-range'). Asserting against that boundary,
+ ;; rather than a hand-rolled now +/- fixed-day approximation, keeps the
+ ;; test robust on every date: a valid boundary occurrence stamped at
+ ;; midnight used to read as out-of-range against a now-90d bound taken
+ ;; at the current clock time, so the test failed or passed depending on
+ ;; the day it ran.
(with-temp-buffer
(insert org-output)
(goto-char (point-min))
@@ -212,9 +244,9 @@ Validates:
(month (string-to-number (match-string 2)))
(day (string-to-number (match-string 3)))
(event-time (encode-time 0 0 0 day month year)))
- ;; All dates should be within window
- (when (or (time-less-p event-time three-months-ago)
- (time-less-p twelve-months-future event-time))
+ ;; Within [window-start, window-end] inclusive.
+ (when (or (time-less-p event-time window-start)
+ (time-less-p window-end event-time))
(setq all-dates-in-range nil))))
(should all-dates-in-range))))
(test-integration-recurring-events-teardown)))
@@ -291,18 +323,21 @@ Validates:
- Valid events still processed"
(test-integration-recurring-events-setup)
(unwind-protect
- (let* ((incomplete-ics "BEGIN:VCALENDAR
+ (let* ((incomplete-ics (format "BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
-DTSTART:20251201T100000Z
+DTSTART:%s
RRULE:FREQ=DAILY;COUNT=2
END:VEVENT
BEGIN:VEVENT
SUMMARY:Valid Event
-DTSTART:20251201T110000Z
-DTEND:20251201T120000Z
+DTSTART:%s
+DTEND:%s
END:VEVENT
-END:VCALENDAR")
+END:VCALENDAR"
+ (test-integration-recurring-events--ics-stamp 4 10 0)
+ (test-integration-recurring-events--ics-stamp 4 11 0)
+ (test-integration-recurring-events--ics-stamp 4 12 0)))
(org-output (calendar-sync--parse-ics incomplete-ics)))
;; Should still generate output (for valid event)
(should (stringp org-output))
diff --git a/tests/test-jumper.el b/tests/test-jumper.el
index fa65d3f4..638f2aa2 100644
--- a/tests/test-jumper.el
+++ b/tests/test-jumper.el
@@ -348,5 +348,30 @@
(should (string-match-p "test line" formatted))))
(test-jumper-teardown))
+;;; Empty completing-read input (vertico-less UI can return "")
+
+(ert-deftest test-jumper-jump-empty-choice-signals-user-error ()
+ "Error: empty input at the jump prompt gives a user-error, not a crash.
+An unmatched choice makes (cdr (assoc ...)) nil, which used to flow into
+the index arithmetic and signal wrong-type-argument."
+ (let ((jumper--next-index 2))
+ (cl-letf (((symbol-function 'jumper--location-candidates)
+ (lambda () '(("[0] here" . 0) ("[1] there" . 1))))
+ ((symbol-function 'get-register) (lambda (_r) nil))
+ ((symbol-function 'completing-read) (lambda (&rest _) "")))
+ (should-error (jumper-jump-to-location) :type 'user-error))))
+
+(ert-deftest test-jumper-remove-empty-choice-cancels ()
+ "Boundary: empty input at the remove prompt cancels instead of crashing."
+ (let ((jumper--next-index 2)
+ removed)
+ (cl-letf (((symbol-function 'jumper--location-candidates)
+ (lambda () '(("[0] here" . 0) ("[1] there" . 1))))
+ ((symbol-function 'completing-read) (lambda (&rest _) ""))
+ ((symbol-function 'jumper--reorder-registers)
+ (lambda (_i) (setq removed t))))
+ (jumper-remove-location)
+ (should-not removed))))
+
(provide 'test-jumper)
;;; test-jumper.el ends here
diff --git a/tests/test-keyboard-compat-setup.el b/tests/test-keyboard-compat-setup.el
index 1c5cd434..a23e24e1 100644
--- a/tests/test-keyboard-compat-setup.el
+++ b/tests/test-keyboard-compat-setup.el
@@ -61,6 +61,14 @@ string can return a meta-prefix event count rather than nil.)"
(cj/keyboard-compat-terminal-setup)
(should (equal input-decode-map (make-sparse-keymap)))))
+(ert-deftest test-keyboard-compat-terminal-setup-on-tty-setup-hook ()
+ "Normal: terminal setup is registered on `tty-setup-hook', which runs for each
+new tty frame. `input-decode-map' is terminal-local, so `emacs-startup-hook'
+\(once, at daemon start, with no tty) leaves every later `emacsclient -t' frame
+without the arrow-key decodings. The GUI half already frame-scopes itself."
+ (should (memq 'cj/keyboard-compat-terminal-setup tty-setup-hook))
+ (should-not (memq 'cj/keyboard-compat-terminal-setup emacs-startup-hook)))
+
;; -------------------------- cj/keyboard-compat-gui-setup ---------------------
(defmacro test-kbc--gui (gui-p &rest body)
@@ -71,18 +79,20 @@ string can return a meta-prefix event count rather than nil.)"
,@body)))
(defconst test-kbc--meta-shift-letters
- '(?o ?m ?y ?f ?w ?e ?l ?r ?v ?h ?t ?z ?u ?d ?i ?c ?b ?k)
- "The 18 letters whose M-<UPPER> form is translated to M-S-<lower> in GUI mode.")
+ '(?o ?m ?y ?f ?w ?e ?l ?r ?v ?h ?t ?z ?u ?d ?i ?c ?b)
+ "The 17 letters whose M-<UPPER> form is translated to M-S-<lower> in GUI mode.")
(ert-deftest test-keyboard-compat-gui-setup-translates-spot-checks ()
- "Normal: in GUI mode, M-O -> M-S-o and M-K -> M-S-k (sampled)."
+ "Normal: in GUI mode, M-O -> M-S-o and M-B -> M-S-b (sampled).
+M-K is no longer translated: show-kill-ring, its only consumer, was retired."
(test-kbc--gui t
(cj/keyboard-compat-gui-setup)
(should (equal (lookup-key key-translation-map (kbd "M-O")) (kbd "M-S-o")))
- (should (equal (lookup-key key-translation-map (kbd "M-K")) (kbd "M-S-k")))
- (should (equal (lookup-key key-translation-map (kbd "M-D")) (kbd "M-S-d")))))
+ (should (equal (lookup-key key-translation-map (kbd "M-B")) (kbd "M-S-b")))
+ (should (equal (lookup-key key-translation-map (kbd "M-D")) (kbd "M-S-d")))
+ (should-not (lookup-key key-translation-map (kbd "M-K")))))
-(ert-deftest test-keyboard-compat-gui-setup-translates-all-eighteen ()
+(ert-deftest test-keyboard-compat-gui-setup-translates-all-seventeen ()
"Normal: every documented M-<UPPER> maps to its M-S-<lower> form."
(test-kbc--gui t
(cj/keyboard-compat-gui-setup)
diff --git a/tests/test-ledger-config.el b/tests/test-ledger-config.el
new file mode 100644
index 00000000..5224c184
--- /dev/null
+++ b/tests/test-ledger-config.el
@@ -0,0 +1,70 @@
+;;; test-ledger-config.el --- Characterization tests for ledger-config -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Captures the behavior ledger-config.el has today, before any guardrail work
+;; changes it. See docs/design/2026-07-10-ledger-config-audit.org for the audit
+;; these tests pin.
+;;
+;; The clean-on-save helpers are defined in the `use-package' `:preface', which
+;; use-package emits unconditionally, so they exist under `make test' even though
+;; ledger-mode itself never loads there (no `package-initialize' in the test run).
+;;
+;; `ledger-mode-clean-buffer' is stubbed: it is the boundary this config delegates
+;; to, and it rewrites the whole buffer. What these tests pin is whether our hook
+;; calls it, not what it does.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ledger-config)
+
+(ert-deftest test-ledger-config-clean-before-save-cleans-when-enabled ()
+ "Normal: with `cj/ledger-clean-on-save' set, the save hook cleans the buffer."
+ (let ((called 0)
+ (cj/ledger-clean-on-save t))
+ (cl-letf (((symbol-function 'ledger-mode-clean-buffer)
+ (lambda (&rest _) (setq called (1+ called)))))
+ (cj/ledger--clean-before-save))
+ (should (= 1 called))))
+
+(ert-deftest test-ledger-config-clean-before-save-skips-when-disabled ()
+ "Boundary: with `cj/ledger-clean-on-save' nil, the save hook does nothing."
+ (let ((called 0)
+ (cj/ledger-clean-on-save nil))
+ (cl-letf (((symbol-function 'ledger-mode-clean-buffer)
+ (lambda (&rest _) (setq called (1+ called)))))
+ (cj/ledger--clean-before-save))
+ (should (= 0 called))))
+
+(ert-deftest test-ledger-config-clean-before-save-demotes-errors ()
+ "Error: a failing clean does not signal, so the file still saves.
+This is the current contract. It also means a clean that fails partway
+leaves the buffer in whatever state it reached, because nothing rolls back."
+ (let ((cj/ledger-clean-on-save t)
+ (inhibit-message t))
+ (cl-letf (((symbol-function 'ledger-mode-clean-buffer)
+ (lambda (&rest _) (error "boom"))))
+ (should (progn (cj/ledger--clean-before-save) t)))))
+
+(ert-deftest test-ledger-config-enable-clean-on-save-is-buffer-local ()
+ "Normal: the hook installs buffer-locally, not globally."
+ (let ((global-before (default-value 'before-save-hook)))
+ (with-temp-buffer
+ (cj/ledger--enable-clean-on-save)
+ (should (memq #'cj/ledger--clean-before-save before-save-hook))
+ (should-not (memq #'cj/ledger--clean-before-save
+ (default-value 'before-save-hook))))
+ (should (equal global-before (default-value 'before-save-hook)))))
+
+(ert-deftest test-ledger-config-clean-on-save-defaults-on ()
+ "Normal: clean-on-save ships enabled.
+Pinned because the audit questions whether a whole-buffer sort belongs on
+every save of a financial file. If that default flips, this test should
+fail and be updated deliberately."
+ (should (eq t (default-value 'cj/ledger-clean-on-save))))
+
+(provide 'test-ledger-config)
+;;; test-ledger-config.el ends here
diff --git a/tests/test-local-repository--car-member.el b/tests/test-local-repository--car-member.el
deleted file mode 100644
index 30ae58c6..00000000
--- a/tests/test-local-repository--car-member.el
+++ /dev/null
@@ -1,58 +0,0 @@
-;;; test-local-repository--car-member.el --- Tests for localrepo--car-member -*- lexical-binding: t -*-
-
-;;; Commentary:
-;; Tests for `localrepo--car-member' in local-repository.el — the predicate
-;; localrepo-initialize uses to check whether an archive id is already
-;; registered in package-archives / package-archive-priorities.
-
-;;; Code:
-
-(require 'ert)
-(require 'local-repository)
-
-;;; Normal Cases
-
-(ert-deftest test-local-repository-localrepo--car-member-found ()
- "Normal: VALUE present as a car returns the matching tail (non-nil)."
- (should (equal (localrepo--car-member 'b '((a . 1) (b . 2) (c . 3)))
- '(b c))))
-
-(ert-deftest test-local-repository-localrepo--car-member-not-found ()
- "Normal: VALUE absent from every car returns nil."
- (should-not (localrepo--car-member 'z '((a . 1) (b . 2)))))
-
-(ert-deftest test-local-repository-localrepo--car-member-string-car ()
- "Normal: car comparison uses `equal', so string keys match by value."
- (should (localrepo--car-member "localrepo"
- '(("gnu" . "url1") ("localrepo" . "url2")))))
-
-;;; Boundary Cases
-
-(ert-deftest test-local-repository-localrepo--car-member-empty-list ()
- "Boundary: an empty list never matches."
- (should-not (localrepo--car-member 'a nil)))
-
-(ert-deftest test-local-repository-localrepo--car-member-single-match ()
- "Boundary: a single-element list whose car matches returns non-nil."
- (should (localrepo--car-member 'only '((only . 1)))))
-
-(ert-deftest test-local-repository-localrepo--car-member-single-no-match ()
- "Boundary: a single-element list whose car differs returns nil."
- (should-not (localrepo--car-member 'x '((only . 1)))))
-
-(ert-deftest test-local-repository-localrepo--car-member-nil-value-with-nil-car ()
- "Boundary: a nil VALUE matches a cons whose car is nil."
- (should (localrepo--car-member nil '((nil . 1) (a . 2)))))
-
-(ert-deftest test-local-repository-localrepo--car-member-nil-value-no-nil-car ()
- "Boundary: a nil VALUE with no nil car returns nil."
- (should-not (localrepo--car-member nil '((a . 1) (b . 2)))))
-
-;;; Error Cases
-
-(ert-deftest test-local-repository-localrepo--car-member-non-cons-element ()
- "Error: a non-cons element makes `car' signal wrong-type-argument."
- (should-error (localrepo--car-member 'x '(1 2)) :type 'wrong-type-argument))
-
-(provide 'test-local-repository--car-member)
-;;; test-local-repository--car-member.el ends here
diff --git a/tests/test-local-repository.el b/tests/test-local-repository.el
new file mode 100644
index 00000000..132f8dc4
--- /dev/null
+++ b/tests/test-local-repository.el
@@ -0,0 +1,32 @@
+;;; test-local-repository.el --- Tests for the local-repository update command -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/update-localrepo-repository' refreshes the checked-in local package
+;; archive at `localrepo-location' (owned by early-init.el) via elpa-mirror.
+;; The elpa-mirror call is mocked at the boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'local-repository)
+
+;; localrepo-location is a defconst in early-init.el, which `make test' never
+;; loads. Declare it special here so the `let' below binds it dynamically and
+;; the module reads that value.
+(defvar localrepo-location nil)
+
+(ert-deftest test-local-repository-update-targets-early-init-location ()
+ "Normal: the mirror update targets `localrepo-location', the single archive
+path early-init.el owns, not a divergent module-local copy."
+ (let ((localrepo-location "/tmp/test-localrepo/")
+ (captured nil))
+ (cl-letf (((symbol-function 'elpamr-create-mirror-for-installed)
+ (lambda (dir &rest _) (setq captured dir))))
+ (cj/update-localrepo-repository)
+ (should (equal captured "/tmp/test-localrepo/")))))
+
+(provide 'test-local-repository)
+;;; test-local-repository.el ends here
diff --git a/tests/test-lorem-optimum.el b/tests/test-lorem-optimum.el
index f928c972..b7d97a8e 100644
--- a/tests/test-lorem-optimum.el
+++ b/tests/test-lorem-optimum.el
@@ -253,5 +253,26 @@ an empty string, not an error."
(let ((cj/lipsum-chain (cj/markov-chain-create)))
(should (equal "" (cj/lipsum-title)))))
+;;; cj/lipsum entry point
+
+(ert-deftest test-lipsum-returns-string-with-populated-chain ()
+ "Normal: cj/lipsum returns a non-empty string when the chain is trained."
+ (let ((cj/lipsum-chain
+ (test-learn "Lorem ipsum dolor sit amet consectetur adipiscing elit")))
+ (let ((result (cj/lipsum 5)))
+ (should (stringp result))
+ (should (> (length result) 0)))))
+
+(ert-deftest test-lipsum-empty-chain-signals-user-error ()
+ "Error: cj/lipsum on an empty chain signals a user-error naming the fix,
+rather than returning nil and letting cj/lipsum-insert do (insert nil),
+which raises a cryptic wrong-type error far from the cause."
+ (let ((cj/lipsum-chain (cj/markov-chain-create)))
+ (should-error (cj/lipsum 5) :type 'user-error)))
+
+(ert-deftest test-lipsum-is-interactive-command ()
+ "Normal: cj/lipsum is a command, as its Commentary (M-x cj/lipsum) advertises."
+ (should (commandp 'cj/lipsum)))
+
(provide 'test-lorem-optimum)
;;; test-lorem-optimum.el ends here
diff --git a/tests/test-mail-config--account-search-queries.el b/tests/test-mail-config--account-search-queries.el
index 9f1b6b3e..02a74692 100644
--- a/tests/test-mail-config--account-search-queries.el
+++ b/tests/test-mail-config--account-search-queries.el
@@ -38,9 +38,12 @@
(ert-deftest test-mail-make-account-map-closures-capture-distinct-queries ()
"Normal: each binding runs its own account-scoped search (no closure leak).
-mu4e-search is mocked to capture the query each command passes."
+mu4e-search is mocked to capture the query each command passes; require is
+mocked so the commands' mu4e load never pulls the real package in batch."
(let ((searched '()))
- (cl-letf (((symbol-function 'mu4e-search)
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &rest _) feature))
+ ((symbol-function 'mu4e-search)
(lambda (q) (push q searched))))
(let ((map (cj/--mail-make-account-map "dmail")))
(funcall (keymap-lookup map "i"))
@@ -49,5 +52,20 @@ mu4e-search is mocked to capture the query each command passes."
(should (member "maildir:/dmail/INBOX AND flag:unread AND NOT flag:trashed"
searched))))
+(ert-deftest test-mail-make-account-map-loads-mu4e-before-search ()
+ "Error: a nav command loads mu4e before it calls `mu4e-search'.
+The C-; e maps register eagerly at startup, but `mu4e-search' carries no
+autoload cookie, so a nav key pressed before mu4e's first launch signaled
+void-function. The command must require mu4e first, then search."
+ (let ((events '()))
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &rest _) (push (list :require feature) events) feature))
+ ((symbol-function 'mu4e-search)
+ (lambda (q) (push (list :search q) events))))
+ (funcall (keymap-lookup (cj/--mail-make-account-map "cmail") "i")))
+ (should (equal (nreverse events)
+ '((:require mu4e)
+ (:search "maildir:/cmail/INBOX"))))))
+
(provide 'test-mail-config--account-search-queries)
;;; test-mail-config--account-search-queries.el ends here
diff --git a/tests/test-mail-config-transport.el b/tests/test-mail-config-transport.el
index 0240102a..9329ac70 100644
--- a/tests/test-mail-config-transport.el
+++ b/tests/test-mail-config-transport.el
@@ -66,6 +66,23 @@ EXECUTABLES is an alist of program name strings to executable paths."
(should (equal test-mail-config--warnings
'((mail-config . "msmtp not found; SMTP mail sending unavailable")))))))
+(ert-deftest test-mail-config-transport-msmtp-missing-sets-descriptive-fallback ()
+ "Error: with msmtp absent, the send functions get a descriptive fallback.
+The old behavior left `message-send-mail-function' nil (the top-level defvar
+pre-empts message.el's default), so the first send died with \"invalid
+function: nil\". The fallback must be installed on both send variables and
+must signal a `user-error' that names msmtp."
+ (test-mail-config--with-executables nil
+ (let (send-mail-function message-send-mail-function)
+ (cj/mail-configure-smtpmail)
+ (should (eq send-mail-function #'cj/mail--send-mail-unavailable))
+ (should (eq message-send-mail-function #'cj/mail--send-mail-unavailable))
+ (should-error (cj/mail--send-mail-unavailable) :type 'user-error)
+ (condition-case err
+ (cj/mail--send-mail-unavailable)
+ (user-error
+ (should (string-match-p "msmtp" (cadr err))))))))
+
(ert-deftest test-mail-config-transport-mbsync-present-builds-command ()
"When mbsync exists, build the mu4e sync command."
(test-mail-config--with-executables '(("mbsync" . "/usr/bin/mbsync"))
diff --git a/tests/test-media-utils--argv.el b/tests/test-media-utils--argv.el
new file mode 100644
index 00000000..81317b60
--- /dev/null
+++ b/tests/test-media-utils--argv.el
@@ -0,0 +1,72 @@
+;;; test-media-utils--argv.el --- Tests for media-utils argv builders -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the pure helpers behind cj/media-play-it's shell-free
+;; launch: cj/media--yt-dlp-argv (stream-URL resolution command),
+;; cj/media--stream-urls (yt-dlp -g output parsing), and
+;; cj/media--play-argv (player launch argv). Argv lists are the
+;; hardening: URLs and args never meet a shell.
+;;
+;; Test organization:
+;; - Normal Cases: formats present/absent, args split, multi-line output
+;; - Boundary Cases: metacharacter URLs verbatim, empty output, nil args
+;; - Error Cases: none (builders are total; error paths live in the caller)
+;;
+;;; Code:
+
+(require 'ert)
+(require 'media-utils)
+
+;;; cj/media--yt-dlp-argv
+
+(ert-deftest test-media-utils--yt-dlp-argv-normal-with-formats ()
+ "Normal: formats join with / behind -f, URL last."
+ (should (equal (cj/media--yt-dlp-argv "https://example.com/v" '("22" "18" "best"))
+ '("yt-dlp" "-f" "22/18/best" "-g" "https://example.com/v"))))
+
+(ert-deftest test-media-utils--yt-dlp-argv-normal-without-formats ()
+ "Normal: nil formats drops the -f pair."
+ (should (equal (cj/media--yt-dlp-argv "https://example.com/v" nil)
+ '("yt-dlp" "-g" "https://example.com/v"))))
+
+(ert-deftest test-media-utils--yt-dlp-argv-boundary-metacharacter-url ()
+ "Boundary: a URL with shell metacharacters stays one verbatim element."
+ (let ((url "https://example.com/v?a=1&b=$(x);c='d'"))
+ (should (equal (car (last (cj/media--yt-dlp-argv url nil))) url))))
+
+;;; cj/media--stream-urls
+
+(ert-deftest test-media-utils--stream-urls-normal-two-lines ()
+ "Normal: each non-empty output line is one stream URL."
+ (should (equal (cj/media--stream-urls "https://a/video\nhttps://a/audio\n")
+ '("https://a/video" "https://a/audio"))))
+
+(ert-deftest test-media-utils--stream-urls-boundary-blank-and-crlf ()
+ "Boundary: blank lines and CR line endings are stripped."
+ (should (equal (cj/media--stream-urls "https://a/v\r\n\n \nhttps://a/u\r\n")
+ '("https://a/v" "https://a/u"))))
+
+(ert-deftest test-media-utils--stream-urls-boundary-empty-output ()
+ "Boundary: empty output yields nil."
+ (should-not (cj/media--stream-urls "")))
+
+;;; cj/media--play-argv
+
+(ert-deftest test-media-utils--play-argv-normal-args-split ()
+ "Normal: the player's raw args string splits into argv words."
+ (should (equal (cj/media--play-argv "vlc" "--no-video --intf dummy"
+ '("https://a/v"))
+ '("vlc" "--no-video" "--intf" "dummy" "https://a/v"))))
+
+(ert-deftest test-media-utils--play-argv-boundary-nil-args ()
+ "Boundary: nil args yields program + URLs only."
+ (should (equal (cj/media--play-argv "mpv" nil '("https://a/v"))
+ '("mpv" "https://a/v"))))
+
+(ert-deftest test-media-utils--play-argv-boundary-multiple-urls ()
+ "Boundary: every resolved stream URL is appended in order."
+ (should (equal (cj/media--play-argv "mpv" nil '("https://a/v" "https://a/u"))
+ '("mpv" "https://a/v" "https://a/u"))))
+
+(provide 'test-media-utils--argv)
+;;; test-media-utils--argv.el ends here
diff --git a/tests/test-media-utils--yt-dl-message.el b/tests/test-media-utils--yt-dl-message.el
new file mode 100644
index 00000000..491b64cf
--- /dev/null
+++ b/tests/test-media-utils--yt-dl-message.el
@@ -0,0 +1,64 @@
+;;; test-media-utils--yt-dl-message.el --- Tests for the yt-dl sentinel message -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/media--yt-dl-message, the pure helper behind
+;; cj/yt-dl-it's process sentinel.
+;;
+;; The behavior under test is a correctness fix, not cosmetics. cj/yt-dl-it
+;; launches "tsp yt-dlp ...", and tsp enqueues the job and exits immediately.
+;; The sentinel therefore fires on tsp's exit, not on yt-dlp's, so the old
+;; "Finished downloading" text claimed a completed download at the moment the
+;; download was merely queued -- and a yt-dlp failure minutes later was silent.
+;; The helper reports queueing, which is the only thing tsp's exit actually
+;; proves.
+;;
+;; Test organization:
+;; - Normal Cases: clean tsp exit reports queued; abnormal exit reports failure
+;; - Boundary Cases: unrelated events return nil; URL text passes through verbatim
+;; - Error Cases: empty event string returns nil
+;;
+;;; Code:
+
+(require 'ert)
+(require 'media-utils)
+
+;;; Normal Cases
+
+(ert-deftest test-media-utils--yt-dl-message-normal-finished-says-queued ()
+ "Normal: a clean tsp exit reports the job queued, never downloaded."
+ (let ((msg (cj/media--yt-dl-message "finished\n" "https://example.com/v")))
+ (should (string-match-p "[Qq]ueued" msg))
+ (should-not (string-match-p "[Ff]inished downloading" msg))))
+
+(ert-deftest test-media-utils--yt-dl-message-normal-abnormal-reports-failure ()
+ "Normal: an abnormal tsp exit reports that queueing failed."
+ (let ((msg (cj/media--yt-dl-message "exited abnormally with code 1\n"
+ "https://example.com/v")))
+ (should msg)
+ (should-not (string-match-p "[Qq]ueued for" msg))))
+
+;;; Boundary Cases
+
+(ert-deftest test-media-utils--yt-dl-message-boundary-unrelated-event-is-nil ()
+ "Boundary: an event that reports neither outcome produces no message."
+ (should (null (cj/media--yt-dl-message "run\n" "https://example.com/v")))
+ (should (null (cj/media--yt-dl-message "stopped\n" "https://example.com/v"))))
+
+(ert-deftest test-media-utils--yt-dl-message-boundary-url-passes-through ()
+ "Boundary: the URL text is carried into the message verbatim."
+ (let ((url "https://example.com/watch?v=a&b=c%20d"))
+ (should (string-match-p (regexp-quote url)
+ (cj/media--yt-dl-message "finished\n" url)))))
+
+(ert-deftest test-media-utils--yt-dl-message-boundary-empty-url ()
+ "Boundary: an empty URL still yields a message rather than signaling."
+ (should (stringp (cj/media--yt-dl-message "finished\n" ""))))
+
+;;; Error Cases
+
+(ert-deftest test-media-utils--yt-dl-message-error-empty-event-is-nil ()
+ "Error: an empty event string matches no outcome and returns nil."
+ (should (null (cj/media--yt-dl-message "" "https://example.com/v"))))
+
+(provide 'test-media-utils--yt-dl-message)
+;;; test-media-utils--yt-dl-message.el ends here
diff --git a/tests/test-media-utils.el b/tests/test-media-utils.el
index 841b6faf..23b36eeb 100644
--- a/tests/test-media-utils.el
+++ b/tests/test-media-utils.el
@@ -38,34 +38,41 @@
;; ----------------------------- cj/media-play-it ------------------------------
(ert-deftest test-media-play-it-direct-playback-command ()
- "Normal: a player that needs no stream URL gets a plain command, no yt-dlp."
+ "Normal: a player that needs no stream URL launches an argv process, no yt-dlp."
(let (captured cj/default-media-player)
(setq cj/default-media-player 'mpv)
(cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/mpv"))
- ((symbol-function 'start-process-shell-command)
- (lambda (_n _b cmd) (setq captured cmd) 'proc))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (setq captured args) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
((symbol-function 'message) #'ignore)
((symbol-function 'cj/log-silently) #'ignore))
(cj/media-play-it "https://example.com/v"))
- (should (string-match-p "mpv" captured))
- (should (string-match-p "example\\.com" captured))
- (should-not (string-match-p "yt-dlp" captured))))
-
-(ert-deftest test-media-play-it-stream-url-wraps-yt-dlp ()
- "Normal: a player needing a stream URL wraps the URL in a yt-dlp -g call."
- (let (captured cj/default-media-player)
+ ;; (NAME BUFFER PROGRAM . ARGS) -- program + args are the argv.
+ (should (equal (nthcdr 2 captured) '("mpv" "https://example.com/v")))
+ (should-not (member "yt-dlp" captured))))
+
+(ert-deftest test-media-play-it-stream-url-resolves-via-yt-dlp ()
+ "Normal: a stream-URL player resolves through a yt-dlp -g capture, then
+launches the player with the resolved URL as argv -- no shell either step."
+ (let (yt-argv captured cj/default-media-player)
(setq cj/default-media-player 'vlc)
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/vlc"))
- ((symbol-function 'start-process-shell-command)
- (lambda (_n _b cmd) (setq captured cmd) 'proc))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process)
+ (lambda (program _infile _dest _display &rest args)
+ (setq yt-argv (cons program args))
+ (insert "https://stream.example.com/resolved\n")
+ 0))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (setq captured args) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
((symbol-function 'message) #'ignore)
((symbol-function 'cj/log-silently) #'ignore))
(cj/media-play-it "https://example.com/v"))
- (should (string-match-p "yt-dlp" captured))
- (should (string-match-p "-g" captured))
- (should (string-match-p "-f 22/18/best" captured))))
+ (should (equal yt-argv
+ '("yt-dlp" "-f" "22/18/best" "-g" "https://example.com/v")))
+ (should (equal (nthcdr 2 captured)
+ '("vlc" "https://stream.example.com/resolved")))))
(ert-deftest test-media-play-it-missing-player-errors ()
"Error: an unavailable player command signals an error before launching."
@@ -74,6 +81,73 @@
(cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-error (cj/media-play-it "https://example.com/v")))))
+(ert-deftest test-media-play-it-missing-yt-dlp-errors ()
+ "Error: a stream-URL player with no yt-dlp on PATH aborts before resolving."
+ (let (cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (cmd &rest _) (and (equal cmd "vlc") "/usr/bin/vlc"))))
+ (should-error (cj/media-play-it "https://example.com/v")))))
+
+(ert-deftest test-media-play-it-yt-dlp-failure-errors ()
+ "Error: a non-zero yt-dlp exit surfaces as an error, player never launches."
+ (let (launched cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process)
+ (lambda (&rest _) (insert "ERROR: no video\n") 1))
+ ((symbol-function 'start-process)
+ (lambda (&rest _) (setq launched t) 'proc))
+ ((symbol-function 'message) #'ignore))
+ (should-error (cj/media-play-it "https://example.com/v")))
+ (should-not launched)))
+
+(ert-deftest test-media-play-it-yt-dlp-empty-output-errors ()
+ "Error: a zero-exit yt-dlp with no output still errors, player never launches."
+ (let (launched cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process) (lambda (&rest _) 0))
+ ((symbol-function 'start-process)
+ (lambda (&rest _) (setq launched t) 'proc))
+ ((symbol-function 'message) #'ignore))
+ (should-error (cj/media-play-it "https://example.com/v")))
+ (should-not launched)))
+
+;; -------------------------- cj/media--play-sentinel --------------------------
+
+(ert-deftest test-media-utils--play-sentinel-normal-finished-kills-buffer ()
+ "Normal: a finished event reports success and reaps the process buffer."
+ (let ((buf (generate-new-buffer " *sentinel-test*"))
+ (said nil))
+ (cl-letf (((symbol-function 'process-buffer) (lambda (_p) buf))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq said (apply #'format fmt args)))))
+ (funcall (cj/media--play-sentinel "https://a/v") 'proc "finished\n"))
+ (should (string-match-p "Finished" said))
+ (should-not (buffer-live-p buf))))
+
+(ert-deftest test-media-utils--play-sentinel-normal-abnormal-exit-kills-buffer ()
+ "Normal: an abnormal exit reports failure and reaps the process buffer."
+ (let ((buf (generate-new-buffer " *sentinel-test*"))
+ (said nil))
+ (cl-letf (((symbol-function 'process-buffer) (lambda (_p) buf))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq said (apply #'format fmt args)))))
+ (funcall (cj/media--play-sentinel "https://a/v") 'proc "exited abnormally with code 2\n"))
+ (should (string-match-p "failed" said))
+ (should-not (buffer-live-p buf))))
+
+(ert-deftest test-media-utils--play-sentinel-boundary-other-event-keeps-buffer ()
+ "Boundary: a non-terminal event (e.g. stop) leaves the buffer alone."
+ (let ((buf (generate-new-buffer " *sentinel-test*")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'process-buffer) (lambda (_p) buf))
+ ((symbol-function 'message) #'ignore))
+ (funcall (cj/media--play-sentinel "https://a/v") 'proc "stopped\n")
+ (should (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
;; ------------------------------- cj/yt-dl-it ---------------------------------
(ert-deftest test-media-yt-dl-it-errors-without-yt-dlp ()
diff --git a/tests/test-mu4e-attachments.el b/tests/test-mu4e-attachments.el
index 0a780977..986c2746 100644
--- a/tests/test-mu4e-attachments.el
+++ b/tests/test-mu4e-attachments.el
@@ -75,6 +75,56 @@ so this fails the same way whether or not mu4e's MIME support is loadable."
(should-error (cj/mu4e--save-attachment-part part "/downloads")
:type 'user-error)))
+(ert-deftest test-mu4e-attachments-save-part-errors-on-stale-handle ()
+ "Error: a handle whose MIME buffer was killed fails with a clear error.
+The selection buffer captures handles when it opens; a real MIME handle's
+car is the buffer holding the part's bytes, and viewing another message
+kills it. Saving through it must signal a `user-error' naming the file,
+not die in `mm-save-part-to-file' (or save another message's bytes)."
+ (let* ((dead (generate-new-buffer "stale-mime-part"))
+ (part (test-mu4e-attachments--part "invoice.pdf" 3 (list dead))))
+ (kill-buffer dead)
+ (should-error (cj/mu4e--save-attachment-part part "/downloads")
+ :type 'user-error)
+ (condition-case err
+ (cj/mu4e--save-attachment-part part "/downloads")
+ (user-error (should (string-match-p "invoice\\.pdf" (cadr err)))))))
+
+(ert-deftest test-mu4e-attachments-save-part-live-buffer-handle-saves ()
+ "Normal: a handle whose MIME buffer is alive saves normally."
+ (let* ((live (generate-new-buffer "live-mime-part"))
+ (part (test-mu4e-attachments--part "invoice.pdf" 3 (list live)))
+ (mu4e-uniquify-save-file-name-function #'identity)
+ saved)
+ (unwind-protect
+ (cl-letf (((symbol-function 'mu4e-join-paths)
+ (lambda (&rest pieces) (mapconcat #'identity pieces "/")))
+ ((symbol-function 'mm-save-part-to-file)
+ (lambda (_handle path) (setq saved path))))
+ (should (equal (cj/mu4e--save-attachment-part part "/downloads")
+ "/downloads/invoice.pdf"))
+ (should (equal saved "/downloads/invoice.pdf")))
+ (kill-buffer live))))
+
+(ert-deftest test-mu4e-attachments-save-parts-mid-batch-failure-propagates ()
+ "Error: a mid-batch save failure propagates; earlier parts stay saved.
+Characterizes the batch path: no silent skip of the failing part, and the
+files already written are not rolled back."
+ (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1)
+ (test-mu4e-attachments--part "b.pdf" 2)
+ (test-mu4e-attachments--part "c.pdf" 3)))
+ (saved '()))
+ (cl-letf (((symbol-function 'cj/mu4e--save-attachment-part)
+ (lambda (part _dir)
+ (let ((name (plist-get part :filename)))
+ (when (equal name "b.pdf")
+ (user-error "Stale handle: %s" name))
+ (push name saved)
+ name))))
+ (should-error (cj/mu4e--save-attachment-parts parts "/downloads")
+ :type 'user-error))
+ (should (equal (nreverse saved) '("a.pdf")))))
+
(ert-deftest test-mu4e-attachments-save-all-prompts-once ()
"Normal: the save-all command prompts for a directory once and saves all parts."
(let ((parts (list (test-mu4e-attachments--part "a.pdf" 1)
diff --git a/tests/test-music-config--add-dired-selection.el b/tests/test-music-config--add-dired-selection.el
new file mode 100644
index 00000000..9380409c
--- /dev/null
+++ b/tests/test-music-config--add-dired-selection.el
@@ -0,0 +1,64 @@
+;;; test-music-config--add-dired-selection.el --- Tests for dired add command -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music-add-dired-selection.
+;;
+;; Test organization:
+;; - Normal Cases: marked files (no region) are all queued
+;; - Boundary Cases: no marks falls back to the file at point
+;; - Error Cases: outside dired signals a user-error
+;;
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; Stub missing dependencies before loading music-config
+(defvar-keymap cj/custom-keymap
+ :doc "Stub keymap for testing")
+
+;; Add EMMS elpa directory to load path for batch testing
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir
+ (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(defun test-add-dired--run (marked)
+ "Run the command with MARKED as dired's marked-file answer; return added files."
+ (let (added)
+ (cl-letf (((symbol-function 'derived-mode-p) (lambda (&rest _) t))
+ ((symbol-function 'cj/music--ensure-playlist-buffer) (lambda () nil))
+ ((symbol-function 'dired-get-marked-files)
+ (lambda (&rest _) marked))
+ ((symbol-function 'file-directory-p) (lambda (_f) nil))
+ ((symbol-function 'cj/music--valid-file-p) (lambda (_f) t))
+ ((symbol-function 'emms-add-file) (lambda (f) (push f added)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (cj/music-add-dired-selection)
+ (nreverse added))))
+
+(ert-deftest test-music-add-dired-selection-queues-all-marked-files ()
+ "Normal: files marked with m (no region) are all queued, not just point.
+The old gate ran dired-get-marked-files only under use-region-p, so marks
+without a region fell to the single-file branch and silently dropped all
+but the point file."
+ (should (equal (test-add-dired--run '("/tmp/a.mp3" "/tmp/b.mp3" "/tmp/c.mp3"))
+ '("/tmp/a.mp3" "/tmp/b.mp3" "/tmp/c.mp3"))))
+
+(ert-deftest test-music-add-dired-selection-point-file-when-no-marks ()
+ "Boundary: with no marks, dired-get-marked-files returns the point file."
+ (should (equal (test-add-dired--run '("/tmp/only.mp3"))
+ '("/tmp/only.mp3"))))
+
+(ert-deftest test-music-add-dired-selection-errors-outside-dired ()
+ "Error: outside a dired buffer the command signals a user-error."
+ (cl-letf (((symbol-function 'derived-mode-p) (lambda (&rest _) nil)))
+ (should-error (cj/music-add-dired-selection) :type 'user-error)))
+
+(provide 'test-music-config--add-dired-selection)
+;;; test-music-config--add-dired-selection.el ends here
diff --git a/tests/test-music-config--after-playlist-clear.el b/tests/test-music-config--after-playlist-clear.el
index c23e2b5b..42dcf0e3 100644
--- a/tests/test-music-config--after-playlist-clear.el
+++ b/tests/test-music-config--after-playlist-clear.el
@@ -112,5 +112,30 @@
(progn (cj/music--after-playlist-clear) nil)
(error err)))))
+(ert-deftest test-music-header-toggle-advice-is-named-and-installed ()
+ "Normal: the header-refresh toggle advice is a named, removable function.
+An anonymous lambda can't be advice-removed and stacks a copy on every
+:config reload, firing the header refresh N times per toggle."
+ (should (fboundp 'cj/music--refresh-header-after-toggle))
+ (dolist (fn '(emms-toggle-repeat-playlist
+ emms-toggle-repeat-track
+ emms-toggle-random-playlist
+ cj/music-toggle-consume))
+ (should (advice-member-p #'cj/music--refresh-header-after-toggle fn))))
+
+(ert-deftest test-music-header-toggle-advice-does-not-stack ()
+ "Boundary: re-running the install (a :config reload) keeps one advice copy."
+ (dolist (fn '(emms-toggle-repeat-playlist emms-toggle-repeat-track))
+ (advice-remove fn #'cj/music--refresh-header-after-toggle)
+ (advice-add fn :after #'cj/music--refresh-header-after-toggle)
+ (advice-remove fn #'cj/music--refresh-header-after-toggle)
+ (advice-add fn :after #'cj/music--refresh-header-after-toggle)
+ (let ((count 0))
+ (advice-mapc (lambda (f _props)
+ (when (eq f 'cj/music--refresh-header-after-toggle)
+ (setq count (1+ count))))
+ fn)
+ (should (= count 1)))))
+
(provide 'test-music-config--after-playlist-clear)
;;; test-music-config--after-playlist-clear.el ends here
diff --git a/tests/test-music-config--art-cache-key.el b/tests/test-music-config--art-cache-key.el
new file mode 100644
index 00000000..bc869808
--- /dev/null
+++ b/tests/test-music-config--art-cache-key.el
@@ -0,0 +1,69 @@
+;;; test-music-config--art-cache-key.el --- Tests for cover-art cache key -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--cache-key': the stable cache-file basename for
+;; a track. A url with a known #RADIOBROWSERUUID keys on the uuid (so the same
+;; station shares one cached logo); any other url keys on a hash of its address;
+;; a file keys on a hash of its path.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--art-cache-key-normal-uuid ()
+ "Normal: a url with a UUID in the entries keys on the UUID."
+ (let ((track (emms-track 'url "https://ck.somafm.com/gs"))
+ (entries '(("https://ck.somafm.com/gs" :name "GS" :uuid "uuid-42" :favicon nil))))
+ (should (string= (cj/music-art--cache-key track entries) "uuid-42"))))
+
+(ert-deftest test-music-config--art-cache-key-boundary-url-no-uuid ()
+ "Boundary: a url with no UUID keys on a stable url- hash, not the raw URL."
+ (let* ((url "https://ck2.example.net/live")
+ (track (emms-track 'url url))
+ (key (cj/music-art--cache-key track nil)))
+ (should (string-prefix-p "url-" key))
+ (should (string= key (concat "url-" (sha1 url))))))
+
+(ert-deftest test-music-config--art-cache-key-normal-file ()
+ "Normal: a file keys on a file- hash of its path."
+ (let* ((path "/music/Kind of Blue/01.flac")
+ (track (emms-track 'file path)))
+ (should (string= (cj/music-art--cache-key track nil)
+ (concat "file-" (sha1 path))))))
+
+(ert-deftest test-music-config--art-cache-key-boundary-empty-uuid-falls-to-hash ()
+ "Boundary: an empty-string UUID is treated as absent, so the url hashes."
+ (let* ((url "https://ck3.example.net/x")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid "" :favicon nil))))
+ (should (string= (cj/music-art--cache-key track entries)
+ (concat "url-" (sha1 url))))))
+
+(ert-deftest test-music-config--art-cache-key-normal-track-property ()
+ "Normal: a queued lookup station carries its uuid as a track property and
+needs no entries at all."
+ (let ((track (emms-track 'url "https://ck4.example.net/live")))
+ (emms-track-set track 'radio-uuid "prop-uuid")
+ (should (string= (cj/music-art--cache-key track nil) "prop-uuid"))))
+
+(ert-deftest test-music-config--art-cache-key-normal-property-beats-entries ()
+ "Normal: the track property wins over a conflicting entries uuid."
+ (let* ((url "https://ck5.example.net/live")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid "entries-uuid" :favicon nil))))
+ (emms-track-set track 'radio-uuid "prop-uuid")
+ (should (string= (cj/music-art--cache-key track entries) "prop-uuid"))))
+
+(provide 'test-music-config--art-cache-key)
+;;; test-music-config--art-cache-key.el ends here
diff --git a/tests/test-music-config--art-favicon-url.el b/tests/test-music-config--art-favicon-url.el
new file mode 100644
index 00000000..9e7b92f8
--- /dev/null
+++ b/tests/test-music-config--art-favicon-url.el
@@ -0,0 +1,69 @@
+;;; test-music-config--art-favicon-url.el --- Tests for stream favicon URL -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--favicon-url': the direct favicon image URL for
+;; a url track, taken from the #RADIOBROWSERFAVICON captured at station creation.
+;; A station with only a UUID resolves its favicon via a separate byuuid lookup
+;; (done in the impure orchestrator), so this pure helper returns nil there.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--art-favicon-url-normal-captured ()
+ "Normal: a captured #RADIOBROWSERFAVICON is returned directly."
+ (let ((track (emms-track 'url "https://fav.somafm.com/gs"))
+ (entries '(("https://fav.somafm.com/gs"
+ :name "GS" :uuid "u1" :favicon "https://cdn.example/gs.png"))))
+ (should (string= (cj/music-art--favicon-url track entries)
+ "https://cdn.example/gs.png"))))
+
+(ert-deftest test-music-config--art-favicon-url-boundary-uuid-only ()
+ "Boundary: a station with a UUID but no captured favicon returns nil
+\(the byuuid lookup is the orchestrator's job)."
+ (let ((track (emms-track 'url "https://fav2.example.net/live"))
+ (entries '(("https://fav2.example.net/live"
+ :name "X" :uuid "u2" :favicon nil))))
+ (should (null (cj/music-art--favicon-url track entries)))))
+
+(ert-deftest test-music-config--art-favicon-url-boundary-empty-favicon ()
+ "Boundary: an empty-string favicon is treated as absent."
+ (let ((track (emms-track 'url "https://fav3.example.net/live"))
+ (entries '(("https://fav3.example.net/live" :name "X" :uuid "u3" :favicon ""))))
+ (should (null (cj/music-art--favicon-url track entries)))))
+
+(ert-deftest test-music-config--art-favicon-url-error-file-track ()
+ "Error: a file track has no stream favicon URL."
+ (let ((track (emms-track 'file "/music/x.flac")))
+ (should (null (cj/music-art--favicon-url track nil)))))
+
+(ert-deftest test-music-config--art-favicon-url-normal-track-property ()
+ "Normal: a queued lookup station carries its favicon as a track property."
+ (let ((track (emms-track 'url "https://fp.example.net/live")))
+ (emms-track-set track 'radio-favicon "https://fp.example.net/icon.png")
+ (should (string= (cj/music-art--favicon-url track nil)
+ "https://fp.example.net/icon.png"))))
+
+(ert-deftest test-music-config--art-favicon-url-normal-property-beats-entries ()
+ "Normal: the track property wins over a conflicting entries favicon."
+ (let* ((url "https://fp2.example.net/live")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid nil
+ :favicon "https://entries.example/e.png"))))
+ (emms-track-set track 'radio-favicon "https://prop.example/p.png")
+ (should (string= (cj/music-art--favicon-url track entries)
+ "https://prop.example/p.png"))))
+
+(provide 'test-music-config--art-favicon-url)
+;;; test-music-config--art-favicon-url.el ends here
diff --git a/tests/test-music-config--art-valid-image.el b/tests/test-music-config--art-valid-image.el
new file mode 100644
index 00000000..c8de0eda
--- /dev/null
+++ b/tests/test-music-config--art-valid-image.el
@@ -0,0 +1,51 @@
+;;; test-music-config--art-valid-image.el --- Tests for fetched-image validation -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music-art--valid-image-p': recognize whether fetched bytes
+;; are actually a displayable image, so an empty body, an HTML error page served
+;; 200, or a text response is rejected before it lands in the cache. Detection
+;; is by image header (`image-type-from-data'), which works headless.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(defconst test-art--png-1x1
+ (base64-decode-string
+ (concat "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
+ "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="))
+ "A minimal valid 1x1 PNG, as raw bytes.")
+
+(ert-deftest test-music-config--art-valid-image-normal-png ()
+ "Normal: real PNG bytes are recognized as a valid image."
+ (should (cj/music-art--valid-image-p test-art--png-1x1)))
+
+(ert-deftest test-music-config--art-valid-image-error-html ()
+ "Error: an HTML error page served 200 is not a valid image."
+ (should-not (cj/music-art--valid-image-p "<html><body>502 Bad Gateway</body></html>")))
+
+(ert-deftest test-music-config--art-valid-image-error-text ()
+ "Error: arbitrary text is not a valid image."
+ (should-not (cj/music-art--valid-image-p "this is not an image")))
+
+(ert-deftest test-music-config--art-valid-image-boundary-empty ()
+ "Boundary: an empty body is not a valid image."
+ (should-not (cj/music-art--valid-image-p "")))
+
+(ert-deftest test-music-config--art-valid-image-boundary-nil ()
+ "Boundary: nil is not a valid image."
+ (should-not (cj/music-art--valid-image-p nil)))
+
+(provide 'test-music-config--art-valid-image)
+;;; test-music-config--art-valid-image.el ends here
diff --git a/tests/test-music-config--bar-fill.el b/tests/test-music-config--bar-fill.el
new file mode 100644
index 00000000..f6d8dac8
--- /dev/null
+++ b/tests/test-music-config--bar-fill.el
@@ -0,0 +1,63 @@
+;;; test-music-config--bar-fill.el --- Tests for progress-bar fill math -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--bar-fill': the pure computation of how many cells
+;; of a WIDTH-cell progress bar are filled given ELAPSED and TOTAL seconds.
+;; A stream (no duration) is indeterminate; the live elapsed source (mpv) is
+;; wired in a later phase, so this helper only does the clamp-and-round math.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+;;; Normal
+
+(ert-deftest test-music-config--bar-fill-normal-half ()
+ "Normal: halfway through fills half the cells."
+ (should (= (cj/music--bar-fill 30 60 20) 10)))
+
+(ert-deftest test-music-config--bar-fill-normal-quarter ()
+ "Normal: a quarter elapsed rounds to a quarter of the cells."
+ (should (= (cj/music--bar-fill 15 60 20) 5)))
+
+;;; Boundary
+
+(ert-deftest test-music-config--bar-fill-boundary-zero-elapsed ()
+ "Boundary: nothing elapsed fills no cells."
+ (should (= (cj/music--bar-fill 0 60 20) 0)))
+
+(ert-deftest test-music-config--bar-fill-boundary-full ()
+ "Boundary: elapsed equal to total fills every cell."
+ (should (= (cj/music--bar-fill 60 60 20) 20)))
+
+(ert-deftest test-music-config--bar-fill-boundary-over-clamps ()
+ "Boundary: elapsed past total clamps to full, never overflows."
+ (should (= (cj/music--bar-fill 90 60 20) 20)))
+
+(ert-deftest test-music-config--bar-fill-boundary-nil-elapsed ()
+ "Boundary: an unknown elapsed (nil) fills no cells."
+ (should (= (cj/music--bar-fill nil 60 20) 0)))
+
+;;; Error / indeterminate
+
+(ert-deftest test-music-config--bar-fill-indeterminate-nil-total ()
+ "Error: a stream (nil total) is indeterminate, not a cell count."
+ (should (eq (cj/music--bar-fill 30 nil 20) 'indeterminate)))
+
+(ert-deftest test-music-config--bar-fill-indeterminate-zero-total ()
+ "Error: a zero total is indeterminate rather than a divide-by-zero."
+ (should (eq (cj/music--bar-fill 30 0 20) 'indeterminate)))
+
+(provide 'test-music-config--bar-fill)
+;;; test-music-config--bar-fill.el ends here
diff --git a/tests/test-music-config--bar-string.el b/tests/test-music-config--bar-string.el
new file mode 100644
index 00000000..bb6ac96f
--- /dev/null
+++ b/tests/test-music-config--bar-string.el
@@ -0,0 +1,50 @@
+;;; test-music-config--bar-string.el --- Tests for the block progress bar -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--bar-string': render a WIDTH-cell block bar from a
+;; filled-cell count (from `cj/music--bar-fill'), or an "on air" marker when the
+;; fill is `indeterminate' (a live stream with no duration). Face-carrying
+;; text; the tests assert the rendered length and content, not the faces.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--bar-string-normal-half ()
+ "Normal: 10 of 20 cells filled renders a 20-cell bar."
+ (let ((s (substring-no-properties (cj/music--bar-string 10 20))))
+ (should (= (length s) 20))
+ (should (= (cl-count ?█ s) 10))
+ (should (= (cl-count ?░ s) 10))))
+
+(ert-deftest test-music-config--bar-string-boundary-empty ()
+ "Boundary: zero fill is all empty cells."
+ (let ((s (substring-no-properties (cj/music--bar-string 0 20))))
+ (should (= (cl-count ?█ s) 0))
+ (should (= (cl-count ?░ s) 20))))
+
+(ert-deftest test-music-config--bar-string-boundary-full ()
+ "Boundary: full fill is all filled cells."
+ (let ((s (substring-no-properties (cj/music--bar-string 20 20))))
+ (should (= (cl-count ?█ s) 20))
+ (should (= (cl-count ?░ s) 0))))
+
+(ert-deftest test-music-config--bar-string-indeterminate-on-air ()
+ "Error/indeterminate: a stream renders an on-air marker, not a bar."
+ (let ((s (substring-no-properties (cj/music--bar-string 'indeterminate 20))))
+ (should (string-match-p "on air" s))
+ (should (= (cl-count ?█ s) 0))))
+
+(provide 'test-music-config--bar-string)
+;;; test-music-config--bar-string.el ends here
diff --git a/tests/test-music-config--completion-table.el b/tests/test-music-config--completion-table.el
index 5e33e655..6b3da442 100644
--- a/tests/test-music-config--completion-table.el
+++ b/tests/test-music-config--completion-table.el
@@ -19,6 +19,10 @@
(defvar-keymap cj/custom-keymap
:doc "Stub keymap for testing")
+;; Declare special here too (the module's bare defvar is file-local) so the
+;; registration test's `let' binds dynamically.
+(defvar marginalia-annotator-registry)
+
;; Load production code
(require 'music-config)
@@ -131,5 +135,15 @@
;; Should not crash, returns empty
(should (null result))))
+;;; Marginalia registration
+
+(ert-deftest test-music-config--completion-table-registers-with-marginalia ()
+ "Normal: building the table registers cj-music-file so marginalia
+right-aligns the size/date annotations."
+ (let ((marginalia-annotator-registry '()))
+ (cj/music--completion-table '("a.mp3"))
+ (should (equal (assq 'cj-music-file marginalia-annotator-registry)
+ '(cj-music-file builtin none)))))
+
(provide 'test-music-config--completion-table)
;;; test-music-config--completion-table.el ends here
diff --git a/tests/test-music-config--delete-playlist-file.el b/tests/test-music-config--delete-playlist-file.el
new file mode 100644
index 00000000..ace51a4d
--- /dev/null
+++ b/tests/test-music-config--delete-playlist-file.el
@@ -0,0 +1,135 @@
+;;; test-music-config--delete-playlist-file.el --- Tests for playlist file deletion -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music--delete-playlist-file function.
+;; Tests the internal helper that removes a playlist's .m3u file,
+;; clears the playlist buffer's file association when it pointed at
+;; the deleted file, and refreshes the radio metadata cache.
+;;
+;; Test organization:
+;; - Normal Cases: Existing file is deleted
+;; - Boundary Cases: Association cleared only when it matches; cache refresh
+;; - Error Cases: Nil path, nonexistent path
+;;
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Stub missing dependencies before loading music-config
+(defvar-keymap cj/custom-keymap
+ :doc "Stub keymap for testing")
+
+;; Add EMMS elpa directory to load path for batch testing
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir
+ (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-delete-playlist--setup ()
+ "Create test base dir and ensure playlist buffer exists."
+ (cj/create-test-base-dir)
+ (let ((buf (get-buffer-create cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (emms-playlist-mode)
+ (setq emms-playlist-buffer-p t))
+ (setq emms-playlist-buffer buf)
+ buf))
+
+(defun test-delete-playlist--teardown ()
+ "Clean up test playlist buffer and temp files."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (setq cj/music-playlist-file nil))
+ (kill-buffer buf))
+ (cj/delete-test-base-dir))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config--delete-playlist-file-normal-removes-file ()
+ "Normal: an existing playlist file is deleted from disk."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file-with-content
+ "#EXTM3U\n/tmp/song.mp3\n" "playlist.m3u")))
+ (cj/music--delete-playlist-file file)
+ (should-not (file-exists-p file)))
+ (test-delete-playlist--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config--delete-playlist-file-boundary-clears-matching-association ()
+ "Boundary: deleting the associated playlist file clears the association."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "playlist.m3u")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file file))
+ (cj/music--delete-playlist-file file)
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (should-not cj/music-playlist-file)))
+ (test-delete-playlist--teardown)))
+
+(ert-deftest test-music-config--delete-playlist-file-boundary-keeps-other-association ()
+ "Boundary: deleting a different file leaves the association untouched."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((doomed (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "doomed.m3u"))
+ (kept (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "kept.m3u")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file kept))
+ (cj/music--delete-playlist-file doomed)
+ (should (file-exists-p kept))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (should (equal cj/music-playlist-file kept))))
+ (test-delete-playlist--teardown)))
+
+(ert-deftest test-music-config--delete-playlist-file-boundary-refreshes-radio-cache ()
+ "Boundary: deletion clears the cached radio metadata."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "playlist.m3u")))
+ (setq cj/music--radio-metadata-cache '(("stale" . "entry")))
+ (cj/music--delete-playlist-file file)
+ (should-not cj/music--radio-metadata-cache))
+ (test-delete-playlist--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--delete-playlist-file-error-nil-path ()
+ "Error: nil path signals user-error."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (should-error (cj/music--delete-playlist-file nil) :type 'user-error)
+ (test-delete-playlist--teardown)))
+
+(ert-deftest test-music-config--delete-playlist-file-error-nonexistent-path ()
+ "Error: a path that does not exist signals user-error and touches nothing."
+ (test-delete-playlist--setup)
+ (unwind-protect
+ (let ((kept (cj/create-temp-test-file-with-content
+ "#EXTM3U\n" "kept.m3u")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file kept))
+ (should-error
+ (cj/music--delete-playlist-file
+ (expand-file-name "no-such.m3u" cj/test-base-dir))
+ :type 'user-error)
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (should (equal cj/music-playlist-file kept))))
+ (test-delete-playlist--teardown)))
+
+(provide 'test-music-config--delete-playlist-file)
+;;; test-music-config--delete-playlist-file.el ends here
diff --git a/tests/test-music-config--display-name.el b/tests/test-music-config--display-name.el
new file mode 100644
index 00000000..c1065f3a
--- /dev/null
+++ b/tests/test-music-config--display-name.el
@@ -0,0 +1,139 @@
+;;; test-music-config--display-name.el --- Tests for track display-name -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--display-name' and `cj/music--format-meta'.
+;;
+;; display-name is the pure, name-only resolver shared by the header's Current
+;; line and the playlist row renderer: for a tagged track it returns
+;; "Artist - Title" (no duration -- duration is right-aligned meta, not name);
+;; for an untagged file the clean filename; for a url track the #EXTINF label
+;; from a passed name-map, else a tidied host; unknown types fall back to
+;; emms-track-simple-description.
+;;
+;; format-meta returns the right-aligned meta string for a row: a file's
+;; duration as "[M:SS]", empty otherwise.
+;;
+;; Track independence: `emms-track' returns a track cached by (type . name)
+;; once EMMS is loaded, so two tests sharing a name would share a mutated
+;; object. Every test below uses a UNIQUE track name to stay independent.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Helpers
+
+(defun test-display-name--file (path &optional title artist duration)
+ "Create a file TRACK with PATH and optional TITLE ARTIST DURATION."
+ (let ((track (emms-track 'file path)))
+ (when title (emms-track-set track 'info-title title))
+ (when artist (emms-track-set track 'info-artist artist))
+ (when duration (emms-track-set track 'info-playing-time duration))
+ track))
+
+(defun test-display-name--url (url &optional title artist)
+ "Create a url TRACK with URL and optional TITLE ARTIST."
+ (let ((track (emms-track 'url url)))
+ (when title (emms-track-set track 'info-title title))
+ (when artist (emms-track-set track 'info-artist artist))
+ track))
+
+;;; Normal -- tagged tracks (no duration in the name)
+
+(ert-deftest test-music-config--display-name-normal-artist-title ()
+ "Normal: tagged track shows Artist - Title, no duration bracket."
+ (let ((track (test-display-name--file
+ "/dn/artist-title.flac" "So What" "Miles Davis" 562)))
+ (should (string= (cj/music--display-name track) "Miles Davis - So What"))))
+
+(ert-deftest test-music-config--display-name-normal-title-only ()
+ "Normal: title without artist shows the title alone."
+ (let ((track (test-display-name--file "/dn/title-only.mp3" "Flamenco Sketches" nil 566)))
+ (should (string= (cj/music--display-name track) "Flamenco Sketches"))))
+
+;;; Normal -- untagged file
+
+(ert-deftest test-music-config--display-name-normal-file-no-tags ()
+ "Normal: untagged file shows filename without path or extension."
+ (let ((track (test-display-name--file "/dn/Kind of Blue/02 - Freddie.flac")))
+ (should (string= (cj/music--display-name track) "02 - Freddie"))))
+
+;;; Normal -- url resolves to #EXTINF label from the name-map
+
+(ert-deftest test-music-config--display-name-normal-url-label-from-map ()
+ "Normal: a url track resolves to its #EXTINF label via the name-map."
+ (let ((track (test-display-name--url "https://ice6.somafm.com/groovesalad-256-mp3"))
+ (map '(("https://ice6.somafm.com/groovesalad-256-mp3" . "SomaFM Groove Salad"))))
+ (should (string= (cj/music--display-name track map) "SomaFM Groove Salad"))))
+
+;;; Normal -- url with tags formats like a tagged track
+
+(ert-deftest test-music-config--display-name-normal-url-with-tags ()
+ "Normal: a url track carrying tags uses Artist - Title, not the URL."
+ (let ((track (test-display-name--url "https://tagged.example.com/stream"
+ "Jazz FM" "Radio Station")))
+ (should (string= (cj/music--display-name track) "Radio Station - Jazz FM"))))
+
+;;; Boundary -- url with no label falls back to a tidied host
+
+(ert-deftest test-music-config--display-name-boundary-url-host-fallback ()
+ "Boundary: a url with no label and no map falls back to the tidied host."
+ (let ((track (test-display-name--url "https://ice6.hostonly.somafm.com/gs")))
+ (should (string= (cj/music--display-name track) "somafm.com"))))
+
+(ert-deftest test-music-config--display-name-boundary-url-not-in-map ()
+ "Boundary: a url absent from a non-empty map still falls to the host."
+ (let ((track (test-display-name--url "https://stream.other.net/live"))
+ (map '(("https://ice6.somafm.com/x" . "Groove Salad"))))
+ (should (string= (cj/music--display-name track map) "other.net"))))
+
+(ert-deftest test-music-config--display-name-boundary-unicode-title ()
+ "Boundary: unicode in the title is preserved."
+ (let ((track (test-display-name--file "/dn/unicode.mp3" "夜に駆ける" "YOASOBI" 258)))
+ (should (string= (cj/music--display-name track) "YOASOBI - 夜に駆ける"))))
+
+(ert-deftest test-music-config--display-name-boundary-file-multiple-dots ()
+ "Boundary: only the final extension is stripped."
+ (let ((track (test-display-name--file "/dn/disc.1.track.03.flac")))
+ (should (string= (cj/music--display-name track) "disc.1.track.03"))))
+
+;;; Error -- unknown type falls back without erroring
+
+(ert-deftest test-music-config--display-name-error-unknown-type ()
+ "Error: an unknown track type falls back to a string, no error."
+ (let* ((track (emms-track 'streamlist "https://unknown.example.com/playlist.m3u"))
+ (result (cj/music--display-name track)))
+ (should (stringp result))
+ (should (string-match-p "example\\.com" result))))
+
+;;; format-meta
+
+(ert-deftest test-music-config--format-meta-normal-file-duration ()
+ "Normal: a file with a duration yields the bracketed M:SS meta."
+ (let ((track (test-display-name--file "/dn/meta-dur.flac" "So What" "Miles" 562)))
+ (should (string= (cj/music--format-meta track) "[9:22]"))))
+
+(ert-deftest test-music-config--format-meta-boundary-no-duration ()
+ "Boundary: a track with no duration yields an empty meta string."
+ (let ((track (test-display-name--file "/dn/meta-nodur.flac" "So What" "Miles")))
+ (should (string= (cj/music--format-meta track) ""))))
+
+(ert-deftest test-music-config--format-meta-boundary-url-no-meta ()
+ "Boundary: a url stream with no duration yields empty meta."
+ (let ((track (test-display-name--url "https://meta.example.com/stream")))
+ (should (string= (cj/music--format-meta track) ""))))
+
+(provide 'test-music-config--display-name)
+;;; test-music-config--display-name.el ends here
diff --git a/tests/test-music-config--get-m3u-basenames.el b/tests/test-music-config--get-m3u-basenames.el
index 91c8af70..1e3875df 100644
--- a/tests/test-music-config--get-m3u-basenames.el
+++ b/tests/test-music-config--get-m3u-basenames.el
@@ -9,7 +9,7 @@
;; Test organization:
;; - Normal Cases: Multiple files, single file
;; - Boundary Cases: Empty directory, extension removal
-;; - Error Cases: Nonexistent directory
+;; - Error Cases: Nonexistent directory is skipped (not fatal)
;;
;;; Code:
@@ -47,7 +47,7 @@
(rename-file file2 (expand-file-name "jazz.m3u" test-dir))
(rename-file file3 (expand-file-name "classical.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (= (length result) 3))
;; Sort for consistent comparison
@@ -63,7 +63,7 @@
(file1 (cj/create-temp-test-file-with-content "" "favorites.m3u")))
(rename-file file1 (expand-file-name "favorites.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (= (length result) 1))
(should (equal (car result) "favorites")))))
@@ -76,7 +76,7 @@
(test-music-config--get-m3u-basenames-setup)
(unwind-protect
(let* ((test-dir (cj/create-test-subdirectory "empty-playlists")))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (null result)))))
(test-music-config--get-m3u-basenames-teardown)))
@@ -89,7 +89,7 @@
(file1 (cj/create-temp-test-file-with-content "" "test.m3u")))
(rename-file file1 (expand-file-name "playlist.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (equal result '("playlist")))
;; Verify no .m3u extension present
@@ -104,18 +104,17 @@
(file1 (cj/create-temp-test-file-with-content "" "test.m3u")))
(rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (equal result '("My Favorite Songs"))))))
(test-music-config--get-m3u-basenames-teardown)))
;;; Error Cases
-(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-signals-error ()
- "Nonexistent directory signals error."
- (let ((cj/music-m3u-root "/nonexistent/directory/path"))
- (should-error (cj/music--get-m3u-basenames)
- :type 'file-error)))
+(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-skipped ()
+ "Nonexistent directories in the roots list are skipped, returning empty."
+ (let ((cj/music-m3u-roots '("/nonexistent/directory/path")))
+ (should-not (cj/music--get-m3u-basenames))))
(provide 'test-music-config--get-m3u-basenames)
;;; test-music-config--get-m3u-basenames.el ends here
diff --git a/tests/test-music-config--get-m3u-files.el b/tests/test-music-config--get-m3u-files.el
index 2d31d554..356735a9 100644
--- a/tests/test-music-config--get-m3u-files.el
+++ b/tests/test-music-config--get-m3u-files.el
@@ -4,12 +4,12 @@
;;
;;; Commentary:
;; Unit tests for cj/music--get-m3u-files function.
-;; Tests the helper that discovers M3U files in the music directory.
+;; Tests the helper that discovers M3U files across cj/music-m3u-roots.
;;
;; Test organization:
;; - Normal Cases: Multiple M3U files, single file
;; - Boundary Cases: Empty directory, non-M3U files, various filenames
-;; - Error Cases: Nonexistent directory
+;; - Error Cases: Nonexistent directory is skipped (not fatal)
;;
;;; Code:
@@ -48,7 +48,7 @@
(rename-file file2 (expand-file-name "playlist2.m3u" test-dir))
(rename-file file3 (expand-file-name "playlist3.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 3))
;; Check structure: list of (basename . fullpath) conses
@@ -70,7 +70,7 @@
(file1 (cj/create-temp-test-file-with-content "" "myplaylist.m3u")))
(rename-file file1 (expand-file-name "myplaylist.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "myplaylist.m3u"))
@@ -84,7 +84,7 @@
(test-music-config--get-m3u-files-setup)
(unwind-protect
(let* ((test-dir (cj/create-test-subdirectory "empty-playlists")))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (null result)))))
(test-music-config--get-m3u-files-teardown)))
@@ -101,7 +101,7 @@
(rename-file mp3-file (expand-file-name "song.mp3" test-dir))
(rename-file json-file (expand-file-name "data.json" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (null result)))))
(test-music-config--get-m3u-files-teardown)))
@@ -114,7 +114,7 @@
(file1 (cj/create-temp-test-file-with-content "" "my-playlist.m3u")))
(rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "My Favorite Songs.m3u")))))
@@ -132,7 +132,7 @@
(rename-file txt-file (expand-file-name "readme.txt" test-dir))
(rename-file mp3-file (expand-file-name "song.mp3" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "playlist.m3u")))))
@@ -140,11 +140,10 @@
;;; Error Cases
-(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-signals-error ()
- "Nonexistent directory signals error."
- (let ((cj/music-m3u-root "/nonexistent/directory/path"))
- (should-error (cj/music--get-m3u-files)
- :type 'file-error)))
+(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-skipped ()
+ "Nonexistent directories in the roots list are skipped, returning empty."
+ (let ((cj/music-m3u-roots '("/nonexistent/directory/path")))
+ (should-not (cj/music--get-m3u-files))))
(provide 'test-music-config--get-m3u-files)
;;; test-music-config--get-m3u-files.el ends here
diff --git a/tests/test-music-config--header-text.el b/tests/test-music-config--header-text.el
index 8de97350..c860c6d4 100644
--- a/tests/test-music-config--header-text.el
+++ b/tests/test-music-config--header-text.el
@@ -139,6 +139,23 @@
(should (string-match-p "consume" plain))))
(test-header--teardown)))
+(ert-deftest test-music-config--header-text-boundary-key-hints-single-save-stop ()
+ "Header key hints: single is on 1, save is on s, and stop (S) is gone.
+SPC/pause covers stop, so the S:stop hint and the [s] single / v:save hints
+are retired."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3"))
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "\\[1\\] single" plain))
+ (should (string-match-p "s:save" plain))
+ (should-not (string-match-p "\\[s\\] single" plain))
+ (should-not (string-match-p "v:save" plain))
+ (should-not (string-match-p "S:stop" plain))))
+ (test-header--teardown)))
+
;;; Error Cases
(ert-deftest test-music-config--header-text-error-empty-playlist-shows-zero-count ()
diff --git a/tests/test-music-config--m3u-entries.el b/tests/test-music-config--m3u-entries.el
new file mode 100644
index 00000000..1eaf1345
--- /dev/null
+++ b/tests/test-music-config--m3u-entries.el
@@ -0,0 +1,67 @@
+;;; test-music-config--m3u-entries.el --- Tests for #EXTINF/UUID/favicon parse -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--m3u-entries': parse .m3u text into an alist of
+;; (stream-url . plist), each plist carrying :name (the #EXTINF label), :uuid
+;; (#RADIOBROWSERUUID), and :favicon (#RADIOBROWSERFAVICON). This is the one
+;; pure parser both the name resolution (Phase 1) and the cover-art layer read.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--m3u-entries-normal-name-only ()
+ "Normal: an #EXTINF + url pair yields :name with nil :uuid and :favicon."
+ (let* ((text "#EXTM3U\n#EXTINF:1,SomaFM Groove Salad\nhttps://ice6.somafm.com/gs\n")
+ (e (cdr (assoc "https://ice6.somafm.com/gs" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "SomaFM Groove Salad"))
+ (should (null (plist-get e :uuid)))
+ (should (null (plist-get e :favicon)))))
+
+(ert-deftest test-music-config--m3u-entries-normal-uuid-and-favicon ()
+ "Normal: UUID and favicon comment lines are captured onto the entry."
+ (let* ((text (concat "#EXTM3U\n#EXTINF:1,Jazz24\n"
+ "#RADIOBROWSERUUID:abc-123\n"
+ "#RADIOBROWSERFAVICON:https://cdn.example/jazz.png\n"
+ "https://jazz.example/live\n"))
+ (e (cdr (assoc "https://jazz.example/live" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "Jazz24"))
+ (should (equal (plist-get e :uuid) "abc-123"))
+ (should (equal (plist-get e :favicon) "https://cdn.example/jazz.png"))))
+
+(ert-deftest test-music-config--m3u-entries-normal-multiple-reset ()
+ "Normal: fields reset between stations (station B has no UUID leak from A)."
+ (let* ((text (concat "#EXTINF:1,A\n#RADIOBROWSERUUID:aaa\nhttps://a.example/1\n"
+ "#EXTINF:1,B\nhttps://b.example/2\n"))
+ (entries (cj/music--m3u-entries text))
+ (b (cdr (assoc "https://b.example/2" entries))))
+ (should (equal (plist-get b :name) "B"))
+ (should (null (plist-get b :uuid)))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-url-without-extinf ()
+ "Boundary: a bare url with no #EXTINF is skipped."
+ (should (null (cj/music--m3u-entries "#EXTM3U\nhttps://plain.example/stream\n"))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-empty ()
+ "Boundary: empty text yields nil."
+ (should (null (cj/music--m3u-entries ""))))
+
+(ert-deftest test-music-config--m3u-entries-boundary-comma-in-name ()
+ "Boundary: a comma inside the #EXTINF label is preserved."
+ (let* ((text "#EXTINF:1,Radio, the Good Kind\nhttps://x.example/s\n")
+ (e (cdr (assoc "https://x.example/s" (cj/music--m3u-entries text)))))
+ (should (equal (plist-get e :name) "Radio, the Good Kind"))))
+
+(provide 'test-music-config--m3u-entries)
+;;; test-music-config--m3u-entries.el ends here
diff --git a/tests/test-music-config--m3u-file-tracks.el b/tests/test-music-config--m3u-file-tracks.el
index badc9817..e3cbd72e 100644
--- a/tests/test-music-config--m3u-file-tracks.el
+++ b/tests/test-music-config--m3u-file-tracks.el
@@ -189,5 +189,23 @@
"Parse nil input returns nil gracefully."
(should (null (cj/music--m3u-file-tracks nil))))
+;;; Non-music filtering
+
+(ert-deftest test-music-config--m3u-file-tracks-filters-non-music-local-files ()
+ "Normal: a local non-music path (a saved cover.jpg line) is dropped;
+music files and stream URLs pass through."
+ (test-music-config--m3u-file-tracks-setup)
+ (unwind-protect
+ (let* ((content (concat "/home/user/music/track1.mp3\n"
+ "/home/user/music/album/cover.jpg\n"
+ "https://somafm.com/stream\n"
+ "/home/user/music/track2.flac\n"))
+ (m3u-file (cj/create-temp-test-file-with-content content "test.m3u"))
+ (tracks (cj/music--m3u-file-tracks m3u-file)))
+ (should (equal tracks '("/home/user/music/track1.mp3"
+ "https://somafm.com/stream"
+ "/home/user/music/track2.flac"))))
+ (test-music-config--m3u-file-tracks-teardown)))
+
(provide 'test-music-config--m3u-file-tracks)
;;; test-music-config--m3u-file-tracks.el ends here
diff --git a/tests/test-music-config--m3u-labels.el b/tests/test-music-config--m3u-labels.el
new file mode 100644
index 00000000..02f328cd
--- /dev/null
+++ b/tests/test-music-config--m3u-labels.el
@@ -0,0 +1,62 @@
+;;; test-music-config--m3u-labels.el --- Tests for #EXTINF label extraction -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--m3u-labels': parse .m3u text into an alist of
+;; (stream-url . #EXTINF-label) pairs. This is the pure core that lets a url
+;; track resolve to its station name (the label written at creation) instead
+;; of the raw stream URL.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--m3u-labels-normal-single ()
+ "Normal: one #EXTINF + url pair yields one (url . label) cons."
+ (let ((text "#EXTM3U\n#EXTINF:1,SomaFM Groove Salad\nhttps://ice6.somafm.com/gs\n"))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://ice6.somafm.com/gs" . "SomaFM Groove Salad"))))))
+
+(ert-deftest test-music-config--m3u-labels-normal-uuid-line-between ()
+ "Normal: a #RADIOBROWSERUUID line between #EXTINF and the url is skipped."
+ (let ((text (concat "#EXTM3U\n#EXTINF:1,Jazz24\n"
+ "#RADIOBROWSERUUID:abc-123\nhttps://jazz.example/live\n")))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://jazz.example/live" . "Jazz24"))))))
+
+(ert-deftest test-music-config--m3u-labels-normal-multiple ()
+ "Normal: multiple stations each yield their own pair."
+ (let ((text (concat "#EXTM3U\n"
+ "#EXTINF:1,Station A\nhttps://a.example/1\n"
+ "#EXTINF:-1,Station B\nhttps://b.example/2\n")))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://a.example/1" . "Station A")
+ ("https://b.example/2" . "Station B"))))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-url-without-extinf ()
+ "Boundary: a bare url with no preceding #EXTINF produces no pair."
+ (let ((text "#EXTM3U\nhttps://plain.example/stream\n"))
+ (should (null (cj/music--m3u-labels text)))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-empty ()
+ "Boundary: empty text yields nil."
+ (should (null (cj/music--m3u-labels ""))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-comma-in-name ()
+ "Boundary: a comma inside the label is preserved (split on the first only)."
+ (let ((text "#EXTINF:1,Radio, the Good Kind\nhttps://x.example/s\n"))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://x.example/s" . "Radio, the Good Kind"))))))
+
+(provide 'test-music-config--m3u-labels)
+;;; test-music-config--m3u-labels.el ends here
diff --git a/tests/test-music-config--m3u-roots.el b/tests/test-music-config--m3u-roots.el
new file mode 100644
index 00000000..626415b6
--- /dev/null
+++ b/tests/test-music-config--m3u-roots.el
@@ -0,0 +1,97 @@
+;;; test-music-config--m3u-roots.el --- multi-directory M3U sourcing tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The player sources .m3u playlists from a LIST of directories
+;; (`cj/music-m3u-roots') so the local-library playlists (~/music) and the
+;; dotfiles-tracked internet-radio playlists (MPD's playlist_directory) surface
+;; together for selection and loading. Two pieces are tested:
+;;
+;; - `cj/music--dedup-m3u-files' — pure: turns a flat list of paths into
+;; (BASENAME . PATH) conses, first occurrence of a basename winning.
+;; - `cj/music--get-m3u-files' — unions the roots on disk, skips missing dirs,
+;; and applies the dedup so an earlier root shadows a same-named later one.
+;;
+;;; Code:
+
+(require 'ert)
+
+;; Stub missing dependencies before loading music-config.
+(defvar-keymap cj/custom-keymap
+ :doc "Stub keymap for testing")
+
+(require 'music-config)
+
+(declare-function cj/music--dedup-m3u-files "music-config" (paths))
+(declare-function cj/music--get-m3u-files "music-config" ())
+(defvar cj/music-m3u-roots)
+
+;;; --------------------------- cj/music--dedup-m3u-files ----------------------
+
+(ert-deftest test-music-config-dedup-m3u-distinct ()
+ "Normal: distinct basenames across dirs all appear, in order."
+ (should (equal (cj/music--dedup-m3u-files
+ '("/music/rhcp.m3u" "/radio/90s Sounds.m3u"))
+ '(("rhcp.m3u" . "/music/rhcp.m3u")
+ ("90s Sounds.m3u" . "/radio/90s Sounds.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-single ()
+ "Normal: a single path yields a single cons."
+ (should (equal (cj/music--dedup-m3u-files '("/music/blues.m3u"))
+ '(("blues.m3u" . "/music/blues.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-collision-first-wins ()
+ "Boundary: a basename in two dirs keeps the first path (earlier root wins)."
+ (should (equal (cj/music--dedup-m3u-files
+ '("/music/jazz.m3u" "/radio/jazz.m3u"))
+ '(("jazz.m3u" . "/music/jazz.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-empty ()
+ "Boundary: an empty path list yields nil."
+ (should-not (cj/music--dedup-m3u-files '())))
+
+;;; ---------------------------- cj/music--get-m3u-files -----------------------
+
+(ert-deftest test-music-config-get-m3u-unions-roots ()
+ "Normal: M3Us from every existing root are unioned."
+ (let ((a (make-temp-file "m3u-a-" t))
+ (b (make-temp-file "m3u-b-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "local.m3u" a))
+ (write-region "" nil (expand-file-name "radio.m3u" b))
+ (let* ((cj/music-m3u-roots (list a b))
+ (bases (mapcar #'car (cj/music--get-m3u-files))))
+ (should (member "local.m3u" bases))
+ (should (member "radio.m3u" bases))))
+ (delete-directory a t)
+ (delete-directory b t))))
+
+(ert-deftest test-music-config-get-m3u-skips-missing-root ()
+ "Error: a non-existent directory in the list is skipped, not fatal."
+ (let ((a (make-temp-file "m3u-a-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "local.m3u" a))
+ (let* ((cj/music-m3u-roots (list a "/no/such/dir/here"))
+ (bases (mapcar #'car (cj/music--get-m3u-files))))
+ (should (equal bases '("local.m3u")))))
+ (delete-directory a t))))
+
+(ert-deftest test-music-config-get-m3u-collision-first-root-wins ()
+ "Boundary: same basename in two roots resolves to the earlier root's file."
+ (let ((a (make-temp-file "m3u-a-" t))
+ (b (make-temp-file "m3u-b-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "jazz.m3u" a))
+ (write-region "" nil (expand-file-name "jazz.m3u" b))
+ (let* ((cj/music-m3u-roots (list a b))
+ (pair (assoc "jazz.m3u" (cj/music--get-m3u-files))))
+ (should (string-prefix-p a (cdr pair)))))
+ (delete-directory a t)
+ (delete-directory b t))))
+
+(provide 'test-music-config--m3u-roots)
+;;; test-music-config--m3u-roots.el ends here
diff --git a/tests/test-music-config--m3u-text.el b/tests/test-music-config--m3u-text.el
new file mode 100644
index 00000000..14c9f2bf
--- /dev/null
+++ b/tests/test-music-config--m3u-text.el
@@ -0,0 +1,93 @@
+;;; test-music-config--m3u-text.el --- playlist .m3u emitter tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The custom .m3u emitter behind playlist save. The stock EMMS m3u writer
+;; emits bare URLs, which would throw away a station's name/uuid/favicon on
+;; save; this emitter writes the same #EXTINF / #RADIOBROWSERUUID /
+;; #RADIOBROWSERFAVICON lines the parser (`cj/music--m3u-entries') reads, so
+;; save -> load round-trips a station's display name and cover-art metadata.
+;; Metadata comes from track properties first, the m3u-scan entries as
+;; fallback (a loaded legacy playlist has entries but no properties).
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music--m3u-text "music-config" (tracks entries))
+(declare-function cj/music-radio--station-track "music-config" (st))
+
+(ert-deftest test-music-m3u-text-normal-file-track-bare-path ()
+ "Normal: a file track is a bare absolute path under the #EXTM3U header."
+ (let ((text (cj/music--m3u-text (list (emms-track 'file "/music/a.flac")) nil)))
+ (should (string-prefix-p "#EXTM3U\n" text))
+ (should (string-match-p "^/music/a\\.flac$" text))))
+
+(ert-deftest test-music-m3u-text-normal-url-track-from-properties ()
+ "Normal: a url track with properties emits uuid, favicon, EXTINF, and url."
+ (let* ((track (cj/music-radio--station-track
+ '(:name "Groove Salad" :url "https://ck.somafm.com/gs"
+ :stationuuid "uuid-1" :favicon "https://somafm.com/i.png")))
+ (text (cj/music--m3u-text (list track) nil)))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-1$" text))
+ (should (string-match-p "^#RADIOBROWSERFAVICON:https://somafm\\.com/i\\.png$" text))
+ (should (string-match-p "^#EXTINF:-1,Groove Salad$" text))
+ (should (string-match-p "^https://ck\\.somafm\\.com/gs$" text))))
+
+(ert-deftest test-music-m3u-text-normal-url-track-from-entries-fallback ()
+ "Normal: a propertyless url track (a loaded legacy playlist) resolves its
+metadata from the ENTRIES alist."
+ (let* ((url "https://legacy.example/stream")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "Legacy FM" :uuid "uuid-9"
+ :favicon "https://legacy.example/f.ico")))
+ (text (cj/music--m3u-text (list track) entries)))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-9$" text))
+ (should (string-match-p "^#EXTINF:-1,Legacy FM$" text))))
+
+(ert-deftest test-music-m3u-text-boundary-url-track-no-metadata ()
+ "Boundary: a url track with neither properties nor entries still gets an
+EXTINF label (the tidied host) and no uuid/favicon lines."
+ (let ((text (cj/music--m3u-text
+ (list (emms-track 'url "https://ice6.somafm.com/live")) nil)))
+ (should (string-match-p "^#EXTINF:-1,somafm\\.com$" text))
+ (should-not (string-match-p "RADIOBROWSERUUID" text))
+ (should-not (string-match-p "RADIOBROWSERFAVICON" text))))
+
+(ert-deftest test-music-m3u-text-normal-mixed-order-preserved ()
+ "Normal: a mixed queue keeps its track order in the file."
+ (let* ((f (emms-track 'file "/music/b.mp3"))
+ (u (cj/music-radio--station-track '(:name "S" :url "https://s.example/x")))
+ (text (cj/music--m3u-text (list f u) nil)))
+ (should (< (string-match "^/music/b\\.mp3$" text)
+ (string-match "^https://s\\.example/x$" text)))))
+
+(ert-deftest test-music-m3u-text-round-trip-through-parser ()
+ "Normal: the parser recovers name, uuid, and favicon from emitted text."
+ (let* ((track (cj/music-radio--station-track
+ '(:name "Round Trip" :url "https://rt.example/s"
+ :stationuuid "uuid-rt" :favicon "https://rt.example/f.png")))
+ (entries (cj/music--m3u-entries (cj/music--m3u-text (list track) nil)))
+ (meta (cdr (assoc "https://rt.example/s" entries))))
+ (should (equal (plist-get meta :name) "Round Trip"))
+ (should (equal (plist-get meta :uuid) "uuid-rt"))
+ (should (equal (plist-get meta :favicon) "https://rt.example/f.png"))))
+
+(ert-deftest test-music-m3u-text-boundary-empty-playlist-header-only ()
+ "Boundary: an empty track list is just the #EXTM3U header."
+ (should (equal (cj/music--m3u-text nil nil) "#EXTM3U\n")))
+
+(provide 'test-music-config--m3u-text)
+;;; test-music-config--m3u-text.el ends here
diff --git a/tests/test-music-config--music-files-recursive.el b/tests/test-music-config--music-files-recursive.el
new file mode 100644
index 00000000..f5f5fc5b
--- /dev/null
+++ b/tests/test-music-config--music-files-recursive.el
@@ -0,0 +1,88 @@
+;;; test-music-config--music-files-recursive.el --- Tests for filtered directory collection -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Directory adds used to hand the whole tree to emms-add-directory-tree,
+;; which adds every file it finds -- cover.jpg and friends ended up as
+;; playlist rows. The filtered walk returns only files passing
+;; cj/music--valid-file-p, skipping hidden dirs/files, sorted. Real temp-dir
+;; fixtures; the EMMS boundary is mocked only in the command-level test.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+(defmacro test-music-files--with-fixture (var &rest body)
+ "Run BODY with VAR bound to a temp music-directory fixture."
+ (declare (indent 1))
+ `(let ((,var (make-temp-file "music-test-" t)))
+ (unwind-protect
+ (progn
+ (make-directory (expand-file-name "album" ,var))
+ (make-directory (expand-file-name ".hidden" ,var))
+ (dolist (f '("song.mp3" "cover.jpg" "album/track.flac"
+ "album/folder.png" "album/notes.txt"
+ ".hidden/secret.mp3" ".stray.ogg"))
+ (write-region "" nil (expand-file-name f ,var)))
+ ,@body)
+ (delete-directory ,var t))))
+
+;;; Normal Cases
+
+(ert-deftest test-music-files-recursive-music-only ()
+ "Normal: only files with accepted music extensions come back, sorted;
+cover art, text files, and hidden entries stay out."
+ (test-music-files--with-fixture root
+ (should (equal (mapcar (lambda (f) (file-relative-name f root))
+ (cj/music--music-files-recursive root))
+ '("album/track.flac" "song.mp3")))))
+
+(ert-deftest test-music-add-directory-recursive-adds-only-music ()
+ "Normal: the directory-add command feeds only music files to EMMS."
+ (test-music-files--with-fixture root
+ (let (added)
+ (cl-letf (((symbol-function 'cj/music--ensure-playlist-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'emms-add-file)
+ (lambda (f) (push f added))))
+ (cj/music-add-directory-recursive root))
+ (should (equal (mapcar (lambda (f) (file-relative-name f root))
+ (nreverse added))
+ '("album/track.flac" "song.mp3"))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-files-recursive-case-insensitive-extensions ()
+ "Boundary: extensions match case-insensitively (Song.MP3 counts)."
+ (let ((root (make-temp-file "music-test-case-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "Song.MP3" root))
+ (should (= 1 (length (cj/music--music-files-recursive root)))))
+ (delete-directory root t))))
+
+(ert-deftest test-music-files-recursive-empty-directory ()
+ "Boundary: a directory with no music files returns nil."
+ (let ((root (make-temp-file "music-test-empty-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "readme.txt" root))
+ (should-not (cj/music--music-files-recursive root)))
+ (delete-directory root t))))
+
+;;; Error Cases
+
+(ert-deftest test-music-add-directory-recursive-not-a-directory-errors ()
+ "Error: a non-directory argument signals user-error."
+ (should-error (cj/music-add-directory-recursive "/nonexistent/nowhere")
+ :type 'user-error))
+
+(provide 'test-music-config--music-files-recursive)
+;;; test-music-config--music-files-recursive.el ends here
diff --git a/tests/test-music-config--pin-point.el b/tests/test-music-config--pin-point.el
new file mode 100644
index 00000000..2d6fa916
--- /dev/null
+++ b/tests/test-music-config--pin-point.el
@@ -0,0 +1,78 @@
+;;; test-music-config--pin-point.el --- Tests for the playlist gutter cursor -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The playlist cursor lives pinned at the start of the row (the number
+;; gutter). The rows are rendered track lines, not editable text, and
+;; vertical motion over thumbnails and stretch-space drifts point to
+;; arbitrary visual columns (usually line end). A buffer-local
+;; post-command snap enforces the model for every motion command. The one
+;; exception is an active isearch, which owns point placement until it ends.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+;;; Normal Cases
+
+(ert-deftest test-music-pin-point-snaps-mid-line-to-bol ()
+ "Normal: point mid-row snaps back to the beginning of the line."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (goto-char (point-min))
+ (forward-char 5)
+ (cj/music--pin-point-to-bol)
+ (should (bolp))
+ (should (= (point) (point-min)))))
+
+(ert-deftest test-music-pin-point-noop-at-bol ()
+ "Normal: point already at the row start stays put."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (goto-char (point-min))
+ (forward-line 1)
+ (let ((before (point)))
+ (cj/music--pin-point-to-bol)
+ (should (= (point) before)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-pin-point-skips-during-isearch ()
+ "Boundary: an active isearch owns point; the pin defers until it ends."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (goto-char (point-min))
+ (forward-char 5)
+ (let ((isearch-mode t))
+ (cj/music--pin-point-to-bol))
+ (should-not (bolp))))
+
+(ert-deftest test-music-pin-point-empty-buffer-no-error ()
+ "Boundary: an empty buffer is a no-op, no error."
+ (with-temp-buffer
+ (should-not (cj/music--pin-point-to-bol))
+ (should (bolp))))
+
+;;; Hook wiring
+
+(ert-deftest test-music-pin-point-ensure-wires-post-command-hook ()
+ "Normal: the playlist buffer gets the pin on its buffer-local
+post-command-hook."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should (member #'cj/music--pin-point-to-bol post-command-hook))))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+(provide 'test-music-config--pin-point)
+;;; test-music-config--pin-point.el ends here
diff --git a/tests/test-music-config--playlist-dock.el b/tests/test-music-config--playlist-dock.el
new file mode 100644
index 00000000..1dbe9fbb
--- /dev/null
+++ b/tests/test-music-config--playlist-dock.el
@@ -0,0 +1,114 @@
+;;; test-music-config--playlist-dock.el --- Tests for the F10 playlist dock -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The F10 playlist always docks at the bottom, whatever the frame's shape.
+;; It used to pick `right' on a wide frame via `cj/preferred-dock-direction',
+;; which produced an unwanted three-way split on a wide terminal.
+;;
+;; `cj/side-window-display' is the window-system boundary here, so it is the
+;; thing stubbed (an ordinary defun -- safe to `cl-letf', unlike the frame-*
+;; subrs). Stubbing it lets these tests assert what the toggle *asks for*
+;; without needing a live frame under `--batch'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'music-config)
+
+(defmacro test-music-dock--with-captured-side (captured &rest body)
+ "Run BODY with the playlist display path stubbed, recording its args in CAPTURED.
+CAPTURED is set to a plist of :side, :size-var and :default-size."
+ (declare (indent 1))
+ `(let ((buffer (generate-new-buffer " *test-playlist*")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/emms--setup) (lambda (&rest _) nil))
+ ((symbol-function 'cj/music--ensure-playlist-buffer)
+ (lambda (&rest _) buffer))
+ ((symbol-function 'emms-playlist-mode-center-current)
+ (lambda (&rest _) nil))
+ ((symbol-function 'cj/side-window-display)
+ (lambda (_buf side size-var default-size)
+ (setq ,captured (list :side side :size-var size-var
+ :default-size default-size))
+ (selected-window))))
+ ,@body)
+ (kill-buffer buffer))))
+
+(ert-deftest test-music-config-playlist-toggle-docks-bottom-on-wide-frame ()
+ "Normal: a wide frame still docks the playlist at the bottom.
+A wide frame used to dock right, splitting the frame three ways."
+ (let (captured)
+ (test-music-dock--with-captured-side captured
+ (cl-letf (((symbol-function 'frame-width) (lambda (&rest _) 400)))
+ (cj/music-playlist-toggle)))
+ (should (eq 'bottom (plist-get captured :side)))))
+
+(ert-deftest test-music-config-playlist-toggle-docks-bottom-on-narrow-frame ()
+ "Boundary: a narrow frame docks at the bottom, as it always did."
+ (let (captured)
+ (test-music-dock--with-captured-side captured
+ (cl-letf (((symbol-function 'frame-width) (lambda (&rest _) 40)))
+ (cj/music-playlist-toggle)))
+ (should (eq 'bottom (plist-get captured :side)))))
+
+(ert-deftest test-music-config-playlist-toggle-uses-height-memory ()
+ "Normal: the bottom dock carries the height fraction and its memory var."
+ (let (captured)
+ (test-music-dock--with-captured-side captured
+ (cj/music-playlist-toggle))
+ (should (eq 'cj/--music-playlist-height (plist-get captured :size-var)))
+ (should (= cj/music-playlist-window-height (plist-get captured :default-size)))))
+
+(ert-deftest test-music-config-playlist-dock-has-no-width-knobs ()
+ "Error: the right-dock width variables are gone, not merely unused.
+A stale `cj/music-playlist-window-width' would read as a live knob that
+silently does nothing."
+ (should-not (boundp 'cj/music-playlist-window-width))
+ (should-not (boundp 'cj/--music-playlist-width))
+ (should-not (fboundp 'cj/--music-playlist-side)))
+
+(ert-deftest test-music-config-playlist-default-height-is-half ()
+ "Normal: the dock opens at half the frame height by default (Craig,
+2026-07-18 -- a third still read too short for a real playlist)."
+ (should (= cj/music-playlist-window-height 0.5)))
+
+(ert-deftest test-music-config-playlist-toggle-off-discards-shrunk-height ()
+ "Error: a captured height below the default is discarded. Window churn
+squeezes the dock, and remembering the squeeze reopens it too short on
+every later toggle."
+ (let ((buffer (generate-new-buffer " *test-playlist-shrink*"))
+ (cj/--music-playlist-height nil))
+ (unwind-protect
+ (save-window-excursion
+ (set-window-buffer (selected-window) buffer)
+ (let ((cj/music-playlist-buffer-name (buffer-name buffer)))
+ (cl-letf (((symbol-function 'cj/side-window-capture-size)
+ (lambda (_w _side var) (set var 0.2)))
+ ((symbol-function 'delete-window) #'ignore)
+ ((symbol-function 'message) #'ignore))
+ (cj/music-playlist-toggle)))
+ (should (null cj/--music-playlist-height)))
+ (kill-buffer buffer))))
+
+(ert-deftest test-music-config-playlist-toggle-off-keeps-enlarged-height ()
+ "Normal: a captured height at or above the default is remembered, so a
+deliberate enlargement sticks for the session."
+ (let ((buffer (generate-new-buffer " *test-playlist-grow*"))
+ (cj/--music-playlist-height nil))
+ (unwind-protect
+ (save-window-excursion
+ (set-window-buffer (selected-window) buffer)
+ (let ((cj/music-playlist-buffer-name (buffer-name buffer)))
+ (cl-letf (((symbol-function 'cj/side-window-capture-size)
+ (lambda (_w _side var) (set var 0.5)))
+ ((symbol-function 'delete-window) #'ignore)
+ ((symbol-function 'message) #'ignore))
+ (cj/music-playlist-toggle)))
+ (should (= 0.5 cj/--music-playlist-height)))
+ (kill-buffer buffer))))
+
+(provide 'test-music-config--playlist-dock)
+;;; test-music-config--playlist-dock.el ends here
diff --git a/tests/test-music-config--playlist-open-position.el b/tests/test-music-config--playlist-open-position.el
new file mode 100644
index 00000000..cd83e65b
--- /dev/null
+++ b/tests/test-music-config--playlist-open-position.el
@@ -0,0 +1,147 @@
+;;; test-music-config--playlist-open-position.el --- Tests for playlist landing position -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Opening the playlist lands point by one rule: the beginning of the playing
+;; track's line when a song is playing, else the top of the list. The old
+;; behavior keyed off EMMS's selected track, which stays set while stopped, so
+;; the playlist opened deep in the list at a stale position. The decision is
+;; a pure helper; the window landing (point + upper-third recenter) is tested
+;; with recenter stubbed at the display boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Declare the emms vars special HERE too: the module's bare (defvar
+;; emms-player-playing-p) marks them special only for code compiled in
+;; that file, so a plain `let' in this lexical-binding test file would
+;; bind them lexically and the module would never see the value (the
+;; scope-shadowing trap from the testing rules).
+(defvar emms-player-playing-p)
+(defvar emms-playlist-selected-marker)
+
+(require 'music-config)
+
+(defmacro test-music-open-pos--with-buffer (var &rest body)
+ "Run BODY with VAR bound to a temp 3-track playlist-shaped buffer."
+ (declare (indent 1))
+ `(let ((,var (generate-new-buffer " *test-open-pos*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer ,var
+ (insert "track one\ntrack two\ntrack three\n"))
+ ,@body)
+ (when (buffer-live-p ,var) (kill-buffer ,var)))))
+
+(defun test-music-open-pos--marker (buffer line offset)
+ "Marker in BUFFER at LINE (1-based) plus OFFSET chars."
+ (with-current-buffer buffer
+ (save-excursion
+ (goto-char (point-min))
+ (forward-line (1- line))
+ (forward-char offset)
+ (point-marker))))
+
+;;; Normal Cases
+
+(ert-deftest test-music-playlist-open-position-playing-lands-on-playing-line-start ()
+ "Normal: playing -> the playing track's line, at its beginning (even when
+the marker sits mid-line)."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p t)
+ (emms-playlist-selected-marker (test-music-open-pos--marker buf 2 4)))
+ (should (= (cj/music--playlist-open-position buf)
+ (with-current-buffer buf
+ (save-excursion (goto-char (point-min)) (forward-line 1) (point))))))))
+
+(ert-deftest test-music-playlist-open-position-stopped-lands-at-top ()
+ "Normal: not playing -> top of the list, even though EMMS still has a
+stale selected track."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p nil)
+ (emms-playlist-selected-marker (test-music-open-pos--marker buf 3 0)))
+ (should (= (cj/music--playlist-open-position buf) 1)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-playlist-open-position-playing-no-marker-lands-at-top ()
+ "Boundary: playing but no usable marker -> top of the list."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p t)
+ (emms-playlist-selected-marker nil))
+ (should (= (cj/music--playlist-open-position buf) 1)))))
+
+(ert-deftest test-music-playlist-open-position-marker-in-other-buffer-lands-at-top ()
+ "Boundary: a marker pointing into a different buffer is ignored."
+ (test-music-open-pos--with-buffer buf
+ (with-temp-buffer
+ (insert "elsewhere\n")
+ (let ((emms-player-playing-p t)
+ (emms-playlist-selected-marker (point-marker)))
+ (should (= (cj/music--playlist-open-position buf) 1))))))
+
+(ert-deftest test-music-playlist-open-position-empty-buffer ()
+ "Boundary: an empty playlist lands at point-min without error."
+ (let ((buf (generate-new-buffer " *test-open-pos-empty*")))
+ (unwind-protect
+ (let ((emms-player-playing-p nil)
+ (emms-playlist-selected-marker nil))
+ (should (= (cj/music--playlist-open-position buf) 1)))
+ (kill-buffer buf))))
+
+;;; Landing (window boundary stubbed)
+
+(ert-deftest test-music-playlist-land-point-playing-recenter-upper-third ()
+ "Normal: landing on a playing row sets window point to its line start and
+recenters into the upper third."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p t)
+ (emms-playlist-selected-marker (test-music-open-pos--marker buf 2 4))
+ (recenter-arg 'not-called))
+ (save-window-excursion
+ (set-window-buffer (selected-window) buf)
+ (cl-letf (((symbol-function 'recenter)
+ (lambda (&optional arg &rest _) (setq recenter-arg arg))))
+ (cj/music--playlist-land-point (selected-window) buf))
+ (should (= (window-point (selected-window))
+ (with-current-buffer buf
+ (save-excursion (goto-char (point-min)) (forward-line 1) (point)))))
+ (should (integerp recenter-arg))
+ (should (>= recenter-arg 1))))))
+
+(ert-deftest test-music-playlist-land-point-stopped-top-no-recenter ()
+ "Normal: landing while stopped puts window point at the top; no recenter."
+ (test-music-open-pos--with-buffer buf
+ (let ((emms-player-playing-p nil)
+ (emms-playlist-selected-marker nil)
+ (recenter-called nil))
+ (save-window-excursion
+ (set-window-buffer (selected-window) buf)
+ (cl-letf (((symbol-function 'recenter)
+ (lambda (&rest _) (setq recenter-called t))))
+ (cj/music--playlist-land-point (selected-window) buf))
+ (should (= (window-point (selected-window)) 1))
+ (should-not recenter-called)))))
+
+;;; hl-line in the playlist buffer
+
+(ert-deftest test-music-playlist-ensure-enables-hl-line ()
+ "Normal: the playlist buffer gets hl-line-mode so the current row is
+findable even when the cursor sits on album art."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should hl-line-mode)))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+(provide 'test-music-config--playlist-open-position)
+;;; test-music-config--playlist-open-position.el ends here
diff --git a/tests/test-music-config--playlist-side.el b/tests/test-music-config--playlist-side.el
deleted file mode 100644
index f4969469..00000000
--- a/tests/test-music-config--playlist-side.el
+++ /dev/null
@@ -1,45 +0,0 @@
-;;; test-music-config--playlist-side.el --- Tests for the F10 dock-side helper -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; `cj/--music-playlist-side' maps the shared dock rule's verdict to a
-;; `display-buffer-in-side-window' side: `right' stays `right', anything
-;; else becomes `bottom'. The decision itself lives in
-;; `cj/preferred-dock-direction' (tested in test-cj-window-geometry-lib.el);
-;; here we stub it (an ordinary defun -- safe to `cl-letf', unlike the
-;; frame-* subrs) to prove the mapping and that the width fraction is
-;; passed through.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'music-config)
-
-(ert-deftest test-music-config--playlist-side-right-verdict-is-right ()
- "Normal: a `right' verdict from the dock rule docks the playlist right."
- (cl-letf (((symbol-function 'cj/preferred-dock-direction)
- (lambda (&rest _) 'right)))
- (should (eq (cj/--music-playlist-side) 'right))))
-
-(ert-deftest test-music-config--playlist-side-below-verdict-is-bottom ()
- "Normal: a `below' verdict maps to the `bottom' side window."
- (cl-letf (((symbol-function 'cj/preferred-dock-direction)
- (lambda (&rest _) 'below)))
- (should (eq (cj/--music-playlist-side) 'bottom))))
-
-(ert-deftest test-music-config--playlist-side-passes-width-fraction ()
- "Normal: the playlist's width fraction reaches the dock rule."
- (let ((cj/music-playlist-window-width 0.4)
- captured)
- (cl-letf (((symbol-function 'cj/preferred-dock-direction)
- (lambda (cols frac &rest _)
- (setq captured (list cols frac))
- 'below)))
- (cj/--music-playlist-side)
- (should (= (nth 1 captured) 0.4))
- (should (integerp (nth 0 captured))))))
-
-(provide 'test-music-config--playlist-side)
-;;; test-music-config--playlist-side.el ends here
diff --git a/tests/test-music-config--radio-station-track.el b/tests/test-music-config--radio-station-track.el
new file mode 100644
index 00000000..5816c776
--- /dev/null
+++ b/tests/test-music-config--radio-station-track.el
@@ -0,0 +1,135 @@
+;;; test-music-config--radio-station-track.el --- station->track + enqueue tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The queue-first radio model: a picked station becomes an EMMS url track
+;; carrying its metadata as track properties (info-title, radio-uuid,
+;; radio-favicon) instead of being written to an .m3u. Covers the pure
+;; station->track builder and the enqueue-and-play buffer mechanics (with
+;; playback mocked at the EMMS boundary).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music-radio--station-track "music-config" (st))
+(declare-function cj/music-radio--enqueue-and-play "music-config" (tracks))
+
+;;; --------------------------- station-track -----------------------------------
+
+(ert-deftest test-music-radio-station-track-normal ()
+ "Normal: a full station plist yields a url track with all three properties."
+ (let ((track (cj/music-radio--station-track
+ '(:name "Adroit Jazz Underground"
+ :url_resolved "https://icecast.walmradio.com:8443/jazz"
+ :stationuuid "ea8059be-d119"
+ :favicon "https://walmradio.com/icon.png"))))
+ (should (eq (emms-track-type track) 'url))
+ (should (equal (emms-track-name track) "https://icecast.walmradio.com:8443/jazz"))
+ (should (equal (emms-track-get track 'info-title) "Adroit Jazz Underground"))
+ (should (equal (emms-track-get track 'radio-uuid) "ea8059be-d119"))
+ (should (equal (emms-track-get track 'radio-favicon) "https://walmradio.com/icon.png"))))
+
+(ert-deftest test-music-radio-station-track-no-url-is-nil ()
+ "Error: a station with no stream URL yields nil, not a broken track."
+ (should-not (cj/music-radio--station-track '(:name "No URL" :url_resolved "" :url ""))))
+
+(ert-deftest test-music-radio-station-track-boundary-no-name ()
+ "Boundary: a nameless station falls back to \"Radio\" for its title."
+ (let ((track (cj/music-radio--station-track '(:url "https://s.example/live"))))
+ (should (equal (emms-track-get track 'info-title) "Radio"))))
+
+(ert-deftest test-music-radio-station-track-boundary-newline-name-stripped ()
+ "Boundary: newlines in an external station name are flattened to spaces."
+ (let ((track (cj/music-radio--station-track
+ '(:name "Line\nBreak" :url "https://s.example/live"))))
+ (should (equal (emms-track-get track 'info-title) "Line Break"))))
+
+(ert-deftest test-music-radio-station-track-boundary-empty-uuid-favicon-absent ()
+ "Boundary: empty-string uuid/favicon are treated as absent, not stored."
+ (let ((track (cj/music-radio--station-track
+ '(:name "X" :url "https://s.example/live" :stationuuid "" :favicon ""))))
+ (should-not (emms-track-get track 'radio-uuid))
+ (should-not (emms-track-get track 'radio-favicon))))
+
+;;; ------------------------- enqueue-and-play ----------------------------------
+
+(defmacro test-music-radio--with-playlist (&rest body)
+ "Run BODY with a fresh, uniquely named playlist buffer and playback mocked."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-radio-enqueue*"))
+ (emms-player-playing-p nil)
+ (started 0) (stopped 0))
+ (unwind-protect
+ (cl-letf (((symbol-function 'emms-start)
+ (lambda () (setq started (1+ started))))
+ ((symbol-function 'emms-stop)
+ (lambda () (setq stopped (1+ stopped)))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-normal-appends-and-plays ()
+ "Normal: tracks land in the playlist buffer in order; the first is selected
+and playback starts."
+ (test-music-radio--with-playlist
+ (let ((t1 (cj/music-radio--station-track '(:name "One" :url "https://one.example/a")))
+ (t2 (cj/music-radio--station-track '(:name "Two" :url "https://two.example/b"))))
+ (cj/music-radio--enqueue-and-play (list t1 t2))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (let ((names '()))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((tr (emms-playlist-track-at (point))))
+ (push (emms-track-name tr) names))
+ (forward-line 1)))
+ (should (equal (nreverse names)
+ '("https://one.example/a" "https://two.example/b"))))
+ (should (equal (emms-track-name (emms-playlist-selected-track))
+ "https://one.example/a")))
+ (should (= started 1)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-boundary-appends-after-existing ()
+ "Boundary: an existing queue is kept; new tracks append and the first NEW
+track is the one selected."
+ (test-music-radio--with-playlist
+ (let ((old (cj/music-radio--station-track '(:name "Old" :url "https://old.example/x")))
+ (new (cj/music-radio--station-track '(:name "New" :url "https://new.example/y"))))
+ (cj/music-radio--enqueue-and-play (list old))
+ (cj/music-radio--enqueue-and-play (list new))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (should (equal (emms-track-name (emms-playlist-selected-track))
+ "https://new.example/y"))))))
+
+(ert-deftest test-music-radio-enqueue-and-play-interrupts-current-playback ()
+ "Normal: when something is playing, enqueue stops it before starting."
+ (test-music-radio--with-playlist
+ (let ((emms-player-playing-p t)
+ (tr (cj/music-radio--station-track '(:name "Z" :url "https://z.example/s"))))
+ (cj/music-radio--enqueue-and-play (list tr))
+ (should (= stopped 1))
+ (should (= started 1)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-boundary-nil-is-no-op ()
+ "Boundary: an empty track list does nothing — no buffer churn, no playback."
+ (test-music-radio--with-playlist
+ (cj/music-radio--enqueue-and-play nil)
+ (should (= started 0))
+ (should (= stopped 0))))
+
+(provide 'test-music-config--radio-station-track)
+;;; test-music-config--radio-station-track.el ends here
diff --git a/tests/test-music-config--radio-tags.el b/tests/test-music-config--radio-tags.el
new file mode 100644
index 00000000..60e1c9d8
--- /dev/null
+++ b/tests/test-music-config--radio-tags.el
@@ -0,0 +1,79 @@
+;;; test-music-config--radio-tags.el --- Tests for radio-browser tag pre-population -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The tag-search prompt completes over the popular tags fetched from
+;; radio-browser's /json/tags endpoint (cached per session). These tests cover
+;; the pure pieces: the endpoint URL, the parse (trim + drop-empty + dedupe,
+;; since the source data is user-generated and dirty), and the session cache.
+;; The network GET is mocked at the boundary.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+(defconst test-music-radio-tags--fixture
+ (concat "[{\"name\":\"jazz \",\"stationcount\":300},"
+ "{\"name\":\" jazz\",\"stationcount\":5},"
+ "{\"name\":\"\",\"stationcount\":2},"
+ "{\"name\":\"rock\",\"stationcount\":100}]")
+ "A recorded /json/tags response with whitespace, empty, and duplicate names.")
+
+;;; Normal Cases
+
+(ert-deftest test-music-radio-tags-url-shape ()
+ "Normal: the tags URL targets /json/tags ordered by station count with the limit."
+ (let* ((cj/music-radio-tag-limit 500)
+ (u (cj/music-radio--tags-url "de1.api.radio-browser.info")))
+ (should (string-match-p "/json/tags" u))
+ (should (string-match-p "order=stationcount" u))
+ (should (string-match-p "limit=500" u))))
+
+(ert-deftest test-music-radio-parse-tags-trims-dedupes-drops-empty ()
+ "Normal: tag names come back trimmed, deduped, and without empties."
+ (should (equal (cj/music-radio--parse-tags test-music-radio-tags--fixture)
+ '("jazz" "rock"))))
+
+(ert-deftest test-music-radio-available-tags-caches-per-session ()
+ "Normal: the fetch runs once; later calls serve the cache."
+ (let ((cj/music-radio--tags-cache nil)
+ (calls 0))
+ (cl-letf (((symbol-function 'cj/music-radio--http-get)
+ (lambda (_url) (cl-incf calls) test-music-radio-tags--fixture)))
+ (should (equal (cj/music-radio--available-tags) '("jazz" "rock")))
+ (should (equal (cj/music-radio--available-tags) '("jazz" "rock")))
+ (should (= calls 1)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-radio-parse-tags-empty-array ()
+ "Boundary: an empty tag array parses to nil."
+ (should-not (cj/music-radio--parse-tags "[]")))
+
+;;; Error Cases
+
+(ert-deftest test-music-radio-available-tags-fetch-failure-returns-nil-and-retries ()
+ "Error: a failed fetch yields nil, leaves the cache empty, and retries next call."
+ (let ((cj/music-radio--tags-cache nil)
+ (calls 0))
+ (cl-letf (((symbol-function 'cj/music-radio--http-get)
+ (lambda (_url) (cl-incf calls) nil)))
+ (should-not (cj/music-radio--available-tags))
+ (should-not cj/music-radio--tags-cache)
+ (should-not (cj/music-radio--available-tags))
+ (should (= calls 2)))))
+
+(ert-deftest test-music-radio-parse-tags-malformed-user-errors ()
+ "Error: a non-JSON body signals user-error, not a raw parse error."
+ (should-error (cj/music-radio--parse-tags "<html>502</html>")
+ :type 'user-error))
+
+(provide 'test-music-config--radio-tags)
+;;; test-music-config--radio-tags.el ends here
diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el
new file mode 100644
index 00000000..a91612bf
--- /dev/null
+++ b/tests/test-music-config--radio.el
@@ -0,0 +1,207 @@
+;;; test-music-config--radio.el --- radio-browser lookup pure-logic tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The pure pieces behind the radio-browser search command (spec
+;; docs/specs/2026-07-06-radio-browser-lookup-spec.org): parsing a recorded
+;; JSON response, picking a station's stream URL, formatting the marginalia
+;; annotation (Variant B: codec/bitrate/country/votes/tags), and building the
+;; search URL. The station->track builder and the queue mechanics live in
+;; test-music-config--radio-station-track.el; the network GET and the
+;; interactive command are exercised in the daemon, not here.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Declare special here too (the module's bare defvar is file-local) so the
+;; registration test's `let' binds dynamically.
+(defvar marginalia-annotator-registry)
+
+(require 'music-config)
+
+(declare-function cj/music-radio--parse-search "music-config" (json-text))
+(declare-function cj/music-radio--station-url "music-config" (st))
+(declare-function cj/music-radio--tags-snippet "music-config" (tags n))
+(declare-function cj/music-radio--format-candidate "music-config" (st))
+(declare-function cj/music-radio--search-url "music-config" (server query))
+
+(defconst test-music-radio--fixture
+ (concat "[{\"stationuuid\":\"ea8059be-d119-4de3-b27b-0d9bd6aedb17\","
+ "\"name\":\"Adroit Jazz Underground\",\"url_resolved\":\"https://icecast.walmradio.com:8443/jazz\","
+ "\"codec\":\"MP3\",\"bitrate\":320,\"countrycode\":\"US\",\"votes\":174208,\"tags\":\"bebop,hard bop,cool\"},"
+ "{\"stationuuid\":\"00000000-no-url\",\"name\":\"No URL Station\",\"url_resolved\":\"\",\"url\":\"\","
+ "\"codec\":\"AAC\",\"bitrate\":0,\"countrycode\":\"FR\",\"votes\":5,\"tags\":\"\"}]")
+ "A two-station recorded radio-browser search response.")
+
+(defun test-music-radio--first ()
+ "First station plist from the fixture."
+ (car (cj/music-radio--parse-search test-music-radio--fixture)))
+
+;;; --------------------------- parse-search -----------------------------------
+
+(ert-deftest test-music-radio-parse-search-normal ()
+ "Normal: a recorded response parses to station plists with the expected fields."
+ (let ((stations (cj/music-radio--parse-search test-music-radio--fixture)))
+ (should (= (length stations) 2))
+ (should (equal (plist-get (car stations) :name) "Adroit Jazz Underground"))
+ (should (equal (plist-get (car stations) :stationuuid)
+ "ea8059be-d119-4de3-b27b-0d9bd6aedb17"))))
+
+(ert-deftest test-music-radio-parse-search-empty ()
+ "Boundary: an empty result array parses to nil."
+ (should-not (cj/music-radio--parse-search "[]")))
+
+(ert-deftest test-music-radio-parse-search-malformed-user-errors ()
+ "Error: a non-JSON body (a gateway page) signals user-error, not a raw parse error."
+ (should-error (cj/music-radio--parse-search "<html>502 Bad Gateway</html>")
+ :type 'user-error))
+
+;;; --------------------------- station-url ------------------------------------
+
+(ert-deftest test-music-radio-station-url-resolved ()
+ "Normal: url_resolved wins when present."
+ (should (equal (cj/music-radio--station-url (test-music-radio--first))
+ "https://icecast.walmradio.com:8443/jazz")))
+
+(ert-deftest test-music-radio-station-url-fallback-to-url ()
+ "Boundary: an empty url_resolved falls back to url."
+ (should (equal (cj/music-radio--station-url
+ '(:url_resolved "" :url "http://fallback.test/stream"))
+ "http://fallback.test/stream")))
+
+(ert-deftest test-music-radio-station-url-none ()
+ "Error: neither url_resolved nor url yields nil."
+ (should-not (cj/music-radio--station-url '(:url_resolved "" :url ""))))
+
+;;; --------------------------- tags-snippet -----------------------------------
+
+(ert-deftest test-music-radio-tags-snippet-takes-first-n ()
+ "Normal: the first N comma-separated tags render trimmed."
+ (should (equal (cj/music-radio--tags-snippet "bebop, hard bop, cool, free jazz" 3)
+ "bebop, hard bop, cool")))
+
+(ert-deftest test-music-radio-tags-snippet-empty ()
+ "Boundary: empty or nil tags render as the empty string."
+ (should (equal (cj/music-radio--tags-snippet "" 3) ""))
+ (should (equal (cj/music-radio--tags-snippet nil 3) "")))
+
+;;; --------------------------- format-candidate (Variant B) -------------------
+
+(ert-deftest test-music-radio-format-candidate-variant-b ()
+ "Normal: the annotation carries codec, votes, and tags (Variant B)."
+ (let ((ann (cj/music-radio--format-candidate (test-music-radio--first))))
+ (should (string-match-p "MP3" ann))
+ (should (string-match-p "174208" ann))
+ (should (string-match-p "bebop" ann))))
+
+;;; --------------------------- search-url -------------------------------------
+
+(ert-deftest test-music-radio-search-url-encodes-query-and-limit ()
+ "Normal: the search URL hex-encodes the query and carries the limit; name is the default field."
+ (let* ((cj/music-radio-search-limit 30)
+ (u (cj/music-radio--search-url "de1.api.radio-browser.info" "smooth jazz")))
+ (should (string-match-p "name=smooth%20jazz" u))
+ (should (string-match-p "limit=30" u))
+ (should (string-match-p "/json/stations/search" u))))
+
+(ert-deftest test-music-radio-search-url-tag-field ()
+ "Normal: field \"tag\" searches the tag= parameter instead of name=."
+ (let ((u (cj/music-radio--search-url "de1.api.radio-browser.info" "ambient" "tag")))
+ (should (string-match-p "tag=ambient" u))
+ (should-not (string-match-p "name=ambient" u))))
+
+(declare-function cj/music-radio--candidates "music-config" (stations))
+
+;;; --------------------------- candidates (dedup) -----------------------------
+
+(ert-deftest test-music-radio-candidates-distinct ()
+ "Normal: distinct station names produce distinct display keys mapping to their stations."
+ (let* ((stations '((:name "Jazz Radio" :codec "MP3" :bitrate 128)
+ (:name "Blues FM" :codec "AAC" :bitrate 64)))
+ (cands (cj/music-radio--candidates stations)))
+ (should (= (length cands) 2))
+ (should (assoc "Jazz Radio" cands))
+ (should (assoc "Blues FM" cands))))
+
+(ert-deftest test-music-radio-candidates-same-name-disambiguated ()
+ "Boundary: two stations with the same name get distinct display keys."
+ (let* ((stations '((:name "Jazz Radio" :codec "MP3" :bitrate 128 :stationuuid "a")
+ (:name "Jazz Radio" :codec "OGG" :bitrate 192 :stationuuid "b")))
+ (cands (cj/music-radio--candidates stations))
+ (keys (mapcar #'car cands)))
+ (should (= (length cands) 2))
+ (should (= (length (delete-dups (copy-sequence keys))) 2))))
+
+;;; --------------------------- query whitespace --------------------------------
+
+(ert-deftest test-music-radio-search-and-play-trims-query ()
+ "Normal: surrounding whitespace on the query is stripped before the search.
+A trailing space in the minibuffer otherwise reaches the API as %20 and
+matches nothing."
+ (let (captured)
+ (cl-letf (((symbol-function 'cj/emms--setup) #'ignore)
+ ((symbol-function 'cj/music-radio--search)
+ (lambda (query _field) (setq captured query) nil)))
+ (should-error (cj/music-radio--search-and-play " jazz " "tag")
+ :type 'user-error))
+ (should (equal captured "jazz"))))
+
+(ert-deftest test-music-radio-search-and-play-whitespace-only-no-search ()
+ "Error: a whitespace-only query errors out before any network search."
+ (let (searched)
+ (cl-letf (((symbol-function 'cj/emms--setup) #'ignore)
+ ((symbol-function 'cj/music-radio--search)
+ (lambda (&rest _) (setq searched t) nil)))
+ (should-error (cj/music-radio--search-and-play " " "tag")
+ :type 'user-error))
+ (should-not searched)))
+
+;;; --------------------------- column alignment --------------------------------
+
+(ert-deftest test-music-radio-format-candidate-votes-column-fixed-width ()
+ "Normal: the votes field pads to a fixed width so the tags column aligns
+across stations with different vote counts."
+ (let* ((low (cj/music-radio--format-candidate
+ '(:codec "MP3" :bitrate 128 :countrycode "US" :votes 7 :tags "jazz")))
+ (high (cj/music-radio--format-candidate
+ '(:codec "MP3" :bitrate 128 :countrycode "US" :votes 174208 :tags "jazz"))))
+ (should (= (string-match "jazz" low) (string-match "jazz" high)))))
+
+(ert-deftest test-music-radio-completion-table-annotates-station ()
+ "Normal: the table's annotation function returns the Variant-B string for
+a station candidate (marginalia handles the right-alignment)."
+ (let* ((candidates '(("Jazz FM" . (:codec "MP3" :bitrate 128 :countrycode "US"
+ :votes 5 :tags "jazz"))))
+ (table (cj/music-radio--completion-table candidates))
+ (meta (funcall table "" nil 'metadata))
+ (annotate (alist-get 'annotation-function (cdr meta))))
+ (should (functionp annotate))
+ (should-not (alist-get 'affixation-function (cdr meta)))
+ (should (string-match-p "MP3" (funcall annotate "Jazz FM")))
+ (should (string-match-p "jazz" (funcall annotate "Jazz FM")))))
+
+(ert-deftest test-music-radio-completion-table-done-sentinel-no-annotation ()
+ "Boundary: the [done] sentinel has no station and annotates as nil."
+ (let* ((candidates '(("[done]") ("Station" . (:codec "MP3" :bitrate 128
+ :countrycode "US" :votes 1 :tags "x"))))
+ (table (cj/music-radio--completion-table candidates))
+ (annotate (alist-get 'annotation-function
+ (cdr (funcall table "" nil 'metadata)))))
+ (should-not (funcall annotate "[done]"))))
+
+(ert-deftest test-music-radio-completion-table-registers-with-marginalia ()
+ "Normal: building the table registers cj-radio-station so marginalia
+right-aligns the table's own annotations."
+ (let ((marginalia-annotator-registry '()))
+ (cj/music-radio--completion-table '(("X" . (:codec "MP3"))))
+ (should (equal (assq 'cj-radio-station marginalia-annotator-registry)
+ '(cj-radio-station builtin none)))))
+
+(provide 'test-music-config--radio)
+;;; test-music-config--radio.el ends here
diff --git a/tests/test-music-config--renumber-rows.el b/tests/test-music-config--renumber-rows.el
new file mode 100644
index 00000000..d5bc5141
--- /dev/null
+++ b/tests/test-music-config--renumber-rows.el
@@ -0,0 +1,232 @@
+;;; test-music-config--renumber-rows.el --- Tests for playlist row numbering -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Playlist rows carry a numeric overlay prefix so the cursor stays visible
+;; when it sits on a cover-art thumbnail and each row's position in the list
+;; is readable. The renumber walks the buffer and rebuilds the overlays; a
+;; buffer-local after-change hook debounces it behind an idle timer. Overlays
+;; leave the buffer text untouched (EMMS owns it), so these tests drive plain
+;; temp buffers.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'music-config)
+
+(defun test-music-renumber--numbers (buffer)
+ "Return the overlay number strings in BUFFER, in position order."
+ (with-current-buffer buffer
+ (mapcar (lambda (ov) (overlay-get ov 'before-string))
+ (sort (seq-filter (lambda (ov) (overlay-get ov 'cj-music-row-number))
+ (overlays-in (point-min) (point-max)))
+ (lambda (a b) (< (overlay-start a) (overlay-start b)))))))
+
+;;; Normal Cases
+
+(ert-deftest test-music-renumber-rows-numbers-each-line ()
+ "Normal: every non-blank line gets a sequential number overlay."
+ (with-temp-buffer
+ (insert "track one\ntrack two\ntrack three\n")
+ (cj/music--renumber-rows (current-buffer))
+ (should (equal (mapcar #'substring-no-properties
+ (test-music-renumber--numbers (current-buffer)))
+ '(" 1 " " 2 " " 3 ")))))
+
+(ert-deftest test-music-renumber-rows-idempotent ()
+ "Normal: renumbering twice leaves one overlay per line, not two."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (cj/music--renumber-rows (current-buffer))
+ (cj/music--renumber-rows (current-buffer))
+ (should (= 2 (length (test-music-renumber--numbers (current-buffer)))))))
+
+(ert-deftest test-music-renumber-rows-number-carries-cursor-property ()
+ "Normal: the number string carries a cursor property. Point is pinned at
+the row start, and without the property redisplay draws the cursor after
+the before-string -- on the album-art thumbnail, where it's invisible."
+ (with-temp-buffer
+ (insert "track one\n")
+ (cj/music--renumber-rows (current-buffer))
+ (let ((s (car (test-music-renumber--numbers (current-buffer)))))
+ (should (get-text-property 0 'cursor s)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-renumber-rows-skips-blank-lines ()
+ "Boundary: blank lines are not numbered and don't advance the count."
+ (with-temp-buffer
+ (insert "track one\n\ntrack two\n")
+ (cj/music--renumber-rows (current-buffer))
+ (should (equal (mapcar #'substring-no-properties
+ (test-music-renumber--numbers (current-buffer)))
+ '(" 1 " " 2 ")))))
+
+(ert-deftest test-music-renumber-rows-empty-buffer-no-overlays ()
+ "Boundary: an empty buffer gets no overlays and no error."
+ (with-temp-buffer
+ (cj/music--renumber-rows (current-buffer))
+ (should-not (test-music-renumber--numbers (current-buffer)))))
+
+;;; Error Cases
+
+(ert-deftest test-music-renumber-rows-dead-buffer-noop ()
+ "Error: renumbering a killed buffer is a silent no-op (the debounce timer
+can fire after the playlist buffer is gone)."
+ (let ((buf (generate-new-buffer " *test-renumber-dead*")))
+ (kill-buffer buf)
+ (should-not (cj/music--renumber-rows buf))))
+
+(ert-deftest test-music-renumber-rows-number-outranks-header-overlay ()
+ "Normal: number overlays carry a priority above the header overlay's 100.
+The header block is a same-position overlay string at the buffer start;
+without the higher priority, row 1's number renders above the header
+instead of next to its own track."
+ (with-temp-buffer
+ (insert "track one\n")
+ (cj/music--renumber-rows (current-buffer))
+ (let ((ov (car (seq-filter (lambda (o) (overlay-get o 'cj-music-row-number))
+ (overlays-in (point-min) (point-max))))))
+ (should (> (or (overlay-get ov 'priority) 0) 100)))))
+
+(ert-deftest test-music-ensure-playlist-buffer-logical-line-motion ()
+ "Normal: the playlist moves by logical lines, not screen lines. The
+multi-line header overlay string at position 1 otherwise absorbs every
+next-line from the top row -- vertical motion steps through the header's
+display and maps back to the same buffer position, so arrows look dead."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should (local-variable-p 'line-move-visual))
+ (should-not line-move-visual)))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+;;; Current-row indicator
+
+(ert-deftest test-music-highlight-current-number-marks-current-row ()
+ "Normal: the current row's number renders inverse-video; moving to another
+row restores the old one and marks the new one. The block cursor only
+draws in the selected window, so the number itself carries the mark."
+ (with-temp-buffer
+ (insert "track one\ntrack two\ntrack three\n")
+ (cj/music--renumber-rows (current-buffer))
+ (goto-char (point-min))
+ (forward-line 1)
+ (cj/music--highlight-current-number)
+ (let ((numbers (test-music-renumber--numbers (current-buffer))))
+ (should-not (plist-get (get-text-property 0 'face (nth 0 numbers)) :inverse-video))
+ (should (plist-get (get-text-property 0 'face (nth 1 numbers)) :inverse-video)))
+ (forward-line 1)
+ (cj/music--highlight-current-number)
+ (let ((numbers (test-music-renumber--numbers (current-buffer))))
+ (should-not (plist-get (get-text-property 0 'face (nth 1 numbers)) :inverse-video))
+ (should (plist-get (get-text-property 0 'face (nth 2 numbers)) :inverse-video)))))
+
+(ert-deftest test-music-highlight-current-number-survives-renumber ()
+ "Boundary: a renumber rebuilds the overlays; the highlight re-applies to
+the current row rather than pointing at a dead overlay."
+ (with-temp-buffer
+ (insert "track one\ntrack two\n")
+ (cj/music--renumber-rows (current-buffer))
+ (goto-char (point-min))
+ (forward-line 1)
+ (cj/music--highlight-current-number)
+ (cj/music--renumber-rows (current-buffer))
+ (let ((numbers (test-music-renumber--numbers (current-buffer))))
+ (should (plist-get (get-text-property 0 'face (nth 1 numbers)) :inverse-video)))))
+
+(ert-deftest test-music-highlight-current-number-keeps-cursor-property ()
+ "Boundary: re-facing a number keeps the cursor property intact."
+ (with-temp-buffer
+ (insert "track one\n")
+ (cj/music--renumber-rows (current-buffer))
+ (goto-char (point-min))
+ (cj/music--highlight-current-number)
+ (let ((s (car (test-music-renumber--numbers (current-buffer)))))
+ (should (get-text-property 0 'cursor s)))))
+
+(ert-deftest test-music-ensure-playlist-buffer-sticky-hl-line ()
+ "Normal: hl-line in the playlist stays visible when the window isn't
+selected -- the dock is glanced at from other windows constantly."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (should (buffer-local-value 'hl-line-sticky-flag created)))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+;;; Sticky header
+
+(ert-deftest test-music-stick-header-moves-overlay-to-window-start ()
+ "Normal: a scroll re-anchors the header overlay at the new window start,
+so the header block stays at the top of the window while the list scrolls."
+ (with-temp-buffer
+ (insert "track one\ntrack two\ntrack three\ntrack four\n")
+ (setq cj/music--header-overlay (make-overlay (point-min) (point-min)))
+ (save-window-excursion
+ (set-window-buffer (selected-window) (current-buffer))
+ (let ((start (save-excursion (goto-char (point-min)) (forward-line 2) (point))))
+ (cj/music--stick-header (selected-window) start)
+ (should (= (overlay-start cj/music--header-overlay) start))
+ ;; Converges: the same start again is a no-op, not a loop.
+ (cj/music--stick-header (selected-window) start)
+ (should (= (overlay-start cj/music--header-overlay) start))))))
+
+(ert-deftest test-music-stick-header-no-overlay-noop ()
+ "Boundary: no header overlay yet -- the scroll handler is a silent no-op."
+ (with-temp-buffer
+ (insert "track one\n")
+ (setq cj/music--header-overlay nil)
+ (save-window-excursion
+ (set-window-buffer (selected-window) (current-buffer))
+ (should-not (cj/music--stick-header (selected-window) (point-min))))))
+
+(ert-deftest test-music-header-anchor-position-displayed-vs-not ()
+ "Normal: the header anchors at the displaying window's start; an
+undisplayed buffer anchors at the top."
+ (with-temp-buffer
+ (insert "track one\ntrack two\ntrack three\ntrack four\n")
+ (save-window-excursion
+ (set-window-buffer (selected-window) (current-buffer))
+ (let ((start (save-excursion (goto-char (point-min)) (forward-line 2) (point))))
+ (set-window-start (selected-window) start)
+ (should (= (cj/music--header-anchor-position) start))))
+ ;; Not displayed after the excursion restores the old config.
+ (should (= (cj/music--header-anchor-position) (point-min)))))
+
+(ert-deftest test-music-ensure-playlist-buffer-wires-scroll-hook ()
+ "Normal: the playlist buffer re-sticks its header on every window scroll."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should (member #'cj/music--stick-header window-scroll-functions))))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+;;; Hook wiring
+
+(ert-deftest test-music-renumber-ensure-playlist-buffer-wires-hook ()
+ "Normal: the playlist buffer gets the debounced renumber on after-change."
+ (let (created)
+ (cl-letf (((symbol-function 'emms-playlist-mode) #'ignore))
+ (unwind-protect
+ (progn
+ (setq created (cj/music--ensure-playlist-buffer))
+ (with-current-buffer created
+ (should (member #'cj/music--schedule-renumber after-change-functions))))
+ (when (buffer-live-p created) (kill-buffer created))))))
+
+(provide 'test-music-config--renumber-rows)
+;;; test-music-config--renumber-rows.el ends here
diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el
deleted file mode 100644
index 8105ee15..00000000
--- a/tests/test-music-config--safe-filename.el
+++ /dev/null
@@ -1,97 +0,0 @@
-;;; test-music-config--safe-filename.el --- Tests for filename sanitization -*- coding: utf-8; lexical-binding: t; -*-
-;;
-;; Author: Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;; Unit tests for cj/music--safe-filename function.
-;; Tests the pure helper that sanitizes filenames by replacing invalid chars.
-;;
-;; Test organization:
-;; - Normal Cases: Valid filenames unchanged, spaces replaced
-;; - Boundary Cases: Special chars, unicode, slashes, consecutive invalid chars
-;; - Error Cases: Nil input
-;;
-;;; Code:
-
-(require 'ert)
-
-;; Stub missing dependencies before loading music-config
-(defvar-keymap cj/custom-keymap
- :doc "Stub keymap for testing")
-
-;; Load production code
-(require 'music-config)
-
-;;; Normal Cases
-
-(ert-deftest test-music-config--safe-filename-normal-alphanumeric-unchanged ()
- "Validate alphanumeric filename remains unchanged."
- (should (string= (cj/music--safe-filename "MyPlaylist123")
- "MyPlaylist123")))
-
-(ert-deftest test-music-config--safe-filename-normal-with-hyphens-unchanged ()
- "Validate filename with hyphens remains unchanged."
- (should (string= (cj/music--safe-filename "my-playlist-name")
- "my-playlist-name")))
-
-(ert-deftest test-music-config--safe-filename-normal-with-underscores-unchanged ()
- "Validate filename with underscores remains unchanged."
- (should (string= (cj/music--safe-filename "my_playlist_name")
- "my_playlist_name")))
-
-(ert-deftest test-music-config--safe-filename-normal-spaces-replaced ()
- "Validate spaces are replaced with underscores."
- (should (string= (cj/music--safe-filename "My Favorite Songs")
- "My_Favorite_Songs")))
-
-;;; Boundary Cases
-
-(ert-deftest test-music-config--safe-filename-boundary-special-chars-replaced ()
- "Validate special characters are replaced with underscores."
- (should (string= (cj/music--safe-filename "playlist@#$%^&*()")
- "playlist_________")))
-
-(ert-deftest test-music-config--safe-filename-boundary-unicode-replaced ()
- "Validate unicode characters are replaced with underscores."
- (should (string= (cj/music--safe-filename "中文歌曲")
- "____")))
-
-(ert-deftest test-music-config--safe-filename-boundary-mixed-valid-invalid ()
- "Validate mixed valid and invalid characters."
- (should (string= (cj/music--safe-filename "Rock & Roll")
- "Rock___Roll")))
-
-(ert-deftest test-music-config--safe-filename-boundary-dots-replaced ()
- "Validate dots are replaced with underscores."
- (should (string= (cj/music--safe-filename "my.playlist.name")
- "my_playlist_name")))
-
-(ert-deftest test-music-config--safe-filename-boundary-slashes-replaced ()
- "Validate slashes are replaced with underscores."
- (should (string= (cj/music--safe-filename "folder/file")
- "folder_file")))
-
-(ert-deftest test-music-config--safe-filename-boundary-consecutive-invalid-chars ()
- "Validate consecutive invalid characters each become underscores."
- (should (string= (cj/music--safe-filename "test!!!name")
- "test___name")))
-
-(ert-deftest test-music-config--safe-filename-boundary-empty-string-unchanged ()
- "Validate empty string remains unchanged."
- (should (string= (cj/music--safe-filename "")
- "")))
-
-(ert-deftest test-music-config--safe-filename-boundary-only-invalid-chars ()
- "Validate string with only invalid characters becomes all underscores."
- (should (string= (cj/music--safe-filename "!@#$%")
- "_____")))
-
-;;; Error Cases
-
-(ert-deftest test-music-config--safe-filename-error-nil-input-signals-error ()
- "Validate nil input signals error."
- (should-error (cj/music--safe-filename nil)
- :type 'wrong-type-argument))
-
-(provide 'test-music-config--safe-filename)
-;;; test-music-config--safe-filename.el ends here
diff --git a/tests/test-music-config--save-helpers.el b/tests/test-music-config--save-helpers.el
new file mode 100644
index 00000000..ffb0a477
--- /dev/null
+++ b/tests/test-music-config--save-helpers.el
@@ -0,0 +1,90 @@
+;;; test-music-config--save-helpers.el --- save default-name + directory tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The pure helpers behind the queue-first save flow: which name the save
+;; prompt pre-fills (`cj/music--save-default-name') and which directory the
+;; file lands in (`cj/music--save-directory'). An all-stream queue saves into
+;; the radio playlist home (`cj/music-radio-save-dir'); anything else saves
+;; into `cj/music-m3u-root'.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(declare-function cj/music--save-default-name "music-config" (tracks file entries))
+(declare-function cj/music--save-directory "music-config" (tracks))
+(declare-function cj/music-radio--station-track "music-config" (st))
+
+;;; --------------------------- save-default-name -------------------------------
+
+(ert-deftest test-music-save-default-name-normal-associated-file-wins ()
+ "Normal: an associated playlist file names the save, station or not."
+ (let ((tr (cj/music-radio--station-track '(:name "S" :url "https://s.example/x"))))
+ (should (equal (cj/music--save-default-name (list tr) "/pl/jazz.m3u" nil)
+ "jazz"))))
+
+(ert-deftest test-music-save-default-name-normal-station-title ()
+ "Normal: with no file, the first url track's station name pre-fills."
+ (let ((tr (cj/music-radio--station-track
+ '(:name "Groove Salad" :url "https://gs.example/x"))))
+ (should (equal (cj/music--save-default-name (list tr) nil nil)
+ "Groove Salad"))))
+
+(ert-deftest test-music-save-default-name-normal-first-url-track-wins ()
+ "Normal: a file track ahead of the station doesn't block the station name;
+the first URL track with a name wins."
+ (let ((f (emms-track 'file "/music/a.flac"))
+ (tr (cj/music-radio--station-track
+ '(:name "Second Pick" :url "https://sp.example/x"))))
+ (should (equal (cj/music--save-default-name (list f tr) nil nil)
+ "Second Pick"))))
+
+(ert-deftest test-music-save-default-name-normal-entries-fallback ()
+ "Normal: a propertyless url track resolves its name from ENTRIES."
+ (let* ((url "https://legacy.example/s")
+ (tr (emms-track 'url url))
+ (entries (list (list url :name "Legacy FM" :uuid nil :favicon nil))))
+ (should (equal (cj/music--save-default-name (list tr) nil entries)
+ "Legacy FM"))))
+
+(ert-deftest test-music-save-default-name-boundary-no-candidates-nil ()
+ "Boundary: no file, no url tracks -> nil (caller falls back to a timestamp)."
+ (should-not (cj/music--save-default-name
+ (list (emms-track 'file "/music/a.flac")) nil nil))
+ (should-not (cj/music--save-default-name nil nil nil)))
+
+;;; ---------------------------- save-directory ---------------------------------
+
+(ert-deftest test-music-save-directory-normal-all-streams-radio-dir ()
+ "Normal: an all-stream queue saves into the radio playlist dir."
+ (let ((u1 (emms-track 'url "https://a.example/1"))
+ (u2 (emms-track 'url "https://b.example/2")))
+ (should (equal (cj/music--save-directory (list u1 u2))
+ cj/music-radio-save-dir))))
+
+(ert-deftest test-music-save-directory-normal-mixed-goes-to-m3u-root ()
+ "Normal: any non-stream track routes the save to the music playlist root."
+ (let ((u (emms-track 'url "https://a.example/1"))
+ (f (emms-track 'file "/music/a.flac")))
+ (should (equal (cj/music--save-directory (list u f))
+ cj/music-m3u-root))))
+
+(ert-deftest test-music-save-directory-boundary-empty-goes-to-m3u-root ()
+ "Boundary: an empty queue defaults to the music playlist root."
+ (should (equal (cj/music--save-directory nil) cj/music-m3u-root)))
+
+(provide 'test-music-config--save-helpers)
+;;; test-music-config--save-helpers.el ends here
diff --git a/tests/test-music-config--tidy-host.el b/tests/test-music-config--tidy-host.el
new file mode 100644
index 00000000..92b104a6
--- /dev/null
+++ b/tests/test-music-config--tidy-host.el
@@ -0,0 +1,47 @@
+;;; test-music-config--tidy-host.el --- Tests for stream-URL host tidying -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--tidy-host': reduce a stream URL to a readable host
+;; label (scheme dropped, a leading "www." removed), used as the last-resort
+;; display name for a url track with no #EXTINF label.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
+(require 'music-config)
+
+(ert-deftest test-music-config--tidy-host-normal ()
+ "Normal: scheme and path are dropped, host kept."
+ (should (string= (cj/music--tidy-host "https://ice6.somafm.com/groovesalad-256-mp3")
+ "somafm.com")))
+
+(ert-deftest test-music-config--tidy-host-normal-http ()
+ "Normal: plain http host with a port keeps the host, drops the port."
+ (should (string= (cj/music--tidy-host "http://stream.example.org:8000/live")
+ "example.org")))
+
+(ert-deftest test-music-config--tidy-host-boundary-strip-www ()
+ "Boundary: a leading www. is stripped."
+ (should (string= (cj/music--tidy-host "https://www.radioparadise.com/m3u/mp3-128.m3u")
+ "radioparadise.com")))
+
+(ert-deftest test-music-config--tidy-host-boundary-bare-domain ()
+ "Boundary: a two-label domain is returned unchanged."
+ (should (string= (cj/music--tidy-host "http://somafm.com/") "somafm.com")))
+
+(ert-deftest test-music-config--tidy-host-error-not-a-url ()
+ "Error: a non-URL string is returned as-is rather than erroring."
+ (should (string= (cj/music--tidy-host "not a url") "not a url")))
+
+(provide 'test-music-config--tidy-host)
+;;; test-music-config--tidy-host.el ends here
diff --git a/tests/test-music-config--track-description.el b/tests/test-music-config--track-description.el
deleted file mode 100644
index a1a1cc6d..00000000
--- a/tests/test-music-config--track-description.el
+++ /dev/null
@@ -1,181 +0,0 @@
-;;; test-music-config--track-description.el --- Tests for track description -*- coding: utf-8; lexical-binding: t; -*-
-;;
-;; Author: Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;; Unit tests for cj/music--track-description function.
-;; Tests the custom track description that replaces EMMS's default file-path display
-;; with human-readable formats based on track type and available metadata.
-;;
-;; Track construction: EMMS tracks are alists created with `emms-track' and
-;; populated with `emms-track-set'. No playlist buffer or player state needed.
-;;
-;; Test organization:
-;; - Normal Cases: Tagged tracks (artist+title+duration), partial metadata, file fallback, URL
-;; - Boundary Cases: Empty strings, missing fields, special characters, long names
-;; - Error Cases: Unknown track type fallback
-;;
-;;; Code:
-
-(require 'ert)
-
-;; Stub missing dependencies before loading music-config
-(defvar-keymap cj/custom-keymap
- :doc "Stub keymap for testing")
-
-;; Add EMMS elpa directory to load path for batch testing
-(let ((emms-dir (car (file-expand-wildcards
- (expand-file-name "elpa/emms-*" user-emacs-directory)))))
- (when emms-dir
- (add-to-list 'load-path emms-dir)))
-
-(require 'emms)
-(require 'emms-playlist-mode)
-(require 'music-config)
-
-;;; Test helpers
-
-(defun test-track-description--make-file-track (path &optional title artist duration)
- "Create a file TRACK with PATH and optional metadata TITLE, ARTIST, DURATION."
- (let ((track (emms-track 'file path)))
- (when title (emms-track-set track 'info-title title))
- (when artist (emms-track-set track 'info-artist artist))
- (when duration (emms-track-set track 'info-playing-time duration))
- track))
-
-(defun test-track-description--make-url-track (url &optional title artist duration)
- "Create a URL TRACK with URL and optional metadata TITLE, ARTIST, DURATION."
- (let ((track (emms-track 'url url)))
- (when title (emms-track-set track 'info-title title))
- (when artist (emms-track-set track 'info-artist artist))
- (when duration (emms-track-set track 'info-playing-time duration))
- track))
-
-;;; Normal Cases — Tagged tracks (artist + title + duration)
-
-(ert-deftest test-music-config--track-description-normal-full-metadata ()
- "Validate track with artist, title, and duration shows all three."
- (let ((track (test-track-description--make-file-track
- "/music/Kind of Blue/01 - So What.flac"
- "So What" "Miles Davis" 562)))
- (should (string= (cj/music--track-description track)
- "Miles Davis - So What [9:22]"))))
-
-(ert-deftest test-music-config--track-description-normal-title-and-artist-no-duration ()
- "Validate track with artist and title but no duration omits bracket."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-nodur.mp3" "Blue in Green" "Miles Davis")))
- (should (string= (cj/music--track-description track)
- "Miles Davis - Blue in Green"))))
-
-(ert-deftest test-music-config--track-description-normal-title-only ()
- "Validate track with title but no artist shows title alone."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-noartist.mp3" "Flamenco Sketches" nil 566)))
- (should (string= (cj/music--track-description track)
- "Flamenco Sketches [9:26]"))))
-
-(ert-deftest test-music-config--track-description-normal-title-only-no-duration ()
- "Validate track with only title shows just the title."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-titleonly.mp3" "All Blues")))
- (should (string= (cj/music--track-description track)
- "All Blues"))))
-
-;;; Normal Cases — File tracks without tags
-
-(ert-deftest test-music-config--track-description-normal-file-no-tags ()
- "Validate untagged file shows filename without path or extension."
- (let ((track (test-track-description--make-file-track
- "/music/Kind of Blue/02 - Freddie Freeloader.flac")))
- (should (string= (cj/music--track-description track)
- "02 - Freddie Freeloader"))))
-
-(ert-deftest test-music-config--track-description-normal-file-nested-path ()
- "Validate deeply nested path still shows only the filename."
- (let ((track (test-track-description--make-file-track
- "/music/Jazz/Miles Davis/Kind of Blue/01 - So What.mp3")))
- (should (string= (cj/music--track-description track)
- "01 - So What"))))
-
-;;; Normal Cases — URL tracks
-
-(ert-deftest test-music-config--track-description-normal-url-plain ()
- "Validate plain URL is shown as-is."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/stream")))
- (should (string= (cj/music--track-description track)
- "https://radio.example.com/stream"))))
-
-(ert-deftest test-music-config--track-description-normal-url-percent-encoded ()
- "Validate percent-encoded URL characters are decoded."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/my%20station%21")))
- (should (string= (cj/music--track-description track)
- "https://radio.example.com/my station!"))))
-
-(ert-deftest test-music-config--track-description-normal-url-with-tags ()
- "Validate URL track with tags uses tag display, not URL."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/stream"
- "Jazz FM" "Radio Station" 0)))
- ;; Duration 0 → nil from format-duration, so no bracket
- (should (string= (cj/music--track-description track)
- "Radio Station - Jazz FM"))))
-
-;;; Boundary Cases
-
-(ert-deftest test-music-config--track-description-boundary-empty-title-string ()
- "Validate empty title string is still truthy, shows empty result."
- (let ((track (test-track-description--make-file-track
- "/music/track.mp3" "" "Artist")))
- ;; Empty string is non-nil, so title branch is taken
- (should (string= (cj/music--track-description track)
- "Artist - "))))
-
-(ert-deftest test-music-config--track-description-boundary-file-no-extension ()
- "Validate file without extension shows full filename."
- (let ((track (test-track-description--make-file-track "/music/README")))
- (should (string= (cj/music--track-description track)
- "README"))))
-
-(ert-deftest test-music-config--track-description-boundary-file-multiple-dots ()
- "Validate file with multiple dots strips only the final extension."
- (let ((track (test-track-description--make-file-track
- "/music/disc.1.track.03.flac")))
- (should (string= (cj/music--track-description track)
- "disc.1.track.03"))))
-
-(ert-deftest test-music-config--track-description-boundary-unicode-title ()
- "Validate unicode characters in metadata are preserved."
- (let ((track (test-track-description--make-file-track
- "/music/track.mp3" "夜に駆ける" "YOASOBI" 258)))
- (should (string= (cj/music--track-description track)
- "YOASOBI - 夜に駆ける [4:18]"))))
-
-(ert-deftest test-music-config--track-description-boundary-url-utf8-percent-encoded ()
- "Validate percent-encoded UTF-8 in URL is decoded correctly."
- (let ((track (test-track-description--make-url-track
- "https://example.com/caf%C3%A9")))
- (should (string= (cj/music--track-description track)
- "https://example.com/café"))))
-
-(ert-deftest test-music-config--track-description-boundary-short-duration ()
- "Validate 1-second track formats correctly in bracket."
- (let ((track (test-track-description--make-file-track
- "/music/t.mp3" "Beep" nil 1)))
- (should (string= (cj/music--track-description track)
- "Beep [0:01]"))))
-
-;;; Error Cases
-
-(ert-deftest test-music-config--track-description-error-unknown-type-fallback ()
- "Validate unknown track type uses emms-track-simple-description fallback."
- (let ((track (emms-track 'streamlist "https://example.com/playlist.m3u")))
- ;; Should not error; falls through to simple-description
- (let ((result (cj/music--track-description track)))
- (should (stringp result))
- (should (string-match-p "example\\.com" result)))))
-
-(provide 'test-music-config--track-description)
-;;; test-music-config--track-description.el ends here
diff --git a/tests/test-music-config-commands.el b/tests/test-music-config-commands.el
index 3c585d0b..40fb2d81 100644
--- a/tests/test-music-config-commands.el
+++ b/tests/test-music-config-commands.el
@@ -30,17 +30,21 @@
;;; cj/music-add-directory-recursive
(ert-deftest test-music-add-directory-recursive-passes-dir-to-emms ()
- "Normal: add-directory-recursive routes through emms-add-directory-tree."
+ "Normal: add-directory-recursive feeds the directory's music files (and
+only those) to emms-add-file -- the raw-tree path added cover art too."
(let* ((tmp (file-name-as-directory (make-temp-file "cj-music-add-" t)))
- called)
+ added)
(unwind-protect
- (cl-letf (((symbol-function 'cj/music--ensure-playlist-buffer) #'ignore)
- ((symbol-function 'emms-add-directory-tree)
- (lambda (dir) (setq called dir)))
- ((symbol-function 'message) #'ignore))
- (cj/music-add-directory-recursive tmp))
+ (progn
+ (write-region "" nil (expand-file-name "song.mp3" tmp))
+ (write-region "" nil (expand-file-name "cover.jpg" tmp))
+ (cl-letf (((symbol-function 'cj/music--ensure-playlist-buffer) #'ignore)
+ ((symbol-function 'emms-add-file)
+ (lambda (f) (push f added)))
+ ((symbol-function 'message) #'ignore))
+ (cj/music-add-directory-recursive tmp)))
(delete-directory tmp t))
- (should (equal called tmp))))
+ (should (equal (mapcar #'file-name-nondirectory added) '("song.mp3")))))
(ert-deftest test-music-add-directory-recursive-errors-on-non-directory ()
"Error: passing a regular file (not a directory) signals user-error."
diff --git a/tests/test-music-config-create-radio-station.el b/tests/test-music-config-create-radio-station.el
index 1f4365a4..4f49f49b 100644
--- a/tests/test-music-config-create-radio-station.el
+++ b/tests/test-music-config-create-radio-station.el
@@ -1,153 +1,131 @@
-;;; test-music-config-create-radio-station.el --- Tests for radio station creation -*- coding: utf-8; lexical-binding: t; -*-
+;;; test-music-config-create-radio-station.el --- Tests for manual radio-station entry -*- coding: utf-8; lexical-binding: t; -*-
;;
;; Author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
-;; Unit tests for cj/music-create-radio-station function.
-;; Tests M3U file creation for radio stations with stream URLs.
+;; Unit tests for cj/music-create-radio-station under the queue-first model:
+;; a hand-entered name + URL becomes a url track in the playlist queue (with
+;; the name as its title property) and playback starts. Nothing is written
+;; to disk — saving is the normal playlist-save flow.
;;
;; Test organization:
-;; - Normal Cases: Standard creation, EXTM3U format, safe filename
-;; - Boundary Cases: Unicode name, complex URL, overwrite confirmed
-;; - Error Cases: Empty name, empty URL, overwrite declined
-;;
+;; - Normal Cases: track queued with title, playback started, no file written
+;; - Boundary Cases: unicode name preserved verbatim
+;; - Error Cases: empty name, empty URL
+
;;; Code:
(require 'ert)
-(require 'testutil-general)
+(require 'cl-lib)
;; Stub missing dependencies before loading music-config
(defvar-keymap cj/custom-keymap
:doc "Stub keymap for testing")
-;; Load production code
-(require 'music-config)
-
-;;; Setup & Teardown
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
-(defun test-music-config-create-radio-station-setup ()
- "Setup test environment with temp directory for M3U output."
- (cj/create-test-base-dir)
- (cj/create-test-subdirectory "radio-playlists"))
+(require 'emms)
+(require 'music-config)
-(defun test-music-config-create-radio-station-teardown ()
- "Clean up test environment."
- (cj/delete-test-base-dir))
+(defmacro test-music-create-radio--with-env (&rest body)
+ "Run BODY with a fresh playlist buffer, playback mocked, messages captured."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-create-radio*"))
+ (emms-player-playing-p nil)
+ (started 0) (msg nil))
+ (ignore started msg)
+ (unwind-protect
+ (cl-letf (((symbol-function 'emms-start)
+ (lambda () (setq started (1+ started))))
+ ((symbol-function 'emms-stop) #'ignore)
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (when fmt (setq msg (apply #'format fmt args))))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name)))))
+
+(defun test-music-create-radio--queued-tracks ()
+ "Track objects currently in the test playlist buffer."
+ (let ((tracks '()))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((tr (emms-playlist-track-at (point))))
+ (push tr tracks))
+ (forward-line 1))))
+ (nreverse tracks)))
;;; Normal Cases
-(ert-deftest test-music-config-create-radio-station-normal-creates-m3u-file ()
- "Creating a radio station produces an M3U file in the music root."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
- (let ((expected-file (expand-file-name "Jazz_FM_Radio.m3u" test-dir)))
- (should (file-exists-p expected-file))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-normal-extm3u-format ()
- "Created file contains EXTM3U header, EXTINF with station name, and URL."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
+(ert-deftest test-music-config-create-radio-station-normal-queues-track ()
+ "Normal: name+url queues a url track carrying the name, and playback starts."
+ (test-music-create-radio--with-env
+ (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
+ (let ((tracks (test-music-create-radio--queued-tracks)))
+ (should (= (length tracks) 1))
+ (should (eq (emms-track-type (car tracks)) 'url))
+ (should (equal (emms-track-name (car tracks)) "http://stream.jazzfm.com/radio"))
+ (should (equal (emms-track-get (car tracks) 'info-title) "Jazz FM")))
+ (should (= started 1))
+ (should (string-match-p "Jazz FM" msg))))
+
+(ert-deftest test-music-config-create-radio-station-normal-writes-no-file ()
+ "Normal: nothing lands on disk — saving is the playlist-save flow's job."
+ (let ((tmp (file-name-as-directory (make-temp-file "cj-radio-nofile-" t))))
(unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Jazz_FM_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p "^#EXTM3U" content))
- (should (string-match-p "#EXTINF:-1,Jazz FM" content))
- (should (string-match-p "http://stream.jazzfm.com/radio" content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-normal-safe-filename ()
- "Station name with special characters produces filesystem-safe filename."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Rock & Roll 101.5" "http://example.com/stream")
- ;; Spaces and special chars replaced with underscores
- (let ((expected-file (expand-file-name "Rock___Roll_101_5_Radio.m3u" test-dir)))
- (should (file-exists-p expected-file))))
- (test-music-config-create-radio-station-teardown))))
+ (test-music-create-radio--with-env
+ (let ((cj/music-m3u-root tmp)
+ (cj/music-radio-save-dir tmp))
+ (cj/music-create-radio-station "NPR" "https://example.test/stream")
+ (should-not (directory-files tmp nil "\\.m3u\\'"))))
+ (delete-directory tmp t))))
;;; Boundary Cases
-(ert-deftest test-music-config-create-radio-station-boundary-unicode-name-safe-filename ()
- "Unicode station name produces safe filename while preserving name in EXTINF."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- (cj/music-create-radio-station "Klassik Radio" "http://example.com/stream")
- ;; Name is all ASCII-safe, so filename uses it directly
- (should (file-exists-p (expand-file-name "Klassik_Radio_Radio.m3u" test-dir)))
- ;; Original name preserved in EXTINF inside the file
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Klassik_Radio_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p "Klassik Radio" content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-boundary-url-with-query-params ()
- "Complex URL with query parameters preserved in file content."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir)
- (url "https://stream.example.com/radio?format=mp3&quality=320&token=abc123"))
- (cj/music-create-radio-station "Test Radio" url)
- (let ((content (with-temp-buffer
- (insert-file-contents
- (expand-file-name "Test_Radio_Radio.m3u" test-dir))
- (buffer-string))))
- (should (string-match-p (regexp-quote url) content))))
- (test-music-config-create-radio-station-teardown))))
-
-(ert-deftest test-music-config-create-radio-station-boundary-overwrite-confirmed ()
- "Overwriting existing file when user confirms succeeds."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- ;; Create initial file
- (cj/music-create-radio-station "MyRadio" "http://old.url/stream")
- (let ((file (expand-file-name "MyRadio_Radio.m3u" test-dir)))
- (should (file-exists-p file))
- ;; Overwrite with user confirming
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) t)))
- (cj/music-create-radio-station "MyRadio" "http://new.url/stream"))
- ;; File should now contain new URL
- (let ((content (with-temp-buffer
- (insert-file-contents file)
- (buffer-string))))
- (should (string-match-p "http://new.url/stream" content))
- (should-not (string-match-p "http://old.url/stream" content)))))
- (test-music-config-create-radio-station-teardown))))
+(ert-deftest test-music-config-create-radio-station-boundary-unicode-name ()
+ "Boundary: a unicode name is kept verbatim on the track (no filename munging)."
+ (test-music-create-radio--with-env
+ (cj/music-create-radio-station "Café Del Mar ☕" "https://cafe.example/stream")
+ (should (equal (emms-track-get (car (test-music-create-radio--queued-tracks))
+ 'info-title)
+ "Café Del Mar ☕"))))
+
+;;; Keymap
+
+(ert-deftest test-music-config-radio-map-prefix-mirrors-playlist-keys ()
+ "Normal: C-; m r is a radio prefix whose n/t/m mirror the playlist buffer."
+ (let ((map (lookup-key cj/music-map "r")))
+ (should (keymapp map))
+ (should (eq (lookup-key map "n") 'cj/music-radio-search-by-name))
+ (should (eq (lookup-key map "t") 'cj/music-radio-search-by-tag))
+ (should (eq (lookup-key map "m") 'cj/music-create-radio-station))))
+
+(ert-deftest test-music-config-menu-map-lowercase-keys ()
+ "Normal: the menu's former uppercase keys live on lowercase homes, and the
+playlist buffer saves on s (single on 1, old save key v unbound)."
+ (should (eq (lookup-key cj/music-map "v") 'cj/music-playlist-show))
+ (should (eq (lookup-key cj/music-map "u") 'emms-shuffle))
+ (should (eq (lookup-key cj/music-map "l") 'emms-toggle-repeat-playlist))
+ (should-not (lookup-key cj/music-map "R"))
+ (should-not (lookup-key cj/music-map "M"))
+ (should-not (lookup-key cj/music-map "Z"))
+ (should (eq (lookup-key emms-playlist-mode-map "s") 'cj/music-playlist-save))
+ (should (eq (lookup-key emms-playlist-mode-map "1") 'emms-toggle-repeat-track))
+ (should-not (lookup-key emms-playlist-mode-map "v")))
;;; Error Cases
(ert-deftest test-music-config-create-radio-station-error-empty-name-signals-user-error ()
- "Empty station name signals user-error."
- (should-error (cj/music-create-radio-station "" "http://example.com/stream")
- :type 'user-error))
+ "Error: empty name signals user-error."
+ (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error))
(ert-deftest test-music-config-create-radio-station-error-empty-url-signals-user-error ()
- "Empty URL signals user-error."
- (should-error (cj/music-create-radio-station "Test Radio" "")
- :type 'user-error))
-
-(ert-deftest test-music-config-create-radio-station-error-overwrite-declined-signals-user-error ()
- "Declining overwrite signals user-error."
- (let ((test-dir (test-music-config-create-radio-station-setup)))
- (unwind-protect
- (let ((cj/music-m3u-root test-dir))
- ;; Create initial file
- (cj/music-create-radio-station "MyRadio" "http://old.url/stream")
- ;; Decline overwrite
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) nil)))
- (should-error (cj/music-create-radio-station "MyRadio" "http://new.url/stream")
- :type 'user-error)))
- (test-music-config-create-radio-station-teardown))))
+ "Error: empty URL signals user-error."
+ (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error))
(provide 'test-music-config-create-radio-station)
;;; test-music-config-create-radio-station.el ends here
diff --git a/tests/test-music-config-helpers-untested.el b/tests/test-music-config-helpers-untested.el
index bfdb2634..87aa210c 100644
--- a/tests/test-music-config-helpers-untested.el
+++ b/tests/test-music-config-helpers-untested.el
@@ -154,17 +154,18 @@ test prelude inserts filler with `inhibit-read-only' bound."
;;; ---------- cj/music-add-directory-recursive ----------
(ert-deftest test-mc-add-directory-recursive-normal-calls-emms ()
- "Normal: with an existing directory, the recursive add reaches emms."
+ "Normal: with an existing directory, the recursive add reaches emms with
+each music file individually (the filtered walk, not the raw tree)."
(test-mc-untested--setup)
(unwind-protect
(let* ((dir cj/test-base-dir)
- (called-with nil))
- (cl-letf (((symbol-function 'emms-add-directory-tree)
- (lambda (d) (setq called-with d)))
+ (added nil))
+ (write-region "" nil (expand-file-name "one.mp3" dir))
+ (cl-letf (((symbol-function 'emms-add-file)
+ (lambda (f) (push f added)))
((symbol-function 'message) #'ignore))
(cj/music-add-directory-recursive dir))
- (should (equal (file-name-as-directory called-with)
- (file-name-as-directory dir))))
+ (should (member "one.mp3" (mapcar #'file-name-nondirectory added))))
(test-mc-untested--teardown)))
(ert-deftest test-mc-add-directory-recursive-error-not-a-directory ()
diff --git a/tests/test-music-config-more-commands.el b/tests/test-music-config-more-commands.el
index c351c1f1..530aa379 100644
--- a/tests/test-music-config-more-commands.el
+++ b/tests/test-music-config-more-commands.el
@@ -9,7 +9,9 @@
;; cj/music-playlist-edit
;; cj/music-playlist-toggle
;; cj/music-playlist-show
-;; cj/music-create-radio-station
+;;
+;; cj/music-create-radio-station lives in
+;; test-music-config-create-radio-station.el.
;;; Code:
@@ -17,12 +19,21 @@
(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir (add-to-list 'load-path emms-dir)))
+
+(require 'emms)
(require 'music-config)
;; Top-level defvars so let-binds reach the dynamic var under lexical
;; scope.
(defvar cj/music-playlist-file nil)
-(defvar emms-source-playlist-ask-before-overwrite t)
;;; cj/music-playlist-load
@@ -56,28 +67,87 @@
;;; cj/music-playlist-save
-(ert-deftest test-music-playlist-save-writes-fresh-name ()
- "Normal: save with a fresh name writes via emms-playlist-save."
- (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-save-" t)))
- (cj/music-m3u-root tmp)
- saved-args msg)
- (unwind-protect
- (cl-letf (((symbol-function 'cj/music--get-m3u-basenames)
- (lambda () nil))
- ((symbol-function 'completing-read)
- (lambda (&rest _) "fresh"))
- ((symbol-function 'cj/music--ensure-playlist-buffer)
- (lambda () (current-buffer)))
- ((symbol-function 'emms-playlist-save)
- (lambda (fmt path) (setq saved-args (list fmt path))))
- ((symbol-function 'cj/music--sync-playlist-file) #'ignore)
- ((symbol-function 'message)
- (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
- (cj/music-playlist-save))
- (delete-directory tmp t))
- (should (equal (car saved-args) 'm3u))
- (should (string-match-p "fresh\\.m3u\\'" (cadr saved-args)))
- (should (string-match-p "Saved playlist" msg))))
+(defmacro test-music-save--with-env (&rest body)
+ "Run BODY with a fresh playlist buffer and temp save directories.
+Binds TMP-M3U and TMP-RADIO (both cleaned up) and captures completing-read's
+INITIAL argument in CR-INITIAL."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-save*"))
+ (tmp-m3u (file-name-as-directory (make-temp-file "cj-save-m3u-" t)))
+ (tmp-radio (file-name-as-directory (make-temp-file "cj-save-radio-" t)))
+ (cj/music-m3u-root tmp-m3u)
+ (cj/music-radio-save-dir tmp-radio)
+ (cr-initial 'unset))
+ (ignore cr-initial)
+ (unwind-protect
+ (cl-letf (((symbol-function 'message) #'ignore)
+ ((symbol-function 'completing-read)
+ (lambda (_prompt _coll &optional _pred _req initial _hist def &rest _)
+ (setq cr-initial initial)
+ (or initial def "fallback"))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name))
+ (delete-directory tmp-m3u t)
+ (delete-directory tmp-radio t))))
+
+(defun test-music-save--queue (tracks)
+ "Put TRACKS into the (fresh) test playlist buffer."
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (save-excursion
+ (goto-char (point-max))
+ (dolist (tr tracks)
+ (emms-playlist-insert-track tr)))))
+
+(ert-deftest test-music-playlist-save-station-queue-prefills-and-targets-radio-dir ()
+ "Normal: an all-stream queue pre-fills the station name and saves into the
+radio dir with the station metadata written."
+ (test-music-save--with-env
+ (let ((tr (emms-track 'url "https://gs.example/stream")))
+ (emms-track-set tr 'info-title "Groove Salad")
+ (emms-track-set tr 'radio-uuid "uuid-gs")
+ (test-music-save--queue (list tr)))
+ (cj/music-playlist-save)
+ (should (equal cr-initial "Groove Salad"))
+ (let ((file (expand-file-name "Groove Salad.m3u" tmp-radio)))
+ (should (file-exists-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (let ((text (buffer-string)))
+ (should (string-match-p "^#EXTINF:-1,Groove Salad$" text))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-gs$" text))
+ (should (string-match-p "^https://gs\\.example/stream$" text)))))
+ (should-not (directory-files tmp-m3u nil "\\.m3u\\'"))))
+
+(ert-deftest test-music-playlist-save-file-queue-targets-m3u-root-no-prefill ()
+ "Normal: a file queue saves into the music root with no station pre-fill."
+ (test-music-save--with-env
+ (test-music-save--queue (list (emms-track 'file "/music/a.flac")))
+ (cj/music-playlist-save)
+ (should-not cr-initial)
+ (should (= (length (directory-files tmp-m3u nil "\\.m3u\\'")) 1))
+ (should-not (directory-files tmp-radio nil "\\.m3u\\'"))))
+
+(ert-deftest test-music-playlist-save-associated-file-name-wins-over-station ()
+ "Normal: a queue with an associated playlist file defaults to that name,
+even when it contains stations."
+ (test-music-save--with-env
+ (let ((tr (emms-track 'url "https://gs.example/stream")))
+ (emms-track-set tr 'info-title "Groove Salad")
+ (test-music-save--queue (list tr)))
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (setq cj/music-playlist-file (expand-file-name "morning.m3u" tmp-radio)))
+ (cj/music-playlist-save)
+ (should-not cr-initial)
+ (should (file-exists-p (expand-file-name "morning.m3u" tmp-radio)))))
+
+(ert-deftest test-music-playlist-save-error-empty-name ()
+ "Error: an empty name at the prompt signals user-error, not a hidden .m3u."
+ (test-music-save--with-env
+ (test-music-save--queue (list (emms-track 'url "https://x.example/s")))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "")))
+ (should-error (cj/music-playlist-save) :type 'user-error))
+ (should-not (directory-files tmp-radio nil "m3u"))))
;;; cj/music-playlist-edit
@@ -138,36 +208,5 @@
(should (eq switched buf))
(should msg)))
-;;; cj/music-create-radio-station
-
-(ert-deftest test-music-create-radio-station-writes-m3u ()
- "Normal: with name+url, an EXTM3U-style file is written into music-m3u-root."
- (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-radio-" t)))
- (cj/music-m3u-root tmp)
- msg)
- (unwind-protect
- (progn
- (cl-letf (((symbol-function 'message)
- (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
- (cj/music-create-radio-station "NPR" "https://example.test/stream"))
- (let ((file (expand-file-name "NPR_Radio.m3u" tmp)))
- (should (file-exists-p file))
- (with-temp-buffer
- (insert-file-contents file)
- (let ((text (buffer-string)))
- (should (string-match-p "#EXTM3U" text))
- (should (string-match-p "NPR" text))
- (should (string-match-p "https://example.test/stream" text))))))
- (delete-directory tmp t))
- (should (string-match-p "Created radio station" msg))))
-
-(ert-deftest test-music-create-radio-station-rejects-empty-name ()
- "Error: an empty name is rejected with user-error."
- (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error))
-
-(ert-deftest test-music-create-radio-station-rejects-empty-url ()
- "Error: an empty URL is rejected with user-error."
- (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error))
-
(provide 'test-music-config-more-commands)
;;; test-music-config-more-commands.el ends here
diff --git a/tests/test-nov-reading--config-defaults.el b/tests/test-nov-reading--config-defaults.el
new file mode 100644
index 00000000..ff52d8f5
--- /dev/null
+++ b/tests/test-nov-reading--config-defaults.el
@@ -0,0 +1,67 @@
+;;; test-nov-reading--config-defaults.el --- nov reading default/order tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Asserts the shipped reading-view defaults: a fresh EPUB opens dark, and the
+;; `c' cycle runs dark -> sepia -> light -> none -> back. The pure-logic file
+;; test-nov-reading--palette.el covers the cycle mechanics on fixtures; this file
+;; pins the real `cj/nov-reading-palettes' / `cj/nov-reading-default-palette'
+;; values so a reorder can't silently drift the default or the cycle order.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'nov-reading)
+
+(declare-function cj/nov--next-reading-palette "nov-reading" (current names))
+(defvar cj/nov-reading-palettes)
+(defvar cj/nov-reading-default-palette)
+(defvar cj/nov-reading-profile)
+(defvar cj/nov--typography-remap-cookies)
+
+(ert-deftest test-nov-reading-config-default-is-dark ()
+ "Normal: a fresh nov buffer opens on the dark palette."
+ (should (equal cj/nov-reading-default-palette "dark")))
+
+(ert-deftest test-nov-reading-config-order-is-dark-sepia-light ()
+ "Normal: the shipped palette order is dark, then sepia, then light."
+ (should (equal (mapcar #'car cj/nov-reading-palettes)
+ '("dark" "sepia" "light"))))
+
+(ert-deftest test-nov-reading-config-cycle-from-default ()
+ "Normal: cycling from the default advances dark -> sepia -> light -> none -> dark."
+ (let ((names (mapcar #'car cj/nov-reading-palettes)))
+ (should (equal (cj/nov--next-reading-palette "dark" names) "sepia"))
+ (should (equal (cj/nov--next-reading-palette "sepia" names) "light"))
+ (should-not (cj/nov--next-reading-palette "light" names))
+ (should (equal (cj/nov--next-reading-palette nil names) "dark"))))
+
+(ert-deftest test-nov-reading-config-uses-reading-font-profile ()
+ "Normal: nov typography names the shared Reading profile."
+ (should (eq cj/nov-reading-profile 'reading)))
+
+(ert-deftest test-nov-reading-depends-on-pure-profile-layer ()
+ "Boundary: loading nov shares profile data without loading Fontaine config."
+ (should (featurep 'font-profiles))
+ (should-not (featurep 'font-config)))
+
+(ert-deftest test-nov-reading-typography-remaps-shared-profile-locally ()
+ "Normal: nov applies Reading locally at its own base height without stacking."
+ (let ((cj/nov--typography-remap-cookies '(old-default old-fixed))
+ (removed nil)
+ (applied nil))
+ (cl-letf (((symbol-function 'face-remap-remove-relative)
+ (lambda (cookie) (push cookie removed)))
+ ((symbol-function 'cj/font-profile-remap-buffer)
+ (lambda (profile height)
+ (setq applied (list profile height))
+ '(new-default new-fixed))))
+ (cj/nov-reading-apply-typography))
+ (should (equal applied '(reading 180)))
+ (should (equal (sort removed #'string-lessp)
+ '(old-default old-fixed)))
+ (should (equal cj/nov--typography-remap-cookies
+ '(new-default new-fixed)))))
+
+(provide 'test-nov-reading--config-defaults)
+;;; test-nov-reading--config-defaults.el ends here
diff --git a/tests/test-org-agenda-config-commands.el b/tests/test-org-agenda-config-commands.el
index 76407439..139bf90f 100644
--- a/tests/test-org-agenda-config-commands.el
+++ b/tests/test-org-agenda-config-commands.el
@@ -176,5 +176,89 @@ large), so the standalone OVERDUE section was redundant."
(should (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} 09:00>"
text)))))
+(ert-deftest test-org-agenda-add-timestamp-preserves-point-and-following-line ()
+ "Normal: the stamp lands between the entry and the next line, point unmoved.
+The docstring promises the event appears \"underneath the line-at-point\",
+so neither the entry nor whatever follows it may be disturbed."
+ (with-temp-buffer
+ (insert "* First\n* Second")
+ (goto-char (point-min))
+ (let ((start (progn (org-end-of-line) (point))))
+ (goto-char (point-min))
+ (cj/add-timestamp-to-org-entry "09:00")
+ ;; Point is left at the end of the entry it stamped.
+ (should (= (point) start)))
+ (let ((lines (split-string (buffer-string) "\n")))
+ (should (equal (nth 0 lines) "* First"))
+ (should (string-match-p "\\`<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} 09:00>\\'"
+ (nth 1 lines)))
+ (should (equal (nth 2 lines) "* Second")))))
+
+(ert-deftest test-org-agenda-add-timestamp-empty-time-string ()
+ "Boundary: an empty time yields a bare date stamp with a trailing space.
+Characterizes current behavior -- the separator space is unconditional, so
+an empty S produces `<DATE >' rather than `<DATE>'. Harmless in an agenda
+\(org reads the date\), and pinned here so a future format change is a
+deliberate one."
+ (with-temp-buffer
+ (insert "* Heading here")
+ (goto-char (point-min))
+ (cj/add-timestamp-to-org-entry "")
+ (should (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} >"
+ (buffer-string)))))
+
+(ert-deftest test-org-agenda-add-timestamp-unicode-time-string ()
+ "Boundary: non-ASCII in S survives into the stamp uncorrupted."
+ (with-temp-buffer
+ (insert "* Heading here")
+ (goto-char (point-min))
+ (cj/add-timestamp-to-org-entry "09:00 café ☕")
+ (should (string-match-p "09:00 café ☕>" (buffer-string)))))
+
+(ert-deftest test-org-agenda-add-timestamp-empty-buffer ()
+ "Boundary: an empty buffer still gets a stamp rather than signalling.
+`org-end-of-line' and `open-line' both no-op safely at point-min."
+ (with-temp-buffer
+ (cj/add-timestamp-to-org-entry "09:00")
+ (should (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} 09:00>"
+ (buffer-string)))))
+
+(ert-deftest test-org-agenda-add-timestamp-read-only-buffer-signals ()
+ "Error: a read-only buffer signals rather than silently dropping the stamp."
+ (with-temp-buffer
+ (insert "* Heading")
+ (goto-char (point-min))
+ (setq buffer-read-only t)
+ (should-error (cj/add-timestamp-to-org-entry "09:00") :type 'buffer-read-only)))
+
+(defconst test-org-agenda--timeformat-special-at-load
+ (special-variable-p 'cj/timeformat)
+ "Whether `cj/timeformat' was special immediately after loading the module.
+Captured here at load, before any test body runs, because the fact under
+test is destroyed by observing it late: calling
+`cj/add-timestamp-to-org-entry' once would execute a `defvar' nested in the
+defun and make the symbol special retroactively. ERT runs tests
+alphabetically, so an in-test `special-variable-p' check passes on the
+strength of whichever test ran first -- green in a full-file run, red in
+isolation. Snapshotting at load makes the guard order-independent.")
+
+(ert-deftest test-org-agenda-timeformat-is-a-top-level-special-variable ()
+ "Normal: `cj/timeformat' is special and bound at load, not first call.
+It used to be `defvar'd inside `cj/add-timestamp-to-org-entry', so it was
+unbound until the command ran once and a `let' around the call bound it
+lexically instead of dynamically. Pinning both halves of the fix: the
+symbol is special at load time, and rebinding it actually reaches the
+command."
+ (should test-org-agenda--timeformat-special-at-load)
+ (should (equal (default-value 'cj/timeformat) "%Y-%m-%d %a"))
+ ;; The dynamic binding must reach the insertion.
+ (with-temp-buffer
+ (insert "* Heading")
+ (goto-char (point-min))
+ (let ((cj/timeformat "%Y"))
+ (cj/add-timestamp-to-org-entry "09:00"))
+ (should (string-match-p "\\`<[0-9]\\{4\\} 09:00>\\'"
+ (nth 1 (split-string (buffer-string) "\n"))))))
+
(provide 'test-org-agenda-config-commands)
;;; test-org-agenda-config-commands.el ends here
diff --git a/tests/test-org-agenda-frame.el b/tests/test-org-agenda-frame.el
new file mode 100644
index 00000000..167cf2ff
--- /dev/null
+++ b/tests/test-org-agenda-frame.el
@@ -0,0 +1,1167 @@
+;;; test-org-agenda-frame.el --- Tests for the fullscreen agenda frame -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Phase 1 of the org-agenda fullscreen frame (spec:
+;; docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org). Frame lookup
+;; is mocked (frame-list / frame-live-p / frame-parameter), the house pattern
+;; from test-dirvish-config-popup.el, since --batch can't create real frames.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-agenda-frame)
+
+;; org-agenda isn't loaded in batch (no package-initialize), so declare the
+;; command list special and bound for the registration tests to let-bind.
+(defvar org-agenda-custom-commands nil)
+
+;;; cj/--agenda-frame — locate the marked frame
+
+(ert-deftest test-org-agenda-frame-find-returns-marked-live-frame ()
+ "Normal: returns the live frame carrying the `cj/agenda-frame' marker."
+ (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) (and (eq p 'cj/agenda-frame) (eq f 'fb)))))
+ (should (eq (cj/--agenda-frame) 'fb))))
+
+(ert-deftest test-org-agenda-frame-find-nil-when-none-marked ()
+ "Boundary: no frame carries the marker -> 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) nil)))
+ (should (null (cj/--agenda-frame)))))
+
+(ert-deftest test-org-agenda-frame-find-ignores-dead-marked-frame ()
+ "Error: a marked but dead frame is not returned."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb)))
+ ((symbol-function 'frame-live-p) (lambda (f) (not (eq f 'fb))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fb)))))
+ (should (null (cj/--agenda-frame)))))
+
+;;; cj/--agenda-frame-p — is FRAME a live agenda frame
+
+(ert-deftest test-org-agenda-frame-p-true-for-live-marked ()
+ "Normal: a live marked frame is an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fa)))))
+ (should (cj/--agenda-frame-p 'fa))))
+
+(ert-deftest test-org-agenda-frame-p-nil-for-unmarked ()
+ "Boundary: a live unmarked frame is not an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) nil)))
+ (should (null (cj/--agenda-frame-p 'fa)))))
+
+(ert-deftest test-org-agenda-frame-p-nil-for-dead-marked ()
+ "Error: a dead marked frame is not an agenda frame."
+ (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) nil))
+ ((symbol-function 'frame-parameter)
+ (lambda (_f p) (eq p 'cj/agenda-frame))))
+ (should (null (cj/--agenda-frame-p 'fa)))))
+
+;;; cj/--agenda-frame-working-frame — route source files to a non-agenda frame
+
+(ert-deftest test-org-agenda-working-frame-returns-non-agenda-frame ()
+ "Normal: with no recorded launch frame, returns the first live non-agenda frame."
+ (let ((cj/--agenda-frame-launch-frame nil))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda work)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda work))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'work)))))
+
+(ert-deftest test-org-agenda-working-frame-prefers-live-launch-frame ()
+ "Normal: the recorded launch frame wins when live and non-agenda."
+ (let ((cj/--agenda-frame-launch-frame 'w2))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda w1 w2)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda w1 w2))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'w2)))))
+
+(ert-deftest test-org-agenda-working-frame-falls-back-when-launch-dead ()
+ "Boundary: a dead recorded launch frame falls back to another non-agenda frame."
+ (let ((cj/--agenda-frame-launch-frame 'gone))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda work)))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda work))))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (eq (cj/--agenda-frame-working-frame) 'work)))))
+
+(ert-deftest test-org-agenda-working-frame-nil-when-only-agenda ()
+ "Error: the agenda frame is the only live frame -> nil (caller creates one)."
+ (let ((cj/--agenda-frame-launch-frame nil))
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda)))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'agenda)))
+ ((symbol-function 'frame-parameter)
+ (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda)))))
+ (should (null (cj/--agenda-frame-working-frame))))))
+
+;;; cj/--agenda-frame-command — the dedicated F seven-day view
+
+(defun test-org-agenda-frame--block-settings ()
+ "Return the per-block settings alist of the agenda-frame command."
+ (let* ((cmd (cj/--agenda-frame-command))
+ (blocks (nth 2 cmd))
+ (agenda-block (car blocks)))
+ (nth 2 agenda-block)))
+
+(defun test-org-agenda-frame--general-settings ()
+ "Return the general (view-wide) settings alist of the agenda-frame command."
+ (nth 3 (cj/--agenda-frame-command)))
+
+(ert-deftest test-org-agenda-frame-command-key-is-F ()
+ "Normal: the command's key is F (collision-free with the existing d)."
+ (should (equal (nth 0 (cj/--agenda-frame-command)) "F")))
+
+(ert-deftest test-org-agenda-frame-command-today-anchored-7-day ()
+ "Normal: the default span is seven days anchored to today, not Monday.
+The span is the frame-span variable (default 7), evaluated the way org
+evaluates custom-command settings."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (equal (default-value 'cj/--agenda-frame-span) 7))
+ (should (equal (eval (cadr (assq 'org-agenda-span s)) t)
+ (default-value 'cj/--agenda-frame-span)))
+ (should (equal (cadr (assq 'org-agenda-start-day s)) "0d"))
+ ;; start-on-weekday nil is what un-anchors the span from Monday.
+ (should (assq 'org-agenda-start-on-weekday s))
+ (should (null (cadr (assq 'org-agenda-start-on-weekday s))))))
+
+(ert-deftest test-org-agenda-frame-command-span-follows-variable ()
+ "Normal: the block span reads `cj/--agenda-frame-span', so d/w can change it
+and a redo -- which re-evaluates the lprops -- picks up the new span."
+ (let ((cj/--agenda-frame-span 1))
+ (should (equal (eval (cadr (assq 'org-agenda-span
+ (test-org-agenda-frame--block-settings)))
+ t)
+ 1)))
+ (let ((cj/--agenda-frame-span 7))
+ (should (equal (eval (cadr (assq 'org-agenda-span
+ (test-org-agenda-frame--block-settings)))
+ t)
+ 7))))
+
+(ert-deftest test-org-agenda-frame-day-view-sets-span-1-and-redoes ()
+ "Normal: d sets the span to one day and refreshes via the safe redo."
+ (let ((cj/--agenda-frame-span 7) redone)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-safe-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-day-view)
+ (should (equal cj/--agenda-frame-span 1))
+ (should redone))))
+
+(ert-deftest test-org-agenda-frame-week-view-sets-span-7-and-redoes ()
+ "Normal: w restores the seven-day span and refreshes."
+ (let ((cj/--agenda-frame-span 1) redone)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-safe-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-week-view)
+ (should (equal cj/--agenda-frame-span 7))
+ (should redone))))
+
+(ert-deftest test-org-agenda-frame-command-tight-prefix-format ()
+ "Normal: the view sets its own prefix format with a narrow category column.
+Without it the global agenda format applies, whose 25-char category pad
+leaves a wide blank gutter between the source name and the item."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (assq 'org-agenda-prefix-format s))
+ (should (string-match-p "%-10:c"
+ (cadr (assq 'org-agenda-prefix-format s))))))
+
+(ert-deftest test-org-agenda-frame-command-skips-done-items ()
+ "Normal: completed tasks never appear in the Full Agenda.
+The global agenda deliberately shows scheduled done items, so the dedicated
+view needs its own skip function to exclude every done-state keyword."
+ (let* ((settings (test-org-agenda-frame--block-settings))
+ (skip (assq 'org-agenda-skip-function settings)))
+ (should skip)
+ (should (equal (eval (cadr skip) t)
+ '(org-agenda-skip-entry-if 'todo 'done)))))
+
+(ert-deftest test-org-agenda-frame-command-render-excludes-scheduled-done ()
+ "Boundary: a scheduled high-priority DONE item is absent from rendered output.
+An equally scheduled active task remains, proving the whole date was not
+skipped."
+ (require 'org-agenda)
+ (let* ((file (make-temp-file "agenda-frame-done-" nil ".org"))
+ (today (format-time-string "%Y-%m-%d %a"))
+ (org-agenda-files (list file))
+ (org-agenda-custom-commands (list (cj/--agenda-frame-command)))
+ (org-agenda-sticky nil)
+ (agenda-buffer "*Org Agenda*"))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "#+TODO: TODO | DONE\n"
+ "* DONE [#A] completed-scheduled-marker\n"
+ "SCHEDULED: <" today ">\n"
+ "* TODO [#A] active-scheduled-marker\n"
+ "SCHEDULED: <" today ">\n"))
+ (org-agenda nil cj/--agenda-frame-command-key)
+ (with-current-buffer agenda-buffer
+ (should-not (string-match-p "completed-scheduled-marker"
+ (buffer-string)))
+ (should (string-match-p "active-scheduled-marker"
+ (buffer-string)))))
+ (when (get-buffer agenda-buffer)
+ (kill-buffer agenda-buffer))
+ (delete-file file))))
+
+(ert-deftest test-org-agenda-frame-do-redo-leaves-sticky-alone ()
+ "Normal: the redo binds current-window but never touches sticky.
+org-agenda-redo handles the in-place rebuild itself (it binds sticky nil
+and redirects the buffer name); a sticky t reaching org-agenda-prepare
+mid-redo makes it throw \\='exit with no catch and the tick fails."
+ (let ((org-agenda-sticky nil)
+ seen-sticky seen-setup (params '()))
+ (with-temp-buffer
+ (insert "agenda line\n")
+ (cl-letf (((symbol-function 'org-agenda-redo)
+ (lambda (&rest _)
+ (setq seen-sticky org-agenda-sticky
+ seen-setup org-agenda-window-setup)))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should (null seen-sticky))
+ (should (eq seen-setup 'current-window))))))
+
+(ert-deftest test-org-agenda-frame-command-follow-mode-off ()
+ "Boundary: follow-mode is forced off locally so a global default can't split."
+ (let ((s (test-org-agenda-frame--block-settings)))
+ (should (assq 'org-agenda-start-with-follow-mode s))
+ (should (null (cadr (assq 'org-agenda-start-with-follow-mode s))))))
+
+(ert-deftest test-org-agenda-frame-command-sticky-and-current-window ()
+ "Normal: current-window in the settings; sticky deliberately NOT there.
+The general settings are baked into the buffer's series-redo-cmd and
+re-applied on every redo; a sticky t there makes org-agenda-use-sticky-p
+true mid-redo (the buffer exists), and org-agenda-prepare throws \\='exit
+with no catch -- every refresh tick fails. Stickiness belongs only in
+the spawn wrapper, where it names the buffer."
+ (let ((g (test-org-agenda-frame--general-settings)))
+ (should-not (assq 'org-agenda-sticky g))
+ ;; org evaluates custom-command setting values via org-let, so the stored
+ ;; form is (quote current-window); eval it the way org would.
+ (should (eq (eval (cadr (assq 'org-agenda-window-setup g)) t)
+ 'current-window))))
+
+;;; cj/--agenda-frame-register-command — idempotent registration
+
+(ert-deftest test-org-agenda-frame-register-adds-entry ()
+ "Normal: registration inserts the F entry into org-agenda-custom-commands."
+ (let ((org-agenda-custom-commands '(("d" "Daily" nil))))
+ (cj/--agenda-frame-register-command)
+ (should (assoc "F" org-agenda-custom-commands))
+ (should (assoc "d" org-agenda-custom-commands))))
+
+(ert-deftest test-org-agenda-frame-register-is-idempotent ()
+ "Boundary: registering twice leaves exactly one F entry."
+ (let ((org-agenda-custom-commands nil))
+ (cj/--agenda-frame-register-command)
+ (cj/--agenda-frame-register-command)
+ (should (= 1 (seq-count (lambda (e) (equal (car e) "F"))
+ org-agenda-custom-commands)))))
+
+;;; Default-deny policy — denial handlers
+
+(ert-deftest test-org-agenda-frame-denied-readonly-messages ()
+ "Normal: the read-only denial shows the read-only message and acts on nothing."
+ (let (captured)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--agenda-frame-denied-readonly))
+ (should (string-match-p "read-only" captured))
+ (should (string-match-p "working frame" captured))))
+
+(ert-deftest test-org-agenda-frame-denied-fixed-view-messages ()
+ "Normal: the view-change denial shows the fixed-view message."
+ (let (captured)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--agenda-frame-denied-fixed-view))
+ (should (string-match-p "day (d) and week (w)" captured))))
+
+;;; Default-deny policy — the keymap
+
+(ert-deftest test-org-agenda-frame-map-catch-all-is-readonly-deny ()
+ "Normal: the [t] default binding denies with the read-only handler."
+ (should (eq (lookup-key cj/agenda-frame-mode-map [t])
+ 'cj/--agenda-frame-denied-readonly)))
+
+(ert-deftest test-org-agenda-frame-map-navigation-allowed ()
+ "Normal: navigation keys resolve to their org-agenda commands."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "n")) 'org-agenda-next-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "p")) 'org-agenda-previous-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-g")) 'keyboard-quit)))
+
+(ert-deftest test-org-agenda-frame-map-point-motion-and-isearch-allowed ()
+ "Normal: read-only point motion and isearch work in the frame.
+C-a/C-e/C-f/C-b move point and C-s/C-r search; all are read-only and
+must not hit the deny catch-all."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-a")) 'move-beginning-of-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-e")) 'move-end-of-line))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-f")) 'forward-char))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-b")) 'backward-char))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-s")) 'isearch-forward))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-r")) 'isearch-backward)))
+
+(ert-deftest test-org-agenda-frame-map-engage-routed ()
+ "Normal: RET and TAB route to the working-frame engage command."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "RET"))
+ 'cj/--agenda-frame-engage-open))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "TAB"))
+ 'cj/--agenda-frame-engage-open)))
+
+(ert-deftest test-org-agenda-frame-map-engage-gui-function-keys ()
+ "Boundary: the GUI [return]/[tab] events engage too, not the [t] deny handler.
+Without these, the catch-all suppresses their translation to RET/TAB in a
+graphical frame and RET would be denied instead of opening the item."
+ (should (eq (lookup-key cj/agenda-frame-mode-map [return])
+ 'cj/--agenda-frame-engage-open))
+ (should (eq (lookup-key cj/agenda-frame-mode-map [tab])
+ 'cj/--agenda-frame-engage-open)))
+
+(ert-deftest test-org-agenda-frame-map-lifecycle-keys ()
+ "Normal: q/Q/x close the frame; r and g take the safe-redo path.
+g is the muscle-memory agenda refresh; it must refresh, not hit the
+fixed-view deny handler."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "q")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "Q")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "x")) 'cj/--agenda-frame-close))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "r")) 'cj/--agenda-frame-safe-redo))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "g")) 'cj/--agenda-frame-safe-redo)))
+
+(ert-deftest test-org-agenda-frame-map-view-changers-fixed-view-deny ()
+ "Boundary: view-changing keys are explicitly denied with the fixed-view message,
+not caught by the read-only catch-all."
+ (dolist (key '("y" "f" "b" "j"))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd key))
+ 'cj/--agenda-frame-denied-fixed-view))))
+
+(ert-deftest test-org-agenda-frame-map-day-week-view-keys ()
+ "Normal: d and w toggle the span (day / week) rather than being denied.
+d shrinks the frame to the current day, w restores the seven-day span."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "d"))
+ 'cj/--agenda-frame-day-view))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "w"))
+ 'cj/--agenda-frame-week-view)))
+
+(ert-deftest test-org-agenda-frame-map-controlled-task-mutations ()
+ "Normal: status and priority changes are the frame's controlled mutations.
+C-c t is Craig's requested status chord, C-c C-t keeps Org's standard agenda
+chord, and both Meta and the configured Super arrows support the same
+priority/status operations."
+ (dolist (binding '(("C-c t" . org-agenda-todo)
+ ("C-c C-t" . org-agenda-todo)
+ ("M-<up>" . org-agenda-priority-up)
+ ("M-<down>" . org-agenda-priority-down)
+ ("M-<left>" . org-agenda-todo-previousset)
+ ("M-<right>" . org-agenda-todo-nextset)
+ ("s-<up>" . org-agenda-priority-up)
+ ("s-<down>" . org-agenda-priority-down)
+ ("s-<left>" . org-agenda-todo-previousset)
+ ("s-<right>" . org-agenda-todo-nextset)))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd (car binding)))
+ (cdr binding)))))
+
+(ert-deftest test-org-agenda-frame-shadow-denies-org-mutations-preserves-allowlist ()
+ "Boundary: with the frame mode active over `org-agenda-mode-map', mutating
+org-agenda keys are denied and the allowlist still works.
+The `[t]' default cannot shadow org-agenda-mode-map's explicit bindings, so
+the shadow walk must add explicit denies for every non-allowlisted key --
+single keys (t/I/k/z/s/.), the C-c mutators (schedule/deadline/clock), and
+C-x (save-all) -- while leaving the allowlist (navigation, engage, refresh,
+d/w, C-c C-o) intact."
+ (require 'org-agenda)
+ (cj/--agenda-frame-shadow-mutations)
+ (with-temp-buffer
+ (use-local-map org-agenda-mode-map)
+ (cj/agenda-frame-mode 1)
+ ;; escaping mutators are now explicitly denied (not org's commands)
+ (dolist (chord '("t" "I" "k" "z" "s" "." "C-c C-s" "C-c C-d"
+ "C-c C-x C-i" "C-x C-s"))
+ (should (eq (key-binding (kbd chord))
+ 'cj/--agenda-frame-denied-readonly)))
+ ;; the allowlist survives the walk
+ (should (eq (key-binding (kbd "n")) 'org-agenda-next-line))
+ (should (eq (key-binding (kbd "p")) 'org-agenda-previous-line))
+ (should (eq (key-binding (kbd "RET")) 'cj/--agenda-frame-engage-open))
+ (should (eq (key-binding (kbd "g")) 'cj/--agenda-frame-safe-redo))
+ (should (eq (key-binding (kbd "d")) 'cj/--agenda-frame-day-view))
+ (should (eq (key-binding (kbd "w")) 'cj/--agenda-frame-week-view))
+ (should (eq (key-binding (kbd "C-c t")) 'org-agenda-todo))
+ (should (eq (key-binding (kbd "C-c C-t")) 'org-agenda-todo))
+ (should (eq (key-binding (kbd "M-<up>")) 'org-agenda-priority-up))
+ (should (eq (key-binding (kbd "M-<right>")) 'org-agenda-todo-nextset))
+ (should (eq (key-binding (kbd "s-<down>")) 'org-agenda-priority-down))
+ (should (eq (key-binding (kbd "s-<left>")) 'org-agenda-todo-previousset))
+ (should (eq (key-binding (kbd "C-c C-o")) 'cj/--agenda-frame-open-link))
+ (should (eq (key-binding (kbd "q")) 'cj/--agenda-frame-close))))
+
+(ert-deftest test-org-agenda-frame-map-frame-controls-bound ()
+ "Normal: the frame's own controls work from inside the frame.
+S-<f8> must close/toggle and C-M-<f8> must force-rescan; unbound, the
+catch-all denies them and the frame can't be closed by its own key."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "S-<f8>"))
+ 'cj/agenda-frame-toggle))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-M-<f8>"))
+ 'cj/org-agenda-refresh-files)))
+
+(ert-deftest test-org-agenda-frame-map-C-x-C-c-closes-frame ()
+ "Normal: C-x C-c in the agenda frame closes the frame, not the daemon.
+The global save-buffers-kill-terminal would kill Emacs itself here (a
+make-frame frame has no client), so the intuitive close gesture must be
+remapped to the frame close."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-x C-c"))
+ 'cj/--agenda-frame-close)))
+
+(ert-deftest test-org-agenda-frame-map-machinery-punched-through ()
+ "Boundary: input machinery is punched through the [t] catch-all.
+switch-frame events, mouse-wheel scrolling, mouse-1 clicks, and the help
+prefix must fall through to their global bindings (an explicit nil shadows
+the default in this map); otherwise every frame-focus change and every
+scroll spams the deny message."
+ (dolist (key (list [switch-frame]
+ [wheel-up] [wheel-down] [wheel-left] [wheel-right]
+ [double-wheel-up] [double-wheel-down]
+ [triple-wheel-up] [triple-wheel-down]
+ [mouse-1] [down-mouse-1] [drag-mouse-1]
+ (kbd "C-h")))
+ ;; accept-default t: a punched key returns nil (falls through to the
+ ;; global map); an unpunched key returns the catch-all deny handler.
+ (should (null (lookup-key cj/agenda-frame-mode-map key t)))))
+
+(ert-deftest test-org-agenda-frame-map-global-escape-chords-denied ()
+ "Boundary: global chords bound elsewhere (M-SPC / M-S-SPC swap ai-term
+agents) are denied by an explicit binding, not left to the [t] catch-all.
+A keymap's default binding does not shadow an explicit binding in a
+lower-priority map, so without an explicit deny here M-SPC follows its
+global binding and escapes the read-only frame into ai-term."
+ (dolist (key '("M-SPC" "M-S-SPC"))
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd key))
+ 'cj/--agenda-frame-denied-readonly))))
+
+(ert-deftest test-org-agenda-frame-map-unpunched-still-denied ()
+ "Normal: an ordinary unbound key still hits the catch-all after the punches."
+ (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "t") t)
+ 'cj/--agenda-frame-denied-readonly)))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-readds-kill-buffer-hook ()
+ "Normal: the finalize re-enable also re-adds the buffer-local kill hook.
+org-agenda-redo's kill-all-local-variables strips the hook installed at
+spawn; without the re-add, killing the buffer after the first refresh tick
+orphans the frame."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) 'win)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should (memq 'cj/--agenda-frame-on-kill-buffer
+ (buffer-local-value 'kill-buffer-hook (current-buffer)))))))
+
+(ert-deftest test-org-agenda-frame-overlay-removable-after-local-var-wipe ()
+ "Error: the failure overlay is found by property, not a buffer-local var.
+kill-all-local-variables (every redo) wipes buffer-local vars while the
+overlay object survives erase-buffer, so a var-held overlay could never be
+removed after a later success -- the failure banner would stick forever."
+ (with-temp-buffer
+ (insert "x\n")
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ ;; Simulate the org-agenda-mode reset between failure and success.
+ (kill-all-local-variables)
+ (cj/--agenda-frame-remove-overlay (current-buffer))
+ ;; The visible banner (a before-string overlay) must be gone.
+ (should (= 0 (seq-count (lambda (o) (overlay-get o 'before-string))
+ (overlays-in (point-min) (point-max)))))))
+
+(ert-deftest test-org-agenda-frame-map-mutation-keys-denied ()
+ "Boundary: a mutation key (t = org-agenda-todo) is denied, never allowlisted.
+It is denied two ways depending on whether the shadow walk has run: the `[t]'
+catch-all handles it (lookup returns nil) before the walk, and the walk binds
+it explicitly to the deny handler once `org-agenda-mode-map' is present. Both
+are a read-only denial; the test asserts the outcome, not which path produced
+it, so it holds whether or not org-agenda is loaded in the test process."
+ (let ((b (lookup-key cj/agenda-frame-mode-map (kbd "t"))))
+ (should (or (null b) (eq b 'cj/--agenda-frame-denied-readonly)))))
+
+;;; Default-deny policy — the minor mode + finalize re-enable
+
+(ert-deftest test-org-agenda-frame-mode-toggles ()
+ "Normal: the minor mode turns on and off in a buffer."
+ (with-temp-buffer
+ (cj/agenda-frame-mode 1)
+ (should cj/agenda-frame-mode)
+ (cj/agenda-frame-mode -1)
+ (should-not cj/agenda-frame-mode)))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-in-agenda-frame ()
+ "Normal: after a build in the agenda frame, the policy is re-enabled."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) 'win)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should cj/agenda-frame-mode))))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-skips-other-buffers ()
+ "Boundary: a build not shown in the agenda frame leaves the policy off."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda))
+ ((symbol-function 'get-buffer-window) (lambda (_b _f) nil)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should-not cj/agenda-frame-mode))))
+
+(ert-deftest test-org-agenda-frame-maybe-enable-no-frame ()
+ "Boundary: no agenda frame at all -> policy stays off, no error."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil)))
+ (cj/--agenda-frame-maybe-enable-mode)
+ (should-not cj/agenda-frame-mode))))
+
+;;; Engage routing — frame target + open
+
+(ert-deftest test-org-agenda-frame-target-frame-uses-working-frame ()
+ "Normal: the engage target is the working frame when one exists."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-working-frame) (lambda () 'work))
+ ((symbol-function 'make-frame) (lambda (&rest _) (error "should not create"))))
+ (should (eq (cj/--agenda-frame-target-frame) 'work))))
+
+(ert-deftest test-org-agenda-frame-target-frame-creates-when-none ()
+ "Boundary: no working frame -> a normal frame is created."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-working-frame) (lambda () nil))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'new)))
+ (should (eq (cj/--agenda-frame-target-frame) 'new))))
+
+(ert-deftest test-org-agenda-frame-engage-open-no-item-errors ()
+ "Error: engaging on a line with no source item signals a user-error."
+ (cl-letf (((symbol-function 'cj/--agenda-frame-item-marker) (lambda () nil)))
+ (should-error (cj/--agenda-frame-engage-open) :type 'user-error)))
+
+(ert-deftest test-org-agenda-frame-engage-open-routes-to-source ()
+ "Normal: engage opens the item's source buffer at the item's position."
+ (let ((source (generate-new-buffer " *frame-engage-source*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer source (insert "line one\nline two\nline three\n"))
+ (let ((marker (set-marker (make-marker) 10 source))
+ focused opened)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-item-marker) (lambda () marker))
+ ((symbol-function 'cj/--agenda-frame-target-frame) (lambda () 'work))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f)))
+ ((symbol-function 'pop-to-buffer-same-window)
+ (lambda (b &rest _) (setq opened b) (set-buffer b)))
+ ((symbol-function 'org-fold-show-context) (lambda (&rest _) nil)))
+ (cj/--agenda-frame-engage-open)
+ (should (eq focused 'work))
+ (should (eq opened source))
+ (should (eq (current-buffer) source))
+ (should (= (point) (line-beginning-position))))))
+ (kill-buffer source))))
+
+;;; Frame lifecycle — sticky buffer, timer-cancel, teardown cleanup
+
+(ert-deftest test-org-agenda-frame-sticky-buffer-name ()
+ "Normal: the sticky buffer is *Org Agenda(F)*; nil when absent."
+ (should (null (cj/--agenda-frame-sticky-buffer)))
+ (let ((buf (get-buffer-create "*Org Agenda(F)*")))
+ (unwind-protect
+ (should (eq (cj/--agenda-frame-sticky-buffer) buf))
+ (kill-buffer buf))))
+
+(ert-deftest test-org-agenda-frame-cancel-timer-safe-when-none ()
+ "Boundary: cancelling with no timer set does nothing and does not error."
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil)))
+ (should-not (cj/--agenda-frame-cancel-timer))))
+
+;;; Frame lifecycle — toggle dispatch
+
+(ert-deftest test-org-agenda-frame-toggle-spawns-when-none ()
+ "Normal: with no agenda frame, toggle spawns one."
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-spawn) (lambda () 'spawned)))
+ (should (eq (cj/--agenda-frame-toggle) 'spawned))))
+
+(ert-deftest test-org-agenda-frame-toggle-deletes-when-selected ()
+ "Normal: toggle from within the agenda frame deletes it."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'selected-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-delete)
+ (lambda () (setq deleted t))))
+ (cj/--agenda-frame-toggle)
+ (should deleted))))
+
+(ert-deftest test-org-agenda-frame-toggle-raises-when-unfocused ()
+ "Normal: toggle from a working frame raises the existing agenda frame."
+ (let (raised)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'selected-frame) (lambda () 'work))
+ ((symbol-function 'cj/--agenda-frame-raise)
+ (lambda (f) (setq raised f))))
+ (cj/--agenda-frame-toggle)
+ (should (eq raised 'af)))))
+
+;;; Frame lifecycle — delete
+
+(ert-deftest test-org-agenda-frame-delete-deletes-live-frame ()
+ "Normal: delete removes the live agenda frame."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'af)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-delete)
+ (should (eq deleted 'af)))))
+
+(ert-deftest test-org-agenda-frame-delete-noop-when-none ()
+ "Boundary: delete with no agenda frame does nothing."
+ (let (called)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil))
+ ((symbol-function 'delete-frame) (lambda (_f &rest _) (setq called t))))
+ (cj/--agenda-frame-delete)
+ (should-not called))))
+
+;;; Frame lifecycle — cleanup on frame death and buffer kill
+
+(ert-deftest test-org-agenda-frame-on-delete-cancels-and-kills-buffer ()
+ "Normal: deleting the agenda frame cancels its timer and kills the sticky buffer."
+ (let ((buf (get-buffer-create "*Org Agenda(F)*"))
+ cancelled)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) (setq cancelled t))))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should cancelled)
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-org-agenda-frame-on-delete-ignores-non-agenda-frame ()
+ "Boundary: a non-agenda frame deletion triggers no cleanup."
+ (let (cancelled)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-p) (lambda (_f) nil))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) (setq cancelled t))))
+ (cj/--agenda-frame-on-delete-frame 'work)
+ (should-not cancelled))))
+
+(ert-deftest test-org-agenda-frame-on-kill-buffer-deletes-frame ()
+ "Normal: killing the dedicated buffer deletes the frame."
+ (let (deleted)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (f) (eq f 'af)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-on-kill-buffer)
+ (should (eq deleted 'af)))))
+
+(ert-deftest test-org-agenda-frame-on-kill-buffer-guarded-during-teardown ()
+ "Boundary: during a teardown the buffer-kill hook does not re-delete the frame."
+ (let (deleted (cj/--agenda-frame-tearing-down t))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--agenda-frame-on-kill-buffer)
+ (should-not deleted))))
+
+;;; Auto-dim suspension while the agenda frame lives
+
+(ert-deftest test-org-agenda-frame-spawn-suspends-auto-dim ()
+ "Normal: spawning the frame turns auto-dim off and remembers it was on.
+The refresh tick's selection swing marks the working window non-selected;
+auto-dim's debounced dim then lands after the tick and the working frame
+visibly dims every five minutes."
+ (defvar auto-dim-other-buffers-mode)
+ (let ((auto-dim-other-buffers-mode t)
+ (cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (equal calls '(-1)))
+ (should cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-spawn-leaves-auto-dim-when-off ()
+ "Boundary: auto-dim already off -> spawn doesn't touch it, no restore later."
+ (defvar auto-dim-other-buffers-mode)
+ (let ((auto-dim-other-buffers-mode nil)
+ (cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (null calls))
+ (should-not cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-on-delete-restores-auto-dim ()
+ "Normal: closing the frame restores auto-dim when spawn had turned it off."
+ (let ((cj/--agenda-frame-dim-was-on t)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) nil)))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should (equal calls '(1)))
+ (should-not cj/--agenda-frame-dim-was-on))))
+
+(ert-deftest test-org-agenda-frame-on-delete-no-dim-restore-when-untouched ()
+ "Boundary: closing without a suspended auto-dim doesn't enable it."
+ (let ((cj/--agenda-frame-dim-was-on nil)
+ calls)
+ (cl-letf (((symbol-function 'auto-dim-other-buffers-mode)
+ (lambda (arg) (push arg calls)))
+ ((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t))
+ ((symbol-function 'cj/--agenda-frame-cancel-timer)
+ (lambda (&optional _f) nil)))
+ (cj/--agenda-frame-on-delete-frame 'af)
+ (should (null calls)))))
+
+;;; Frame lifecycle — transactional spawn rollback
+
+(ert-deftest test-org-agenda-frame-spawn-rolls-back-on-failure ()
+ "Error: a failure after make-frame deletes the partial frame, restores the
+working frame, and signals a user-error."
+ (let (deleted focus)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'pf))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focus f)))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) (error "boom")))
+ ((symbol-function 'frame-live-p) (lambda (f) (memq f '(pf launch))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (should-error (cj/--agenda-frame-spawn) :type 'user-error)
+ (should (eq deleted 'pf))
+ (should (eq focus 'launch)))))
+
+;;; Phase 2 — wall-clock alignment
+
+(ert-deftest test-org-agenda-frame-seconds-to-next-mark-aligned ()
+ "Normal: a time exactly on a 5-minute boundary yields a full period."
+ ;; 1000000200 is divisible by 300 (a :00/:05 wall-clock mark).
+ (should (= (cj/--agenda-frame-seconds-to-next-mark 1000000200 300) 300)))
+
+(ert-deftest test-org-agenda-frame-seconds-to-next-mark-midway ()
+ "Boundary: partway through a period returns the remainder to the next mark."
+ ;; 1000000200 + 120 -> 180 seconds remain to the next 300 mark.
+ (should (= (cj/--agenda-frame-seconds-to-next-mark 1000000320 300) 180)))
+
+;;; Phase 2 — deterministic point restoration
+
+(defun test-org-agenda-frame--make-agenda-buffer (lines source)
+ "Insert LINES into the current buffer; each is (TEXT . SRC-POS).
+A non-nil SRC-POS puts an org-marker into SOURCE at that position on the line."
+ (dolist (spec lines)
+ (let ((start (point)))
+ (insert (car spec) "\n")
+ (when (cdr spec)
+ (put-text-property start (1+ start) 'org-marker
+ (set-marker (make-marker) (cdr spec) source))))))
+
+(ert-deftest test-org-agenda-frame-restore-point-duplicate-nearest ()
+ "Normal: a source marker occurring twice restores the occurrence nearest the old line."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item @2" . 3) ("filler" . nil)
+ ("filler" . nil) ("item @5" . 3))
+ src)
+ (let ((old (set-marker (make-marker) 3 src)))
+ (cj/--agenda-frame-restore-point old 4))
+ ;; lines 2 and 5 both point at src pos 3; nearest to old-line 4 is line 5.
+ (should (= (line-number-at-pos) 5)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-missing-clamps ()
+ "Boundary: a gone marker clamps the old line into range and lands on an item."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item" . 3) ("item" . 5))
+ src)
+ ;; old-line 99 is past the end; clamp to last line (an item).
+ (let ((gone (set-marker (make-marker) 99 src)))
+ (cj/--agenda-frame-restore-point gone 99))
+ (should (get-text-property (line-beginning-position) 'org-marker)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-header-goes-to-first-item ()
+ "Boundary: with no marker match, a header line moves to the first item."
+ (let ((src (generate-new-buffer " *rp-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "aaaaaaaaaa\n"))
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item one" . 3) ("item two" . 5))
+ src)
+ (cj/--agenda-frame-restore-point nil 1) ; line 1 is the header
+ (should (= (line-number-at-pos) 2))
+ (should (get-text-property (line-beginning-position) 'org-marker)))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-restore-point-empty-buffer-start ()
+ "Error: an empty (item-less) view leaves point at buffer start."
+ (with-temp-buffer
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("only a header" . nil) ("no items here" . nil)) nil)
+ (cj/--agenda-frame-restore-point nil 2)
+ (should (= (point) (point-min)))))
+
+;;; Phase 2 — snapshot marker cloning
+
+(ert-deftest test-org-agenda-frame-clone-and-reinstall-markers ()
+ "Normal: cloned markers survive nulling the originals and reinstall live."
+ (let ((src (generate-new-buffer " *clone-src*")))
+ (unwind-protect
+ (let (clones)
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ (test-org-agenda-frame--make-agenda-buffer '(("item" . 4)) src)
+ (setq clones (cj/--agenda-frame-snapshot-markers (current-buffer)))
+ ;; Simulate org-agenda-reset-markers nulling the buffer's originals.
+ (let ((orig (get-text-property (point-min) 'org-marker)))
+ (set-marker orig nil)))
+ ;; Reinstall into a fresh buffer copy; the clone must still be live.
+ (with-temp-buffer
+ (test-org-agenda-frame--make-agenda-buffer '(("item" . nil)) nil)
+ (cj/--agenda-frame-reinstall-markers (current-buffer) clones)
+ (let ((m (get-text-property (point-min) 'org-marker)))
+ (should (markerp m))
+ (should (eq (marker-buffer m) src))
+ (should (= (marker-position m) 4)))))
+ (kill-buffer src))))
+
+;;; Phase 2 — failure latch (report once per consecutive-failure run)
+
+(ert-deftest test-org-agenda-frame-failure-latch-reports-once ()
+ "Normal: the first failure of a run reports; subsequent ones stay silent."
+ (let ((params '()))
+ (cl-letf (((symbol-function 'frame-parameter)
+ (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v))))
+ (should (cj/--agenda-frame-record-failure 'af)) ; 0 -> 1, report
+ (should-not (cj/--agenda-frame-record-failure 'af)) ; 1 -> 2, silent
+ (cj/--agenda-frame-clear-failure 'af)
+ (should (cj/--agenda-frame-record-failure 'af))))) ; reset -> report again
+
+;;; Phase 2 — timer start + duplicate prevention
+
+(ert-deftest test-org-agenda-frame-start-timer-sets-timer ()
+ "Normal: start-timer schedules and stores a timer when none exists."
+ (let (stored)
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f _p) nil))
+ ((symbol-function 'run-at-time) (lambda (&rest _) 'the-timer))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f _p v) (setq stored v))))
+ (should (eq (cj/--agenda-frame-start-timer 'af) 'the-timer))
+ (should (eq stored 'the-timer)))))
+
+(ert-deftest test-org-agenda-frame-start-timer-no-duplicate ()
+ "Boundary: a frame already carrying a live timer is not given a second one."
+ (let (called)
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f _p) 'existing))
+ ((symbol-function 'timerp) (lambda (x) (eq x 'existing)))
+ ((symbol-function 'run-at-time) (lambda (&rest _) (setq called t) 'new)))
+ (cj/--agenda-frame-start-timer 'af)
+ (should-not called))))
+
+;;; Phase 2 — do-redo orchestration (success and failure branches)
+
+(ert-deftest test-org-agenda-frame-do-redo-success-clears-and-releases ()
+ "Normal: a successful redo clears the failure latch and releases the snapshot."
+ (let ((params (list (cons 'cj/agenda-frame-fail-count 3)))
+ released)
+ (with-temp-buffer
+ (insert "agenda line\n")
+ (cl-letf (((symbol-function 'org-agenda-redo) (lambda (&rest _) nil))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'cj/--agenda-frame-release-snapshot)
+ (lambda (_s) (setq released t))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should (equal (alist-get 'cj/agenda-frame-fail-count params) 0))
+ (should released)))))
+
+(ert-deftest test-org-agenda-frame-do-redo-error-restores-reenables-reports ()
+ "Error: a redo that fails mid-rebuild restores the last-good buffer verbatim,
+re-enables the policy, shows one overlay, and reports once."
+ (let ((params '()) msgs)
+ (with-temp-buffer
+ (insert "good agenda content\n")
+ (unwind-protect
+ (cl-letf (((symbol-function 'org-agenda-redo)
+ (lambda (&rest _) (erase-buffer) (insert "PARTIAL") (error "boom")))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'message)
+ (lambda (fmt &rest a) (push (apply #'format fmt a) msgs))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil)
+ (should cj/agenda-frame-mode) ; policy re-enabled
+ (should (= 1 (length (cj/--agenda-frame-failure-overlays
+ (current-buffer))))) ; overlay shown
+ (should (string-match-p "good agenda content" (buffer-string))) ; restored
+ (should-not (string-match-p "PARTIAL" (buffer-string)))
+ (should (= 1 (seq-count (lambda (m) (string-match-p "refresh failed" m))
+ msgs))))
+ (cj/agenda-frame-mode -1)
+ (cj/--agenda-frame-remove-overlay (current-buffer))))))
+
+(ert-deftest test-org-agenda-frame-do-redo-follows-item-across-shift ()
+ "Normal: on a successful redo, point follows the same source item even when
+lines shift and the buffer's own markers are nulled (the reset-markers case).
+Guards against restoring by raw line number after the item moved."
+ (let ((src (generate-new-buffer " *shift-src*"))
+ (params '()))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ ;; Before: item B (src pos 5) sits on line 3, and point is on it.
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("item A" . 3) ("item B" . 5)) src)
+ (goto-char (point-min)) (forward-line 2) ; line 3, item B
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setf (alist-get p params) v)))
+ ((symbol-function 'org-agenda-redo)
+ (lambda (&rest _)
+ ;; Null the buffer's originals (as org-agenda-reset-markers
+ ;; does), then rebuild with item B shifted to line 4.
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (let ((m (get-text-property (line-beginning-position) 'org-marker)))
+ (when (markerp m) (set-marker m nil)))
+ (forward-line 1)))
+ (erase-buffer)
+ (test-org-agenda-frame--make-agenda-buffer
+ '(("header" . nil) ("new item" . 1) ("item A" . 3) ("item B" . 5))
+ src))))
+ (cj/--agenda-frame-do-redo 'af (current-buffer) nil))
+ ;; Point should be on the rebuilt item B (src pos 5), now line 4 --
+ ;; not clamped to old line 3 (which is now item A, src pos 3).
+ (let ((m (get-text-property (line-beginning-position) 'org-marker)))
+ (should (markerp m))
+ (should (= (marker-position m) 5))))
+ (kill-buffer src))))
+
+;;; Phase 2 — snapshot round-trip, release, safe-redo window contract, overlay
+
+(ert-deftest test-org-agenda-frame-restore-snapshot-round-trip ()
+ "Normal: snapshot then restore reinstates text and a live cloned marker."
+ (let ((src (generate-new-buffer " *ss-src*")))
+ (unwind-protect
+ (with-temp-buffer
+ (with-current-buffer src (insert "0123456789\n"))
+ (test-org-agenda-frame--make-agenda-buffer '(("item alpha" . 4)) src)
+ (let ((snap (cj/--agenda-frame-snapshot (current-buffer) nil)))
+ (erase-buffer)
+ (insert "CORRUPT")
+ (cj/--agenda-frame-restore-snapshot (current-buffer) snap nil)
+ (should (string-match-p "item alpha" (buffer-string)))
+ (let ((m (get-text-property (point-min) 'org-marker)))
+ (should (markerp m))
+ (should (eq (marker-buffer m) src))
+ (should (= (marker-position m) 4)))
+ (cj/--agenda-frame-release-snapshot snap)
+ (should-not (marker-buffer (cdr (car (plist-get snap :markers)))))))
+ (kill-buffer src))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-selects-and-restores-window ()
+ "Normal: the active tick runs the redo and restores the prior window (no focus theft)."
+ (let (redone (prev (selected-window)))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should redone)
+ (should (eq (selected-window) prev)))))
+
+(ert-deftest test-org-agenda-frame-overlay-idempotent ()
+ "Boundary: showing the failure overlay twice keeps a single overlay."
+ (with-temp-buffer
+ (insert "x\n")
+ (unwind-protect
+ (progn
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ (let ((first (car (cj/--agenda-frame-failure-overlays (current-buffer)))))
+ (cj/--agenda-frame-show-failure-overlay (current-buffer))
+ (should (equal (cj/--agenda-frame-failure-overlays (current-buffer))
+ (list first)))
+ (should (= 1 (seq-count (lambda (o) (overlay-get o 'before-string))
+ (overlays-in (point-min) (point-max)))))))
+ (cj/--agenda-frame-remove-overlay (current-buffer)))))
+
+;;; Phase 2 — public command + F8-family rebind
+
+(ert-deftest test-org-agenda-frame-public-toggle-wraps-private ()
+ "Normal: the interactive command delegates to the private toggle."
+ (let (called)
+ (cl-letf (((symbol-function 'cj/--agenda-frame-toggle)
+ (lambda () (setq called t))))
+ (call-interactively 'cj/agenda-frame-toggle)
+ (should called))))
+
+(ert-deftest test-org-agenda-frame-parameters-normal-tiled-frame ()
+ "Boundary: the agenda frame is a normal frame (the tiling WM places it
+side by side with the working frame), carrying the marker and a distinct name."
+ (let ((params (cj/--agenda-frame-make-parameters)))
+ (should (assq cj/--agenda-frame-parameter params))
+ (should (equal (cdr (assq 'name params)) "Full Agenda"))
+ ;; No fullscreen request -- a fullboth frame would cover the whole output
+ ;; instead of tiling beside the working frame.
+ (should-not (assq 'fullscreen params))))
+
+(ert-deftest test-org-agenda-frame-spawn-binds-sticky-and-current-window ()
+ "Normal: spawn dynamically binds sticky + current-window around the render.
+The custom command's own settings apply too late to name the buffer, so
+without these bindings the buffer is plain *Org Agenda* -- which matches
+the 0.75 below-selected display rule and splits the new frame with the
+working buffer left on top."
+ (let (seen-sticky seen-setup)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda)
+ (lambda (&rest _)
+ (setq seen-sticky org-agenda-sticky
+ seen-setup org-agenda-window-setup)))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (eq seen-sticky t))
+ (should (eq seen-setup 'current-window)))))
+
+(ert-deftest test-org-agenda-frame-spawn-forces-single-window ()
+ "Boundary: spawn collapses the frame to one window after rendering.
+A display rule that still splits the frame must not leave a second window
+showing the launch buffer."
+ (let (collapsed)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows)
+ (lambda (&rest _) (setq collapsed t)))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should collapsed))))
+
+(ert-deftest test-org-agenda-frame-spawn-resets-span-to-7 ()
+ "Normal: a fresh spawn opens at the documented seven-day default, even when a
+prior frame's session left `cj/--agenda-frame-span' at the day view."
+ (let ((cj/--agenda-frame-span 1))
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'delete-other-windows) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer) (lambda (_f) nil)))
+ (cj/--agenda-frame-spawn)
+ (should (equal cj/--agenda-frame-span 7)))))
+
+(ert-deftest test-org-agenda-frame-spawn-starts-timer ()
+ "Normal: a successful spawn starts the refresh timer for the new frame."
+ (let (timed)
+ (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch))
+ ((symbol-function 'make-frame) (lambda (&rest _) 'af))
+ ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil))
+ ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil))
+ ((symbol-function 'org-agenda) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'cj/--agenda-frame-start-timer)
+ (lambda (f) (setq timed f))))
+ (should (eq (cj/--agenda-frame-spawn) 'af))
+ (should (eq timed 'af)))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-inhibits-redisplay ()
+ "Normal: the tick runs with redisplay inhibited.
+The rebuild takes visible time; without this, the agenda window is the
+selected window for the whole rebuild and the user watches their cursor
+go hollow every five minutes -- indistinguishable from focus theft."
+ (let (seen (prev (selected-window)))
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq seen inhibit-redisplay))))
+ (cj/--agenda-frame-safe-redo)
+ (should (eq seen t)))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-skips-during-minibuffer ()
+ "Boundary: a tick while a minibuffer is active is skipped entirely.
+Reselecting windows under an active minibuffer session can break it; the
+next tick catches up."
+ (let (redone (prev (selected-window)))
+ (cl-letf (((symbol-function 'active-minibuffer-window) (lambda () 'mini))
+ ((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer)
+ (lambda () (current-buffer)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) prev))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should-not redone))))
+
+(ert-deftest test-org-agenda-frame-safe-redo-noop-when-not-shown ()
+ "Boundary: a tick with the buffer not shown in the frame does not redo or error."
+ (let (redone)
+ (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af))
+ ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil))
+ ((symbol-function 'get-buffer-window) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--agenda-frame-do-redo)
+ (lambda (&rest _) (setq redone t))))
+ (cj/--agenda-frame-safe-redo)
+ (should-not redone))))
+
+(ert-deftest test-org-agenda-frame-install-keys-rebinds-f8-family ()
+ "Normal: S-<f8> toggles the frame; the force-rescan moves to C-M-<f8>."
+ (let ((map (make-sparse-keymap)))
+ (cj/--agenda-frame-install-keys map)
+ (should (eq (lookup-key map (kbd "S-<f8>")) 'cj/agenda-frame-toggle))
+ (should (eq (lookup-key map (kbd "C-M-<f8>")) 'cj/org-agenda-refresh-files))))
+
+(provide 'test-org-agenda-frame)
+;;; test-org-agenda-frame.el ends here
diff --git a/tests/test-org-capture-config--neutralize.el b/tests/test-org-capture-config--neutralize.el
new file mode 100644
index 00000000..4a03830a
--- /dev/null
+++ b/tests/test-org-capture-config--neutralize.el
@@ -0,0 +1,136 @@
+;;; test-org-capture-config--neutralize.el --- Popup neutralize-guard tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the "org-capture" popup neutralize guards. The popup frame opens
+;; showing the daemon's last buffer; if a capture aborts before its UI paints,
+;; the popup lingers on that buffer, and a live eat/vterm terminal shown there
+;; clamps the real frame to the popup's rows. The guards repoint every
+;; non-capture-UI window of the popup at *scratch*: one fires on frame creation
+;; (after-make-frame-functions), one on any buffer change
+;; (window-buffer-change-functions).
+;;
+;; The tests drive the real batch frame (renamed to "org-capture" and restored
+;; in cleanup, the same idiom as the popup-window integration test) rather than
+;; mocking frame primitives. `window-buffer-change-functions' only runs during
+;; redisplay, so the module's own hook cannot fire mid-test in batch.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+(require 'org-capture)
+(require 'user-constants)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+(defmacro test-neutralize--with-popup-frame (buffer-name &rest body)
+ "Run BODY with the batch frame named \"org-capture\" showing BUFFER-NAME.
+The frame name reverts to auto-naming and the test buffer is killed
+afterward. BODY sees the buffer bound to `buf'."
+ (declare (indent 1))
+ `(let ((buf (get-buffer-create ,buffer-name)))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "org-capture")
+ (delete-other-windows)
+ (set-window-buffer (selected-window) buf)
+ ,@body)
+ ;; Restore to nil, not the saved name: the batch frame's auto name is
+ ;; "F1", and Emacs refuses to set F<num>-shaped names explicitly.
+ ;; nil reverts to auto-naming (same idiom as the popup-window test).
+ (set-frame-parameter nil 'name nil)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+;;; cj/org-capture--neutralize-frame
+
+(ert-deftest test-org-capture-config-neutralize-frame-evicts-plain-buffer ()
+ "Normal: a non-capture-UI buffer in the popup frame is repointed to *scratch*."
+ (test-neutralize--with-popup-frame "neutralize-plain.org"
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (equal (buffer-name (window-buffer (selected-window)))
+ "*scratch*"))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-spares-capture-buffer ()
+ "Normal: a CAPTURE-* buffer is capture UI and stays put."
+ (test-neutralize--with-popup-frame "CAPTURE-neutralize.org"
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) buf))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-spares-select-menu ()
+ "Normal: the *Org Select* template menu is capture UI and stays put."
+ (test-neutralize--with-popup-frame "*Org Select*"
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) buf))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-scratch-untouched ()
+ "Boundary: a window already on *scratch* is left alone (idempotent)."
+ (let ((scratch (get-buffer-create "*scratch*")))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "org-capture")
+ (delete-other-windows)
+ (set-window-buffer (selected-window) scratch)
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) scratch))
+ ;; Second pass is also a no-op.
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) scratch)))
+ (set-frame-parameter nil 'name nil))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-other-frame-untouched ()
+ "Boundary: a frame not named \"org-capture\" is never neutralized."
+ (let ((buf (get-buffer-create "neutralize-other-frame.org")))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "some-other-frame")
+ (delete-other-windows)
+ (set-window-buffer (selected-window) buf)
+ (cj/org-capture--neutralize-frame (selected-frame))
+ (should (eq (window-buffer (selected-window)) buf)))
+ (set-frame-parameter nil 'name nil)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-org-capture-config-neutralize-frame-dead-input-no-error ()
+ "Error: nil and non-frame inputs are ignored without raising."
+ (should-not (cj/org-capture--neutralize-frame nil))
+ (should-not (cj/org-capture--neutralize-frame 'not-a-frame)))
+
+;;; cj/org-capture--neutralize-new-frame (Guard 1 wrapper)
+
+(ert-deftest test-org-capture-config-neutralize-new-frame-delegates ()
+ "Normal: the frame-creation wrapper neutralizes the popup frame."
+ (test-neutralize--with-popup-frame "neutralize-new-frame.org"
+ (cj/org-capture--neutralize-new-frame (selected-frame))
+ (should (equal (buffer-name (window-buffer (selected-window)))
+ "*scratch*"))))
+
+;;; cj/org-capture--neutralize-on-buffer-change (Guard 2 wrapper)
+
+(ert-deftest test-org-capture-config-neutralize-on-buffer-change-window-arg ()
+ "Normal: a window argument resolves to its frame and neutralizes it.
+`window-buffer-change-functions' passes a window when buffer-local."
+ (test-neutralize--with-popup-frame "neutralize-window-arg.org"
+ (cj/org-capture--neutralize-on-buffer-change (selected-window))
+ (should (equal (buffer-name (window-buffer (selected-window)))
+ "*scratch*"))))
+
+(ert-deftest test-org-capture-config-neutralize-on-buffer-change-frame-arg ()
+ "Normal: a frame argument passes straight through.
+`window-buffer-change-functions' passes a frame when global."
+ (test-neutralize--with-popup-frame "neutralize-frame-arg.org"
+ (cj/org-capture--neutralize-on-buffer-change (selected-frame))
+ (should (equal (buffer-name (window-buffer (selected-window)))
+ "*scratch*"))))
+
+;;; Hook wiring
+
+(ert-deftest test-org-capture-config-neutralize-hooks-registered ()
+ "Normal: both guards are installed on their hooks at module load."
+ (should (memq #'cj/org-capture--neutralize-new-frame
+ after-make-frame-functions))
+ (should (memq #'cj/org-capture--neutralize-on-buffer-change
+ window-buffer-change-functions)))
+
+(provide 'test-org-capture-config--neutralize)
+;;; test-org-capture-config--neutralize.el ends here
diff --git a/tests/test-org-config-noop-state-log.el b/tests/test-org-config-noop-state-log.el
new file mode 100644
index 00000000..125eb300
--- /dev/null
+++ b/tests/test-org-config-noop-state-log.el
@@ -0,0 +1,59 @@
+;;; test-org-config-noop-state-log.el --- Suppress no-op state-change logs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; org's state-change logging writes "- State X from X" lines for no-op
+;; transitions (identical from/to state), which carry no information. With
+;; `org-log-into-drawer' nil those lines also land inline, where they wedge
+;; between a heading and its planning line and break org's parser. Two fixes
+;; in `cj/org-todo-settings': log state changes into :LOGBOOK: drawers
+;; (`org-log-into-drawer' t), and suppress no-op state logs at the
+;; `org-add-log-setup' choke point via `cj/org--suppress-noop-state-log'.
+
+;;; Code:
+
+(require 'ert)
+(require 'org) ;; declares the org-log-* vars special
+(require 'org-config)
+
+(ert-deftest test-org-config-log-into-drawer-enabled ()
+ "Normal: cj/org-todo-settings enables org-log-into-drawer (LOGBOOK)."
+ (let ((org-log-into-drawer nil))
+ (cj/org-todo-settings)
+ (should (eq org-log-into-drawer t))))
+
+(ert-deftest test-org-config-noop-state-log-p-identical-is-noop ()
+ "Normal: identical from/to state on a 'state purpose is a no-op."
+ (should (cj/org--noop-state-log-p 'state "TODO" "TODO")))
+
+(ert-deftest test-org-config-noop-state-log-p-real-change-not-noop ()
+ "Normal: a genuine state change is not a no-op."
+ (should-not (cj/org--noop-state-log-p 'state "DONE" "TODO")))
+
+(ert-deftest test-org-config-noop-state-log-p-nil-prev-not-noop ()
+ "Boundary: a nil previous state (initial log) is not a no-op."
+ (should-not (cj/org--noop-state-log-p 'state "TODO" nil)))
+
+(ert-deftest test-org-config-noop-state-log-p-non-state-purpose-not-noop ()
+ "Boundary: identical strings under a non-state purpose are not suppressed."
+ (should-not (cj/org--noop-state-log-p 'note "x" "x")))
+
+(ert-deftest test-org-config-noop-state-log-p-nil-both-not-noop ()
+ "Error: a nil/nil state pair is not a suppressible no-op."
+ (should-not (cj/org--noop-state-log-p 'state nil nil)))
+
+(ert-deftest test-org-config-suppress-advice-skips-noop ()
+ "Normal: the advice does NOT call through for a no-op state transition."
+ (let ((called nil))
+ (cj/org--suppress-noop-state-log
+ (lambda (&rest _) (setq called t)) 'state "TODO" "TODO")
+ (should-not called)))
+
+(ert-deftest test-org-config-suppress-advice-passes-real-change ()
+ "Normal: the advice calls through for a genuine state change."
+ (let ((called nil))
+ (cj/org--suppress-noop-state-log
+ (lambda (&rest _) (setq called t)) 'state "DONE" "TODO")
+ (should called)))
+
+(provide 'test-org-config-noop-state-log)
+;;; test-org-config-noop-state-log.el ends here
diff --git a/tests/test-org-contacts-config-find.el b/tests/test-org-contacts-config-find.el
new file mode 100644
index 00000000..60a5e713
--- /dev/null
+++ b/tests/test-org-contacts-config-find.el
@@ -0,0 +1,72 @@
+;;; test-org-contacts-config-find.el --- Tests for cj/org-contacts-find -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/org-contacts-find collects contact headings (name, position, and the
+;; EMAIL/PHONE annotation) before prompting, then jumps to the selected
+;; heading's stored position. These tests pin the collector helper and a
+;; command-level smoke test: the jump must land on the heading, never on a
+;; body line that merely mentions the name.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-contacts-config)
+
+;;; cj/--org-contacts-collect
+
+(ert-deftest test-org-contacts-collect-returns-name-position-info ()
+ "Normal: collector returns heading name, heading position, and EMAIL/PHONE."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Alice\n:PROPERTIES:\n:EMAIL: alice@example.com\n:END:\n"
+ "some body text mentioning Bob\n"
+ "* Bob\n:PROPERTIES:\n:PHONE: 555-1234\n:END:\n")
+ (let* ((alist (cj/--org-contacts-collect (current-buffer)))
+ (alice (assoc "Alice" alist))
+ (bob (assoc "Bob" alist)))
+ (should (equal (length alist) 2))
+ (should (equal (nth 2 alice) "alice@example.com"))
+ (should (equal (nth 2 bob) "555-1234"))
+ ;; positions land on the headings, not the body line that mentions Bob
+ (should (save-excursion (goto-char (nth 1 alice)) (looking-at "\\* Alice")))
+ (should (save-excursion (goto-char (nth 1 bob)) (looking-at "\\* Bob"))))))
+
+(ert-deftest test-org-contacts-collect-empty-buffer-returns-nil ()
+ "Boundary: a buffer with no headings yields an empty alist."
+ (with-temp-buffer
+ (org-mode)
+ (should (null (cj/--org-contacts-collect (current-buffer))))))
+
+(ert-deftest test-org-contacts-collect-heading-without-props-has-nil-info ()
+ "Error: a heading with no EMAIL or PHONE yields nil info."
+ (with-temp-buffer
+ (org-mode)
+ (insert "* Carol\n")
+ (let ((alist (cj/--org-contacts-collect (current-buffer))))
+ (should (equal (nth 2 (assoc "Carol" alist)) nil)))))
+
+;;; cj/org-contacts-find
+
+(ert-deftest test-org-contacts-find-jumps-to-heading-not-body ()
+ "Normal: selecting a contact jumps to its heading, not a body mention."
+ (let ((contacts-file (make-temp-file "org-contacts-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file contacts-file
+ (insert "* Alice\n:PROPERTIES:\n:EMAIL: alice@example.com\n:END:\n"
+ "note: call Bob about Alice\n"
+ "* Bob\n:PROPERTIES:\n:PHONE: 555-1234\n:END:\n"))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "Bob"))
+ ((symbol-function 'org-fold-show-entry) #'ignore)
+ ((symbol-function 'org-reveal) #'ignore))
+ (cj/org-contacts-find)
+ (should (looking-at "\\* Bob"))))
+ (delete-file contacts-file))))
+
+(provide 'test-org-contacts-config-find)
+;;; test-org-contacts-config-find.el ends here
diff --git a/tests/test-org-drill-config-source.el b/tests/test-org-drill-config-source.el
new file mode 100644
index 00000000..ccda99ef
--- /dev/null
+++ b/tests/test-org-drill-config-source.el
@@ -0,0 +1,31 @@
+;;; test-org-drill-config-source.el --- Tests for org-drill source selection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--org-drill-source-keywords decides how use-package obtains org-drill:
+;; a local dev checkout via :load-path when it exists, otherwise the upstream
+;; repo via :vc. Without this, a machine lacking the checkout hits :demand t
+;; against a nonexistent :load-path and drill fails to load entirely.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-drill-config)
+
+(ert-deftest test-org-drill-source-keywords-uses-load-path-when-checkout-exists ()
+ "Normal: an existing checkout directory selects :load-path."
+ (let ((dir (make-temp-file "org-drill-checkout-" t)))
+ (unwind-protect
+ (should (equal (cj/--org-drill-source-keywords dir)
+ (list :load-path dir)))
+ (delete-directory dir t))))
+
+(ert-deftest test-org-drill-source-keywords-falls-back-to-vc-when-absent ()
+ "Error: a missing checkout falls back to a :vc install spec."
+ (let ((kws (cj/--org-drill-source-keywords "/nonexistent/org-drill-xyz")))
+ (should (eq (car kws) :vc))
+ (should (plist-get (nth 1 kws) :url))))
+
+(provide 'test-org-drill-config-source)
+;;; test-org-drill-config-source.el ends here
diff --git a/tests/test-org-refile-config--advice-helpers.el b/tests/test-org-refile-config--advice-helpers.el
new file mode 100644
index 00000000..0d9979d8
--- /dev/null
+++ b/tests/test-org-refile-config--advice-helpers.el
@@ -0,0 +1,87 @@
+;;; test-org-refile-config--advice-helpers.el --- Tests for the refile advice helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the two named advice helpers extracted from anonymous lambdas
+;; in the org-refile use-package :config block:
+;;
+;; cj/org-refile--save-all-buffers (:after org-refile)
+;; cj/org-refile--ensure-targets-in-org-mode (:before org-refile-get-targets)
+;;
+;; They were anonymous `(lambda (&rest _) ...)' advices, which cannot be
+;; `advice-remove'd by reference and cannot be tested. Naming them makes both
+;; possible. The install-by-reference and removability are verified live in the
+;; daemon (the :config block doesn't run under batch make test); these tests
+;; pin the extracted logic.
+;;
+;; Test organization:
+;; - Normal Cases: ensure-targets visits each string-named target
+;; - Boundary Cases: empty targets, non-string cars, mixed list
+;; - Error Cases: a nil target list is a no-op
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-refile-config)
+
+;; The module's bare `(defvar org-refile-targets)' marks the symbol special only
+;; within its own file, so it isn't special here. Declare it (with a value) so
+;; the `let' bindings below bind it dynamically, the way the sibling
+;; test-org-refile-config-commands.el does.
+(defvar org-refile-targets nil)
+
+;;; cj/org-refile--ensure-targets-in-org-mode
+
+(ert-deftest test-org-refile-ensure-targets-visits-each-string-target ()
+ "Normal: every string-named target file is passed to the ensure helper."
+ (let ((org-refile-targets '(("/a.org" :maxlevel . 3)
+ ("/b.org" :maxlevel . 3)))
+ (seen '()))
+ (cl-letf (((symbol-function 'cj/org-refile-ensure-org-mode)
+ (lambda (f) (push f seen))))
+ (cj/org-refile--ensure-targets-in-org-mode))
+ (should (equal '("/a.org" "/b.org") (nreverse seen)))))
+
+;;; Boundary
+
+(ert-deftest test-org-refile-ensure-targets-skips-non-string-cars ()
+ "Boundary: a target whose car is not a string (a function/symbol spec) is
+skipped rather than passed to the ensure helper."
+ (let ((org-refile-targets `((,(lambda () '("/x.org")) :maxlevel . 2)
+ ("/real.org" :maxlevel . 2)
+ (org-agenda-files :maxlevel . 2)))
+ (seen '()))
+ (cl-letf (((symbol-function 'cj/org-refile-ensure-org-mode)
+ (lambda (f) (push f seen))))
+ (cj/org-refile--ensure-targets-in-org-mode))
+ (should (equal '("/real.org") seen))))
+
+(ert-deftest test-org-refile-ensure-targets-empty-list-is-noop ()
+ "Boundary: no targets means the ensure helper is never called."
+ (let ((org-refile-targets '())
+ (called nil))
+ (cl-letf (((symbol-function 'cj/org-refile-ensure-org-mode)
+ (lambda (_f) (setq called t))))
+ (cj/org-refile--ensure-targets-in-org-mode))
+ (should-not called)))
+
+;;; Error
+
+(ert-deftest test-org-refile-ensure-targets-nil-targets-does-not-signal ()
+ "Error: a nil `org-refile-targets' completes without signaling."
+ (let ((org-refile-targets nil))
+ (cl-letf (((symbol-function 'cj/org-refile-ensure-org-mode) #'ignore))
+ (should (progn (cj/org-refile--ensure-targets-in-org-mode) t)))))
+
+;;; cj/org-refile--save-all-buffers
+
+(ert-deftest test-org-refile-save-all-buffers-delegates ()
+ "Normal: the save helper calls `org-save-all-org-buffers'."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'org-save-all-org-buffers)
+ (lambda (&rest _) (setq called t))))
+ (cj/org-refile--save-all-buffers))
+ (should called)))
+
+(provide 'test-org-refile-config--advice-helpers)
+;;; test-org-refile-config--advice-helpers.el ends here
diff --git a/tests/test-org-reveal-config-keymap.el b/tests/test-org-reveal-config-keymap.el
new file mode 100644
index 00000000..17a21b19
--- /dev/null
+++ b/tests/test-org-reveal-config-keymap.el
@@ -0,0 +1,38 @@
+;;; test-org-reveal-config-keymap.el --- Tests for org-reveal-config prefix keymap -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The presentation commands are reached through `cj/reveal-map', a prefix
+;; keymap registered under "C-; p" via `cj/register-prefix-map'. These
+;; tests pin that structure so the module can't silently regress to raw
+;; `global-set-key' calls (which carry a hidden load-order dependency on
+;; keybindings.el establishing "C-;" as a prefix first).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'keybindings)
+(require 'org-reveal-config)
+
+(ert-deftest test-org-reveal-config-reveal-map-is-a-keymap ()
+ "Normal: `cj/reveal-map' is a keymap."
+ (should (keymapp cj/reveal-map)))
+
+(ert-deftest test-org-reveal-config-reveal-map-bindings ()
+ "Normal: each presentation command is reachable under `cj/reveal-map'."
+ (dolist (pair '(("SPC" . cj/reveal-present)
+ ("e" . cj/reveal-export)
+ ("p" . cj/reveal-preview-start)
+ ("s" . cj/reveal-preview-stop)
+ ("h" . cj/reveal-insert-header)
+ ("H" . cj/reveal-remove-headers)
+ ("n" . cj/reveal-new)))
+ (should (eq (keymap-lookup cj/reveal-map (car pair)) (cdr pair)))))
+
+(ert-deftest test-org-reveal-config-reveal-map-registered-under-p ()
+ "Normal: `cj/reveal-map' is registered under \"p\" in `cj/custom-keymap'."
+ (should (eq (keymap-lookup cj/custom-keymap "p") cj/reveal-map)))
+
+(provide 'test-org-reveal-config-keymap)
+;;; test-org-reveal-config-keymap.el ends here
diff --git a/tests/test-org-roam-config-format.el b/tests/test-org-roam-config-format.el
index e9378b7a..16370ca1 100644
--- a/tests/test-org-roam-config-format.el
+++ b/tests/test-org-roam-config-format.el
@@ -147,5 +147,21 @@ Returns the formatted file content."
(let ((result (test-format "Title" "id" "* Content")))
(should (string-match-p "#\\+FILETAGS: Topic\n\n\\* Content" result))))
+(defvar org-roam-capture-templates)
+
+(ert-deftest test-org-roam-config-immediate-insert-binding-is-dynamic ()
+ "Normal: the immediate-insert template binding reaches the insert call.
+Without the module's defvar, the byte-compiled let is a dead lexical
+binding: org-roam-node-insert would see the untouched global templates
+and :immediate-finish never applies -- the \"immediate\" insert opens a
+capture buffer. Asserted behaviorally (not via special-variable-p,
+whose answer differs between the package-loaded and bare batch envs)."
+ (let ((org-roam-capture-templates '(("d" "default")))
+ seen)
+ (cl-letf (((symbol-function 'org-roam-node-insert)
+ (lambda (&rest _) (setq seen org-roam-capture-templates))))
+ (cj/org-roam-node-insert-immediate nil))
+ (should (equal seen '(("d" "default" :immediate-finish t))))))
+
(provide 'test-org-roam-config-format)
;;; test-org-roam-config-format.el ends here
diff --git a/tests/test-org-roam-config-tag-and-find.el b/tests/test-org-roam-config-tag-and-find.el
index f98d8af0..97a7a683 100644
--- a/tests/test-org-roam-config-tag-and-find.el
+++ b/tests/test-org-roam-config-tag-and-find.el
@@ -147,6 +147,17 @@
;; The 4th arg is the subdir
(should (equal (nth 3 args) "recipes/"))))
+(ert-deftest test-org-roam-find-node-project-delegates-to-find-node ()
+ "Normal: `find-node-project' uses Project tag, 'p' key, project.org template."
+ (let ((args nil))
+ (cl-letf (((symbol-function 'cj/org-roam-find-node)
+ (lambda (&rest a) (setq args a))))
+ (cj/org-roam-find-node-project))
+ (should (equal (car args) "Project"))
+ (should (equal (cadr args) "p"))
+ ;; The 3rd arg is the template file, under the canonical roam-dir/templates/.
+ (should (string-suffix-p "templates/project.org" (nth 2 args)))))
+
;;; cj/org-roam-node-insert-immediate
(ert-deftest test-org-roam-node-insert-immediate-rebinds-templates-and-calls-insert ()
diff --git a/tests/test-pdf-config--reading-palette.el b/tests/test-pdf-config--reading-palette.el
new file mode 100644
index 00000000..6a02d838
--- /dev/null
+++ b/tests/test-pdf-config--reading-palette.el
@@ -0,0 +1,87 @@
+;;; test-pdf-config--reading-palette.el --- pdf reading-palette tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Pure-logic tests for the pdf-view reading-palette layer: name->colors
+;; resolution, the shipped default and order, and the cycle (palettes, then the
+;; no-palette state, wrapping). The live application (which drives
+;; `pdf-view-midnight-minor-mode') is exercised in the daemon, not here.
+;;
+;; Requires pdf-config, which uses use-package for pdf-tools; `make test' runs
+;; without the implicit package-initialize, so bootstrap `package' first (the
+;; shared format-wiring helper). pdf-tools is loaded lazily (:defer t), so this
+;; only needs the pure top-level helpers, not the pdf-view runtime.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+
+(format-test--ensure-packages-init)
+(require 'pdf-config)
+
+(declare-function cj/pdf--reading-palette-colors "pdf-config" (name))
+(declare-function cj/pdf--next-reading-palette "pdf-config" (current names))
+(defvar cj/pdf-reading-palettes)
+(defvar cj/pdf-reading-default-palette)
+
+;;; ------------------------ cj/pdf--reading-palette-colors --------------------
+
+(ert-deftest test-pdf-reading-palette-colors-known ()
+ "Normal: a known palette resolves to a (FG . BG) cons of two strings."
+ (let ((colors (cj/pdf--reading-palette-colors "dark")))
+ (should (consp colors))
+ (should (stringp (car colors)))
+ (should (stringp (cdr colors)))))
+
+(ert-deftest test-pdf-reading-palette-colors-unknown ()
+ "Error: an unknown palette name resolves to nil."
+ (should-not (cj/pdf--reading-palette-colors "nope")))
+
+(ert-deftest test-pdf-reading-palette-colors-nil ()
+ "Boundary: a nil name (the no-palette state) resolves to nil."
+ (should-not (cj/pdf--reading-palette-colors nil)))
+
+;;; --------------------------- shipped default + order ------------------------
+
+(ert-deftest test-pdf-reading-default-is-dark ()
+ "Normal: a fresh PDF opens on the dark palette."
+ (should (equal cj/pdf-reading-default-palette "dark")))
+
+(ert-deftest test-pdf-reading-order-is-dark-sepia-light ()
+ "Normal: the shipped palette order is dark, then sepia, then light."
+ (should (equal (mapcar #'car cj/pdf-reading-palettes)
+ '("dark" "sepia" "light"))))
+
+;;; ------------------------- cj/pdf--next-reading-palette ---------------------
+
+(ert-deftest test-pdf-reading-next-palette-advances ()
+ "Normal: cycles to the next palette in order."
+ (should (equal (cj/pdf--next-reading-palette "dark" '("dark" "sepia" "light"))
+ "sepia")))
+
+(ert-deftest test-pdf-reading-next-palette-last-to-none ()
+ "Boundary: the last palette cycles to the no-palette state (nil)."
+ (should-not (cj/pdf--next-reading-palette "light" '("dark" "sepia" "light"))))
+
+(ert-deftest test-pdf-reading-next-palette-none-to-first ()
+ "Boundary: the no-palette state (nil) cycles to the first palette."
+ (should (equal (cj/pdf--next-reading-palette nil '("dark" "sepia" "light"))
+ "dark")))
+
+(ert-deftest test-pdf-reading-next-palette-unknown-current-falls-to-first ()
+ "Error: an unknown current palette falls back to the first."
+ (should (equal (cj/pdf--next-reading-palette "gone" '("dark" "sepia" "light"))
+ "dark")))
+
+(ert-deftest test-pdf-reading-cycle-from-default ()
+ "Normal: cycling from the default advances dark -> sepia -> light -> none -> dark."
+ (let ((names (mapcar #'car cj/pdf-reading-palettes)))
+ (should (equal (cj/pdf--next-reading-palette "dark" names) "sepia"))
+ (should (equal (cj/pdf--next-reading-palette "sepia" names) "light"))
+ (should-not (cj/pdf--next-reading-palette "light" names))
+ (should (equal (cj/pdf--next-reading-palette nil names) "dark"))))
+
+(provide 'test-pdf-config--reading-palette)
+;;; test-pdf-config--reading-palette.el ends here
diff --git a/tests/test-pre-commit-hook.bats b/tests/test-pre-commit-hook.bats
new file mode 100644
index 00000000..413c71d0
--- /dev/null
+++ b/tests/test-pre-commit-hook.bats
@@ -0,0 +1,126 @@
+#!/usr/bin/env bats
+# Tests for githooks/pre-commit — the secret scan and paren check.
+#
+# The scan reads its input through a pipeline:
+#
+# added_lines="$(git diff --cached ... | grep '^+' | grep -v '^+++' || true)"
+#
+# `grep` exits 1 when it matches nothing, which is the ordinary case, so the
+# `|| true` has to stay. But with no `pipefail` it also swallows a failure of
+# `git diff` itself, and an empty `added_lines` makes the scan search nothing,
+# find nothing, and report clean. A gate that passes without looking is the
+# failure this file exists to pin: the fail-open test drives a broken `git diff`
+# and asserts the hook refuses rather than exiting 0.
+#
+# Each test builds a throwaway git repo in BATS_TEST_TMPDIR, so nothing touches
+# the real repository or its hooks.
+
+setup() {
+ HOOK="${BATS_TEST_DIRNAME}/../githooks/pre-commit"
+ REPO="${BATS_TEST_TMPDIR}/repo"
+ mkdir -p "$REPO"
+ cd "$REPO" || return 1
+ git init -q .
+ git config user.email t@example.com
+ git config user.name Test
+ # Split so the fixtures never appear as credential-shaped literals here.
+ AWS_TAIL="IOSFODNN7EXAMPLE"
+ WORD_TAIL="word"
+}
+
+# Put a stub `git` ahead of the real one that fails for the staged-diff call
+# and delegates everything else, so only the pipeline under test breaks.
+break_staged_diff() {
+ mkdir -p "${BATS_TEST_TMPDIR}/bin"
+ cat > "${BATS_TEST_TMPDIR}/bin/git" <<'STUB'
+#!/usr/bin/env bash
+if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ] && [ "${3:-}" = "-U0" ]; then
+ echo "simulated git failure" >&2
+ exit 128
+fi
+exec /usr/bin/git "$@"
+STUB
+ chmod +x "${BATS_TEST_TMPDIR}/bin/git"
+ PATH="${BATS_TEST_TMPDIR}/bin:$PATH"
+}
+
+# ------------------------------- Normal cases -------------------------------
+
+@test "secret scan: blocks a staged AWS key" {
+ # Assembled at runtime: a literal key-shaped string in this file would trip
+ # the very hook under test on every commit that touches it, and this repo
+ # mirrors to a public remote.
+ printf 'aws = "%s"\n' "AKIA${AWS_TAIL}" > creds.txt
+ git add creds.txt
+ run "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"potential secret"* ]]
+}
+
+@test "secret scan: blocks a staged keyword=value password" {
+ printf '%s = "%s"\n' "pass${WORD_TAIL}" "correcthorsebatterystaple" > conf.txt
+ git add conf.txt
+ run "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"potential secret"* ]]
+}
+
+@test "secret scan: allows an ordinary staged file" {
+ printf 'just some prose\n' > notes.txt
+ git add notes.txt
+ run "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# ------------------------------ Boundary cases ------------------------------
+
+@test "secret scan: allows a commit with nothing staged" {
+ run "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "paren check: blocks an unbalanced staged .el file" {
+ printf '(defun broken ()\n (message "no close"\n' > bad.el
+ git add bad.el
+ run "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"paren check failed"* ]]
+}
+
+@test "paren check: allows a balanced staged .el file" {
+ printf '(defun fine ()\n (message "ok"))\n' > good.el
+ git add good.el
+ run "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# -------------------------------- Error cases -------------------------------
+
+@test "secret scan: refuses to pass when the staged diff cannot be read" {
+ # The scan must not report clean after searching nothing. Without a
+ # pipefail-aware guard the broken diff yields an empty added_lines and the
+ # hook exits 0, letting a real secret through unscanned.
+ printf 'aws = "%s"\n' "AKIA${AWS_TAIL}" > creds.txt
+ git add creds.txt
+ break_staged_diff
+ run "$HOOK"
+ [ "$status" -ne 0 ]
+}
+
+@test "paren check: refuses to pass when the staged file list cannot be read" {
+ printf '(defun broken ()\n (message "no close"\n' > bad.el
+ git add bad.el
+ mkdir -p "${BATS_TEST_TMPDIR}/bin2"
+ cat > "${BATS_TEST_TMPDIR}/bin2/git" <<'STUB'
+#!/usr/bin/env bash
+if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ] && [ "${3:-}" = "--name-only" ]; then
+ echo "simulated git failure" >&2
+ exit 128
+fi
+exec /usr/bin/git "$@"
+STUB
+ chmod +x "${BATS_TEST_TMPDIR}/bin2/git"
+ PATH="${BATS_TEST_TMPDIR}/bin2:$PATH"
+ run "$HOOK"
+ [ "$status" -ne 0 ]
+}
diff --git a/tests/test-prog-c--tool-warnings.el b/tests/test-prog-c--tool-warnings.el
new file mode 100644
index 00000000..9d4da57e
--- /dev/null
+++ b/tests/test-prog-c--tool-warnings.el
@@ -0,0 +1,34 @@
+;;; test-prog-c--tool-warnings.el --- Load-time missing-tool warnings -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit flagged clangd for the load-time missing-tool
+;; warning pyright/prettier already have. clang-format is the same
+;; class: its use-package block gates on `:if (executable-find ...)',
+;; which evaluates once at startup, so an absent binary silently
+;; disables the format key until the next restart — the warn is the
+;; only visible trace.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+(format-test--ensure-packages-init)
+(require 'prog-c)
+
+(ert-deftest test-prog-c-warns-when-tools-missing ()
+ "Error: loading without the C tools on PATH warns for each one."
+ (let ((warned '()))
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (push msg warned))))
+ (load (expand-file-name "modules/prog-c.el" user-emacs-directory) nil t))
+ (dolist (tool '("clangd" "clang-format"))
+ (should (cl-some (lambda (m) (string-match-p (regexp-quote tool) m))
+ warned)))))
+
+(provide 'test-prog-c--tool-warnings)
+;;; test-prog-c--tool-warnings.el ends here
diff --git a/tests/test-prog-general--pin-go-treesit-revision.el b/tests/test-prog-general--pin-go-treesit-revision.el
new file mode 100644
index 00000000..42845b4f
--- /dev/null
+++ b/tests/test-prog-general--pin-go-treesit-revision.el
@@ -0,0 +1,51 @@
+;;; test-prog-general--pin-go-treesit-revision.el --- Go grammar pinning -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The treesit-auto Go recipe is pinned for compatibility with Emacs 30.2.
+;; Keep the mutation independent of cl-defstruct's compile-time setter
+;; expansion: prog-general is loaded before treesit-auto defines that setter
+;; during a normal startup.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; Deliberately put `revision' at a different offset from treesit-auto's real
+;; struct. The production helper must discover the slot rather than hard-code
+;; the package's current vector layout.
+(cl-defstruct treesit-auto-recipe lang revision url)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-general)
+
+(ert-deftest test-prog-general-pin-go-treesit-revision-updates-go ()
+ "Normal: the Go recipe receives the supported grammar revision."
+ (let* ((go (make-treesit-auto-recipe
+ :lang 'go :revision "main" :url "https://example.test/go"))
+ (python (make-treesit-auto-recipe
+ :lang 'python :revision "main"
+ :url "https://example.test/python")))
+ (cj/treesit-auto-pin-go-revision (list python go))
+ (should (equal (treesit-auto-recipe-revision go) "v0.19.1"))
+ (should (equal (treesit-auto-recipe-revision python) "main"))))
+
+(ert-deftest test-prog-general-pin-go-treesit-revision-no-go-is-no-op ()
+ "Boundary: a recipe list without Go remains unchanged."
+ (let ((python (make-treesit-auto-recipe
+ :lang 'python :revision "main"
+ :url "https://example.test/python")))
+ (should-not (cj/treesit-auto-pin-go-revision (list python)))
+ (should (equal (treesit-auto-recipe-revision python) "main"))))
+
+(ert-deftest test-prog-general-pin-go-treesit-revision-empty-is-no-op ()
+ "Boundary: an empty recipe list does not signal an error."
+ (should-not (cj/treesit-auto-pin-go-revision nil)))
+
+(ert-deftest test-prog-general-pin-go-treesit-revision-malformed-recipe-errors ()
+ "Error: a malformed recipe signals instead of silently skipping the pin."
+ (should-error (cj/treesit-auto-pin-go-revision '(not-a-recipe))
+ :type 'wrong-type-argument))
+
+(provide 'test-prog-general--pin-go-treesit-revision)
+;;; test-prog-general--pin-go-treesit-revision.el ends here
diff --git a/tests/test-prog-general-lsp.el b/tests/test-prog-general-lsp.el
new file mode 100644
index 00000000..e61e9a17
--- /dev/null
+++ b/tests/test-prog-general-lsp.el
@@ -0,0 +1,148 @@
+;;; test-prog-general-lsp.el --- LSP config + helper tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the LSP configuration in prog-general.el, the single owner of
+;; generic LSP policy (prog-lsp.el was folded in and removed 2026-07-10).
+;;
+;; Two layers:
+;; - Load-time invariants that hold the moment the config loads, before any
+;; server starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start
+;; a slow LSP), and no mode accrues a duplicate lsp-deferred entry.
+;; - The pure helpers cj/lsp--add-file-watch-ignored-extras and
+;; cj/lsp--remove-eldoc-provider-global, exercised directly with Normal /
+;; Boundary / Error cases.
+;;
+;; The quiet-UI :config defaults (snippet off, symbol highlighting off,
+;; idle-delay 0.5, ...) are deferred to lsp-mode's own load (see the make-test
+;; no-package-initialize note in CLAUDE.md), so they aren't asserted here; the
+;; daemon verifies them.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'use-package)
+
+;; Declare lsp-mode's defcustom / eldoc's hook as special so `let' binds them
+;; dynamically. Real definitions load only when lsp-mode / eldoc activate; in
+;; the test environment these provide the value cells the helpers read through.
+(defvar lsp-file-watch-ignored-directories nil)
+(defvar eldoc-documentation-functions nil)
+
+(defun lsp-eldoc-function (&rest _args)
+ "Stub lsp-mode Eldoc function for tests.")
+
+(require 'prog-general)
+
+;;; Load-time invariants
+
+(ert-deftest test-prog-general-lsp-enable-remote-nil ()
+ "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files."
+ (should (boundp 'lsp-enable-remote))
+ (should (null lsp-enable-remote)))
+
+(ert-deftest test-prog-general-lsp-no-duplicate-mode-hook ()
+ "Boundary: a mode never holds more than one lsp-deferred entry.
+The per-language modules add lsp-deferred to their own mode hooks; add-hook
+dedups identical symbols, and this pins that invariant so a future non-symbol
+\(lambda) addition that breaks it gets caught."
+ (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook))
+ (when (boundp hook)
+ (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook)))))))
+
+;;; File-watch ignore helper — Normal
+
+(ert-deftest test-prog-general-lsp-file-watch-adds-all-patterns ()
+ "Normal: every entry from `cj/lsp-file-watch-ignored-extras' lands in the list."
+ (let ((lsp-file-watch-ignored-directories nil))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (should (= (length lsp-file-watch-ignored-directories)
+ (length cj/lsp-file-watch-ignored-extras)))
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (member pattern lsp-file-watch-ignored-directories)))))
+
+(ert-deftest test-prog-general-lsp-file-watch-extends-not-replaces ()
+ "Normal: pre-existing entries (lsp-mode defaults) are preserved."
+ (let ((lsp-file-watch-ignored-directories
+ '("[/\\\\]\\.git\\'" "[/\\\\]\\.svn\\'" "[/\\\\]\\.idea\\'")))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories))
+ (should (member "[/\\\\]\\.svn\\'" lsp-file-watch-ignored-directories))
+ (should (member "[/\\\\]\\.idea\\'" lsp-file-watch-ignored-directories))
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (member pattern lsp-file-watch-ignored-directories)))))
+
+(ert-deftest test-prog-general-lsp-file-watch-key-patterns-present ()
+ "Normal: specific expected directory names appear in the constant."
+ (dolist (name '("node_modules" "target" "__pycache__" ".venv" "venv"
+ "dist" "coverage" "test-results" "playwright-report"
+ ".terraform" ".ruff_cache" ".pytest_cache" ".mypy_cache"))
+ (should (cl-some (lambda (p) (string-match-p (regexp-quote name) p))
+ cj/lsp-file-watch-ignored-extras))))
+
+;;; File-watch ignore helper — Boundary
+
+(ert-deftest test-prog-general-lsp-file-watch-idempotent ()
+ "Boundary: calling twice leaves each pattern present exactly once."
+ (let ((lsp-file-watch-ignored-directories nil))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (cj/lsp--add-file-watch-ignored-extras)
+ (should (= (length lsp-file-watch-ignored-directories)
+ (length cj/lsp-file-watch-ignored-extras)))
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories
+ :test #'equal))))))
+
+(ert-deftest test-prog-general-lsp-file-watch-all-patterns-non-empty ()
+ "Boundary: every pattern is a non-empty string."
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (stringp pattern))
+ (should (not (string-empty-p pattern)))))
+
+(ert-deftest test-prog-general-lsp-file-watch-all-patterns-valid-regex ()
+ "Boundary: every pattern compiles as a valid Emacs regex."
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (condition-case err
+ ;; string-match-p compiles the regex; invalid syntax raises invalid-regexp.
+ (string-match-p pattern "/some/sample/path")
+ (invalid-regexp
+ (ert-fail (format "Invalid regex %S: %s"
+ pattern (error-message-string err)))))))
+
+;;; File-watch ignore helper — Error
+
+(ert-deftest test-prog-general-lsp-file-watch-non-list-target ()
+ "Error: non-list target value triggers `add-to-list' wrong-type-argument."
+ (let ((lsp-file-watch-ignored-directories "not-a-list"))
+ (should-error (cj/lsp--add-file-watch-ignored-extras)
+ :type 'wrong-type-argument)))
+
+;;; Eldoc provider helper
+
+(ert-deftest test-prog-general-lsp-eldoc-provider-removed-globally ()
+ "Normal: remove lsp-mode's Eldoc provider from the global hook value.
+The per-buffer removal this replaced raced lsp-mode's own buffer-local hook
+population; removing globally before any LSP buffer attaches makes the absence
+stick for every subsequent lsp-managed buffer."
+ (let ((eldoc-documentation-functions
+ (list #'lsp-eldoc-function 'eldoc-documentation-default)))
+ (cj/lsp--remove-eldoc-provider-global)
+ (should-not (memq #'lsp-eldoc-function eldoc-documentation-functions))
+ (should (memq 'eldoc-documentation-default eldoc-documentation-functions))))
+
+(ert-deftest test-prog-general-lsp-eldoc-provider-removal-idempotent ()
+ "Boundary: re-running the removal after the provider is gone is a no-op."
+ (let ((eldoc-documentation-functions '(eldoc-documentation-default)))
+ (cj/lsp--remove-eldoc-provider-global)
+ (cj/lsp--remove-eldoc-provider-global)
+ (should (equal eldoc-documentation-functions
+ '(eldoc-documentation-default)))))
+
+(ert-deftest test-prog-general-lsp-no-obsolete-eldoc-hook-reference ()
+ "Regression: prog-general should not reference obsolete `lsp-eldoc-hook'."
+ (with-temp-buffer
+ (insert-file-contents (expand-file-name "modules/prog-general.el" user-emacs-directory))
+ (should-not (re-search-forward "\\_<lsp-eldoc-hook\\_>" nil t))))
+
+(provide 'test-prog-general-lsp)
+;;; test-prog-general-lsp.el ends here
diff --git a/tests/test-prog-go--classic-mode-hooks.el b/tests/test-prog-go--classic-mode-hooks.el
new file mode 100644
index 00000000..a9defe2e
--- /dev/null
+++ b/tests/test-prog-go--classic-mode-hooks.el
@@ -0,0 +1,46 @@
+;;; test-prog-go--classic-mode-hooks.el --- Classic go-mode hooks + gopls warn -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit found the Go setup hooks attached to go-ts-mode
+;; only, so a fallback to classic go-mode silently lost indent, keys,
+;; and LSP. It also flagged gopls for the load-time missing-tool
+;; warning pyright/prettier already have; that warn is only honest if
+;; ~/go/bin is on `exec-path' at load time (it used to join only in
+;; go-mode's deferred :config, so gopls installed there read as
+;; missing).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+(format-test--ensure-packages-init)
+(require 'prog-go)
+
+(ert-deftest test-prog-go-hooks-cover-classic-go-mode ()
+ "Normal: classic go-mode runs the same setup + keybindings as go-ts-mode."
+ (dolist (hook '(go-mode-hook go-ts-mode-hook))
+ (should (memq #'cj/go-setup (symbol-value hook)))
+ (should (memq #'cj/go-mode-keybindings (symbol-value hook)))))
+
+(ert-deftest test-prog-go-bin-path-on-exec-path-at-load ()
+ "Normal: ~/go/bin joins `exec-path' at module load, not first go buffer.
+The load-time gopls warn resolves through `exec-path'; registering the
+Go bin directory only in go-mode's deferred :config would make gopls
+installed there warn as missing on every start."
+ (should (member go-bin-path exec-path)))
+
+(ert-deftest test-prog-go-warns-when-gopls-missing ()
+ "Error: loading the module without gopls on PATH warns about gopls."
+ (let ((warned '()))
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (push msg warned))))
+ (load (expand-file-name "modules/prog-go.el" user-emacs-directory) nil t))
+ (should (cl-some (lambda (m) (string-match-p "gopls" m)) warned))))
+
+(provide 'test-prog-go--classic-mode-hooks)
+;;; test-prog-go--classic-mode-hooks.el ends here
diff --git a/tests/test-prog-lsp--add-file-watch-ignored-extras.el b/tests/test-prog-lsp--add-file-watch-ignored-extras.el
deleted file mode 100644
index 9b71cab8..00000000
--- a/tests/test-prog-lsp--add-file-watch-ignored-extras.el
+++ /dev/null
@@ -1,116 +0,0 @@
-;;; test-prog-lsp--add-file-watch-ignored-extras.el --- Tests for cj/lsp--add-file-watch-ignored-extras -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Tests for cj/lsp--add-file-watch-ignored-extras in prog-lsp.el.
-;; The function adds project-agnostic build/cache directory patterns to
-;; `lsp-file-watch-ignored-directories' without replacing lsp-mode's
-;; defaults. Patterns are sourced from `cj/lsp-file-watch-ignored-extras'.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-;; Declare lsp-mode's defcustom as a special variable so `let' binds it
-;; dynamically. Real definition is lsp-mode's; loaded only when use-package
-;; activates lsp-mode. In the test environment, this stub provides the value
-;; cell `add-to-list' needs.
-(defvar lsp-file-watch-ignored-directories nil)
-(defvar eldoc-documentation-functions nil)
-
-(defun lsp-eldoc-function (&rest _args)
- "Stub lsp-mode Eldoc function for tests.")
-
-(require 'prog-lsp)
-
-;;; Normal Cases
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-adds-all-patterns ()
- "Normal: every entry from `cj/lsp-file-watch-ignored-extras' lands in the list."
- (let ((lsp-file-watch-ignored-directories nil))
- (cj/lsp--add-file-watch-ignored-extras)
- (should (= (length lsp-file-watch-ignored-directories)
- (length cj/lsp-file-watch-ignored-extras)))
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (member pattern lsp-file-watch-ignored-directories)))))
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-extends-not-replaces ()
- "Normal: pre-existing entries (lsp-mode defaults) are preserved."
- (let ((lsp-file-watch-ignored-directories
- '("[/\\\\]\\.git\\'" "[/\\\\]\\.svn\\'" "[/\\\\]\\.idea\\'")))
- (cj/lsp--add-file-watch-ignored-extras)
- (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories))
- (should (member "[/\\\\]\\.svn\\'" lsp-file-watch-ignored-directories))
- (should (member "[/\\\\]\\.idea\\'" lsp-file-watch-ignored-directories))
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (member pattern lsp-file-watch-ignored-directories)))))
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-key-patterns-present ()
- "Normal: specific expected directory names appear in the constant."
- (dolist (name '("node_modules" "target" "__pycache__" ".venv" "venv"
- "dist" "coverage" "test-results" "playwright-report"
- ".terraform" ".ruff_cache" ".pytest_cache" ".mypy_cache"))
- (should (cl-some (lambda (p) (string-match-p (regexp-quote name) p))
- cj/lsp-file-watch-ignored-extras))))
-
-;;; Boundary Cases
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-idempotent ()
- "Boundary: calling twice doesn't duplicate entries."
- (let ((lsp-file-watch-ignored-directories nil))
- (cj/lsp--add-file-watch-ignored-extras)
- (cj/lsp--add-file-watch-ignored-extras)
- (should (= (length lsp-file-watch-ignored-directories)
- (length cj/lsp-file-watch-ignored-extras)))))
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-all-patterns-non-empty ()
- "Boundary: every pattern is a non-empty string."
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (stringp pattern))
- (should (not (string-empty-p pattern)))))
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-all-patterns-valid-regex ()
- "Boundary: every pattern compiles as a valid Emacs regex."
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (condition-case err
- ;; string-match-p compiles the regex; invalid syntax raises invalid-regexp.
- (string-match-p pattern "/some/sample/path")
- (invalid-regexp
- (ert-fail (format "Invalid regex %S: %s"
- pattern (error-message-string err)))))))
-
-;;; Error Cases
-
-(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-error-non-list-target ()
- "Error: non-list target value triggers `add-to-list' wrong-type-argument."
- (let ((lsp-file-watch-ignored-directories "not-a-list"))
- (should-error (cj/lsp--add-file-watch-ignored-extras)
- :type 'wrong-type-argument)))
-
-(ert-deftest test-prog-lsp--remove-eldoc-provider-global-removes-from-default ()
- "Normal: remove lsp-mode's Eldoc provider from the global hook value.
-The per-buffer removal that this replaced raced lsp-mode's own buffer-
-local hook population; removing globally before any LSP buffer attaches
-makes the absence stick for every subsequent lsp-managed buffer."
- (let ((eldoc-documentation-functions
- '(lsp-eldoc-function eldoc-documentation-default)))
- (cj/lsp--remove-eldoc-provider-global)
- (should-not (memq #'lsp-eldoc-function eldoc-documentation-functions))
- (should (memq 'eldoc-documentation-default eldoc-documentation-functions))))
-
-(ert-deftest test-prog-lsp--remove-eldoc-provider-global-is-idempotent ()
- "Boundary: re-running the removal after the provider is gone is a no-op."
- (let ((eldoc-documentation-functions '(eldoc-documentation-default)))
- (cj/lsp--remove-eldoc-provider-global)
- (cj/lsp--remove-eldoc-provider-global)
- (should (equal eldoc-documentation-functions
- '(eldoc-documentation-default)))))
-
-(ert-deftest test-prog-lsp--module-no-obsolete-lsp-eldoc-hook-reference ()
- "Regression: prog-lsp should not reference obsolete `lsp-eldoc-hook'."
- (with-temp-buffer
- (insert-file-contents (expand-file-name "modules/prog-lsp.el" user-emacs-directory))
- (should-not (re-search-forward "\\_<lsp-eldoc-hook\\_>" nil t))))
-
-(provide 'test-prog-lsp--add-file-watch-ignored-extras)
-;;; test-prog-lsp--add-file-watch-ignored-extras.el ends here
diff --git a/tests/test-prog-lsp.el b/tests/test-prog-lsp.el
deleted file mode 100644
index 7e38111d..00000000
--- a/tests/test-prog-lsp.el
+++ /dev/null
@@ -1,66 +0,0 @@
-;;; test-prog-lsp.el --- Startup smoke test for LSP config resolution -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; A narrow smoke test of prog-lsp.el, the central LSP module. It pins the
-;; invariants that should hold the moment the config loads, before any server
-;; starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start a slow
-;; LSP), the file-watch-ignore defaults live in one idempotent place, the eldoc
-;; provider is stripped from the global hook, and a mode never accrues a
-;; duplicate lsp-deferred entry. The generic :config defaults are deferred to
-;; lsp-mode's own load (see the make-test no-package-initialize note in
-;; CLAUDE.md), so this tests the top-level :init and helper surface, which runs.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-(require 'use-package)
-(require 'prog-lsp)
-
-;; lsp-mode's defcustom isn't loaded under make test, and prog-lsp's bare
-;; `(defvar lsp-file-watch-ignored-directories)' only marks it special within
-;; that file's unit. Declare it special here too so the `let' bindings below
-;; bind dynamically (the helper reads it through the symbol via add-to-list).
-(defvar lsp-file-watch-ignored-directories nil)
-
-(ert-deftest test-prog-lsp-enable-remote-nil ()
- "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files."
- (should (boundp 'lsp-enable-remote))
- (should (null lsp-enable-remote)))
-
-(ert-deftest test-prog-lsp-file-watch-adds-extras ()
- "Normal: the build/cache ignore patterns get appended to lsp's watch-ignore list."
- (let ((lsp-file-watch-ignored-directories '("[/\\\\]\\.git\\'")))
- (cj/lsp--add-file-watch-ignored-extras)
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (member pattern lsp-file-watch-ignored-directories)))
- (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories))))
-
-(ert-deftest test-prog-lsp-file-watch-idempotent ()
- "Boundary: adding the extras twice leaves each pattern present exactly once."
- (let ((lsp-file-watch-ignored-directories '()))
- (cj/lsp--add-file-watch-ignored-extras)
- (cj/lsp--add-file-watch-ignored-extras)
- (dolist (pattern cj/lsp-file-watch-ignored-extras)
- (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories
- :test #'equal))))))
-
-(ert-deftest test-prog-lsp-eldoc-provider-removed-globally ()
- "Normal: the global eldoc provider is stripped so lsp can't reattach it."
- (let ((eldoc-documentation-functions
- (list #'lsp-eldoc-function #'ignore)))
- (cj/lsp--remove-eldoc-provider-global)
- (should-not (memq 'lsp-eldoc-function eldoc-documentation-functions))
- (should (memq 'ignore eldoc-documentation-functions))))
-
-(ert-deftest test-prog-lsp-no-duplicate-mode-hook ()
- "Boundary: a mode prog-lsp wires never holds more than one lsp-deferred entry.
-prog-lsp and the per-language modules both add lsp-deferred for some modes;
-add-hook dedups identical symbols, and this pins that invariant so a future
-non-symbol (lambda) addition that breaks it gets caught."
- (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook))
- (when (boundp hook)
- (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook)))))))
-
-(provide 'test-prog-lsp)
-;;; test-prog-lsp.el ends here
diff --git a/tests/test-prog-python--lsp-guard.el b/tests/test-prog-python--lsp-guard.el
new file mode 100644
index 00000000..071e1ca9
--- /dev/null
+++ b/tests/test-prog-python--lsp-guard.el
@@ -0,0 +1,64 @@
+;;; test-prog-python--lsp-guard.el --- Python LSP guard tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit found lsp-pyright's unguarded :hook lambda calling
+;; (require 'lsp-pyright) + (lsp-deferred) on every python-ts buffer, so
+;; pyright-less machines got the LSP attach prompt that cj/python-setup's
+;; guard exists to prevent. The guarded branch now owns the require and
+;; the attach, and both classic and treesit modes run the same setup.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'prog-python)
+
+(defmacro test-python-guard--setup (pyright-found &rest body)
+ "Run `cj/python-setup' with pyright presence set to PYRIGHT-FOUND.
+Records requires into `required' and lsp attaches into `attached';
+BODY sees both. Package minor modes are stubbed at their boundary."
+ (declare (indent 1))
+ `(let ((required '()) (attached nil))
+ (cl-letf (((symbol-function 'company-mode) #'ignore)
+ ((symbol-function 'flyspell-prog-mode) #'ignore)
+ ((symbol-function 'superword-mode) #'ignore)
+ ((symbol-function 'executable-find)
+ (lambda (&rest _) ,pyright-found))
+ ((symbol-function 'require)
+ (lambda (feature &rest _) (push feature required) feature))
+ ((symbol-function 'lsp-deferred)
+ (lambda () (setq attached t))))
+ (with-temp-buffer
+ (cj/python-setup))
+ ,@body)))
+
+(ert-deftest test-prog-python-setup-pyright-absent-no-lsp ()
+ "Error: without pyright, setup neither loads lsp-pyright nor attaches."
+ (test-python-guard--setup nil
+ (should-not (memq 'lsp-pyright required))
+ (should-not attached)))
+
+(ert-deftest test-prog-python-setup-pyright-present-attaches ()
+ "Normal: with pyright on PATH, setup loads lsp-pyright then attaches."
+ (test-python-guard--setup "/usr/bin/pyright"
+ (should (memq 'lsp-pyright required))
+ (should attached)))
+
+(ert-deftest test-prog-python-hooks-cover-both-mode-variants ()
+ "Normal: classic and treesit python modes both run the same setup."
+ (dolist (hook '(python-mode-hook python-ts-mode-hook))
+ (should (memq #'cj/python-setup (symbol-value hook)))
+ (should (memq #'cj/python-mode-keybindings (symbol-value hook)))))
+
+(ert-deftest test-prog-python-no-anonymous-hook-lambdas ()
+ "Boundary: no anonymous lambda remains on the python hooks.
+The audit's unguarded lsp-pyright lambda was anonymous; symbols only
+means every hook entry is a named, greppable function."
+ (dolist (hook '(python-mode-hook python-ts-mode-hook))
+ (dolist (fn (symbol-value hook))
+ (should (symbolp fn)))))
+
+(provide 'test-prog-python--lsp-guard)
+;;; test-prog-python--lsp-guard.el ends here
diff --git a/tests/test-prog-shell--tool-warnings.el b/tests/test-prog-shell--tool-warnings.el
new file mode 100644
index 00000000..37c1e559
--- /dev/null
+++ b/tests/test-prog-shell--tool-warnings.el
@@ -0,0 +1,34 @@
+;;; test-prog-shell--tool-warnings.el --- Load-time missing-tool warnings -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit flagged bash-language-server, shfmt, and shellcheck
+;; for the load-time missing-tool warnings pyright/prettier already
+;; have. The shfmt and flycheck use-package blocks gate on `:if
+;; (executable-find ...)', which evaluates once at startup — an absent
+;; tool silently disables that setup until the next restart, so the
+;; warn is the only visible trace.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+(format-test--ensure-packages-init)
+(require 'prog-shell)
+
+(ert-deftest test-prog-shell-warns-when-tools-missing ()
+ "Error: loading without the shell tools on PATH warns for each one."
+ (let ((warned '()))
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (push msg warned))))
+ (load (expand-file-name "modules/prog-shell.el" user-emacs-directory) nil t))
+ (dolist (tool '("bash-language-server" "shfmt" "shellcheck"))
+ (should (cl-some (lambda (m) (string-match-p (regexp-quote tool) m))
+ warned)))))
+
+(provide 'test-prog-shell--tool-warnings)
+;;; test-prog-shell--tool-warnings.el ends here
diff --git a/tests/test-prog-webdev--classic-and-web-mode-hooks.el b/tests/test-prog-webdev--classic-and-web-mode-hooks.el
new file mode 100644
index 00000000..20fc2fb8
--- /dev/null
+++ b/tests/test-prog-webdev--classic-and-web-mode-hooks.el
@@ -0,0 +1,78 @@
+;;; test-prog-webdev--classic-and-web-mode-hooks.el --- Classic js + web-mode setup hooks -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit found the webdev setup hooks attached to the
+;; tree-sitter modes only, so a grammar-unavailable fallback to classic
+;; js-mode silently lost indent/keys/LSP, and web-mode got the format
+;; key but none of the promised setup (no company/flyspell/LSP in HTML
+;; buffers). These tests pin the classic js-mode hooks and the
+;; web-mode setup hook, plus the html-language-server guard that keeps
+;; LSP silent on machines without the server.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-format-wiring)
+(format-test--ensure-packages-init)
+(require 'prog-webdev)
+
+(ert-deftest test-prog-webdev-hooks-cover-classic-js-mode ()
+ "Normal: classic js-mode runs the same setup + keybindings as js-ts-mode."
+ (should (memq #'cj/webdev-setup js-mode-hook))
+ (should (memq #'cj/webdev-keybindings js-mode-hook)))
+
+(ert-deftest test-prog-webdev-web-mode-hook-runs-setup ()
+ "Normal: web-mode runs `cj/web-mode-setup' alongside the keybindings."
+ (should (memq #'cj/web-mode-setup web-mode-hook))
+ (should (memq #'cj/webdev-keybindings web-mode-hook)))
+
+(ert-deftest test-prog-webdev-web-mode-setup-sets-buffer-local-preferences ()
+ "Normal: web-mode setup lands the shared webdev buffer preferences."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'company-mode) #'ignore)
+ ((symbol-function 'flyspell-prog-mode) #'ignore)
+ ((symbol-function 'superword-mode) #'ignore)
+ ((symbol-function 'electric-pair-local-mode) #'ignore)
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
+ (cj/web-mode-setup))
+ (should (= fill-column 100))
+ (should (= tab-width 2))
+ (should-not indent-tabs-mode)))
+
+(ert-deftest test-prog-webdev-web-mode-setup-starts-lsp-when-html-server-on-path ()
+ "Normal: with the html language server on PATH, `lsp-deferred' fires."
+ (with-temp-buffer
+ (let ((started nil))
+ (cl-letf (((symbol-function 'company-mode) #'ignore)
+ ((symbol-function 'flyspell-prog-mode) #'ignore)
+ ((symbol-function 'superword-mode) #'ignore)
+ ((symbol-function 'electric-pair-local-mode) #'ignore)
+ ((symbol-function 'lsp-deferred)
+ (lambda (&rest _) (setq started t)))
+ ((symbol-function 'executable-find)
+ (lambda (path &rest _)
+ (when (equal path html-language-server-path)
+ "/usr/bin/vscode-html-language-server"))))
+ (cj/web-mode-setup))
+ (should started))))
+
+(ert-deftest test-prog-webdev-web-mode-setup-skips-lsp-without-html-server ()
+ "Boundary: without the html language server, `lsp-deferred' is NOT called."
+ (with-temp-buffer
+ (let ((started nil))
+ (cl-letf (((symbol-function 'company-mode) #'ignore)
+ ((symbol-function 'flyspell-prog-mode) #'ignore)
+ ((symbol-function 'superword-mode) #'ignore)
+ ((symbol-function 'electric-pair-local-mode) #'ignore)
+ ((symbol-function 'lsp-deferred)
+ (lambda (&rest _) (setq started t)))
+ ((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
+ (cj/web-mode-setup))
+ (should-not started))))
+
+(provide 'test-prog-webdev--classic-and-web-mode-hooks)
+;;; test-prog-webdev--classic-and-web-mode-hooks.el ends here
diff --git a/tests/test-restclient-config--keymap.el b/tests/test-restclient-config--keymap.el
new file mode 100644
index 00000000..7265c4f3
--- /dev/null
+++ b/tests/test-restclient-config--keymap.el
@@ -0,0 +1,29 @@
+;;; test-restclient-config--keymap.el --- Tests for the restclient prefix keymap -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Pins the C-; R prefix wiring. The bindings must go through
+;; `cj/restclient-map' + `cj/register-prefix-map' (like the other C-;
+;; prefixes), not raw `global-set-key' calls that silently depend on
+;; keybindings.el having installed C-; first.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'restclient-config)
+
+;;; Normal Cases
+
+(ert-deftest test-restclient-keymap-registered-under-custom-prefix ()
+ "Normal: cj/restclient-map is bound at R inside cj/custom-keymap."
+ (should (boundp 'cj/restclient-map))
+ (should (eq cj/restclient-map (keymap-lookup cj/custom-keymap "R"))))
+
+(ert-deftest test-restclient-keymap-binds-new-buffer-and-open-file ()
+ "Normal: the prefix map carries the two restclient commands."
+ (should (eq #'cj/restclient-new-buffer (keymap-lookup cj/restclient-map "n")))
+ (should (eq #'cj/restclient-open-file (keymap-lookup cj/restclient-map "o"))))
+
+(provide 'test-restclient-config--keymap)
+;;; test-restclient-config--keymap.el ends here
diff --git a/tests/test-slack-config--notify.el b/tests/test-slack-config--notify.el
new file mode 100644
index 00000000..d830ae29
--- /dev/null
+++ b/tests/test-slack-config--notify.el
@@ -0,0 +1,113 @@
+;;; test-slack-config--notify.el --- Slack notification hardening tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The config audit found `cj/slack-notify' missing signel's hardening: no
+;; body truncation (giant toasts), no whitespace collapse, no sound gating,
+;; and no `notifications-notify' fallback when the notify script is absent
+;; (the raw `start-process' error was swallowed by the condition-case, so
+;; the notification silently vanished). This mirrors signel's shape in
+;; place; the shared cj/messenger-notify extraction belongs to the
+;; messenger-unification task.
+;;
+;; The slack package's own predicates (`slack-im-p', `slack-message-minep',
+;; `slack-message-mentioned-p') are package boundaries and are mocked; the
+;; formatter and routing logic run real.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'slack-config)
+
+;;; ------------------------------ body formatter -----------------------------
+
+(ert-deftest test-slack-config-format-notify-body-collapses-whitespace ()
+ "Normal: whitespace runs (including newlines) become single spaces."
+ (should (equal (cj/slack--format-notify-body "a b\nc\t\td")
+ "a b c d")))
+
+(ert-deftest test-slack-config-format-notify-body-truncates-long ()
+ "Boundary: text over the max truncates to max length ending in an ellipsis."
+ (let ((long (make-string 500 ?x)))
+ (let ((formatted (cj/slack--format-notify-body long)))
+ (should (= (length formatted) cj/slack--notify-body-max))
+ (should (string-suffix-p "…" formatted)))))
+
+(ert-deftest test-slack-config-format-notify-body-empty ()
+ "Boundary: empty and whitespace-only input format to the empty string."
+ (should (equal (cj/slack--format-notify-body "") ""))
+ (should (equal (cj/slack--format-notify-body " \n\t ") "")))
+
+;;; ----------------------------- delivery routing ----------------------------
+
+(ert-deftest test-slack-config-send-notification-script-silent-by-default ()
+ "Normal: with the notify script on PATH and sound off, --silent is passed."
+ (let ((argv nil) (cj/slack-notify-sound nil))
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (prog &rest _) (when (equal prog "notify") "/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (_name _buf &rest args) (setq argv args))))
+ (cj/slack--send-notification "Slack: general" "hello"))
+ (should (equal argv '("/bin/notify" "info" "Slack: general" "hello" "--silent")))))
+
+(ert-deftest test-slack-config-send-notification-sound-enabled ()
+ "Boundary: with sound enabled, --silent is not passed."
+ (let ((argv nil) (cj/slack-notify-sound t))
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (prog &rest _) (when (equal prog "notify") "/bin/notify")))
+ ((symbol-function 'start-process)
+ (lambda (_name _buf &rest args) (setq argv args))))
+ (cj/slack--send-notification "Slack: general" "hello"))
+ (should (equal argv '("/bin/notify" "info" "Slack: general" "hello")))))
+
+(ert-deftest test-slack-config-send-notification-fallback-without-script ()
+ "Error: with no notify script, delivery falls back to notifications-notify."
+ (let ((fallback nil))
+ (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))
+ ((symbol-function 'notifications-notify)
+ (lambda (&rest args) (setq fallback args))))
+ (cj/slack--send-notification "Slack: general" "hello"))
+ (should (equal (plist-get fallback :title) "Slack: general"))
+ (should (equal (plist-get fallback :body) "hello"))))
+
+;;; --------------------------- predicate wiring ------------------------------
+
+(defmacro test-slack-notify--with-message (minep im-p mentioned-p &rest body)
+ "Run BODY with the slack package predicates mocked to the given values.
+Also mocks room/body accessors and captures delivery into `sent'."
+ (declare (indent 3))
+ `(let ((sent nil))
+ (cl-letf (((symbol-function 'slack-message-minep) (lambda (&rest _) ,minep))
+ ((symbol-function 'slack-im-p) (lambda (&rest _) ,im-p))
+ ((symbol-function 'slack-message-mentioned-p) (lambda (&rest _) ,mentioned-p))
+ ((symbol-function 'slack-room-display-name) (lambda (&rest _) "general"))
+ ((symbol-function 'slack-message-body) (lambda (&rest _) "the message"))
+ ((symbol-function 'cj/slack--send-notification)
+ (lambda (title body) (setq sent (list title body)))))
+ (cj/slack-notify 'msg 'room 'team)
+ ,@body)))
+
+(ert-deftest test-slack-config-notify-dm-notifies ()
+ "Normal: a DM from someone else raises a notification."
+ (test-slack-notify--with-message nil t nil
+ (should (equal sent '("Slack: general" "the message")))))
+
+(ert-deftest test-slack-config-notify-mention-notifies ()
+ "Normal: an @mention in a channel raises a notification."
+ (test-slack-notify--with-message nil nil t
+ (should (equal sent '("Slack: general" "the message")))))
+
+(ert-deftest test-slack-config-notify-own-message-silent ()
+ "Boundary: your own message never notifies, even in a DM."
+ (test-slack-notify--with-message t t t
+ (should-not sent)))
+
+(ert-deftest test-slack-config-notify-plain-channel-silent ()
+ "Boundary: a channel message with no mention stays silent."
+ (test-slack-notify--with-message nil nil nil
+ (should-not sent)))
+
+(provide 'test-slack-config--notify)
+;;; test-slack-config--notify.el ends here
diff --git a/tests/test-slack-config-reactions.el b/tests/test-slack-config-reactions.el
index 491b8147..bfcf3e29 100644
--- a/tests/test-slack-config-reactions.el
+++ b/tests/test-slack-config-reactions.el
@@ -92,5 +92,13 @@
(let ((slack-current-buffer nil))
(should-error (cj/slack-message-add-reaction) :type 'user-error)))
+(ert-deftest test-slack-config-message-add-reaction-errors-before-slack-loads ()
+ "Error: a cold call before slack.el ever loads gives the friendly user-error.
+The module defvars slack-current-buffer with no value, so until slack.el
+binds it the variable is void -- a bare read signals void-variable instead
+of the intended \"Not in a Slack buffer\"."
+ (makunbound 'slack-current-buffer)
+ (should-error (cj/slack-message-add-reaction) :type 'user-error))
+
(provide 'test-slack-config-reactions)
;;; test-slack-config-reactions.el ends here
diff --git a/tests/test-system-commands-resolve-and-run.el b/tests/test-system-commands-resolve-and-run.el
index 9d92c5d6..7e5146b1 100644
--- a/tests/test-system-commands-resolve-and-run.el
+++ b/tests/test-system-commands-resolve-and-run.el
@@ -230,5 +230,37 @@ kill-emacs directly (the service owns the daemon lifecycle)."
(cj/system-command-menu))
(should (eq called 'cj/system-cmd-lock))))
+;;; Lock command resolves the locker at call time
+
+(defun test-system-cmd--run-lock-capturing ()
+ "Run the lock command; return the shell command line it launched."
+ (let (cmd-line)
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buf c) (setq cmd-line c) 'fake-proc))
+ ((symbol-function 'set-process-query-on-exit-flag) #'ignore)
+ ((symbol-function 'set-process-sentinel) #'ignore)
+ ((symbol-function 'message) #'ignore))
+ (cj/system-cmd-lock))
+ cmd-line))
+
+(ert-deftest test-system-cmd-lock-follows-session-type-at-call-time ()
+ "Normal: the locker tracks the live session type, not the load-time bake.
+A daemon started before WAYLAND_DISPLAY was imported used to freeze the
+locker to slock forever; Lock then failed silently on Wayland."
+ (let ((lockscreen-cmd nil))
+ (cl-letf (((symbol-function 'env-wayland-p) (lambda () t)))
+ (should (string-match-p "loginctl lock-session"
+ (test-system-cmd--run-lock-capturing))))
+ (cl-letf (((symbol-function 'env-wayland-p) (lambda () nil)))
+ (should (string-match-p "slock"
+ (test-system-cmd--run-lock-capturing))))))
+
+(ert-deftest test-system-cmd-lock-explicit-override-wins ()
+ "Boundary: a user-set lockscreen-cmd overrides the session-type resolution."
+ (let ((lockscreen-cmd "my-locker --now"))
+ (cl-letf (((symbol-function 'env-wayland-p) (lambda () t)))
+ (should (string-match-p "my-locker --now"
+ (test-system-cmd--run-lock-capturing))))))
+
(provide 'test-system-commands-resolve-and-run)
;;; test-system-commands-resolve-and-run.el ends here
diff --git a/tests/test-system-defaults-functions.el b/tests/test-system-defaults-functions.el
index c603fc7e..4b647166 100644
--- a/tests/test-system-defaults-functions.el
+++ b/tests/test-system-defaults-functions.el
@@ -162,5 +162,36 @@ and the rendered S-expression lands in the log."
(should (string-match-p ":slot" contents)))))
(delete-file comp-warnings-log))))
+(ert-deftest test-system-defaults-log-comp-warning-unwritable-log-does-not-signal ()
+ "Error: an unwritable log path must not signal.
+The function is `:before-until' advice on `display-warning'; a signal here
+propagates out of `display-warning' and breaks warning display for every
+async native-comp notice. It swallows the write failure and still returns
+t (the warning stays suppressed), rather than crashing."
+ (let ((comp-warnings-log "/proc/nonexistent-dir/cannot-write.log"))
+ (should (eq t (cj/log-comp-warning 'comp "boom")))))
+
+(ert-deftest test-system-defaults-log-comp-warning-caps-log-growth ()
+ "Boundary: the log is bounded — once it exceeds the cap, a further write
+resets it (deletes the old file, keeping only the new entry) rather than
+growing without limit. A hard reset, not a tail-trim: on overflow the old
+history is discarded, which is fine for a transient diagnostic log."
+ (let ((comp-warnings-log (make-temp-file "comp-warnings-" nil ".log")))
+ (unwind-protect
+ (progn
+ ;; Seed the file well over the cap.
+ (with-temp-file comp-warnings-log
+ (insert (make-string (1+ cj/comp-warnings-log-max-bytes) ?x)))
+ (should (> (file-attribute-size (file-attributes comp-warnings-log))
+ cj/comp-warnings-log-max-bytes))
+ (cj/log-comp-warning 'comp "after the cap")
+ (should (<= (file-attribute-size (file-attributes comp-warnings-log))
+ cj/comp-warnings-log-max-bytes))
+ ;; The newest entry survives the trim.
+ (with-temp-buffer
+ (insert-file-contents comp-warnings-log)
+ (should (string-match-p "after the cap" (buffer-string)))))
+ (delete-file comp-warnings-log))))
+
(provide 'test-system-defaults-functions)
;;; test-system-defaults-functions.el ends here
diff --git a/tests/test-system-lib--ensure-marginalia-align.el b/tests/test-system-lib--ensure-marginalia-align.el
new file mode 100644
index 00000000..33ff2ba2
--- /dev/null
+++ b/tests/test-system-lib--ensure-marginalia-align.el
@@ -0,0 +1,57 @@
+;;; test-system-lib--ensure-marginalia-align.el --- Tests for marginalia category registration -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Custom completion categories (cj-music-file, cj-radio-station, and every
+;; category passed to the system-lib table helpers) bypass marginalia, so
+;; their annotations never get its right-alignment even with marginalia-align
+;; set. The registration helper adds a builtin entry per category so the
+;; table's own annotation function renders through marginalia's aligned field.
+
+;;; Code:
+
+(require 'ert)
+
+;; The module's bare defvar marks this special only file-locally; declare it
+;; here too so `let' binds dynamically (the scope-shadowing trap).
+(defvar marginalia-annotator-registry)
+
+(require 'system-lib)
+
+;;; Normal Cases
+
+(ert-deftest test-system-lib-ensure-marginalia-align-registers-category ()
+ "Normal: an unregistered category gains a builtin registry entry."
+ (let ((marginalia-annotator-registry '((file some-annotator builtin none))))
+ (cj/completion-ensure-marginalia-align 'cj-test-category)
+ (should (equal (assq 'cj-test-category marginalia-annotator-registry)
+ '(cj-test-category builtin none)))))
+
+(ert-deftest test-system-lib-ensure-marginalia-align-idempotent ()
+ "Normal: registering the same category twice leaves one entry."
+ (let ((marginalia-annotator-registry '()))
+ (cj/completion-ensure-marginalia-align 'cj-test-category)
+ (cj/completion-ensure-marginalia-align 'cj-test-category)
+ (should (= 1 (length marginalia-annotator-registry)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-system-lib-ensure-marginalia-align-preserves-existing-entry ()
+ "Boundary: a category with an existing (possibly custom) entry is untouched."
+ (let ((marginalia-annotator-registry '((cj-test-category my-custom-annotator))))
+ (cj/completion-ensure-marginalia-align 'cj-test-category)
+ (should (equal (assq 'cj-test-category marginalia-annotator-registry)
+ '(cj-test-category my-custom-annotator)))))
+
+;;; Error Cases
+
+(ert-deftest test-system-lib-ensure-marginalia-align-marginalia-absent-noop ()
+ "Error: without marginalia loaded (registry void) the helper is a silent
+no-op -- annotations just stay unaligned, nothing breaks."
+ ;; marginalia is not loadable in the batch environment, so the global
+ ;; registry is genuinely void outside the `let's above.
+ (should-not (cj/completion-ensure-marginalia-align 'cj-test-category)))
+
+(provide 'test-system-lib--ensure-marginalia-align)
+;;; test-system-lib--ensure-marginalia-align.el ends here
diff --git a/tests/test-test-runner--nil-global-directory.el b/tests/test-test-runner--nil-global-directory.el
new file mode 100644
index 00000000..c70de5bd
--- /dev/null
+++ b/tests/test-test-runner--nil-global-directory.el
@@ -0,0 +1,51 @@
+;;; test-test-runner--nil-global-directory.el --- Tests for the no-test-directory path -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Outside a Projectile project, `cj/test--get-test-directory' falls back
+;; to `cj/test-global-directory', which defaults to nil. These tests pin
+;; the contract for that nil case: discovery helpers return nil instead
+;; of crashing on (file-directory-p nil), and the interactive commands
+;; signal `user-error' instead of wrong-type-argument.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'test-runner)
+
+(defmacro test-runner-nil-dir--outside-project (&rest body)
+ "Run BODY with no project root and a nil `cj/test-global-directory'."
+ (declare (indent 0))
+ `(let ((cj/test-global-directory nil))
+ (cl-letf (((symbol-function 'cj/test--project-root)
+ (lambda () nil)))
+ ,@body)))
+
+;;; Boundary Cases
+
+(ert-deftest test-test-runner-nil-dir-get-test-directory-returns-nil ()
+ "Boundary: no project and no global dir yields nil, not an error."
+ (test-runner-nil-dir--outside-project
+ (should (null (cj/test--get-test-directory)))))
+
+(ert-deftest test-test-runner-nil-dir-get-test-files-returns-nil ()
+ "Boundary: file discovery returns nil instead of crashing on a nil dir."
+ (test-runner-nil-dir--outside-project
+ (should (null (cj/test--get-test-files)))))
+
+;;; Error Cases
+
+(ert-deftest test-test-runner-nil-dir-load-all-signals-user-error ()
+ "Error: `cj/test-load-all' signals `user-error', not wrong-type-argument."
+ (test-runner-nil-dir--outside-project
+ (should-error (cj/test-load-all) :type 'user-error)))
+
+(ert-deftest test-test-runner-nil-dir-focus-add-signals-user-error ()
+ "Error: `cj/test-focus-add' signals `user-error', not wrong-type-argument."
+ (test-runner-nil-dir--outside-project
+ (should-error (cj/test-focus-add) :type 'user-error)))
+
+(provide 'test-test-runner--nil-global-directory)
+;;; test-test-runner--nil-global-directory.el ends here
diff --git a/tests/test-test-runner.el b/tests/test-test-runner.el
index 0ff66f7f..6854b72e 100644
--- a/tests/test-test-runner.el
+++ b/tests/test-test-runner.el
@@ -152,6 +152,25 @@ FILES is an alist of relative test filenames to file contents."
(should (eq (car result) 'not-in-testdir)))
(test-testrunner-teardown))
+(ert-deftest test-testrunner-focus-add-file-shared-prefix-sibling-rejected ()
+ "Boundary: a sibling directory sharing the test dir's name prefix is outside it.
+`/tmp/x/tests-old/f.el' starts with `/tmp/x/tests' as a string, but it is not
+in `/tmp/x/tests'. A raw `string-prefix-p' on the two truenames accepts it;
+comparing against the directory with a trailing slash rejects it."
+ (test-testrunner-setup)
+ (let* ((testdir (file-truename test-testrunner--temp-dir))
+ (sibling (concat (directory-file-name testdir) "-old"))
+ (filepath (expand-file-name "test-foo.el" sibling)))
+ (unwind-protect
+ (progn
+ (make-directory sibling t)
+ (with-temp-file filepath (insert ";; not in the test dir\n"))
+ (let ((result (cj/test--do-focus-add-file
+ filepath test-testrunner--temp-dir '())))
+ (should (eq (car result) 'not-in-testdir))))
+ (when (file-directory-p sibling) (delete-directory sibling t))))
+ (test-testrunner-teardown))
+
(ert-deftest test-testrunner-focus-add-file-already-focused ()
"Should detect already focused file."
(test-testrunner-setup)
diff --git a/tests/test-text-config.el b/tests/test-text-config.el
index 96935e1b..82dfe05e 100644
--- a/tests/test-text-config.el
+++ b/tests/test-text-config.el
@@ -37,5 +37,18 @@ standard boundary check."
(should (eq (cj/prettify-compose-block-markers-p start end "lambda")
(prettify-symbols-default-compose-p start end "lambda"))))))
+(ert-deftest test-text-config-edit-indirect-bound-on-reachable-key ()
+ "Error/regression: edit-indirect-region is bound on M-I -- the event
+Meta+Shift+i actually produces -- not the unreachable M-S-i, which no
+keypress generates so it silently fell through to M-i tab-to-tab-stop."
+ (should (eq (key-binding (kbd "M-I")) #'edit-indirect-region))
+ (should-not (eq (key-binding (kbd "M-S-i")) #'edit-indirect-region)))
+
+(ert-deftest test-text-config-accent-uses-completion-agnostic-backend ()
+ "Regression: C-` invokes accent-menu, which reads through the minibuffer
+and so survives a Company->Corfu migration, rather than accent-company,
+whose company backend would break silently once Company is gone."
+ (should (eq (key-binding (kbd "C-`")) #'accent-menu)))
+
(provide 'test-text-config)
;;; test-text-config.el ends here
diff --git a/tests/test-ui-theme-persistence.el b/tests/test-ui-theme-persistence.el
index 02bb105a..250b606b 100644
--- a/tests/test-ui-theme-persistence.el
+++ b/tests/test-ui-theme-persistence.el
@@ -32,6 +32,19 @@
"modus-vivendi")))
(delete-file file))))
+(ert-deftest test-ui-theme-write-file-contents-creates-missing-parent-dir ()
+ "Boundary: writing into a not-yet-existing directory creates it first.
+On a fresh machine `persist/' does not exist, and `file-writable-p' returns nil
+for a file inside a missing directory, so the write must create the parent."
+ (let* ((sandbox (make-temp-file "ui-theme-sandbox-" t))
+ (file (expand-file-name "persist/emacs-theme" sandbox)))
+ (unwind-protect
+ (progn
+ (should-not (file-directory-p (file-name-directory file)))
+ (should (cj/theme-write-file-contents "modus-vivendi" file))
+ (should (equal (cj/theme-read-file-contents file) "modus-vivendi")))
+ (delete-directory sandbox t))))
+
(ert-deftest test-ui-theme-write-file-contents-uses-write-region ()
"Theme persistence should write directly instead of visiting the file."
(let ((file (make-temp-file "ui-theme-write-region-"))
diff --git a/tests/test-undead-buffers-kill-all-other-buffers-and-windows.el b/tests/test-undead-buffers-kill-all-other-buffers-and-windows.el
index 36d82add..bcb9f833 100644
--- a/tests/test-undead-buffers-kill-all-other-buffers-and-windows.el
+++ b/tests/test-undead-buffers-kill-all-other-buffers-and-windows.el
@@ -158,5 +158,22 @@
(kill-buffer buf))))))
(test-kill-all-other-buffers-and-windows-teardown)))
+(ert-deftest test-kill-all-other-buffers-and-windows-with-prefix-still-kills ()
+ "Boundary: C-u on the wrapper must still kill, not spam the undead list.
+The delegated cj/kill-buffer-or-bury-alive reads current-prefix-arg, so a
+prefixed wrapper call used to take the add-to-undead-list branch for every
+buffer -- nothing killed, list spammed."
+ (test-kill-all-other-buffers-and-windows-setup)
+ (unwind-protect
+ (let ((cj/undead-buffer-list cj/undead-buffer-list)
+ (buf (generate-new-buffer "*test-prefix-kill*")))
+ (unwind-protect
+ (let ((current-prefix-arg '(4)))
+ (cj/kill-all-other-buffers-and-windows)
+ (should-not (buffer-live-p buf))
+ (should-not (member "*test-prefix-kill*" cj/undead-buffer-list)))
+ (when (buffer-live-p buf) (kill-buffer buf))))
+ (test-kill-all-other-buffers-and-windows-teardown)))
+
(provide 'test-undead-buffers-kill-all-other-buffers-and-windows)
;;; test-undead-buffers-kill-all-other-buffers-and-windows.el ends here
diff --git a/tests/test-undead-buffers-kill-other-window.el b/tests/test-undead-buffers-kill-other-window.el
index e9371a0f..000ada9b 100644
--- a/tests/test-undead-buffers-kill-other-window.el
+++ b/tests/test-undead-buffers-kill-other-window.el
@@ -66,8 +66,9 @@
;;; Boundary Cases
-(ert-deftest test-kill-other-window-single-window-should-only-kill-buffer ()
- "With single window, should only kill the current buffer."
+(ert-deftest test-kill-other-window-single-window-signals-no-other-window ()
+ "Error: with a single window, signal `user-error' and kill nothing.
+There is no other window, so acting would kill the buffer being viewed."
(test-kill-other-window-setup)
(unwind-protect
(let ((buf (generate-new-buffer "*test-single-other*")))
@@ -75,9 +76,9 @@
(progn
(switch-to-buffer buf)
(should (one-window-p))
- (cj/kill-other-window)
+ (should-error (cj/kill-other-window) :type 'user-error)
(should (one-window-p))
- (should-not (buffer-live-p buf)))
+ (should (buffer-live-p buf)))
(when (buffer-live-p buf) (kill-buffer buf))))
(test-kill-other-window-teardown)))
diff --git a/tests/test-validate-el-hook.bats b/tests/test-validate-el-hook.bats
new file mode 100644
index 00000000..43c3569c
--- /dev/null
+++ b/tests/test-validate-el-hook.bats
@@ -0,0 +1,97 @@
+#!/usr/bin/env bats
+# Tests for .claude/hooks/validate-el.sh — the auto-test runner.
+#
+# The runner used to skip entirely above MAX_AUTO_TEST_FILES=20, with no else
+# branch: nothing printed, exit 0, indistinguishable from a passing run. That
+# was live for the three largest families here (calendar-sync 63 test files,
+# music 45, ai-term 35), so every edit to those ran parens and byte-compile and
+# zero tests, silently.
+#
+# The cap was removed rather than made loud, because its premise did not hold.
+# Measured on this machine, running a whole family takes about a second:
+# ai-term 208 tests in 1.0s, music 403 in 1.7s, calendar-sync 633 in 0.9s. It
+# was also concealing a real cross-test pollution bug in calendar-sync that
+# only appears when that family runs in one process.
+#
+# These tests pin that no file count is skipped. Each builds a synthetic
+# project in BATS_TEST_TMPDIR and points CLAUDE_PROJECT_DIR at it, so nothing
+# runs against the real tree.
+
+setup() {
+ HOOK="${BATS_TEST_DIRNAME}/../.claude/hooks/validate-el.sh"
+ PROJ="${BATS_TEST_TMPDIR}/proj"
+ mkdir -p "$PROJ/modules" "$PROJ/tests"
+ export CLAUDE_PROJECT_DIR="$PROJ"
+ printf '(provide (quote widget))\n' > "$PROJ/modules/widget.el"
+}
+
+# N green test files matching the widget stem.
+make_tests() {
+ local n="$1" i
+ for ((i = 1; i <= n; i++)); do
+ printf '(require (quote ert))\n(ert-deftest test-widget-%d () (should t))\n' \
+ "$i" > "$PROJ/tests/test-widget-${i}.el"
+ done
+}
+
+# One failing test file, to prove the run is real rather than merely quiet.
+make_failing_test() {
+ printf '(require (quote ert))\n(ert-deftest test-widget-bad () (should nil))\n' \
+ > "$PROJ/tests/test-widget-bad.el"
+}
+
+hook_input() {
+ printf '{"tool_input":{"file_path":"%s"}}' "$PROJ/modules/widget.el"
+}
+
+run_hook() {
+ run bash -c "$(printf '%q' "$HOOK") <<< '$(hook_input)'"
+}
+
+# ------------------------------- Normal cases -------------------------------
+
+@test "a small family runs and passes quietly" {
+ make_tests 3
+ run_hook
+ [ "$status" -eq 0 ]
+}
+
+@test "a failing test blocks, so a quiet pass means the tests really ran" {
+ make_tests 3
+ make_failing_test
+ run_hook
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"TESTS FAILED"* ]]
+}
+
+# ------------------------------ Boundary cases ------------------------------
+
+@test "at the old cap of 20 files: runs" {
+ make_tests 20
+ run_hook
+ [ "$status" -eq 0 ]
+}
+
+@test "past the old cap: still runs, no longer skipped" {
+ make_tests 21
+ run_hook
+ [ "$status" -eq 0 ]
+ [[ "${output,,}" != *"skipped"* ]]
+}
+
+@test "well past the old cap: a failure in file 63 is still caught" {
+ # The regression this guards: at 63 files the runner used to skip, so a red
+ # test in a big family reported clean. calendar-sync is exactly this size.
+ make_tests 63
+ make_failing_test
+ run_hook
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"TESTS FAILED"* ]]
+}
+
+# -------------------------------- Error cases -------------------------------
+
+@test "no matching tests: exits clean without running anything" {
+ run_hook
+ [ "$status" -eq 0 ]
+}
diff --git a/tests/test-vc-config--git-clone.el b/tests/test-vc-config--git-clone.el
index 3b39ece2..46ce3d40 100644
--- a/tests/test-vc-config--git-clone.el
+++ b/tests/test-vc-config--git-clone.el
@@ -2,9 +2,11 @@
;;; Commentary:
;; Unit tests for cj/--git-clone-dir-name (robust repo-dir derivation across
-;; HTTPS, scp-style SSH, ssh:// and local URLs) and for cj/git-clone-clipboard-url
-;; reporting a failed clone from the process exit status instead of silently
-;; assuming the directory appeared.
+;; HTTPS, scp-style SSH, ssh:// and local URLs), for the async clone process
+;; wiring in cj/git-clone-clipboard-url (make-process argv, no shell), and
+;; for the sentinel built by cj/--git-clone-make-sentinel (open on success,
+;; surface the process buffer on failure). Sentinel tests drive real
+;; short-lived processes rather than mocking process primitives.
;;; Code:
@@ -14,6 +16,18 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'vc-config)
+(defun test-vc-clone--run-sentinel (command sentinel buffer)
+ "Run COMMAND with SENTINEL and BUFFER; wait for process exit."
+ (let ((proc (make-process :name "test-vc-clone"
+ :buffer buffer
+ :command command
+ :sentinel sentinel)))
+ (while (process-live-p proc)
+ (accept-process-output proc 0.05))
+ ;; Give the sentinel a chance to run after exit.
+ (accept-process-output nil 0.05)
+ proc))
+
;;; cj/--git-clone-dir-name — Normal Cases
(ert-deftest test-vc-git-clone-dir-name-https-with-git-suffix ()
@@ -36,7 +50,7 @@
(should (equal "repo"
(cj/--git-clone-dir-name "ssh://git@example.com/user/repo.git"))))
-;;; Boundary Cases
+;;; cj/--git-clone-dir-name — Boundary Cases
(ert-deftest test-vc-git-clone-dir-name-ssh-scp-without-user ()
"Boundary: scp-style SSH with no user path (host:repo.git) still works.
@@ -60,29 +74,110 @@ since there is no `/' separator."
(should (equal "repo"
(cj/--git-clone-dir-name " https://example.com/user/repo.git\n"))))
-;;; cj/git-clone-clipboard-url — Error Cases
+;;; cj/--git-clone-open — Normal / Boundary Cases
-(ert-deftest test-vc-git-clone-clipboard-url-reports-clone-failure ()
- "Error: a nonzero git exit status surfaces a user-error, not silence.
-Uses a real writable temp dir as the target (so the file predicates run
-for real) and mocks only the clone process to fail."
- (let ((target (make-temp-file "cj-clone-fail-" t)))
+(ert-deftest test-vc-git-clone-open-finds-readme ()
+ "Normal: a README in the clone is opened."
+ (let ((dir (make-temp-file "cj-clone-open-" t))
+ (opened nil))
+ (unwind-protect
+ (progn
+ (with-temp-file (expand-file-name "README.md" dir) (insert "hi"))
+ (cl-letf (((symbol-function 'find-file)
+ (lambda (f &rest _) (setq opened f)))
+ ((symbol-function 'dired) #'ignore))
+ (cj/--git-clone-open dir)
+ (should (equal (expand-file-name "README.md" dir) opened))))
+ (delete-directory dir t))))
+
+(ert-deftest test-vc-git-clone-open-no-readme-dires ()
+ "Boundary: with no README, the clone directory is dired."
+ (let ((dir (make-temp-file "cj-clone-open-" t))
+ (dired-dir nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'find-file)
+ (lambda (&rest _) (error "find-file should not run")))
+ ((symbol-function 'dired)
+ (lambda (d &rest _) (setq dired-dir d))))
+ (cj/--git-clone-open dir)
+ (should (equal dir dired-dir)))
+ (delete-directory dir t))))
+
+;;; cj/--git-clone-make-sentinel — Normal / Error Cases
+
+(ert-deftest test-vc-git-clone-sentinel-success-opens-clone ()
+ "Normal: a zero-exit clone process opens the clone directory."
+ (let ((buffer (generate-new-buffer " *test-clone-ok*"))
+ (opened nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--git-clone-open)
+ (lambda (d) (setq opened d)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (test-vc-clone--run-sentinel
+ '("true") (cj/--git-clone-make-sentinel "url" "/tmp/clone-dst") buffer)
+ (should (equal "/tmp/clone-dst" opened)))
+ (kill-buffer buffer))))
+
+(ert-deftest test-vc-git-clone-sentinel-failure-pops-process-buffer ()
+ "Error: a nonzero exit surfaces the process buffer, never opens the clone."
+ (let ((buffer (generate-new-buffer " *test-clone-fail*"))
+ (opened nil)
+ (popped nil))
(unwind-protect
- (cl-letf (((symbol-function 'call-process) (lambda (&rest _) 128))
- ((symbol-function 'pop-to-buffer) #'ignore)
- ((symbol-function 'message) #'ignore))
- (should-error
- (cj/git-clone-clipboard-url "https://example.com/user/repo.git" target)
- :type 'user-error))
+ (cl-letf (((symbol-function 'cj/--git-clone-open)
+ (lambda (d) (setq opened d)))
+ ((symbol-function 'pop-to-buffer)
+ (lambda (b &rest _) (setq popped b)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (test-vc-clone--run-sentinel
+ '("false") (cj/--git-clone-make-sentinel "url" "/tmp/clone-dst") buffer)
+ (should-not opened)
+ (should (eq buffer popped)))
+ (kill-buffer buffer))))
+
+;;; cj/git-clone-clipboard-url — Normal / Error Cases
+
+(ert-deftest test-vc-git-clone-clipboard-url-spawns-async-argv ()
+ "Normal: the clone runs as an async process with a plain argv, no shell.
+The `--' separator must precede the URL so a leading-dash URL cannot be
+read as a git flag."
+ (let ((target (make-temp-file "cj-clone-async-" t))
+ (spawned nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'make-process)
+ (lambda (&rest args)
+ (setq spawned (plist-get args :command))
+ nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (cj/git-clone-clipboard-url "https://example.com/user/repo.git" target)
+ (should (equal (list "git" "clone" "--"
+ "https://example.com/user/repo.git"
+ (expand-file-name "repo" target))
+ spawned)))
(delete-directory target t))))
(ert-deftest test-vc-git-clone-clipboard-url-empty-clipboard-errors ()
"Error: an empty clipboard URL aborts before any clone attempt."
- (let ((cloned nil))
- (cl-letf (((symbol-function 'call-process)
- (lambda (&rest _) (setq cloned t) 0)))
+ (let ((spawned nil))
+ (cl-letf (((symbol-function 'make-process)
+ (lambda (&rest _) (setq spawned t) nil)))
(should-error (cj/git-clone-clipboard-url " " "/tmp") :type 'user-error))
- (should-not cloned)))
+ (should-not spawned)))
+
+(ert-deftest test-vc-git-clone-clipboard-url-existing-destination-errors ()
+ "Error: an existing clone destination aborts before any clone attempt."
+ (let ((target (make-temp-file "cj-clone-exists-" t))
+ (spawned nil))
+ (unwind-protect
+ (progn
+ (make-directory (expand-file-name "repo" target))
+ (cl-letf (((symbol-function 'make-process)
+ (lambda (&rest _) (setq spawned t) nil)))
+ (should-error
+ (cj/git-clone-clipboard-url "https://example.com/user/repo.git" target)
+ :type 'user-error))
+ (should-not spawned))
+ (delete-directory target t))))
(provide 'test-vc-config--git-clone)
;;; test-vc-config--git-clone.el ends here
diff --git a/tests/test-vc-config--gutter-hunk-candidates.el b/tests/test-vc-config--gutter-hunk-candidates.el
new file mode 100644
index 00000000..65db0c3c
--- /dev/null
+++ b/tests/test-vc-config--gutter-hunk-candidates.el
@@ -0,0 +1,69 @@
+;;; test-vc-config--gutter-hunk-candidates.el --- Tests for cj/--git-gutter-hunk-candidates -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Unit tests for cj/--git-gutter-hunk-candidates, the pure helper that
+;; builds completion candidates (label . line) from git-gutter hunk start
+;; lines against the current buffer's text. The interactive wrapper
+;; cj/goto-git-gutter-diff-hunks maps git-gutter:diffinfos onto start
+;; lines and delegates here.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'vc-config)
+
+;;; Normal Cases
+
+(ert-deftest test-vc-gutter-hunk-candidates-labels-carry-line-text ()
+ "Normal: each candidate label contains the hunk line's text."
+ (with-temp-buffer
+ (insert "alpha\nbravo\ncharlie\n")
+ (let ((candidates (cj/--git-gutter-hunk-candidates '(2 3))))
+ (should (= 2 (length candidates)))
+ (should (string-match-p "bravo" (car (nth 0 candidates))))
+ (should (string-match-p "charlie" (car (nth 1 candidates)))))))
+
+(ert-deftest test-vc-gutter-hunk-candidates-cdr-is-start-line ()
+ "Normal: each candidate's cdr is the hunk's start line number."
+ (with-temp-buffer
+ (insert "alpha\nbravo\ncharlie\n")
+ (let ((candidates (cj/--git-gutter-hunk-candidates '(1 3))))
+ (should (equal '(1 3) (mapcar #'cdr candidates))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-vc-gutter-hunk-candidates-empty-input-returns-nil ()
+ "Boundary: no hunks produce no candidates."
+ (with-temp-buffer
+ (insert "alpha\n")
+ (should (null (cj/--git-gutter-hunk-candidates '())))))
+
+(ert-deftest test-vc-gutter-hunk-candidates-first-line ()
+ "Boundary: a hunk on line 1 resolves to the first line's text."
+ (with-temp-buffer
+ (insert "alpha\nbravo\n")
+ (let ((candidates (cj/--git-gutter-hunk-candidates '(1))))
+ (should (string-match-p "alpha" (caar candidates)))
+ (should (= 1 (cdar candidates))))))
+
+(ert-deftest test-vc-gutter-hunk-candidates-unicode-line-text ()
+ "Boundary: line text with unicode survives into the label."
+ (with-temp-buffer
+ (insert "naïve — 日本語\n")
+ (let ((candidates (cj/--git-gutter-hunk-candidates '(1))))
+ (should (string-match-p "日本語" (caar candidates))))))
+
+;;; Error Cases
+
+(ert-deftest test-vc-goto-git-gutter-diff-hunks-no-hunks-user-error ()
+ "Error: the command signals `user-error' when the buffer has no hunks."
+ (with-temp-buffer
+ (setq-local git-gutter:diffinfos nil)
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) nil)))
+ (should-error (cj/goto-git-gutter-diff-hunks) :type 'user-error))))
+
+(provide 'test-vc-config--gutter-hunk-candidates)
+;;; test-vc-config--gutter-hunk-candidates.el ends here
diff --git a/tests/test-vc-config--timemachine-commands.el b/tests/test-vc-config--timemachine-commands.el
new file mode 100644
index 00000000..36a71695
--- /dev/null
+++ b/tests/test-vc-config--timemachine-commands.el
@@ -0,0 +1,36 @@
+;;; test-vc-config--timemachine-commands.el --- Tests for git-timemachine command wiring -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Guards the git-timemachine autoload surface in vc-config.el. The
+;; upstream package defines no `git-timemachine-show-selected-revision';
+;; an autoload for it in :commands creates a phantom M-x command that
+;; errors after loading the package. The real selector lives in the
+;; cj/ namespace.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'vc-config)
+
+;;; Normal Cases
+
+(ert-deftest test-vc-timemachine-selected-revision-cj-command-defined ()
+ "Normal: the cj/ selected-revision selector is defined by vc-config."
+ (should (fboundp 'cj/git-timemachine-show-selected-revision)))
+
+(ert-deftest test-vc-timemachine-entry-command-defined ()
+ "Normal: the cj/git-timemachine entry command is defined."
+ (should (commandp 'cj/git-timemachine)))
+
+;;; Error Cases
+
+(ert-deftest test-vc-timemachine-no-phantom-package-autoload ()
+ "Error: no autoload exists for a function the package never defines.
+An autoload stub for `git-timemachine-show-selected-revision' would
+surface in M-x and signal void-function after the package loads."
+ (should-not (fboundp 'git-timemachine-show-selected-revision)))
+
+(provide 'test-vc-config--timemachine-commands)
+;;; test-vc-config--timemachine-commands.el ends here
diff --git a/tests/test-video-audio-recording--build-video-command.el b/tests/test-video-audio-recording--build-video-command.el
index 4f290978..1ffce95b 100644
--- a/tests/test-video-audio-recording--build-video-command.el
+++ b/tests/test-video-audio-recording--build-video-command.el
@@ -27,6 +27,14 @@
(should (string-match-p "-i pipe:0" cmd))
(should (string-match-p "-c:v copy" cmd))))))
+(ert-deftest test-video-audio-recording--build-video-command-normal-wayland-keeps-wf-recorder-stderr ()
+ "Wayland command does not discard wf-recorder stderr, so a failed grab is diagnosable."
+ (let ((cj/recording-mic-boost 2.0)
+ (cj/recording-system-volume 1.0))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_prog &rest _) t)))
+ (let ((cmd (cj/recording--build-video-command "mic" "sys" "/tmp/out.mkv" t)))
+ (should-not (string-match-p "2>/dev/null" cmd))))))
+
(ert-deftest test-video-audio-recording--build-video-command-normal-x11-uses-x11grab ()
"X11 command uses ffmpeg with x11grab, no wf-recorder."
(let ((cj/recording-mic-boost 2.0)
diff --git a/tests/test-video-audio-recording--start-race.el b/tests/test-video-audio-recording--start-race.el
new file mode 100644
index 00000000..36ea8595
--- /dev/null
+++ b/tests/test-video-audio-recording--start-race.el
@@ -0,0 +1,56 @@
+;;; test-video-audio-recording--start-race.el --- start-race fix tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the wf-recorder start-race fix: the poll that waits for a dying
+;; wf-recorder to release the compositor capture before launching a new one, and
+;; the fail-fast timing predicate that tells a 0.5s failed start from a real
+;; recording. The pgrep wrapper and the live start/stop wiring are exercised in
+;; the daemon, not here; the poll is tested with an injected predicate so no real
+;; process is needed.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+(declare-function cj/recording--start-failed-p "video-audio-recording-capture" (elapsed threshold))
+(declare-function cj/recording--wait-for-no-wf-recorder "video-audio-recording-capture" (timeout-secs &optional running-p))
+
+;;; ------------------------- cj/recording--start-failed-p ---------------------
+
+(ert-deftest test-recording-start-failed-p-short-exit-is-failure ()
+ "Normal: an exit well before the threshold is a failed start."
+ (should (cj/recording--start-failed-p 0.5 1.5)))
+
+(ert-deftest test-recording-start-failed-p-long-run-is-not-failure ()
+ "Normal: a long-running recording that ends is not a failed start."
+ (should-not (cj/recording--start-failed-p 30.0 1.5)))
+
+(ert-deftest test-recording-start-failed-p-at-threshold-is-not-failure ()
+ "Boundary: an exit exactly at the threshold is not counted as failed."
+ (should-not (cj/recording--start-failed-p 1.5 1.5)))
+
+;;; -------------------- cj/recording--wait-for-no-wf-recorder ------------------
+
+(ert-deftest test-recording-wait-for-no-wf-recorder-clears ()
+ "Normal: returns t once the injected predicate reports wf-recorder gone."
+ (let ((n 0))
+ (should (cj/recording--wait-for-no-wf-recorder
+ 2.0
+ (lambda () (setq n (1+ n)) (< n 3))))))
+
+(ert-deftest test-recording-wait-for-no-wf-recorder-already-clear ()
+ "Boundary: an already-clear predicate returns t immediately."
+ (should (cj/recording--wait-for-no-wf-recorder 2.0 (lambda () nil))))
+
+(ert-deftest test-recording-wait-for-no-wf-recorder-times-out ()
+ "Error: a predicate that never clears returns nil at the timeout."
+ (should-not (cj/recording--wait-for-no-wf-recorder 0.15 (lambda () t))))
+
+(provide 'test-video-audio-recording--start-race)
+;;; test-video-audio-recording--start-race.el ends here
diff --git a/tests/test-video-audio-recording-group-devices-by-hardware.el b/tests/test-video-audio-recording-group-devices-by-hardware.el
deleted file mode 100644
index 2be4982f..00000000
--- a/tests/test-video-audio-recording-group-devices-by-hardware.el
+++ /dev/null
@@ -1,194 +0,0 @@
-;;; test-video-audio-recording-group-devices-by-hardware.el --- Tests for cj/recording-group-devices-by-hardware -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Unit tests for cj/recording-group-devices-by-hardware function.
-;; Tests grouping of audio sources by physical hardware device.
-;; Critical test: Bluetooth MAC address normalization (colons vs underscores).
-;;
-;; This function is used by the quick setup command to automatically pair
-;; microphone and monitor devices from the same hardware.
-
-;;; Code:
-
-(require 'ert)
-
-;; Stub dependencies before loading the module
-(defvar cj/custom-keymap (make-sparse-keymap)
- "Stub keymap for testing.")
-
-;; Now load the actual production module
-(require 'video-audio-recording)
-
-;;; Test Fixtures Helper
-
-(defun test-load-fixture (filename)
- "Load fixture file FILENAME from tests/fixtures directory."
- (let ((fixture-path (expand-file-name
- (concat "tests/fixtures/" filename)
- user-emacs-directory)))
- (with-temp-buffer
- (insert-file-contents fixture-path)
- (buffer-string))))
-
-;;; Normal Cases
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-all-types-grouped ()
- "Test grouping of all three device types (built-in, USB, Bluetooth).
-This is the key test validating the complete grouping logic."
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (= 3 (length result)))
- ;; Check that we have all three device types
- (let ((names (mapcar #'car result)))
- (should (member "Built-in Audio" names))
- (should (member "Bluetooth Headset" names))
- (should (member "Jabra SPEAK 510 USB" names)))
- ;; Verify each device has both mic and monitor
- (dolist (device result)
- (should (stringp (car device))) ; friendly name
- (should (stringp (cadr device))) ; mic device
- (should (stringp (cddr device))) ; monitor device
- (should-not (string-suffix-p ".monitor" (cadr device))) ; mic not monitor
- (should (string-suffix-p ".monitor" (cddr device)))))))) ; monitor has suffix
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-built-in-paired ()
- "Test that built-in laptop audio devices are correctly paired."
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let* ((result (cj/recording-group-devices-by-hardware))
- (built-in (assoc "Built-in Audio" result)))
- (should built-in)
- (should (string-match-p "pci-0000_00_1f" (cadr built-in)))
- (should (string-match-p "pci-0000_00_1f" (cddr built-in)))
- (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (cadr built-in)))
- (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" (cddr built-in)))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-usb-paired ()
- "Test that USB devices (Jabra) are correctly paired."
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let* ((result (cj/recording-group-devices-by-hardware))
- (jabra (assoc "Jabra SPEAK 510 USB" result)))
- (should jabra)
- (should (string-match-p "Jabra" (cadr jabra)))
- (should (string-match-p "Jabra" (cddr jabra)))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-bluetooth-paired ()
- "Test that Bluetooth devices are correctly paired.
-CRITICAL: Tests MAC address normalization (colons in input, underscores in output)."
- (let ((output (test-load-fixture "pactl-output-normal.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let* ((result (cj/recording-group-devices-by-hardware))
- (bluetooth (assoc "Bluetooth Headset" result)))
- (should bluetooth)
- ;; Input has colons: bluez_input.00:1B:66:C0:91:6D
- (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth)))
- ;; Output has underscores: bluez_output.00_1B_66_C0_91_6D.1.monitor
- ;; But they should still be grouped together (MAC address normalized)
- (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth)))))))
-
-;;; Boundary Cases
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-empty-returns-empty ()
- "Test that empty pactl output returns empty list."
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) "")))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (null result)))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-inputs-returns-empty ()
- "Test that only input devices (no monitors) returns empty list.
-Devices must have BOTH mic and monitor to be included."
- (let ((output (test-load-fixture "pactl-output-inputs-only.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (null result))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-monitors-returns-empty ()
- "Test that only monitor devices (no inputs) returns empty list."
- (let ((output (test-load-fixture "pactl-output-monitors-only.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (null result))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-single-complete-device ()
- "Test that single device with both mic and monitor is returned."
- (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length result)))
- (should (equal "Built-in Audio" (caar result)))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-mixed-complete-incomplete ()
- "Test that only devices with BOTH mic and monitor are included.
-Incomplete devices (only mic or only monitor) are filtered out."
- (let ((output (concat
- ;; Complete device (built-in)
- "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
- "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
- ;; Incomplete: USB mic with no monitor
- "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n"
- ;; Incomplete: Bluetooth monitor with no mic
- "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- ;; Only the complete built-in device should be returned
- (should (= 1 (length result)))
- (should (equal "Built-in Audio" (caar result)))))))
-
-;;; Error Cases
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-malformed-output-returns-empty ()
- "Test that malformed pactl output returns empty list."
- (let ((output (test-load-fixture "pactl-output-malformed.txt")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (listp result))
- (should (null result))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-unknown-device-type ()
- "Test that unknown device types get generic 'USB Audio Device' name."
- (let ((output (concat
- "100\talsa_input.usb-unknown_device-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n"
- "99\talsa_output.usb-unknown_device-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length result)))
- ;; Should get generic USB name (not matching Jabra pattern)
- (should (equal "USB Audio Device" (caar result)))))))
-
-(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-bluetooth-mac-case-variations ()
- "Test that Bluetooth MAC addresses work with different formatting.
-Tests the normalization logic handles various MAC address formats."
- (let ((output (concat
- ;; Input with colons (typical)
- "79\tbluez_input.AA:BB:CC:DD:EE:FF\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"
- ;; Output with underscores (typical)
- "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) output)))
- (let ((result (cj/recording-group-devices-by-hardware)))
- (should (= 1 (length result)))
- (should (equal "Bluetooth Headset" (caar result)))
- ;; Verify both devices paired despite different MAC formats
- (let ((device (car result)))
- (should (string-match-p "AA:BB:CC" (cadr device)))
- (should (string-match-p "AA_BB_CC" (cddr device))))))))
-
-(provide 'test-video-audio-recording-group-devices-by-hardware)
-;;; test-video-audio-recording-group-devices-by-hardware.el ends here
diff --git a/tests/test-video-audio-recording-process-sentinel.el b/tests/test-video-audio-recording-process-sentinel.el
index 92fb3f0d..d733e46f 100644
--- a/tests/test-video-audio-recording-process-sentinel.el
+++ b/tests/test-video-audio-recording-process-sentinel.el
@@ -190,5 +190,157 @@
(should (null cj/audio-recording-ffmpeg-process))))
(test-sentinel-teardown)))
+;;; Failed-Start Stub Deletion
+;;
+;; On Wayland a wf-recorder that fails to grab the compositor capture
+;; still writes a ~500KB, ~0.5s stub .mkv before dying. The sentinel
+;; detects the failed start (exit sooner than
+;; `cj/recording-start-fail-threshold' without a user stop); these tests
+;; pin that it also deletes the stub file stamped on the process as the
+;; `cj-output-file' property — and that normal stops and user stops
+;; never delete anything.
+
+(defun test-sentinel--make-exited-process ()
+ "Return a real process that has already exited.
+Drives the sentinel with a genuinely dead process so `process-status'
+and the process plist behave for real instead of through mocks."
+ (let ((proc (make-process :name "test-sentinel-exited"
+ :command '("true")
+ :sentinel #'ignore)))
+ (while (process-live-p proc)
+ (accept-process-output proc 0.05))
+ proc))
+
+(defun test-sentinel--make-stub-file ()
+ "Create and return a temp file standing in for the stub .mkv."
+ (make-temp-file "test-sentinel-stub-" nil ".mkv" "stub-content"))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-failed-start-deletes-stub ()
+ "Normal: a failed video start deletes the stub output file."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process))
+ (stub (test-sentinel--make-stub-file)))
+ (unwind-protect
+ (progn
+ (setq cj/video-recording-ffmpeg-process proc)
+ ;; Exited immediately after its start time — a failed start.
+ (process-put proc 'cj-start-time (float-time))
+ (process-put proc 'cj-output-file stub)
+ (cj/recording-process-sentinel proc "exited abnormally\n")
+ (should-not (file-exists-p stub)))
+ (when (file-exists-p stub) (delete-file stub))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-failed-start-still-messages ()
+ "Normal: the failed-start branch still reports the failure to the user."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process))
+ (stub (test-sentinel--make-stub-file))
+ (failure-messaged nil))
+ (unwind-protect
+ (progn
+ (setq cj/video-recording-ffmpeg-process proc)
+ (process-put proc 'cj-start-time (float-time))
+ (process-put proc 'cj-output-file stub)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (let ((msg (apply #'format fmt args)))
+ (when (string-match-p "failed to start" msg)
+ (setq failure-messaged t))))))
+ (cj/recording-process-sentinel proc "exited abnormally\n"))
+ (should failure-messaged))
+ (when (file-exists-p stub) (delete-file stub))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-long-run-keeps-file ()
+ "Normal: a recording that ran past the threshold keeps its output file."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process))
+ (stub (test-sentinel--make-stub-file)))
+ (unwind-protect
+ (progn
+ (setq cj/video-recording-ffmpeg-process proc)
+ ;; Ran well past the fail threshold — a real recording.
+ (process-put proc 'cj-start-time
+ (- (float-time)
+ (* 10 cj/recording-start-fail-threshold)))
+ (process-put proc 'cj-output-file stub)
+ (cj/recording-process-sentinel proc "finished\n")
+ (should (file-exists-p stub)))
+ (when (file-exists-p stub) (delete-file stub))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-boundary-user-stop-keeps-file ()
+ "Boundary: a quick user stop (cj-stopping) never deletes the file."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process))
+ (stub (test-sentinel--make-stub-file)))
+ (unwind-protect
+ (progn
+ (setq cj/video-recording-ffmpeg-process proc)
+ ;; Quick exit, but the user asked for it.
+ (process-put proc 'cj-start-time (float-time))
+ (process-put proc 'cj-stopping t)
+ (process-put proc 'cj-output-file stub)
+ (cj/recording-process-sentinel proc "finished\n")
+ (should (file-exists-p stub)))
+ (when (file-exists-p stub) (delete-file stub))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-boundary-missing-stub-no-error ()
+ "Boundary: failed start whose stub never hit disk signals no error."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process)))
+ (setq cj/video-recording-ffmpeg-process proc)
+ (process-put proc 'cj-start-time (float-time))
+ (process-put proc 'cj-output-file "/nonexistent/dir/never-written.mkv")
+ ;; Must not signal even though the file is absent.
+ (cj/recording-process-sentinel proc "exited abnormally\n")
+ (should (null cj/video-recording-ffmpeg-process)))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-error-nil-output-property-no-error ()
+ "Error: failed start with no cj-output-file property signals no error.
+Covers processes started before the property existed (a live daemon
+mid-upgrade) — the sentinel degrades to the old message-only path."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((proc (test-sentinel--make-exited-process)))
+ (setq cj/video-recording-ffmpeg-process proc)
+ (process-put proc 'cj-start-time (float-time))
+ ;; No cj-output-file property at all.
+ (cj/recording-process-sentinel proc "exited abnormally\n")
+ (should (null cj/video-recording-ffmpeg-process)))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-start-stamps-output-file ()
+ "Normal: `cj/ffmpeg-record-video' stamps cj-output-file on the process."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((cj/recording-mic-device "test-mic-device")
+ (cj/recording-system-device "test-monitor-device")
+ (cj/recording-mic-boost 2.0)
+ (cj/recording-system-volume 1.0))
+ (cl-letf (((symbol-function 'cj/recording--wayland-p) (lambda () nil))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (cj/ffmpeg-record-video "/tmp/video-recordings/")
+ (let ((output-file (process-get cj/video-recording-ffmpeg-process
+ 'cj-output-file)))
+ (should (stringp output-file))
+ (should (string-suffix-p ".mkv" output-file))
+ (should (string-prefix-p "/tmp/video-recordings/" output-file))))
+ (when cj/video-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/video-recording-ffmpeg-process))))
+ (test-sentinel-teardown)))
+
(provide 'test-video-audio-recording-process-sentinel)
;;; test-video-audio-recording-process-sentinel.el ends here
diff --git a/tests/test-wrap-up--bury-buffers.el b/tests/test-wrap-up--bury-buffers.el
new file mode 100644
index 00000000..00df69c7
--- /dev/null
+++ b/tests/test-wrap-up--bury-buffers.el
@@ -0,0 +1,96 @@
+;;; test-wrap-up--bury-buffers.el --- Tests for cj/bury-buffers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Characterization tests for cj/bury-buffers, which buries the noisy
+;; compile-and-shell buffers at the end of startup.
+;;
+;; Written to pin the buried set while a dead clause was removed. The function
+;; tested `(derived-mode-p 'elisp-compile-mode)', and no such mode exists in
+;; Emacs -- the real one is `emacs-lisp-compilation-mode', which derives from
+;; `compilation-mode' and so was already matched by the clause above it. The
+;; clause could never be true, and removing it must not change which buffers
+;; get buried. These tests are what makes that claim checkable.
+;;
+;; Test organization:
+;; - Normal Cases: each buried mode is buried; byte-compilation output included
+;; - Boundary Cases: an ordinary buffer is left alone; an empty buffer list
+;; - Error Cases: a killed buffer in the list does not break the sweep
+;;
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'wrap-up)
+
+;; Required explicitly so each test stands alone. Without these the
+;; comint-mode test passed only because ERT runs tests in alphabetical order
+;; and an earlier test loaded `compile', which pulls in comint -- so renaming
+;; or running that test by itself made it fail with void-function comint-mode.
+(require 'comint)
+(require 'bytecomp)
+
+(defmacro test-wrap-up--with-mode-buffer (mode &rest body)
+ "Create a buffer in MODE, bind it to `buf', run BODY, then kill it."
+ (declare (indent 1))
+ `(let ((buf (generate-new-buffer "*test-bury*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf (funcall ,mode))
+ ,@body)
+ (kill-buffer buf))))
+
+;;; Normal Cases
+
+(ert-deftest test-wrap-up-bury-buffers-buries-compilation ()
+ "Normal: a compilation-mode buffer is buried."
+ (test-wrap-up--with-mode-buffer #'compilation-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should-not (eq buf (car (buffer-list))))))
+
+(ert-deftest test-wrap-up-bury-buffers-buries-byte-compilation-output ()
+ "Normal: the real byte-compilation mode is buried.
+`emacs-lisp-compilation-mode' derives from `compilation-mode', which is
+why the never-matching elisp-compile-mode clause was redundant."
+ (should (eq 'compilation-mode
+ (get 'emacs-lisp-compilation-mode 'derived-mode-parent)))
+ (test-wrap-up--with-mode-buffer #'emacs-lisp-compilation-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should-not (eq buf (car (buffer-list))))))
+
+(ert-deftest test-wrap-up-bury-buffers-buries-comint ()
+ "Normal: a comint-mode buffer is buried."
+ (test-wrap-up--with-mode-buffer #'comint-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should-not (eq buf (car (buffer-list))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wrap-up-bury-buffers-leaves-ordinary-buffer ()
+ "Boundary: a fundamental-mode buffer is not buried."
+ (test-wrap-up--with-mode-buffer #'fundamental-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should (eq buf (car (buffer-list))))))
+
+(ert-deftest test-wrap-up-bury-buffers-leaves-text-buffer ()
+ "Boundary: an ordinary text-mode buffer is not buried."
+ (test-wrap-up--with-mode-buffer #'text-mode
+ (switch-to-buffer buf)
+ (cj/bury-buffers)
+ (should (eq buf (car (buffer-list))))))
+
+;;; Error Cases
+
+(ert-deftest test-wrap-up-bury-buffers-survives-dead-mode-name ()
+ "Error: the sweep completes even though elisp-compile-mode does not exist.
+The removed clause named a mode Emacs has never defined; this pins that
+the function still runs cleanly with no such mode anywhere."
+ (should-not (fboundp 'elisp-compile-mode))
+ (should-not (get 'elisp-compile-mode 'derived-mode-parent))
+ (cj/bury-buffers))
+
+(provide 'test-wrap-up--bury-buffers)
+;;; test-wrap-up--bury-buffers.el ends here
diff --git a/themes/WIP-theme.el b/themes/WIP-theme.el
index 860aa919..e3b8f371 100644
--- a/themes/WIP-theme.el
+++ b/themes/WIP-theme.el
@@ -39,9 +39,11 @@
'(region ((t (:background "#424f5e"))))
'(hl-line ((t (:inherit highlight :background "#222223"))))
'(highlight ((t (:foreground "#dab53d" :background "#424f5e" :distant-foreground "#100f0f"))))
- '(mode-line ((t (:foreground "#cbd0d6" :background "#424f5e" :height 130 :box (:line-width 1 :color "#a9b2bb")))))
- '(mode-line-highlight ((t (:foreground "#e6ce88" :background "#424f5e"))))
- '(mode-line-inactive ((t (:inherit mode-line :foreground "#100f0f" :box (:line-width 1 :color "#54677d")))))
+ '(mode-line ((t (:foreground "#cbd0d6" :background "#303842" :height 130 :box (:line-width 1 :color "#7c838a")))))
+ '(mode-line-highlight ((t (:foreground "#e6ce88" :background "#303842"))))
+ '(mode-line-inactive ((t (:inherit mode-line :foreground "#100f0f" :background "#100f0f" :distant-foreground "#100f0f" :height 2 :box (:line-width 1 :color "#7c838a")))))
+ '(tab-bar ((t (:height 130))))
+ '(tab-line ((t (:height 130))))
'(fringe ((t (:foreground "#f3e7c5" :weight bold))))
'(line-number ((t (:foreground "#54677d" :height 130))))
'(line-number-current-line ((t (:foreground "#e6ce88" :height 130))))
@@ -56,8 +58,6 @@
'(warning ((t (:foreground "#ab8d2e" :weight bold))))
'(success ((t (:foreground "#74932f" :weight bold))))
'(vertical-border ((t (:foreground "#4a4b4f"))))
- '(tab-bar ((t (:height 130))))
- '(tab-line ((t (:height 130))))
'(org-document-title ((t (:foreground "#ab8d2e" :background "#100f0f" :weight bold :height 1.2))))
'(org-document-info ((t (:foreground "#ab8d2e" :background "#100f0f" :height 1.15))))
'(org-document-info-keyword ((t (:foreground "#7c838a" :background "#100f0f"))))
diff --git a/working/eat-sixel-patch/eat-xtwinops.patch b/working/eat-sixel-patch/eat-xtwinops.patch
new file mode 100644
index 00000000..4347d729
--- /dev/null
+++ b/working/eat-sixel-patch/eat-xtwinops.patch
@@ -0,0 +1,46 @@
+--- a/eat.el
++++ b/eat.el
+@@ -2809,6 +2809,33 @@
+ (funcall (eat--t-term-input-fn eat--t-term) eat--t-term
+ "\e[>0;0;0c")))))
+
++(defun eat--t-send-window-size-report (n)
++ "Respond to XTWINOPS window size report request N.
++
++N is 14 (text area size in pixels), 16 (cell size in pixels) or 18
++\(text area size in characters). Other operations are ignored.
++Multiplexers like tmux send \\='CSI 14 t\\=' to discover the cell
++pixel size they need before they will emit Sixel to a terminal."
++ (let ((disp (eat--t-term-display eat--t-term)))
++ (pcase n
++ (14
++ (funcall (eat--t-term-input-fn eat--t-term) eat--t-term
++ (format "\e[4;%i;%it"
++ (* (eat--t-disp-height disp)
++ (eat--t-term-char-height eat--t-term))
++ (* (eat--t-disp-width disp)
++ (eat--t-term-char-width eat--t-term)))))
++ (16
++ (funcall (eat--t-term-input-fn eat--t-term) eat--t-term
++ (format "\e[6;%i;%it"
++ (eat--t-term-char-height eat--t-term)
++ (eat--t-term-char-width eat--t-term))))
++ (18
++ (funcall (eat--t-term-input-fn eat--t-term) eat--t-term
++ (format "\e[8;%i;%it"
++ (eat--t-disp-height disp)
++ (eat--t-disp-width disp)))))))
++
+ (defun eat--t-send-graphics-attrs (attr operation)
+ "Send graphics attributes.
+
+@@ -3632,6 +3659,9 @@
+ ;; CSI s.
+ (`((?s) nil nil)
+ (eat--t-save-cur))
++ ;; CSI <n> t.
++ (`((?t) nil ((,n)))
++ (eat--t-send-window-size-report n))
+ ;; CSI u.
+ (`((?u) nil nil)
+ (eat--t-restore-cur)))))))
diff --git a/working/eat-sixel-patch/rulesets-companion-note.org b/working/eat-sixel-patch/rulesets-companion-note.org
new file mode 100644
index 00000000..2df33273
--- /dev/null
+++ b/working/eat-sixel-patch/rulesets-companion-note.org
@@ -0,0 +1,5 @@
+#+TITLE: EAT XTWINOPS patch companion note
+#+SOURCE: from rulesets
+#+DATE: 2026-07-13 10:58:54 -0500
+
+EAT XTWINOPS patch (companion file eat-xtwinops.patch, sent separately just now): verified live on ratio 2026-07-13, this makes sixel images render AND persist in tmux inside EAT. Root cause chain: tmux 3.7b has native sixel but refuses to transmit unless it knows the client's cell pixel size; it asks via CSI 14 t (XTWINOPS), which EAT 0.9.4 silently ignores (its CSI dispatch has no 't' case). The patch adds eat--t-send-window-size-report answering CSI 14/16/18 t from eat--t-term-char-width/height and the display dims — same fields EAT's own XTSMGRAPHICS reply already uses. With it, tmux learns the geometry at client attach, ingests raw sixel into its grid, and re-emits on every redraw — images survive window switches, scrolling, and resizing (Craig verified visually). Needs on the emacsd side: (1) a durable home for the patch — the elpa file is version-pinned, so likely a load-path shadow or an advice-free vendored fix, your call, plus wiring so both daily drivers load it (velox verified ready: same tmux build, imagemagick present); (2) written test-first per testing.md when productionizing — this was a timeboxed spike, treat the diff as evidence not deliverable; (3) strongly consider sending it upstream to EAT (codeberg akib/emacs-eat) so 0.9.5+ ships it stock. tmux side is separate (two conf lines, handoff sent to dotfiles). Ask rulesets for the full investigation log if useful.
diff --git a/working/eat-sixel-patch/takuzu-original-bug-report.org b/working/eat-sixel-patch/takuzu-original-bug-report.org
new file mode 100644
index 00000000..02107a88
--- /dev/null
+++ b/working/eat-sixel-patch/takuzu-original-bug-report.org
@@ -0,0 +1,17 @@
+#+TITLE: Bug/investigate: images from Claude Code don't display in th
+#+SOURCE: from takuzu
+#+DATE: 2026-07-11 14:41:39 -0500
+
+Bug/investigate: images from Claude Code don't display in the EAT terminal.
+
+Context: running Claude Code inside EAT (eat 0.9.4 from elpa). When the agent posts an image to the terminal, nothing renders — Craig can't see it.
+
+Finding: EAT *does* have Sixel image support. eat.el defines eat-sixel-scale, eat-sixel-aspect-ratio, eat-sixel-render-formats, and a full Sixel decode/render path (sixel-buffer, sixel-palette, sixel-render-format, etc.). So the capability exists; this isn't a plain 'EAT can't do images' limitation.
+
+Likely causes to check:
+1. Protocol mismatch: Claude Code CLI probably emits the kitty graphics protocol or the iTerm2 inline-image protocol (OSC 1337), NOT Sixel. EAT renders Sixel only, so a kitty/iTerm image sequence is dropped. Confirm which protocol Claude Code uses (env like TERM, or its terminal-capability detection) and whether it can be told to emit Sixel.
+2. Sixel render-format availability: eat-sixel-render-formats lists preferred render backends; if none is available in this Emacs build, Sixel silently won't display. Test EAT with a known-good sixel image (e.g. img2sixel output) to confirm EAT-side rendering works at all.
+
+Repro: in an EAT session, have Claude Code (or any tool) post an image; observe nothing renders. Next step: identify the emitted escape sequence (capture raw bytes) to confirm protocol.
+
+From the takuzu session, 2026-07-11 — surfaced because the agent kept posting game screenshots the terminal couldn't show.
diff --git a/working/music-svg-app-design/2026-07-19-music-config-ui-remodel-render-fixture.svg b/working/music-svg-app-design/2026-07-19-music-config-ui-remodel-render-fixture.svg
new file mode 100644
index 00000000..aad6f746
--- /dev/null
+++ b/working/music-svg-app-design/2026-07-19-music-config-ui-remodel-render-fixture.svg
@@ -0,0 +1,87 @@
+<svg xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 1916 821" width="1916" height="821">
+ <defs>
+ <radialGradient id="glassGlow" cx="40%" cy="0" r="90%">
+ <stop offset="0" stop-color="#22231f" stop-opacity=".28"/>
+ <stop offset="1" stop-color="#050605" stop-opacity="0"/>
+ </radialGradient>
+ <linearGradient id="headerBrass" x1="0" x2="1">
+ <stop offset="0" stop-color="#c4aa89"/>
+ <stop offset=".5" stop-color="#ae9374"/>
+ <stop offset="1" stop-color="#c2a27d"/>
+ </linearGradient>
+ <clipPath id="metadataClip"><rect x="780" y="104" width="350" height="180"/></clipPath>
+ <clipPath id="playlistClip"><rect x="1188" y="126" width="610" height="520"/></clipPath>
+ </defs>
+
+ <image x="0" y="0" width="1916" height="821"
+ xlink:href="concepts/30a-dupre-studios-user-refined-playlist.png"/>
+
+ <rect x="605" y="96" width="540" height="250" fill="#070807"/>
+ <rect x="605" y="96" width="540" height="250" fill="url(#glassGlow)"/>
+ <image x="612" y="108" width="146" height="173" xlink:href="assets/vinyl-placeholder.svg"/>
+ <g clip-path="url(#metadataClip)" fill="#ebe5d9" font-family="Berkeley Mono, monospace" font-size="20" letter-spacing="1.4">
+ <text x="780" y="130">A CHICKEN WITH ITS HEAD CUT OFF — REMASTERED ANNIVERSARY EDITION</text>
+ <text x="780" y="166">THE MAGNETIC FIELDS AND THE LONG-LOST ORCHESTRA</text>
+ <text x="780" y="202">69 LOVE SONGS: THE COMPLETE THREE-VOLUME COLLECTION</text>
+ <text x="780" y="238">MERGE RECORDS</text>
+ <text x="780" y="274">1999</text>
+ </g>
+ <text x="610" y="322" fill="#ddd4c5" font-family="Berkeley Mono, monospace" font-size="19">01:30</text>
+ <line x1="679" y1="316" x2="1023" y2="316" stroke="#3b3a37" stroke-width="2"/>
+ <line x1="679" y1="316" x2="850" y2="316" stroke="#f2a900" stroke-width="2"/>
+ <circle cx="850" cy="316" r="9" fill="#c79a60" stroke="#f0d4a7" stroke-width="1"/>
+ <text x="1040" y="322" fill="#ddd4c5" font-family="Berkeley Mono, monospace" font-size="19">02:59</text>
+
+ <rect x="1172" y="52" width="688" height="65" fill="url(#headerBrass)"/>
+ <g fill="#251c14" font-family="Berkeley Mono, monospace" font-size="20" letter-spacing="1.5">
+ <text x="1202" y="94">PLAYLIST</text>
+ <text x="1316" y="94">|</text>
+ <text x="1355" y="94">69 LOVE SONGS — COMPLETE</text>
+ <text x="1731" y="94">69 TRACKS</text>
+ </g>
+ <rect x="1172" y="118" width="688" height="545" fill="#070807"/>
+ <rect x="1172" y="118" width="688" height="545" fill="url(#glassGlow)"/>
+ <g clip-path="url(#playlistClip)" font-family="Berkeley Mono, monospace" font-size="17" letter-spacing=".8">
+ <g fill="#ddd8cf">
+ <text x="1192" y="153">01</text><text x="1237" y="153">ABSOLUTELY CUCKOO — THE MAGNETIC FIELDS</text><text x="1760" y="153">02:02</text>
+ <text x="1192" y="190">02</text><text x="1237" y="190">I DON’T BELIEVE IN THE SUN — THE MAGNETIC FIELDS</text><text x="1760" y="190">02:50</text>
+ <text x="1192" y="227">03</text><text x="1237" y="227">ALL MY LITTLE WORDS — THE MAGNETIC FIELDS</text><text x="1760" y="227">02:03</text>
+ </g>
+ <rect x="1175" y="238" width="650" height="37" fill="#f2a900" fill-opacity=".07"/>
+ <g fill="#f2a900">
+ <text x="1174" y="264">▶</text><text x="1192" y="264">04</text><text x="1237" y="264">A CHICKEN WITH ITS HEAD CUT OFF — THE MAGNETIC FIELDS</text><text x="1760" y="264">02:59</text>
+ </g>
+ <g fill="#ddd8cf">
+ <text x="1192" y="301">05</text><text x="1237" y="301">(I’M) DAZOTA — THE MAGNETIC FIELDS</text><text x="1760" y="301">02:38</text>
+ <text x="1192" y="338">06</text><text x="1237" y="338">I DON’T WANT TO GET OVER YOU — THE MAGNETIC FIELDS</text><text x="1760" y="338">02:50</text>
+ <text x="1192" y="375">07</text><text x="1237" y="375">COME BACK FROM SAN FRANCISCO — THE MAGNETIC FIELDS</text><text x="1760" y="375">02:37</text>
+ <text x="1192" y="412">08</text><text x="1237" y="412">THE LUCKIEST GUY ON THE LOWER EAST SIDE — THE MAGNETIC FIELDS</text><text x="1760" y="412">01:32</text>
+ <text x="1192" y="449">09</text><text x="1237" y="449">LET’S PRETEND WE’RE BUNNY RABBITS — THE MAGNETIC FIELDS</text><text x="1760" y="449">02:10</text>
+ <text x="1192" y="486">10</text><text x="1237" y="486">THE CACTUS WHERE YOUR HEART SHOULD BE — THE MAGNETIC FIELDS</text><text x="1760" y="486">02:38</text>
+ <text x="1192" y="523">11</text><text x="1237" y="523">I THINK I NEED A NEW HEART — THE MAGNETIC FIELDS</text><text x="1760" y="523">02:58</text>
+ <text x="1192" y="560">12</text><text x="1237" y="560">THE BOOK OF LOVE — THE MAGNETIC FIELDS</text><text x="1760" y="560">02:56</text>
+ <text x="1192" y="597">13</text><text x="1237" y="597">FIDO, YOUR LEASH IS TOO LONG — THE MAGNETIC FIELDS</text><text x="1760" y="597">02:13</text>
+ <text x="1192" y="634">14</text><text x="1237" y="634">HOW FUCKING ROMANTIC — THE MAGNETIC FIELDS</text><text x="1760" y="634">00:58</text>
+ </g>
+ </g>
+
+ <g fill="#171714" stroke="#6b5a3c" stroke-width="1.5">
+ <circle cx="862" cy="614" r="6"/><circle cx="850" cy="588" r="6"/><circle cx="846" cy="559" r="6"/>
+ <circle cx="850" cy="530" r="6"/><circle cx="861" cy="502" r="6"/><circle cx="878" cy="479" r="6"/>
+ <circle cx="901" cy="461" r="6"/><circle cx="928" cy="451" r="6"/><circle cx="957" cy="447" r="6"/>
+ <circle cx="986" cy="451" r="6"/><circle cx="1013" cy="461" r="6"/><circle cx="1036" cy="479" r="6"/>
+ <circle cx="1053" cy="502" r="6"/><circle cx="1064" cy="530" r="6"/><circle cx="1068" cy="559" r="6"/>
+ <circle cx="1064" cy="588" r="6"/><circle cx="1052" cy="614" r="6"/>
+ </g>
+ <g fill="#ffb23c" stroke="#fff0bd" stroke-width="1.5">
+ <circle cx="862" cy="614" r="6"/><circle cx="850" cy="588" r="6"/><circle cx="846" cy="559" r="6"/>
+ <circle cx="850" cy="530" r="6"/><circle cx="861" cy="502" r="6"/><circle cx="878" cy="479" r="6"/>
+ <circle cx="901" cy="461" r="6"/><circle cx="928" cy="451" r="6"/><circle cx="957" cy="447" r="6"/>
+ <circle cx="986" cy="451" r="6"/><circle cx="1013" cy="461" r="6"/>
+ </g>
+
+ <line x1="227" y1="537" x2="245" y2="467" stroke="#f3d59c" stroke-width="2"/>
+ <line x1="480" y1="537" x2="496" y2="466" stroke="#f3d59c" stroke-width="2"/>
+</svg>
diff --git a/working/music-svg-app-design/2026-07-19-music-config-ui-remodel-spec.org b/working/music-svg-app-design/2026-07-19-music-config-ui-remodel-spec.org
new file mode 100644
index 00000000..6e68e104
--- /dev/null
+++ b/working/music-svg-app-design/2026-07-19-music-config-ui-remodel-spec.org
@@ -0,0 +1,923 @@
+#+TITLE: Music Config UI Remodel — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-19
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Music Config UI Remodel
+:PROPERTIES:
+:ID: bcbb9342-1884-4281-8821-89b06f6f8793
+:END:
+- 2026-07-19 Sun @ 16:03:13 -0500 — drafted from the approved Dupre Studios design session.
+
+** Prototype iterations
+
+The design session started with working HTML directions, moved through six
+distinct visual concepts, and then refined one receiver layout through paired
+playlist/radio boards. The last board is Craig's GIMP refinement and is the
+visual authority for this spec.
+
+- [[file:music-svg-directions.html][Initial working SVG directions]]
+- [[file:concept-board.html][Six-direction comparison board]]
+- [[file:concepts/01-champagne-receiver.png][01 Champagne Receiver]]
+- [[file:concepts/02-le-mans-night-cluster.png][02 Le Mans Night Cluster]]
+- [[file:concepts/03-geneva-playback-chronograph.png][03 Geneva Playback Chronograph]]
+- [[file:concepts/04-mastering-room-reel-console.png][04 Mastering Room Reel Console]]
+- [[file:concepts/05-perpetual-calendar-salon.png][05 Perpetual Calendar Salon]]
+- [[file:concepts/06-transatlantic-broadcast-navigator.png][06 Transatlantic Broadcast Navigator]]
+- [[file:concepts/07-functional-black-glass-receiver.png][07 Functional black-glass receiver]]
+- [[file:concepts/08-functional-black-glass-flipped.png][08 Playlist-flipped receiver]]
+- [[file:concepts/09-functional-black-glass-retro.png][09 Warmer retro receiver]]
+- [[file:concepts/10-warm-black-glass-player-volume.png][10 Player-volume receiver]]
+- [[file:concepts/11-illuminated-black-glass-controls.png][11 Illuminated controls]]
+- [[file:concepts/12-consolidated-functional-receiver.png][12 Consolidated functional receiver]]
+- [[file:concepts/13-black-silver-chronograph-receiver.png][13 Black-silver chronograph]]
+- [[file:concepts/14-corrected-luxury-chronograph-receiver.png][14 Corrected luxury chronograph]]
+- [[file:concepts/15-champagne-brass-digital-hifi.png][15 Champagne digital hi-fi]]
+- [[file:concepts/16-champagne-aluminum-radio-groups.png][16 Explicit radio grouping]]
+- [[file:concepts/17-dupre-coltrane-scrollbar.png][17 Long-playlist and scrollbar stress test]]
+- [[file:concepts/18-dupre-branding-control-study.png][18 Branding and control study]]
+- [[file:concepts/19a-dupre-playlist-state.png][19a Playlist state]] and [[file:concepts/19b-dupre-radio-state.png][19b radio state]]
+- [[file:concepts/20-dupre-playlist-radio-comparison.png][20 Long playlist / compact radio comparison]]
+- [[file:concepts/21-dupre-semantic-controls-comparison.png][21 Semantic control comparison]]
+- [[file:concepts/22-dupre-identity-bay-comparison.png][22 Identity-bay comparison]]
+- [[file:concepts/23-dupre-swapped-info-comparison.png][23 Swapped information comparison]]
+- [[file:concepts/24-dupre-full-brass-vu-comparison.png][24 Full-brass VU comparison]]
+- [[file:concepts/25-dupre-seamless-flush-comparison.png][25 Seamless flush comparison]]
+- [[file:concepts/26-dupre-studios-rounded-chrome-comparison.png][26 Dupre Studios comparison]]
+- [[file:concepts/27-dupre-studios-leather-analog-header-comparison.png][27 Leather and analog-header comparison]]
+- [[file:concepts/28-dupre-studios-balanced-controls-comparison.png][28 Balanced-control comparison]]
+- [[file:concepts/29a-dupre-studios-brass-seek-playlist.png][29 Brass seek-thumb pass]]
+- [[file:concepts/30a-dupre-studios-user-refined-playlist.png][30 Craig's near-final playlist board (visual authority)]]
+- [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][31 Functional local-playlist / radio prototype (acceptance candidate)]]
+
+* Metadata
+| Status | Not ready — first review found one functional-prototype blocker |
+|----------+------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+------------------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+------------------------------------------------------------------------|
+| Related | [[file:../../todo.org][music: SVG player application design and delivery]]; [[file:../../docs/specs/2026-07-06-fancy-music-player-ui-spec.org][implemented fancy player spec]]; [[file:../../docs/specs/music-config-without-emms-spec.org][EMMS-free architecture spec]] |
+|----------+------------------------------------------------------------------------|
+
+* Summary
+
+Replace the current text-and-image EMMS playlist surface with a realistic
+Dupre Studios receiver rendered inside Emacs. The interface uses a raster skin
+for leather, glass, brass, and machined controls, then draws all changing
+information and illumination in SVG. Emacs Lisp remains the application
+runtime and maps the SVG surface to the existing music-config commands.
+
+The remodel preserves the plain and fancy text views as fallbacks. It does not
+change M3U ownership, radio-browser behavior, or the current EMMS playback model.
+
+* Problem / Context
+
+The implemented fancy player improved names, artwork, and progress, but it is
+still a text playlist with a multi-line overlay. It cannot present the player
+as one coherent instrument. Controls are key hints rather than controls,
+playlist management is visually detached from playback, and active state is
+limited to text faces and a character-cell progress bar.
+
+Craig wants a bottom-docked application that looks like a manufactured object:
+black leather, black glass, champagne brass, lit analog meters, and discreet
+controls. The beauty has to explain state. A lamp, depressed key, moving
+needle, seek position, or selected row must correspond to real music-config or
+mpv behavior. Decorative AM/FM, tone, power, reload, manual URL, and other
+unsupported faceplate controls are prohibited.
+
+The final 1916×821 board settles the visual direction. The remaining design
+problem is how to preserve its realism without baking live state into a picture.
+An all-vector redraw would lose much of the leather and metal detail. A single
+clickable PNG would preserve the appearance but freeze track data, meter state,
+playlist rows, and button feedback. The chosen design separates the physical
+object from its information and behavior.
+
+* Goals and Non-Goals
+
+** Goals
+
+- Match Craig's near-final board closely enough that the running player reads as
+ the same manufactured receiver, not a simplified diagram of it.
+- Preserve the full music-config workflow: playback, seek, player volume,
+ playlist browsing and persistence, radio search, repeat/single/random/consume,
+ reordering, add, new, load, save, and delete.
+- Make every visible state honest. Meter needles use real program levels,
+ volume lamps use mpv volume, progress uses mpv position, and mode lamps use
+ current mode state.
+- Keep the hot path compact: album information and controls on the left,
+ fourteen playlist rows and playlist actions on the right.
+- Keep keyboard operation complete while adding direct mouse interaction.
+- Preserve the existing text render for TTY frames, unsupported SVG builds, and
+ opt-out/rollback.
+- Keep the renderer independent of EMMS objects so the planned EMMS-free/VAMP
+ work can replace the controller without replacing the visual layer.
+
+** Non-Goals
+
+- No music library browser, tag editor, lyrics view, waveform editor, or album
+ collection database.
+- No streaming-service integration.
+- No fake receiver functions such as AM/FM tuning, bass, treble, speakers, or
+ power.
+- No permanent faceplate controls for reload, editing the M3U file, or manual URL
+ entry. Existing keyboard and M-x access may remain.
+- No all-vector imitation of leather, brushed metal, or photographic reflections.
+- No EMMS removal or VAMP extraction in this project. Those remain separate
+ architecture efforts.
+- No alternate narrow/mobile layout in v1. The player scales as one panoramic
+ instrument.
+
+** Scope tiers
+
+- v1: neutral raster skin, SVG renderer, dedicated graphical view buffer,
+ complete keyboard/mouse command surface, playlist viewport, seek, persistent
+ mpv volume, real stereo VU telemetry, local-file and radio states, diagnostics,
+ fallbacks, tests, and live verification.
+- Out of scope: library browsing, ratings, per-track menus, editable metadata,
+ alternate skins, responsive reflow, and remote control.
+- vNext: multiple visual skins, a narrower alternate layout, richer motion
+ transitions, and reuse in a future standalone VAMP package.
+
+* Design
+
+** User view
+
+F10 opens the player in the existing bottom side-window position. In a
+graphical frame with the remodel enabled, the buffer is the Dupre Studios
+receiver. The same keys still work. The visible buttons also work with the
+mouse, the playlist scrolls inside its fourteen-row aperture, and selecting a
+row starts that track.
+
+A local file shows album art, title, artist, album, label, and year on separate
+lines when those fields exist. It shows elapsed and total time with a draggable
+seek control. A live station shows station art and two ON AIR indications: one
+under the art and one in the active station row. It never shows a seek control
+or invented time for a stream. Missing metadata produces no placeholder word.
+
+The interface behaves like hardware. Play/Pause sits depressed and glows green
+while playing. Momentary transport controls brighten while pressed. Active
+play modes illuminate the circumference of their brass buttons. Volume lamps
+fill continuously to the current mpv volume. VU needles move from the actual
+left and right audio levels.
+
+The interaction model is available in the [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][functional browser prototype]]. It
+switches between a 69-track local fixture and an 18-station radio fixture and
+wires transport, playback modes, radio search, playlist actions, row selection,
+seek, volume, wheel/keyboard browsing, scrollbar drag, empty state, and delete
+confirmation. Its built-in twelve-check behavior pass is green. Craig's
+hands-on acceptance remains the last prototype gate.
+
+** Research and visual sources
+
+The design session compared NAD restraint, Marshall's black-and-brass control
+language, McIntosh black glass and meter hierarchy, vintage stereo transport
+controls, automobile instrument clusters, and black/silver chronographs. The
+useful lessons were consistent: controls need one visual family, status lights
+need stable color meaning, labels belong on the faceplate, and ornament should
+make state easier to read.
+
+The local Dupre references are the implementation sources:
+
+- [[file:references/dupre-gallery.png][Dupre component gallery capture]]
+- [[file:references/waybar-redesign.png][Waybar redesign capture]]
+- [[file:README.org][Design-session decisions and critique log]]
+- [[file:concepts/30a-dupre-studios-user-refined-playlist.png][Near-final visual authority]]
+- [[file:../../../code/archsetup/docs/prototypes/gallery-widget.el][svg.el Dupre renderer proof of concept]]
+
+** Canonical geometry
+
+The source canvas is 1916×821 with a viewBox of =0 0 1916 821=. The renderer
+preserves that aspect ratio and scales it to the available window width. It
+does not reflow the playlist under the controls or move controls at runtime.
+Extra vertical room stays black below the instrument. A window too short for
+legible rendering falls back to the existing fancy text view rather than
+shrinking controls into unusable targets.
+
+Craig's final board defines the visual positions. Phase 1 records them in one
+geometry table, including every visual rectangle, label anchor, hit target, and
+playlist row. Rendering and hit testing use the same constants. No second
+hand-maintained map is allowed.
+
+The principal regions are:
+
+| Region | Approximate source bounds |
+|------------------------+---------------------------|
+| Complete instrument | 0,0 to 1916,821 |
+| Player upper glass | 50,50 to 1170,368 |
+| Lower brass deck | 69,372 to 1147,744 |
+| Playlist glass | 1170,52 to 1862,744 |
+| Branding field | 150,115 to 500,275 |
+| Artwork | 609,108 to 755,281 |
+| Metadata | 780,100 to 1125,275 |
+| Seek / ON AIR line | 608,302 to 1125,330 |
+| Playlist viewport | 1175,126 to 1835,658 |
+| Playlist actions | 1173,671 to 1850,733 |
+
+Exact measured values replace these orientation bounds in the geometry table.
+
+** Rendering layers
+
+The SVG is a scene graph with four layers.
+
+1. The physical shell is raster. It contains the leather chassis, compressed
+ seams, black glass texture, fine low-contrast champagne micro-brush, recessed
+ grooves, fixed branding,
+ engraved labels, neutral control bodies, neutral volume knob, and empty VU
+ scales. The application embeds the assets with =svg-embed= and caches their
+ encoded data for the Emacs session.
+
+2. Static vector geometry supplies clip paths, hit-region outlines used only in
+ debug mode, row separators, and any simple lines that need to stay sharp at
+ different scales.
+
+3. Dynamic SVG supplies album/station art, metadata, playlist header and rows,
+ progress, brass seek thumb, scrollbar, transport glyphs, pressed states,
+ lamps, VU needles, and meter light. No live text, needle, lamp, selected row,
+ scrollbar, or progress state is baked into the raster shell.
+
+4. Emacs Lisp supplies behavior. SVG rendered through librsvg does not run
+ browser JavaScript. A buffer-local keymap receives mouse events,
+ =posn-object-x-y= returns pixel coordinates inside the displayed image, and
+ one scale transform maps them back to the source coordinate system.
+
+The near-final PNG cannot be embedded unchanged. Phase 1 creates a neutral
+layered source by removing metadata, playlist text, active lamps, VU needles,
+progress, and selection from the final board. The source stays editable. The
+editable source stays one full-resolution instrument. Runtime rendering uses
+three aligned image regions: upper player glass, lower control deck, and
+playlist. A compact control sprite sheet and neutral VU face supplement those
+regions. The current 1916×821 art is treated as a two-times source for a typical
+roughly 958×410 rendered dock. Opaque runtime crops may be exported at their
+display size after a PNG/JPEG visual comparison. V1 does not upscale beyond the
+native source pixels.
+
+** Module and state boundary
+
+The SVG view lives in a new =modules/music-svg-ui.el= module. It requires
+=svg=, =dom=, and ordinary Emacs image support, but it does not require EMMS.
+Its pure entry points accept plain data:
+
+- =cj/music-svg-render= takes a state snapshot and viewport.
+- =cj/music-svg-hit-target= maps source coordinates to an action description.
+- =cj/music-svg-truncate= fits display text to a fixed pixel/character budget.
+- =cj/music-svg-scrollbar= computes thumb length and position.
+- =cj/music-svg-vu-angle= maps a smoothed dB value to the meter scale.
+
+=modules/music-config.el= remains the controller. It builds an ephemeral
+=cj/music-ui-state= snapshot from the current EMMS playlist and player state,
+then dispatches renderer actions back to existing commands. The snapshot owns
+no durable music data. EMMS and M3U files remain authoritative in v1.
+
+The snapshot contains:
+
+- playback status and track kind
+- current track identity and metadata
+- art path, elapsed time, duration, and seekability
+- playlist name/file, total count, rows, selected index, and viewport offset
+- repeat, single, random, and consume state
+- player volume
+- left/right meter values
+- transient pressed/focused target
+
+The graphical view is a dedicated =*Music Player*= special-mode buffer. The
+hidden EMMS playlist buffer continues to own playlist markers and track order.
+Controller helpers translate a visible row index to an EMMS buffer position
+before selection, playback, reorder, or deletion. This avoids making a single
+image character pretend to be fourteen editable EMMS rows, and it leaves a
+clean seam for the EMMS-free state API later.
+
+** Metadata and typography
+
+Berkeley Mono is the default instrument typeface because it is the Dupre token
+font and is installed on the target system. Missing font support falls back to
+=monospace=. The cursive Dupre Studios mark remains raster in the physical
+shell.
+
+Title, artist, album, label, and year each get one line. Empty values disappear
+without closing the line spacing above them. The title receives the largest
+width budget and truncates with a literal ellipsis. The renderer uses the
+fixed-width font metrics and the measured metadata box, not a guessed character
+count from the current window.
+
+The playlist shows fourteen one-line rows. Each row is number, title, artist,
+and duration on one line. The row text truncates before the duration column.
+Inactive text is soft white. The active row is amber and carries the triangular
+play marker. There are no decorative status dots.
+
+The header is the only coffee/cream instrument card. It shows the current
+collection or search name and item count, but never labels the source as a
+playlist or radio. Four concealed warm-light pools are part of the static skin,
+but the text is SVG because the name and count change. Its champagne surface is
+smooth metal with a fine linear micro-brush, never coarse or wavy wood grain.
+
+** Playlist viewport
+
+The UI keeps a buffer-local first-visible index. The current track is kept in
+view after track changes unless the user is actively browsing elsewhere. Mouse
+wheel moves three rows. Up/Down moves the UI cursor one row. Page Up/Page Down
+moves fourteen. Home/End goes to the first/last track. A row click selects and
+starts that track. The existing keyboard play command remains available.
+
+For =N= tracks and fourteen visible rows, the scrollbar thumb fraction is
+=min(1, 14/N)=. Its position is =offset/(N-14)= when =N > 14=. Dragging the
+thumb changes the viewport but never changes the playing track. No scrollbar
+appears for fourteen or fewer tracks.
+
+** Control behavior
+
+The visible controls map to existing behavior:
+
+| Surface | Action |
+|---------------+--------|
+| PREV | =cj/music-previous= |
+| PLAY/PAUSE | start selected/current track, or =emms-pause= while playing |
+| STOP | =emms-stop= |
+| NEXT | =cj/music-next= |
+| REPEAT | =emms-toggle-repeat-playlist= |
+| SINGLE | =emms-toggle-repeat-track= |
+| RANDOM | =emms-toggle-random-playlist= |
+| CONSUME | =cj/music-toggle-consume= |
+| NAME | =cj/music-radio-search-by-name= |
+| TAGS | =cj/music-radio-search-by-tag= |
+| ADD | =cj/music-fuzzy-select-and-add= |
+| NEW | =cj/music-playlist-clear=, including its file-association reset |
+| LOAD | =cj/music-playlist-load= |
+| SAVE | =cj/music-playlist-save= |
+| DELETE | =cj/music-delete-playlist= with its existing strong confirmation |
+
+Mouse press immediately renders the physical pressed state. Release inside the
+same target dispatches the command. Release elsewhere cancels it. The
+Play/Pause key remains depressed while playback is active. PREV and NEXT only
+brighten during a press. STOP has no latched light.
+
+The four play-mode controls are alternate-action pushbuttons, not lamps or
+rotary controls. Each has an unmarked spun-brass cap matching the volume cap, a
+fixed collar, and a narrow dark travel seam; the faceplate label identifies its
+function. Off stands slightly proud and casts a small lower shadow. On sits
+nearly flush, collapses that shadow, and illuminates a thin desaturated-green
+ring inside the cap/collar gap. Play/Pause uses the same warm instrument green
+through its cut-out symbol with a tight internal bloom; the depressed key
+remains the primary playing cue.
+
+The SVG mode retains the full existing keyboard surface, including commands not
+placed on the faceplate: manual station creation, reload, edit, append to another
+playlist, shuffle, track removal, and track reordering. Which-key/help text
+documents those commands. The faceplate stays limited to the approved controls.
+
+** Seek and volume
+
+Local files show elapsed time, total duration, an amber progress line, and a
+narrow vertical machined-brass grip. Clicking or dragging the slider sends an
+mpv =seek= command in
+=absolute-percent= mode. The displayed thumb is computed from mpv =time-pos=
+and =duration=. While dragging, the local preview follows the pointer; the next
+mpv response confirms or corrects it.
+
+URL tracks always use the radio presentation. The slider and times disappear.
+One red =ON AIR= line appears under the station art, and the active playlist row
+contains the second =ON AIR=. No other ON AIR label is rendered.
+
+Player volume is separate from the system mixer. Add
+=cj/music-player-volume=, default 100, and apply it to every new mpv process.
+The knob has no number or pointer. Its equally spaced lamps show the stored
+0–100 value. Click/drag around the arc sets volume, the mouse wheel changes it
+by five, and the existing +/− bindings call the same player-volume commands.
+Setting volume writes mpv's =volume= property and updates the stored value so a
+track change does not reset it.
+
+Every volume position is one fixed lamp well. Dark and illuminated states use
+the same center and mounting bezel; the lit state replaces the dark lens rather
+than adding another dot or adjacent glow. The neutral raster skin contains no
+baked lamp cores or light spill.
+
+** Real VU telemetry
+
+mpv starts with a labeled FFmpeg audio filter:
+
+#+begin_src text
+--af-add=@meter:lavfi=[astats=metadata=1:reset=1:measure_perchannel=RMS_level+Peak_level:measure_overall=none]
+#+end_src
+
+The UI reads =af-metadata/meter= over mpv JSON IPC. FFmpeg supplies
+=lavfi.astats.1.RMS_level= and =lavfi.astats.2.RMS_level= (plus peak values).
+Mono duplicates channel one to both meters. Missing or nonnumeric data rests
+the needles and records one diagnostic; it never invents motion.
+
+This path was verified locally on 2026-07-19 with mpv 0.41.0 and FFmpeg 8.1.2.
+A synthetic stereo source returned distinct left/right RMS and peak values
+through the labeled property. The official references are:
+
+- [[https://mpv.io/manual/master/#command-interface-af-metadata][mpv af-metadata property]]
+- [[https://ffmpeg.org/ffmpeg-filters.html#astats-1][FFmpeg astats filter]]
+
+The renderer maps dB to the printed meter scale (−40 through +3). A 300 ms
+attack and slower 600 ms release approximate a physical VU movement. Values
+below the scale rest at the left stop. Values above +3 clamp at the right stop.
+
+Telemetry uses one persistent, nonblocking IPC connection with request IDs and
+line-buffered JSON replies. It polls only while the player view is visible and
+audio is running. It closes when playback stops or the view is hidden. The
+existing one-shot IPC helpers remain valid for infrequent commands.
+
+** Refresh and render budget
+
+Hooks trigger immediate redraws for track, playlist, playback, mode, and art
+changes. Progress updates four times per second while a seekable file plays.
+Meters target ten updates per second while visible and playing. Paused, stopped,
+hidden, and TTY states run no animation timer.
+
+The [[file:prototypes/2026-07-19-music-config-ui-remodel-benchmark.org][prototype benchmark]] measured full-image and tiled refresh for one minute
+each at the real dock size. Full refresh failed at 133.616 ms median and
+139.822 ms p95. A display-sized lower-deck tile passed at 35.513 ms median and
+38.221 ms p95. The application therefore uses three aligned images: upper
+player glass, lower control deck, and playlist. Each image includes the
+matching raster crop. Meter ticks refresh only the lower deck; hooks refresh
+the other regions. The source geometry and user-visible layout do not change.
+
+** Errors and empty states
+
+An empty queue shows the receiver, =UNTITLED=, =0 TRACKS=, and fourteen blank
+rows. Playlist actions and radio search remain enabled. Transport, seek, and
+mode actions that require a track are visibly inactive and report a short
+message if invoked by key.
+
+Missing artwork uses the existing vinyl placeholder. Missing metadata is
+omitted. The UI never displays =Unknown=, =N/A=, a raw nil, or a raw stream URL
+as program information.
+
+If SVG, librsvg, the raster shell, or a minimum legible viewport is unavailable,
+F10 opens the implemented fancy/text playlist instead and reports why once. If
+mpv IPC is unavailable, seek, volume telemetry, and meters disable without
+stopping playback. A command reports the failed operation and the next step,
+for example: =Music seek unavailable: mpv IPC socket is not connected; start a
+track and try again.=
+
+=M-x cj/music-ui-doctor= reports graphical/SVG support, resolved assets and font,
+view size, last render time, mpv socket state, meter filter state, telemetry
+timer state, and the last telemetry error. It does not log private file paths
+or stream URLs unless called with a debug prefix.
+
+* Alternatives Considered
+
+** Hybrid raster skin plus dynamic SVG (chosen)
+
+- Good, because it preserves the leather, glass, brass grain, and machined
+ highlights from the approved board while keeping every changing value honest.
+- Bad, because the neutral raster source and SVG geometry must stay aligned.
+- Neutral, because the result is still one SVG application document even though
+ the document embeds raster assets.
+
+** Rebuild the complete receiver as vector geometry
+
+- Good, because every surface would scale without raster limits and theme tokens
+ could recolor everything.
+- Bad, because reproducing leather, brushed brass, glass, and irregular reflected
+ light would take much longer and would look less like the approved board.
+- Neutral, because simple Dupre controls still supply useful vector geometry
+ inside the hybrid renderer.
+
+** Use the final PNG as one clickable background
+
+- Good, because it would match the approved image immediately.
+- Bad, because the PNG contains one track, one playlist, lit volume segments,
+ meter needles, progress, and button state. Painting over those areas would
+ flatten the material texture and produce visible patches.
+- Neutral, because a cleaned neutral derivative of the PNG is the chosen shell.
+
+** Browser/xwidget application
+
+- Good, because browser SVG supports native DOM events and partial element
+ updates.
+- Bad, because it introduces a browser runtime inside Emacs, complicates window
+ integration, and weakens TTY and configuration portability.
+- Neutral, because browser prototypes remain useful before the svg.el port.
+
+** Replace the EMMS playlist buffer in place
+
+- Good, because no second buffer name or adapter is needed.
+- Bad, because EMMS commands depend on point and text markers while the SVG is
+ one displayed image glyph. Mixing invisible rows and image coordinates would
+ be brittle.
+- Neutral, because the dedicated view still delegates every operation to the
+ existing EMMS buffer in v1.
+
+* Decisions [10/10]
+
+** DONE Hybrid raster/SVG rendering
+- Context: the final board's realism comes from material texture and reflected
+ light, while live state must remain replaceable.
+- Decision: We will embed a neutral raster skin in an SVG scene and render all
+ changing information, needles, lights, and selection as SVG.
+- Consequences: easier, the running UI can closely match the board. Harder, the
+ asset export and SVG geometry need one measured coordinate system.
+
+** DONE Craig's final board is the visual authority
+- Context: the design session produced many useful directions, but implementation
+ needs one source for proportion and material treatment.
+- Decision: We will use concept 30, Craig's 1916×821 GIMP refinement, as the
+ layout and appearance authority.
+- Consequences: easier, visual disputes can be checked against one board. Harder,
+ the radio state must be derived without drifting from that hardware.
+
+** DONE Neutral physical shell
+- Context: the final flattened PNG contains transient information and active
+ state.
+- Decision: We will create an editable neutral source and export a clean shell,
+ control sprites, and neutral VU face. No live state remains in those assets.
+- Consequences: easier, texture stays realistic. Harder, Phase 1 includes careful
+ image cleanup before application code can look finished.
+
+** DONE Dedicated graphical view buffer
+- Context: EMMS playlist operations are point/marker based and cannot treat one
+ SVG image as fourteen text rows.
+- Decision: We will render into =*Music Player*= and use controller adapters to
+ operate on the hidden EMMS playlist buffer.
+- Consequences: easier, the SVG renderer stays clean and future-backend ready.
+ Harder, selection and reorder commands need explicit index-to-marker adapters.
+
+** DONE One geometry table for drawing and input
+- Context: separate hand-maintained visual and hit maps will drift.
+- Decision: We will measure the final board once and derive rendering, hit tests,
+ clipping, and scaling from the same constants.
+- Consequences: easier, controls stay clickable after scaling. Harder, geometry
+ extraction is a named deliverable rather than ad hoc coordinates in draw code.
+
+** DONE Direct control semantics
+- Context: every faceplate control must perform real music-config behavior.
+- Decision: We will map the approved transport, modes, radio search, playlist
+ actions, seek, and volume controls directly to their existing commands or the
+ named mpv additions in this spec.
+- Consequences: easier, the interface is self-explanatory. Harder, commands not
+ approved for the faceplate remain keyboard/M-x only and need discoverable help.
+
+** DONE Persistent per-player volume
+- Context: the existing +/- path can change the system mixer, while the design's
+ knob is the player's volume and mpv restarts for each track.
+- Decision: We will store volume in music-config, write mpv's volume property,
+ and apply the stored value when every new mpv process starts.
+- Consequences: easier, the knob is truthful and track changes preserve level.
+ Harder, one more piece of runtime state must stay synchronized with mpv.
+
+** DONE Real meter data through mpv astats
+- Context: random needle animation would violate the design's functional-lighting
+ rule.
+- Decision: We will read left/right RMS metadata from a labeled FFmpeg astats
+ filter over mpv IPC and apply physical needle smoothing.
+- Consequences: easier, the VUs convey real program level. Harder, a visible
+ view needs a small persistent IPC telemetry client and bounded refresh timer.
+
+** DONE Current text views remain fallbacks
+- Context: TTY frames and SVG/asset failures need a usable player, and the
+ implemented fancy text UI already works.
+- Decision: We will add an SVG enable flag and fall back to the current fancy or
+ plain text path without changing M3U or playback state.
+- Consequences: easier, rollout and rollback are safe. Harder, the text path
+ remains maintained alongside the SVG application.
+
+** DONE Three-region render path after measurement
+- Context: =svg-possibly-update-image= rerasterizes an SVG image, so ten full
+ receiver renders per second may exceed the dock's CPU budget.
+- Decision: The prototype benchmark measured the full receiver at 133.616 ms
+ median / 139.822 ms p95 and the display-sized lower-deck tile at 35.513 ms
+ median / 38.221 ms p95. We will use three aligned regions and refresh only
+ the lower deck at meter cadence.
+- Consequences: easier, meter animation passes the render budget with margin.
+ Harder, the renderer places three images and the asset export must hide their
+ boundaries exactly.
+
+* Review findings [0/1]
+
+** TODO The final interaction model has no working two-state prototype :blocking:
+
+The design session produced extensive static boards and established concept 30
+as the visual authority, but the final geometry has not been exercised as a
+working local-playlist and radio prototype. That leaves click targets, drag
+behavior, playlist scrolling, long-text clipping, local/radio state changes,
+and the full-image render budget unproven. Starting implementation now would
+make the first build phase answer product and performance questions that belong
+in the design gate.
+
+Smallest resolution: create one functional prototype using concept 30's
+geometry and neutral skin. It may be a browser prototype or an Emacs SVG proof,
+but it must switch between a long local playlist and a live-radio fixture; wire
+transport, modes, radio search, playlist actions, row and scrollbar navigation,
+seek, and volume interaction; show all pressed and latched states; stress long
+metadata; and record one minute of render timing at the intended dock size.
+Link the accepted prototype under Prototype iterations and Design, record any
+geometry or performance changes here, then repeat spec review.
+
+Progress on 2026-07-19: [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][prototype 1]] now supplies both fixtures and the named
+interactions; its twelve-check behavior pass is green. The [[file:prototypes/2026-07-19-music-config-ui-remodel-benchmark.org][librsvg benchmark]]
+records a full-render failure and a passing lower-deck tile, so the spec now
+requires the three-region renderer. Craig's hands-on acceptance is the only
+remaining part of this finding.
+
+* Implementation phases
+
+** Phase 1 — Neutral skin and geometry manifest
+
+Create the editable neutral source from concept 30. Export aligned upper-glass,
+lower-deck, and playlist crops plus control sprites and the VU face under final
+dated asset names. Record measured
+source rectangles, anchors, clip boxes, and hit targets in one Lisp geometry
+table. Add an asset/geometry debug command that renders target outlines over the
+skin. The phase ends with a static SVG in Emacs that matches the final board and
+contains no baked live state.
+
+** Phase 2 — Pure SVG renderer and view mode
+
+Add =modules/music-svg-ui.el=, the state structs, pure layout helpers, raster
+embedding cache, text clipping, dynamic node construction, and
+=cj/music-svg-mode=. F10 can show/hide the static graphical view while the
+existing fancy/text path remains available. Add unit tests for scale transforms,
+hit testing, truncation, scrollbar math, mode colors, and VU angle mapping.
+
+** Phase 3 — Controller snapshot and playlist viewport
+
+Build state snapshots from EMMS without exposing EMMS objects to the renderer.
+Render metadata, album art, header, fourteen rows, selection, and the exact
+scrollbar. Implement keyboard and mouse scrolling, row selection/play, and
+index-to-EMMS-marker adapters. Characterize existing reorder/removal behavior
+before routing those keys through the graphical view.
+
+** Phase 4 — Transport, modes, radio search, and playlist actions
+
+Wire the approved faceplate controls and pressed/latched rendering. Preserve
+the remaining keyboard-only commands and add a concise help surface. Cover
+empty-queue behavior, destructive Delete confirmation, command errors, and
+state refresh hooks. The phase ends with a complete functional control surface
+using the existing backend.
+
+** Phase 5 — Seek and persistent mpv volume
+
+Add player-owned volume state and apply it on process start. Route +/−, wheel,
+click, and drag through the same volume setters. Add absolute seek and drag
+confirmation using mpv time/duration. Render local progress and the exact brass
+thumb. Unit-test value/coordinate mapping and use a fake IPC ledger for command
+order.
+
+** Phase 6 — Stereo telemetry and timed refresh
+
+Add the labeled astats filter, persistent nonblocking telemetry connection,
+JSON request/reply routing, meter parsing, dB mapping, and needle ballistics.
+Run progress and meter timers only while visible and active. Measure the render
+budget for one minute and use the documented three-region fallback if needed.
+Test with the synthetic no-audio-output source used during spec research.
+
+** Phase 7 — Radio state, failures, and diagnostics
+
+Render the matched live-radio state with exactly two ON AIR labels and no seek
+surface. Handle missing art/metadata, unavailable SVG/assets/font, lost mpv IPC,
+missing meter metadata, hidden-window timer shutdown, and an empty queue. Add
+=cj/music-ui-doctor= and verify that fallback never mutates playback or playlist
+state.
+
+** Phase 8 — Integration, documentation, and live verification
+
+Run the focused music tests, batch load, byte compilation, TTY fallback, and GUI
+daemon verification. Exercise a long local playlist, a short radio playlist,
+seek, volume across track changes, every control, scrolling, reorder, deletion,
+and one-hour timer stability. Update music documentation and key hints. Make
+the SVG view the graphical default only after Craig accepts the live result.
+
+* Acceptance criteria
+
+- [ ] F10 opens the Dupre Studios receiver in the bottom side window on a
+ graphical frame and toggles it closed without changing playback.
+- [ ] The running receiver matches concept 30 in proportion, material character,
+ spacing, typography, and control placement at the normal dock size.
+- [ ] Leather, glass, brass, fixed engraving, and neutral hardware come from the
+ raster skin; live information and illumination are not baked into it.
+- [ ] The UI remains fully usable from the keyboard.
+- [ ] Every visible button maps to the command named in this spec.
+- [ ] Pressed, playing, and latched mode states are physically and chromatically
+ distinct.
+- [ ] Local metadata uses separate lines and omits missing values. No visible
+ program field says Unknown or N/A.
+- [ ] Long metadata and playlist rows truncate with an ellipsis before colliding
+ with adjacent fields.
+- [ ] The analog header card shows only the collection/search name and item
+ count; it never adds a PLAYLIST or RADIO type label.
+- [ ] Champagne surfaces use a fine low-contrast metallic micro-brush and never
+ read as wood grain.
+- [ ] The playlist shows fourteen one-line rows with artist inline, selected row
+ amber, and a functional scrollbar whose size and position follow the formulas.
+- [ ] Mouse wheel, keyboard navigation, scrollbar drag, row click/play, reorder,
+ and removal operate on the intended EMMS tracks.
+- [ ] ADD, NEW, LOAD, SAVE, and DELETE preserve current playlist/M3U semantics.
+- [ ] DELETE uses the existing strong confirmation and reports which playlist was
+ deleted.
+- [ ] A local track displays actual elapsed/duration and supports click/drag seek.
+- [ ] The seek thumb is brass and its position comes from mpv state.
+- [ ] A live station has no slider or times and shows exactly two ON AIR labels.
+- [ ] The volume knob controls mpv, not the system mixer, and persists across
+ track changes.
+- [ ] Volume segments fill cumulatively to the stored mpv volume and have no
+ numeric display.
+- [ ] VU needles use distinct real left/right RMS values, rest on missing data,
+ and never use random animation.
+- [ ] Paused, stopped, hidden, and TTY states run no meter/progress timer.
+- [ ] Full or tiled rendering meets the median/95th-percentile budget during a
+ one-minute meter run.
+- [ ] Empty playlist, missing art, missing metadata, missing SVG/assets, and lost
+ mpv IPC each have the fallback or actionable error defined in this spec.
+- [ ] =cj/music-ui-doctor= reports the renderer and telemetry state without
+ exposing file paths or stream URLs by default.
+- [ ] Setting the SVG enable flag off restores the implemented fancy/text UI.
+- [ ] Existing music-config tests pass, and new pure tests cover geometry,
+ snapshots, hit targets, clipping, scrolling, volume, seek, meter mapping, and
+ timer lifecycle.
+- [ ] Craig accepts both a long local-playlist state and a live-radio state in the
+ real Emacs side window before the SVG view becomes the default.
+
+* Readiness dimensions
+
+- Data model & ownership: EMMS and M3U remain authoritative. The SVG snapshot,
+ pressed target, viewport offset, meter smoothing, and render timing are
+ generated runtime state. Player volume is music-config runtime state applied
+ to each mpv process. Raster source assets are project-authored and versioned.
+- Errors, empty states & failure: defined in the Errors and empty states section.
+ Playback continues if rendering or meter telemetry fails. Destructive
+ playlist deletion retains the existing strong confirmation.
+- Security & privacy: no credentials are added. Diagnostics omit local paths and
+ stream URLs by default. Album art and radio favicons stay in the existing
+ local cache.
+- Observability: =cj/music-ui-doctor= exposes assets, font, viewport, render
+ timing, socket/filter/timer state, and last telemetry error. The UI itself
+ exposes player state through lamps, needles, progress, and selection.
+- Performance & scale: fourteen visible rows from playlists tested through at
+ least 1,000 tracks. SVG refresh has explicit median/95th-percentile budgets,
+ a one-minute test, and a pre-approved tiled fallback. Hidden views do no timed
+ work.
+- Reuse & lost opportunities: reuses the current music commands, EMMS state,
+ M3U/radio/art code, bottom side window, Dupre tokens, =svg.el=, and the gallery
+ widget proof. It deliberately does not wait for the EMMS-free/VAMP rewrite.
+- Architecture fit & weak points: new renderer module is pure at its boundary;
+ music-config owns EMMS adaptation and mpv effects. Weak points are raster/SVG
+ alignment, point-based EMMS adapters, full-image rerasterization, and telemetry
+ lifecycle. Geometry sharing, adapter tests, tiled fallback, and visibility-
+ scoped IPC mitigate them.
+- Config surface: add =cj/music-svg-ui= (boolean, default off until live acceptance,
+ then on) and =cj/music-player-volume= (integer 0–100, default 100). Keep
+ =cj/music-fancy-ui= as the fallback selector. Refresh rates and geometry are
+ internal until real use proves a customization need.
+- Documentation plan: update music-config commentary/docstrings, F10/keybinding
+ notes, the music VERIFY task, and the design README. The final spec and dated
+ runtime assets remain linked from the parent task.
+- Dev tooling: use the existing ERT/byte-compile commands, focused music test
+ files, Emacs daemon live verification, and a deterministic synthetic mpv meter
+ script/fixture with =ao=null=. No network or audible output in the normal
+ suite.
+- Rollout, compatibility & rollback: SVG is additive and initially disabled.
+ Fallback is automatic. Rollback is setting =cj/music-svg-ui= nil or reverting
+ the new renderer/controller changes; M3U files and cached art do not migrate.
+- External APIs & deps: built-in Emacs 30.2 =svg.el= and librsvg are the render
+ path. mpv 0.41 and FFmpeg 8.1 were checked locally. Official mpv and FFmpeg
+ documentation confirms labeled audio-filter metadata and astats keys.
+
+* Risks, Rabbit Holes, and Drawbacks
+
+- Cleaning a neutral skin from a flattened final board can produce visible
+ texture patches. Keep the layered source, use broad material samples rather
+ than small clone spots, and approve the neutral shell before wiring state.
+- Full-image librsvg refresh may be too expensive at meter cadence. Measure in
+ the prototype. Use the specified three-region split rather than lowering the
+ meter to a decorative crawl.
+- EMMS commands expect point in the playlist buffer. Keep every index-to-marker
+ translation in one controller adapter and test reorder/remove against the same
+ logical track.
+- SVG text metrics can differ from Emacs face metrics. Use the installed Dupre
+ fixed-width font, fixed boxes, clipping paths, and visual stress fixtures with
+ long titles.
+- An mpv filter may fail for an unusual audio format. Playback wins: leave the
+ meter at rest, show the failure in the doctor, and never stop the track.
+- The raster skin limits infinite scaling and alternate themes. V1 targets the
+ current dock and approved aesthetic. Multiple skins and responsive reflow are
+ separate work.
+
+* Testing / Verification / Rollout
+
+Pure ERT tests cover layout transforms, geometry/hit agreement, text fitting,
+playlist window/scrollbar math, state-to-color mapping, volume/seek conversion,
+VU dB/angle mapping, ballistics, and timer state. Controller tests use an EMMS
+fixture or stubs and an mpv IPC ledger so they do not play sound.
+
+Integration tests build representative local and radio snapshots, render SVG
+strings, and assert required IDs, text, clip paths, and state classes. They do
+not use pixel-perfect screenshots as the main contract because librsvg/font
+versions can change antialiasing. A small set of manual reference screenshots
+supports the visual acceptance step.
+
+The live pass runs in the real bottom side window with the normal long playlist
+and a radio playlist. It covers every faceplate control, keyboard parity,
+scrolling, long strings, seek, persistent volume, two-channel meters, exactly two
+ON AIR states, hidden-window timer shutdown, and fallback. The SVG flag changes
+to default-on only after that pass.
+
+* References / Appendix
+
+- [[file:../../docs/specs/2026-07-06-fancy-music-player-ui-spec.org][Implemented fancy music-player UI spec]]
+- [[file:../../docs/specs/music-config-without-emms-spec.org][Music-config without EMMS architecture spec]]
+- [[file:README.org][Music SVG design record]]
+- [[file:concepts/30a-dupre-studios-user-refined-playlist.png][Near-final playlist board]]
+- [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][Functional two-state prototype]]
+- [[file:prototypes/2026-07-19-music-config-ui-remodel-benchmark.org][Prototype librsvg benchmark]]
+- [[https://www.gnu.org/software/emacs/manual/html_node/elisp/SVG-Images.html][Emacs Lisp SVG images]]
+- [[https://mpv.io/manual/master/#json-ipc][mpv JSON IPC]]
+- [[https://mpv.io/manual/master/#command-interface-af-metadata][mpv audio-filter metadata]]
+- [[https://ffmpeg.org/ffmpeg-filters.html#astats-1][FFmpeg astats]]
+
+* Review and iteration history
+
+** 2026-07-19 Sun @ 19:11:57 -0500 — Codex (emacs-d) — hardware-depth correction
+- What: changed the upper boundary of the lower deck from a raised champagne
+ rim to a dark glass reveal descending into the brass faceplate. Unified all
+ four play-mode and both radio-search controls as blank, circularly machined
+ brass pushbutton caps. Removed the returned search glyphs and expanded then
+ tightened the source crop to preserve the volume knob's complete black
+ knurled skirt without importing the old faceplate halo.
+- Why: the prior neutral-faceplate overlay flattened the glass/metal depth,
+ allowed the small-control treatments to diverge, and clipped the knob at a
+ perfect circle that hid its physical knurling. These changes restore a
+ believable assembly hierarchy while retaining state-owned lamps.
+- Artifacts: updated [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][functional prototype 1]]; twelve-check interaction regression green.
+
+** 2026-07-19 Sun @ 18:43:00 -0500 — Codex (emacs-d) — prototype refinement
+- What: replaced the baked lower-deck faceplate with a neutral SVG-owned
+ champagne micro-brush surface, then restored only the approved volume knob
+ through a circular crop. Re-engraved the fixed control labels on the new
+ surface and reduced the volume arc to small dark wells with state-owned amber
+ cores and local glow. Verified both local and radio layouts and reran the
+ twelve-check headless interaction pass successfully.
+- Why: masking individual source-image lamps left visible baked illumination and
+ made the faceplate look patched. Owning the complete neutral deck surface in
+ SVG removes the conflicting lamp state and the woodlike raster grain while
+ retaining the realistic machined knob from the approved visual authority.
+- Artifacts: updated [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][functional prototype 1]]; local and radio state screenshots.
+
+** 2026-07-19 Sun @ 18:39:41 -0500 — Codex (emacs-d) — prototype refinement
+- What: applied Craig's second hands-on pass. Removed source-type words and the
+ separator from the analog card; changed radio fixture naming to =NAME SEARCH=;
+ replaced the circular seek ornament with a vertical machined-brass grip;
+ removed all mode-cap glyphs and matched their smooth spun-brass finish to the
+ volume cap; and rebuilt the volume arc as dark recessed wells whose amber core
+ and glow exist only for lit positions.
+- Why: the card should identify the current collection/search rather than report
+ an internal playlist/radio type. The prior seek coin had no hardware model,
+ the mode symbols duplicated their faceplate labels, and baked light spill made
+ some nominally dark volume positions glow. The spec now also rejects coarse,
+ wavy champagne texture that reads as wood grain.
+- Artifacts: updated [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][functional prototype 1]]; Craig's 18:29–18:32 screenshots.
+
+** 2026-07-19 Sun @ 18:19:28 -0500 — Codex (emacs-d) — prototype refinement
+- What: incorporated Craig's first hands-on notes. Rebuilt play modes as
+ alternate-action brass pushbuttons with engraved glyphs, visible travel, and
+ a restrained inset green ring; tightened Play/Pause illumination; aligned
+ each volume lamp's dark and lit state to one physical well; restored four
+ concealed warm-light pools on a darker coffee/cream playlist card; and made
+ the prototype's synthetic-VU / production-signal distinction explicit.
+- Why: the prior controls read as glowing domes, the green looked like a neon
+ outline, the flattened source and live volume arcs produced double lamps, and
+ the playlist card had lost its analog illumination model. Production VUs
+ remain tied to real mpv astats RMS data as already specified.
+- Artifacts: updated [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][functional prototype 1]]; Craig's 17:59–18:03 screenshots.
+
+** 2026-07-19 Sun @ 17:56:27 -0500 — Codex (emacs-d) — prototype author
+- What: built functional prototype 1 from concept 30 with local and radio
+ fixtures, all approved faceplate controls, long-string stress data, exactly
+ fourteen visible rows, scrollbar and seek/volume drag, pressed/latched states,
+ empty/delete flows, keyboard navigation, and synthetic stereo needle motion.
+ Its built-in twelve-check behavior pass is green. Measured librsvg for one
+ minute per path: full receiver 133.616 ms median / 139.822 ms p95 (fail),
+ display-sized lower deck 35.513 ms median / 38.221 ms p95 (pass).
+- Why: this supplies the interaction and performance evidence requested by the
+ first review finding. The measured result makes the three-region renderer a
+ requirement rather than a contingency. Craig's hands-on prototype acceptance
+ remains pending, so the finding stays open and the spec stays DRAFT.
+- Artifacts: [[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][functional prototype 1]]; [[file:prototypes/2026-07-19-music-config-ui-remodel-benchmark.org][benchmark record]];
+ [[file:2026-07-19-music-config-ui-remodel-render-fixture.svg][full-render fixture template]].
+
+** 2026-07-19 Sun @ 16:16:57 -0500 — Codex (emacs-d) — reviewer
+- What: completed the first implementation-readiness review after reading the
+ current music-config render, EMMS playlist, radio, mpv IPC, and volume paths.
+ Assigned =Not ready= and recorded one blocking finding: the approved final
+ board is static, so its two application states, hit/drag/scroll behavior, and
+ rendering budget have not passed the project's functional-prototype gate.
+- Why: the architecture, ownership, failure handling, telemetry source, rollout,
+ acceptance criteria, and eight phase boundaries are otherwise specific enough
+ to implement. A working prototype is the smallest remaining test of the
+ design rather than an invitation to redesign it during Phase 1.
+- Artifacts: =* Review findings [0/1]= in this spec; source checks in
+ =modules/music-config.el=; Dupre =gallery-widget.el= SVG proof; verified mpv
+ =af-metadata/meter= / FFmpeg =astats= telemetry path.
+
+** 2026-07-19 Sun @ 16:03:13 -0500 — Codex (emacs-d) — author
+- What: drafted the Music Config UI Remodel spec from Craig's selected concept,
+ manual GIMP refinement, and hybrid raster/SVG decision. Verified the real VU
+ telemetry path against the installed mpv/FFmpeg stack and primary docs.
+- Why: the visual design is settled closely enough to define the renderer,
+ controller boundary, interaction surface, phases, and test contract before
+ implementation.
+- Artifacts: [[file:concepts/30a-dupre-studios-user-refined-playlist.png][concept 30]]; local mpv astats IPC check; parent music SVG task.
diff --git a/working/music-svg-app-design/README.org b/working/music-svg-app-design/README.org
new file mode 100644
index 00000000..5ef6d67d
--- /dev/null
+++ b/working/music-svg-app-design/README.org
@@ -0,0 +1,426 @@
+#+TITLE: Music SVG Application Design Working Set
+#+AUTHOR: Craig Jennings
+
+* Purpose
+
+Six functional HTML/SVG prototypes for the music-config application design session.
+They explore the Dupre chronometer, hi-fi stereo, and automobile-dashboard visual
+language while preserving the current player, queue, radio, and playlist workflows.
+
+This directory owns the complete in-progress remodel package while the design
+continues. The implementation spec is
+[[file:2026-07-19-music-config-ui-remodel-spec.org][2026-07-19-music-config-ui-remodel-spec.org]].
+The current interactive and rendering evidence lives under [[file:prototypes/][prototypes/]].
+Concept boards, source artwork, and visual references stay beside them so local
+iteration does not depend on files scattered through =docs/=. When the design
+is accepted, the spec, prototypes, benchmark, render fixture, and selected final
+assets graduate together to their permanent =docs/= and =assets/= homes.
+
+The selected receiver now has a final interaction candidate at
+[[file:prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html][prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html]]. It uses
+concept 30 as the physical shell and overlays the changing local-playlist and
+radio state. The paired [[file:prototypes/2026-07-19-music-config-ui-remodel-benchmark.org][librsvg benchmark]] makes the three-region production
+renderer mandatory: full-instrument meter refresh missed the budget, while the
+lower control tile passed.
+
+* Inputs
+
+- [[file:../../docs/specs/2026-07-06-fancy-music-player-ui-spec.org][Existing music player UI spec]]
+- [[file:../../docs/specs/music-config-without-emms-spec.org][EMMS-free architecture spec]]
+- [[file:../../../code/archsetup/docs/prototypes/panel-widget-gallery.html][Dupre panel-widget gallery]]
+- [[file:../../../code/archsetup/docs/prototypes/waybar-redesign-prototype.html][Waybar redesign prototype]]
+
+* Prototype Directions
+
+1. Gold-pinstripe tuner console
+2. Touring-car instrument cluster
+3. Chronometer record deck
+4. Studio rack and tape machine
+5. Perpetual-calendar salon receiver
+6. Mission-control radio navigator
+
+* High-Fidelity Concept Boards — Iteration Two
+
+The second pass deliberately separates visual design from implementation. These
+boards establish materials, lighting, silhouette, hierarchy, and instrument
+semantics before the selected direction is translated into Dupre components.
+
+1. [[file:concepts/01-champagne-receiver.png][Champagne Receiver]]
+2. [[file:concepts/02-le-mans-night-cluster.png][Le Mans Night Cluster]]
+3. [[file:concepts/03-geneva-playback-chronograph.png][Geneva Playback Chronograph]]
+4. [[file:concepts/04-mastering-room-reel-console.png][Mastering Room Reel Console]]
+5. [[file:concepts/05-perpetual-calendar-salon.png][Perpetual Calendar Salon]]
+6. [[file:concepts/06-transatlantic-broadcast-navigator.png][Transatlantic Broadcast Navigator]]
+7. [[file:concepts/07-functional-black-glass-receiver.png][Functional Black Glass Receiver]]
+8. [[file:concepts/08-functional-black-glass-flipped.png][Functional Black Glass Receiver — flipped]]
+9. [[file:concepts/09-functional-black-glass-retro.png][Functional Black Glass Receiver — retro]]
+10. [[file:concepts/10-warm-black-glass-player-volume.png][Warm Black Glass Receiver — player volume]]
+11. [[file:concepts/11-illuminated-black-glass-controls.png][Illuminated Black Glass Receiver — varied controls]]
+12. [[file:concepts/12-consolidated-functional-receiver.png][Consolidated Functional Receiver]]
+13. [[file:concepts/13-black-silver-chronograph-receiver.png][Black and Silver Chronograph Receiver]]
+14. [[file:concepts/14-corrected-luxury-chronograph-receiver.png][Corrected Luxury Chronograph Receiver]]
+15. [[file:concepts/15-champagne-brass-digital-hifi.png][Champagne-Brass Digital Hi-Fi]]
+16. [[file:concepts/16-champagne-aluminum-radio-groups.png][Champagne-Aluminum Radio Groups]]
+17. [[file:concepts/17-dupre-coltrane-scrollbar.png][Dupre Coltrane Scrollbar State]]
+18. [[file:concepts/18-dupre-branding-control-study.png][Dupre Branding and Compact-Control Study]]
+19. [[file:concepts/19a-dupre-playlist-state.png][Dupre Playlist State]] / [[file:concepts/19b-dupre-radio-state.png][Dupre Radio State]]
+20. [[file:concepts/20-dupre-playlist-radio-comparison.png][Dupre Long-Playlist / Compact-Radio Comparison]] ([[file:concepts/20a-dupre-long-playlist.png][playlist source]] / [[file:concepts/20b-dupre-compact-radio.png][radio source]])
+21. [[file:concepts/21-dupre-semantic-controls-comparison.png][Dupre Semantic-Controls Comparison]] ([[file:concepts/21a-dupre-mode-lamps-playlist.png][playlist source]] / [[file:concepts/21b-dupre-two-on-air-radio.png][radio source]])
+
+- [[file:concept-board.html][Open the six-board review sheet]]
+
+The original functional HTML pass remains at
+[[file:music-svg-directions.html][music-svg-directions.html]] for interaction and
+workflow reference; it is not the material-design target.
+
+* Current Design Decisions
+
+- Beauty must communicate real application function; no decorative controls.
+- Preserve the Champagne Receiver's now-playing, progress, transport, queue,
+ and playlist-management regions as the functional nucleus.
+- Remove AM/FM, tone, speaker, loudness, power, and other receiver controls
+ without a =music-config.el= command or state.
+- Target a compact panoramic panel in the bottom portion of a predominantly
+ portrait Emacs frame; playlist and player controls must sit side by side.
+- Prefer a neutral black-glass, warm-meter, polished-chrome language with
+ restrained warm metal and tactile black leather; keep whitespace low without
+ returning to clutter. Do not use cold blue illumination.
+- Permanent radio controls map only to the implemented name and tag searches.
+ Manual URL entry remains an Emacs command rather than faceplate hardware.
+- Place the player on the left and playlist on the right.
+- The dots around the volume knob are a cumulative illuminated scale driven by
+ actual volume; the last lit dot is the precise pointer. They never chase or
+ flash.
+- The knob controls dedicated mpv player volume rather than the system mixer.
+ Persist the level in Emacs, pass it to each newly started per-track mpv
+ process, and read/write mpv's =volume= property over the existing IPC socket.
+- Drive the stereo VU meter from real mpv audio analysis. A labeled lavfi
+ =astats= filter exposes per-channel RMS and peak readings through
+ =af-metadata/<label>=; apply analog needle ballistics in the UI. Treat the
+ meter as pre-volume program level so it describes the recording independently
+ of listening volume.
+- Prefer neutral black glass, chrome, and pebbled leather with warm ivory,
+ amber, and restrained jewel illumination; avoid cold blue light.
+- All playlist and now-playing typography is self-luminous behind smoked glass,
+ never merely engraved. Compose album metadata conditionally from album,
+ label, year/date, genre, composer, performer, track, and disc; omit missing
+ values and separators, and never display =Unknown= or =N/A=.
+- Do not display a numeric player-volume value. The physical knob pointer,
+ ticks, and cumulative lamps communicate level.
+- Omit manual URL entry and playlist reload from the permanent faceplate; the
+ commands remain available through Emacs.
+- For seekable tracks, the progress indicator is an interactive slider. For a
+ non-seekable live stream, replace the entire slider/time treatment with an
+ =ON AIR= state; never show both states together.
+- With the slider handling seeking, transport contains only previous,
+ play/pause, stop, and next. Labels live on the faceplate beneath compact
+ physical controls.
+- Put repeat, single, random, and consume lamps/toggles on the lower player
+ control rail. Put radio name/tag search there as well. The right side is
+ reserved for the large playlist and visible playlist actions.
+- Present now-playing metadata as separate illuminated lines and omit the
+ =NOW PLAYING= caption.
+- Use one consistent family of small round chrome transport pushbuttons. Mode
+ controls use four identical low-profile luxury latching pushbuttons with
+ polished-steel collars, smoked-black enamel centers, and integrated faceted
+ jewels: pressed and lit means on; raised and dark means off. Bat toggles are
+ prohibited because they read as utilitarian rather than luxury hardware.
+- Build depth with a realistic two-step polished-chrome bezel and dark gasket
+ around both the VU meter and complete player where they meet black leather.
+ Avoid excessive parallel chrome rails.
+- Use a coherent brushed-silver lower control faceplate. Chrome and silver
+ dominate; brass remains a hairline accent.
+- The playlist shows roughly fourteen compact one-line rows with artist inline,
+ an explicit illuminated playlist name, and square matte-anodized action keys.
+ Visible actions are add, new, load, save, and delete. Delete is a normal
+ matte-red key; edit is not permanent faceplate functionality.
+- Put the =ON AIR= lamp inside the radio control bank. It remains dark for a
+ local file and lights only for a live radio source.
+- Do not use the drum/tape roller as a saved-playlist selector. Its unlabeled
+ adjacent digits were mistaken for a counter and its purpose was not legible;
+ the labeled =LOAD= action is sufficient. If a drum mechanism is revisited,
+ it must expose a unique function that is understood without explanation.
+- Volume segments are identical and evenly spaced; light continuously from the
+ rightmost minimum through and including the pointer-aligned segment.
+- Allocate approximately 42 percent of usable width to the player and 58
+ percent to the playlist. When all fourteen rows fit, show neither a scrollbar
+ nor another redundant track-count mechanism.
+- Remove the current-track =01 / 14= complication entirely: the highlighted
+ playlist row communicates the same information more directly.
+- Put the mode/status/radio rail above the transport rail. Align =ON AIR= with
+ the other mode jewels and give it the same visible diameter, while retaining
+ its non-pressable status-only behavior.
+- Transport hardware is a coherent bank of four shallow, closely spaced,
+ machined-metal hi-fi piano keys rather than separate round buttons.
+- Use distinctly digital, self-luminous typography for player metadata and the
+ playlist; keep faceplate labels engraved rather than digital.
+- A pale brushed champagne-brass control faceplate is viable so long as black
+ glass and polished chrome remain dominant and the brass never becomes bronze,
+ copper, brown, or sepia.
+- Prefer authentic pale champagne-anodized aluminum over brass: fine horizontal
+ brushing, cool metallic highlights, and crisp machined edges retain warmth
+ while matching real high-end audio construction.
+- Fasteners are small, discreet, countersunk details rather than prominent
+ corner bolts.
+- Make radio membership explicit on the faceplate. A restrained engraved
+ =RADIO= heading and hairline bracket span exactly =ON AIR=, =BY NAME=, and
+ =BY TAG=; a separate =PLAY MODES= heading owns repeat, single, random, and
+ consume. Do not use another heavy enclosure.
+- Mode controls combine an unmistakably pressable shallow aluminum cap with a
+ smaller inset jewel. =ON AIR= uses the same jewel diameter and baseline but
+ no cap or travel, preserving its status-only meaning.
+- For a seekable track, slider position must be computed from elapsed/duration
+ rather than independently illustrated. Concept 16 still places the handle
+ too far right for =02:37 / 05:35=; implementation must place it at 47 percent.
+- Color carries stable semantics: warm ivory is ordinary information; amber is
+ active playback, progress, and volume; green is an enabled playback mode;
+ neutral silver is keyboard focus; red is reserved for delete, live =ON AIR=,
+ and faults. Do not color ordinary playlist actions arbitrarily.
+- Use conventional volume geometry: minimum is lower left, maximum lower right,
+ and illumination advances clockwise from minimum through the knob pointer.
+ Segments after the pointer toward maximum remain dark.
+- Playing state must be physical as well as chromatic: the =PLAY/PAUSE= key sits
+ visibly lower with its shadow withdrawn; a restrained amber edge is secondary
+ confirmation.
+- The faceplate may carry one modest maker's plaque: coarse black crosshatch
+ that survives small SVG rendering, with a raised cursive =Dupre= wordmark.
+ It is a badge, not a fake speaker grille, and must not justify extra height.
+- For lists longer than the fourteen-row viewport, show a narrow functional
+ scrollbar. Thumb length represents =visible rows / total rows= and position
+ represents the top visible row. Concept 17 demonstrates the long-list state
+ but its generated thumb remains shorter than the required 14/32 (44 percent).
+- The next review set is a matched two-state pair: one seekable normal-playlist
+ player and one live-radio player using identical hardware. The radio state
+ replaces slider and times with =ON AIR=, lights the red broadcast lamp, and
+ conditionally shows only available station/stream metadata.
+- Do not assume short metadata. Recover display width by reducing album art and
+ VU width roughly 20 percent; allow modest automatic font condensation before
+ ellipsis. Do not marquee by default. Full truncated values remain available
+ through normal Emacs help/minibuffer affordances.
+- Compact playback modes into a 2×2 internally illuminated-legend block. The
+ engraved symbol itself glows; do not add a separate lamp or oversized lip.
+ Keep explicit micro-labels because =CONSUME= has no universal symbol.
+- Place a similarly compact radio block immediately beside play modes: =NAME=
+ and =TAG= momentary buttons above a flush =ON AIR= window. This leaves the
+ right side visually quiet around volume.
+- Branding is part of the faceplate surface, never a plate atop another plate.
+ Concept 18 compares: black-enamel inscription (A), bright-cut inscription
+ (B), same-plane crosshatched negative-space field (C), and direct-mounted
+ chrome script without backing (D).
+- Select concept 18 option D: a moderately enlarged direct-mounted chrome
+ cursive =Dupre= script at the faceplate's upper left, with no backing. Put
+ compact proportional =NAME=, =TAG=, and =ON AIR= radio controls below it.
+- Transport adopts the same internally illuminated-legend construction as the
+ mode keys. The symbol itself is translucent: amber while active, dim ivory
+ while inactive. Do not add a separate under-key light strip. Concepts 19a/b
+ still contain a generated amber strip beneath Play and are non-authoritative
+ on that detail.
+- Treat local and live playback as matched states of identical hardware. A
+ seekable file shows slider and times and keeps =ON AIR= dark; a live stream
+ removes all seek/time hardware, displays =ON AIR / LIVE STREAM= in that region,
+ and lights the faceplate broadcast window red.
+- Scrollbar thumbs remain mathematical UI, not illustrative ornament: 14/32 is
+ 44 percent and 14/18 is 78 percent. Both generated concept-19 thumbs are too
+ short despite their otherwise useful long-list demonstrations.
+- Reserve a clear brand field at the far left of the faceplate and optically
+ center =Dupre= within that invisible rectangle. All functional controls stay
+ outside it.
+- Immediately right of the brand field, stack =NAME=, =TAG=, and =ON AIR= in a
+ narrow vertical Radio column, right-justified tightly against an even smaller
+ 2×2 Play Modes block. Volume retains the quiet right-hand field.
+- Transport state is symbol-only illumination. The area beneath every key is
+ plain faceplate plus its engraved label; no amber strip, lamp, or glow.
+- Stress-test metadata with realistic long values. Concept 20 uses =A CHICKEN
+ WITH ITS HEAD CUT OFF= / =THE MAGNETIC FIELDS= and the 69-track =69 LOVE SONGS=
+ list without marquee or truncation.
+- Review normal and live states together by composing their independently
+ generated panoramic sources. This preserves the true bottom-dock form factor
+ while making both states simultaneously visible.
+- Concept 20's scrollbar rendering remains non-authoritative: the 14/69 playlist
+ thumb should be 20 percent, and the 14/18 radio thumb should be 78 percent and
+ clearly visible. The image model made the former too short and omitted the
+ latter's filled thumb.
+- Live playback contains exactly two =ON AIR= presentations: a red bottom-most
+ metadata line and the current station row. Remove the faceplate status window
+ and any centered banner or =LIVE STREAM= caption.
+- The compact controls form a measured 3×2 grid above transport. Every small
+ control is half a transport key's width and equal to its height; stacked Radio
+ occupies column one and the 2×2 Play Modes block columns two and three. The
+ grid's right edge aligns with =NEXT=.
+- Radio =NAME= and =TAG= are momentary search buttons with magnifying-glass
+ glyphs and no lamps. Persistent playback modes use a separate tiny status
+ lamp above a compact champagne/brass actuator, borrowing the Heston preset
+ language without numeric legends.
+- Branding is a true faceplate aperture: champagne aluminum is cut away to
+ reveal a recessed dark woven/diamond-textured subplate with centered warm-white
+ cursive =Dupre=. It is not a badge attached atop the faceplate.
+- =PLAY/PAUSE= glows green through its symbol while playing. Previous, Stop, and
+ Next stay neutral and merely brighten ivory during activation.
+- Volume uses a deeply knurled black body and machined champagne/brass cap with no
+ pointer. The cumulative lamp arc is the sole indicator and its final lit
+ segment communicates the level.
+- =ADD= and =NEW= may share a restrained OD-green anodized tint. =LOAD= and
+ =SAVE= remain neutral; =DELETE= remains matte red.
+- Concept 21 again renders scrollbar fill incorrectly; the exact 20/78 percent
+ requirements remain implementation constraints rather than image guidance.
+- Tone =DELETE= down from saturated red to muted, low-saturation oxblood or
+ burgundy. It remains unmistakably destructive without becoming the loudest
+ object in the application.
+- Playback modes use four small circular brass pushbuttons mounted directly on
+ the metal. Remove the black switch tiles and separate lamps; illuminate only
+ a thin circumference around an active brass actuator. A single hairline
+ engraved boundary contains the 2×2 group and its geometry aligns with the
+ transport grid ending at =NEXT=.
+- Split the lower player into two genuine material zones rather than stacking
+ branding plates. The left 28--32 percent is uninterrupted black woven grille
+ continuous with the chassis; center a restrained warm-ivory/champagne cursive
+ =Dupre= directly in that negative space with no border, plaque, or fasteners.
+ The right 68--72 percent is one compact self-contained champagne-anodized
+ control island containing Radio, Play Modes, transport, and Volume.
+- Concept 22 validates this identity-bay/control-island architecture in matched
+ playlist and radio states. Its compact balance is authoritative; the image
+ model's engraved Play Modes boundary does not yet reach the =NEXT= alignment
+ datum, so the exact grid alignment remains an implementation constraint.
+- Lock the concept-22 champagne faceplate and every component mounted on it:
+ Radio keys, direct-mounted brass Play Modes matrix and engraved boundary,
+ pointerless cumulative Volume dial, transport keys, fasteners, typography,
+ materials, spacing, and illumination semantics. Subsequent exploration may
+ change surrounding glass and layout, but not this control component.
+- Swap the information and identity zones. Put reduced album/station art and
+ separate-line metadata in the lower black-glass bay beside the locked
+ faceplate. Put =Dupre= directly on the upper black glass with no backing
+ texture, beside substantially larger VU meters.
+- Light each VU face with a subtle warm pool rising from a hidden lamp directly
+ below its =VU= legend. The fixture is not visible; it only explains the
+ meter's restrained internal illumination.
+- Give the player approximately 49 percent of total width and the list 51
+ percent. Keep fourteen rows; ellipsize only the title/artist field when it
+ exceeds its allocation, while preserving the row number, current-state marker,
+ duration, and live status.
+- In radio state, place the first approved =ON AIR= directly underneath the
+ reduced station art. The second remains in the active station row; all
+ inactive status cells are empty. Concept 23 is authoritative for this
+ swapped-information arrangement.
+- Divide the player itself into two equal-height tiers. The upper tier is one
+ continuous black-glass identity/information display; =Dupre= stays at far left
+ while reduced art, separate-line metadata, and conditional seek information
+ occupy the remaining width.
+- The lower tier is one uninterrupted full-width champagne-brass faceplate, not
+ a small plate stacked over another material. Recess modest dual VU meters
+ into its left side and integrate the locked Radio, Play Modes, Volume, and
+ transport hardware on its right with proportional spacing.
+- Return the VU meters to approximately concept-22 scale or slightly smaller.
+ They are black instrument windows with narrow chrome bezels physically set
+ into the brass; their concealed lower lamps remain a soft wash rather than a
+ visible point source.
+- Concept 24 demonstrates this full-brass lower tier in matched playlist and
+ radio states. State-dependent information changes only in the upper glass
+ and control illumination; the underlying manufactured unit is identical.
+- Remove fasteners and corner radii from every interior surface. The upper
+ information glass, lower brass faceplate, and right playlist are three flush
+ rectangular fields separated only by one horizontal and one vertical rule.
+ Rounded depth remains exclusively in the outer leather/chrome chassis.
+- Align the left edge of album or station art exactly with the left edge of the
+ stacked Radio buttons below. This is a shared vertical datum and reserves a
+ larger uninterrupted field for =Dupre=. Confine local seek time, line, and
+ handle to the information block beginning at that datum; nothing extends
+ beneath the wordmark. Live =ON AIR= occupies the corresponding position below
+ station art without a seek affordance.
+- Render ordinary album metadata and inactive list rows in soft neutral white.
+ Amber is reserved for the list header/rule and the currently selected row;
+ inactive rows have no lamps, glow, dots, or amber tint.
+- VU windows are flush or slightly recessed apertures in the brass, with only a
+ hairline inlaid edge and inner shadow. They must never read as raised boxes or
+ chrome housings attached to the faceplate.
+- Concept 25 demonstrates the seamless square-panel and white-type direction.
+ The image generator still places art left of the Radio datum despite a
+ targeted correction; exact shared-edge alignment remains authoritative for
+ the implemented SVG.
+- Reintroduce rounded depth selectively rather than through internal cards. The
+ two-step chrome trim encircling the complete unit has clearly rounded corners,
+ and the flush VU apertures may use modest rounded corners.
+- Rename the maker signature to a two-line =Dupre Studios=: large cursive
+ =Dupre= above smaller widely tracked uppercase =STUDIOS=, centered as one
+ optical lockup directly on the black glass.
+- Move album/station art, metadata, and the conditional seek/live treatment
+ farther right to enlarge the calm manufacturer field.
+- Remove the upper vertical player/list separator entirely. Branding, program
+ information, and playlist share one continuous black-glass plane; header rules
+ and column alignment alone establish list hierarchy.
+- Define the lower brass faceplate as a distinct receiver module without adding
+ fasteners: a narrow recessed dark groove followed by slim deep chrome encircles
+ all four sides of the brass. Its surround uses graceful rounded corners while
+ the single brass field remains uninterrupted.
+- Concept 26 demonstrates the =Dupre Studios= lockup, continuous upper glass,
+ rounded outer chrome, rounded flush VU apertures, and fully encircled brass
+ control module in matched playlist and radio states.
+- Remove the complete unit's outer metal trim. The exterior silhouette is a
+ softly rolled black-leather edge; the leather terminates directly against the
+ continuous black glass through a narrow compressed dark seam, with no metal
+ between those materials.
+- Convert every remaining metal edge from chrome to warm brass. This includes
+ the bead encircling the lower faceplate and the hairline rounded VU aperture
+ edges. No silver/chrome edging remains anywhere.
+- The artwork, stacked Radio keys, and =PREV= transport key share one exact left
+ edge. Treat this as a single vertical construction datum; the seek assembly
+ begins there as well and remains below only the art/metadata block.
+- Make only the playlist/radio header row an analog instrument card: darker
+ coffee-and-cream stock behind glass with dark espresso lettering. Four hidden
+ warm lamps above it—one at each corner and two evenly spaced between—cast
+ broad subtle pools downward. Neither bulbs nor hotspots are visible, and all
+ list rows below remain black with white/selected-amber type.
+- Concept 27 demonstrates the leather-to-glass junction, brass-only edging, and
+ analog illuminated header in matched playlist/radio states. Its artwork is
+ visually close to the control datum; implementation must make the shared edge
+ mathematically exact.
+- Adopt Craig's GIMP faceplate composition as the control-layout authority.
+ Divide usable brass width into approximately 44/25/31 percent zones: dual VUs
+ over transport at left, Play Modes over a horizontal Name/Tag Radio pair at
+ center, and Volume vertically centered at right. Use shared baselines,
+ consistent label offsets, and even component gaps so the plate is full without
+ crowding.
+- The four transport keys span roughly the combined width of the two VUs and sit
+ directly beneath them. The center controls form two aligned modules: 2×2
+ direct-mounted brass modes above and two black radio search keys below.
+- Increase the Volume control to the diameter in Craig's GIMP arrangement, but
+ make it a low-profile frontal instrument: shallow knurled skirt, broad flush
+ machined brass cap, minimal shadow, and no domed, magnified, or fisheye look.
+ Center the complete knob/arc vertically and put =VOLUME= beneath it.
+- Volume lamps return to unmistakable warm amber/gold. Cumulative lit segments
+ must never wash out to white, cream, coral, or pink; unlit segments remain
+ dark.
+- Concept 28 demonstrates the proportional three-zone control layout and flatter
+ enlarged Volume dial in matched playlist and radio states.
+- Finalize the program-information construction line by moving the artwork and
+ all metadata right as one rigid group until the artwork's left edge is exactly
+ collinear with the left engraved boundary of the Radio group below. The local
+ seek treatment begins on that same datum: move its left time and left endpoint
+ with the information group, keep its right time and endpoint fixed, and shorten
+ the run accordingly.
+- The circular seek thumb is warm polished brass, not chrome or silver. Its
+ restrained highlight may distinguish it from the amber progress line without
+ introducing a cold-metal exception to the brass-only material language.
+- Treat Craig's manually refined 1916×821 playlist board, saved as
+ =concepts/30a-dupre-studios-user-refined-playlist.png=, as the near-final
+ visual authority. It resolves the upper-panel composition: the larger calm
+ =Dupre Studios= field, right-shifted artwork and separate metadata lines, a
+ contained shorter seek run with brass thumb, and a playlist-dominant right
+ side all coexist without crowding.
+- Preserve the board's proportional lower deck: paired illuminated VUs and one
+ coherent transport family at left, compact direct-mounted Play Modes and Radio
+ Search controls in the center, and the large vertically centered low-profile
+ Volume instrument at right.
+
+* Concept 14 Review Notes
+
+The four matching jeweled latching mode buttons, simplified two-step chrome
+bezel, fourteen-row playlist, explicit playlist name, one-line track rows, and
+clearly labeled action keys move the design toward the selected language. The
+board still contains three image-generation errors that are not design changes:
+
+- The player remains wider than the playlist instead of the required 42/58
+ allocation.
+- The volume arc stays illuminated beyond the knob pointer instead of ending at
+ and including the pointer-aligned segment.
+- =COLUMBIA= and =1959= share a line instead of occupying separate conditional
+ metadata lines.
diff --git a/working/music-svg-app-design/assets/vinyl-placeholder.svg b/working/music-svg-app-design/assets/vinyl-placeholder.svg
new file mode 100644
index 00000000..cf01519f
--- /dev/null
+++ b/working/music-svg-app-design/assets/vinyl-placeholder.svg
@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300" role="img" aria-label="Vinyl record placeholder">
+ <defs>
+ <radialGradient id="disc" cx="50%" cy="50%" r="50%">
+ <stop offset="0%" stop-color="#1a1a1a"/>
+ <stop offset="100%" stop-color="#000000"/>
+ </radialGradient>
+ </defs>
+ <circle cx="150" cy="150" r="148" fill="url(#disc)"/>
+ <g fill="none" stroke="#2b2b2b" stroke-width="1">
+ <circle cx="150" cy="150" r="138"/>
+ <circle cx="150" cy="150" r="126"/>
+ <circle cx="150" cy="150" r="114"/>
+ <circle cx="150" cy="150" r="102"/>
+ <circle cx="150" cy="150" r="90"/>
+ <circle cx="150" cy="150" r="78"/>
+ </g>
+ <circle cx="150" cy="150" r="52" fill="#d99a2b"/>
+ <circle cx="150" cy="150" r="52" fill="none" stroke="#a8741a" stroke-width="2"/>
+ <circle cx="150" cy="150" r="7" fill="#111111"/>
+</svg>
diff --git a/working/music-svg-app-design/concept-board.html b/working/music-svg-app-design/concept-board.html
new file mode 100644
index 00000000..378d13ea
--- /dev/null
+++ b/working/music-svg-app-design/concept-board.html
@@ -0,0 +1,25 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Music SVG application — high-fidelity concept boards</title>
+<style>
+:root{color-scheme:dark;--bg:#0c0b0a;--panel:#151310;--ink:#eee3c8;--dim:#97938a;--gold:#e7aa3f;--line:#40351f}
+*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at top,#282014 0,transparent 38%),var(--bg);color:var(--ink);font:14px/1.5 "BerkeleyMono Nerd Font","Berkeley Mono",monospace}
+header{max-width:1500px;margin:auto;padding:28px 24px 18px}h1{margin:4px 0 8px;color:var(--gold);font:500 clamp(24px,4vw,44px) Georgia,serif}.eyebrow{color:var(--dim);font-size:11px;letter-spacing:.22em;text-transform:uppercase}p{max-width:880px;color:var(--dim)}
+main{max-width:1500px;margin:auto;padding:0 24px 70px;display:grid;gap:28px}.board{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--panel);box-shadow:0 18px 50px #0009}.board img{display:block;width:100%;height:auto}.caption{display:flex;gap:18px;align-items:baseline;padding:13px 17px;border-top:1px solid var(--line)}.num{color:var(--gold)}h2{font-size:15px;letter-spacing:.08em;margin:0;font-weight:500}.hint{margin-left:auto;color:var(--dim);font-size:11px}@media(max-width:700px){header,main{padding-left:12px;padding-right:12px}.caption{align-items:flex-start;flex-wrap:wrap}.hint{width:100%;margin:0}}
+</style>
+</head>
+<body>
+<header><div class="eyebrow">Music SVG application · material-design pass</div><h1>Six instruments worth living with</h1><p>Judge the silhouette, material palette, visual hierarchy, and behavior metaphors. Typography is illustrative; implementation will use the actual Dupre kit and SVG-safe primitives.</p></header>
+<main>
+<article class="board"><img src="concepts/01-champagne-receiver.png" alt="Champagne Receiver music application"><div class="caption"><span class="num">01</span><h2>Champagne Receiver</h2><span class="hint">hi-fi receiver · blue glass · walnut · moving-coil VU</span></div></article>
+<article class="board"><img src="concepts/02-le-mans-night-cluster.png" alt="Le Mans Night Cluster music application"><div class="caption"><span class="num">02</span><h2>Le Mans Night Cluster</h2><span class="hint">grand tourer · leather · chrome gauges · tell-tales</span></div></article>
+<article class="board"><img src="concepts/03-geneva-playback-chronograph.png" alt="Geneva Playback Chronograph music application"><div class="caption"><span class="num">03</span><h2>Geneva Playback Chronograph</h2><span class="hint">guilloché · sapphire chapter ring · jeweled complications</span></div></article>
+<article class="board"><img src="concepts/04-mastering-room-reel-console.png" alt="Mastering Room Reel Console music application"><div class="caption"><span class="num">04</span><h2>Mastering Room Reel Console</h2><span class="hint">reel-to-reel · brushed aluminum · VU glass · walnut rack</span></div></article>
+<article class="board"><img src="concepts/05-perpetual-calendar-salon.png" alt="Perpetual Calendar Salon music application"><div class="caption"><span class="num">05</span><h2>Perpetual Calendar Salon</h2><span class="hint">rosewood · ivory enamel · apertures · moonphase record</span></div></article>
+<article class="board"><img src="concepts/06-transatlantic-broadcast-navigator.png" alt="Transatlantic Broadcast Navigator music application"><div class="caption"><span class="num">06</span><h2>Transatlantic Broadcast Navigator</h2><span class="hint">black glass · chrome · frequency scale · radio radar</span></div></article>
+</main>
+</body>
+</html>
diff --git a/working/music-svg-app-design/concepts/01-champagne-receiver.png b/working/music-svg-app-design/concepts/01-champagne-receiver.png
new file mode 100644
index 00000000..453ca9e3
--- /dev/null
+++ b/working/music-svg-app-design/concepts/01-champagne-receiver.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/02-le-mans-night-cluster.png b/working/music-svg-app-design/concepts/02-le-mans-night-cluster.png
new file mode 100644
index 00000000..f967f7f6
--- /dev/null
+++ b/working/music-svg-app-design/concepts/02-le-mans-night-cluster.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/03-geneva-playback-chronograph.png b/working/music-svg-app-design/concepts/03-geneva-playback-chronograph.png
new file mode 100644
index 00000000..3990e075
--- /dev/null
+++ b/working/music-svg-app-design/concepts/03-geneva-playback-chronograph.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/04-mastering-room-reel-console.png b/working/music-svg-app-design/concepts/04-mastering-room-reel-console.png
new file mode 100644
index 00000000..3d9ced49
--- /dev/null
+++ b/working/music-svg-app-design/concepts/04-mastering-room-reel-console.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/05-perpetual-calendar-salon.png b/working/music-svg-app-design/concepts/05-perpetual-calendar-salon.png
new file mode 100644
index 00000000..301bec21
--- /dev/null
+++ b/working/music-svg-app-design/concepts/05-perpetual-calendar-salon.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/06-transatlantic-broadcast-navigator.png b/working/music-svg-app-design/concepts/06-transatlantic-broadcast-navigator.png
new file mode 100644
index 00000000..148f573f
--- /dev/null
+++ b/working/music-svg-app-design/concepts/06-transatlantic-broadcast-navigator.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/07-functional-black-glass-receiver.png b/working/music-svg-app-design/concepts/07-functional-black-glass-receiver.png
new file mode 100644
index 00000000..8be7cc89
--- /dev/null
+++ b/working/music-svg-app-design/concepts/07-functional-black-glass-receiver.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/08-functional-black-glass-flipped.png b/working/music-svg-app-design/concepts/08-functional-black-glass-flipped.png
new file mode 100644
index 00000000..404c7493
--- /dev/null
+++ b/working/music-svg-app-design/concepts/08-functional-black-glass-flipped.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/09-functional-black-glass-retro.png b/working/music-svg-app-design/concepts/09-functional-black-glass-retro.png
new file mode 100644
index 00000000..12893867
--- /dev/null
+++ b/working/music-svg-app-design/concepts/09-functional-black-glass-retro.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/10-warm-black-glass-player-volume.png b/working/music-svg-app-design/concepts/10-warm-black-glass-player-volume.png
new file mode 100644
index 00000000..ae1ed9f7
--- /dev/null
+++ b/working/music-svg-app-design/concepts/10-warm-black-glass-player-volume.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/11-illuminated-black-glass-controls.png b/working/music-svg-app-design/concepts/11-illuminated-black-glass-controls.png
new file mode 100644
index 00000000..df8ce658
--- /dev/null
+++ b/working/music-svg-app-design/concepts/11-illuminated-black-glass-controls.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/12-consolidated-functional-receiver.png b/working/music-svg-app-design/concepts/12-consolidated-functional-receiver.png
new file mode 100644
index 00000000..dc2ccab4
--- /dev/null
+++ b/working/music-svg-app-design/concepts/12-consolidated-functional-receiver.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/13-black-silver-chronograph-receiver.png b/working/music-svg-app-design/concepts/13-black-silver-chronograph-receiver.png
new file mode 100644
index 00000000..2fe1af0e
--- /dev/null
+++ b/working/music-svg-app-design/concepts/13-black-silver-chronograph-receiver.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/14-corrected-luxury-chronograph-receiver.png b/working/music-svg-app-design/concepts/14-corrected-luxury-chronograph-receiver.png
new file mode 100644
index 00000000..c238a8f6
--- /dev/null
+++ b/working/music-svg-app-design/concepts/14-corrected-luxury-chronograph-receiver.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/15-champagne-brass-digital-hifi.png b/working/music-svg-app-design/concepts/15-champagne-brass-digital-hifi.png
new file mode 100644
index 00000000..7469c348
--- /dev/null
+++ b/working/music-svg-app-design/concepts/15-champagne-brass-digital-hifi.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/16-champagne-aluminum-radio-groups.png b/working/music-svg-app-design/concepts/16-champagne-aluminum-radio-groups.png
new file mode 100644
index 00000000..e15bb4c5
--- /dev/null
+++ b/working/music-svg-app-design/concepts/16-champagne-aluminum-radio-groups.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/17-dupre-coltrane-scrollbar.png b/working/music-svg-app-design/concepts/17-dupre-coltrane-scrollbar.png
new file mode 100644
index 00000000..9e9e948e
--- /dev/null
+++ b/working/music-svg-app-design/concepts/17-dupre-coltrane-scrollbar.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/18-dupre-branding-control-study.png b/working/music-svg-app-design/concepts/18-dupre-branding-control-study.png
new file mode 100644
index 00000000..6826f613
--- /dev/null
+++ b/working/music-svg-app-design/concepts/18-dupre-branding-control-study.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/19a-dupre-playlist-state.png b/working/music-svg-app-design/concepts/19a-dupre-playlist-state.png
new file mode 100644
index 00000000..cb08988d
--- /dev/null
+++ b/working/music-svg-app-design/concepts/19a-dupre-playlist-state.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/19b-dupre-radio-state.png b/working/music-svg-app-design/concepts/19b-dupre-radio-state.png
new file mode 100644
index 00000000..26f0ff9a
--- /dev/null
+++ b/working/music-svg-app-design/concepts/19b-dupre-radio-state.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/20-dupre-playlist-radio-comparison.png b/working/music-svg-app-design/concepts/20-dupre-playlist-radio-comparison.png
new file mode 100644
index 00000000..60648503
--- /dev/null
+++ b/working/music-svg-app-design/concepts/20-dupre-playlist-radio-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/20a-dupre-long-playlist.png b/working/music-svg-app-design/concepts/20a-dupre-long-playlist.png
new file mode 100644
index 00000000..b4fb84a2
--- /dev/null
+++ b/working/music-svg-app-design/concepts/20a-dupre-long-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/20b-dupre-compact-radio.png b/working/music-svg-app-design/concepts/20b-dupre-compact-radio.png
new file mode 100644
index 00000000..42992161
--- /dev/null
+++ b/working/music-svg-app-design/concepts/20b-dupre-compact-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/21-dupre-semantic-controls-comparison.png b/working/music-svg-app-design/concepts/21-dupre-semantic-controls-comparison.png
new file mode 100644
index 00000000..c86f1f59
--- /dev/null
+++ b/working/music-svg-app-design/concepts/21-dupre-semantic-controls-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/21a-dupre-mode-lamps-playlist.png b/working/music-svg-app-design/concepts/21a-dupre-mode-lamps-playlist.png
new file mode 100644
index 00000000..a18fdc8b
--- /dev/null
+++ b/working/music-svg-app-design/concepts/21a-dupre-mode-lamps-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/21b-dupre-two-on-air-radio.png b/working/music-svg-app-design/concepts/21b-dupre-two-on-air-radio.png
new file mode 100644
index 00000000..70303bc2
--- /dev/null
+++ b/working/music-svg-app-design/concepts/21b-dupre-two-on-air-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/22-dupre-identity-bay-comparison.png b/working/music-svg-app-design/concepts/22-dupre-identity-bay-comparison.png
new file mode 100644
index 00000000..c0e7d59a
--- /dev/null
+++ b/working/music-svg-app-design/concepts/22-dupre-identity-bay-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/22a-dupre-identity-bay-playlist.png b/working/music-svg-app-design/concepts/22a-dupre-identity-bay-playlist.png
new file mode 100644
index 00000000..1ab38a42
--- /dev/null
+++ b/working/music-svg-app-design/concepts/22a-dupre-identity-bay-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/22b-dupre-identity-bay-radio.png b/working/music-svg-app-design/concepts/22b-dupre-identity-bay-radio.png
new file mode 100644
index 00000000..f752ed17
--- /dev/null
+++ b/working/music-svg-app-design/concepts/22b-dupre-identity-bay-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/23-dupre-swapped-info-comparison.png b/working/music-svg-app-design/concepts/23-dupre-swapped-info-comparison.png
new file mode 100644
index 00000000..252ad160
--- /dev/null
+++ b/working/music-svg-app-design/concepts/23-dupre-swapped-info-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/23a-dupre-swapped-info-playlist.png b/working/music-svg-app-design/concepts/23a-dupre-swapped-info-playlist.png
new file mode 100644
index 00000000..f9df6b85
--- /dev/null
+++ b/working/music-svg-app-design/concepts/23a-dupre-swapped-info-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/23b-dupre-swapped-info-radio.png b/working/music-svg-app-design/concepts/23b-dupre-swapped-info-radio.png
new file mode 100644
index 00000000..e532e98e
--- /dev/null
+++ b/working/music-svg-app-design/concepts/23b-dupre-swapped-info-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/24-dupre-full-brass-vu-comparison.png b/working/music-svg-app-design/concepts/24-dupre-full-brass-vu-comparison.png
new file mode 100644
index 00000000..285ec95f
--- /dev/null
+++ b/working/music-svg-app-design/concepts/24-dupre-full-brass-vu-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/24a-dupre-full-brass-vu-playlist.png b/working/music-svg-app-design/concepts/24a-dupre-full-brass-vu-playlist.png
new file mode 100644
index 00000000..59dd8109
--- /dev/null
+++ b/working/music-svg-app-design/concepts/24a-dupre-full-brass-vu-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/24b-dupre-full-brass-vu-radio.png b/working/music-svg-app-design/concepts/24b-dupre-full-brass-vu-radio.png
new file mode 100644
index 00000000..9a0bde44
--- /dev/null
+++ b/working/music-svg-app-design/concepts/24b-dupre-full-brass-vu-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/25-dupre-seamless-flush-comparison.png b/working/music-svg-app-design/concepts/25-dupre-seamless-flush-comparison.png
new file mode 100644
index 00000000..049d1dd3
--- /dev/null
+++ b/working/music-svg-app-design/concepts/25-dupre-seamless-flush-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/25a-dupre-seamless-flush-playlist.png b/working/music-svg-app-design/concepts/25a-dupre-seamless-flush-playlist.png
new file mode 100644
index 00000000..16063222
--- /dev/null
+++ b/working/music-svg-app-design/concepts/25a-dupre-seamless-flush-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/25b-dupre-seamless-flush-radio.png b/working/music-svg-app-design/concepts/25b-dupre-seamless-flush-radio.png
new file mode 100644
index 00000000..4fdbbbb8
--- /dev/null
+++ b/working/music-svg-app-design/concepts/25b-dupre-seamless-flush-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/26-dupre-studios-rounded-chrome-comparison.png b/working/music-svg-app-design/concepts/26-dupre-studios-rounded-chrome-comparison.png
new file mode 100644
index 00000000..ed067d99
--- /dev/null
+++ b/working/music-svg-app-design/concepts/26-dupre-studios-rounded-chrome-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/26a-dupre-studios-rounded-chrome-playlist.png b/working/music-svg-app-design/concepts/26a-dupre-studios-rounded-chrome-playlist.png
new file mode 100644
index 00000000..b086886d
--- /dev/null
+++ b/working/music-svg-app-design/concepts/26a-dupre-studios-rounded-chrome-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/26b-dupre-studios-rounded-chrome-radio.png b/working/music-svg-app-design/concepts/26b-dupre-studios-rounded-chrome-radio.png
new file mode 100644
index 00000000..dce2f048
--- /dev/null
+++ b/working/music-svg-app-design/concepts/26b-dupre-studios-rounded-chrome-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/27-dupre-studios-leather-analog-header-comparison.png b/working/music-svg-app-design/concepts/27-dupre-studios-leather-analog-header-comparison.png
new file mode 100644
index 00000000..603aa5a0
--- /dev/null
+++ b/working/music-svg-app-design/concepts/27-dupre-studios-leather-analog-header-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/27a-dupre-studios-leather-analog-header-playlist.png b/working/music-svg-app-design/concepts/27a-dupre-studios-leather-analog-header-playlist.png
new file mode 100644
index 00000000..288ad19b
--- /dev/null
+++ b/working/music-svg-app-design/concepts/27a-dupre-studios-leather-analog-header-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/27b-dupre-studios-leather-analog-header-radio.png b/working/music-svg-app-design/concepts/27b-dupre-studios-leather-analog-header-radio.png
new file mode 100644
index 00000000..c02286a2
--- /dev/null
+++ b/working/music-svg-app-design/concepts/27b-dupre-studios-leather-analog-header-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/28-dupre-studios-balanced-controls-comparison.png b/working/music-svg-app-design/concepts/28-dupre-studios-balanced-controls-comparison.png
new file mode 100644
index 00000000..1e8199f9
--- /dev/null
+++ b/working/music-svg-app-design/concepts/28-dupre-studios-balanced-controls-comparison.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/28a-dupre-studios-balanced-controls-playlist.png b/working/music-svg-app-design/concepts/28a-dupre-studios-balanced-controls-playlist.png
new file mode 100644
index 00000000..ee329bea
--- /dev/null
+++ b/working/music-svg-app-design/concepts/28a-dupre-studios-balanced-controls-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/28b-dupre-studios-balanced-controls-radio.png b/working/music-svg-app-design/concepts/28b-dupre-studios-balanced-controls-radio.png
new file mode 100644
index 00000000..516f555f
--- /dev/null
+++ b/working/music-svg-app-design/concepts/28b-dupre-studios-balanced-controls-radio.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/29a-dupre-studios-brass-seek-playlist.png b/working/music-svg-app-design/concepts/29a-dupre-studios-brass-seek-playlist.png
new file mode 100644
index 00000000..20871536
--- /dev/null
+++ b/working/music-svg-app-design/concepts/29a-dupre-studios-brass-seek-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/concepts/29a-dupre-studios-brass-seek-playlist.xcf b/working/music-svg-app-design/concepts/29a-dupre-studios-brass-seek-playlist.xcf
new file mode 100644
index 00000000..9691728b
--- /dev/null
+++ b/working/music-svg-app-design/concepts/29a-dupre-studios-brass-seek-playlist.xcf
Binary files differ
diff --git a/working/music-svg-app-design/concepts/30a-dupre-studios-user-refined-playlist.png b/working/music-svg-app-design/concepts/30a-dupre-studios-user-refined-playlist.png
new file mode 100644
index 00000000..50dae41b
--- /dev/null
+++ b/working/music-svg-app-design/concepts/30a-dupre-studios-user-refined-playlist.png
Binary files differ
diff --git a/working/music-svg-app-design/music-svg-directions.html b/working/music-svg-app-design/music-svg-directions.html
new file mode 100644
index 00000000..07b0456e
--- /dev/null
+++ b/working/music-svg-app-design/music-svg-directions.html
@@ -0,0 +1,35 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Music SVG application — six directions</title>
+<style>
+:root{--ground:#151311;--panel:#100f0f;--raise:#1a1917;--well:#090a0b;--cream:#f3e7c5;--steel:#969385;--dim:#6f7478;--gold:#e2a038;--goldhi:#ffbe54;--amber:#f0b552;--green:#7fe0a0;--red:#e2543f;--line:#393226;--mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 50% -20%,#302818 0,transparent 55%),var(--ground);color:var(--cream);font:14px/1.35 var(--mono)}header{padding:20px max(20px,calc((100vw - 1320px)/2));border-bottom:1px solid var(--line);background:#100f0fdd;position:sticky;top:0;z-index:4;backdrop-filter:blur(12px)}.eyebrow{color:var(--steel);font-size:10px;letter-spacing:.22em;text-transform:uppercase}.head{display:flex;gap:20px;align-items:end;justify-content:space-between;flex-wrap:wrap}.head h1{font-size:22px;color:var(--goldhi);margin:3px 0}.head p{max-width:640px;margin:0;color:var(--steel)}.tabs{display:flex;gap:6px;flex-wrap:wrap}.tabs button,.button{font:inherit;color:var(--steel);background:var(--raise);border:1px solid var(--line);border-radius:5px;padding:7px 9px;cursor:pointer}.tabs button:hover,.tabs button.on,.button:hover{color:var(--panel);background:var(--gold);border-color:var(--goldhi)}main{max-width:1320px;margin:auto;padding:28px 20px 60px}.note{color:var(--steel);margin:0 0 14px}.concept{display:none;min-height:650px}.concept.on{display:block}.face{min-height:640px;padding:20px;border:1px solid #5d471d;border-radius:16px;background:linear-gradient(135deg,#211b12,#0b0b0b 45%,#19150f);box-shadow:inset 0 1px #ffffff13,0 18px 55px #0008;position:relative;overflow:hidden}.face:before,.face:after{content:"";position:absolute;left:16px;right:16px;height:2px;background:linear-gradient(90deg,transparent,var(--gold),var(--goldhi),var(--gold),transparent);opacity:.9}.face:before{top:11px}.face:after{bottom:11px}.label{color:var(--gold);font-size:10px;letter-spacing:.18em;text-transform:uppercase}.well{background:linear-gradient(#060707,#121313);border:1px solid #342b1b;border-radius:8px;box-shadow:inset 0 2px 12px #000;padding:13px}.screen{color:var(--green);text-shadow:0 0 8px #7fe0a077}.title{font:600 clamp(20px,3vw,38px) Georgia,serif;color:var(--cream)}.sub{color:var(--steel)}.gold{color:var(--goldhi)}.lamp{display:inline-block;width:9px;height:9px;border-radius:100%;background:#312818;border:1px solid #6b5223}.lamp.on{background:var(--goldhi);box-shadow:0 0 12px #ffbe54}.lamp.live{background:var(--green);box-shadow:0 0 12px #7fe0a0}.lamp.warn{background:var(--red);box-shadow:0 0 12px #e2543f}.transport{display:flex;gap:8px;align-items:center;justify-content:center}.transport button{width:46px;height:39px;border-radius:6px;border:1px solid #685025;background:linear-gradient(#3a2e1c,#17130d);color:var(--goldhi);font:18px var(--mono);cursor:pointer}.transport button.play{background:linear-gradient(#f0c46c,#8f671f);color:#181109}.transport button:active{transform:translateY(1px);filter:brightness(1.2)}.queue{display:grid;gap:4px}.track{display:grid;grid-template-columns:30px 1fr auto;gap:10px;align-items:center;padding:7px 8px;border-bottom:1px solid #ffffff0b;cursor:pointer}.track:hover,.track.current{background:#e2a03818}.track.current{border-left:3px solid var(--goldhi)}.track .num{color:var(--gold)}.track .meta{color:var(--steel);font-size:11px}.progress{height:7px;background:#28221a;border:1px solid #554321;border-radius:8px;overflow:hidden}.progress i{display:block;height:100%;width:42%;background:linear-gradient(90deg,#8f671f,var(--goldhi),#fff0af);box-shadow:0 0 8px var(--goldhi)}.presets{display:flex;gap:6px;flex-wrap:wrap}.preset{padding:5px 8px;border:1px solid #49391e;border-radius:20px;background:#17130e;color:var(--steel);cursor:pointer}.preset.on{color:var(--panel);background:var(--goldhi)}.knob{width:84px;height:84px;border:8px solid #5c4319;border-radius:50%;background:radial-gradient(circle at 38% 30%,#ffe0a0,#b77b27 45%,#3d2b13 68%);position:relative;box-shadow:0 3px 8px #000}.knob:after{content:"";position:absolute;width:3px;height:27px;background:#1b1308;top:8px;left:38px;border-radius:2px;transform:rotate(32deg);transform-origin:50% 34px}.gauge{width:230px;height:126px;overflow:hidden;position:relative}.gauge svg{width:230px;height:230px;position:absolute;bottom:-104px}.gauge b{position:absolute;bottom:7px;left:0;right:0;text-align:center;color:var(--goldhi);font-size:24px}.svg{width:100%;height:100%}.caption{margin-top:13px;color:var(--steel);font-size:12px}.pinstripe{display:grid;grid-template-columns:minmax(240px,1.1fr) minmax(360px,1.8fr) minmax(240px,1fr);gap:16px;align-items:stretch}.odometer{font-size:clamp(27px,5vw,58px);letter-spacing:.12em;color:#17120a;background:var(--cream);border:3px solid var(--gold);padding:10px 14px;border-radius:6px;box-shadow:inset 0 -5px #b9a988}.dashboard{display:grid;grid-template-columns:1fr minmax(340px,1.45fr) 1fr;gap:18px;align-items:center}.dash-center{text-align:center}.dial{width:min(100%,310px);aspect-ratio:1;margin:auto;border:10px solid #30343a;border-radius:50%;background:radial-gradient(circle,#171611 0 52%,transparent 53%),repeating-conic-gradient(from 225deg,#d59a36 0 1deg,transparent 1deg 7deg);position:relative}.dial:after{content:"";position:absolute;left:50%;bottom:50%;width:3px;height:36%;background:var(--red);transform-origin:bottom;transform:rotate(42deg);box-shadow:0 0 8px #e2543f}.dial .dialtext{position:absolute;inset:0;display:grid;place-content:center;text-align:center;color:var(--goldhi)}.chronometer{display:grid;grid-template-columns:minmax(260px,1fr) minmax(280px,1fr);gap:22px}.chrono{border-radius:50%;aspect-ratio:1;max-width:440px;margin:auto;border:12px double var(--gold);background:repeating-radial-gradient(circle,#15110c 0 5px,#1f1910 6px 7px);position:relative;display:grid;place-content:center;text-align:center}.chrono:before{content:"";position:absolute;inset:13px;border:1px solid #c28b2c;border-radius:50%;background:repeating-conic-gradient(#d69b38 0 1deg,transparent 1deg 6deg)}.chrono>*{position:relative}.time{font-size:clamp(34px,6vw,66px);letter-spacing:.05em;color:var(--cream)}.rack{display:grid;grid-template-columns:190px 1fr 210px;gap:14px}.vu{display:flex;gap:7px;align-items:end;height:110px}.vu i{width:12px;background:linear-gradient(var(--red) 0 18%,var(--goldhi) 18% 50%,var(--green) 50%);border-radius:2px;box-shadow:0 0 6px #7fe0a066}.vu i:nth-child(1){height:28%}.vu i:nth-child(2){height:55%}.vu i:nth-child(3){height:38%}.vu i:nth-child(4){height:76%}.vu i:nth-child(5){height:92%}.vu i:nth-child(6){height:64%}.vu i:nth-child(7){height:48%}.calendar{display:grid;grid-template-columns:1fr 1.35fr 1fr;gap:16px}.apertures{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.aperture{padding:11px 5px;text-align:center;background:#080706;border:2px solid var(--gold);border-radius:5px;color:var(--cream);font-size:20px;box-shadow:inset 0 3px 10px #000}.radio{display:grid;grid-template-columns:1.1fr 1.5fr;gap:18px}.radar{aspect-ratio:1;max-width:370px;margin:auto;border:2px solid #4b742d;border-radius:50%;background:repeating-radial-gradient(circle,#102012 0 1px,transparent 2px 58px),linear-gradient(135deg,#07100a,#122d16);position:relative;overflow:hidden}.radar:before{content:"";position:absolute;inset:50% 0 0 50%;background:conic-gradient(from 15deg,#7fe0a088,transparent 55deg);transform-origin:0 0;animation:sweep 3s linear infinite}.radar:after{content:"• 104.9\A\A ◉ \A\A •";white-space:pre;position:absolute;inset:30px;color:var(--green);font-size:20px;text-shadow:0 0 8px var(--green)}@keyframes sweep{to{transform:rotate(360deg)}}@media(max-width:900px){.pinstripe,.dashboard,.rack,.calendar,.chronometer,.radio{grid-template-columns:1fr}.face{min-height:auto}.gauge{margin:auto}}
+</style>
+</head>
+<body>
+<header><div class="head"><div><div class="eyebrow">Music SVG application · six functional directions</div><h1>Which instrument do you want to live with?</h1><p>Every direction drives the same player state: transport, queue selection, radio preset, playback mode, and volume all respond.</p></div><nav class="tabs" id="tabs"></nav></div></header>
+<main><p class="note" id="note"></p><div id="stage"></div></main>
+<script>
+const state={playing:true,track:0,volume:68,preset:'Groove Salad',modes:{repeat:false,random:false,consume:false}};
+const tracks=[['Groove Salad','SomaFM · 256k','◉'],['Blue in Green','Miles Davis · 5:37','♪'],['Dawn Chorus','Tycho · 4:21','♪'],['Drone Zone','SomaFM · 128k','◉'],['Night Drive','Chromatics · 4:06','♪']];
+const names=['Gold-pinstripe tuner','Touring-car dashboard','Chronometer record deck','Studio rack','Perpetual-calendar salon','Radio navigator'];
+const notes=['One machined faceplate: the clearest bridge to Waybar direction 1. A tape-counter queue and a brass volume knob make library work feel deliberate.','A driving instrument cluster: progress becomes speed, queue position becomes odometer, and state becomes a small set of unmistakable tell-tales.','A timepiece that treats playback as elapsed time. Best for focused listening: transport is controlled from the bezel and the queue becomes a program card.','A tactile late-70s receiver: VU meters make audio activity visible, while a rack layout gives playlist and library controls equal status.','A restrained horological receiver: album/stream art sits beside calendar-like status apertures. Best if the music panel should feel like a dressed object, not a machine.','A green-phosphor tuner/navigation station: radio discovery takes center stage, with stations as signals and playlists as routes.'];
+function controls(){return `<div class="transport"><button data-action="prev">‹‹</button><button class="play" data-action="play">${state.playing?'Ⅱ':'▶'}</button><button data-action="next">››</button><button data-action="seek">↶</button><button data-action="library">☰</button></div>`}
+function queue(){return `<div class="queue">${tracks.map((t,i)=>`<div class="track ${i===state.track?'current':''}" data-track="${i}"><span class="num">${String(i+1).padStart(2,'0')}</span><span>${t[2]} ${t[0]}<br><span class="meta">${t[1]}</span></span><span class="lamp ${i===state.track?'live':''}"></span></div>`).join('')}</div>`}
+function modes(){return `<div class="presets"><span class="preset ${state.modes.repeat?'on':''}" data-mode="repeat">repeat</span><span class="preset ${state.modes.random?'on':''}" data-mode="random">random</span><span class="preset ${state.modes.consume?'on':''}" data-mode="consume">consume</span></div>`}
+function radioPresets(){return `<div class="presets">${['Groove Salad','Drone Zone','FIP','NTS 1'].map(x=>`<span class="preset ${x===state.preset?'on':''}" data-preset="${x}">${x}</span>`).join('')}</div>`}
+function now(){let t=tracks[state.track];return `<div class="label">Now playing <span class="lamp ${state.playing?'live':''}"></span></div><div class="title">${t[0]}</div><div class="sub">${t[1]} · ${state.playing?'playing':'paused'}</div><div class="progress"><i style="width:${state.playing?42:13}%"></i></div>`}
+function pinstripe(){return `<section class="concept pinstripe on"><div class="face"><div class="pinstripe"><div class="well"><div class="label">Program counter</div><div class="odometer">0${state.track+1}·24</div><p class="caption">Playlist position is a physical counter. The control bank below opens load, save, edit, and delete flows.</p>${modes()}</div><div class="well">${now()}<div style="height:12px"></div>${controls()}<div style="height:16px"></div><div class="label">Live queue</div>${queue()}</div><div class="well" style="display:grid;place-items:center;align-content:center;gap:12px"><div class="label">Volume</div><div class="knob" data-action="volume"></div><b class="gold">${state.volume}%</b><div class="label">Radio presets</div>${radioPresets()}</div></div><p class="caption">Direction 1 — gold pinstripe tuner console. Use as the base language if the player should visibly belong beside the Waybar odometer console.</p></div></section>`}
+function dashboard(){return `<section class="concept dashboard"><div class="face"><div class="dashboard"><div class="well"><div class="label">Source / modes</div>${radioPresets()}<div style="height:18px"></div>${modes()}<div style="height:18px"></div><div class="label">Queue</div>${queue()}</div><div class="dash-center"><div class="dial"><div class="dialtext"><div class="label">elapsed</div><div style="font-size:42px">02:18</div><div class="gold">${state.track+1} / ${tracks.length}</div></div></div><div style="height:12px"></div>${controls()}<p class="caption">Needle position means playback progress. Lamps mean real state: green live, amber engaged, red fault or missing media.</p></div><div class="well"><div class="label">Signal and volume</div><div class="gauge"><svg viewBox="0 0 230 230"><path d="M28 172 A95 95 0 0 1 202 172" fill="none" stroke="#5d451e" stroke-width="16"/><path d="M28 172 A95 95 0 0 1 164 83" fill="none" stroke="#e2a038" stroke-width="10"/><line x1="115" y1="172" x2="153" y2="95" stroke="#ffbe54" stroke-width="4"/></svg><b>${state.volume}<small>%</small></b></div><div class="label">Tell-tales</div><p><span class="lamp live"></span> playing &nbsp; <span class="lamp on"></span> repeat &nbsp; <span class="lamp"></span> queue saved</p></div></div><p class="caption">Direction 2 — touring-car dashboard. Strongest one-glance state model; it turns progress, volume, queue position, and errors into instruments rather than labels.</p></div></section>`}
+function chronometer(){return `<section class="concept chronometer"><div class="face"><div class="chronometer"><div class="chrono"><div class="label">${state.playing?'running':'held'} chronometer</div><div class="time">02:18</div><div class="sub">of 05:37</div><div style="margin-top:18px">${controls()}</div></div><div class="well">${now()}<div style="height:16px"></div><div class="label">Program card</div>${queue()}<div style="height:15px"></div><div class="label">Playback modes</div>${modes()}<div style="height:15px"></div><div class="label">Station bank</div>${radioPresets()}</div></div><p class="caption">Direction 3 — chronometer record deck. Playback time is the hero; a physical bezel is a compelling seek surface in the SVG port.</p></div></section>`}
+function rack(){return `<section class="concept rack"><div class="face"><div class="rack"><div class="well"><div class="label">Input / source</div>${radioPresets()}<div style="height:15px"></div><div class="label">Program modes</div>${modes()}<div style="height:20px"></div><div class="label">Master</div><div class="knob" data-action="volume"></div><p class="gold">${state.volume}%</p></div><div class="well">${now()}<div style="height:18px"></div><div class="label">Stereo program</div><div class="vu">${Array.from({length:7},()=>'<i></i>').join('')}<span style="width:18px"></span>${Array.from({length:7},()=>'<i></i>').join('')}</div><div class="screen" style="margin:12px 0;padding:8px">L −8.1 dB&nbsp;&nbsp; R −7.8 dB&nbsp;&nbsp; ${state.playing?'SIGNAL':'HOLD'}</div>${controls()}</div><div class="well"><div class="label">Tape library / queue</div>${queue()}</div></div><p class="caption">Direction 4 — studio rack and tape machine. The VU meters are alive only while a track is playing; flashing or red meter states become a useful failure vocabulary.</p></div></section>`}
+function calendar(){return `<section class="concept calendar"><div class="face"><div class="calendar"><div class="well"><div class="label">Library cabinet</div>${queue()}<div style="height:12px"></div><div class="label">File operations</div><div class="presets"><span class="preset">load</span><span class="preset">save</span><span class="preset">edit</span><span class="preset">delete</span></div></div><div class="well"><div class="label">Music perpetual calendar</div><div class="apertures"><div class="aperture">JAZZ</div><div class="aperture">SIDE A</div><div class="aperture">LIVE</div></div><div style="height:22px"></div>${now()}<div style="height:18px"></div>${controls()}<div style="height:18px"></div>${modes()}</div><div class="well"><div class="label">Station complications</div>${radioPresets()}<div style="height:18px"></div><div class="label">Volume winding</div><div class="knob" data-action="volume"></div><p class="gold">${state.volume}%</p></div></div><p class="caption">Direction 5 — perpetual-calendar dress. Gold-framed apertures make source, mode, and queue identity feel calm and ceremonial; reserve flashing for actual attention, never decoration.</p></div></section>`}
+function navigator(){return `<section class="concept radio"><div class="face"><div class="radio"><div><div class="radar"></div><div class="caption" style="text-align:center">The sweep means an active radio discovery request. A fixed blip means a saved station; a pulsing blip means buffering or reconnecting.</div></div><div class="well"><div class="label">Tuner navigator</div>${now()}<div style="height:16px"></div>${controls()}<div style="height:16px"></div><div class="label">Stations</div>${radioPresets()}<div style="height:16px"></div><div class="label">Route / playlist</div>${queue()}<div style="height:16px"></div>${modes()}</div></div><p class="caption">Direction 6 — mission-control radio navigator. Best for elevating radio search, tag browsing, and saved stations to first-class interaction rather than hiding them behind a command row.</p></div></section>`}
+const renders=[pinstripe,dashboard,chronometer,rack,calendar,navigator];let active=Math.max(0,Math.min(renders.length-1,Number(new URLSearchParams(location.search).get('direction'))||0));
+function render(){document.querySelector('#tabs').innerHTML=names.map((n,i)=>`<button class="${i===active?'on':''}" data-tab="${i}">${i+1}. ${n}</button>`).join('');document.querySelector('#stage').innerHTML=renders[active]();document.querySelector('#stage .concept').classList.add('on');document.querySelector('#note').textContent=notes[active];bind()}
+function bind(){document.querySelectorAll('[data-tab]').forEach(e=>e.onclick=()=>{active=+e.dataset.tab;render()});document.querySelectorAll('[data-track]').forEach(e=>e.onclick=()=>{state.track=+e.dataset.track;render()});document.querySelectorAll('[data-preset]').forEach(e=>e.onclick=()=>{state.preset=e.dataset.preset;state.track=0;render()});document.querySelectorAll('[data-mode]').forEach(e=>e.onclick=()=>{state.modes[e.dataset.mode]=!state.modes[e.dataset.mode];render()});document.querySelectorAll('[data-action]').forEach(e=>e.onclick=()=>{let a=e.dataset.action;if(a==='play')state.playing=!state.playing;if(a==='next')state.track=(state.track+1)%tracks.length;if(a==='prev')state.track=(state.track+tracks.length-1)%tracks.length;if(a==='volume')state.volume=state.volume>82?35:state.volume+8;render()})}render();
+</script>
+</body>
+</html>
diff --git a/working/music-svg-app-design/prototypes/2026-07-19-music-config-ui-remodel-benchmark.org b/working/music-svg-app-design/prototypes/2026-07-19-music-config-ui-remodel-benchmark.org
new file mode 100644
index 00000000..7243ad5f
--- /dev/null
+++ b/working/music-svg-app-design/prototypes/2026-07-19-music-config-ui-remodel-benchmark.org
@@ -0,0 +1,61 @@
+#+TITLE: Music Config UI Remodel Prototype Benchmark
+#+DATE: 2026-07-19
+
+* Purpose
+
+Measure the rasterization cost that matters for the proposed Emacs SVG port.
+The interactive browser prototype proves behavior, but its DOM timings do not
+predict librsvg cost. These trials use =rsvg-convert= 2.62.3 against embedded
+raster/SVG fixtures at the intended roughly 958×411 dock size.
+
+The shell loop launched a fresh =rsvg-convert= process for each sample. Process
+startup is therefore included, making these results a conservative bound for an
+in-process Emacs/librsvg update.
+
+* Full instrument trial
+
+- Fixture: concept 30 embedded as a 3.0 MiB data URI, plus live metadata,
+ playlist rows, progress, VU needles, and volume segments.
+- Output: 958×411.
+- Duration: 60 seconds.
+- Samples: 448.
+- Mean: 134.074 ms.
+- Median: 133.616 ms.
+- 95th percentile: 139.822 ms.
+- Range: 125.105–163.231 ms.
+- Verdict: fail. A full receiver rasterization cannot drive ten-Hz VU motion.
+
+* Lower-deck tile trial
+
+- Fixture: the opaque lower control deck exported at its 585×226 display size
+ as JPEG quality 88, embedded as a 52 KiB data URI, plus two dynamic needles
+ and twenty-one dynamic volume segments.
+- Output: 585×226.
+- Duration: 60 seconds.
+- Samples: 1,677.
+- Mean: 35.694 ms.
+- Median: 35.513 ms.
+- 95th percentile: 38.221 ms.
+- Range: 31.719–62.626 ms.
+- Verdict: pass. Both the under-50-ms median and under-100-ms p95 budgets pass.
+
+* Resulting implementation constraint
+
+The production view uses three aligned image regions from one coordinate
+manifest:
+
+1. Upper player glass, redrawn for track, art, seek, and radio-state changes.
+2. Lower control deck, redrawn at meter cadence while visible and playing.
+3. Playlist, redrawn for queue, selection, and scroll changes.
+
+The editable neutral source remains lossless and full resolution. The opaque
+runtime deck export is sized for the normal dock and may use a high-quality JPEG
+after visual comparison with PNG; it is not the editable source. A wider dock
+may select a larger cached export, but V1 never returns to full-instrument
+meter-rate rasterization.
+
+* Related artifacts
+
+- [[file:2026-07-19-music-config-ui-remodel-prototype-1.html][Functional browser prototype]]
+- [[file:../2026-07-19-music-config-ui-remodel-render-fixture.svg][Full-render SVG fixture]]
+- [[file:../2026-07-19-music-config-ui-remodel-spec.org][Music Config UI Remodel spec]]
diff --git a/working/music-svg-app-design/prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html b/working/music-svg-app-design/prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html
new file mode 100644
index 00000000..c05a6947
--- /dev/null
+++ b/working/music-svg-app-design/prototypes/2026-07-19-music-config-ui-remodel-prototype-1.html
@@ -0,0 +1,1450 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Dupre Studios Music Config UI Remodel</title>
+<style>
+ :root {
+ --black: #050605;
+ --glass: #090a09;
+ --soft-white: #e9e2d3;
+ --dim-white: #a9a59c;
+ --amber: #f2a900;
+ --amber-hot: #ffc34f;
+ --green: #9dda59;
+ --red: #a73a29;
+ --brass: #c9a574;
+ --coffee: #b69c7d;
+ --ink: #201810;
+ --mono: "Berkeley Mono", "BerkeleyMono Nerd Font", ui-monospace, monospace;
+ }
+
+ * { box-sizing: border-box; }
+
+ [hidden] { display: none !important; }
+
+ html, body { min-height: 100%; }
+
+ body {
+ margin: 0;
+ color: var(--soft-white);
+ background:
+ radial-gradient(circle at 50% -20%, #2c261e 0, transparent 42%),
+ linear-gradient(#0e0e0d, #050505 70%);
+ font-family: var(--mono);
+ }
+
+ button, input { font: inherit; }
+
+ .workbench {
+ width: min(100%, 1960px);
+ margin: 0 auto;
+ padding: 16px 20px 28px;
+ }
+
+ .prototype-bar {
+ min-height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 12px;
+ color: #bbb3a3;
+ font-size: 12px;
+ letter-spacing: .04em;
+ }
+
+ .prototype-bar h1 {
+ margin: 0 0 3px;
+ color: #e8d6b7;
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: .13em;
+ text-transform: uppercase;
+ }
+
+ .prototype-bar p { margin: 0; }
+
+ .fixture-switch, .bench-controls {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+
+ .lab-button {
+ min-height: 31px;
+ padding: 5px 10px;
+ color: #bbb3a3;
+ border: 1px solid #554b3e;
+ border-radius: 3px;
+ background: linear-gradient(#26231f, #141311);
+ cursor: pointer;
+ }
+
+ .lab-button:hover, .lab-button[aria-pressed="true"] {
+ color: #17130e;
+ border-color: #b89765;
+ background: linear-gradient(#e0c292, #a98857);
+ }
+
+ .bench-readout {
+ min-width: 242px;
+ color: #9b9487;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ }
+
+ .receiver-wrap {
+ width: 100%;
+ overflow: hidden;
+ border-radius: 0 0 12px 12px;
+ box-shadow: 0 28px 60px #000c;
+ }
+
+ .receiver {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 1916 / 821;
+ overflow: hidden;
+ background: #060606 url("../concepts/30a-dupre-studios-user-refined-playlist.png") center / 100% 100% no-repeat;
+ user-select: none;
+ touch-action: none;
+ }
+
+ .dynamic-svg, .hit-layer {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ .dynamic-svg { pointer-events: none; }
+
+ .upper-live {
+ position: absolute;
+ left: 31.55%;
+ top: 11.6%;
+ width: 27.9%;
+ height: 31.4%;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse at 36% 10%, #25242140, transparent 46%),
+ linear-gradient(100deg, #090a09 0, #060706 72%, #080908 100%);
+ box-shadow: inset 0 0 34px #000;
+ }
+
+ .art {
+ position: absolute;
+ left: 1.4%;
+ top: 1.5%;
+ width: 28%;
+ height: 69%;
+ overflow: hidden;
+ border: 1px solid #292724;
+ border-radius: 2px;
+ background: #171716;
+ box-shadow: 0 5px 15px #000b;
+ }
+
+ .art img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ }
+
+ .album-crop {
+ width: 100%;
+ height: 100%;
+ background-image: url("../concepts/30a-dupre-studios-user-refined-playlist.png");
+ background-size: 1312.33% 475.2%;
+ background-position: 34.42% 20.9%;
+ }
+
+ .metadata {
+ position: absolute;
+ left: 33.1%;
+ right: 1.5%;
+ top: 0;
+ height: 72%;
+ display: grid;
+ align-content: start;
+ gap: 4.6%;
+ padding-top: 1.2%;
+ color: var(--soft-white);
+ font-size: clamp(7px, 1.04vw, 20px);
+ line-height: 1.07;
+ letter-spacing: .08em;
+ text-shadow: 0 0 6px #fff2;
+ text-transform: uppercase;
+ }
+
+ .metadata-line {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .metadata-line.dim { color: #d5cfc2; }
+
+ .on-air-under-art {
+ position: absolute;
+ left: 1.6%;
+ top: 73.5%;
+ width: 27.5%;
+ color: #e9543f;
+ font-size: clamp(8px, 1vw, 18px);
+ letter-spacing: .18em;
+ text-align: center;
+ text-shadow: 0 0 9px #e9543f99;
+ }
+
+ .seek-line {
+ position: absolute;
+ left: 1.4%;
+ right: 1.5%;
+ bottom: 2.5%;
+ height: 20%;
+ display: grid;
+ grid-template-columns: 12% 1fr 12%;
+ align-items: center;
+ gap: 3%;
+ color: #d2cabd;
+ font-size: clamp(8px, .94vw, 18px);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .seek-time:last-child { text-align: right; }
+
+ .seek-track {
+ position: relative;
+ height: 12px;
+ cursor: ew-resize;
+ }
+
+ .seek-track::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 5px;
+ height: 2px;
+ background: #373735;
+ box-shadow: inset 0 1px #000;
+ }
+
+ .seek-fill {
+ position: absolute;
+ left: 0;
+ top: 5px;
+ width: calc(var(--progress) * 1%);
+ height: 2px;
+ background: var(--amber);
+ box-shadow: 0 0 4px #f2a90088;
+ }
+
+ .seek-thumb {
+ position: absolute;
+ top: 50%;
+ left: calc(var(--progress) * 1%);
+ width: 12px;
+ height: 21px;
+ border: 1px solid #ead0a4;
+ border-radius: 3px;
+ background: linear-gradient(90deg, #6f4c2d, #caa16a 22%, #f0d3a0 48%, #ad7f4a 74%, #654429);
+ box-shadow: 0 2px 4px #000d, inset 0 1px #fff4, inset 0 -1px #50351f;
+ transform: translate(-50%, -50%);
+ }
+
+ .seek-thumb::after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 3px;
+ bottom: 3px;
+ width: 1px;
+ background: #5f4026aa;
+ box-shadow: 1px 0 #f2d7aa66;
+ }
+
+ .playlist-header {
+ position: absolute;
+ left: 61.16%;
+ top: 6.35%;
+ width: 36%;
+ height: 7.8%;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 2.4%;
+ padding: 0 2.1%;
+ overflow: hidden;
+ color: #1f1811;
+ background:
+ radial-gradient(ellipse 10% 100% at 5% -3%, #fff1d7 0, #eac184aa 35%, transparent 76%),
+ radial-gradient(ellipse 10% 100% at 35% -3%, #fff1d7 0, #eac184aa 35%, transparent 76%),
+ radial-gradient(ellipse 10% 100% at 65% -3%, #fff1d7 0, #eac184aa 35%, transparent 76%),
+ radial-gradient(ellipse 10% 100% at 95% -3%, #fff1d7 0, #eac184aa 35%, transparent 76%),
+ linear-gradient(90deg, #d5b78c22, transparent 25% 75%, #5c3c2233),
+ linear-gradient(180deg, #92795e, #877057);
+ box-shadow:
+ inset 0 1px #f7dfb6,
+ inset 0 5px 14px #fff2,
+ inset 0 -8px 15px #5a3d2433,
+ inset 0 0 0 1px #73583b66;
+ font-size: clamp(8px, 1.04vw, 20px);
+ letter-spacing: .075em;
+ text-transform: uppercase;
+ }
+
+ .header-name {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .playlist-live {
+ position: absolute;
+ left: 61.05%;
+ top: 14.25%;
+ width: 36.12%;
+ height: 66.15%;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse at 48% 0, #20211e22, transparent 55%),
+ linear-gradient(90deg, #070807, #090a09 65%, #050605);
+ box-shadow: inset 0 0 26px #000;
+ }
+
+ .playlist-scroll {
+ position: absolute;
+ inset: 0 22px 0 0;
+ overflow-y: scroll;
+ scrollbar-width: none;
+ overscroll-behavior: contain;
+ }
+
+ .playlist-scroll::-webkit-scrollbar { display: none; }
+
+ .playlist-rows { min-height: 100%; }
+
+ .playlist-row {
+ height: var(--row-height, 37px);
+ min-height: var(--row-height, 37px);
+ display: grid;
+ grid-template-columns: 7.5% minmax(0, 1fr) 12%;
+ align-items: center;
+ gap: 1.5%;
+ padding: 0 1.2% 0 2.6%;
+ color: #dad6cd;
+ border-bottom: 1px solid #ffffff06;
+ font-size: clamp(7px, .91vw, 17px);
+ letter-spacing: .045em;
+ text-transform: uppercase;
+ cursor: pointer;
+ }
+
+ .playlist-row:hover { background: #f2a9000b; color: #fffaf0; }
+
+ .playlist-row.selected {
+ color: var(--amber);
+ background: linear-gradient(90deg, #f2a90012, transparent 72%);
+ text-shadow: 0 0 7px #f2a90044;
+ }
+
+ .row-number { position: relative; font-variant-numeric: tabular-nums; }
+
+ .playlist-row.selected .row-number::before {
+ content: "▶";
+ position: absolute;
+ right: calc(100% + 4px);
+ font-size: .78em;
+ }
+
+ .row-name {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .row-meta { text-align: right; font-variant-numeric: tabular-nums; }
+
+ .scroll-rail {
+ position: absolute;
+ top: 1.2%;
+ right: 5px;
+ bottom: 1.2%;
+ width: 9px;
+ border: 1px solid #554b3d;
+ border-radius: 5px;
+ background: #191815;
+ box-shadow: inset 0 1px 3px #000;
+ }
+
+ .scroll-thumb {
+ position: absolute;
+ left: -1px;
+ top: 0;
+ width: 9px;
+ min-height: 38px;
+ border: 1px solid #aa9574;
+ border-radius: 5px;
+ background: linear-gradient(90deg, #7b6a53, #bca887 45%, #76634b);
+ box-shadow: 0 0 4px #000;
+ cursor: ns-resize;
+ }
+
+ .empty-row {
+ height: var(--row-height, 37px);
+ border-bottom: 1px solid #ffffff05;
+ }
+
+ .transport-bank {
+ position: absolute;
+ left: 5.95%;
+ top: 71.7%;
+ width: 24.5%;
+ height: 7.45%;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 5.4%;
+ }
+
+ .transport-button {
+ position: relative;
+ border: 1px solid #1d1b18;
+ border-radius: 4px;
+ color: #b6a687;
+ background:
+ linear-gradient(180deg, #292927 0 7%, #171716 15% 72%, #090a09 100%);
+ box-shadow:
+ inset 0 1px #5b5953,
+ inset 0 -3px #050505,
+ 0 5px 5px #0009;
+ font-size: clamp(10px, 1.4vw, 25px);
+ cursor: pointer;
+ transform: translateY(0);
+ }
+
+ .transport-button:hover { color: #e9ddc6; filter: brightness(1.12); }
+
+ .transport-button:active, .transport-button.pressed {
+ transform: translateY(3px);
+ box-shadow: inset 0 2px 4px #000, 0 1px 2px #000b;
+ }
+
+ .transport-button.playing {
+ color: #bdd27c;
+ text-shadow: 0 0 2px #a7c26c, 0 0 4px #70883e99;
+ transform: translateY(3px);
+ box-shadow: inset 0 2px 5px #000, 0 1px 2px #000b;
+ }
+
+ .mode-button,
+ .radio-button {
+ position: absolute;
+ width: 2.05%;
+ aspect-ratio: 1;
+ padding: 0;
+ border: 1px solid #6e5436;
+ border-radius: 50%;
+ color: #2a2118;
+ background:
+ radial-gradient(circle at 34% 25%, #fff0c5 0 2.5%, transparent 5%),
+ repeating-radial-gradient(circle at 50% 50%, #fff3d00b 0 1px, #4b2e160b 1px 2px),
+ radial-gradient(circle at 46% 42%, #e8cb94 0, #c79a60 52%, #8b6038 82%, #d3aa70 100%);
+ box-shadow:
+ 0 3px 3px #0009,
+ inset 0 1px #f2d39e,
+ 0 0 0 3px #342719,
+ 0 0 0 4px #d0ad78;
+ cursor: pointer;
+ transform: translateY(0);
+ }
+
+ .mode-button::after {
+ content: "";
+ position: absolute;
+ inset: -4px;
+ border: 1px solid transparent;
+ border-radius: 50%;
+ }
+
+ .mode-button[aria-pressed="true"]::after {
+ border-color: #829b55;
+ box-shadow: 0 0 2px #819a55aa, inset 0 0 2px #71864988;
+ }
+
+ .mode-button[aria-pressed="true"], .mode-button:active {
+ transform: translateY(2px);
+ box-shadow:
+ 0 1px 1px #0009,
+ inset 0 2px 3px #3c291777,
+ 0 0 0 3px #2c2117,
+ 0 0 0 4px #bb9766;
+ }
+
+ .radio-button {
+ box-shadow:
+ 0 3px 3px #0009,
+ inset 0 1px #f2d39e,
+ 0 0 0 3px #342719,
+ 0 0 0 4px #d0ad78;
+ }
+
+ .radio-button:active {
+ transform: translateY(2px);
+ box-shadow:
+ 0 1px 1px #0009,
+ inset 0 2px 3px #3c291777,
+ 0 0 0 3px #2c2117,
+ 0 0 0 4px #bb9766;
+ }
+
+ .mode-repeat { left: 34.68%; top: 52.75%; }
+ .mode-single { left: 39.18%; top: 52.75%; }
+ .mode-random { left: 34.68%; top: 61.25%; }
+ .mode-consume { left: 39.18%; top: 61.25%; }
+ .radio-name { left: 34.68%; top: 75.65%; }
+ .radio-tags { left: 39.18%; top: 75.65%; }
+
+ .volume-hit {
+ position: absolute;
+ left: 43.2%;
+ top: 49.6%;
+ width: 14.6%;
+ height: 34%;
+ border-radius: 50%;
+ cursor: grab;
+ }
+
+ .volume-hit:active { cursor: grabbing; }
+
+ .playlist-actions {
+ position: absolute;
+ left: 61.13%;
+ top: 81.65%;
+ width: 35.45%;
+ height: 7.45%;
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: .65%;
+ }
+
+ .playlist-action {
+ border: 1px solid #302b23;
+ border-radius: 2px;
+ color: #ccc5b8;
+ background: linear-gradient(#1d1c19, #090a09);
+ box-shadow: inset 0 1px #555147, 0 3px 5px #0007;
+ font-size: clamp(8px, 1vw, 19px);
+ letter-spacing: .05em;
+ cursor: pointer;
+ }
+
+ .playlist-action[data-action="add"],
+ .playlist-action[data-action="new"] {
+ background: linear-gradient(#29271d, #11120d);
+ }
+
+ .playlist-action[data-action="delete"] {
+ color: #ead3cc;
+ border-color: #572c24;
+ background: linear-gradient(#552019, #2e0f0b);
+ }
+
+ .playlist-action:hover { filter: brightness(1.2); }
+
+ .playlist-action:active { transform: translateY(2px); box-shadow: inset 0 2px 5px #000; }
+
+ .status-toast {
+ position: absolute;
+ left: 50%;
+ bottom: 2.7%;
+ z-index: 8;
+ max-width: 52%;
+ padding: .55% 1%;
+ color: #d7ccb8;
+ border: 1px solid #6d5b42;
+ border-radius: 3px;
+ background: #090907ed;
+ box-shadow: 0 5px 14px #000c;
+ font-size: clamp(7px, .78vw, 15px);
+ letter-spacing: .04em;
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(-50%, 8px);
+ transition: opacity .15s, transform .15s;
+ }
+
+ .status-toast.show { opacity: 1; transform: translate(-50%, 0); }
+
+ dialog {
+ width: min(430px, calc(100vw - 40px));
+ color: #e6dccb;
+ border: 1px solid #9b7651;
+ border-radius: 6px;
+ background: linear-gradient(#1d1b18, #0b0b0a);
+ box-shadow: 0 24px 70px #000;
+ font-family: var(--mono);
+ }
+
+ dialog::backdrop { background: #000b; }
+ dialog h2 { color: #e0be87; font-size: 16px; font-weight: 500; }
+ dialog p { color: #bdb5a8; font-size: 13px; line-height: 1.45; }
+ dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; }
+
+ .delete-confirm {
+ color: #f0dad3;
+ border-color: #7c3327;
+ background: #592018;
+ }
+
+ .inspection-note {
+ margin: 13px 2px 0;
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ color: #7f796f;
+ font-size: 11px;
+ line-height: 1.5;
+ }
+
+ .inspection-note span:last-child { text-align: right; }
+
+ @media (max-width: 980px) {
+ .workbench { padding: 10px 8px 20px; }
+ .prototype-bar { align-items: flex-start; flex-direction: column; }
+ .fixture-switch, .bench-controls { justify-content: flex-start; }
+ .bench-readout { text-align: left; }
+ }
+</style>
+</head>
+<body>
+<main class="workbench">
+ <header class="prototype-bar">
+ <div>
+ <h1>Music Config UI Remodel · Functional Prototype 1</h1>
+ <p>Drive the receiver. The controls outside it only switch fixtures and run the review benchmark.</p>
+ </div>
+ <div class="fixture-switch" aria-label="Prototype fixtures">
+ <button class="lab-button" id="localFixture" aria-pressed="true">Local playlist</button>
+ <button class="lab-button" id="radioFixture" aria-pressed="false">Radio station</button>
+ <button class="lab-button" id="restoreFixture">Restore fixture</button>
+ </div>
+ <div class="bench-controls">
+ <button class="lab-button" id="benchmarkButton">Run 60s benchmark</button>
+ <span class="bench-readout" id="benchmarkReadout">Dynamic updates: awaiting benchmark</span>
+ </div>
+ </header>
+
+ <section class="receiver-wrap" aria-label="Dupre Studios receiver prototype">
+ <div class="receiver" id="receiver">
+ <div class="upper-live" id="upperLive">
+ <div class="art" id="art"><div class="album-crop" aria-label="69 Love Songs album art"></div></div>
+ <div class="metadata" id="metadata"></div>
+ <div class="on-air-under-art" id="onAirUnderArt" hidden>ON AIR</div>
+ <div class="seek-line" id="seekLine">
+ <span class="seek-time" id="elapsed">01:30</span>
+ <div class="seek-track" id="seekTrack" role="slider" tabindex="0" aria-label="Track position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
+ <div class="seek-fill"></div>
+ <div class="seek-thumb"></div>
+ </div>
+ <span class="seek-time" id="duration">02:59</span>
+ </div>
+ </div>
+
+ <div class="playlist-header" id="playlistHeader">
+ <span class="header-name" id="headerName">69 LOVE SONGS — COMPLETE</span>
+ <span id="headerCount">69 TRACKS</span>
+ </div>
+
+ <div class="playlist-live">
+ <div class="playlist-scroll" id="playlistScroll" tabindex="0" aria-label="Playlist rows">
+ <div class="playlist-rows" id="playlistRows"></div>
+ </div>
+ <div class="scroll-rail" id="scrollRail" hidden><div class="scroll-thumb" id="scrollThumb"></div></div>
+ </div>
+
+ <svg class="dynamic-svg" viewBox="0 0 1916 821" aria-hidden="true">
+ <defs>
+ <radialGradient id="meterGlow" cx="50%" cy="100%" r="65%">
+ <stop offset="0" stop-color="#ffc55b" stop-opacity=".75"/>
+ <stop offset=".32" stop-color="#8c5b16" stop-opacity=".18"/>
+ <stop offset="1" stop-color="#000" stop-opacity="0"/>
+ </radialGradient>
+ <linearGradient id="meterFrame" x1="0" x2="1">
+ <stop offset="0" stop-color="#5b4325"/>
+ <stop offset=".18" stop-color="#e2c08a"/>
+ <stop offset=".5" stop-color="#7c5a32"/>
+ <stop offset=".82" stop-color="#ddba82"/>
+ <stop offset="1" stop-color="#4a321c"/>
+ </linearGradient>
+ <linearGradient id="faceplateMask" x1="0" y1="0" x2="1" y2="1">
+ <stop offset="0" stop-color="#c3a477"/>
+ <stop offset=".52" stop-color="#b9996d"/>
+ <stop offset="1" stop-color="#c5a77b"/>
+ </linearGradient>
+ <linearGradient id="champagneFace" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0" stop-color="#c8b89f"/>
+ <stop offset=".18" stop-color="#b9a489"/>
+ <stop offset=".56" stop-color="#b19a7c"/>
+ <stop offset="1" stop-color="#a68e71"/>
+ </linearGradient>
+ <linearGradient id="glassLip" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0" stop-color="#050606" stop-opacity=".96"/>
+ <stop offset=".45" stop-color="#111311"/>
+ <stop offset=".76" stop-color="#030404"/>
+ <stop offset="1" stop-color="#2a2118"/>
+ </linearGradient>
+ <pattern id="microBrush" width="5" height="4" patternUnits="userSpaceOnUse">
+ <path d="M0 .5 H5" stroke="#fff5df" stroke-opacity=".1" stroke-width=".55"/>
+ <path d="M0 3.5 H5" stroke="#57452f" stroke-opacity=".08" stroke-width=".45"/>
+ </pattern>
+ <clipPath id="volumeKnobClip">
+ <circle cx="972" cy="551" r="98"/>
+ </clipPath>
+ <radialGradient id="volumeLamp" cx="36%" cy="28%" r="70%">
+ <stop offset="0" stop-color="#fff8d7"/>
+ <stop offset=".24" stop-color="#ffd27d"/>
+ <stop offset=".72" stop-color="#f1a02e"/>
+ <stop offset="1" stop-color="#b96513"/>
+ </radialGradient>
+ </defs>
+ <g id="faceplateSurface">
+ <rect x="69" y="366" width="1078" height="31" rx="19" fill="url(#glassLip)"/>
+ <path d="M88 369 H1128 Q1143 369 1147 385" fill="none" stroke="#353735" stroke-opacity=".8" stroke-width="2"/>
+ <rect x="69" y="379" width="1078" height="367" rx="19" fill="url(#champagneFace)"/>
+ <rect x="69" y="379" width="1078" height="367" rx="19" fill="url(#microBrush)"/>
+ <path d="M88 379 H1128 Q1142 379 1147 395" fill="none" stroke="#f0d09c" stroke-width="2.2"/>
+ <rect x="73" y="383" width="1070" height="359" rx="16" fill="none" stroke="#d2b584" stroke-width="1.5"/>
+ <rect x="77" y="387" width="1062" height="351" rx="14" fill="none" stroke="#715438" stroke-opacity=".72" stroke-width="1.4"/>
+ <line x1="641" y1="578" x2="817" y2="578" stroke="#705438" stroke-width="1.2"/>
+ <g fill="#261d16" font-family="Berkeley Mono, monospace" text-anchor="middle" letter-spacing=".5">
+ <text x="729" y="422" font-size="17">PLAY MODES</text>
+ <text x="684" y="492" font-size="15">REPEAT</text>
+ <text x="770" y="492" font-size="15">SINGLE</text>
+ <text x="684" y="563" font-size="15">RANDOM</text>
+ <text x="770" y="563" font-size="15">CONSUME</text>
+ <text x="729" y="608" font-size="16">RADIO SEARCH</text>
+ <text x="684" y="686" font-size="15">NAME</text>
+ <text x="770" y="686" font-size="15">TAGS</text>
+ <text x="162" y="690" font-size="16">PREV</text>
+ <text x="285" y="690" font-size="16">PLAY/PAUSE</text>
+ <text x="408" y="690" font-size="16">STOP</text>
+ <text x="531" y="690" font-size="16">NEXT</text>
+ <text x="972" y="691" font-size="17">VOLUME</text>
+ </g>
+ <image href="../concepts/30a-dupre-studios-user-refined-playlist.png"
+ x="0" y="0" width="1916" height="821" clip-path="url(#volumeKnobClip)"/>
+ </g>
+ <g id="meters"></g>
+ <g id="volumeMask"></g>
+ <g id="volumeSegments"></g>
+ </svg>
+
+ <div class="transport-bank" aria-label="Transport controls">
+ <button class="transport-button" data-transport="prev" aria-label="Previous">|◀</button>
+ <button class="transport-button" data-transport="play" aria-label="Play or pause">▶Ⅱ</button>
+ <button class="transport-button" data-transport="stop" aria-label="Stop">■</button>
+ <button class="transport-button" data-transport="next" aria-label="Next">▶|</button>
+ </div>
+
+ <button class="mode-button mode-repeat" data-mode="repeat" aria-label="Repeat playlist" aria-pressed="false"></button>
+ <button class="mode-button mode-single" data-mode="single" aria-label="Repeat one track" aria-pressed="false"></button>
+ <button class="mode-button mode-random" data-mode="random" aria-label="Random playback" aria-pressed="false"></button>
+ <button class="mode-button mode-consume" data-mode="consume" aria-label="Consume tracks" aria-pressed="false"></button>
+ <button class="radio-button radio-name" data-radio="name" aria-label="Search radio stations by name"></button>
+ <button class="radio-button radio-tags" data-radio="tags" aria-label="Search radio stations by tag"></button>
+ <div class="volume-hit" id="volumeHit" role="slider" tabindex="0" aria-label="Player volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="68"></div>
+
+ <div class="playlist-actions" aria-label="Playlist actions">
+ <button class="playlist-action" data-action="add">ADD</button>
+ <button class="playlist-action" data-action="new">NEW</button>
+ <button class="playlist-action" data-action="load">LOAD</button>
+ <button class="playlist-action" data-action="save">SAVE</button>
+ <button class="playlist-action" data-action="delete">DELETE</button>
+ </div>
+
+ <div class="status-toast" id="statusToast" role="status" aria-live="polite"></div>
+ </div>
+ </section>
+
+ <div class="inspection-note">
+ <span>Keyboard: Space play/pause · arrows select · Page Up/Down browse · Home/End · R/S/X/C modes. Prototype VU motion is synthetic; production reads mpv RMS.</span>
+ <span id="interactionStatus">Local fixture · playing · volume 68</span>
+ </div>
+</main>
+
+<dialog id="deleteDialog">
+ <form method="dialog">
+ <h2>Delete this playlist?</h2>
+ <p id="deletePrompt">This removes the saved playlist. Playback stops and the receiver returns to an untitled empty queue.</p>
+ <menu>
+ <button class="lab-button" value="cancel">Cancel</button>
+ <button class="lab-button delete-confirm" value="confirm">Delete playlist</button>
+ </menu>
+ </form>
+</dialog>
+
+<script>
+(() => {
+ "use strict";
+
+ const BASE_ROW_HEIGHT = 37;
+ const VISIBLE_ROWS = 14;
+ const localSeed = [
+ ["Absolutely Cuckoo", "The Magnetic Fields", "02:02"],
+ ["I Don’t Believe in the Sun", "The Magnetic Fields", "02:50"],
+ ["All My Little Words", "The Magnetic Fields", "02:03"],
+ ["A Chicken With Its Head Cut Off — Remastered Anniversary Edition", "The Magnetic Fields and the Long-Lost Orchestra", "02:59"],
+ ["(I’m) Dazota", "The Magnetic Fields", "02:38"],
+ ["I Don’t Want to Get Over You", "The Magnetic Fields", "02:50"],
+ ["Come Back From San Francisco", "The Magnetic Fields", "02:37"],
+ ["The Luckiest Guy on the Lower East Side", "The Magnetic Fields", "01:32"],
+ ["Let’s Pretend We’re Bunny Rabbits", "The Magnetic Fields", "02:10"],
+ ["The Cactus Where Your Heart Should Be", "The Magnetic Fields", "02:38"],
+ ["I Think I Need a New Heart", "The Magnetic Fields", "02:58"],
+ ["The Book of Love", "The Magnetic Fields", "02:56"],
+ ["Fido, Your Leash Is Too Long", "The Magnetic Fields", "02:13"],
+ ["How Fucking Romantic", "The Magnetic Fields", "00:58"],
+ ["The One You Really Love", "The Magnetic Fields", "02:53"],
+ ["Punk Love", "The Magnetic Fields", "00:58"],
+ ["Parades Go By", "The Magnetic Fields", "02:56"],
+ ["Boa Constrictor", "The Magnetic Fields", "00:58"],
+ ["A Pretty Girl Is Like…", "The Magnetic Fields", "01:50"],
+ ["My Sentimental Melody", "The Magnetic Fields", "03:07"]
+ ];
+
+ const fillerTitles = [
+ "Nothing Matters When We’re Dancing", "Sweet-Lovin’ Man", "The Things We Did and Didn’t Do",
+ "Roses", "Love Is Like Jazz", "When My Boy Walks Down the Street", "Time Enough for Rocking",
+ "Very Funny", "Grand Canyon", "No One Will Ever Love You", "If You Don’t Cry",
+ "You’re My Only Home", "Washington, D.C.", "Long-Forgotten Fairytale"
+ ];
+
+ const radioSeed = [
+ ["Groove Salad", "SomaFM · Ambient / Downtempo", "ON AIR"],
+ ["Drone Zone", "SomaFM · Atmospheric Textures", "256k"],
+ ["Secret Agent", "SomaFM · Cinematic", "256k"],
+ ["Illinois Street Lounge", "SomaFM · Lounge", "256k"],
+ ["Space Station Soma", "SomaFM · Space Music", "256k"],
+ ["Left Coast 70s", "SomaFM · Mellow Rock", "256k"],
+ ["The Trip", "SomaFM · Progressive House", "256k"],
+ ["Underground 80s", "SomaFM · Synthpop", "256k"],
+ ["Deep Space One", "SomaFM · Ambient", "256k"],
+ ["Black Rock FM", "SomaFM · Eclectic", "256k"],
+ ["Bossa Beyond", "SomaFM · Brazilian", "256k"],
+ ["Seven Inch Soul", "SomaFM · Vintage Soul", "256k"],
+ ["Suburbs of Goa", "SomaFM · South Asian", "256k"],
+ ["Boot Liquor", "SomaFM · Americana", "256k"],
+ ["Heavyweight Reggae", "SomaFM · Roots Reggae", "256k"],
+ ["Lush", "SomaFM · Female Vocals", "256k"],
+ ["Beat Blender", "SomaFM · Electronic", "256k"],
+ ["Digitalis", "SomaFM · Indie Electronic", "256k"]
+ ];
+
+ const makeLocalTracks = () => Array.from({length: 69}, (_, index) => {
+ const base = localSeed[index] || [
+ fillerTitles[index % fillerTitles.length],
+ index % 7 === 0 ? "The Magnetic Fields with an Improbably Long Guest Credit" : "The Magnetic Fields",
+ `${String(1 + (index % 3)).padStart(2, "0")}:${String((17 * index) % 60).padStart(2, "0")}`
+ ];
+ return {id: `local-${index}`, title: base[0], artist: base[1], meta: base[2]};
+ });
+
+ const makeRadioTracks = () => radioSeed.map((row, index) => ({
+ id: `radio-${index}`, title: row[0], artist: row[1], meta: row[2]
+ }));
+
+ const fixtures = {
+ local: {
+ name: "69 Love Songs — The Complete Three-Volume Collection",
+ kind: "PLAYLIST",
+ tracks: makeLocalTracks,
+ selected: 3,
+ position: 90,
+ duration: 179,
+ metadata: [
+ "A Chicken With Its Head Cut Off — Remastered Anniversary Edition",
+ "The Magnetic Fields and the Long-Lost Orchestra",
+ "69 Love Songs: The Complete Three-Volume Collection",
+ "Merge Records",
+ "1999"
+ ]
+ },
+ radio: {
+ name: "Name Search · Ambient",
+ kind: "RADIO",
+ tracks: makeRadioTracks,
+ selected: 0,
+ position: 0,
+ duration: 0,
+ metadata: ["Groove Salad", "SomaFM", "Ambient / Downtempo", "256 kbps"]
+ }
+ };
+
+ const state = {
+ fixture: "local",
+ playlistName: fixtures.local.name,
+ tracks: fixtures.local.tracks(),
+ selected: fixtures.local.selected,
+ position: fixtures.local.position,
+ duration: fixtures.local.duration,
+ playing: true,
+ volume: 68,
+ modes: {repeat: false, single: false, random: false, consume: false},
+ search: null,
+ vu: {left: -20, right: -20},
+ benchmark: null
+ };
+
+ const $ = selector => document.querySelector(selector);
+ const $$ = selector => [...document.querySelectorAll(selector)];
+ const receiver = $("#receiver");
+ const rows = $("#playlistRows");
+ const scroll = $("#playlistScroll");
+ const rail = $("#scrollRail");
+ const thumb = $("#scrollThumb");
+ const seekTrack = $("#seekTrack");
+ const volumeHit = $("#volumeHit");
+ const toast = $("#statusToast");
+ const deleteDialog = $("#deleteDialog");
+ let toastTimer = null;
+ let seekDragging = false;
+ let volumeDragging = false;
+ let thumbDragging = false;
+ let thumbGrabOffset = 0;
+ let lastMeterTime = performance.now();
+
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
+ const formatTime = seconds => {
+ const value = Math.max(0, Math.round(seconds));
+ return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`;
+ };
+
+ function announce(message) {
+ clearTimeout(toastTimer);
+ toast.textContent = message;
+ toast.classList.add("show");
+ toastTimer = setTimeout(() => toast.classList.remove("show"), 1800);
+ updateInspection(message);
+ }
+
+ function currentTrack() { return state.tracks[state.selected] || null; }
+
+ function switchFixture(name, message = null) {
+ const source = fixtures[name];
+ state.fixture = name;
+ state.playlistName = source.name;
+ state.tracks = source.tracks();
+ state.selected = source.selected;
+ state.position = source.position;
+ state.duration = source.duration;
+ state.playing = true;
+ state.search = null;
+ scroll.scrollTop = 0;
+ renderAll();
+ announce(message || `${name === "local" ? "Local playlist" : "Radio station"} fixture loaded`);
+ }
+
+ function renderInfo() {
+ const source = fixtures[state.fixture];
+ const track = currentTrack();
+ const metadata = track ? (state.fixture === "local" ? [
+ track.title,
+ track.artist,
+ source.metadata[2],
+ source.metadata[3],
+ source.metadata[4]
+ ] : [track.title, "SomaFM", track.artist.replace(/^SomaFM · /, ""), "256 kbps"]) : [];
+
+ $("#metadata").innerHTML = metadata.filter(Boolean).map((line, index) =>
+ `<div class="metadata-line ${index ? "dim" : ""}" title="${escapeHtml(line)}">${escapeHtml(line)}</div>`
+ ).join("");
+
+ $("#art").innerHTML = state.fixture === "radio"
+ ? '<img src="../../../assets/vinyl-placeholder.svg" alt="Radio station artwork unavailable; vinyl placeholder">'
+ : '<div class="album-crop" role="img" aria-label="69 Love Songs album art"></div>';
+
+ const onAir = state.fixture === "radio" && Boolean(track);
+ $("#onAirUnderArt").hidden = !onAir;
+ $("#seekLine").hidden = onAir || !track;
+ if (track && !onAir) updateSeekVisual();
+ }
+
+ function escapeHtml(value) {
+ return String(value).replace(/[&<>"']/g, character => ({
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
+ })[character]);
+ }
+
+ function renderHeader() {
+ $("#headerName").textContent = state.playlistName || "UNTITLED";
+ $("#headerName").title = state.playlistName || "UNTITLED";
+ const noun = state.fixture === "radio" ? (state.tracks.length === 1 ? "STATION" : "STATIONS") : (state.tracks.length === 1 ? "TRACK" : "TRACKS");
+ $("#headerCount").textContent = `${state.tracks.length} ${noun}`;
+ }
+
+ function renderRows() {
+ const rowHeight = scroll.clientHeight / VISIBLE_ROWS;
+ rows.style.setProperty("--row-height", `${rowHeight}px`);
+ if (!state.tracks.length) {
+ rows.innerHTML = Array.from({length: VISIBLE_ROWS}, () => '<div class="empty-row"></div>').join("");
+ rows.style.height = `${scroll.clientHeight}px`;
+ rail.hidden = true;
+ return;
+ }
+ rows.style.height = `${rowHeight * Math.max(VISIBLE_ROWS, state.tracks.length)}px`;
+ rows.innerHTML = state.tracks.map((track, index) => `
+ <div class="playlist-row ${index === state.selected ? "selected" : ""}" data-row="${index}" title="${escapeHtml(`${track.title} — ${track.artist}`)}">
+ <span class="row-number">${String(index + 1).padStart(2, "0")}</span>
+ <span class="row-name">${escapeHtml(track.title)} — ${escapeHtml(track.artist)}</span>
+ <span class="row-meta">${state.fixture === "radio" && index === state.selected && state.playing ? "ON AIR" : escapeHtml(track.meta)}</span>
+ </div>`).join("");
+ $$("[data-row]").forEach(row => row.addEventListener("click", () => selectTrack(Number(row.dataset.row), true)));
+ requestAnimationFrame(updateScrollbar);
+ }
+
+ function renderControls() {
+ const hasTrack = Boolean(currentTrack());
+ const play = $('[data-transport="play"]');
+ play.classList.toggle("playing", state.playing && hasTrack);
+ play.disabled = !hasTrack;
+ $('[data-transport="prev"]').disabled = !hasTrack;
+ $('[data-transport="next"]').disabled = !hasTrack;
+ $('[data-transport="stop"]').disabled = !hasTrack;
+ $$("[data-mode]").forEach(button => button.setAttribute("aria-pressed", String(state.modes[button.dataset.mode])));
+ $("#localFixture").setAttribute("aria-pressed", String(state.fixture === "local"));
+ $("#radioFixture").setAttribute("aria-pressed", String(state.fixture === "radio"));
+ volumeHit.setAttribute("aria-valuenow", String(state.volume));
+ updateVolumeSegments();
+ updateInspection();
+ }
+
+ function renderAll() {
+ const started = performance.now();
+ renderInfo();
+ renderHeader();
+ renderRows();
+ renderControls();
+ recordRender(performance.now() - started);
+ }
+
+ function updateSeekVisual() {
+ const percentage = state.duration ? clamp((state.position / state.duration) * 100, 0, 100) : 0;
+ seekTrack.style.setProperty("--progress", percentage.toFixed(3));
+ seekTrack.setAttribute("aria-valuenow", String(Math.round(percentage)));
+ $("#elapsed").textContent = formatTime(state.position);
+ $("#duration").textContent = formatTime(state.duration);
+ }
+
+ function seekFromPointer(event) {
+ if (state.fixture !== "local" || !currentTrack()) return;
+ const bounds = seekTrack.getBoundingClientRect();
+ const fraction = clamp((event.clientX - bounds.left) / bounds.width, 0, 1);
+ state.position = state.duration * fraction;
+ updateSeekVisual();
+ updateInspection(`Seek ${formatTime(state.position)} / ${formatTime(state.duration)}`);
+ }
+
+ function selectTrack(index, play = false) {
+ if (!state.tracks.length) return;
+ state.selected = clamp(index, 0, state.tracks.length - 1);
+ state.position = 0;
+ if (play) state.playing = true;
+ renderInfo();
+ renderRows();
+ renderControls();
+ ensureSelectedVisible();
+ }
+
+ function ensureSelectedVisible() {
+ const row = rows.children[state.selected];
+ if (row) row.scrollIntoView({block: "nearest"});
+ }
+
+ function moveTrack(delta) {
+ if (!state.tracks.length) return;
+ const next = (state.selected + delta + state.tracks.length) % state.tracks.length;
+ selectTrack(next, true);
+ announce(`${delta < 0 ? "Previous" : "Next"}: ${currentTrack().title}`);
+ }
+
+ function togglePlay() {
+ if (!currentTrack()) return;
+ state.playing = !state.playing;
+ renderRows();
+ renderControls();
+ announce(state.playing ? "Playback resumed" : "Playback paused");
+ }
+
+ function setVolume(value, message = true) {
+ state.volume = Math.round(clamp(value, 0, 100));
+ renderControls();
+ if (message) updateInspection(`Player volume ${state.volume}`);
+ }
+
+ function volumeFromPointer(event) {
+ const bounds = volumeHit.getBoundingClientRect();
+ const x = event.clientX - (bounds.left + bounds.width / 2);
+ const y = event.clientY - (bounds.top + bounds.height / 2);
+ let angle = Math.atan2(y, x) * 180 / Math.PI;
+ if (angle < 150) angle += 360;
+ setVolume(((clamp(angle, 150, 390) - 150) / 240) * 100, false);
+ }
+
+ function updateVolumeSegments() {
+ const points = [
+ [885, 630], [871, 608], [861, 583], [857, 557], [857, 531], [864, 507],
+ [875, 484], [890, 464], [906, 448], [924, 440], [947, 432], [972, 431],
+ [996, 433], [1018, 440], [1038, 452], [1055, 467], [1068, 486], [1079, 507],
+ [1086, 532], [1087, 557], [1085, 582], [1078, 608], [1059, 628]
+ ];
+ const count = points.length;
+ const lit = Math.round((state.volume / 100) * (count - 1));
+ $("#volumeMask").innerHTML = points.map(([x, y]) =>
+ `<circle cx="${x}" cy="${y}" r="5.3" fill="#0d0e0c" stroke="#655039" stroke-width=".8"/>`
+ ).join("");
+ const circles = points.map(([x, y], index) => {
+ const active = index <= lit;
+ if (active) {
+ return `<g><circle cx="${x}" cy="${y}" r="10" fill="#ff9d24" fill-opacity=".28"/><circle cx="${x}" cy="${y}" r="7" fill="#ffad32" fill-opacity=".22"/><circle cx="${x}" cy="${y}" r="4.8" fill="url(#volumeLamp)" stroke="#fff1c2" stroke-width=".85"/></g>`;
+ }
+ return "";
+ }).join("");
+ $("#volumeSegments").innerHTML = circles;
+ }
+
+ function meterMarkup(x, id) {
+ const labels = ["-40", "-20", "-10", "-6", "-3", "0", "+3"];
+ const ticks = Array.from({length: 19}, (_, index) => {
+ const angle = -56 + index * (112 / 18);
+ const major = index % 3 === 0;
+ const radians = angle * Math.PI / 180;
+ const x1 = 115 + Math.sin(radians) * (major ? 91 : 95);
+ const y1 = 131 - Math.cos(radians) * (major ? 91 : 95);
+ const x2 = 115 + Math.sin(radians) * 102;
+ const y2 = 131 - Math.cos(radians) * 102;
+ return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${index > 14 ? "#d43b25" : "#d7c48d"}" stroke-width="${major ? 1.7 : 1}"/>`;
+ }).join("");
+ const labelText = labels.map((label, index) => {
+ const angle = -52 + index * (104 / (labels.length - 1));
+ const radians = angle * Math.PI / 180;
+ const lx = 115 + Math.sin(radians) * 72;
+ const ly = 131 - Math.cos(radians) * 72;
+ return `<text x="${lx}" y="${ly}" text-anchor="middle" fill="${index > 4 ? "#de442f" : "#ddd0aa"}" font-family="Berkeley Mono,monospace" font-size="10">${label}</text>`;
+ }).join("");
+ return `<g transform="translate(${x} 432)">
+ <rect x="0" y="0" width="230" height="113" rx="7" fill="url(#meterFrame)"/>
+ <rect x="4" y="4" width="222" height="105" rx="5" fill="#050605" stroke="#1b1812" stroke-width="2"/>
+ <rect x="5" y="5" width="220" height="103" rx="5" fill="url(#meterGlow)"/>
+ ${ticks}${labelText}
+ <text x="115" y="91" text-anchor="middle" fill="#e7d7ae" font-family="Berkeley Mono,monospace" font-size="18">VU</text>
+ <circle cx="115" cy="111" r="17" fill="url(#meterGlow)" opacity=".9"/>
+ <line id="needle-${id}" x1="115" y1="105" x2="115" y2="27" stroke="#f3d59c" stroke-width="2.2" transform="rotate(0 115 105)"/>
+ <circle cx="115" cy="105" r="3.2" fill="#c7a56f"/>
+ </g>`;
+ }
+
+ function initMeters() {
+ $("#meters").innerHTML = meterMarkup(112, "left") + meterMarkup(365, "right");
+ }
+
+ function vuAngle(db) {
+ const normalized = clamp((db + 40) / 43, 0, 1);
+ return -54 + normalized * 108;
+ }
+
+ function updateMeters(timestamp) {
+ const started = performance.now();
+ const elapsed = Math.min(.05, (timestamp - lastMeterTime) / 1000);
+ lastMeterTime = timestamp;
+ const signal = state.playing && currentTrack();
+ const base = timestamp / 350;
+ const targets = signal ? {
+ left: -13 + Math.sin(base * 1.13) * 5 + Math.sin(base * 2.31) * 2,
+ right: -14 + Math.sin(base * .97 + 1.1) * 5.5 + Math.sin(base * 2.08) * 2
+ } : {left: -40, right: -40};
+ for (const side of ["left", "right"]) {
+ const target = targets[side];
+ const speed = target > state.vu[side] ? 7.2 : 2.4;
+ state.vu[side] += (target - state.vu[side]) * Math.min(1, elapsed * speed);
+ $(`#needle-${side}`).setAttribute("transform", `rotate(${vuAngle(state.vu[side]).toFixed(2)} 115 105)`);
+ }
+ recordRender(performance.now() - started);
+ requestAnimationFrame(updateMeters);
+ }
+
+ function updateScrollbar() {
+ const overflow = scroll.scrollHeight - scroll.clientHeight;
+ rail.hidden = overflow <= 1;
+ if (overflow <= 1) return;
+ const railHeight = rail.clientHeight;
+ const ratio = scroll.clientHeight / scroll.scrollHeight;
+ const thumbHeight = Math.max(38, railHeight * ratio);
+ const travel = railHeight - thumbHeight;
+ thumb.style.height = `${thumbHeight}px`;
+ thumb.style.top = `${travel * (scroll.scrollTop / overflow)}px`;
+ }
+
+ function updateInspection(message = null) {
+ const label = state.fixture === "radio" ? "Radio fixture" : "Local fixture";
+ const play = state.playing ? "playing" : "paused";
+ $("#interactionStatus").textContent = message || `${label} · ${play} · volume ${state.volume}`;
+ }
+
+ function handlePlaylistAction(action) {
+ if (action === "add") {
+ const number = state.tracks.length + 1;
+ state.tracks.push({id: `added-${Date.now()}`, title: "Newly Added Track With a Deliberately Long Display Name", artist: "Prototype Library", meta: "04:12"});
+ state.selected = number - 1;
+ renderAll();
+ ensureSelectedVisible();
+ announce(`Added track ${number}`);
+ } else if (action === "new") {
+ state.playlistName = "UNTITLED";
+ state.tracks = [];
+ state.selected = 0;
+ state.playing = false;
+ renderAll();
+ announce("New empty playlist");
+ } else if (action === "load") {
+ switchFixture(state.fixture, `Loaded ${fixtures[state.fixture].name}`);
+ } else if (action === "save") {
+ announce(`Saved ${state.playlistName || "UNTITLED"} · ${state.tracks.length} tracks`);
+ } else if (action === "delete") {
+ $("#deletePrompt").textContent = `Delete “${state.playlistName || "UNTITLED"}”? This removes the saved playlist and leaves an untitled empty queue.`;
+ deleteDialog.showModal();
+ }
+ }
+
+ function handleTransport(action) {
+ if (action === "prev") moveTrack(-1);
+ if (action === "next") moveTrack(1);
+ if (action === "play") togglePlay();
+ if (action === "stop") {
+ state.playing = false;
+ state.position = 0;
+ renderRows();
+ renderControls();
+ if (state.fixture === "local") updateSeekVisual();
+ announce("Playback stopped");
+ }
+ }
+
+ function radioSearch(kind) {
+ const label = kind === "name" ? "Name search · Groove" : "Tag search · Ambient";
+ if (state.fixture !== "radio") switchFixture("radio", label);
+ state.search = kind;
+ state.playlistName = label;
+ renderHeader();
+ announce(label);
+ }
+
+ function recordRender(value) {
+ if (!state.benchmark) return;
+ state.benchmark.samples.push(value);
+ }
+
+ function percentile(sorted, fraction) {
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] || 0;
+ }
+
+ function updateBenchmarkReadout() {
+ if (!state.benchmark) return;
+ const elapsed = performance.now() - state.benchmark.started;
+ const remaining = Math.max(0, state.benchmark.duration - elapsed);
+ const sorted = [...state.benchmark.samples].sort((a, b) => a - b);
+ const median = percentile(sorted, .5);
+ const p95 = percentile(sorted, .95);
+ $("#benchmarkReadout").textContent = remaining > 0
+ ? `Benchmark ${Math.ceil(remaining / 1000)}s · median ${median.toFixed(2)} ms · p95 ${p95.toFixed(2)} ms`
+ : `Complete · ${sorted.length} updates · median ${median.toFixed(2)} ms · p95 ${p95.toFixed(2)} ms`;
+ if (remaining > 0) setTimeout(updateBenchmarkReadout, 250);
+ else {
+ state.benchmark.complete = true;
+ $("#benchmarkButton").disabled = false;
+ document.documentElement.dataset.benchmark = JSON.stringify({
+ samples: sorted.length,
+ medianMs: Number(median.toFixed(3)),
+ p95Ms: Number(p95.toFixed(3)),
+ durationMs: state.benchmark.duration,
+ viewport: `${Math.round(receiver.getBoundingClientRect().width)}x${Math.round(receiver.getBoundingClientRect().height)}`
+ });
+ announce("60-second dynamic-update benchmark complete");
+ }
+ }
+
+ function runBenchmark(duration = 60000) {
+ if (state.benchmark && !state.benchmark.complete) return;
+ state.benchmark = {started: performance.now(), duration, samples: [], complete: false};
+ $("#benchmarkButton").disabled = true;
+ updateBenchmarkReadout();
+ }
+
+ function bindEvents() {
+ $("#localFixture").addEventListener("click", () => switchFixture("local"));
+ $("#radioFixture").addEventListener("click", () => switchFixture("radio"));
+ $("#restoreFixture").addEventListener("click", () => switchFixture(state.fixture));
+ $("#benchmarkButton").addEventListener("click", () => runBenchmark());
+
+ $$("[data-transport]").forEach(button => button.addEventListener("click", () => handleTransport(button.dataset.transport)));
+ $$("[data-mode]").forEach(button => button.addEventListener("click", () => {
+ const mode = button.dataset.mode;
+ state.modes[mode] = !state.modes[mode];
+ renderControls();
+ announce(`${mode.toUpperCase()} ${state.modes[mode] ? "enabled" : "disabled"}`);
+ }));
+ $$("[data-radio]").forEach(button => button.addEventListener("click", () => radioSearch(button.dataset.radio)));
+ $$("[data-action]").forEach(button => button.addEventListener("click", () => handlePlaylistAction(button.dataset.action)));
+
+ seekTrack.addEventListener("pointerdown", event => {
+ seekDragging = true;
+ seekTrack.setPointerCapture(event.pointerId);
+ seekFromPointer(event);
+ });
+ seekTrack.addEventListener("pointermove", event => { if (seekDragging) seekFromPointer(event); });
+ seekTrack.addEventListener("pointerup", event => {
+ seekDragging = false;
+ seekTrack.releasePointerCapture(event.pointerId);
+ announce(`Seeked to ${formatTime(state.position)}`);
+ });
+ seekTrack.addEventListener("keydown", event => {
+ if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
+ event.preventDefault();
+ state.position = clamp(state.position + (event.key === "ArrowRight" ? 5 : -5), 0, state.duration);
+ updateSeekVisual();
+ });
+
+ volumeHit.addEventListener("pointerdown", event => {
+ volumeDragging = true;
+ volumeHit.setPointerCapture(event.pointerId);
+ volumeFromPointer(event);
+ });
+ volumeHit.addEventListener("pointermove", event => { if (volumeDragging) volumeFromPointer(event); });
+ volumeHit.addEventListener("pointerup", event => {
+ volumeDragging = false;
+ volumeHit.releasePointerCapture(event.pointerId);
+ announce(`Player volume ${state.volume}`);
+ });
+ volumeHit.addEventListener("wheel", event => {
+ event.preventDefault();
+ setVolume(state.volume + (event.deltaY < 0 ? 5 : -5));
+ }, {passive: false});
+ volumeHit.addEventListener("keydown", event => {
+ if (!["ArrowLeft", "ArrowDown", "ArrowRight", "ArrowUp"].includes(event.key)) return;
+ event.preventDefault();
+ setVolume(state.volume + (["ArrowRight", "ArrowUp"].includes(event.key) ? 5 : -5));
+ });
+
+ scroll.addEventListener("scroll", updateScrollbar);
+ thumb.addEventListener("pointerdown", event => {
+ thumbDragging = true;
+ thumbGrabOffset = event.clientY - thumb.getBoundingClientRect().top;
+ thumb.setPointerCapture(event.pointerId);
+ });
+ thumb.addEventListener("pointermove", event => {
+ if (!thumbDragging) return;
+ const railBox = rail.getBoundingClientRect();
+ const thumbHeight = thumb.getBoundingClientRect().height;
+ const travel = railBox.height - thumbHeight;
+ const top = clamp(event.clientY - railBox.top - thumbGrabOffset, 0, travel);
+ scroll.scrollTop = (top / travel) * (scroll.scrollHeight - scroll.clientHeight);
+ });
+ thumb.addEventListener("pointerup", event => {
+ thumbDragging = false;
+ thumb.releasePointerCapture(event.pointerId);
+ });
+
+ deleteDialog.addEventListener("close", () => {
+ if (deleteDialog.returnValue !== "confirm") return;
+ const deleted = state.playlistName || "UNTITLED";
+ state.playlistName = "UNTITLED";
+ state.tracks = [];
+ state.selected = 0;
+ state.playing = false;
+ renderAll();
+ announce(`Deleted ${deleted}`);
+ });
+
+ document.addEventListener("keydown", event => {
+ if (deleteDialog.open || event.target.matches("button, input, [role=slider], .playlist-scroll")) return;
+ if (event.code === "Space") { event.preventDefault(); togglePlay(); }
+ else if (event.key === "ArrowDown") { event.preventDefault(); selectTrack(state.selected + 1); ensureSelectedVisible(); }
+ else if (event.key === "ArrowUp") { event.preventDefault(); selectTrack(state.selected - 1); ensureSelectedVisible(); }
+ else if (event.key === "PageDown") { event.preventDefault(); selectTrack(state.selected + VISIBLE_ROWS); ensureSelectedVisible(); }
+ else if (event.key === "PageUp") { event.preventDefault(); selectTrack(state.selected - VISIBLE_ROWS); ensureSelectedVisible(); }
+ else if (event.key === "Home") { event.preventDefault(); selectTrack(0); ensureSelectedVisible(); }
+ else if (event.key === "End") { event.preventDefault(); selectTrack(state.tracks.length - 1); ensureSelectedVisible(); }
+ else if (["r", "s", "x", "c"].includes(event.key.toLowerCase())) {
+ const mode = ({r: "repeat", s: "single", x: "random", c: "consume"})[event.key.toLowerCase()];
+ state.modes[mode] = !state.modes[mode];
+ renderControls();
+ }
+ });
+
+ window.addEventListener("resize", () => {
+ renderRows();
+ updateScrollbar();
+ });
+ }
+
+ function runSelfTest() {
+ const checks = [];
+ const check = (name, condition) => {
+ checks.push({name, passed: Boolean(condition)});
+ if (!condition) throw new Error(`Self-test failed: ${name}`);
+ };
+ try {
+ switchFixture("radio", "Self-test radio fixture");
+ check("radio hides seek", $("#seekLine").hidden);
+ check("radio has two ON AIR indications", !$("#onAirUnderArt").hidden && $(".playlist-row.selected .row-meta")?.textContent === "ON AIR");
+ $('[data-mode="repeat"]').click();
+ check("mode latches", state.modes.repeat);
+ $('[data-transport="next"]').click();
+ check("next selects a station", state.selected === 1);
+ $('[data-action="add"]').click();
+ check("add changes queue", state.tracks.length === 19);
+ $('[data-action="new"]').click();
+ check("new clears queue", state.tracks.length === 0 && state.playlistName === "UNTITLED");
+ $('[data-action="load"]').click();
+ check("load restores queue", state.tracks.length === 18);
+ switchFixture("local", "Self-test local fixture");
+ check("local shows seek", !$("#seekLine").hidden);
+ setVolume(35, false);
+ check("volume state changes", state.volume === 35);
+ state.position = state.duration * .72;
+ updateSeekVisual();
+ check("seek state changes", Math.round(Number(seekTrack.getAttribute("aria-valuenow"))) === 72);
+ scroll.scrollTop = scroll.scrollHeight;
+ updateScrollbar();
+ check("long playlist has scrollbar", !rail.hidden && scroll.scrollTop > 0);
+ check("fourteen rows fit viewport", Math.abs(rows.children[0].getBoundingClientRect().height * VISIBLE_ROWS - scroll.clientHeight) < 2);
+ switchFixture("local", "Self-test restored");
+ document.documentElement.dataset.selftest = JSON.stringify({passed: true, checks});
+ } catch (error) {
+ document.documentElement.dataset.selftest = JSON.stringify({passed: false, error: error.message, checks});
+ console.error(error);
+ }
+ }
+
+ initMeters();
+ bindEvents();
+ renderAll();
+ requestAnimationFrame(updateMeters);
+
+ const params = new URLSearchParams(location.search);
+ if (params.get("state") === "radio") switchFixture("radio");
+ if (params.has("benchmark")) runBenchmark(Number(params.get("benchmark")) || 60000);
+ if (params.has("selftest")) setTimeout(runSelfTest, 50);
+})();
+</script>
+</body>
+</html>
diff --git a/working/music-svg-app-design/references/29-alignment-guide.png b/working/music-svg-app-design/references/29-alignment-guide.png
new file mode 100644
index 00000000..d8da4ebf
--- /dev/null
+++ b/working/music-svg-app-design/references/29-alignment-guide.png
Binary files differ
diff --git a/working/music-svg-app-design/references/dupre-gallery-tall.png b/working/music-svg-app-design/references/dupre-gallery-tall.png
new file mode 100644
index 00000000..0f42c2ae
--- /dev/null
+++ b/working/music-svg-app-design/references/dupre-gallery-tall.png
Binary files differ
diff --git a/working/music-svg-app-design/references/dupre-gallery.png b/working/music-svg-app-design/references/dupre-gallery.png
new file mode 100644
index 00000000..1842fb5a
--- /dev/null
+++ b/working/music-svg-app-design/references/dupre-gallery.png
Binary files differ
diff --git a/working/music-svg-app-design/references/waybar-redesign-tall.png b/working/music-svg-app-design/references/waybar-redesign-tall.png
new file mode 100644
index 00000000..554dc041
--- /dev/null
+++ b/working/music-svg-app-design/references/waybar-redesign-tall.png
Binary files differ
diff --git a/working/music-svg-app-design/references/waybar-redesign.png b/working/music-svg-app-design/references/waybar-redesign.png
new file mode 100644
index 00000000..17e4c307
--- /dev/null
+++ b/working/music-svg-app-design/references/waybar-redesign.png
Binary files differ