aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-term.el188
-rw-r--r--modules/calendar-sync.el84
-rw-r--r--modules/calibredb-epub-config.el49
-rw-r--r--modules/cj-window-geometry-lib.el35
-rw-r--r--modules/cj-window-toggle-lib.el30
-rw-r--r--modules/custom-case.el120
-rw-r--r--modules/custom-comments.el167
-rw-r--r--modules/dirvish-config.el51
-rw-r--r--modules/dwim-shell-config.el76
-rw-r--r--modules/term-config.el5
-rw-r--r--scripts/theme-studio/WIP.json20
-rw-r--r--scripts/theme-studio/app-core.js13
-rw-r--r--scripts/theme-studio/browser-gates.js269
-rw-r--r--scripts/theme-studio/capture-default-faces.py62
-rw-r--r--scripts/theme-studio/face_coverage.py38
-rw-r--r--scripts/theme-studio/generate.py148
-rw-r--r--scripts/theme-studio/inline-strip.mjs15
-rw-r--r--scripts/theme-studio/test-app-core.mjs32
-rw-r--r--scripts/theme-studio/test-app-util.mjs6
-rw-r--r--scripts/theme-studio/test-colormath.mjs7
-rw-r--r--scripts/theme-studio/test_generate.py36
-rw-r--r--scripts/theme-studio/theme-studio.html280
-rw-r--r--tests/test-ai-term--capture-state.el6
-rw-r--r--tests/test-ai-term--reuse-edge-window.el41
-rw-r--r--tests/test-calendar-sync--parse-exception-event.el64
-rw-r--r--tests/test-cj-window-geometry-lib.el20
-rw-r--r--tests/test-cj-window-toggle-lib.el13
-rw-r--r--tests/test-dirvish-config-playlist.el55
-rw-r--r--tests/test-dwim-shell-config-command-fixes.el55
-rw-r--r--tests/test-term-toggle--display.el13
-rw-r--r--themes/WIP-theme.el8
-rw-r--r--todo.org119
32 files changed, 1238 insertions, 887 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 8dfd5e370..25e56c508 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -433,6 +433,18 @@ without deleting), nil when the window was deleted. Consumed by
buried agent in the current window (the only one) or splitting per
the saved direction.")
+(defvar cj/--ai-term-last-toggle-deleted-split nil
+ "Non-nil when the last F9 toggle-off deleted the agent's own split window.
+
+Set t by `cj/--ai-term-toggle-off' only when it actually `delete-window's
+the agent (a multi-window layout where the agent had its own window);
+nil for a bury or a degenerate swap. Consumed by
+`cj/--ai-term-reuse-edge-window': when set, the next toggle-on re-splits a
+fresh agent window instead of reusing a window at the edge. Without this,
+toggling the agent off and on in a 3+ window layout would reuse the user's
+working window at the edge, displacing its buffer and collapsing the layout
+-- the toggle must be reversible (off then on returns the same windows).")
+
(defvar cj/--ai-term-last-hidden-buffer nil
"The agent buffer hidden by the most recent F9 toggle-off.
@@ -445,21 +457,28 @@ the \"the displayed buffer changes\" bug. Falls back to the buffer-list
MRU when nil or when the remembered buffer has been killed.")
(defvar cj/--ai-term-last-size nil
- "Last user-chosen body size for the AI-term display.
+ "Last user-chosen size for the AI-term display.
Positive integer: body-columns when `cj/--ai-term-last-direction'
-is right or left, body-lines when below or above. nil means use
+is right or left, total-lines when below or above. nil means use
the host-aware default from `cj/--ai-term-default-size' (a float
-fraction).
-
-Body size, not total size, because total-width includes the
-right-edge divider when the window has a right sibling but excludes
-it when the window is at the frame edge. Capturing total-width
-from a rightmost agent (no divider) and replaying into a middle
-position (with divider) leaves the body 1 column short -- visible
-as 1 col of the sibling buffer peeking through where agent should
-have ended. Body-width is divider-independent and matches what the
-user actually sees.
+fraction). See `cj/window-replay-size' for the per-axis capture.
+
+The axis choice is asymmetric. Width captures body-width, not
+total-width: total-width includes the right-edge divider when the
+window has a right sibling but excludes it at the frame edge, so
+capturing total-width from a rightmost agent (no divider) and
+replaying into a middle position (with divider) leaves the body 1
+column short. Body-width is divider-independent.
+
+Height captures total-height, not body-height: every window has
+exactly one mode line regardless of position, so total-height has
+no divider-position problem, and total-height is the same whether
+the window is active or inactive. Body-height would subtract the
+mode line's pixel height, which differs between an active and an
+inactive (theme-shrunk) mode line -- capturing body-height active
+and replaying it inactive then re-measuring active drifts the
+window down by ~1 line per toggle (the F9 shrink bug, 2026-06-20).
Absolute values rather than fractions because
`display-buffer-in-direction' interprets a float `window-width' /
@@ -527,14 +546,22 @@ displaced buffer and the agent, never changing the window count.
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
-non-agent buffer, which is replaced (it stays alive, just unshown)."
- (let* ((direction (or cj/--ai-term-last-direction
- (cj/--ai-term-default-direction)))
- (win (cj/window-at-edge direction)))
- (when (and win (not (window-dedicated-p win)))
- (display-buffer-record-window 'reuse win buffer)
- (set-window-buffer win buffer)
- win)))
+non-agent buffer, which is replaced (it stays alive, just unshown).
+
+Skipped entirely when the prior toggle-off deleted the agent's own split
+window (`cj/--ai-term-last-toggle-deleted-split'): re-showing then reuses a
+working window at the edge and collapses the layout. Consume the flag and
+return nil so `cj/--ai-term-display-saved' re-splits a fresh agent window,
+keeping the toggle reversible."
+ (if cj/--ai-term-last-toggle-deleted-split
+ (progn (setq cj/--ai-term-last-toggle-deleted-split nil) nil)
+ (let* ((direction (or cj/--ai-term-last-direction
+ (cj/--ai-term-default-direction)))
+ (win (cj/window-at-edge direction)))
+ (when (and win (not (window-dedicated-p win)))
+ (display-buffer-record-window 'reuse win buffer)
+ (set-window-buffer win buffer)
+ win))))
(defun cj/--ai-term-display-saved (buffer alist)
"Display-buffer action: split per saved direction and size.
@@ -774,6 +801,72 @@ launches from either (only kitty inline-graphics degrade in a TTY)."
(when win (select-window win))))
buf))
+(defun cj/--ai-term-swap-to-working-buffer (win)
+ "In WIN, switch to the most-recent non-agent buffer (a working file).
+Falls back to `other-buffer' (excluding WIN's current agent buffer) when no
+non-agent buffer is on record. Used at toggle-off and close so dismissing an
+agent surfaces the file the user was working on rather than another agent or
+the agent itself."
+ (with-selected-window win
+ (switch-to-buffer
+ (or (cj/--ai-term-most-recent-non-agent-buffer)
+ (other-buffer (window-buffer win) t)))))
+
+(defun cj/--ai-term-toggle-off (win)
+ "Hide the agent shown in WIN for an F9 toggle-off. Always returns nil.
+
+Two cases, by window count:
+
+- Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no prior
+ layout for the native undo to restore and deleting would leave the frame
+ empty. Bury and flag, so the next toggle-on (`cj/--ai-term-display-saved')
+ restores the agent in place at full frame rather than splitting. Capture
+ geometry for that restore. `bury-buffer' can no-op when the window's
+ prev-buffer history holds only the agent (common right after `C-x 1'), so
+ force a swap to a non-agent buffer to keep the toggle observable.
+
+- Multi-window: collapse the agent split outright by deleting its window, so
+ the working buffer (e.g. todo.org) reclaims the space. F9 is a pure
+ show/hide toggle of THE agent split -- it must never surface a different
+ agent. `quit-restore-window' can't guarantee that here: switching among
+ several agents reuses the one slot via `set-window-buffer' (see
+ `cj/--ai-term-reuse-existing-agent'), which leaves the window's
+ `quit-restore' parameter pointing at the FIRST agent shown. Once it's
+ stale, `quit-restore-window' falls back to `switch-to-prev-buffer' and
+ surfaces another agent instead of removing the window -- exactly the \"F9
+ shows another agent\" bug. `delete-window' is unconditional and
+ slot-history-independent. Capture geometry first so the next toggle-on
+ splits at the same size (the user's chosen split width is preserved)."
+ ;; Remember which agent we're hiding so the next toggle-on reopens this
+ ;; same one, not whichever agent is most-recent in `buffer-list'.
+ (setq cj/--ai-term-last-hidden-buffer (window-buffer win))
+ (cond
+ ((one-window-p)
+ (cj/--ai-term-capture-state win)
+ (setq cj/--ai-term-last-was-bury t)
+ (setq cj/--ai-term-last-toggle-deleted-split nil)
+ (bury-buffer (window-buffer win))
+ (when (and (window-live-p win)
+ (cj/--ai-term-buffer-p (window-buffer win)))
+ (cj/--ai-term-swap-to-working-buffer win)))
+ (t
+ (cj/--ai-term-capture-state win)
+ (setq cj/--ai-term-last-was-bury nil)
+ (if (and (window-live-p win)
+ (> (length (window-list (window-frame win) 'never)) 1))
+ (progn
+ (delete-window win)
+ ;; The agent had its own window in a multi-window layout, now gone:
+ ;; the next toggle-on must re-split it rather than reuse a working
+ ;; window at the edge (see `cj/--ai-term-reuse-edge-window').
+ (setq cj/--ai-term-last-toggle-deleted-split t))
+ ;; Degenerate fallback (window became sole between dispatch and
+ ;; here): swap to a non-agent buffer rather than leave the agent up.
+ (setq cj/--ai-term-last-toggle-deleted-split nil)
+ (when (window-live-p win)
+ (cj/--ai-term-swap-to-working-buffer win)))))
+ nil)
+
(defun cj/ai-term (&optional arg)
"Smart F9 dispatch for the AI-term launcher.
@@ -793,55 +886,7 @@ M-F9 (and C-S-F9) close an agent via `cj/ai-term-close'."
(interactive "P")
(pcase (cj/--ai-term-dispatch)
(`(toggle-off . ,win)
- ;; Remember which agent we're hiding so the next toggle-on reopens this
- ;; same one, not whichever agent is most-recent in `buffer-list'.
- (setq cj/--ai-term-last-hidden-buffer (window-buffer win))
- (cond
- ;; Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no
- ;; prior layout for the native undo to restore and deleting would
- ;; leave the frame empty. Bury and flag, so the next toggle-on
- ;; (`cj/--ai-term-display-saved') restores the agent in place at
- ;; full frame rather than splitting. Capture geometry for that
- ;; restore. `bury-buffer' can no-op when the window's prev-buffer
- ;; history holds only the agent (common right after `C-x 1'), so
- ;; force a swap to a non-agent buffer to keep the toggle observable.
- ((one-window-p)
- (cj/--ai-term-capture-state win)
- (setq cj/--ai-term-last-was-bury t)
- (bury-buffer (window-buffer win))
- (when (and (window-live-p win)
- (cj/--ai-term-buffer-p (window-buffer win)))
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer (window-buffer win) t))))))
- ;; Multi-window: collapse the agent split outright by deleting its
- ;; window, so the working buffer (e.g. todo.org) reclaims the space.
- ;; F9 is a pure show/hide toggle of THE agent split -- it must never
- ;; surface a different agent. `quit-restore-window' can't guarantee
- ;; that here: switching among several agents reuses the one slot via
- ;; `set-window-buffer' (see `cj/--ai-term-reuse-existing-agent'),
- ;; which leaves the window's `quit-restore' parameter pointing at the
- ;; FIRST agent shown. Once it's stale, `quit-restore-window' falls
- ;; back to `switch-to-prev-buffer' and surfaces another agent instead
- ;; of removing the window -- exactly the "F9 shows another agent"
- ;; bug. `delete-window' is unconditional and slot-history-independent.
- ;; Capture geometry first so the next toggle-on splits at the same
- ;; size (the user's chosen split width is preserved across the toggle).
- (t
- (cj/--ai-term-capture-state win)
- (setq cj/--ai-term-last-was-bury nil)
- (if (and (window-live-p win)
- (> (length (window-list (window-frame win) 'never)) 1))
- (delete-window win)
- ;; Degenerate fallback (window became sole between dispatch and
- ;; here): swap to a non-agent buffer rather than leave the agent up.
- (when (window-live-p win)
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer (window-buffer win) t))))))))
- nil)
+ (cj/--ai-term-toggle-off win))
(`(redisplay-recent . ,buf)
(display-buffer buf)
(unless arg
@@ -881,10 +926,7 @@ when BUFFER isn't an AI-term buffer."
(buffer-local-value 'default-directory buffer)))
(let ((win (get-buffer-window buffer)))
(when (window-live-p win)
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer buffer t))))))
+ (cj/--ai-term-swap-to-working-buffer win)))
(let ((kill-buffer-query-functions nil))
(kill-buffer buffer))))
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 13c74ca16..2ff535668 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -454,53 +454,55 @@ Handles formats: 20260203T090000Z, 20260203T090000, 20260203."
(defalias 'calendar-sync--parse-recurrence-id #'calendar-sync--parse-ics-datetime
"Parse RECURRENCE-ID value. See `calendar-sync--parse-ics-datetime'.")
+(defun calendar-sync--parse-exception-event (event-str)
+ "Parse a RECURRENCE-ID override EVENT-STR into an exception plist, or nil.
+Returns nil when EVENT-STR carries no RECURRENCE-ID, or its recurrence-id /
+start time fail to parse. The plist holds :recurrence-id (localized),
+:recurrence-id-raw, :start, :end, :summary, :description, :location."
+ (let ((recurrence-id (calendar-sync--get-recurrence-id event-str)))
+ (when recurrence-id
+ (let* ((recurrence-id-line (calendar-sync--get-recurrence-id-line event-str))
+ (recurrence-id-tzid (calendar-sync--extract-tzid recurrence-id-line))
+ (recurrence-id-is-utc (string-suffix-p "Z" recurrence-id))
+ (recurrence-id-parsed (calendar-sync--parse-recurrence-id recurrence-id))
+ ;; Parse the new times from the exception
+ (dtstart (calendar-sync--get-property event-str "DTSTART"))
+ (dtend (calendar-sync--get-property event-str "DTEND"))
+ (dtstart-line (calendar-sync--get-property-line event-str "DTSTART"))
+ (dtend-line (calendar-sync--get-property-line event-str "DTEND"))
+ (start-tzid (calendar-sync--extract-tzid dtstart-line))
+ (end-tzid (calendar-sync--extract-tzid dtend-line))
+ (start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
+ (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid)))
+ (summary (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "SUMMARY")))
+ (description (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "DESCRIPTION")))
+ (location (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "LOCATION"))))
+ (when (and recurrence-id-parsed start-parsed)
+ (list :recurrence-id (calendar-sync--localize-parsed-datetime
+ recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid)
+ :recurrence-id-raw recurrence-id
+ :start start-parsed
+ :end end-parsed
+ :summary summary
+ :description description
+ :location location))))))
+
(defun calendar-sync--collect-recurrence-exceptions (ics-content)
"Collect all RECURRENCE-ID events from ICS-CONTENT.
Returns hash table mapping UID to list of exception event plists.
Each exception plist contains :recurrence-id (parsed), :start, :end, :summary, etc."
(let ((exceptions (make-hash-table :test 'equal)))
(when (and ics-content (stringp ics-content))
- (let ((events (calendar-sync--split-events ics-content)))
- (dolist (event-str events)
- (let ((recurrence-id (calendar-sync--get-recurrence-id event-str))
- (uid (calendar-sync--get-property event-str "UID")))
- (when (and recurrence-id uid)
- ;; Parse the exception event
- (let* ((recurrence-id-line (calendar-sync--get-recurrence-id-line event-str))
- (recurrence-id-tzid (calendar-sync--extract-tzid recurrence-id-line))
- (recurrence-id-is-utc (and recurrence-id
- (string-suffix-p "Z" recurrence-id)))
- (recurrence-id-parsed (calendar-sync--parse-recurrence-id recurrence-id))
- ;; Parse the new times from the exception
- (dtstart (calendar-sync--get-property event-str "DTSTART"))
- (dtend (calendar-sync--get-property event-str "DTEND"))
- (dtstart-line (calendar-sync--get-property-line event-str "DTSTART"))
- (dtend-line (calendar-sync--get-property-line event-str "DTEND"))
- (start-tzid (calendar-sync--extract-tzid dtstart-line))
- (end-tzid (calendar-sync--extract-tzid dtend-line))
- (start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
- (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid)))
- (summary (calendar-sync--clean-text
- (calendar-sync--get-property event-str "SUMMARY")))
- (description (calendar-sync--clean-text
- (calendar-sync--get-property event-str "DESCRIPTION")))
- (location (calendar-sync--clean-text
- (calendar-sync--get-property event-str "LOCATION"))))
- (when (and recurrence-id-parsed start-parsed)
- (let ((local-recurrence-id
- (calendar-sync--localize-parsed-datetime
- recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid)))
- (let ((exception-plist
- (list :recurrence-id local-recurrence-id
- :recurrence-id-raw recurrence-id
- :start start-parsed
- :end end-parsed
- :summary summary
- :description description
- :location location)))
- ;; Add to hash table
- (let ((existing (gethash uid exceptions)))
- (puthash uid (cons exception-plist existing) exceptions)))))))))))
+ (dolist (event-str (calendar-sync--split-events ics-content))
+ (let ((uid (calendar-sync--get-property event-str "UID"))
+ (exception-plist (calendar-sync--parse-exception-event event-str)))
+ (when (and uid exception-plist)
+ (puthash uid
+ (cons exception-plist (gethash uid exceptions))
+ exceptions)))))
exceptions))
(defun calendar-sync--occurrence-matches-exception-p (occurrence exception)
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index a17bf8c91..6d5963515 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -241,6 +241,29 @@ layout passes -- each pass narrows the body width but not the natural width."
"Return the preferred EPUB text column count for WINDOW."
(cj/nov--text-width (cj/nov--natural-window-width window)))
+(defun cj/nov--rerender-preserving-position ()
+ "Re-render the nov document, restoring point's relative position.
+Capture point as a fraction of the buffer, re-render, then move point to the
+same fraction of the re-rendered buffer so the reading position is kept
+approximately."
+ (let ((frac (when (> (point-max) (point-min))
+ (/ (float (- (point) (point-min)))
+ (- (point-max) (point-min))))))
+ (nov-render-document)
+ (when frac
+ (goto-char (+ (point-min)
+ (round (* frac (- (point-max) (point-min)))))))))
+
+(defun cj/nov--center-in-window (win total width)
+ "Center a WIDTH-column text block in WIN, given its TOTAL natural width.
+Set equal left/right display margins and push the fringes to the window edge."
+ ;; floor: never let the margins squeeze the text area below WIDTH.
+ (let ((margin (max 0 (/ (- total width) 2))))
+ (set-window-margins win margin margin))
+ ;; Push the fringes out to the window's edge; otherwise they sit between the
+ ;; margin and the text and show as thin vertical lines beside it.
+ (set-window-fringes win nil nil t))
+
(defun cj/nov-update-layout (&optional _frame)
"Size the EPUB text column for this buffer and center it in its window.
`nov-text-width' is set so nov's `shr' fills the text to roughly 80% of the
@@ -256,20 +279,9 @@ command."
(width (cj/nov--text-width total)))
(unless (eql nov-text-width width)
(setq-local nov-text-width width)
- (let ((frac (when (> (point-max) (point-min))
- (/ (float (- (point) (point-min)))
- (- (point-max) (point-min))))))
- (nov-render-document)
- (when frac
- (goto-char (+ (point-min)
- (round (* frac (- (point-max) (point-min)))))))))
+ (cj/nov--rerender-preserving-position))
(when win
- ;; floor: never let the margins squeeze the text area below WIDTH.
- (let ((margin (max 0 (/ (- total width) 2))))
- (set-window-margins win margin margin))
- ;; Push the fringes out to the window's edge; otherwise they sit between
- ;; the margin and the text and show as thin vertical lines beside it.
- (set-window-fringes win nil nil t)))))
+ (cj/nov--center-in-window win total width)))))
(defun cj/--nov-adjust-margin (delta)
"Add DELTA to `cj/nov-margin-percent' (clamped 0..25), re-lay-out, and report.
@@ -293,11 +305,12 @@ A positive DELTA narrows the text column; a negative DELTA widens it."
(defun cj/nov-apply-preferences ()
"Apply preferences after nov-mode has launched."
(interactive)
- ;; Use Merriweather for comfortable reading with appropriate scaling
- ;; Darker sepia color (#E8DCC0) is easier on the eyes than pure white
- (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0 :foreground "#E8DCC0")
- (face-remap-add-relative 'default :family "Merriweather" :height 180 :foreground "#E8DCC0")
- (face-remap-add-relative 'fixed-pitch :height 180 :foreground "#E8DCC0")
+ ;; Use Merriweather for comfortable reading with appropriate scaling.
+ ;; Darker sepia color (#E8DCC0) is easier on the eyes than pure white.
+ (let ((sepia "#E8DCC0"))
+ (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0 :foreground sepia)
+ (face-remap-add-relative 'default :family "Merriweather" :height 180 :foreground sepia)
+ (face-remap-add-relative 'fixed-pitch :height 180 :foreground sepia))
;; Enable visual-line-mode for proper text wrapping
(visual-line-mode 1)
;; Set fill-column as a fallback
diff --git a/modules/cj-window-geometry-lib.el b/modules/cj-window-geometry-lib.el
index 4c0662124..4484a1d15 100644
--- a/modules/cj-window-geometry-lib.el
+++ b/modules/cj-window-geometry-lib.el
@@ -42,21 +42,34 @@ fails to span the full height."
((not spans-full-height) (if (= top root-top) 'above 'below))
(t (or default 'right)))))
-(defun cj/window-body-size (window direction)
- "Return WINDOW's body size on the axis matching DIRECTION.
+(defun cj/window-replay-size (window direction)
+ "Return WINDOW's size to capture for geometry replay, on DIRECTION's axis.
Returns body-width (columns) when DIRECTION is right or left.
-Returns body-height (lines) when DIRECTION is below or above.
-
-Body size, not total size, is the right thing to capture for
-geometry replay: total-width includes the right-side divider when
-the window has a right sibling but excludes it at the frame edge,
-so a captured rightmost window replayed into a middle position
-would leave the body 1 col short. Body size is divider-
-independent and matches what the user actually sees."
+Returns total-height (lines) when DIRECTION is below or above.
+
+The axis choice is deliberately asymmetric, for two different reasons:
+
+- Width: body-width, not total-width. Total-width includes the right-side
+ divider when the window has a right sibling but excludes it at the frame
+ edge, so a captured rightmost window replayed into a middle position would
+ leave the body 1 col short. Body-width is divider-independent and matches
+ what the user sees.
+
+- Height: total-height, not body-height. Every window carries exactly one
+ mode line regardless of position, so total-height has no analog of the
+ divider-position problem -- it is position-independent. Body-height does
+ NOT work here: it subtracts the mode line's *pixel* height, which differs
+ between an active (full-height) and an inactive (theme-shrunk) mode line.
+ Capturing body-height while the window is active and replaying it while the
+ window is displayed inactive then re-measuring active drifts the value down
+ by ~1 line per toggle whenever the inactive mode line is shorter than a text
+ line (e.g. a theme that sets `mode-line-inactive' to a sub-line height).
+ Total-height is identical active or inactive, so the capture/replay
+ round-trip is a fixed point."
(if (memq direction '(right left))
(window-body-width window)
- (window-body-height window)))
+ (window-total-height window)))
(defun cj/cardinal-to-edge-direction (direction)
"Map cardinal DIRECTION to its `display-buffer-in-direction' edge variant.
diff --git a/modules/cj-window-toggle-lib.el b/modules/cj-window-toggle-lib.el
index ba91f5a40..175a1d958 100644
--- a/modules/cj-window-toggle-lib.el
+++ b/modules/cj-window-toggle-lib.el
@@ -44,7 +44,7 @@ No-op when WINDOW is nil or not live."
(if (or (null allowed) (memq dir allowed))
(progn
(set direction-var dir)
- (set size-var (cj/window-body-size window dir)))
+ (set size-var (cj/window-replay-size window dir)))
(set direction-var default-direction)
(set size-var nil)))))
@@ -59,10 +59,12 @@ DEFAULT-SIZE when the stored values are nil. The cardinal direction
is mapped to its frame-edge variant via
`cj/cardinal-to-edge-direction' so the new buffer always lands at
the same frame edge regardless of the selected window. An integer
-size is wrapped in a `(body-columns . N)' / `(body-lines . N)' cons
-so `display-buffer-in-direction' sets the body explicitly,
-divider-independent. A float size passes through as a fraction of
-the new window's parent.
+size is wrapped per axis: a width size as a `(body-columns . N)'
+cons (divider-independent body width), a height size as a plain
+integer total-line count. Height uses total rather than body so the
+capture/replay round-trip is immune to the mode line's pixel height
+(see `cj/window-replay-size'). A float size passes through as a
+fraction of the new window's parent.
Caller-supplied ALIST entries for direction, window-width, or
window-height are stripped before delegating to
@@ -74,15 +76,15 @@ placement; the remaining alist entries are passed through."
(edge-direction (or (cj/cardinal-to-edge-direction direction)
(cj/cardinal-to-edge-direction default-direction)))
(size (or stored-size default-size))
- (size-key (if (memq direction '(right left))
- 'window-width
- 'window-height))
- (body-tag (if (memq direction '(right left))
- 'body-columns
- 'body-lines))
- (size-value (if (integerp size)
- (cons body-tag size)
- size))
+ (width-axis (memq direction '(right left)))
+ (size-key (if width-axis 'window-width 'window-height))
+ ;; A width integer is a body-column count (divider-independent); a
+ ;; height integer is a plain total-line count (mode-line-pixel-
+ ;; independent -- see `cj/window-replay-size'). Floats pass through.
+ (size-value (cond
+ ((not (integerp size)) size)
+ (width-axis (cons 'body-columns size))
+ (t size)))
(filtered (cl-remove-if
(lambda (cell)
(memq (car-safe cell)
diff --git a/modules/custom-case.el b/modules/custom-case.el
index d30ebf942..876226958 100644
--- a/modules/custom-case.el
+++ b/modules/custom-case.el
@@ -49,6 +49,18 @@
(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)
+ "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."
+ (or is-first
+ (not (member word word-skip))
+ (save-excursion
+ (and (not (zerop (skip-chars-backward "[:blank:]" prev-word-end)))
+ (memq (char-before (point)) chars-skip-reset)))))
+
(defun cj/title-case-region ()
"Capitalize the region in title case format.
Title case is a capitalization convention where major words are capitalized,
@@ -58,67 +70,53 @@ considered major words. Short (i.e., three letters or fewer) conjunctions,
short prepositions, and all articles are considered minor words."
(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))))
- (save-excursion
- ;; work on uppercased text (e.g., headlines) by downcasing first
- (downcase-region beg end)
- (goto-char beg)
-
- (while (< (point) end)
- (setq prev-word-end (point))
- (skip-chars-forward (concat "^" word-chars) end)
- (when (>= (point) end) ;; no word chars remaining
- (goto-char end))
- (let ((word-end
- (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
- (or
- ;; Always allow capitalization.
- is-first
- ;; If it's not a skip word, allow.
- (not (member word word-skip))
- ;; Check the beginning of the previous word doesn't reset first.
- (save-excursion
- (and
- (not (zerop
- (skip-chars-backward "[:blank:]" prev-word-end)))
- (memq (char-before (point)) chars-skip-reset))))
- (delete-region (point) (1+ (point)))
- (insert c-up))))))
- (goto-char word-end)
- (setq is-first 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))))
+ (save-excursion
+ ;; work on uppercased text (e.g., headlines) by downcasing first
+ (downcase-region beg end)
+ (goto-char beg)
+ (while (< (point) end)
+ (setq prev-word-end (point))
+ (skip-chars-forward (concat "^" word-chars) end)
+ (when (>= (point) end) ;; no word chars remaining
+ (goto-char end))
+ (let ((word-end
+ (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))))))
+ (goto-char word-end)
+ (setq is-first nil))))))
;; replace the capitalize-region keybinding to call title-case
(keymap-global-set "<remap> <capitalize-region>" #'cj/title-case-region)
diff --git a/modules/custom-comments.el b/modules/custom-comments.el
index cae911061..231a03860 100644
--- a/modules/custom-comments.el
+++ b/modules/custom-comments.el
@@ -109,6 +109,14 @@ inputs. Used by all divider / border helpers below."
decoration-char))
decoration-char)
+(defun cj/--comment-emit-prefix (cmt-start)
+ "Insert CMT-START -- doubled when it is a lone semicolon -- and a trailing space.
+A bare =;= is doubled to =;;= so the line reads as an Emacs-Lisp comment. This
+is the line-opening prologue shared by the divider and inline-border emitters."
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " "))
+
;; ----------------------------- Inline Border ---------------------------------
(defun cj/--comment-inline-border (cmt-start cmt-end decoration-char text length)
@@ -138,10 +146,7 @@ LENGTH is the total width of the line."
(error "Length %d is too small for text '%s' (need at least %d more chars)"
length text (- min-space space-on-each-side)))
;; Generate the line
- (insert cmt-start)
- (when (equal cmt-start ";")
- (insert cmt-start))
- (insert " ")
+ (cj/--comment-emit-prefix cmt-start)
;; Left decoration
(dotimes (_ space-on-each-side)
(insert decoration-char))
@@ -181,48 +186,11 @@ Uses the lesser of `fill-column\\=' or 80 for line length."
CMT-START and CMT-END are the comment syntax strings.
DECORATION-CHAR is the character to use for the divider lines.
TEXT is the comment text.
-LENGTH is the total width of each line."
- (cj/--validate-decoration-char decoration-char)
- (let* ((current-column-pos (current-column))
- (min-length (+ current-column-pos
- (length cmt-start)
- (if (equal cmt-start ";") 1 0) ; doubled semicolon
- 1 ; space after comment-start
- 3 ; minimum decoration chars
- (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))
- (let* ((available-width (- length current-column-pos
- (length cmt-start)
- (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
- (line (make-string available-width (string-to-char decoration-char))))
- ;; Top line
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
- (insert line)
- (when (not (string-empty-p cmt-end))
- (insert " " cmt-end))
- (newline)
-
- ;; Text line
- (dotimes (_ current-column-pos) (insert " "))
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " " text)
- (when (not (string-empty-p cmt-end))
- (insert " " cmt-end))
- (newline)
+LENGTH is the total width of each line.
- ;; Bottom line
- (dotimes (_ current-column-pos) (insert " "))
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
- (insert line)
- (when (not (string-empty-p cmt-end))
- (insert " " cmt-end))
- (newline))))
+A simple divider is a padded divider with no padding before the text, so it
+delegates to `cj/--comment-padded-divider' with PADDING 0."
+ (cj/--comment-padded-divider cmt-start cmt-end decoration-char text length 0))
(defun cj/comment-simple-divider ()
"Insert a simple divider comment banner.
@@ -276,9 +244,7 @@ PADDING is the number of spaces before the text."
(if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
(line (make-string available-width (string-to-char decoration-char))))
;; Top line
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
+ (cj/--comment-emit-prefix cmt-start)
(insert line)
(when (not (string-empty-p cmt-end))
(insert " " cmt-end))
@@ -286,9 +252,7 @@ PADDING is the number of spaces before the text."
;; Text line with padding
(dotimes (_ current-column-pos) (insert " "))
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
+ (cj/--comment-emit-prefix cmt-start)
(dotimes (_ padding) (insert " "))
(insert text)
(when (not (string-empty-p cmt-end))
@@ -297,9 +261,7 @@ PADDING is the number of spaces before the text."
;; Bottom line
(dotimes (_ current-column-pos) (insert " "))
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
+ (cj/--comment-emit-prefix cmt-start)
(insert line)
(when (not (string-empty-p cmt-end))
(insert " " cmt-end))
@@ -335,12 +297,12 @@ Prompts for decoration character, text, padding, and length option."
;; -------------------------------- Comment Box --------------------------------
-(defun cj/--comment-box (cmt-start cmt-end decoration-char text length)
- "Internal implementation: Generate a 3-line box comment with centered text.
-CMT-START and CMT-END are the comment syntax strings.
-DECORATION-CHAR is the character to use for borders.
-TEXT is the comment text (centered).
-LENGTH is the total width of each line."
+(defun cj/--comment-box-emit (cmt-start cmt-end decoration-char text length heavy)
+ "Emit a box comment with centered TEXT; the border/text/border skeleton.
+CMT-START and CMT-END are the comment syntax strings. DECORATION-CHAR borders
+the box. LENGTH is the total width of each line. When HEAVY is non-nil, an
+interior blank-bordered line is added above and below the text line (the only
+difference between the plain box and the heavy box)."
(cj/--validate-decoration-char decoration-char)
(let* ((current-column-pos (current-column))
(comment-char (if (equal cmt-start ";") ";;" cmt-start))
@@ -363,11 +325,22 @@ LENGTH is the total width of each line."
(padding-each-side (max 1 (/ (- text-available text-length) 2)))
(right-padding (if (= (% (- text-available text-length) 2) 0)
padding-each-side
- (1+ padding-each-side))))
+ (1+ padding-each-side)))
+ ;; Interior side-border line: repeats the comment prefix and suffix so
+ ;; the blank rows stay valid comments in line-comment languages (elisp,
+ ;; Python). Only inserted for the heavy box.
+ (empty-line (concat comment-char " " decoration-char
+ (make-string (- available-width 2) ?\s)
+ decoration-char " " comment-end-char)))
;; Top border
(insert comment-char " " border-line " " comment-end-char)
(newline)
+ (when heavy
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert empty-line)
+ (newline))
+
;; Centered text line with side borders
(dotimes (_ current-column-pos) (insert " "))
(insert comment-char " " decoration-char " ")
@@ -377,11 +350,24 @@ LENGTH is the total width of each line."
(insert " " decoration-char " " comment-end-char)
(newline)
+ (when heavy
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert empty-line)
+ (newline))
+
;; Bottom border
(dotimes (_ current-column-pos) (insert " "))
(insert comment-char " " border-line " " comment-end-char)
(newline))))
+(defun cj/--comment-box (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a 3-line box comment with centered text.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for borders.
+TEXT is the comment text (centered).
+LENGTH is the total width of each line."
+ (cj/--comment-box-emit cmt-start cmt-end decoration-char text length nil))
+
(defun cj/comment-box ()
"Insert a 3-line comment box with centered text.
Prompts for decoration character, text, and uses `fill-column' for length."
@@ -404,62 +390,11 @@ Prompts for decoration character, text, and uses `fill-column' for length."
CMT-START and CMT-END are the comment syntax strings.
DECORATION-CHAR is the character to use for borders.
TEXT is the comment text (centered).
-LENGTH is the total width of each line."
- (cj/--validate-decoration-char decoration-char)
- (let* ((current-column-pos (current-column))
- (comment-char (if (equal cmt-start ";") ";;" cmt-start))
- (comment-end-char (if (string-empty-p cmt-end) comment-char cmt-end))
- (min-length (+ current-column-pos
- (length comment-char)
- 2 ; spaces around content
- (length comment-end-char)
- 6))) ; 3 border chars + text space + 3 border chars
- (when (< length min-length)
- (error "Length %d is too small to generate comment (minimum %d)" length min-length))
- (let* ((available-width (- length current-column-pos
- (length comment-char)
- (length comment-end-char)
- 2)) ; spaces around content
- (border-line (make-string available-width (string-to-char decoration-char)))
- (text-available (- available-width 4)) ; 2 side decorations, 2 spaces
- (text-length (length text))
- (padding-each-side (max 1 (/ (- text-available text-length) 2)))
- (right-padding (if (= (% (- text-available text-length) 2) 0)
- padding-each-side
- (1+ padding-each-side)))
- ;; Interior side-border lines repeat the comment prefix and suffix so
- ;; the empty/text rows stay valid comments in line-comment languages
- ;; (elisp, Python). Previously they began with a bare decoration char.
- (empty-line (concat comment-char " " decoration-char
- (make-string (- available-width 2) ?\s)
- decoration-char " " comment-end-char)))
- ;; Top border
- (insert comment-char " " border-line " " comment-end-char)
- (newline)
-
- ;; Empty line with side borders
- (dotimes (_ current-column-pos) (insert " "))
- (insert empty-line)
- (newline)
-
- ;; Centered text line
- (dotimes (_ current-column-pos) (insert " "))
- (insert comment-char " " decoration-char " ")
- (dotimes (_ padding-each-side) (insert " "))
- (insert text)
- (dotimes (_ right-padding) (insert " "))
- (insert " " decoration-char " " comment-end-char)
- (newline)
-
- ;; Empty line with side borders
- (dotimes (_ current-column-pos) (insert " "))
- (insert empty-line)
- (newline)
+LENGTH is the total width of each line.
- ;; Bottom border
- (dotimes (_ current-column-pos) (insert " "))
- (insert comment-char " " border-line " " comment-end-char)
- (newline))))
+A heavy box is a box with an interior blank-bordered line above and below the
+text, so it delegates to `cj/--comment-box-emit' with HEAVY non-nil."
+ (cj/--comment-box-emit cmt-start cmt-end decoration-char text length t))
(defun cj/comment-heavy-box ()
"Insert a heavy box comment with blank lines around centered text.
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index b7e33337e..c86f3d1bf 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -119,6 +119,35 @@ through a `../' or absolute path. Pure helper."
(and (not (string-empty-p name))
(not (string-match-p "/" name))))
+(defun cj/--playlist-resolve-target ()
+ "Prompt for a playlist name and return the .m3u path to write under `music-dir'.
+Re-prompt until the name is a safe bare filename (no `/'). When the target
+already exists, ask whether to overwrite, cancel, or rename: overwrite returns
+the path, cancel signals a `user-error', rename re-prompts. Interactive
+prompting only -- the caller does the file write."
+ (let ((base-name nil)
+ (playlist-path nil)
+ (done nil))
+ (while (not done)
+ (setq base-name (cj/--playlist-sanitize-name
+ (read-string "Playlist name (without .m3u): ")))
+ (cond
+ ((not (cj/--playlist-name-safe-p base-name))
+ (message "Playlist name must be a bare filename, without '/'."))
+ (t
+ (setq playlist-path (expand-file-name (concat base-name ".m3u") music-dir))
+ (if (not (file-exists-p playlist-path))
+ (setq done t)
+ (let ((choice (read-char-choice
+ (format "Playlist '%s' exists. [o]verwrite, [c]ancel, [r]ename? "
+ (file-name-nondirectory playlist-path))
+ '(?o ?c ?r))))
+ (cl-case choice
+ (?o (setq done t))
+ (?c (user-error "Cancelled playlist creation"))
+ (?r (setq done nil))))))))
+ playlist-path))
+
(defun cj/dired-create-playlist-from-marked ()
"Create an .m3u playlist file from marked files in Dired (or Dirvish).
Filters for audio files, prompts for the playlist name, and saves the resulting
@@ -131,27 +160,7 @@ Filters for audio files, prompts for the playlist name, and saves the resulting
(if (zerop count)
(user-error "No audio files marked (extensions: %s)"
(string-join cj/audio-file-extensions ", "))
- (let ((base-name nil)
- (playlist-path nil)
- (done nil))
- (while (not done)
- (setq base-name (cj/--playlist-sanitize-name
- (read-string "Playlist name (without .m3u): ")))
- (cond
- ((not (cj/--playlist-name-safe-p base-name))
- (message "Playlist name must be a bare filename, without '/'."))
- (t
- (setq playlist-path (expand-file-name (concat base-name ".m3u") music-dir))
- (if (not (file-exists-p playlist-path))
- (setq done t)
- (let ((choice (read-char-choice
- (format "Playlist '%s' exists. [o]verwrite, [c]ancel, [r]ename? "
- (file-name-nondirectory playlist-path))
- '(?o ?c ?r))))
- (cl-case choice
- (?o (setq done t))
- (?c (user-error "Cancelled playlist creation"))
- (?r (setq done nil))))))))
+ (let ((playlist-path (cj/--playlist-resolve-target)))
(with-temp-file playlist-path
(dolist (af audio-files)
(insert af "\n")))
diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el
index ad17ea913..230a8532c 100644
--- a/modules/dwim-shell-config.el
+++ b/modules/dwim-shell-config.el
@@ -210,6 +210,41 @@ The timestamp is interpolated here with `format-time-string' so it can't sit
dead inside the shell's single quotes the way a literal =$(date ...)= did."
(format "cp -p '<<f>>' '<<f>>.%s.bak'" (format-time-string "%Y%m%d_%H%M%S")))
+(defun cj/dwim-shell--tar-gzip-command (single-p)
+ "Return the tar-gzip command template.
+SINGLE-P non-nil names the archive after the lone file (=<fne>.tar.gz=);
+otherwise a shared =archive.tar.gz= over all marked files."
+ (if single-p
+ "tar czf '<<fne>>.tar.gz' '<<f>>'"
+ "tar czf '<<archive.tar.gz(u)>>' '<<*>>'"))
+
+(defun cj/dwim-shell--text-to-speech-command (system voice)
+ "Return the text-to-speech command template for SYSTEM using VOICE.
+SYSTEM is a `system-type' symbol: `darwin' synthesizes with `say' and VOICE;
+any other system uses `espeak' (VOICE unused)."
+ (if (eq system 'darwin)
+ (format "say -v %s -o '<<fne>>.aiff' -f '<<f>>'" voice)
+ "espeak -f '<<f>>' -w '<<fne>>.wav'"))
+
+(defun cj/dwim-shell--video-trim-command (trim-type start end)
+ "Return the ffmpeg video-trim command template for TRIM-TYPE.
+TRIM-TYPE is \"Beginning\", \"End\", or \"Both\". START trims that many
+seconds off the front, END off the back (each ignored for the side it does
+not apply to). Signals a `user-error' when a used second count is negative."
+ (pcase trim-type
+ ("Beginning"
+ (when (< start 0) (user-error "Seconds must be non-negative"))
+ (format "ffmpeg -i '<<f>>' -y -ss %d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ start))
+ ("End"
+ (when (< end 0) (user-error "Seconds must be non-negative"))
+ (format "ffmpeg -sseof -%d -i '<<f>>' -y -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ end))
+ ("Both"
+ (when (or (< start 0) (< end 0)) (user-error "Seconds must be non-negative"))
+ (format "ffmpeg -i '<<f>>' -y -ss %d -sseof -%d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ start end))))
+
;; ----------------------------- Dwim Shell Command ----------------------------
(use-package dwim-shell-command
@@ -357,9 +392,8 @@ Otherwise, unzip it to an appropriately named subdirectory "
"Tar gzip all marked files into archive.tar.gz."
(interactive)
(dwim-shell-command-on-marked-files
- "Tar gzip" (if (eq 1 (seq-length (dwim-shell-command--files)))
- "tar czf '<<fne>>.tar.gz' '<<f>>'"
- "tar czf '<<archive.tar.gz(u)>>' '<<*>>'")
+ "Tar gzip" (cj/dwim-shell--tar-gzip-command
+ (eq 1 (seq-length (dwim-shell-command--files))))
:utils "tar"))
(defun cj/dwim-shell-commands-epub-to-org ()
@@ -448,34 +482,18 @@ process list, and the file is removed only after the spawned process exits."
"Trim video with options for beginning, end, or both."
(interactive)
(let* ((trim-type (completing-read "Trim from: "
- '("Beginning" "End" "Both")
- nil t))
- (command (pcase trim-type
- ("Beginning"
- (let ((seconds (read-number "Seconds to trim from beginning: " 5)))
- (when (< seconds 0)
- (user-error "Seconds must be non-negative"))
- (format "ffmpeg -i '<<f>>' -y -ss %d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
- seconds)))
- ("End"
- (let ((seconds (read-number "Seconds to trim from end: " 5)))
- (when (< seconds 0)
- (user-error "Seconds must be non-negative"))
- (format "ffmpeg -sseof -%d -i '<<f>>' -y -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
- seconds)))
- ("Both"
- (let ((start (read-number "Seconds to trim from beginning: " 5))
- (end (read-number "Seconds to trim from end: " 5)))
- (when (or (< start 0) (< end 0))
- (user-error "Seconds must be non-negative"))
- (format "ffmpeg -i '<<f>>' -y -ss %d -sseof -%d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
- start end))))))
- (dwim-shell-command-on-marked-files
+ '("Beginning" "End" "Both")
+ nil t))
+ (start (if (member trim-type '("Beginning" "Both"))
+ (read-number "Seconds to trim from beginning: " 5) 0))
+ (end (if (member trim-type '("End" "Both"))
+ (read-number "Seconds to trim from end: " 5) 0))
+ (command (cj/dwim-shell--video-trim-command trim-type start end)))
+ (dwim-shell-command-on-marked-files
(format "Trim video (%s)" trim-type)
command
:silent-success t
:utils "ffmpeg")))
-
(defun cj/dwim-shell-commands-drop-audio-from-video ()
"Drop audio from all marked videos."
(interactive)
@@ -694,9 +712,7 @@ all marked files rather than once per file."
"en")))
(dwim-shell-command-on-marked-files
"Text to speech"
- (if (eq system-type 'darwin)
- (format "say -v %s -o '<<fne>>.aiff' -f '<<f>>'" voice)
- "espeak -f '<<f>>' -w '<<fne>>.wav'")
+ (cj/dwim-shell--text-to-speech-command system-type voice)
:utils (if (eq system-type 'darwin) "say" "espeak"))))
(defun cj/dwim-shell-commands-remove-empty-directories ()
diff --git a/modules/term-config.el b/modules/term-config.el
index 33f54d75a..0a7991409 100644
--- a/modules/term-config.el
+++ b/modules/term-config.el
@@ -313,8 +313,9 @@ Symbol: right, left, or below. `above' is never stored. nil means use the
default `below' for F12's traditional bottom split.")
(defvar cj/--term-toggle-last-size nil
- "Last user-chosen body size for the F12 terminal display.
-Positive integer: body-cols (right/left) or body-lines (below/above).
+ "Last user-chosen size for the F12 terminal display.
+Positive integer: body-cols (right/left) or total-lines (below/above) -- see
+`cj/window-replay-size' for why the vertical axis uses total, not body.
nil means fall back to `cj/term-toggle-window-height' as a fraction.")
(defun cj/--term-toggle-buffer-p (buffer)
diff --git a/scripts/theme-studio/WIP.json b/scripts/theme-studio/WIP.json
index 830a013af..9735383e4 100644
--- a/scripts/theme-studio/WIP.json
+++ b/scripts/theme-studio/WIP.json
@@ -7390,32 +7390,36 @@
},
"orderless": {
"orderless-match-face-0": {
- "fg": "#223fbf",
+ "fg": "#cbd0d6",
"bg": null,
"weight": "bold",
+ "slant": "italic",
"inherit": null,
- "source": "default"
+ "source": "user"
},
"orderless-match-face-1": {
- "fg": "#8f0075",
+ "fg": "#c99990",
"bg": null,
"weight": "bold",
+ "slant": "italic",
"inherit": null,
- "source": "default"
+ "source": "user"
},
"orderless-match-face-2": {
- "fg": "#145a00",
+ "fg": "#c5d4ae",
"bg": null,
"weight": "bold",
+ "slant": "italic",
"inherit": null,
- "source": "default"
+ "source": "user"
},
"orderless-match-face-3": {
- "fg": "#804000",
+ "fg": "#bea9dc",
"bg": null,
"weight": "bold",
+ "slant": "italic",
"inherit": null,
- "source": "default"
+ "source": "user"
}
},
"org-roam": {
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index a69d958c0..f02191c67 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -156,17 +156,6 @@ function resolveUiAttr(face,attr,uimap){
return walkInheritChain(face,f=>UI_INHERIT[f],f=>uimap[f]&&uimap[f][attr]);
}
-// Text color for a swatch-dropdown popup row. A row showing a real palette color
-// sits on the popup's own fixed background, so its name/hex text must inherit the
-// popup foreground (return '' to use the CSS color). Coloring it for contrast
-// against the swatch instead picks near-black text for a mid/dark swatch, which
-// is unreadable on the dark popup. Only the "default" row, filled solid with
-// SHOWN, uses a contrast color computed against that fill.
-function dropdownRowTextColor(hex,shown,textOnFn){
- if(hex)return '';
- return shown?textOnFn(shown):'';
-}
-
// Turn a theme name into a safe filename slug: collapse runs of disallowed
// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
@@ -566,4 +555,4 @@ function composeHoverTitle(doc,base){
return doc||base;
}
-export { nameToHex, migrateLegacyFace, cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, clampHeight, HEIGHT_MIN, HEIGHT_MAX, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
+export { nameToHex, migrateLegacyFace, cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, clampHeight, HEIGHT_MIN, HEIGHT_MAX, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index 5d747b1d8..503d7ea11 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -1,3 +1,52 @@
+// Shared gate harness. Each call site keeps its literal location.hash==='#NAMEtest'
+// check (run-tests.sh greps it); gate() owns the ok/notes/A setup and the verdict
+// postamble. Note format standardized to ' fails=note1,note2'.
+function gate(id, body){
+ const name=id.toUpperCase();
+ let ok=true;const notes=[];
+ const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ body(A);
+ const verdict=name+' '+(ok?'PASS':'FAIL');
+ document.title=verdict;
+ const d=document.createElement('div');d.id=id;
+ d.textContent=verdict+(notes.length?' fails='+notes.join(','):'');
+ document.body.appendChild(d);
+}
+function withSavedState(keys, body){
+ // Snapshot the named studio globals, run BODY, then restore them in a finally
+ // so opening the studio at a #gate hash doesn't leave its state mutated for
+ // interactive use. Each key maps to a [get, set, clone] triple over the live
+ // let-binding. Scope the keys to what the gate actually touches.
+ // JSON clone (not structuredClone): the studio data objects carry values
+ // structuredClone throws on, and a JSON round-trip of the data is exactly what
+ // the gates' own local saves already use.
+ const jc=x=>JSON.parse(JSON.stringify(x));
+ const reg={
+ PALETTE:[()=>PALETTE, v=>{PALETTE=v;}, jc],
+ MAP:[()=>MAP, v=>{MAP=v;}, jc],
+ SYNTAX:[()=>SYNTAX, v=>{SYNTAX=v;}, jc],
+ UIMAP:[()=>UIMAP, v=>{UIMAP=v;}, jc],
+ PKGMAP:[()=>PKGMAP, v=>{PKGMAP=v;}, jc],
+ LOCKED:[()=>LOCKED, v=>{LOCKED.clear();for(const k of v)LOCKED.add(k);}, s=>new Set(s)],
+ };
+ const snap=keys.map(k=>[k, reg[k][2](reg[k][0]())]);
+ try{ body(); }
+ finally{ for(const [k,v] of snap) reg[k][1](v); }
+}
+// Shared preview-face validator for the #mdtest / #mupreviewtest / #gnustest
+// gates: render HTML into a detached div, then assert it exercises at least
+// MINCOUNT data-faces, that every data-face is a real face of the package
+// (drawn from FACES, the app's face rows), and that each face in REQUIRED is
+// present. A is the gate's assertion collector; NAME labels the failure note.
+function assertPreviewFaces(A, html, faces, minCount, name, required){
+ const box=document.createElement('div');box.innerHTML=html;
+ const valid=new Set((faces||[]).map(r=>r[0]));
+ const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
+ A(used.length>=minCount,'preview exercises many faces ('+used.length+')');
+ const bad=used.filter(f=>!valid.has(f));
+ A(bad.length===0,'every data-face is a real '+name+' face; bad='+bad.join(','));
+ for(const f of required) A(used.includes(f),'preview includes '+f);
+}
// Phase-1 self-test (open with #selftest): seed -> export -> import -> compare.
function pkgSelftest(){
const seeded=seedPkgmap();
@@ -24,7 +73,7 @@ if(location.hash==='#selftest')pkgSelftest();
// 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
// leave locked rows (syntax bare-kind, ui:, pkg: keys) untouched.
-if(location.hash==='#locktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#locktest')gate('locktest',A=>withSavedState(['PALETTE','MAP','SYNTAX','UIMAP','PKGMAP','LOCKED'],()=>{
const cssRgb=h=>{const [r,g,b]=hex2rgb(h);return 'rgb('+r+', '+g+', '+b+')';};
LOCKED.clear();buildTable();
{const k=CATS.map(c=>c[0]).filter(k=>k!=='bg'&&k!=='p')[0];
@@ -94,13 +143,12 @@ if(location.hash==='#locktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
if(filter&&faces.length>1){filter.value=faces[0];buildPkgTable();const b=document.getElementById('pkglocktoggle');b.click();
A(faces.every(face=>LOCKED.has('pkg:'+app+':'+face)),'pkg lock-all covers the whole package even when filtered');
filter.value='';buildPkgTable();}}
- document.title='LOCKTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='locktest';d.textContent='LOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ }));
// Sort gate (open with #sorttest): all three tables now share srtTable/cellVal.
// Verifies the syntax table (which used to have its own srt) sorts by color
// value and by element name, that a repeat click reverses, and that the UI and
// package tables still sort. Guards the unified sort for the later stages.
-if(location.hash==='#sorttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#sorttest')gate('sorttest',A=>{
const ddVals=tb=>[...document.querySelectorAll('#'+tb+' tr:not(.detailrow)')].map(tr=>{const dd=tr.cells[2].querySelector('.cdd');return dd?(dd.dataset.val||''):'';});
const txtVals=tb=>[...document.querySelectorAll('#'+tb+' tr:not(.detailrow)')].map(tr=>tr.cells[1].innerText.trim().toLowerCase());
const asc=a=>a.every((v,i)=>i===0||a[i-1]<=v),desc=a=>a.every((v,i)=>i===0||a[i-1]>=v);
@@ -110,13 +158,12 @@ if(location.hash==='#sorttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
srtTable('legbody',1);A(asc(txtVals('legbody')),'legbody-elements-asc');
buildUITable();srtTable('uibody',1);A(asc(txtVals('uibody')),'uibody-face-asc');
buildPkgTable();srtTable('pkgbody',2);A(asc(ddVals('pkgbody')),'pkgbody-fg-asc');
- document.title='SORTTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='sorttest';d.textContent='SORTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Live-buffer rendering gate (open with #mocktest): pins the face-faithfulness
// fixes so they cannot silently regress — overlay faces keep syntax colors and
// honor their styles, the cursor sits on a glyph, line numbers honor weight, the
// fringe shows its foreground indicator, and the mode-line carries its box.
-if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMAP'],()=>{
const Q=s=>document.querySelector('#mockframe '+s);
buildMockFrame();
A(Q('[data-face="highlight"] [data-k]'),'highlight-keeps-token-colors');
@@ -165,13 +212,12 @@ if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(pkgWeight()&&pkgWeight().dataset.val==='','pkg weight dropdown starts empty when model is unset');
pickEnum(pkgWeight(),'heavy');
A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited');
- document.title='MOCKTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='mocktest';d.textContent='MOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ }));
// Palette-generator gate (open with #generatortest): previewing is non-mutating,
// clicking a generated tile loads the existing selector, adding creates a normal
// singleton base column, and appending a preview column commits all span members
// under one stable column id.
-if(location.hash==='#generatortest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#generatortest')gate('generatortest',A=>{
const before=JSON.stringify(PALETTE);
A(document.getElementById('genaccents').value==='5','default accent count is 5');
A(document.getElementById('gensource').value==='palette','default generator source is palette');
@@ -221,12 +267,11 @@ if(location.hash==='#generatortest'){let ok=true;const notes=[];const A=(c,n)=>{
GEN_PROPOSAL={summary:{generated:1,rejected:0,minContrast:null},columns:[{name:'medium-aquamarine',members:[{name:'medium-aquamarine',hex:'#66cdaa',offset:0,columnId:'medium-aquamarine'}]}]};
renderGeneratorPreview();
A(document.querySelector('#genpreview .genchip .gn').textContent==='medium aquamarine','generated tile names display spaces instead of hyphens');
- document.title='GENERATORTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='generatortest';d.textContent='GENERATORTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Auto-dim gate (open with #autodimtest): the bespoke split preview shows the
// selected language in both panes -- the left in real syntax colors, the right
// collapsed to the single auto-dim-other-buffers face -- and tracks the langsel.
-if(location.hash==='#autodimtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#autodimtest')gate('autodimtest',A=>{
const langs=Object.keys(SAMPLES),ls=document.getElementById('langsel');
ls.value=langs[0];
const box=document.createElement('div');box.innerHTML=renderAutodimPreview();
@@ -240,8 +285,7 @@ if(location.hash==='#autodimtest'){let ok=true;const notes=[];const A=(c,n)=>{if
if(langs.length>1){const t1=box.textContent;ls.value=langs[1];
const box2=document.createElement('div');box2.innerHTML=renderAutodimPreview();
A(box2.textContent!==t1,'preview tracks the language selector');ls.value=langs[0];}
- document.title='AUTODIMTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='autodimtest';d.textContent='AUTODIMTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
if(location.hash.startsWith('#pick')){openPicker();const m=location.hash.slice(5);if(m){const b=document.querySelector('.pmode button[data-m="'+m+'"]');if(b)b.click();}}
if(location.hash==='#cursortest'){document.getElementById('newhexstr').value='#67809c';openPicker();const sc=document.getElementById('svcur'),hc=document.getElementById('huecur');const L=parseFloat(sc.style.left||'0'),T=parseFloat(sc.style.top||'0'),H=parseFloat(hc.style.top||'0');const ok=L>1&&T>1&&H>1;document.title='CURSORTEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='cursortest';d.textContent='CURSORTEST '+(ok?'PASS':'FAIL')+' left='+sc.style.left+' top='+sc.style.top+' hue='+hc.style.top;document.body.appendChild(d);}
if(location.hash.startsWith('#app')){const ap=location.hash.slice(4),s=document.getElementById('appsel');if(s&&ap){s.value=ap;pkgChanged();}}
@@ -292,7 +336,7 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById('
// Worst-case readout gate (open with #contrasttest): a covered overlay face shows
// the floor over its foreground set and names the limiting foreground, an
// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set".
-if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#contrasttest')gate('contrasttest',A=>{
const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP));
CATS.forEach(c=>{if(c[0]!=='bg'&&c[0]!=='p')setSyntaxFg(c[0],'');});
setSyntaxFg('p','#f0fef0');setSyntaxFg('kw','#67809c');setSyntaxFg('str','#a3b18a');setSyntaxFg('bg','#000000');
@@ -357,12 +401,11 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i
}else A(false,'syntax table has a p row with a dropdown');
if(pLocked){LOCKED.add('p');buildTable();}
for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);syncSyntaxFromCache();for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();applyGround();
- document.title='CONTRASTTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='contrasttest';d.textContent='CONTRASTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Bevel gate (open with #beveltest): released/pressed boxes derive their
// highlight and shadow from the face's effective bg per Emacs's relief
// algorithm, and pressed draws the shadow edge first.
-if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#beveltest')gate('beveltest',A=>{
const saveUI=JSON.parse(JSON.stringify(UIMAP)),saveP=PALETTE.slice(),savePK=JSON.parse(JSON.stringify(PKGMAP));
UIMAP['mode-line']={fg:'#d8dee9',bg:'#30343c',weight:null,slant:null,underline:null,strike:null,box:{style:'released',width:1,color:null}};
buildUITable();
@@ -388,14 +431,13 @@ if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
if(pdd){pdd.click();const redRow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>(c.dataset.name||'').includes('red'));if(redRow)redRow.click();}
A(PKGMAP[app][face].box&&PKGMAP[app][face].box.color==='#ff0000','package box color dropdown writes box.color');
PALETTE=saveP;PKGMAP=savePK;for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();buildPkgTable();
- document.title='BEVELTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='beveltest';d.textContent='BEVELTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Gallery gate (open with #gallerytest): the color dropdown opens a 2D grid in
// the palette-panel shape. Driven on a throwaway dropdown so no real face state
// is mutated. Covers: grid opens, every palette color has a cell, a cell click
// fires onPick + updates the trigger, the pick highlights on reopen, the default
// chip clears.
-if(location.hash==='#gallerytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#gallerytest')gate('gallerytest',A=>withSavedState(['MAP','SYNTAX'],()=>{
let picked='__none__';
const dd=mkColorDropdown(ddList(''),'',(hex)=>{picked=hex;},{});
document.body.appendChild(dd);
@@ -419,11 +461,10 @@ if(location.hash==='#gallerytest'){let ok=true;const notes=[];const A=(c,n)=>{if
trig.click();const defc=document.querySelector('.cddpop.cddgrid .cddgdef');if(defc)defc.click();
A(picked==='','the default chip clears the assignment: '+JSON.stringify(picked));
dd.remove();closeColorDropdown();
- document.title='GALLERYTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='gallerytest';d.textContent='GALLERYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ }));
// Preview-link gate (open with #previewlinktest): known bespoke-preview face
// mappings stay wired to the face that Emacs actually uses.
-if(location.hash==='#previewlinktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#previewlinktest')gate('previewlinktest',A=>{
const box=document.createElement('div');
box.innerHTML=renderOrgPreview();
const headline=[...box.querySelectorAll('[data-face="org-headline-todo"]')].find(e=>e.textContent.includes('Heading three'));
@@ -438,11 +479,10 @@ if(location.hash==='#previewlinktest'){let ok=true;const notes=[];const A=(c,n)=
const bob=[...box.querySelectorAll('[data-face="erc-default-face"]')].some(e=>e.textContent.includes('hi craig'));
A(own,'erc own sent message uses erc-input-face');
A(bob,'erc remote message uses erc-default-face');
- document.title='PREVIEWLINKTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='previewlinktest';d.textContent='PREVIEWLINKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe
// lightness band for a selected covered face and hides it when no face is selected.
-if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#safetest')gate('safetest',A=>withSavedState(['MAP','SYNTAX'],()=>{
const saveMAP=Object.assign({},MAP);
setSyntaxFg('p','#f0fef0');setSyntaxFg('kw','#67809c');setSyntaxFg('bg','#000000');
document.getElementById('newhexstr').value='#202830';openPicker();setPkModel('oklch');
@@ -454,11 +494,10 @@ if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(band&&band.style.display==='none','safe band hidden when no face is selected');
for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);syncSyntaxFromCache();
setPkModel('hsv');closePicker();
- document.title='SAFETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='safetest';d.textContent='SAFETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ }));
// Gone-rebind gate (open with #healtest): deleting a named color then recreating
// the name re-points face references stranded on the old hex to the new color.
-if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#healtest')gate('healtest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx;
PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];setSyntaxFg('kw','#67809c');lastGone={};selectedIdx=null;renderPalette();buildTable();
const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue');
@@ -471,12 +510,11 @@ if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(!('blue' in lastGone),'heal consumed the gone entry');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel;
renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
- document.title='HEALTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Column-strip gate (open with #columntest): the palette renders as a pinned
// ground column plus structural columns, chips keep their controls, and renaming
// a color leaves it in the same strip because the column id is stable.
-if(location.hash==='#columntest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#columntest')gate('columntest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveG=Object.assign({},lastGone),saveSel=selectedIdx;
setSyntaxFg('bg','#0d0b0a');setSyntaxFg('p','#f0fef0');
PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette();
@@ -559,13 +597,12 @@ if(location.hash==='#columntest'){let ok=true;const notes=[];const A=(c,n)=>{if(
A(lastGone['blue']==='#3a6ea5'&&lastGone['blue+1']==='#92acc2','clear palette records removed names for recovery');
A(selectedIdx===null,'clear palette clears selected color');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();lastGone=saveG;selectedIdx=saveSel;renderPalette();
- document.title='COLUMNTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='columntest';d.textContent='COLUMNTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Count-control gate (open with #counttest): the per-column count regenerates the
// column — count up adds symmetric steps, count down drops the extremes, a
// reference to a surviving step follows the new hex, a reference to a removed step
// is left on its old (now-gone) hex.
-if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#counttest')gate('counttest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx;
paletteShowFull=true; // this gate asserts span tiles, so render the full palette
setSyntaxFg('bg','#204060');setSyntaxFg('p','#f0fef0');
@@ -611,12 +648,11 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
const lo=_lum(MAP['bg']),hi=_lum(MAP['p']),blue=PALETTE.filter(p=>p[2]==='blue').map(p=>_lum(p[0]));
A(blue.length&&blue.every(L=>L>=lo-1e-6&&L<=hi+1e-6),'generated span stays within the bg/fg bounds');}
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
- document.title='COUNTTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Base-edit + ground-edit gate (open with #baseedittest): editing a column base
// recolors the whole column at the same count and references follow; editing a
// ground swatch writes the bg/fg assignment.
-if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#baseedittest')gate('baseedittest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx;
setSyntaxFg('bg','#0d0b0a');setSyntaxFg('p','#f0fef0');
PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']];
@@ -647,11 +683,10 @@ if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{i
A(!PALETTE.some(p=>/^fg[+-]\d+$/.test(p[1])),'editing fg does not generate fg span tiles from a same-hex normal column');
A(PALETTE.find(p=>p[1]==='fg')[2]==='ground','editing fg preserves the ground column id');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
- document.title='BASEEDITTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Round-trip gate (open with #roundtriptest): export stays a flat palette with
// stable column ids, and import does not need color-derived column reconstruction.
-if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#roundtriptest')gate('roundtriptest',A=>{
const stable=o=>Array.isArray(o)?o.map(stable):(o&&typeof o==='object'?Object.fromEntries(Object.keys(o).sort().map(k=>[k,stable(o[k])])):o);
const diff=(a,b,p='')=>{if(JSON.stringify(a)===JSON.stringify(b))return '';if(typeof a!==typeof b||!a||!b||typeof a!=='object')return p+': '+JSON.stringify(a)+' != '+JSON.stringify(b);
const ks=[...new Set([...Object.keys(a),...Object.keys(b)])].sort();for(const k of ks){const d=diff(a[k],b[k],p?p+'.'+k:k);if(d)return d;}return p;};
@@ -669,13 +704,12 @@ if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{
A(obj.palette.some(e=>e[1]==='renamed-blue'&&e[2]==='blue'),'renamed color keeps its stable column id through export/import');
A(obj.locks&&obj.locks.includes('kw')&&obj.locks.includes('ui:region'),'lock state survives export/import');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();LOCKED=saveL;
- document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// View-selector gate (open with #viewtest): the assignment panel is driven by a
// single #viewsel dropdown -- two editor entries (@code, @ui) then a "package
// faces" optgroup of every app, alphabetically by label -- and switching it
// shows exactly one of the three view blocks.
-if(location.hash==='#viewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#viewtest')gate('viewtest',A=>{
const sel=document.getElementById('viewsel');
A(!!sel,'viewsel-exists');
if(sel){
@@ -695,14 +729,13 @@ if(location.hash==='#viewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(curApp()===firstApp,'curApp-returns-selected-app');
A(!document.querySelector('#pkgbody .sbtn[title="reset to default"]'),'no-per-row-reset-button');
}
- document.title='VIEWTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='viewtest';d.textContent='VIEWTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Non-default-marker gate (open with #ndtest): a per-face setting cell gets the
// .nd corner flag only when its value differs from the face's seed default. Cell
// order in a pkg row: 0 lock, 1 label, 2 fg, 3 bg, 4 style, 5 box, 6 contrast.
// inherit + height live in the row expander, so a non-default height flags the
// expander toggle (exp-nd) rather than an inline cell.
-if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#ndtest')gate('ndtest',A=>withSavedState(['PKGMAP','LOCKED'],()=>{
LOCKED.clear();
const app=curApp(),row=APPS[app].faces[0],face=row[0];
PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
@@ -717,22 +750,20 @@ if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){
A(tr2.cells[4].classList.contains('nd'),'toggled-weight-marks-style-box');
A(!tr2.querySelector('.exptoggle').classList.contains('exp-nd'),'restored-height-unflags-expander');
PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
- document.title='NDTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='ndtest';d.textContent='NDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ }));
// Contrast-cell gate (open with #crtest): the per-face contrast column shows a
// bare colored number (no PASS/FAIL word); the WCAG verdict lives in the hover.
-if(location.hash==='#crtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#crtest')gate('crtest',A=>{
const app=curApp(),face=APPS[app].faces[0][0];buildPkgTable();
const cell=document.querySelector('#pkgbody tr[data-face="'+face+'"]').cells[6];
const span=cell&&cell.querySelector('span');
A(span&&/^\d+\.\d$/.test(span.textContent.trim()),'contrast cell is a bare number: '+(span&&span.textContent));
A(span&&!/PASS|FAIL/.test(span.textContent),'no PASS/FAIL word in the contrast cell');
A(span&&span.title&&/(passes|fails) WCAG/i.test(span.title),'contrast cell carries a WCAG hover: '+(span&&span.title));
- document.title='CRTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='crtest';d.textContent='CRTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// View-nav gate (open with #navtest): the prev/next arrows flanking the view
// dropdown step the selection (clamped, no wrap) and re-render the view.
-if(location.hash==='#navtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#navtest')gate('navtest',A=>{
const sel=document.getElementById('viewsel'),prev=document.getElementById('viewprev'),next=document.getElementById('viewnext');
A(!!prev&&!!next,'nav arrows exist');
if(sel&&prev&&next){
@@ -746,57 +777,35 @@ if(location.hash==='#navtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c)
sel.selectedIndex=2;onViewChange();
A(sel.options[2]&&sel.options[2].value[0]!=='@'&&vis('view-pkg'),'stepping to a package shows the pkg view');
}
- document.title='NAVTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='navtest';d.textContent='NAVTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Markdown-preview gate (open with #mdtest): markdown-mode has a dedicated README
// renderer, and every data-face it emits is a real markdown-mode face.
-if(location.hash==='#mdtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#mdtest')gate('mdtest',A=>{
A(APPS['markdown-mode']&&APPS['markdown-mode'].preview==='markdown','markdown-mode wired to the markdown preview');
A(!!PACKAGE_PREVIEWS['markdown'],'markdown renderer registered');
if(PACKAGE_PREVIEWS['markdown']&&APPS['markdown-mode']){
- const box=document.createElement('div');box.innerHTML=PACKAGE_PREVIEWS['markdown']();
- const valid=new Set(APPS['markdown-mode'].faces.map(r=>r[0]));
- const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
- A(used.length>=15,'preview exercises many faces ('+used.length+')');
- const bad=used.filter(f=>!valid.has(f));
- A(bad.length===0,'every data-face is a real markdown face; bad='+bad.join(','));
- for(const f of ['markdown-header-face-1','markdown-bold-face','markdown-inline-code-face','markdown-blockquote-face','markdown-gfm-checkbox-face','markdown-table-face'])
- A(used.includes(f),'preview includes '+f);
+ assertPreviewFaces(A, PACKAGE_PREVIEWS['markdown'](), APPS['markdown-mode'].faces, 15, 'markdown',
+ ['markdown-header-face-1','markdown-bold-face','markdown-inline-code-face','markdown-blockquote-face','markdown-gfm-checkbox-face','markdown-table-face']);
}
- document.title='MDTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='mdtest';d.textContent='MDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// mu4e-preview gate (open with #mupreviewtest): the mu4e preview is a realistic
// headers list + message view, and every data-face it emits is a real mu4e face.
-if(location.hash==='#mupreviewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
- const box=document.createElement('div');box.innerHTML=renderMu4ePreview();
- const valid=new Set((APPS['mu4e']&&APPS['mu4e'].faces||[]).map(r=>r[0]));
- const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
- A(used.length>=20,'preview exercises many faces ('+used.length+')');
- const bad=used.filter(f=>!valid.has(f));
- A(bad.length===0,'every data-face is a real mu4e face; bad='+bad.join(','));
- for(const f of ['mu4e-unread-face','mu4e-flagged-face','mu4e-replied-face','mu4e-draft-face','mu4e-trashed-face','mu4e-header-highlight-face','mu4e-header-marks-face','mu4e-contact-face','mu4e-compose-separator-face'])
- A(used.includes(f),'preview includes '+f);
- document.title='MUPREVIEWTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='mupreviewtest';d.textContent='MUPREVIEWTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+if(location.hash==='#mupreviewtest')gate('mupreviewtest',A=>{
+ assertPreviewFaces(A, renderMu4ePreview(), APPS['mu4e']&&APPS['mu4e'].faces, 20, 'mu4e',
+ ['mu4e-unread-face','mu4e-flagged-face','mu4e-replied-face','mu4e-draft-face','mu4e-trashed-face','mu4e-header-highlight-face','mu4e-header-marks-face','mu4e-contact-face','mu4e-compose-separator-face']);
+ });
// gnus-preview gate (open with #gnustest): gnus is its own view package (it drives
// the mu4e article view), and every data-face its preview emits is a real gnus face.
-if(location.hash==='#gnustest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#gnustest')gate('gnustest',A=>{
A(!!APPS['gnus'],'gnus is a registered view package');
A(APPS['gnus']&&APPS['gnus'].preview==='gnus','gnus uses the gnus preview renderer');
- const box=document.createElement('div');box.innerHTML=renderGnusPreview();
- const valid=new Set((APPS['gnus']&&APPS['gnus'].faces||[]).map(r=>r[0]));
- const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
- A(used.length>=20,'preview exercises many faces ('+used.length+')');
- const bad=used.filter(f=>!valid.has(f));
- A(bad.length===0,'every data-face is a real gnus face; bad='+bad.join(','));
- for(const f of ['gnus-header-name','gnus-header-from','gnus-header-subject','gnus-cite-1','gnus-cite-attribution','gnus-signature','gnus-button','gnus-emphasis-highlight-words'])
- A(used.includes(f),'preview includes '+f);
- document.title='GNUSTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='gnustest';d.textContent='GNUSTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ assertPreviewFaces(A, renderGnusPreview(), APPS['gnus']&&APPS['gnus'].faces, 20, 'gnus',
+ ['gnus-header-name','gnus-header-from','gnus-header-subject','gnus-cite-1','gnus-cite-attribution','gnus-signature','gnus-button','gnus-emphasis-highlight-words']);
+ });
// picker-distinct gate (open with #pickertest): the color picker panel must stand
// out from the page background. It carries a highlighted gold accent border, and its
// background is meaningfully lighter than the body so the two are easy to tell apart.
-if(location.hash==='#pickertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#pickertest')gate('pickertest',A=>{
const pk=document.getElementById('picker');A(!!pk,'picker element exists');
if(pk){const cs=getComputedStyle(pk),body=getComputedStyle(document.body);
const bc=(cs.borderTopColor.match(/\d+/g)||[]).slice(0,3).map(Number);
@@ -806,12 +815,11 @@ if(location.hash==='#pickertest'){let ok=true;const notes=[];const A=(c,n)=>{if(
const lift=pkbg.map((c,i)=>c-bdbg[i]);
A(lift.every(d=>d>=12),'picker background is clearly lighter than the page (per-channel lift '+lift.join(',')+')');
}
- document.title='PICKERTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='pickertest';d.textContent='PICKERTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Box-cluster gate (open with #boxtest): the box control is a 2x2 cluster of
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
-if(location.hash==='#boxtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#boxtest')gate('boxtest',A=>{
LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box;
UIMAP[f].box=null;buildUITable();
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5];
@@ -827,11 +835,10 @@ if(location.hash==='#boxtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c)
A(UIMAP[f].box===null,'blank-click-clears-box');
A(dd.style.display==='none','color-hidden-again-after-clear');
UIMAP[f].box=saveBox;buildUITable();
- document.title='BOXTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='boxtest';d.textContent='BOXTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Style-cluster gate (open with #styletest): the style cell holds a weight
// selector, a slant selector, and box-like underline and strike controls.
-if(location.hash==='#styletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#styletest')gate('styletest',A=>{
buildUITable();const f=UI_FACES[0][0];
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4];
const cluster=cell.querySelector('.stylecluster');
@@ -851,11 +858,10 @@ if(location.hash==='#styletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(sital&&sital.style.fontStyle==='italic','slant-options-preview-their-own-slant: italic renders italic');
closeColorDropdown();
A(cluster&&cluster.querySelectorAll('.boxctl').length===1,'strike-control-in-row-underline-moved-to-expander');
- document.title='STYLETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='styletest';d.textContent='STYLETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Expander gate (open with #expandtest): the per-row "more" toggle reveals a
// detail row with the overflow attribute editor, and its controls write the model.
-if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#expandtest')gate('expandtest',A=>{
buildUITable();
const row=document.querySelector('#uibody tr[data-face="region"]');
const detail=document.querySelector('#uibody tr.detailrow[data-detail-for="region"]');
@@ -895,13 +901,12 @@ if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
buildPkgTable();const pface=APPS[curApp()].faces[0][0];
const pdetail=document.querySelector('#pkgbody tr.detailrow[data-detail-for="'+pface+'"]');
A(pdetail&&pdetail.querySelector('select.detailsel'),'package-expander-offers-inherit');
- document.title='EXPANDTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='expandtest';d.textContent='EXPANDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Height-clamp gate (open with #heighttest): the expander height field coerces a
// typed value into [HEIGHT_MIN,HEIGHT_MAX] and writes the clamped number back, so
// an out-of-range type/paste can't reach the model. Guards the fact that an
// <input type=number> min/max only constrain its steppers, never typed text.
-if(location.hash==='#heighttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#heighttest')gate('heighttest',A=>{
const face=UI_FACES[0][0],save=JSON.parse(JSON.stringify(UIMAP[face]));
buildUITable();
const hin=()=>document.querySelector('#uibody tr.detailrow[data-detail-for="'+face+'"] .hstep');
@@ -916,12 +921,11 @@ if(location.hash==='#heighttest'){let ok=true;const notes=[];const A=(c,n)=>{if(
typeHeight('');
A(UIMAP[face].height===null,'blank-unsets-to-null: '+UIMAP[face].height);
UIMAP[face]=save;buildUITable();
- document.title='HEIGHTTEST '+(ok?'PASS':'FAIL');
- const hd=document.createElement('div');hd.id='heighttest';hd.textContent='HEIGHTTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(hd);}
+ });
// Language-dropdown gate (open with #langtest): the language list is sorted
// alphabetically with Elisp pinned as the default selection, and the ‹ › arrows
// step the selection (clamped, no wrap).
-if(location.hash==='#langtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#langtest')gate('langtest',A=>{
buildLangSel();
const s=document.getElementById('langsel');
const labels=[...s.options].map(o=>o.value);
@@ -934,11 +938,10 @@ if(location.hash==='#langtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(s.selectedIndex===1,'next steps forward one');
s.selectedIndex=s.options.length-1;stepLang(1);
A(s.selectedIndex===s.options.length-1,'next clamps at the last language');
- document.title='LANGTEST '+(ok?'PASS':'FAIL');
- const ld=document.createElement('div');ld.id='langtest';ld.textContent='LANGTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ld);}
+ });
// View-lock-indicator gate (open with #viewlocktest): the view dropdown prefixes a
// lock glyph on a view whose every element is locked, and clears it otherwise.
-if(location.hash==='#viewlocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#viewlocktest')gate('viewlocktest',A=>withSavedState(['LOCKED'],()=>{
LOCKED.clear();updateViewLockIndicators();
const s=document.getElementById('viewsel'),codeOpt=()=>[...s.options].find(o=>o.value==='@code');
A(codeOpt()&&!codeOpt().textContent.startsWith('🔒'),'unlocked view shows no lock glyph: '+(codeOpt()&&codeOpt().textContent));
@@ -948,11 +951,10 @@ if(location.hash==='#viewlocktest'){let ok=true;const notes=[];const A=(c,n)=>{i
LOCKED.delete(syntaxLockKeys()[0]);updateViewLockIndicators();
A(codeOpt()&&!codeOpt().textContent.startsWith('🔒'),'unlocking one element clears the glyph');
LOCKED.clear();updateViewLockIndicators();
- document.title='VIEWLOCKTEST '+(ok?'PASS':'FAIL');
- const vd=document.createElement('div');vd.id='viewlocktest';vd.textContent='VIEWLOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(vd);}
+ }));
// Detail-hover gate (open with #detailhovertest): every label in the expander
// detail row carries an explanatory hover, the way the table-header labels do.
-if(location.hash==='#detailhovertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#detailhovertest')gate('detailhovertest',A=>{
buildUITable();
const f=UI_FACES[0][0],detail=document.querySelector('#uibody tr.detailrow[data-detail-for="'+f+'"]');
const fields=detail?[...detail.querySelectorAll('.detailfield')]:[];
@@ -960,12 +962,11 @@ if(location.hash==='#detailhovertest'){let ok=true;const notes=[];const A=(c,n)=
A(fields.every(g=>g.title&&g.title.length>0),'every detail field has a hover: '+fields.map(g=>g.querySelector('span').textContent+(g.title?'+':'-')).join(' '));
const inh=fields.find(g=>g.querySelector('span').textContent==='inherit');
A(inh&&/inherit/i.test(inh.title),'inherit field hover mentions inheritance: '+(inh&&inh.title));
- document.title='DETAILHOVERTEST '+(ok?'PASS':'FAIL');
- const dh=document.createElement('div');dh.id='detailhovertest';dh.textContent='DETAILHOVERTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(dh);}
+ });
// Expand/collapse-all gate (open with #expandalltest): the header toggle opens or
// closes every row's detail at once, the per-row triangles track state (▶ closed,
// ▼ open), and the header button's label follows the aggregate.
-if(location.hash==='#expandalltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#expandalltest')gate('expandalltest',A=>{
buildUITable();
const tb=document.getElementById('uibody'),btn=document.getElementById('uiexpandall');
const details=()=>[...tb.querySelectorAll('tr.detailrow')];
@@ -983,12 +984,11 @@ if(location.hash==='#expandalltest'){let ok=true;const notes=[];const A=(c,n)=>{
firstTog().click();
A(open()===1,'a single row toggle opens just that row');
A(btn.textContent.indexOf('▼')===0,'button reflects a single open row as ▼ collapse all');
- document.title='EXPANDALLTEST '+(ok?'PASS':'FAIL');
- const ea=document.createElement('div');ea.id='expandalltest';ea.textContent='EXPANDALLTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ea);}
+ });
// Expander-persistence gate (open with #expandpersisttest): a package edit rebuilds
// the whole table, so an open expander must reopen instead of collapsing under the
// user. Editing a value inside the open expander must not close the row.
-if(location.hash==='#expandpersisttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#expandpersisttest')gate('expandpersisttest',A=>withSavedState(['PKGMAP'],()=>{
EXPANDED.clear();
const app=curApp(),face=APPS[app].faces[0][0];buildPkgTable();
const row=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"]');
@@ -1002,20 +1002,18 @@ if(location.hash==='#expandpersisttest'){let ok=true;const notes=[];const A=(c,n
row().querySelector('.exptoggle').click();buildPkgTable();
A(detail()&&detail().style.display==='none','a collapsed expander stays collapsed across a rebuild');
EXPANDED.clear();buildPkgTable();
- document.title='EXPANDPERSISTTEST '+(ok?'PASS':'FAIL');
- const ep=document.createElement('div');ep.id='expandpersisttest';ep.textContent='EXPANDPERSISTTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ep);}
+ }));
// Palette default-state gate (open with #paldefaulttest): the studio opens with
// the palette collapsed to base colors so the span tints don't crowd the first
// view. initApp() ran at page load, so the live toggle reflects the opening state.
-if(location.hash==='#paldefaulttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#paldefaulttest')gate('paldefaulttest',A=>{
const tg=document.getElementById('paltoggle');
A(!!tg,'palette toggle present after boot');
A(tg&&tg.textContent==='▶','palette opens collapsed to base colors (arrow shows right-pointing ▶)');
- document.title='PALDEFAULTTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='paldefaulttest';d.textContent='PALDEFAULTTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Palette display-toggle gate (open with #paltoggletest): the arrow control
// collapses each column to its base color and expands back to full spans.
-if(location.hash==='#paltoggletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#paltoggletest')gate('paltoggletest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP);
paletteShowFull=true; // start expanded so the first click collapses to base-only
setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0');
@@ -1032,12 +1030,11 @@ if(location.hash==='#paltoggletest'){let ok=true;const notes=[];const A=(c,n)=>{
document.getElementById('paltoggle').click();
A(blueChips()===5,'toggling-back-restores-spans');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();renderPalette();
- document.title='PALTOGGLETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='paltoggletest';d.textContent='PALTOGGLETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Unused-tile gate (open with #unusedtest): a palette color referenced nowhere
// in the theme gets the .unused flag; a column with no used members gets
// .unused-col; referenced colors stay unflagged.
-if(location.hash==='#unusedtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#unusedtest')gate('unusedtest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSyn=JSON.parse(JSON.stringify(SYNTAX)),saveU=JSON.parse(JSON.stringify(UIMAP));
setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0');
PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue'],['#123456','teal','teal']];
@@ -1053,12 +1050,11 @@ if(location.hash==='#unusedtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
A(tealStrip&&tealStrip.classList.contains('unused-col'),'all-unused-column-flagged');
A(blueStrip&&!blueStrip.classList.contains('unused-col'),'used-column-not-flagged');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const k in SYNTAX)delete SYNTAX[k];Object.assign(SYNTAX,saveSyn);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);syncSyntaxFromCache();renderPalette();
- document.title='UNUSEDTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='unusedtest';d.textContent='UNUSEDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Gone-assignment gate (open with #gonetest): a swatch whose assigned color is
// no longer in the palette gets the .gone flag; an assignment to a present color
// does not.
-if(location.hash==='#gonetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#gonetest')gate('gonetest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP));
setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0');
PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue']];
@@ -1070,11 +1066,10 @@ if(location.hash==='#gonetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(goneDd&&goneDd.classList.contains('gone'),'assignment-to-missing-color-flagged');
A(okDd&&!okDd.classList.contains('gone'),'assignment-to-present-color-not-flagged');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);syncSyntaxFromCache();buildUITable();
- document.title='GONETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='gonetest';d.textContent='GONETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Tile-usage-hover gate (open with #usagetest): a tile's title lists the
// "view area > element" pairings that use its color, under the name/hex line.
-if(location.hash==='#usagetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#usagetest')gate('usagetest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP));
setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0');
PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue']];
@@ -1086,12 +1081,11 @@ if(location.hash==='#usagetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(blueChip&&blueChip.title.includes('ui faces > '+f0label),'hover-title-lists-ui-face-usage');
A(blueChip&&blueChip.title.split('\n').length>1,'usage-list-on-its-own-line-under-current-info');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);syncSyntaxFromCache();renderPalette();
- document.title='USAGETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='usagetest';d.textContent='USAGETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Element-docstring hovers (open with #hovertest): each table's category cell
// carries the face's Emacs docstring on top of its prior hover text, and the
// existing label-span hints are left intact (added in addition, not replaced).
-if(location.hash==='#hovertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#hovertest')gate('hovertest',A=>{
buildTable();buildUITable();buildPkgTable();
const synCell=document.querySelector('#legbody tr[data-kind="kw"] .cat');
A(synCell&&synCell.title===SYNTAX_DOCS['kw'],'syntax cat cell shows the category face docstring: '+(synCell&&synCell.title));
@@ -1103,8 +1097,7 @@ if(location.hash==='#hovertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(docFace,'a package face with a docstring exists to test');
if(docFace){const pkgCell=document.querySelector('#pkgbody tr[data-face="'+docFace+'"] .cat');
A(pkgCell&&pkgCell.title===FACE_DOCS[docFace]+'\n\n'+docFace,'package cat cell shows docstring on top of the face name: '+(pkgCell&&JSON.stringify(pkgCell.title)));}
- document.title='HOVERTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='hovertest';d.textContent='HOVERTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Export via the File System Access API (open with #savetest): exportTheme writes
// the theme JSON straight to the picked file handle and closes it, so re-exporting
// overwrites in place instead of the browser uniquifying to "name (1).json".
diff --git a/scripts/theme-studio/capture-default-faces.py b/scripts/theme-studio/capture-default-faces.py
index 8c8fd6679..a5214fd5a 100644
--- a/scripts/theme-studio/capture-default-faces.py
+++ b/scripts/theme-studio/capture-default-faces.py
@@ -163,47 +163,45 @@ def normalize_value(value: object) -> object:
return value
+def _condition_clauses_pass(clauses: dict) -> bool:
+ """Apply the four display-condition rules to a {key: values} mapping.
+ Returns False when any present clause excludes the GUI-light target."""
+ if "class" in clauses:
+ vals = clauses["class"]
+ if "color" not in vals and "grayscale" not in vals:
+ return False
+ if "min-colors" in clauses:
+ vals = clauses["min-colors"]
+ if vals and isinstance(vals[0], int) and vals[0] > 16777216:
+ return False
+ if "background" in clauses:
+ vals = clauses["background"]
+ if vals and "light" not in vals:
+ return False
+ if "type" in clauses:
+ if "tty" in clauses["type"]:
+ return False
+ return True
+
+
def condition_matches(condition: object) -> bool:
if condition in (True, "t", None):
return True
if condition == "default":
return False
+ # Normalize the two display-spec shapes -- a {key: values} dict, or a list of
+ # [key, *values] clauses -- to one {key: values} mapping, then run the four
+ # rules once (see `_condition_clauses_pass').
if isinstance(condition, dict):
- if "class" in condition:
- vals = condition["class"] or []
- if "color" not in vals and "grayscale" not in vals:
- return False
- if "min-colors" in condition:
- vals = condition["min-colors"] or []
- if vals and isinstance(vals[0], int) and vals[0] > 16777216:
- return False
- if "background" in condition:
- vals = condition["background"] or []
- if vals and "light" not in vals:
- return False
- if "type" in condition and "tty" in (condition["type"] or []):
- return False
- return True
+ clauses = {k: (condition[k] or []) for k in condition}
+ return _condition_clauses_pass(clauses)
if not isinstance(condition, list):
return False
+ clauses = {}
for clause in condition:
- if not isinstance(clause, list) or not clause:
- continue
- key = clause[0]
- vals = clause[1:]
- if key == "class":
- if "color" not in vals and "grayscale" not in vals:
- return False
- elif key == "min-colors":
- if vals and isinstance(vals[0], int) and vals[0] > 16777216:
- return False
- elif key == "background":
- if vals and "light" not in vals:
- return False
- elif key == "type":
- if "tty" in vals:
- return False
- return True
+ if isinstance(clause, list) and clause:
+ clauses[clause[0]] = clause[1:]
+ return _condition_clauses_pass(clauses)
def choose_gui_light(default_spec: object) -> dict[str, object]:
diff --git a/scripts/theme-studio/face_coverage.py b/scripts/theme-studio/face_coverage.py
index ba761230b..c6200e05c 100644
--- a/scripts/theme-studio/face_coverage.py
+++ b/scripts/theme-studio/face_coverage.py
@@ -115,22 +115,37 @@ def load_managed():
CORE_FILES = {'faces', 'frame'}
+def path_kind(path):
+ """Classify a defface source PATH into a coarse origin kind.
+ Returns one of: 'none' (no path), 'elpa', 'user', 'builtin', 'other'.
+ Shared by bucket_from_source and bucket_of_source, which each map the kind
+ to their own vocabulary."""
+ if not path:
+ return 'none'
+ if '/elpa/' in path:
+ return 'elpa'
+ if '/.emacs.d/modules' in path:
+ return 'user'
+ if path.startswith('/usr/share/emacs') or path.startswith('/usr/lib/emacs'):
+ return 'builtin'
+ return 'other'
+
+
def bucket_from_source(path):
"""Derive a bucket name from a face's defface file, for faces that match no
known family. elpa -> the package dir name (version stripped); built-in ->
the source file basename; otherwise emacs-core (can't tell)."""
- if not path:
- return 'emacs-core'
- if '/elpa/' in path:
+ kind = path_kind(path)
+ if kind == 'elpa':
pkgdir = path.split('/elpa/', 1)[1].split('/', 1)[0]
return re.sub(r'-[0-9].*$', '', pkgdir) or 'emacs-core'
- if '/.emacs.d/modules' in path:
+ if kind == 'user':
return 'user-config'
- if path.startswith('/usr/share/emacs') or path.startswith('/usr/lib/emacs'):
+ if kind == 'builtin':
base = os.path.basename(path)
base = base[:-3] if base.endswith('.el') else base
return 'emacs-core' if base in CORE_FILES else base
- return 'emacs-core'
+ return 'emacs-core' # 'none' or 'other'
def make_group_of(families, src):
@@ -155,15 +170,8 @@ def make_group_of(families, src):
def bucket_of_source(path):
- if not path:
- return 'unloaded'
- if '/elpa/' in path:
- return 'elpa'
- if '/.emacs.d/modules' in path:
- return 'user'
- if path.startswith('/usr/share/emacs') or path.startswith('/usr/lib/emacs'):
- return 'builtin'
- return 'other'
+ return {'none': 'unloaded', 'elpa': 'elpa', 'user': 'user',
+ 'builtin': 'builtin', 'other': 'other'}[path_kind(path)]
def classify(name, items, src, pkgfaces):
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index f0955d2df..6baa67a91 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -72,7 +72,7 @@ SAMPLES={"Elisp":ns['ELS'],"Go":ns['GOS'],"Python":ns['PYS'],"TypeScript":ns['TS
"Racket":ns['RACKETS'],"Scheme":ns['SCHEMES'],"Haskell":ns['HASKELLS'],"OCaml":ns['OCAMLS'],"Scala":ns['SCALAS'],"Kotlin":ns['KOTLINS'],"Swift":ns['SWIFTS'],"Lua":ns['LUAS'],"Ruby":ns['RUBYS'],"Perl":ns['PERLS'],"R":ns['RLANGS'],"Erlang":ns['ERLANGS'],"SQL":ns['SQLS'],"PHP":ns['PHPS'],"Ada":ns['ADAS'],"Fortran":ns['FORTRANS'],"MATLAB":ns['MATLABS'],"Assembly":ns['ASMS']}
COLS=ns['COLS']
DEFAULT_FACES_PATH=os.path.join(HERE,'emacs-default-faces.json')
-DEFAULTS=DefaultFaces.from_path(DEFAULT_FACES_PATH)
+
def column_id(name):
name = name or 'color'
if re.fullmatch(r'color-\d+', name):
@@ -207,9 +207,6 @@ def apply_seed_packages(apps,data,seed):
if seed:
apply_package_overrides(apps,data.get('packages'))
-MAP,BOLD,ITALIC_MAP=initial_maps(COLS,DEFAULTS)
-
-PALETTE=[[MAP['bg'],"bg","ground"],[MAP['p'],"fg","ground"]]
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 · def","resolve push"],
["fnc","function · call","printf rsync get"],["dec","decorator → type","@dataclass"],
@@ -231,72 +228,97 @@ UI_FACES=[["cursor","cursor","Aa|"],["region","region (selection)","selected tex
["show-paren-mismatch","show-paren-mismatch",") ("],["link","link","https://"],
["error","error","error!"],["warning","warning","warning"],
["success","success","ok"],["vertical-border","vertical-border","|"]]
-UIMAP=build_uimap(UI_FACES,DEFAULTS)
-# Optional palette seed: THEME_STUDIO_SEED=<file.json> seeds the tool's starting
-# palette / syntax / UI from a theme.json (path relative to
-# this dir), instead of the hardcoded defaults above. Unset leaves them unchanged.
-# Placed after every default it overrides (notably UIMAP) so the merge has targets.
-# Mirrors what the in-page Import does, so reseed and import agree.
-LOCKS=[]
-# THEME_STUDIO_SEED=<file>.json opens an existing theme as the starting point.
-# Unset starts empty: only bg/fg are in the palette.
-_seed=os.environ.get('THEME_STUDIO_SEED')
-_d=load_seed_data(_seed)
-PALETTE,UIMAP,LOCKS=apply_seed_basics(_d,PALETTE,UIMAP,LOCKS)
-PALETTE=normalize_palette(PALETTE)
-SYNTAX=build_syntax(COLS,MAP,BOLD,ITALIC_MAP,DEFAULTS)
-apply_syntax_seed(_d if _seed else {},SYNTAX,MAP)
-# Bespoke apps are single-sourced as BESPOKE_APP_SPECS in face_data.py (one
-# row per app: key, label, preview, FACES, prefix, SEED).
-APPS={key:{"label":label,"preview":preview,"faces":face_rows(faces,prefix,seed)}
- for key,label,preview,faces,prefix,seed in BESPOKE_APP_SPECS}
-# Phase 6: merge the generated all-package inventory (refresh with build-inventory.el).
-# Bespoke apps stay; every other installed package becomes an editable generic app.
-_inv_path=os.path.join(HERE,"package-inventory.json")
-add_inventory_apps(APPS, _inv_path)
-apply_default_face_seeds(APPS, DEFAULTS)
-# Apply seed theme package overrides when THEME_STUDIO_SEED is set: each full
-# per-face spec (color + structure) replaces the hardcoded face seed before render.
-apply_seed_packages(APPS,_d,_seed)
+OUT=os.path.join(HERE,'theme-studio.html')
+_CACHE={}
-if DEFAULTS.available:
- add_default_palette_colors(PALETTE,MAP,SYNTAX,UIMAP,APPS,DEFAULTS)
+def _build():
+ """Assemble the page, caching the derived data + HTML. Deferred from import
+ so a consumer that only needs the cheap module constants (e.g.
+ face_coverage.py reading UI_FACES) does not pay the full DEFAULTS + inventory
+ + fill cost; the file write stays __main__-guarded as before."""
+ if _CACHE:
+ return _CACHE
+ DEFAULTS=DefaultFaces.from_path(DEFAULT_FACES_PATH)
+ MAP,BOLD,ITALIC_MAP=initial_maps(COLS,DEFAULTS)
+ PALETTE=[[MAP['bg'],"bg","ground"],[MAP['p'],"fg","ground"]]
+ UIMAP=build_uimap(UI_FACES,DEFAULTS)
-PALETTE=normalize_palette(PALETTE)
-HTML=read_text('theme-studio.template.html')
-# Fill the data placeholders. str.replace is literal (no backref interpretation),
-# so backslashes in the inlined JS survive intact — the escaping-bug class that
-# the triple-quoted string used to cause is gone now that app.js is a real file.
-# Caveat: these tokens are replaced everywhere they appear, including inside code
-# comments. Don't write a placeholder name (COLORMATH_J, APP_CORE_J, ...) in
-# prose in any inlined file, or that prose gets the body spliced into it too.
-def fill_data(s):
- return (s.replace("COLORMATH_J",COLORMATH_BODY)
- .replace("APP_CORE_J",APP_CORE_BODY)
- .replace("PREVIEWS_J",PREVIEWS_BODY)
- .replace("APP_UTIL_J",APP_UTIL_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)
- .replace("BROWSER_GATES_J",BROWSER_GATES_BODY)
- .replace("COLOR_NAMES_J",json.dumps(COLOR_NAMES))
- .replace("FACE_DOCS_J",json.dumps(FACE_DOCS)).replace("SYNTAX_DOCS_J",json.dumps(SYNTAX_DOCS))
- .replace("SAMPLES_J",json.dumps(SAMPLES))
- .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS))
- .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS))
- .replace("SYNTAX_J",json.dumps(SYNTAX)).replace("MAP_J",json.dumps(MAP)).replace("LOCKS_J",json.dumps(LOCKS)))
+ # Optional palette seed: THEME_STUDIO_SEED=<file.json> seeds the tool's starting
+ # palette / syntax / UI from a theme.json (path relative to
+ # this dir), instead of the hardcoded defaults above. Unset leaves them unchanged.
+ # Placed after every default it overrides (notably UIMAP) so the merge has targets.
+ # Mirrors what the in-page Import does, so reseed and import agree.
+ LOCKS=[]
+ # THEME_STUDIO_SEED=<file>.json opens an existing theme as the starting point.
+ # Unset starts empty: only bg/fg are in the palette.
+ _seed=os.environ.get('THEME_STUDIO_SEED')
+ _d=load_seed_data(_seed)
+ PALETTE,UIMAP,LOCKS=apply_seed_basics(_d,PALETTE,UIMAP,LOCKS)
+ PALETTE=normalize_palette(PALETTE)
+ SYNTAX=build_syntax(COLS,MAP,BOLD,ITALIC_MAP,DEFAULTS)
+ apply_syntax_seed(_d if _seed else {},SYNTAX,MAP)
+ # Bespoke apps are single-sourced as BESPOKE_APP_SPECS in face_data.py (one
+ # row per app: key, label, preview, FACES, prefix, SEED).
+ APPS={key:{"label":label,"preview":preview,"faces":face_rows(faces,prefix,seed)}
+ for key,label,preview,faces,prefix,seed in BESPOKE_APP_SPECS}
+ # Phase 6: merge the generated all-package inventory (refresh with build-inventory.el).
+ # Bespoke apps stay; every other installed package becomes an editable generic app.
+ _inv_path=os.path.join(HERE,"package-inventory.json")
+ add_inventory_apps(APPS, _inv_path)
+ apply_default_face_seeds(APPS, DEFAULTS)
+ # Apply seed theme package overrides when THEME_STUDIO_SEED is set: each full
+ # per-face spec (color + structure) replaces the hardcoded face seed before render.
+ apply_seed_packages(APPS,_d,_seed)
-# Splice the stylesheet and script in first, then fill the data placeholders they
-# carry. The page contains app.js exactly as fill_data(APP_BODY) renders it —
-# APP_FILLED is that rendering, the handle the inline-integrity test asserts on.
-HTML=fill_data(HTML.replace("STYLES_CSS",STYLES).replace("APP_JS",APP_BODY))
-APP_FILLED=fill_data(APP_BODY)
-OUT=os.path.join(HERE,'theme-studio.html')
+ if DEFAULTS.available:
+ add_default_palette_colors(PALETTE,MAP,SYNTAX,UIMAP,APPS,DEFAULTS)
+
+ PALETTE=normalize_palette(PALETTE)
+ HTML=read_text('theme-studio.template.html')
+ # Fill the data placeholders. str.replace is literal (no backref interpretation),
+ # so backslashes in the inlined JS survive intact — the escaping-bug class that
+ # the triple-quoted string used to cause is gone now that app.js is a real file.
+ # Caveat: these tokens are replaced everywhere they appear, including inside code
+ # comments. Don't write a placeholder name (COLORMATH_J, APP_CORE_J, ...) in
+ # prose in any inlined file, or that prose gets the body spliced into it too.
+ def fill_data(s):
+ return (s.replace("COLORMATH_J",COLORMATH_BODY)
+ .replace("APP_CORE_J",APP_CORE_BODY)
+ .replace("PREVIEWS_J",PREVIEWS_BODY)
+ .replace("APP_UTIL_J",APP_UTIL_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)
+ .replace("BROWSER_GATES_J",BROWSER_GATES_BODY)
+ .replace("COLOR_NAMES_J",json.dumps(COLOR_NAMES))
+ .replace("FACE_DOCS_J",json.dumps(FACE_DOCS)).replace("SYNTAX_DOCS_J",json.dumps(SYNTAX_DOCS))
+ .replace("SAMPLES_J",json.dumps(SAMPLES))
+ .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS))
+ .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS))
+ .replace("SYNTAX_J",json.dumps(SYNTAX)).replace("MAP_J",json.dumps(MAP)).replace("LOCKS_J",json.dumps(LOCKS)))
+
+ # Splice the stylesheet and script in first, then fill the data placeholders they
+ # carry. The page contains app.js exactly as fill_data(APP_BODY) renders it —
+ # APP_FILLED is that rendering, the handle the inline-integrity test asserts on.
+ HTML=fill_data(HTML.replace("STYLES_CSS",STYLES).replace("APP_JS",APP_BODY))
+ APP_FILLED=fill_data(APP_BODY)
+ _CACHE.update(DEFAULTS=DEFAULTS, MAP=MAP, BOLD=BOLD, ITALIC_MAP=ITALIC_MAP,
+ PALETTE=PALETTE, UIMAP=UIMAP, LOCKS=LOCKS, SYNTAX=SYNTAX,
+ APPS=APPS, HTML=HTML, APP_FILLED=APP_FILLED)
+ return _CACHE
+
+def __getattr__(name):
+ # PEP 562: lazily expose any built attribute (HTML, MAP, APPS, ...). Every
+ # other name is a real module global and never reaches here.
+ built = _build()
+ if name in built:
+ return built[name]
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def render_theme_studio(out_path=OUT):
with open(out_path,"w") as out:
- out.write(HTML)
+ out.write(_build()['HTML'])
print("wrote",out_path)
if __name__=='__main__':
diff --git a/scripts/theme-studio/inline-strip.mjs b/scripts/theme-studio/inline-strip.mjs
new file mode 100644
index 000000000..112d55ce6
--- /dev/null
+++ b/scripts/theme-studio/inline-strip.mjs
@@ -0,0 +1,15 @@
+// Shared by the inline-integrity tests (test-colormath.mjs, test-app-core.mjs,
+// test-app-util.mjs). Mirrors strip_exports in generate.py: drop top-level
+// export/import lines (a pure module may import a peer for its own unit tests,
+// while the inlined page copy relies on that peer already being present), then
+// rstrip. The page is asserted to carry the stripped body verbatim, so this MUST
+// stay aligned with generate.py's strip_exports -- one definition keeps the three
+// test copies from drifting apart.
+//
+// (This file matches the `*.mjs` test glob in run-tests.sh; it carries no tests,
+// so it contributes zero to the count.)
+export const stripInlinedBody = (s) =>
+ s.split('\n')
+ .filter((l) => !(l.startsWith('export') || l.startsWith('import')))
+ .join('\n')
+ .replace(/\s+$/, '');
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index cdfa0bc1e..217ea0e6b 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
- nameToHex, migrateLegacyFace, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify,
+ nameToHex, migrateLegacyFace, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, paletteOptionList, spanNeighborHex, slugify,
clearPalettePlan, deletePaletteColumnPlan, groundColumnMembersFromPalette, areAllLocked, lockToggleLabel, toggleLockSet,
galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, stepViewIndex,
cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle,
@@ -819,35 +819,34 @@ test('slugify: Error — an all-disallowed name falls back to "theme"', () => {
// Guards the one-source-of-truth contract, same as the colormath integrity test:
// the page must carry app-core.js's body (sans exports) verbatim. Requires
// `python3 generate.py` to have run first.
-const stripExports = (s) =>
- s.split('\n').filter((l) => !(l.startsWith('export') || l.startsWith('import'))).join('\n').replace(/\s+$/, '');
+import { stripInlinedBody } from './inline-strip.mjs';
test('inline-integrity: theme-studio.html contains the app-core.js body verbatim', () => {
- const body = stripExports(readFileSync(here + 'app-core.js', 'utf8'));
+ const body = stripInlinedBody(readFileSync(here + 'app-core.js', 'utf8'));
const html = readFileSync(here + 'theme-studio.html', 'utf8');
assert.ok(html.includes(body), 'generated page is missing the app-core.js body verbatim');
});
test('inline-integrity: theme-studio.html contains palette-generator-core.js verbatim', () => {
- const body = stripExports(readFileSync(here + 'palette-generator-core.js', 'utf8'));
+ const body = stripInlinedBody(readFileSync(here + 'palette-generator-core.js', 'utf8'));
const html = readFileSync(here + 'theme-studio.html', 'utf8');
assert.ok(html.includes(body), 'generated page is missing palette-generator-core.js verbatim');
});
test('inline-integrity: theme-studio.html contains palette-generator-ui.js verbatim', () => {
- const body = stripExports(readFileSync(here + 'palette-generator-ui.js', 'utf8'));
+ const body = stripInlinedBody(readFileSync(here + 'palette-generator-ui.js', 'utf8'));
const html = readFileSync(here + 'theme-studio.html', 'utf8');
assert.ok(html.includes(body), 'generated page is missing palette-generator-ui.js verbatim');
});
test('inline-integrity: theme-studio.html contains palette-actions.js verbatim', () => {
- const body = stripExports(readFileSync(here + 'palette-actions.js', 'utf8'));
+ const body = stripInlinedBody(readFileSync(here + 'palette-actions.js', 'utf8'));
const html = readFileSync(here + 'theme-studio.html', 'utf8');
assert.ok(html.includes(body), 'generated page is missing palette-actions.js verbatim');
});
test('inline-integrity: theme-studio.html contains browser-gates.js verbatim', () => {
- const body = stripExports(readFileSync(here + 'browser-gates.js', 'utf8'));
+ const body = stripInlinedBody(readFileSync(here + 'browser-gates.js', 'utf8'));
const html = readFileSync(here + 'theme-studio.html', 'utf8');
assert.ok(html.includes(body), 'generated page is missing browser-gates.js verbatim');
});
@@ -901,23 +900,6 @@ test('resolveUiAttr: a face with no inherit and an unset attribute returns null'
assert.equal(resolveUiAttr('region', 'bg', { 'region': { bg: null } }), null);
});
-// dropdownRowTextColor: a popup row showing a real palette color inherits the
-// popup foreground (legible on the fixed dark popup); only the filled default
-// row uses a contrast color against its own background. textOn is stubbed so the
-// test asserts the decision, not the contrast math.
-const stubTextOn = (h) => (h === '#000000' ? '#fff' : '#000');
-test('dropdownRowTextColor: a real palette color inherits the popup fg (empty)', () => {
- assert.equal(dropdownRowTextColor('#2a3a5a', '#2a3a5a', stubTextOn), '');
-});
-test('dropdownRowTextColor: a dark swatch still inherits (regression: blues were unreadable)', () => {
- assert.equal(dropdownRowTextColor('#000000', '#000000', stubTextOn), '');
-});
-test('dropdownRowTextColor: the filled default row contrasts against its fill', () => {
- assert.equal(dropdownRowTextColor('', '#cdced1', stubTextOn), '#000');
-});
-test('dropdownRowTextColor: a default row with no fill inherits (empty)', () => {
- assert.equal(dropdownRowTextColor('', '', stubTextOn), '');
-});
// appViewKeysSorted: the assignment-view dropdown lists package apps
// alphabetically by display label, independent of the APPS build order
diff --git a/scripts/theme-studio/test-app-util.mjs b/scripts/theme-studio/test-app-util.mjs
index 37cf0889b..057f55f8d 100644
--- a/scripts/theme-studio/test-app-util.mjs
+++ b/scripts/theme-studio/test-app-util.mjs
@@ -84,12 +84,10 @@ test('textOn: Boundary — straddles the ~0.179 luminance crossover', () => {
// Inline-integrity: the page must carry app-util.js's body (sans import/export)
// verbatim — the same strip generate.py applies. Requires `python3 generate.py`.
-const stripModule = (s) =>
- s.split('\n').filter((l) => !(l.startsWith('export') || l.startsWith('import')))
- .join('\n').replace(/\s+$/, '');
+import { stripInlinedBody } from './inline-strip.mjs';
test('inline-integrity: theme-studio.html contains the app-util.js body verbatim', () => {
- const body = stripModule(readFileSync(here + 'app-util.js', 'utf8'));
+ const body = stripInlinedBody(readFileSync(here + 'app-util.js', 'utf8'));
const html = readFileSync(here + 'theme-studio.html', 'utf8');
assert.ok(html.includes(body), 'generated page is missing the app-util.js body verbatim');
});
diff --git a/scripts/theme-studio/test-colormath.mjs b/scripts/theme-studio/test-colormath.mjs
index ee40e3437..a1ec9264e 100644
--- a/scripts/theme-studio/test-colormath.mjs
+++ b/scripts/theme-studio/test-colormath.mjs
@@ -18,9 +18,8 @@ import {
const close = (a, b, eps = 0.005) => Math.abs(a - b) <= eps;
const here = fileURLToPath(new URL('.', import.meta.url));
-// Same export-strip generate.py applies before inlining (drop `export` lines, rstrip).
-const stripExports = (s) =>
- s.split('\n').filter((l) => !l.startsWith('export')).join('\n').replace(/\s+$/, '');
+// Same strip generate.py applies before inlining (drop export/import lines, rstrip).
+import { stripInlinedBody } from './inline-strip.mjs';
test('srgb2oklab achromatic anchors', () => {
const w = srgb2oklab('#ffffff');
@@ -266,7 +265,7 @@ test('reliefColors: malformed hex returns null pair (Error)', () => {
// body (sans exports) verbatim, so the inlined copy and the tested module cannot
// drift. Requires `python3 generate.py` to have run first.
test('inline-integrity: theme-studio.html contains the colormath.js body verbatim', () => {
- const body = stripExports(readFileSync(here + 'colormath.js', 'utf8'));
+ const body = stripInlinedBody(readFileSync(here + 'colormath.js', 'utf8'));
const html = readFileSync(here + 'theme-studio.html', 'utf8');
assert.ok(html.includes(body), 'generated page is missing the colormath.js body verbatim');
});
diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py
index 40956917e..974fca68a 100644
--- a/scripts/theme-studio/test_generate.py
+++ b/scripts/theme-studio/test_generate.py
@@ -102,31 +102,17 @@ class AssembledPage(unittest.TestCase):
self.assertIn("keyword", generate.SYNTAX_DOCS["kw"].lower())
self.assertIn(json.dumps(generate.SYNTAX_DOCS), generate.HTML)
- def test_page_carries_the_colormath_body_verbatim(self):
- # Python-side inline-integrity: the same guarantee the JS test asserts, but
- # checked at the point the page is built rather than after a round-trip.
- self.assertIn(generate.COLORMATH_BODY, generate.HTML)
-
- def test_page_carries_the_app_core_body_verbatim(self):
- # app-core.js inlines verbatim (no data placeholders), so the inlined copy
- # and the unit-tested module cannot drift.
- self.assertIn(generate.APP_CORE_BODY, generate.HTML)
-
- def test_page_carries_the_app_util_body_verbatim(self):
- # app-util.js inlines verbatim after its import line is stripped.
- self.assertIn(generate.APP_UTIL_BODY, generate.HTML)
-
- def test_page_carries_palette_generator_core_verbatim(self):
- self.assertIn(generate.PALETTE_GENERATOR_CORE_BODY, generate.HTML)
-
- def test_page_carries_palette_generator_ui_verbatim(self):
- self.assertIn(generate.PALETTE_GENERATOR_UI_BODY, generate.HTML)
-
- def test_page_carries_palette_actions_verbatim(self):
- self.assertIn(generate.PALETTE_ACTIONS_BODY, generate.HTML)
-
- def test_page_carries_browser_gates_verbatim(self):
- self.assertIn(generate.BROWSER_GATES_BODY, generate.HTML)
+ def test_page_carries_each_inlined_body_verbatim(self):
+ # Python-side inline-integrity: every verbatim-inlined module (no data
+ # placeholders, exports/imports stripped) must appear in the page byte for
+ # byte, so the inlined copy and the unit-tested module cannot drift. Checked
+ # at build time rather than after a round-trip. app-util.js's import line is
+ # already stripped in APP_UTIL_BODY.
+ for name in ("COLORMATH_BODY", "APP_CORE_BODY", "APP_UTIL_BODY",
+ "PALETTE_GENERATOR_CORE_BODY", "PALETTE_GENERATOR_UI_BODY",
+ "PALETTE_ACTIONS_BODY", "BROWSER_GATES_BODY"):
+ with self.subTest(body=name):
+ self.assertIn(getattr(generate, name), generate.HTML)
def test_app_util_inlined_body_has_no_import_line(self):
# The `import rl` line must be gone, or the page <script> is invalid.
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 97c36554e..4896a2387 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -693,17 +693,6 @@ function resolveUiAttr(face,attr,uimap){
return walkInheritChain(face,f=>UI_INHERIT[f],f=>uimap[f]&&uimap[f][attr]);
}
-// Text color for a swatch-dropdown popup row. A row showing a real palette color
-// sits on the popup's own fixed background, so its name/hex text must inherit the
-// popup foreground (return '' to use the CSS color). Coloring it for contrast
-// against the swatch instead picks near-black text for a mid/dark swatch, which
-// is unreadable on the dark popup. Only the "default" row, filled solid with
-// SHOWN, uses a contrast color computed against that fill.
-function dropdownRowTextColor(hex,shown,textOnFn){
- if(hex)return '';
- return shown?textOnFn(shown):'';
-}
-
// Turn a theme name into a safe filename slug: collapse runs of disallowed
// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
@@ -3064,6 +3053,55 @@ function initApp(){
}
initApp();
addEventListener('resize',()=>{syncMockHeight();syncPkgHeight();});
+// Shared gate harness. Each call site keeps its literal location.hash==='#NAMEtest'
+// check (run-tests.sh greps it); gate() owns the ok/notes/A setup and the verdict
+// postamble. Note format standardized to ' fails=note1,note2'.
+function gate(id, body){
+ const name=id.toUpperCase();
+ let ok=true;const notes=[];
+ const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ body(A);
+ const verdict=name+' '+(ok?'PASS':'FAIL');
+ document.title=verdict;
+ const d=document.createElement('div');d.id=id;
+ d.textContent=verdict+(notes.length?' fails='+notes.join(','):'');
+ document.body.appendChild(d);
+}
+function withSavedState(keys, body){
+ // Snapshot the named studio globals, run BODY, then restore them in a finally
+ // so opening the studio at a #gate hash doesn't leave its state mutated for
+ // interactive use. Each key maps to a [get, set, clone] triple over the live
+ // let-binding. Scope the keys to what the gate actually touches.
+ // JSON clone (not structuredClone): the studio data objects carry values
+ // structuredClone throws on, and a JSON round-trip of the data is exactly what
+ // the gates' own local saves already use.
+ const jc=x=>JSON.parse(JSON.stringify(x));
+ const reg={
+ PALETTE:[()=>PALETTE, v=>{PALETTE=v;}, jc],
+ MAP:[()=>MAP, v=>{MAP=v;}, jc],
+ SYNTAX:[()=>SYNTAX, v=>{SYNTAX=v;}, jc],
+ UIMAP:[()=>UIMAP, v=>{UIMAP=v;}, jc],
+ PKGMAP:[()=>PKGMAP, v=>{PKGMAP=v;}, jc],
+ LOCKED:[()=>LOCKED, v=>{LOCKED.clear();for(const k of v)LOCKED.add(k);}, s=>new Set(s)],
+ };
+ const snap=keys.map(k=>[k, reg[k][2](reg[k][0]())]);
+ try{ body(); }
+ finally{ for(const [k,v] of snap) reg[k][1](v); }
+}
+// Shared preview-face validator for the #mdtest / #mupreviewtest / #gnustest
+// gates: render HTML into a detached div, then assert it exercises at least
+// MINCOUNT data-faces, that every data-face is a real face of the package
+// (drawn from FACES, the app's face rows), and that each face in REQUIRED is
+// present. A is the gate's assertion collector; NAME labels the failure note.
+function assertPreviewFaces(A, html, faces, minCount, name, required){
+ const box=document.createElement('div');box.innerHTML=html;
+ const valid=new Set((faces||[]).map(r=>r[0]));
+ const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
+ A(used.length>=minCount,'preview exercises many faces ('+used.length+')');
+ const bad=used.filter(f=>!valid.has(f));
+ A(bad.length===0,'every data-face is a real '+name+' face; bad='+bad.join(','));
+ for(const f of required) A(used.includes(f),'preview includes '+f);
+}
// Phase-1 self-test (open with #selftest): seed -> export -> import -> compare.
function pkgSelftest(){
const seeded=seedPkgmap();
@@ -3090,7 +3128,7 @@ if(location.hash==='#selftest')pkgSelftest();
// 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
// leave locked rows (syntax bare-kind, ui:, pkg: keys) untouched.
-if(location.hash==='#locktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#locktest')gate('locktest',A=>withSavedState(['PALETTE','MAP','SYNTAX','UIMAP','PKGMAP','LOCKED'],()=>{
const cssRgb=h=>{const [r,g,b]=hex2rgb(h);return 'rgb('+r+', '+g+', '+b+')';};
LOCKED.clear();buildTable();
{const k=CATS.map(c=>c[0]).filter(k=>k!=='bg'&&k!=='p')[0];
@@ -3160,13 +3198,12 @@ if(location.hash==='#locktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
if(filter&&faces.length>1){filter.value=faces[0];buildPkgTable();const b=document.getElementById('pkglocktoggle');b.click();
A(faces.every(face=>LOCKED.has('pkg:'+app+':'+face)),'pkg lock-all covers the whole package even when filtered');
filter.value='';buildPkgTable();}}
- document.title='LOCKTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='locktest';d.textContent='LOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ }));
// Sort gate (open with #sorttest): all three tables now share srtTable/cellVal.
// Verifies the syntax table (which used to have its own srt) sorts by color
// value and by element name, that a repeat click reverses, and that the UI and
// package tables still sort. Guards the unified sort for the later stages.
-if(location.hash==='#sorttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#sorttest')gate('sorttest',A=>{
const ddVals=tb=>[...document.querySelectorAll('#'+tb+' tr:not(.detailrow)')].map(tr=>{const dd=tr.cells[2].querySelector('.cdd');return dd?(dd.dataset.val||''):'';});
const txtVals=tb=>[...document.querySelectorAll('#'+tb+' tr:not(.detailrow)')].map(tr=>tr.cells[1].innerText.trim().toLowerCase());
const asc=a=>a.every((v,i)=>i===0||a[i-1]<=v),desc=a=>a.every((v,i)=>i===0||a[i-1]>=v);
@@ -3176,13 +3213,12 @@ if(location.hash==='#sorttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
srtTable('legbody',1);A(asc(txtVals('legbody')),'legbody-elements-asc');
buildUITable();srtTable('uibody',1);A(asc(txtVals('uibody')),'uibody-face-asc');
buildPkgTable();srtTable('pkgbody',2);A(asc(ddVals('pkgbody')),'pkgbody-fg-asc');
- document.title='SORTTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='sorttest';d.textContent='SORTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Live-buffer rendering gate (open with #mocktest): pins the face-faithfulness
// fixes so they cannot silently regress — overlay faces keep syntax colors and
// honor their styles, the cursor sits on a glyph, line numbers honor weight, the
// fringe shows its foreground indicator, and the mode-line carries its box.
-if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMAP'],()=>{
const Q=s=>document.querySelector('#mockframe '+s);
buildMockFrame();
A(Q('[data-face="highlight"] [data-k]'),'highlight-keeps-token-colors');
@@ -3231,13 +3267,12 @@ if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(pkgWeight()&&pkgWeight().dataset.val==='','pkg weight dropdown starts empty when model is unset');
pickEnum(pkgWeight(),'heavy');
A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited');
- document.title='MOCKTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='mocktest';d.textContent='MOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ }));
// Palette-generator gate (open with #generatortest): previewing is non-mutating,
// clicking a generated tile loads the existing selector, adding creates a normal
// singleton base column, and appending a preview column commits all span members
// under one stable column id.
-if(location.hash==='#generatortest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#generatortest')gate('generatortest',A=>{
const before=JSON.stringify(PALETTE);
A(document.getElementById('genaccents').value==='5','default accent count is 5');
A(document.getElementById('gensource').value==='palette','default generator source is palette');
@@ -3287,12 +3322,11 @@ if(location.hash==='#generatortest'){let ok=true;const notes=[];const A=(c,n)=>{
GEN_PROPOSAL={summary:{generated:1,rejected:0,minContrast:null},columns:[{name:'medium-aquamarine',members:[{name:'medium-aquamarine',hex:'#66cdaa',offset:0,columnId:'medium-aquamarine'}]}]};
renderGeneratorPreview();
A(document.querySelector('#genpreview .genchip .gn').textContent==='medium aquamarine','generated tile names display spaces instead of hyphens');
- document.title='GENERATORTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='generatortest';d.textContent='GENERATORTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Auto-dim gate (open with #autodimtest): the bespoke split preview shows the
// selected language in both panes -- the left in real syntax colors, the right
// collapsed to the single auto-dim-other-buffers face -- and tracks the langsel.
-if(location.hash==='#autodimtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#autodimtest')gate('autodimtest',A=>{
const langs=Object.keys(SAMPLES),ls=document.getElementById('langsel');
ls.value=langs[0];
const box=document.createElement('div');box.innerHTML=renderAutodimPreview();
@@ -3306,8 +3340,7 @@ if(location.hash==='#autodimtest'){let ok=true;const notes=[];const A=(c,n)=>{if
if(langs.length>1){const t1=box.textContent;ls.value=langs[1];
const box2=document.createElement('div');box2.innerHTML=renderAutodimPreview();
A(box2.textContent!==t1,'preview tracks the language selector');ls.value=langs[0];}
- document.title='AUTODIMTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='autodimtest';d.textContent='AUTODIMTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
if(location.hash.startsWith('#pick')){openPicker();const m=location.hash.slice(5);if(m){const b=document.querySelector('.pmode button[data-m="'+m+'"]');if(b)b.click();}}
if(location.hash==='#cursortest'){document.getElementById('newhexstr').value='#67809c';openPicker();const sc=document.getElementById('svcur'),hc=document.getElementById('huecur');const L=parseFloat(sc.style.left||'0'),T=parseFloat(sc.style.top||'0'),H=parseFloat(hc.style.top||'0');const ok=L>1&&T>1&&H>1;document.title='CURSORTEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='cursortest';d.textContent='CURSORTEST '+(ok?'PASS':'FAIL')+' left='+sc.style.left+' top='+sc.style.top+' hue='+hc.style.top;document.body.appendChild(d);}
if(location.hash.startsWith('#app')){const ap=location.hash.slice(4),s=document.getElementById('appsel');if(s&&ap){s.value=ap;pkgChanged();}}
@@ -3358,7 +3391,7 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById('
// Worst-case readout gate (open with #contrasttest): a covered overlay face shows
// the floor over its foreground set and names the limiting foreground, an
// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set".
-if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#contrasttest')gate('contrasttest',A=>{
const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP));
CATS.forEach(c=>{if(c[0]!=='bg'&&c[0]!=='p')setSyntaxFg(c[0],'');});
setSyntaxFg('p','#f0fef0');setSyntaxFg('kw','#67809c');setSyntaxFg('str','#a3b18a');setSyntaxFg('bg','#000000');
@@ -3423,12 +3456,11 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i
}else A(false,'syntax table has a p row with a dropdown');
if(pLocked){LOCKED.add('p');buildTable();}
for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);syncSyntaxFromCache();for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();applyGround();
- document.title='CONTRASTTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='contrasttest';d.textContent='CONTRASTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Bevel gate (open with #beveltest): released/pressed boxes derive their
// highlight and shadow from the face's effective bg per Emacs's relief
// algorithm, and pressed draws the shadow edge first.
-if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#beveltest')gate('beveltest',A=>{
const saveUI=JSON.parse(JSON.stringify(UIMAP)),saveP=PALETTE.slice(),savePK=JSON.parse(JSON.stringify(PKGMAP));
UIMAP['mode-line']={fg:'#d8dee9',bg:'#30343c',weight:null,slant:null,underline:null,strike:null,box:{style:'released',width:1,color:null}};
buildUITable();
@@ -3454,14 +3486,13 @@ if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
if(pdd){pdd.click();const redRow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>(c.dataset.name||'').includes('red'));if(redRow)redRow.click();}
A(PKGMAP[app][face].box&&PKGMAP[app][face].box.color==='#ff0000','package box color dropdown writes box.color');
PALETTE=saveP;PKGMAP=savePK;for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();buildPkgTable();
- document.title='BEVELTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='beveltest';d.textContent='BEVELTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Gallery gate (open with #gallerytest): the color dropdown opens a 2D grid in
// the palette-panel shape. Driven on a throwaway dropdown so no real face state
// is mutated. Covers: grid opens, every palette color has a cell, a cell click
// fires onPick + updates the trigger, the pick highlights on reopen, the default
// chip clears.
-if(location.hash==='#gallerytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#gallerytest')gate('gallerytest',A=>withSavedState(['MAP','SYNTAX'],()=>{
let picked='__none__';
const dd=mkColorDropdown(ddList(''),'',(hex)=>{picked=hex;},{});
document.body.appendChild(dd);
@@ -3485,11 +3516,10 @@ if(location.hash==='#gallerytest'){let ok=true;const notes=[];const A=(c,n)=>{if
trig.click();const defc=document.querySelector('.cddpop.cddgrid .cddgdef');if(defc)defc.click();
A(picked==='','the default chip clears the assignment: '+JSON.stringify(picked));
dd.remove();closeColorDropdown();
- document.title='GALLERYTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='gallerytest';d.textContent='GALLERYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ }));
// Preview-link gate (open with #previewlinktest): known bespoke-preview face
// mappings stay wired to the face that Emacs actually uses.
-if(location.hash==='#previewlinktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#previewlinktest')gate('previewlinktest',A=>{
const box=document.createElement('div');
box.innerHTML=renderOrgPreview();
const headline=[...box.querySelectorAll('[data-face="org-headline-todo"]')].find(e=>e.textContent.includes('Heading three'));
@@ -3504,11 +3534,10 @@ if(location.hash==='#previewlinktest'){let ok=true;const notes=[];const A=(c,n)=
const bob=[...box.querySelectorAll('[data-face="erc-default-face"]')].some(e=>e.textContent.includes('hi craig'));
A(own,'erc own sent message uses erc-input-face');
A(bob,'erc remote message uses erc-default-face');
- document.title='PREVIEWLINKTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='previewlinktest';d.textContent='PREVIEWLINKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe
// lightness band for a selected covered face and hides it when no face is selected.
-if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#safetest')gate('safetest',A=>withSavedState(['MAP','SYNTAX'],()=>{
const saveMAP=Object.assign({},MAP);
setSyntaxFg('p','#f0fef0');setSyntaxFg('kw','#67809c');setSyntaxFg('bg','#000000');
document.getElementById('newhexstr').value='#202830';openPicker();setPkModel('oklch');
@@ -3520,11 +3549,10 @@ if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(band&&band.style.display==='none','safe band hidden when no face is selected');
for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);syncSyntaxFromCache();
setPkModel('hsv');closePicker();
- document.title='SAFETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='safetest';d.textContent='SAFETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ }));
// Gone-rebind gate (open with #healtest): deleting a named color then recreating
// the name re-points face references stranded on the old hex to the new color.
-if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#healtest')gate('healtest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx;
PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];setSyntaxFg('kw','#67809c');lastGone={};selectedIdx=null;renderPalette();buildTable();
const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue');
@@ -3537,12 +3565,11 @@ if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(!('blue' in lastGone),'heal consumed the gone entry');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel;
renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
- document.title='HEALTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Column-strip gate (open with #columntest): the palette renders as a pinned
// ground column plus structural columns, chips keep their controls, and renaming
// a color leaves it in the same strip because the column id is stable.
-if(location.hash==='#columntest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#columntest')gate('columntest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveG=Object.assign({},lastGone),saveSel=selectedIdx;
setSyntaxFg('bg','#0d0b0a');setSyntaxFg('p','#f0fef0');
PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette();
@@ -3625,13 +3652,12 @@ if(location.hash==='#columntest'){let ok=true;const notes=[];const A=(c,n)=>{if(
A(lastGone['blue']==='#3a6ea5'&&lastGone['blue+1']==='#92acc2','clear palette records removed names for recovery');
A(selectedIdx===null,'clear palette clears selected color');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();lastGone=saveG;selectedIdx=saveSel;renderPalette();
- document.title='COLUMNTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='columntest';d.textContent='COLUMNTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Count-control gate (open with #counttest): the per-column count regenerates the
// column — count up adds symmetric steps, count down drops the extremes, a
// reference to a surviving step follows the new hex, a reference to a removed step
// is left on its old (now-gone) hex.
-if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#counttest')gate('counttest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx;
paletteShowFull=true; // this gate asserts span tiles, so render the full palette
setSyntaxFg('bg','#204060');setSyntaxFg('p','#f0fef0');
@@ -3677,12 +3703,11 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
const lo=_lum(MAP['bg']),hi=_lum(MAP['p']),blue=PALETTE.filter(p=>p[2]==='blue').map(p=>_lum(p[0]));
A(blue.length&&blue.every(L=>L>=lo-1e-6&&L<=hi+1e-6),'generated span stays within the bg/fg bounds');}
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
- document.title='COUNTTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Base-edit + ground-edit gate (open with #baseedittest): editing a column base
// recolors the whole column at the same count and references follow; editing a
// ground swatch writes the bg/fg assignment.
-if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#baseedittest')gate('baseedittest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx;
setSyntaxFg('bg','#0d0b0a');setSyntaxFg('p','#f0fef0');
PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']];
@@ -3713,11 +3738,10 @@ if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{i
A(!PALETTE.some(p=>/^fg[+-]\d+$/.test(p[1])),'editing fg does not generate fg span tiles from a same-hex normal column');
A(PALETTE.find(p=>p[1]==='fg')[2]==='ground','editing fg preserves the ground column id');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
- document.title='BASEEDITTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// Round-trip gate (open with #roundtriptest): export stays a flat palette with
// stable column ids, and import does not need color-derived column reconstruction.
-if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#roundtriptest')gate('roundtriptest',A=>{
const stable=o=>Array.isArray(o)?o.map(stable):(o&&typeof o==='object'?Object.fromEntries(Object.keys(o).sort().map(k=>[k,stable(o[k])])):o);
const diff=(a,b,p='')=>{if(JSON.stringify(a)===JSON.stringify(b))return '';if(typeof a!==typeof b||!a||!b||typeof a!=='object')return p+': '+JSON.stringify(a)+' != '+JSON.stringify(b);
const ks=[...new Set([...Object.keys(a),...Object.keys(b)])].sort();for(const k of ks){const d=diff(a[k],b[k],p?p+'.'+k:k);if(d)return d;}return p;};
@@ -3735,13 +3759,12 @@ if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{
A(obj.palette.some(e=>e[1]==='renamed-blue'&&e[2]==='blue'),'renamed color keeps its stable column id through export/import');
A(obj.locks&&obj.locks.includes('kw')&&obj.locks.includes('ui:region'),'lock state survives export/import');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();LOCKED=saveL;
- document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+ });
// View-selector gate (open with #viewtest): the assignment panel is driven by a
// single #viewsel dropdown -- two editor entries (@code, @ui) then a "package
// faces" optgroup of every app, alphabetically by label -- and switching it
// shows exactly one of the three view blocks.
-if(location.hash==='#viewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#viewtest')gate('viewtest',A=>{
const sel=document.getElementById('viewsel');
A(!!sel,'viewsel-exists');
if(sel){
@@ -3761,14 +3784,13 @@ if(location.hash==='#viewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(curApp()===firstApp,'curApp-returns-selected-app');
A(!document.querySelector('#pkgbody .sbtn[title="reset to default"]'),'no-per-row-reset-button');
}
- document.title='VIEWTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='viewtest';d.textContent='VIEWTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Non-default-marker gate (open with #ndtest): a per-face setting cell gets the
// .nd corner flag only when its value differs from the face's seed default. Cell
// order in a pkg row: 0 lock, 1 label, 2 fg, 3 bg, 4 style, 5 box, 6 contrast.
// inherit + height live in the row expander, so a non-default height flags the
// expander toggle (exp-nd) rather than an inline cell.
-if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#ndtest')gate('ndtest',A=>withSavedState(['PKGMAP','LOCKED'],()=>{
LOCKED.clear();
const app=curApp(),row=APPS[app].faces[0],face=row[0];
PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
@@ -3783,22 +3805,20 @@ if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){
A(tr2.cells[4].classList.contains('nd'),'toggled-weight-marks-style-box');
A(!tr2.querySelector('.exptoggle').classList.contains('exp-nd'),'restored-height-unflags-expander');
PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
- document.title='NDTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='ndtest';d.textContent='NDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ }));
// Contrast-cell gate (open with #crtest): the per-face contrast column shows a
// bare colored number (no PASS/FAIL word); the WCAG verdict lives in the hover.
-if(location.hash==='#crtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#crtest')gate('crtest',A=>{
const app=curApp(),face=APPS[app].faces[0][0];buildPkgTable();
const cell=document.querySelector('#pkgbody tr[data-face="'+face+'"]').cells[6];
const span=cell&&cell.querySelector('span');
A(span&&/^\d+\.\d$/.test(span.textContent.trim()),'contrast cell is a bare number: '+(span&&span.textContent));
A(span&&!/PASS|FAIL/.test(span.textContent),'no PASS/FAIL word in the contrast cell');
A(span&&span.title&&/(passes|fails) WCAG/i.test(span.title),'contrast cell carries a WCAG hover: '+(span&&span.title));
- document.title='CRTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='crtest';d.textContent='CRTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// View-nav gate (open with #navtest): the prev/next arrows flanking the view
// dropdown step the selection (clamped, no wrap) and re-render the view.
-if(location.hash==='#navtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#navtest')gate('navtest',A=>{
const sel=document.getElementById('viewsel'),prev=document.getElementById('viewprev'),next=document.getElementById('viewnext');
A(!!prev&&!!next,'nav arrows exist');
if(sel&&prev&&next){
@@ -3812,57 +3832,35 @@ if(location.hash==='#navtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c)
sel.selectedIndex=2;onViewChange();
A(sel.options[2]&&sel.options[2].value[0]!=='@'&&vis('view-pkg'),'stepping to a package shows the pkg view');
}
- document.title='NAVTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='navtest';d.textContent='NAVTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Markdown-preview gate (open with #mdtest): markdown-mode has a dedicated README
// renderer, and every data-face it emits is a real markdown-mode face.
-if(location.hash==='#mdtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#mdtest')gate('mdtest',A=>{
A(APPS['markdown-mode']&&APPS['markdown-mode'].preview==='markdown','markdown-mode wired to the markdown preview');
A(!!PACKAGE_PREVIEWS['markdown'],'markdown renderer registered');
if(PACKAGE_PREVIEWS['markdown']&&APPS['markdown-mode']){
- const box=document.createElement('div');box.innerHTML=PACKAGE_PREVIEWS['markdown']();
- const valid=new Set(APPS['markdown-mode'].faces.map(r=>r[0]));
- const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
- A(used.length>=15,'preview exercises many faces ('+used.length+')');
- const bad=used.filter(f=>!valid.has(f));
- A(bad.length===0,'every data-face is a real markdown face; bad='+bad.join(','));
- for(const f of ['markdown-header-face-1','markdown-bold-face','markdown-inline-code-face','markdown-blockquote-face','markdown-gfm-checkbox-face','markdown-table-face'])
- A(used.includes(f),'preview includes '+f);
+ assertPreviewFaces(A, PACKAGE_PREVIEWS['markdown'](), APPS['markdown-mode'].faces, 15, 'markdown',
+ ['markdown-header-face-1','markdown-bold-face','markdown-inline-code-face','markdown-blockquote-face','markdown-gfm-checkbox-face','markdown-table-face']);
}
- document.title='MDTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='mdtest';d.textContent='MDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// mu4e-preview gate (open with #mupreviewtest): the mu4e preview is a realistic
// headers list + message view, and every data-face it emits is a real mu4e face.
-if(location.hash==='#mupreviewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
- const box=document.createElement('div');box.innerHTML=renderMu4ePreview();
- const valid=new Set((APPS['mu4e']&&APPS['mu4e'].faces||[]).map(r=>r[0]));
- const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
- A(used.length>=20,'preview exercises many faces ('+used.length+')');
- const bad=used.filter(f=>!valid.has(f));
- A(bad.length===0,'every data-face is a real mu4e face; bad='+bad.join(','));
- for(const f of ['mu4e-unread-face','mu4e-flagged-face','mu4e-replied-face','mu4e-draft-face','mu4e-trashed-face','mu4e-header-highlight-face','mu4e-header-marks-face','mu4e-contact-face','mu4e-compose-separator-face'])
- A(used.includes(f),'preview includes '+f);
- document.title='MUPREVIEWTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='mupreviewtest';d.textContent='MUPREVIEWTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+if(location.hash==='#mupreviewtest')gate('mupreviewtest',A=>{
+ assertPreviewFaces(A, renderMu4ePreview(), APPS['mu4e']&&APPS['mu4e'].faces, 20, 'mu4e',
+ ['mu4e-unread-face','mu4e-flagged-face','mu4e-replied-face','mu4e-draft-face','mu4e-trashed-face','mu4e-header-highlight-face','mu4e-header-marks-face','mu4e-contact-face','mu4e-compose-separator-face']);
+ });
// gnus-preview gate (open with #gnustest): gnus is its own view package (it drives
// the mu4e article view), and every data-face its preview emits is a real gnus face.
-if(location.hash==='#gnustest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#gnustest')gate('gnustest',A=>{
A(!!APPS['gnus'],'gnus is a registered view package');
A(APPS['gnus']&&APPS['gnus'].preview==='gnus','gnus uses the gnus preview renderer');
- const box=document.createElement('div');box.innerHTML=renderGnusPreview();
- const valid=new Set((APPS['gnus']&&APPS['gnus'].faces||[]).map(r=>r[0]));
- const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
- A(used.length>=20,'preview exercises many faces ('+used.length+')');
- const bad=used.filter(f=>!valid.has(f));
- A(bad.length===0,'every data-face is a real gnus face; bad='+bad.join(','));
- for(const f of ['gnus-header-name','gnus-header-from','gnus-header-subject','gnus-cite-1','gnus-cite-attribution','gnus-signature','gnus-button','gnus-emphasis-highlight-words'])
- A(used.includes(f),'preview includes '+f);
- document.title='GNUSTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='gnustest';d.textContent='GNUSTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ assertPreviewFaces(A, renderGnusPreview(), APPS['gnus']&&APPS['gnus'].faces, 20, 'gnus',
+ ['gnus-header-name','gnus-header-from','gnus-header-subject','gnus-cite-1','gnus-cite-attribution','gnus-signature','gnus-button','gnus-emphasis-highlight-words']);
+ });
// picker-distinct gate (open with #pickertest): the color picker panel must stand
// out from the page background. It carries a highlighted gold accent border, and its
// background is meaningfully lighter than the body so the two are easy to tell apart.
-if(location.hash==='#pickertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#pickertest')gate('pickertest',A=>{
const pk=document.getElementById('picker');A(!!pk,'picker element exists');
if(pk){const cs=getComputedStyle(pk),body=getComputedStyle(document.body);
const bc=(cs.borderTopColor.match(/\d+/g)||[]).slice(0,3).map(Number);
@@ -3872,12 +3870,11 @@ if(location.hash==='#pickertest'){let ok=true;const notes=[];const A=(c,n)=>{if(
const lift=pkbg.map((c,i)=>c-bdbg[i]);
A(lift.every(d=>d>=12),'picker background is clearly lighter than the page (per-channel lift '+lift.join(',')+')');
}
- document.title='PICKERTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='pickertest';d.textContent='PICKERTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Box-cluster gate (open with #boxtest): the box control is a 2x2 cluster of
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
-if(location.hash==='#boxtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#boxtest')gate('boxtest',A=>{
LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box;
UIMAP[f].box=null;buildUITable();
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5];
@@ -3893,11 +3890,10 @@ if(location.hash==='#boxtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c)
A(UIMAP[f].box===null,'blank-click-clears-box');
A(dd.style.display==='none','color-hidden-again-after-clear');
UIMAP[f].box=saveBox;buildUITable();
- document.title='BOXTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='boxtest';d.textContent='BOXTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Style-cluster gate (open with #styletest): the style cell holds a weight
// selector, a slant selector, and box-like underline and strike controls.
-if(location.hash==='#styletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#styletest')gate('styletest',A=>{
buildUITable();const f=UI_FACES[0][0];
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4];
const cluster=cell.querySelector('.stylecluster');
@@ -3917,11 +3913,10 @@ if(location.hash==='#styletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(sital&&sital.style.fontStyle==='italic','slant-options-preview-their-own-slant: italic renders italic');
closeColorDropdown();
A(cluster&&cluster.querySelectorAll('.boxctl').length===1,'strike-control-in-row-underline-moved-to-expander');
- document.title='STYLETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='styletest';d.textContent='STYLETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Expander gate (open with #expandtest): the per-row "more" toggle reveals a
// detail row with the overflow attribute editor, and its controls write the model.
-if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#expandtest')gate('expandtest',A=>{
buildUITable();
const row=document.querySelector('#uibody tr[data-face="region"]');
const detail=document.querySelector('#uibody tr.detailrow[data-detail-for="region"]');
@@ -3961,13 +3956,12 @@ if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
buildPkgTable();const pface=APPS[curApp()].faces[0][0];
const pdetail=document.querySelector('#pkgbody tr.detailrow[data-detail-for="'+pface+'"]');
A(pdetail&&pdetail.querySelector('select.detailsel'),'package-expander-offers-inherit');
- document.title='EXPANDTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='expandtest';d.textContent='EXPANDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Height-clamp gate (open with #heighttest): the expander height field coerces a
// typed value into [HEIGHT_MIN,HEIGHT_MAX] and writes the clamped number back, so
// an out-of-range type/paste can't reach the model. Guards the fact that an
// <input type=number> min/max only constrain its steppers, never typed text.
-if(location.hash==='#heighttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#heighttest')gate('heighttest',A=>{
const face=UI_FACES[0][0],save=JSON.parse(JSON.stringify(UIMAP[face]));
buildUITable();
const hin=()=>document.querySelector('#uibody tr.detailrow[data-detail-for="'+face+'"] .hstep');
@@ -3982,12 +3976,11 @@ if(location.hash==='#heighttest'){let ok=true;const notes=[];const A=(c,n)=>{if(
typeHeight('');
A(UIMAP[face].height===null,'blank-unsets-to-null: '+UIMAP[face].height);
UIMAP[face]=save;buildUITable();
- document.title='HEIGHTTEST '+(ok?'PASS':'FAIL');
- const hd=document.createElement('div');hd.id='heighttest';hd.textContent='HEIGHTTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(hd);}
+ });
// Language-dropdown gate (open with #langtest): the language list is sorted
// alphabetically with Elisp pinned as the default selection, and the ‹ › arrows
// step the selection (clamped, no wrap).
-if(location.hash==='#langtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#langtest')gate('langtest',A=>{
buildLangSel();
const s=document.getElementById('langsel');
const labels=[...s.options].map(o=>o.value);
@@ -4000,11 +3993,10 @@ if(location.hash==='#langtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(s.selectedIndex===1,'next steps forward one');
s.selectedIndex=s.options.length-1;stepLang(1);
A(s.selectedIndex===s.options.length-1,'next clamps at the last language');
- document.title='LANGTEST '+(ok?'PASS':'FAIL');
- const ld=document.createElement('div');ld.id='langtest';ld.textContent='LANGTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ld);}
+ });
// View-lock-indicator gate (open with #viewlocktest): the view dropdown prefixes a
// lock glyph on a view whose every element is locked, and clears it otherwise.
-if(location.hash==='#viewlocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#viewlocktest')gate('viewlocktest',A=>withSavedState(['LOCKED'],()=>{
LOCKED.clear();updateViewLockIndicators();
const s=document.getElementById('viewsel'),codeOpt=()=>[...s.options].find(o=>o.value==='@code');
A(codeOpt()&&!codeOpt().textContent.startsWith('🔒'),'unlocked view shows no lock glyph: '+(codeOpt()&&codeOpt().textContent));
@@ -4014,11 +4006,10 @@ if(location.hash==='#viewlocktest'){let ok=true;const notes=[];const A=(c,n)=>{i
LOCKED.delete(syntaxLockKeys()[0]);updateViewLockIndicators();
A(codeOpt()&&!codeOpt().textContent.startsWith('🔒'),'unlocking one element clears the glyph');
LOCKED.clear();updateViewLockIndicators();
- document.title='VIEWLOCKTEST '+(ok?'PASS':'FAIL');
- const vd=document.createElement('div');vd.id='viewlocktest';vd.textContent='VIEWLOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(vd);}
+ }));
// Detail-hover gate (open with #detailhovertest): every label in the expander
// detail row carries an explanatory hover, the way the table-header labels do.
-if(location.hash==='#detailhovertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#detailhovertest')gate('detailhovertest',A=>{
buildUITable();
const f=UI_FACES[0][0],detail=document.querySelector('#uibody tr.detailrow[data-detail-for="'+f+'"]');
const fields=detail?[...detail.querySelectorAll('.detailfield')]:[];
@@ -4026,12 +4017,11 @@ if(location.hash==='#detailhovertest'){let ok=true;const notes=[];const A=(c,n)=
A(fields.every(g=>g.title&&g.title.length>0),'every detail field has a hover: '+fields.map(g=>g.querySelector('span').textContent+(g.title?'+':'-')).join(' '));
const inh=fields.find(g=>g.querySelector('span').textContent==='inherit');
A(inh&&/inherit/i.test(inh.title),'inherit field hover mentions inheritance: '+(inh&&inh.title));
- document.title='DETAILHOVERTEST '+(ok?'PASS':'FAIL');
- const dh=document.createElement('div');dh.id='detailhovertest';dh.textContent='DETAILHOVERTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(dh);}
+ });
// Expand/collapse-all gate (open with #expandalltest): the header toggle opens or
// closes every row's detail at once, the per-row triangles track state (▶ closed,
// ▼ open), and the header button's label follows the aggregate.
-if(location.hash==='#expandalltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#expandalltest')gate('expandalltest',A=>{
buildUITable();
const tb=document.getElementById('uibody'),btn=document.getElementById('uiexpandall');
const details=()=>[...tb.querySelectorAll('tr.detailrow')];
@@ -4049,12 +4039,11 @@ if(location.hash==='#expandalltest'){let ok=true;const notes=[];const A=(c,n)=>{
firstTog().click();
A(open()===1,'a single row toggle opens just that row');
A(btn.textContent.indexOf('▼')===0,'button reflects a single open row as ▼ collapse all');
- document.title='EXPANDALLTEST '+(ok?'PASS':'FAIL');
- const ea=document.createElement('div');ea.id='expandalltest';ea.textContent='EXPANDALLTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ea);}
+ });
// Expander-persistence gate (open with #expandpersisttest): a package edit rebuilds
// the whole table, so an open expander must reopen instead of collapsing under the
// user. Editing a value inside the open expander must not close the row.
-if(location.hash==='#expandpersisttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#expandpersisttest')gate('expandpersisttest',A=>withSavedState(['PKGMAP'],()=>{
EXPANDED.clear();
const app=curApp(),face=APPS[app].faces[0][0];buildPkgTable();
const row=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"]');
@@ -4068,20 +4057,18 @@ if(location.hash==='#expandpersisttest'){let ok=true;const notes=[];const A=(c,n
row().querySelector('.exptoggle').click();buildPkgTable();
A(detail()&&detail().style.display==='none','a collapsed expander stays collapsed across a rebuild');
EXPANDED.clear();buildPkgTable();
- document.title='EXPANDPERSISTTEST '+(ok?'PASS':'FAIL');
- const ep=document.createElement('div');ep.id='expandpersisttest';ep.textContent='EXPANDPERSISTTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ep);}
+ }));
// Palette default-state gate (open with #paldefaulttest): the studio opens with
// the palette collapsed to base colors so the span tints don't crowd the first
// view. initApp() ran at page load, so the live toggle reflects the opening state.
-if(location.hash==='#paldefaulttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#paldefaulttest')gate('paldefaulttest',A=>{
const tg=document.getElementById('paltoggle');
A(!!tg,'palette toggle present after boot');
A(tg&&tg.textContent==='▶','palette opens collapsed to base colors (arrow shows right-pointing ▶)');
- document.title='PALDEFAULTTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='paldefaulttest';d.textContent='PALDEFAULTTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Palette display-toggle gate (open with #paltoggletest): the arrow control
// collapses each column to its base color and expands back to full spans.
-if(location.hash==='#paltoggletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#paltoggletest')gate('paltoggletest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP);
paletteShowFull=true; // start expanded so the first click collapses to base-only
setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0');
@@ -4098,12 +4085,11 @@ if(location.hash==='#paltoggletest'){let ok=true;const notes=[];const A=(c,n)=>{
document.getElementById('paltoggle').click();
A(blueChips()===5,'toggling-back-restores-spans');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);syncSyntaxFromCache();renderPalette();
- document.title='PALTOGGLETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='paltoggletest';d.textContent='PALTOGGLETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Unused-tile gate (open with #unusedtest): a palette color referenced nowhere
// in the theme gets the .unused flag; a column with no used members gets
// .unused-col; referenced colors stay unflagged.
-if(location.hash==='#unusedtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#unusedtest')gate('unusedtest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSyn=JSON.parse(JSON.stringify(SYNTAX)),saveU=JSON.parse(JSON.stringify(UIMAP));
setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0');
PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue'],['#123456','teal','teal']];
@@ -4119,12 +4105,11 @@ if(location.hash==='#unusedtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
A(tealStrip&&tealStrip.classList.contains('unused-col'),'all-unused-column-flagged');
A(blueStrip&&!blueStrip.classList.contains('unused-col'),'used-column-not-flagged');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const k in SYNTAX)delete SYNTAX[k];Object.assign(SYNTAX,saveSyn);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);syncSyntaxFromCache();renderPalette();
- document.title='UNUSEDTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='unusedtest';d.textContent='UNUSEDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Gone-assignment gate (open with #gonetest): a swatch whose assigned color is
// no longer in the palette gets the .gone flag; an assignment to a present color
// does not.
-if(location.hash==='#gonetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#gonetest')gate('gonetest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP));
setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0');
PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue']];
@@ -4136,11 +4121,10 @@ if(location.hash==='#gonetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(goneDd&&goneDd.classList.contains('gone'),'assignment-to-missing-color-flagged');
A(okDd&&!okDd.classList.contains('gone'),'assignment-to-present-color-not-flagged');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);syncSyntaxFromCache();buildUITable();
- document.title='GONETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='gonetest';d.textContent='GONETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Tile-usage-hover gate (open with #usagetest): a tile's title lists the
// "view area > element" pairings that use its color, under the name/hex line.
-if(location.hash==='#usagetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#usagetest')gate('usagetest',A=>{
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP));
setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0');
PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue']];
@@ -4152,12 +4136,11 @@ if(location.hash==='#usagetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(blueChip&&blueChip.title.includes('ui faces > '+f0label),'hover-title-lists-ui-face-usage');
A(blueChip&&blueChip.title.split('\n').length>1,'usage-list-on-its-own-line-under-current-info');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);syncSyntaxFromCache();renderPalette();
- document.title='USAGETEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='usagetest';d.textContent='USAGETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Element-docstring hovers (open with #hovertest): each table's category cell
// carries the face's Emacs docstring on top of its prior hover text, and the
// existing label-span hints are left intact (added in addition, not replaced).
-if(location.hash==='#hovertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#hovertest')gate('hovertest',A=>{
buildTable();buildUITable();buildPkgTable();
const synCell=document.querySelector('#legbody tr[data-kind="kw"] .cat');
A(synCell&&synCell.title===SYNTAX_DOCS['kw'],'syntax cat cell shows the category face docstring: '+(synCell&&synCell.title));
@@ -4169,8 +4152,7 @@ if(location.hash==='#hovertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(docFace,'a package face with a docstring exists to test');
if(docFace){const pkgCell=document.querySelector('#pkgbody tr[data-face="'+docFace+'"] .cat');
A(pkgCell&&pkgCell.title===FACE_DOCS[docFace]+'\n\n'+docFace,'package cat cell shows docstring on top of the face name: '+(pkgCell&&JSON.stringify(pkgCell.title)));}
- document.title='HOVERTEST '+(ok?'PASS':'FAIL');
- const d=document.createElement('div');d.id='hovertest';d.textContent='HOVERTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+ });
// Export via the File System Access API (open with #savetest): exportTheme writes
// the theme JSON straight to the picked file handle and closes it, so re-exporting
// overwrites in place instead of the browser uniquifying to "name (1).json".
diff --git a/tests/test-ai-term--capture-state.el b/tests/test-ai-term--capture-state.el
index 543f83ad7..aa7421350 100644
--- a/tests/test-ai-term--capture-state.el
+++ b/tests/test-ai-term--capture-state.el
@@ -27,7 +27,9 @@
(should (= cj/--ai-term-last-size (window-body-width right))))))
(ert-deftest test-ai-term--capture-state-below-split-sets-direction ()
- "Normal: below-split window -> direction=below, integer body-lines matching window."
+ "Normal: below-split window -> direction=below, integer total-lines matching window.
+The vertical axis captures total-height (not body-height) so the toggle
+round-trip is immune to the mode line's pixel height."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below))
@@ -36,7 +38,7 @@
(cj/--ai-term-capture-state below)
(should (eq cj/--ai-term-last-direction 'below))
(should (integerp cj/--ai-term-last-size))
- (should (= cj/--ai-term-last-size (window-body-height below))))))
+ (should (= cj/--ai-term-last-size (window-total-height below))))))
(ert-deftest test-ai-term--capture-state-noop-on-dead-window ()
"Boundary: nil window -> state remains unchanged."
diff --git a/tests/test-ai-term--reuse-edge-window.el b/tests/test-ai-term--reuse-edge-window.el
index f6259ae50..a9a0529e8 100644
--- a/tests/test-ai-term--reuse-edge-window.el
+++ b/tests/test-ai-term--reuse-edge-window.el
@@ -269,5 +269,46 @@ most-recent agent, which would now be the other one."
(when (get-buffer right-name) (kill-buffer right-name))
(cj/test--kill-agent-buffers))))
+(ert-deftest test-ai-term--reuse-edge-window-3win-toggle-restores-own-window ()
+ "Regression: in a 3-window layout the agent has its own split, so toggling it
+off then on restores it as its own window without displacing a working window.
+Before the fix, toggle-on reused the bottom edge (the user's main window),
+collapsing three windows to two and hiding the main buffer. A toggle must be
+reversible: off then on returns to the same layout."
+ (cj/test--kill-agent-buffers)
+ (let ((agent-name "agent [3win-toggle]")
+ (code-name "*test-3win-code*")
+ (main-name "*test-3win-main*")
+ (cj/--ai-term-last-direction nil)
+ (cj/--ai-term-last-size nil)
+ (cj/--ai-term-last-was-bury nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'cj/--ai-term-default-direction) (lambda (&rest _) 'below)))
+ (let ((code-buf (get-buffer-create code-name))
+ (main-buf (get-buffer-create main-name))
+ (agent-buf (get-buffer-create agent-name)))
+ (set-window-buffer (selected-window) code-buf)
+ (let* ((main-win (split-window (selected-window) nil 'below))
+ (agent-win (split-window main-win nil 'below)))
+ (set-window-buffer main-win main-buf)
+ (set-window-buffer agent-win agent-buf)
+ (should (= (count-windows) 3))
+ (let ((display-buffer-alist (cj/--ai-term-display-rule-list)))
+ (select-window agent-win)
+ (cj/test--call-as-gui #'cj/ai-term) ; off -> code | main
+ (should (= (count-windows) 2))
+ (should-not (member agent-name (cj/test--displayed-buffer-names)))
+ (cj/test--call-as-gui #'cj/ai-term) ; on -> back to 3 windows
+ (should (= (count-windows) 3))
+ (let ((bufs (cj/test--displayed-buffer-names)))
+ (should (member agent-name bufs))
+ (should (member code-name bufs))
+ (should (member main-name bufs))))))))
+ (when (get-buffer code-name) (kill-buffer code-name))
+ (when (get-buffer main-name) (kill-buffer main-name))
+ (cj/test--kill-agent-buffers))))
+
(provide 'test-ai-term--reuse-edge-window)
;;; test-ai-term--reuse-edge-window.el ends here
diff --git a/tests/test-calendar-sync--parse-exception-event.el b/tests/test-calendar-sync--parse-exception-event.el
new file mode 100644
index 000000000..1935d3ebb
--- /dev/null
+++ b/tests/test-calendar-sync--parse-exception-event.el
@@ -0,0 +1,64 @@
+;;; test-calendar-sync--parse-exception-event.el --- Tests for one-event exception parsing -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--parse-exception-event, the per-VEVENT half of
+;; calendar-sync--collect-recurrence-exceptions: it turns a single RECURRENCE-ID
+;; override VEVENT into an exception plist (or nil). One function per file.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name)))
+(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name)))
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+(defun test-cs-parse-exc--override-event (start end)
+ "Return a RECURRENCE-ID override VEVENT string for START..END."
+ (concat "BEGIN:VEVENT\n"
+ "UID:override@google.com\n"
+ "RECURRENCE-ID:20260203T090000Z\n"
+ "SUMMARY:Rescheduled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "END:VEVENT"))
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-normal-returns-plist ()
+ "Normal: a RECURRENCE-ID override parses into a plist with its overridden times."
+ (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 (plist-get plist :recurrence-id))
+ (should (equal "20260203T090000Z" (plist-get plist :recurrence-id-raw)))
+ (should (plist-get plist :start))
+ (should (plist-get plist :end))
+ (should (equal "Rescheduled Meeting" (plist-get plist :summary)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-boundary-no-recurrence-id ()
+ "Boundary: a VEVENT with no RECURRENCE-ID is not an override and returns nil."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (event (test-calendar-sync-make-vevent "Regular Event" start end)))
+ (should-not (calendar-sync--parse-exception-event event))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-error-unparseable-times ()
+ "Error: a RECURRENCE-ID override whose times do not parse returns nil rather
+than a half-built plist."
+ (let ((event (concat "BEGIN:VEVENT\n"
+ "UID:broken@google.com\n"
+ "RECURRENCE-ID:not-a-timestamp\n"
+ "SUMMARY:Broken Override\n"
+ "DTSTART:also-garbage\n"
+ "END:VEVENT")))
+ (should-not (calendar-sync--parse-exception-event event))))
+
+(provide 'test-calendar-sync--parse-exception-event)
+;;; test-calendar-sync--parse-exception-event.el ends here
diff --git a/tests/test-cj-window-geometry-lib.el b/tests/test-cj-window-geometry-lib.el
index 938749f21..d32a48a92 100644
--- a/tests/test-cj-window-geometry-lib.el
+++ b/tests/test-cj-window-geometry-lib.el
@@ -2,7 +2,7 @@
;;; Commentary:
;; Tests the pure helpers in `cj-window-geometry-lib.el':
-;; `cj/window-direction', `cj/window-body-size',
+;; `cj/window-direction', `cj/window-replay-size',
;; `cj/cardinal-to-edge-direction', and `cj/window-at-edge'.
;;; Code:
@@ -52,30 +52,32 @@
(delete-other-windows)
(should (eq (cj/window-direction (selected-window) 'below) 'below))))
-(ert-deftest test-cj-window-geometry--body-size-right-returns-body-cols ()
+(ert-deftest test-cj-window-geometry--replay-size-right-returns-body-cols ()
"Normal: right window with direction='right -> body-width in cols."
(save-window-excursion
(delete-other-windows)
(let ((right (split-window (selected-window) nil 'right)))
- (should (= (cj/window-body-size right 'right)
+ (should (= (cj/window-replay-size right 'right)
(window-body-width right))))))
-(ert-deftest test-cj-window-geometry--body-size-below-returns-body-lines ()
- "Normal: below window with direction='below -> body-height in lines."
+(ert-deftest test-cj-window-geometry--replay-size-below-returns-total-lines ()
+ "Normal: below window with direction='below -> total-height in lines.
+The vertical axis captures total-height (not body-height) so the capture/
+replay round-trip is immune to the mode line's pixel height."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below)))
- (should (= (cj/window-body-size below 'below)
- (window-body-height below))))))
+ (should (= (cj/window-replay-size below 'below)
+ (window-total-height below))))))
-(ert-deftest test-cj-window-geometry--body-size-narrow-window ()
+(ert-deftest test-cj-window-geometry--replay-size-narrow-window ()
"Normal: deliberately narrow right window -> matching body cols."
(save-window-excursion
(delete-other-windows)
(let* ((frame-w (frame-width))
(target-cols (/ frame-w 4))
(right (split-window (selected-window) (- target-cols) 'right)))
- (should (= (cj/window-body-size right 'right)
+ (should (= (cj/window-replay-size right 'right)
(window-body-width right))))))
(ert-deftest test-cj-window-geometry--cardinal-to-edge-right ()
diff --git a/tests/test-cj-window-toggle-lib.el b/tests/test-cj-window-toggle-lib.el
index 0762e255c..5edd06e96 100644
--- a/tests/test-cj-window-toggle-lib.el
+++ b/tests/test-cj-window-toggle-lib.el
@@ -36,7 +36,9 @@
(window-body-width right))))))
(ert-deftest test-cj-window-toggle-capture-records-below-split ()
- "Normal: below-split window writes direction=below and integer body-lines."
+ "Normal: below-split window writes direction=below and integer total-lines.
+The vertical axis captures total-height, not body-height, so the round-trip
+is immune to the mode line's pixel height (see `cj/window-replay-size')."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below))
@@ -49,7 +51,7 @@
(should (eq test-cj-window-toggle--last-direction 'below))
(should (integerp test-cj-window-toggle--last-size))
(should (= test-cj-window-toggle--last-size
- (window-body-height below))))))
+ (window-total-height below))))))
(ert-deftest test-cj-window-toggle-capture-falls-back-to-default-direction ()
"Boundary: window filling the frame uses the supplied default direction."
@@ -156,7 +158,9 @@ transfer; clearing it lets the consumer's default size apply."
(should (eq (cdr (assq 'inhibit-same-window received-alist)) t))))
(ert-deftest test-cj-window-toggle-display-saved-maps-below-to-bottom ()
- "Normal: saved below + integer size -> bottom edge, body-lines cons."
+ "Normal: saved below + integer size -> bottom edge, plain total-line count.
+The height axis replays a total-line integer (not a body-lines cons) so the
+round-trip is immune to the mode line's pixel height."
(let (received-alist
(test-cj-window-toggle--last-direction 'below)
(test-cj-window-toggle--last-size 12))
@@ -169,8 +173,7 @@ transfer; clearing it lets the consumer's default size apply."
'test-cj-window-toggle--last-size
0.7))
(should (eq (cdr (assq 'direction received-alist)) 'bottom))
- (should (equal (cdr (assq 'window-height received-alist))
- '(body-lines . 12)))
+ (should (equal (cdr (assq 'window-height received-alist)) 12))
(should-not (assq 'window-width received-alist))))
(ert-deftest test-cj-window-toggle-display-saved-maps-right-to-rightmost ()
diff --git a/tests/test-dirvish-config-playlist.el b/tests/test-dirvish-config-playlist.el
index d059a899a..14bb94ac7 100644
--- a/tests/test-dirvish-config-playlist.el
+++ b/tests/test-dirvish-config-playlist.el
@@ -10,6 +10,7 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
(require 'package)
(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
@@ -93,5 +94,59 @@ lowercase extension list."
(dolist (bad '("../evil" "../../etc/cron" "/etc/passwd" "sub/dir/name"))
(should-not (cj/--playlist-name-safe-p bad))))
+;;; cj/--playlist-resolve-target
+;;
+;; Drives the real `file-exists-p' against a temp `music-dir' (mocking a C
+;; primitive triggers a native-comp trampoline rebuild that fails under
+;; --batch); only the ordinary `read-string' / `read-char-choice' prompts are
+;; stubbed.
+
+(ert-deftest test-cj--playlist-resolve-target-returns-path-for-new-name ()
+ "Normal: a safe name with no existing file returns its .m3u path under music-dir."
+ (let* ((music-dir (make-temp-file "cj-playlist-" t)))
+ (unwind-protect
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "roadtrip")))
+ (should (equal (expand-file-name "roadtrip.m3u" music-dir)
+ (cj/--playlist-resolve-target))))
+ (delete-directory music-dir t))))
+
+(ert-deftest test-cj--playlist-resolve-target-reprompts-on-unsafe-name ()
+ "Boundary: an unsafe name (with `/') re-prompts until a safe name is given."
+ (let* ((music-dir (make-temp-file "cj-playlist-" t))
+ (answers '("../escape" "safe"))
+ (asked 0))
+ (unwind-protect
+ (cl-letf (((symbol-function 'read-string)
+ (lambda (&rest _) (prog1 (nth asked answers) (cl-incf asked))))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (should (equal (expand-file-name "safe.m3u" music-dir)
+ (cj/--playlist-resolve-target)))
+ (should (= 2 asked)))
+ (delete-directory music-dir t))))
+
+(ert-deftest test-cj--playlist-resolve-target-overwrite-returns-existing-path ()
+ "Normal: when the target exists, choosing overwrite returns the same path."
+ (let* ((music-dir (make-temp-file "cj-playlist-" t))
+ (existing (expand-file-name "mix.m3u" music-dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file existing (insert "old\n"))
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "mix"))
+ ((symbol-function 'read-char-choice) (lambda (&rest _) ?o)))
+ (should (equal existing (cj/--playlist-resolve-target)))))
+ (delete-directory music-dir t))))
+
+(ert-deftest test-cj--playlist-resolve-target-cancel-signals-user-error ()
+ "Error: when the target exists, choosing cancel aborts with a `user-error'."
+ (let* ((music-dir (make-temp-file "cj-playlist-" t))
+ (existing (expand-file-name "mix.m3u" music-dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file existing (insert "old\n"))
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "mix"))
+ ((symbol-function 'read-char-choice) (lambda (&rest _) ?c)))
+ (should-error (cj/--playlist-resolve-target) :type 'user-error)))
+ (delete-directory music-dir t))))
+
(provide 'test-dirvish-config-playlist)
;;; test-dirvish-config-playlist.el ends here
diff --git a/tests/test-dwim-shell-config-command-fixes.el b/tests/test-dwim-shell-config-command-fixes.el
index 2f49a868f..2cc3ae72b 100644
--- a/tests/test-dwim-shell-config-command-fixes.el
+++ b/tests/test-dwim-shell-config-command-fixes.el
@@ -29,5 +29,60 @@ so the substitution can't sit dead inside single quotes."
(should (string-match-p "\\.[0-9]\\{8\\}_[0-9]\\{6\\}\\.bak'" cmd))
(should-not (string-match-p "\\$(date" cmd))))
+;;; ----------------------- tar-gzip command builder --------------------------
+
+(ert-deftest test-dwim-tar-gzip-command-single-names-after-file ()
+ "Normal: a single marked file names the archive <fne>.tar.gz over <<f>>."
+ (let ((cmd (cj/dwim-shell--tar-gzip-command t)))
+ (should (string-match-p "'<<fne>>\\.tar\\.gz'" cmd))
+ (should (string-match-p "'<<f>>'" cmd))))
+
+(ert-deftest test-dwim-tar-gzip-command-multi-uses-shared-archive ()
+ "Boundary: multiple files tar into a shared archive.tar.gz over <<*>>."
+ (let ((cmd (cj/dwim-shell--tar-gzip-command nil)))
+ (should (string-match-p "archive\\.tar\\.gz" cmd))
+ (should (string-match-p "'<<\\*>>'" cmd))))
+
+;;; --------------------- text-to-speech command builder ----------------------
+
+(ert-deftest test-dwim-text-to-speech-command-darwin-uses-say-voice ()
+ "Normal: on darwin the command uses `say' with the chosen voice."
+ (let ((cmd (cj/dwim-shell--text-to-speech-command 'darwin "Samantha")))
+ (should (string-match-p "\\`say -v Samantha " cmd))
+ (should (string-match-p "'<<fne>>\\.aiff'" cmd))))
+
+(ert-deftest test-dwim-text-to-speech-command-linux-uses-espeak ()
+ "Boundary: a non-darwin system uses `espeak' and ignores the voice."
+ (let ((cmd (cj/dwim-shell--text-to-speech-command 'gnu/linux "ignored")))
+ (should (string-match-p "\\`espeak " cmd))
+ (should (string-match-p "'<<fne>>\\.wav'" cmd))
+ (should-not (string-match-p "ignored" cmd))))
+
+;;; ----------------------- video-trim command builder ------------------------
+
+(ert-deftest test-dwim-video-trim-command-beginning-uses-ss ()
+ "Normal: trimming the beginning emits a leading -ss with the start seconds."
+ (let ((cmd (cj/dwim-shell--video-trim-command "Beginning" 7 0)))
+ (should (string-match-p "-ss 7 " cmd))
+ (should-not (string-match-p "-sseof" cmd))))
+
+(ert-deftest test-dwim-video-trim-command-end-uses-sseof ()
+ "Normal: trimming the end emits -sseof with the end seconds, no -ss."
+ (let ((cmd (cj/dwim-shell--video-trim-command "End" 0 9)))
+ (should (string-match-p "-sseof -9 " cmd))
+ (should-not (string-match-p "-ss [0-9]" cmd))))
+
+(ert-deftest test-dwim-video-trim-command-both-uses-ss-and-sseof ()
+ "Normal: trimming both ends emits both -ss start and -sseof end."
+ (let ((cmd (cj/dwim-shell--video-trim-command "Both" 3 4)))
+ (should (string-match-p "-ss 3 " cmd))
+ (should (string-match-p "-sseof -4 " cmd))))
+
+(ert-deftest test-dwim-video-trim-command-negative-seconds-errors ()
+ "Error: a negative second count for the used side signals a user-error."
+ (should-error (cj/dwim-shell--video-trim-command "Beginning" -1 0) :type 'user-error)
+ (should-error (cj/dwim-shell--video-trim-command "End" 0 -1) :type 'user-error)
+ (should-error (cj/dwim-shell--video-trim-command "Both" 0 -2) :type 'user-error))
+
(provide 'test-dwim-shell-config-command-fixes)
;;; test-dwim-shell-config-command-fixes.el ends here
diff --git a/tests/test-term-toggle--display.el b/tests/test-term-toggle--display.el
index 7fa7f0a98..d6dd33da2 100644
--- a/tests/test-term-toggle--display.el
+++ b/tests/test-term-toggle--display.el
@@ -17,7 +17,9 @@
(require 'term-config)
(ert-deftest test-term-toggle--capture-state-records-direction-and-size ()
- "Normal: capture-state writes direction and integer body size."
+ "Normal: capture-state writes direction and integer size.
+The vertical axis captures total-height (not body-height) so the toggle
+round-trip is immune to the mode line's pixel height."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below))
@@ -26,7 +28,7 @@
(cj/--term-toggle-capture-state below)
(should (eq cj/--term-toggle-last-direction 'below))
(should (integerp cj/--term-toggle-last-size))
- (should (= cj/--term-toggle-last-size (window-body-height below))))))
+ (should (= cj/--term-toggle-last-size (window-total-height below))))))
(ert-deftest test-term-toggle--capture-state-noop-on-dead-window ()
"Boundary: nil window -> state remains unchanged."
@@ -50,7 +52,9 @@
(should (eq (cdr (assq 'inhibit-same-window received-alist)) t))))
(ert-deftest test-term-toggle--display-saved-maps-cardinal-to-edge ()
- "Normal: saved 'below maps to bottom edge; integer size wraps in body-lines."
+ "Normal: saved 'below maps to bottom edge; integer size is a plain total-line count.
+The height axis replays a total-line integer (not a body-lines cons) so the
+round-trip is immune to the mode line's pixel height."
(let (received-alist
(cj/--term-toggle-last-direction 'below)
(cj/--term-toggle-last-size 12))
@@ -58,8 +62,7 @@
(lambda (_b a) (setq received-alist a) 'fake-window)))
(cj/--term-toggle-display-saved 'fake-buf nil))
(should (eq (cdr (assq 'direction received-alist)) 'bottom))
- (should (equal (cdr (assq 'window-height received-alist))
- '(body-lines . 12)))
+ (should (equal (cdr (assq 'window-height received-alist)) 12))
(should-not (assq 'window-width received-alist))))
(ert-deftest test-term-toggle--display-saved-strips-conflicting-alist-entries ()
diff --git a/themes/WIP-theme.el b/themes/WIP-theme.el
index eed4d44f6..0a6b31efe 100644
--- a/themes/WIP-theme.el
+++ b/themes/WIP-theme.el
@@ -771,10 +771,10 @@
'(nerd-icons-red-alt ((t (:foreground "#843031"))))
'(nerd-icons-silver ((t (:foreground "#716e68"))))
'(nerd-icons-yellow ((t (:foreground "#ffcc0e"))))
- '(orderless-match-face-0 ((t (:foreground "#223fbf" :weight bold))))
- '(orderless-match-face-1 ((t (:foreground "#8f0075" :weight bold))))
- '(orderless-match-face-2 ((t (:foreground "#145a00" :weight bold))))
- '(orderless-match-face-3 ((t (:foreground "#804000" :weight bold))))
+ '(orderless-match-face-0 ((t (:foreground "#cbd0d6" :weight bold :slant italic))))
+ '(orderless-match-face-1 ((t (:foreground "#c99990" :weight bold :slant italic))))
+ '(orderless-match-face-2 ((t (:foreground "#c5d4ae" :weight bold :slant italic))))
+ '(orderless-match-face-3 ((t (:foreground "#bea9dc" :weight bold :slant italic))))
'(org-superstar-first ((t (:inherit org-warning))))
'(org-superstar-item ((t (:inherit default))))
'(org-superstar-leading ((t (:inherit default :foreground "#bebebe"))))
diff --git a/todo.org b/todo.org
index 5401fa8d4..395dd4c0f 100644
--- a/todo.org
+++ b/todo.org
@@ -55,6 +55,107 @@ Tags are additive. For example, a small wrong-behavior fix can be
=:bug:quick:=, and a feature that requires internal restructuring can be
=:feature:refactor:=.
* Emacs Open Work
+** DONE [#B] F9 toggle collapses a 3-window layout to 2 :bug:
+CLOSED: [2026-06-20 Sat]
+Fixed 2026-06-20 (option 1 — reversible toggle, Craig's call). In a 3+ window layout where
+the agent had its own split, toggle-on reused the working window at the bottom edge,
+displacing its buffer and collapsing three windows to two. Added a flag
+(=cj/--ai-term-last-toggle-deleted-split=) set when toggle-off delete-windows the agent's own
+window; =cj/--ai-term-reuse-edge-window= consumes it and falls through to a fresh re-split, so
+the agent returns to its own window and the others are untouched. The flag only changes the 3+
+window case (2-window slot-reuse unchanged). TDD regression
+=test-ai-term--reuse-edge-window-3win-toggle-restores-own-window=; full =make test= green;
+live-reloaded. Commit 64916462. GUI sign-off is a VERIFY under Manual testing and validation.
+
+** TODO [#B] Codebase refactoring program — remaining batch :refactor:solo:
+Resumes the full-codebase refactoring scan run of 2026-06-20 (8-agent fan-out over
+modules/ + scripts/theme-studio/). The goal: apply every scan finding except the
+won't-do items, one focused refactor per commit. 25 done and pushed across the
+2026-06-20 sessions (see =.ai/sessions/= for the logs); 8 remain, listed below.
+The 5 medium extractions are done (calibredb-epub nov helpers fccf29b0, ai-term
+toggle-off 62fee96b, calendar-sync exception parser 23f405b4, dirvish playlist-target
+a1ca2fb0, custom-case title-case-word 4cc9ca0b); the 2 big single-file and 6
+theme-studio items below remain.
+
+*** Working protocol (apply to every item)
+- TDD: write/keep a failing-then-green test; harvest new test seams the refactor opens.
+- Behavior-preserving only. If a "dedup" would delete a real test seam or couple
+ dissimilar code, SKIP it and record why (see skips below).
+- Per refactor, verify in this order, then commit + push (no-approvals mode):
+ 1. =make test-file FILE=<basename.el>= for touched + new tests.
+ 2. =make validate-modules= (loads all 123 modules; catches load/paren errors).
+ 3. Init-launch smoke on a throwaway daemon: =emacs --daemon=cj-sNN=, then
+ =emacsclient -s cj-sNN -e '(emacs-pid)'= to capture the PID, check
+ =(length features)= = 807 and no init errors in the log, then kill by that
+ PID (the emacsclient kill-emacs is flaky; pkill -f 'daemon=cj-sNN'
+ self-matches its own shell — kill the captured PID).
+ 4. Live-reload the edited module into Craig's running daemon
+ (=emacsclient -e '(load "/home/cjennings/.emacs.d/modules/<m>.el")'=); skip
+ the live reload for big use-package modules whose :config restacks (verify via
+ the fresh smoke daemon instead, as with mail-config).
+- Tab-heavy files: =sed -n 'A,Bp' FILE | cat -A= to get exact bytes before an Edit;
+ write NEW code in the documented 2-space style.
+- Shared asset already created: =cj/format-region-with-program= in system-lib.el
+ (the run-a-formatter-over-the-buffer helper). Reuse it for any further
+ format-region duplicates.
+
+*** DONE — medium extractions (2026-06-20 afternoon)
+All five shipped: calibredb-epub nov re-render/centering helpers (fccf29b0);
+ai-term toggle-off teardown + working-buffer swap (62fee96b); calendar-sync
+per-event exception parser (23f405b4); dirvish playlist-target resolution
+(a1ca2fb0); custom-case per-word title-case decision (4cc9ca0b).
+
+*** DONE — big single-file + theme-studio (2026-06-20 afternoon, no-approvals run)
+Both big single-file items shipped: dwim-shell branching command builders
+(f93b4615); custom-comments divider/box generator dedup (42f0c88a). Five of the
+six theme-studio items shipped: face_coverage path_kind (9a52370b),
+capture-default-faces condition_matches unify (28b4d1cf), dropdownRowTextColor
+delete (10a56789), test-file inline-integrity dedup — subTest loop + shared
+inline-strip.mjs (13969c70), generate.py lazy _build()/__getattr__ (6df4ebdc),
+browser-gates assertPreviewFaces for the 3 preview gates (5627f137).
+
+*** DONE — browser-gates harness rewrite (with Craig's go-ahead, 2026-06-20)
+- =gate(id, body)= helper (05697e83): the 38 standard gates' ok/notes/A + title +
+ result-div boilerplate, note format standardized to " fails=". Each call site keeps
+ its literal =location.hash==='#NAMEtest'=. 6 custom gates stay inline. First automated
+ attempt deleted gates (a closing-finder spanned boundaries) — caught by a gate-count
+ guard, reverted, redone anchored on each gate's unique =d.id=. Verified all 44 green +
+ a forced A(false) in a converted gate still FAILs.
+- =withSavedState(keys, body)= (a473aa7c): wraps the 7 restore-nothing gates, scoped to
+ the globals each mutates; JSON-clone snapshot + finally-restore (structuredClone threw
+ on the studio objects — caught by the gate run as "no verdict", switched to JSON like
+ the gates' own local saves). The 14 self-restoring gates left as-is. Verified 44 green,
+ restore round-trip holds, broken assertion in a wrapped gate still FAILs.
+
+*** Remaining — item-8 plan() factory (deferred, low value)
+The =plan(overrides)= factory for the ~30 planPaletteGenerator calls (test-app-core.mjs
++ test-palette-generator-core.mjs) was deferred. The calls pass heterogeneous options
+(scheme/accentCount/sourceMode/vibe/intent vary per call); a factory only dedups the
+constant spanCount:0/rng and would hide which options each test actually exercises —
+premature abstraction over varying calls. The other two item-8 parts (subTest loop +
+shared stripExports) shipped in 13969c70.
+
+*** WON'T-DO (do not re-attempt — assessed and rejected)
+- theme-studio buildTable/buildUITable/buildPkgTable merge: genuine per-tier divergence
+ (column order, syntax dual fg/bg dropdowns, ui preview cell, pkg nd markers) + the
+ =.cells[N]= positional sort coupling make a unified builder MORE complex than the
+ three explicit ones. Close as won't-do.
+- Cross-language test overlap (browser-gates preview gate vs test_generate.py
+ PackageFaceCoverage): don't merge — would couple a fast Python test to a headless
+ browser run. A one-line comment in each noting the split is the most that's worth it.
+
+*** Skipped this run (with reasons — don't redo)
+- eshell-config ssh-alias "merge the two helpers": =cj/--eshell-ssh-alias-commands= is
+ a deliberate pure/effectful split with 3 dedicated tests; merging deletes the seam.
+- prog-*-setup boilerplate: only python+webdev share the full pattern; shell/c/elisp/
+ common-lisp differ materially. A keyword-arg helper would be less readable. No
+ premature abstraction.
+- erc join-command =cj/erc--ensure-active-connection= extraction: nesting-only on
+ untestable UI (call-interactively/switch-to-buffer), no test seam, risky tab-rewrite.
+- coverage-core =simplecov-executable-lines= vs =parse-simplecov= clone: borderline
+ MEDIUM, differs only by a =(> hits 0)= predicate; parameterize with a keep-line-p
+ only if revisiting. Low priority.
+
** TODO [#B] Un-pin ghostel from 0.33.0 once upstream fixes #422/#423 :bug:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-20
@@ -347,6 +448,24 @@ What we're verifying: in dirvish, d now duplicates the file at point (delete-to-
- Answer no first (confirm nothing happens), then press D again and answer yes
- Note whether sudo prompts for a password and whether the file actually disappears
Expected: d duplicates; D names the exact targets and only deletes on yes; the files are gone with no trash copy. If sudo needs a password that shell-command can't supply, flag it — the delete may need to route through a tty instead.
+*** VERIFY F9 agent toggle no longer shrinks the window after a C-; b pull-away
+What we're verifying: the f9 split-shrink bug is fixed. The toggle now captures the agent window's total-height (not body-height), so the capture/replay round-trip is immune to the mode line's pixel height — the agent should keep the same height across repeated toggles even with the WIP theme's near-zero =mode-line-inactive= (=:height 2=). The batch harness can't reproduce this (a TTY mode line is always a full line), so this needs a real GUI frame.
+- Open the agent (F9) and maximize it so it fills the frame (=C-x 1= inside it)
+- Pull it down with =C-; b <down>= (the reveal slivers in above; the agent becomes the bottom window) and press =<escape>= to end the sticky resize
+- Note the agent window's height (eyeball it, or =M-:= the form below)
+#+begin_src emacs-lisp
+(window-total-height (get-buffer-window (current-buffer)))
+#+end_src
+- Press F9 to toggle the agent off, then F9 again to toggle it back on. Repeat 5–6 times.
+- Re-check the height after each on-toggle
+Expected: the agent window stays the same height every cycle (no one-line-per-toggle shrink). Before the fix it lost ~1 line each toggle. If it still shrinks, capture the height sequence and reopen this as a TODO — the remaining drift would point at a different rounding source than the mode-line pixel height.
+*** VERIFY F9 toggle preserves all windows in a 3-window layout
+What we're verifying: toggling the agent off then on in a 3-window layout no longer eats a working window. The fix re-splits the agent into its own window on toggle-on instead of reusing the working window at the edge, so the layout is preserved across the toggle (off then on returns the same three windows). Batch tests already assert the window count; this is the visual read in a real frame.
+- Set up three windows: a code/file window on top, a second working window (e.g. todo.org) in the middle, and the agent (F9) as a full-width strip at the bottom
+- Note the three windows and which buffers they show
+- Press F9 to toggle the agent off (the bottom strip closes, two windows remain)
+- Press F9 again to toggle it back on
+Expected: you are back to three windows — the agent returns as its own bottom strip, and both working windows (code + middle) are still showing their buffers. Before the fix, toggle-on replaced the middle/bottom working window with the agent, leaving two windows.
** PROJECT [#A] Theme-Studio Open Work
Parent grouping the open theme-studio / theming issues; close each child independently.