aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore7
-rw-r--r--Makefile10
-rw-r--r--docs/specs/keybinding-console-safety-spec.org2
-rw-r--r--modules/dirvish-config.el10
-rw-r--r--modules/elfeed-config.el5
-rw-r--r--modules/org-capture-config.el8
-rw-r--r--modules/org-reveal-config.el148
-rw-r--r--tests/test-dirvish-config--side-key.el40
-rw-r--r--tests/test-keybindings--fullscreen.el45
-rw-r--r--tests/test-makefile-clean-compiled.el121
-rw-r--r--tests/test-org-capture-config-fkey-guard.el15
-rw-r--r--tests/test-org-reveal-config-header-template.el6
-rw-r--r--tests/test-org-reveal-config-sizing.el246
13 files changed, 643 insertions, 20 deletions
diff --git a/.gitignore b/.gitignore
index 023c9f56..c831eac5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,9 +62,10 @@ auto-save-list/
# reveal.js local clone (managed by scripts/setup-reveal.sh)
/reveal.js/
-# Claude tooling — gitignored, not published to the public mirror
+# Agent tooling — gitignored, not published to the public mirror
/.ai/
/.claude/
+/.codex/
/CLAUDE.md
/AGENTS.md
history
@@ -87,8 +88,10 @@ history
# Data directory (calendar sync output, API rest files)
/data/
-# Network Security Manager runtime database (cert fingerprints, written by Emacs)
+# Network Security Manager runtime database (cert fingerprints, written by Emacs;
+# network-security.data before Emacs 31, network-security.eld since)
/network-security.data
+/network-security.eld
# --- elisp ruleset ---
*.eln
diff --git a/Makefile b/Makefile
index fd759f30..920933a7 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@
# make telega-image - Build the telega-server container image
# make profile - Profile Emacs startup performance
# make clean - Remove test artifacts and compiled files
-# make clean-compiled - Remove .elc/.eln files only
+# make clean-compiled - Remove .elc/.eln files only (keeps elpa/)
# make clean-tests - Remove test artifacts only
# make reset - Reset to first launch (destructive!)
@@ -106,7 +106,7 @@ help:
@echo " make telega-image - Build the telega-server container image"
@echo " make profile - Profile Emacs startup performance"
@echo " make clean - Remove test artifacts and compiled files"
- @echo " make clean-compiled - Remove .elc/.eln files only"
+ @echo " make clean-compiled - Remove .elc/.eln files only (keeps elpa/)"
@echo " make clean-tests - Remove test artifacts only"
@echo " make reset - Reset to first launch (DESTRUCTIVE!)"
@echo ""
@@ -494,9 +494,13 @@ task-sorted:
clean: clean-tests clean-compiled
@echo "✓ Clean complete"
+# elpa/ is excluded: package.el owns that bytecode and nothing here rebuilds it.
+# Without it every package loads from source, slower and with no native-comp
+# JIT, and Emacs 31 warns about each file lacking a lexical-binding cookie.
clean-compiled:
@echo "Removing compiled files (.elc, .eln)..."
- @find $(EMACS_HOME) -type f \( -name "*.eln" -o -name "*.elc" \) -delete
+ @find "$(EMACS_HOME)" -type f \( -name "*.eln" -o -name "*.elc" \) \
+ -not -path "$(patsubst %/,%,$(EMACS_HOME))/elpa/*" -delete
@echo "✓ Compiled files removed"
clean-tests:
diff --git a/docs/specs/keybinding-console-safety-spec.org b/docs/specs/keybinding-console-safety-spec.org
index 5fd7d52c..9781b43a 100644
--- a/docs/specs/keybinding-console-safety-spec.org
+++ b/docs/specs/keybinding-console-safety-spec.org
@@ -846,7 +846,7 @@ have large package-managed binding lists not enumerated here.
- [ ] M-D (dirvish-mode-map) — dwim-shell-commands-menu — (dwim-shell-config.el:934)
- [ ] + (dirvish-mode-map) — cj/music-add-dired-selection — (music-config.el:597)
- [ ] T (dired/dirvish-mode-map) — cj/transcribe-media-at-point — (transcription-config.el:463/467)
- - [ ] <f11> (dirvish-mode-map) — dirvish-side — (dirvish-config.el:481)
+ - [ ] <f9> (global) — dirvish-side — (dirvish-config.el:661; F11 is stock toggle-frame-fullscreen again as of 2026-09-22)
- [ ] Shell / terminal
- [ ] C-r (eshell-mode-map) — cj/eshell-history-search — (eshell-config.el:202)
- [ ] <up> (eshell-hist-mode-map) — previous-line — (eshell-config.el:99)
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index 6c849198..e9b9fde6 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -29,7 +29,7 @@
;; - S: Study — start an org-drill session on the .org file at point
;; - M-D (Meta-Shift-d): DWIM shell commands menu
;; - TAB: Toggle subtree expansion
-;; - F11: Toggle sidebar view
+;; - F9: Toggle sidebar view
;;; Code:
@@ -653,7 +653,13 @@ no popup frame is live."
(("C-x d" . dirvish)
("C-x C-d" . dirvish)
("C-x D" . dirvish)
- ("<f11>" . dirvish-side)
+ ;; F9, not F11: F11 is stock Emacs's toggle-frame-fullscreen, the same key
+ ;; every other application uses, and the sidebar sat on it for years by
+ ;; shadowing that. F9 is the free bare F-key: ai-term left it for M-SPC
+ ;; in June 2026 and the recording chord came off it on 2026-09-22. F2 is
+ ;; the preview key (markdown-mode binds it) and F5 is reserved for the
+ ;; debug backend, so neither was a candidate.
+ ("<f9>" . dirvish-side)
:map dirvish-mode-map
("bg" . cj/set-wallpaper)
("/" . dirvish-narrow)
diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el
index f4feef3a..34e90eb1 100644
--- a/modules/elfeed-config.el
+++ b/modules/elfeed-config.el
@@ -28,6 +28,11 @@
(require 'user-constants)
(require 'system-lib)
(require 'media-utils)
+;; elfeed.el ends with a bare `(unless byte-compile-root-dir ...)'. That
+;; variable lives in bytecomp, so loading elfeed (source or .elc) in a
+;; session that hasn't loaded bytecomp signals void-variable. I load it here, ahead of
+;; any elfeed load, rather than rely on something else having pulled it in.
+(require 'bytecomp)
(declare-function elfeed "elfeed")
(declare-function elfeed-update "elfeed")
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
index b7250f1e..8f5903fe 100644
--- a/modules/org-capture-config.el
+++ b/modules/org-capture-config.el
@@ -145,12 +145,16 @@ re-scanning large target files after the first successful lookup."
:around #'cj/org-capture--set-target-location-advice))
;; --------------------------- Capture F-Key Guard -----------------------------
-;; The global popup keys (F1 dashboard sweep, F10 music, F11 dirvish-side,
+;; The global popup keys (F1 dashboard sweep, F9 dirvish-side, F10 music,
;; F12 terminal, M-SPC agent swap) fire even while a capture is in progress
;; and pop their UI over the capture popup. org-capture-mode is a minor
;; mode active exactly for the capture's duration and its keymap shadows
;; the global map, so blocking the keys there scopes the guard precisely:
;; the moment the capture finalizes or aborts, the keys work again.
+;;
+;; The list follows the command, not the key. F11 was on it while it held
+;; dirvish-side; now that F11 is the frame fullscreen toggle it pops nothing
+;; over a capture and stays usable.
(defun cj/--org-capture-blocked-key ()
"Refuse a global popup key while a capture is in progress."
@@ -160,7 +164,7 @@ re-scanning large target files after the first successful lookup."
(defvar org-capture-mode-map)
(with-eval-after-load 'org-capture
- (dolist (key '("<f1>" "<f10>" "<f11>" "<f12>" "M-SPC"))
+ (dolist (key '("<f1>" "<f9>" "<f10>" "<f12>" "M-SPC"))
(keymap-set org-capture-mode-map key #'cj/--org-capture-blocked-key)))
;; ----------------------- Project-Aware Capture Target ------------------------
diff --git a/modules/org-reveal-config.el b/modules/org-reveal-config.el
index f842680b..ddb0803d 100644
--- a/modules/org-reveal-config.el
+++ b/modules/org-reveal-config.el
@@ -18,6 +18,10 @@
;; Fully offline workflow using a local reveal.js clone (managed by
;; scripts/setup-reveal.sh) and self-contained HTML export.
;;
+;; Slides use a 16:9 canvas with a smaller base font than the stock theme,
+;; and a script in the export shrinks any slide whose content would
+;; otherwise be clipped (see "Slide Sizing" below).
+;;
;; Keybindings (C-; p prefix):
;; - C-; p SPC : Ensure headers, export, and open in browser
;; - C-; p e : Export to self-contained HTML and open in browser
@@ -37,6 +41,8 @@
(defvar org-reveal-plugins)
(defvar org-reveal-highlight-css)
(defvar org-reveal-init-options)
+(defvar org-reveal-head-preamble)
+(defvar org-reveal-extra-script)
(declare-function org-reveal-export-to-html "ox-reveal")
;; --------------------------------- Constants ---------------------------------
@@ -51,6 +57,135 @@
(defconst cj/reveal-default-transition "none"
"Default reveal.js slide transition for new presentations.")
+;; ------------------------------- Slide Sizing --------------------------------
+;;
+;; reveal.js lays every slide out on a fixed canvas and scales that canvas
+;; to the window; content taller than the canvas is clipped, not scrolled.
+;; Three things keep a dense slide readable instead of cut off:
+;;
+;; - a 16:9 canvas (the shape of every screen the slides land on), so lines
+;; wrap later than on the stock 960x700 frame;
+;; - a smaller base font and heading scale, set through the theme's CSS
+;; variables in the head preamble so any reveal.js 4+ theme picks them up;
+;; - a script, spliced in after Reveal.initialize, that shrinks any slide
+;; whose content still overflows. It floors at half size: a slide that
+;; overflows even there needs splitting, not shrinking.
+;;
+;; The script rides ox-reveal's extra-script slot, so a file that sets its
+;; own #+REVEAL_EXTRA_SCRIPT replaces it. Print view (?print-pdf) paginates
+;; instead of clipping, so the fit is skipped there and a printed deck can
+;; differ from the presented one.
+
+(defconst cj/reveal-canvas-width 1280
+ "Width of the reveal.js slide canvas in CSS pixels.")
+
+(defconst cj/reveal-canvas-height 720
+ "Height of the reveal.js slide canvas in CSS pixels.")
+
+(defconst cj/reveal-base-font-size "30px"
+ "Base font size for slide text, as a px value.
+The stock themes use 42px, which fits about six short lines on a slide.
+Keep it in px: ox-reveal runs the head preamble through `format-spec',
+so a % unit would break the export.")
+
+(defun cj/--reveal-init-options ()
+ "Return the Reveal.initialize options shared by default and template.
+One line with no trailing comma, so ox-reveal can splice it into the
+Reveal.initialize call."
+ (format "slideNumber:true, hash:true, transition:'%s', width:%d, height:%d"
+ cj/reveal-default-transition
+ cj/reveal-canvas-width
+ cj/reveal-canvas-height))
+
+(defun cj/--reveal-head-preamble ()
+ "Return the <style> element for the document head.
+Sets the house font sizes through the theme variables, stops the theme
+from upper-casing headings, and left-aligns slide text (reveal centers
+everything by default); the title slide and standalone images keep the
+centering."
+ (format "<style>
+:root {
+ --r-main-font-size: %s;
+ --r-heading1-size: 2.2em;
+ --r-heading2-size: 1.5em;
+ --r-heading3-size: 1.2em;
+ --r-heading4-size: 1em;
+}
+.reveal h1, .reveal h2, .reveal h3, .reveal p { text-transform: none; }
+.reveal .slides section { text-align: left; }
+.reveal .slides #sec-title-slide { text-align: center; }
+.reveal .slides section .figure { text-align: center; }
+</style>"
+ cj/reveal-base-font-size))
+
+(defun cj/--reveal-fit-script ()
+ "Return the JavaScript that shrinks overflowing slides to the canvas.
+ox-reveal emits it inside the <script> element right after
+Reveal.initialize, so Reveal already exists: every slide is fitted once
+reveal is ready and the current one refitted on each slide change.
+Hidden slides are shown for the measurement only, so the display state
+reveal manages is restored before anything paints."
+ "// Shrink any slide whose content overflows the canvas height so it scales
+// down instead of being clipped. Floors at half size: a slide that still
+// overflows there needs splitting, not shrinking.
+(function () {
+ var FLOOR = 0.5; // never below half size: split the slide instead
+ var MARGIN = 0.05; // breathing room above the bottom edge of the canvas
+ var PASSES = 8; // fixed-height content converges slowly; cap the loop
+ var busy = false; // layout() re-enters through slidechanged in scroll view
+ // Overview boxes every slide at the canvas height and print view paginates,
+ // so a measurement there is meaningless.
+ function skip() {
+ return (Reveal.isPrintView && Reveal.isPrintView())
+ || (Reveal.isOverview && Reveal.isOverview());
+ }
+ // Shrink SLIDE until it fits; return true when its font size changed.
+ function fit(slide) {
+ if (!slide || skip()) { return false; }
+ var limit = Reveal.getConfig().height * (1 - MARGIN);
+ var before = slide.style.fontSize;
+ var scale = 1;
+ slide.style.fontSize = '';
+ for (var pass = 0; pass < PASSES && scale > FLOOR && slide.scrollHeight > limit; pass++) {
+ scale = Math.max(FLOOR, scale * limit / slide.scrollHeight);
+ slide.style.fontSize = (scale * 100).toFixed(1) + '%';
+ }
+ if (scale === FLOOR && slide.scrollHeight > limit) {
+ console.warn('reveal fit: slide still overflows at half size, split it: '
+ + (slide.id || '(no id)'));
+ }
+ return slide.style.fontSize !== before;
+ }
+ function shown(el, fn) {
+ var saved = el.style.display;
+ el.style.display = 'block';
+ try { fn(); } finally { el.style.display = saved; }
+ }
+ function fitAll() {
+ busy = true;
+ try {
+ Reveal.getSlides().forEach(function (slide) {
+ var parent = slide.parentNode;
+ var run = function () { shown(slide, function () { fit(slide); }); };
+ if (parent && parent.classList && parent.classList.contains('stack')) {
+ shown(parent, run);
+ } else {
+ run();
+ }
+ });
+ Reveal.layout();
+ } finally { busy = false; }
+ }
+ function refit(slide) {
+ if (busy) { return; }
+ busy = true;
+ try { if (fit(slide)) { Reveal.layout(); } } finally { busy = false; }
+ }
+ if (Reveal.isReady()) { fitAll(); } else { Reveal.on('ready', fitAll); }
+ Reveal.on('slidechanged', function (event) { refit(event.currentSlide); });
+ Reveal.on('overviewhidden', function () { refit(Reveal.getCurrentSlide()); });
+})();")
+
;; --------------------------------- ox-reveal ---------------------------------
(use-package ox-reveal
@@ -60,10 +195,9 @@
(setq org-reveal-single-file t)
(setq org-reveal-plugins '(highlight notes search zoom))
(setq org-reveal-highlight-css "%r/plugin/highlight/monokai.css")
- (setq org-reveal-init-options (format "slideNumber:true, hash:true, transition:'%s'"
- cj/reveal-default-transition))
- (setq org-reveal-head-preamble
- "<style>.reveal h1,.reveal h2,.reveal h3,.reveal p{text-transform:none;}</style>"))
+ (setq org-reveal-init-options (cj/--reveal-init-options))
+ (setq org-reveal-head-preamble (cj/--reveal-head-preamble))
+ (setq org-reveal-extra-script (cj/--reveal-fit-script)))
;; ----------------------------- Private Helpers -------------------------------
@@ -76,15 +210,15 @@
#+DATE: %s
#+REVEAL_ROOT: %s
#+REVEAL_THEME: %s
-#+REVEAL_INIT_OPTIONS: slideNumber:true, hash:true, transition:'%s'
+#+REVEAL_INIT_OPTIONS: %s
#+REVEAL_PLUGINS: (highlight notes search zoom)
#+REVEAL_HIGHLIGHT_CSS: %%r/plugin/highlight/monokai.css
-#+OPTIONS: toc:nil num:nil date:nil timestamp:nil author:t
+#+OPTIONS: toc:nil num:nil date:nil timestamp:nil author:t ^:nil
" title (user-full-name) (format-time-string "%Y-%m-%d")
(concat "file://" cj/reveal-root)
cj/reveal-default-theme
- cj/reveal-default-transition))
+ (cj/--reveal-init-options)))
(defun cj/--reveal-title-to-filename (title)
"Convert TITLE to a slug-based .org filename.
diff --git a/tests/test-dirvish-config--side-key.el b/tests/test-dirvish-config--side-key.el
new file mode 100644
index 00000000..1f08314c
--- /dev/null
+++ b/tests/test-dirvish-config--side-key.el
@@ -0,0 +1,40 @@
+;;; test-dirvish-config--side-key.el --- dirvish-side lives on F9 -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The sidebar toggle moved from F11 to F9 on 2026-09-22 so F11 could go back
+;; to `toggle-frame-fullscreen', the stock binding every other application
+;; shares and which the :bind had been shadowing. F9 was the free bare
+;; F-key: ai-term left it for M-SPC in June and the recording chord came off
+;; it the same day this moved. F2 is the preview key and F5 is reserved for
+;; the debug backend, so neither was a candidate.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'keybindings)
+(require 'dirvish-config)
+
+;;; Normal
+
+(ert-deftest test-dirvish-config-side-on-f9 ()
+ "Normal: F9 toggles the dirvish sidebar."
+ (should (eq (keymap-lookup global-map "<f9>") #'dirvish-side)))
+
+;;; Boundary
+
+(ert-deftest test-dirvish-config-side-not-on-f11 ()
+ "Boundary/regression: F11 no longer reaches dirvish-side, so the stock
+`toggle-frame-fullscreen' binding is no longer shadowed."
+ (should-not (eq (keymap-lookup global-map "<f11>") #'dirvish-side))
+ (should (eq (keymap-lookup global-map "<f11>") #'toggle-frame-fullscreen)))
+
+;;; Error
+
+(ert-deftest test-dirvish-config-side-target-is-a-command ()
+ "Error (positive control): the binding points at a real interactive
+command, so a keypress does not fail with a `commandp' error at press time."
+ (should (commandp (keymap-lookup global-map "<f9>"))))
+
+(provide 'test-dirvish-config--side-key)
+;;; test-dirvish-config--side-key.el ends here
diff --git a/tests/test-keybindings--fullscreen.el b/tests/test-keybindings--fullscreen.el
new file mode 100644
index 00000000..c35e398c
--- /dev/null
+++ b/tests/test-keybindings--fullscreen.el
@@ -0,0 +1,45 @@
+;;; test-keybindings--fullscreen.el --- F11 toggles frame fullscreen -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Stock Emacs binds F11 to `toggle-frame-fullscreen', the same key every
+;; other application on the desktop uses for it. This config never rebound
+;; it; dirvish-config's :bind put dirvish-side on F11 and shadowed the stock
+;; binding for as long as that entry existed. It moved to F9 on 2026-09-22
+;; (see test-dirvish-config--side-key.el, which pins the un-shadowing).
+;;
+;; These tests pin the foundation layer's side of that: keybindings.el must
+;; leave the stock F11 binding in place. Only keybindings.el is loaded here
+;; on purpose. A feature module that later claimed F11 would fail the
+;; dirvish-side regression test, not this one.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'keybindings)
+
+;;; Normal
+
+(ert-deftest test-keybindings-f11-toggles-fullscreen ()
+ "Normal: F11 runs `toggle-frame-fullscreen' once the foundation layer has
+loaded. Stock Emacs provides the binding; this pins that keybindings.el does
+not unset or rebind it."
+ (should (eq (keymap-lookup global-map "<f11>") #'toggle-frame-fullscreen)))
+
+;;; Boundary
+
+(ert-deftest test-keybindings-f11-is-the-only-fullscreen-chord ()
+ "Boundary: the plain key carries it; the shifted chord is left free so a
+grazed Shift does not toggle the frame twice."
+ (should-not (eq (keymap-lookup global-map "S-<f11>")
+ #'toggle-frame-fullscreen)))
+
+;;; Error
+
+(ert-deftest test-keybindings-f11-target-is-a-command ()
+ "Error (positive control): the target is a real interactive command, so a
+press does not fail with a `commandp' error at press time."
+ (should (commandp (keymap-lookup global-map "<f11>"))))
+
+(provide 'test-keybindings--fullscreen)
+;;; test-keybindings--fullscreen.el ends here
diff --git a/tests/test-makefile-clean-compiled.el b/tests/test-makefile-clean-compiled.el
new file mode 100644
index 00000000..0deb54f3
--- /dev/null
+++ b/tests/test-makefile-clean-compiled.el
@@ -0,0 +1,121 @@
+;;; test-makefile-clean-compiled.el --- Tests for the clean-compiled make target -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; `make clean-compiled' (and `make clean', which depends on it) removes the
+;; config's own compiled output. It must leave elpa/ alone: package.el owns
+;; that bytecode, nothing in this repo regenerates it, and without it Emacs
+;; loads every package from source -- slower, no native-comp JIT, and on
+;; Emacs 31 a "Missing `lexical-binding' cookie" warning per cookie-less file.
+;;
+;; Each test runs the real target against a throwaway EMACS_HOME. Before the
+;; real run, a dry run (make -n) confirms the override reached the recipe, so a
+;; broken override can never sweep the developer's live tree.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defconst test-makefile-cc--repo
+ (file-name-directory
+ (directory-file-name
+ (file-name-directory (or load-file-name buffer-file-name))))
+ "Repository root holding the Makefile under test.")
+
+(defun test-makefile-cc--touch (root rel)
+ "Create an empty file REL under ROOT, making parent dirs."
+ (let ((path (expand-file-name rel root)))
+ (make-directory (file-name-directory path) t)
+ (write-region "" nil path)
+ path))
+
+(defun test-makefile-cc--make (home &optional dry-run)
+ "Run the clean-compiled target with EMACS_HOME=HOME.
+With DRY-RUN, pass -n. Return (EXIT . OUTPUT)."
+ (with-temp-buffer
+ (let* ((process-environment
+ (cl-remove-if (lambda (e) (string-match-p "\\`MAKE\\(FLAGS\\|LEVEL\\)=" e))
+ process-environment))
+ (args (append (and dry-run '("-n"))
+ (list "--no-print-directory"
+ "-C" test-makefile-cc--repo "clean-compiled"
+ (concat "EMACS_HOME=" (directory-file-name home)))))
+ (exit (apply #'call-process "make" nil t nil args)))
+ (cons exit (buffer-string)))))
+
+(defmacro test-makefile-cc--with-home (files &rest body)
+ "Create a temp EMACS_HOME holding FILES, bind it to HOME, run BODY.
+Refuses to run the real target unless a dry run shows the override applied."
+ (declare (indent 1))
+ `(let ((home (file-name-as-directory (make-temp-file "test-makefile-cc-" t))))
+ (unwind-protect
+ (progn
+ (dolist (f ,files) (test-makefile-cc--touch home f))
+ (let ((dry (test-makefile-cc--make home t)))
+ (should (= 0 (car dry)))
+ (should (string-match-p (regexp-quote (directory-file-name home))
+ (cdr dry)))
+ (should-not (string-match-p
+ (regexp-quote (expand-file-name "~/.emacs.d"))
+ (cdr dry))))
+ ,@body)
+ (delete-directory home t))))
+
+(defun test-makefile-cc--exists (home rel)
+ "Return non-nil when REL exists under HOME."
+ (file-exists-p (expand-file-name rel home)))
+
+(ert-deftest test-makefile-clean-compiled-keeps-elpa-bytecode ()
+ "Normal: package bytecode under elpa/ survives; the config's own is removed."
+ (test-makefile-cc--with-home '("modules/foo.elc"
+ "init.elc"
+ "elpa/pkg-1.0/pkg.el"
+ "elpa/pkg-1.0/pkg.elc"
+ "elpa/pkg-1.0/sub/inner.elc")
+ (should (= 0 (car (test-makefile-cc--make home))))
+ (should (test-makefile-cc--exists home "elpa/pkg-1.0/pkg.elc"))
+ (should (test-makefile-cc--exists home "elpa/pkg-1.0/sub/inner.elc"))
+ (should (test-makefile-cc--exists home "elpa/pkg-1.0/pkg.el"))
+ (should-not (test-makefile-cc--exists home "modules/foo.elc"))
+ (should-not (test-makefile-cc--exists home "init.elc"))))
+
+(ert-deftest test-makefile-clean-compiled-still-removes-native-code ()
+ "Normal: .eln outside elpa/ is still removed, as before."
+ (test-makefile-cc--with-home '("eln-cache/31.1-abc/foo.eln"
+ "modules/foo.elc")
+ (should (= 0 (car (test-makefile-cc--make home))))
+ (should-not (test-makefile-cc--exists home "eln-cache/31.1-abc/foo.eln"))
+ (should-not (test-makefile-cc--exists home "modules/foo.elc"))))
+
+(ert-deftest test-makefile-clean-compiled-no-elpa-dir ()
+ "Boundary: a home with no elpa/ at all still cleans and succeeds."
+ (test-makefile-cc--with-home '("modules/foo.elc")
+ (should (= 0 (car (test-makefile-cc--make home))))
+ (should-not (test-makefile-cc--exists home "modules/foo.elc"))))
+
+(ert-deftest test-makefile-clean-compiled-only-top-level-elpa-spared ()
+ "Boundary: only the top-level elpa/ is spared, not look-alike paths."
+ (test-makefile-cc--with-home '(".elpa-mirror/x.elc"
+ "tests/fixtures/elpa/y.elc"
+ "elpa/pkg-1.0/pkg.elc")
+ (should (= 0 (car (test-makefile-cc--make home))))
+ (should-not (test-makefile-cc--exists home ".elpa-mirror/x.elc"))
+ (should-not (test-makefile-cc--exists home "tests/fixtures/elpa/y.elc"))
+ (should (test-makefile-cc--exists home "elpa/pkg-1.0/pkg.elc"))))
+
+(ert-deftest test-makefile-clean-compiled-missing-home-fails ()
+ "Error: a nonexistent EMACS_HOME makes the target fail, not silently pass."
+ (let* ((parent (make-temp-file "test-makefile-cc-missing-" t))
+ (home (expand-file-name "absent/" parent)))
+ (unwind-protect
+ (progn
+ (should-not (file-exists-p home))
+ (let ((dry (cdr (test-makefile-cc--make home t))))
+ (should (string-match-p (regexp-quote (directory-file-name home)) dry))
+ (should-not (string-match-p
+ (regexp-quote (expand-file-name "~/.emacs.d")) dry)))
+ (should-not (= 0 (car (test-makefile-cc--make home)))))
+ (delete-directory parent t))))
+
+(provide 'test-makefile-clean-compiled)
+;;; test-makefile-clean-compiled.el ends here
diff --git a/tests/test-org-capture-config-fkey-guard.el b/tests/test-org-capture-config-fkey-guard.el
index b288ce4f..c0687bb2 100644
--- a/tests/test-org-capture-config-fkey-guard.el
+++ b/tests/test-org-capture-config-fkey-guard.el
@@ -2,8 +2,10 @@
;;; Commentary:
;; While a capture is in progress, the global popup keys (F1 dashboard
-;; sweep, F10 music, F11 dirvish-side, F12 terminal, M-SPC agent swap)
-;; must not fire and pop UI over the capture. org-capture-mode is a
+;; sweep, F9 dirvish-side, F10 music, F12 terminal, M-SPC agent swap)
+;; must not fire and pop UI over the capture. F11 is deliberately not on
+;; the list: it toggles frame fullscreen, which pops nothing over the
+;; capture, so blocking it would only get in the way. org-capture-mode is a
;; minor mode active exactly for the capture's duration and its keymap
;; shadows the global map, so the guard binds those keys there to a
;; blocker that signals a `user-error' naming the way out.
@@ -21,10 +23,17 @@
(ert-deftest test-org-capture-fkey-guard-keys-bound ()
"Normal: every leaking popup key is bound to the blocker in capture mode."
- (dolist (key '("<f1>" "<f10>" "<f11>" "<f12>" "M-SPC"))
+ (dolist (key '("<f1>" "<f9>" "<f10>" "<f12>" "M-SPC"))
(should (eq (keymap-lookup org-capture-mode-map key)
#'cj/--org-capture-blocked-key))))
+(ert-deftest test-org-capture-fkey-guard-leaves-fullscreen-alone ()
+ "Boundary: F11 (frame fullscreen) is not blocked. It pops no UI, and
+before dirvish-side moved to F9 it was on this list only because F11 was the
+sidebar key; the entry must follow the command, not the key."
+ (should-not (eq (keymap-lookup org-capture-mode-map "<f11>")
+ #'cj/--org-capture-blocked-key)))
+
(ert-deftest test-org-capture-fkey-guard-blocker-signals-user-error ()
"Error: the blocker signals a user-error rather than doing nothing."
(should-error (cj/--org-capture-blocked-key) :type 'user-error))
diff --git a/tests/test-org-reveal-config-header-template.el b/tests/test-org-reveal-config-header-template.el
index 9bda10db..b7ceff53 100644
--- a/tests/test-org-reveal-config-header-template.el
+++ b/tests/test-org-reveal-config-header-template.el
@@ -144,5 +144,11 @@
"Nil title should signal an error rather than silently producing garbage."
(should-error (test-reveal--header nil) :type 'user-error))
+(ert-deftest test-org-reveal-config-header-template-normal-keeps-underscores-literal ()
+ "Normal: #+OPTIONS: turns off ^ markup so snake_case identifiers in slide text stay literal.
+Without it a heading like event_payloads_ais exports as event with a subscript."
+ (let ((result (cj/--reveal-header-template "My Talk")))
+ (should (string-match-p "^#\\+OPTIONS:.*\\^:nil" result))))
+
(provide 'test-org-reveal-config-header-template)
;;; test-org-reveal-config-header-template.el ends here
diff --git a/tests/test-org-reveal-config-sizing.el b/tests/test-org-reveal-config-sizing.el
new file mode 100644
index 00000000..19e4e173
--- /dev/null
+++ b/tests/test-org-reveal-config-sizing.el
@@ -0,0 +1,246 @@
+;;; test-org-reveal-config-sizing.el --- Tests for reveal.js slide sizing and auto-fit -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Dense slides overflowed reveal.js's 960x700 canvas at the theme's 42px
+;; base font and were clipped with no way to scroll. The module now ships
+;; a 16:9 canvas with a smaller base font (via the head preamble) and a
+;; script, spliced in after Reveal.initialize, that shrinks any slide whose
+;; content still overflows. These tests pin the three builders and run the
+;; fit algorithm under node against a stub Reveal API.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'json)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'keybindings)
+(require 'org-reveal-config)
+
+(defun test-org-reveal-config--count (regexp string)
+ "Return how many times REGEXP matches in STRING."
+ (let ((n 0) (start 0))
+ (while (string-match regexp string start)
+ (setq n (1+ n)
+ start (match-end 0)))
+ n))
+
+;; ------------------------------ Init options -------------------------------
+
+(ert-deftest test-org-reveal-config-init-options-normal-has-behaviour-and-canvas ()
+ "Normal: the shared init options carry slide numbers, hash, transition and the canvas size."
+ (let ((opts (cj/--reveal-init-options)))
+ (should (string-match-p "slideNumber:true" opts))
+ (should (string-match-p "hash:true" opts))
+ (should (string-match-p (format "transition:'%s'" cj/reveal-default-transition) opts))
+ (should (string-match-p (format "width:%d" cj/reveal-canvas-width) opts))
+ (should (string-match-p (format "height:%d" cj/reveal-canvas-height) opts))))
+
+(ert-deftest test-org-reveal-config-init-options-boundary-splices-into-initialize ()
+ "Boundary: one line, no trailing comma, so it splices into Reveal.initialize({...})."
+ (let ((opts (cj/--reveal-init-options)))
+ (should-not (string-match-p "\n" opts))
+ (should-not (string-suffix-p "," (string-trim opts)))))
+
+(ert-deftest test-org-reveal-config-init-options-normal-canvas-is-16-9 ()
+ "Normal: the canvas is a 16:9 frame, the shape of every projector and monitor the slides land on."
+ (should (= (* 9 cj/reveal-canvas-width) (* 16 cj/reveal-canvas-height))))
+
+(ert-deftest test-org-reveal-config-header-template-normal-uses-shared-init-options ()
+ "Normal: the header template emits the same init options as the global default, so a file never drifts from the module."
+ (let ((result (cj/--reveal-header-template "Sizing")))
+ (should (string-match-p
+ (format "^#\\+REVEAL_INIT_OPTIONS: %s$"
+ (regexp-quote (cj/--reveal-init-options)))
+ result))))
+
+;; ------------------------------ Head preamble ------------------------------
+
+(ert-deftest test-org-reveal-config-head-preamble-normal-sets-font-sizes ()
+ "Normal: the preamble lowers the theme's base font and heading sizes through the theme variables."
+ (let ((css (cj/--reveal-head-preamble)))
+ (should (string-match-p (format "--r-main-font-size: *%s"
+ (regexp-quote cj/reveal-base-font-size))
+ css))
+ (should (string-match-p "--r-heading1-size" css))
+ (should (string-match-p "--r-heading2-size" css))))
+
+(ert-deftest test-org-reveal-config-head-preamble-normal-keeps-no-caps-rule ()
+ "Normal: the rule that stops the theme upper-casing headings survives the rewrite."
+ (should (string-match-p "text-transform: *none" (cj/--reveal-head-preamble))))
+
+(ert-deftest test-org-reveal-config-head-preamble-normal-left-aligns-slide-content ()
+ "Normal: slide text is left-aligned by default; the title slide and standalone images keep reveal's centering."
+ (let ((css (cj/--reveal-head-preamble)))
+ (should (string-match-p "\\.reveal \\.slides section {[^}]*text-align: *left" css))
+ (should (string-match-p "#sec-title-slide {[^}]*text-align: *center" css))
+ (should (string-match-p "section \\.figure {[^}]*text-align: *center" css))))
+
+(ert-deftest test-org-reveal-config-head-preamble-boundary-one-style-element ()
+ "Boundary: exactly one <style> element, opened and closed."
+ (let ((css (cj/--reveal-head-preamble)))
+ (should (= 1 (test-org-reveal-config--count "<style" css)))
+ (should (= 1 (test-org-reveal-config--count "</style>" css)))))
+
+;; -------------------------------- Fit script -------------------------------
+
+(ert-deftest test-org-reveal-config-fit-script-normal-hooks-reveal-events ()
+ "Normal: the script fits every slide once reveal is ready and refits on each slide change."
+ (let ((js (cj/--reveal-fit-script)))
+ (should (string-match-p "Reveal\\.on('ready'" js))
+ (should (string-match-p "Reveal\\.on('slidechanged'" js))
+ (should (string-match-p "Reveal\\.on('overviewhidden'" js))
+ (should (string-match-p "isOverview" js))
+ (should (string-match-p "scrollHeight" js))
+ (should (string-match-p "getConfig()\\.height" js))))
+
+(ert-deftest test-org-reveal-config-fit-script-boundary-no-script-tags ()
+ "Boundary: ox-reveal splices the text inside its own <script> element, so it must carry no tags."
+ (let ((js (cj/--reveal-fit-script)))
+ (should-not (string-match-p "<script" js))
+ (should-not (string-match-p "</script" js))))
+
+(defconst test-org-reveal-config--fit-harness
+ "const MODE = '%s';
+let overview = false;
+const make = (base, fixed, inStack) => {
+ const s = { style: { fontSize: '', display: 'none' },
+ parentNode: { classList: { contains: (c) => inStack && c === 'stack' },
+ style: { display: 'none' } } };
+ Object.defineProperty(s, 'scrollHeight', { get() {
+ const m = /^([\\d.]+)%%$/.exec(s.style.fontSize);
+ const h = fixed + Math.round(base * (m ? parseFloat(m[1]) / 100 : 1));
+ return overview ? Math.max(h, 720) : h; } });
+ return s;
+};
+const slides = [make(600, 0, false), make(684, 0, true), make(1368, 0, true),
+ make(100000, 0, false), make(900, 100, false), make(500, 450, true)];
+const handlers = {};
+let layouts = 0;
+let warnings = 0;
+console.warn = () => { warnings++; };
+global.Reveal = {
+ getConfig: () => ({ height: 720 }),
+ getSlides: () => slides,
+ getCurrentSlide: () => slides[2],
+ layout: () => { layouts++;
+ if (MODE === 'reentrant' && handlers.slidechanged) {
+ handlers.slidechanged({ currentSlide: slides[2] }); } },
+ isReady: () => MODE === 'ready',
+ isPrintView: () => false,
+ isOverview: () => overview,
+ on: (name, fn) => { handlers[name] = fn; } };
+%s
+const readyHandler = Boolean(handlers.ready);
+if (handlers.ready) { handlers.ready(); }
+if (MODE === 'overview') {
+ overview = true;
+ handlers.slidechanged({ currentSlide: slides[0] });
+ overview = false;
+ handlers.overviewhidden();
+}
+if (MODE === 'reentrant') {
+ slides[4].style.fontSize = '';
+ handlers.slidechanged({ currentSlide: slides[4] });
+}
+handlers.slidechanged({ currentSlide: slides[2] });
+handlers.slidechanged({ currentSlide: null });
+process.stdout.write(JSON.stringify({
+ sizes: slides.map(s => s.style.fontSize),
+ displays: slides.map(s => s.style.display),
+ parents: slides.map(s => s.parentNode.style.display),
+ overflow: slides.map(s => s.scrollHeight > 684),
+ readyHandler, overviewHandler: Boolean(handlers.overviewhidden),
+ layouts, warnings }));
+"
+ "Node harness: a stub Reveal API around the fit script.
+The first %s is the harness MODE (normal, ready, overview, reentrant) and
+the second is the script. Each stub slide's scrollHeight is a fixed part
+plus a text part that scales with the font-size the script sets, so a
+later measuring pass sees the effect of the earlier one. Slides 1, 2 and
+5 sit in a .stack parent, as every ox-reveal content slide does. The
+canvas is 720px, so the usable limit with the 5% margin is 684px. In
+overview the stub floors scrollHeight at the 720px box, the way reveal's
+overview CSS does.")
+
+(defun test-org-reveal-config--run-fit-harness (&optional mode)
+ "Run the fit script under node in harness MODE; return the parsed result."
+ (let ((file (make-temp-file "reveal-fit-" nil ".js"))
+ (out (generate-new-buffer " *fit-harness*")))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert (format test-org-reveal-config--fit-harness
+ (or mode "normal")
+ (cj/--reveal-fit-script))))
+ (let ((status (call-process "node" nil out nil file)))
+ (unless (eq status 0)
+ (error "node exited %s: %s" status
+ (with-current-buffer out (buffer-string))))
+ (with-current-buffer out
+ (goto-char (point-min))
+ (json-parse-buffer :object-type 'alist :array-type 'list))))
+ (delete-file file)
+ (kill-buffer out))))
+
+(ert-deftest test-org-reveal-config-fit-script-normal-shrinks-only-overflowing-slides ()
+ "Normal: a slide twice the usable height is scaled to 50%; slides that fit are left alone."
+ (skip-unless (executable-find "node"))
+ (let-alist (test-org-reveal-config--run-fit-harness)
+ (should (equal "" (nth 0 .sizes)))
+ (should (equal "" (nth 1 .sizes)))
+ (should (equal "50.0%" (nth 2 .sizes)))))
+
+(ert-deftest test-org-reveal-config-fit-script-normal-converges-with-fixed-height-content ()
+ "Normal: content with a fixed-height part needs several passes and still lands inside the limit."
+ (skip-unless (executable-find "node"))
+ (let-alist (test-org-reveal-config--run-fit-harness)
+ (should (string-match-p "\\`6[0-9]\\.[0-9]%\\'" (nth 4 .sizes)))
+ (should (eq :false (nth 4 .overflow)))))
+
+(ert-deftest test-org-reveal-config-fit-script-boundary-floors-at-half-size ()
+ "Boundary: content that would need less than half size stops at the floor, and only those slides are reported."
+ (skip-unless (executable-find "node"))
+ (let-alist (test-org-reveal-config--run-fit-harness)
+ (should (equal "50.0%" (nth 3 .sizes)))
+ (should (equal "50.0%" (nth 5 .sizes)))
+ (should (eq t (nth 3 .overflow)))
+ (should (eq t (nth 5 .overflow)))
+ (should (= 2 .warnings))))
+
+(ert-deftest test-org-reveal-config-fit-script-boundary-restores-display-and-relayouts ()
+ "Boundary: measuring hidden slides and their stack parents leaves display as reveal set it, and layout runs afterwards."
+ (skip-unless (executable-find "node"))
+ (let-alist (test-org-reveal-config--run-fit-harness)
+ (should (equal '("none" "none" "none" "none" "none" "none") .displays))
+ (should (equal '("none" "none" "none" "none" "none" "none") .parents))
+ (should (> .layouts 0))))
+
+(ert-deftest test-org-reveal-config-fit-script-normal-fits-at-once-when-reveal-is-ready ()
+ "Normal: when reveal is already ready the slides are fitted immediately instead of waiting for an event."
+ (skip-unless (executable-find "node"))
+ (let-alist (test-org-reveal-config--run-fit-harness "ready")
+ (should (eq :false .readyHandler))
+ (should (equal "50.0%" (nth 2 .sizes)))))
+
+(ert-deftest test-org-reveal-config-fit-script-boundary-overview-is-left-alone ()
+ "Boundary: in overview every slide measures as the full box, so nothing is refitted until the overview closes."
+ (skip-unless (executable-find "node"))
+ (let-alist (test-org-reveal-config--run-fit-harness "overview")
+ (should (equal "" (nth 0 .sizes)))
+ (should (eq t .overviewHandler))))
+
+(ert-deftest test-org-reveal-config-fit-script-error-null-slide-is-ignored ()
+ "Error: a slidechanged event without a current slide is ignored rather than thrown."
+ (skip-unless (executable-find "node"))
+ (should (test-org-reveal-config--run-fit-harness)))
+
+(ert-deftest test-org-reveal-config-fit-script-error-reentrant-layout-does-not-recurse ()
+ "Error: a layout that fires slidechanged again (reveal's scroll view does) must not recurse without end."
+ (skip-unless (executable-find "node"))
+ (let-alist (test-org-reveal-config--run-fit-harness "reentrant")
+ (should (>= .layouts 2))))
+
+(provide 'test-org-reveal-config-sizing)
+;;; test-org-reveal-config-sizing.el ends here