aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.claude/rules/todo-format.md54
-rw-r--r--modules/ai-term.el67
-rw-r--r--modules/system-utils.el2
-rw-r--r--modules/term-config.el11
-rw-r--r--scripts/theme-studio/browser-gates.js225
-rw-r--r--scripts/theme-studio/theme-studio.html225
-rw-r--r--tests/test-ai-term--f9-in-term.el18
-rw-r--r--tests/test-ai-term--next-agent-buffer.el73
-rw-r--r--todo.org132
9 files changed, 475 insertions, 332 deletions
diff --git a/.claude/rules/todo-format.md b/.claude/rules/todo-format.md
index b1fb57b8f..b9e93bb5a 100644
--- a/.claude/rules/todo-format.md
+++ b/.claude/rules/todo-format.md
@@ -130,7 +130,7 @@ becomes
The agenda view (`org-agenda`) shows entries at the section + top-task level. Letting `**` tasks stay task-shaped preserves their visibility as "things that recently shipped." Letting `***+` sub-tasks flip to dated entries keeps the agenda from being clogged with a long list of completed sub-tasks at every depth — those become history within their parent instead.
-`VERIFY` is the documented exception: it follows the dated-rewrite rule at **all** depths (including `**`), because a resolved VERIFY is an answered question rather than a finished task. See the VERIFY section below.
+`VERIFY` follows the dated-rewrite rule at `***` and deeper, the same as any sub-task. At `**` it does *not*: a top-level VERIFY completes task-shaped — a `DONE`/`CANCELLED` keyword plus a `CLOSED:` line, exactly like a top-level `TODO`. Dated headers never appear at `**`. Level 2 always carries a terminal keyword; dated headers are a `***`-and-deeper shape only. See the VERIFY section below.
## VERIFY tasks
@@ -191,19 +191,31 @@ The sibling rule is the active force that keeps `todo.org` flat. Without
it, VERIFYs accumulate one level deeper than their trigger every time —
turning a clean parent tree into a long pole of nested sub-headings.
-### Completion — dated rewrite + content replacement
+### Completion — depth decides the heading shape
-When a VERIFY resolves, **rewrite the heading and body together** at the
-same depth — regardless of whether the VERIFY is at `**` or `***`:
+When a VERIFY resolves, **rewrite the heading and body together**. The body
+replacement is the same at every depth (step 2 below); the heading shape
+depends on the VERIFY's level, mirroring the depth-based rule for ordinary
+tasks — dated entries at `***` and deeper, terminal keyword at `**`.
-1. **Replace the heading.** Drop the `VERIFY` keyword (and any priority
- cookie / tags) and replace with a timestamp + short description:
+1. **Replace the heading — by depth.**
- *** 2026-05-15 Fri @ 14:00:00 -0500 <what was answered or done>
+ - **At `***` and deeper — dated event-log entry.** Drop the `VERIFY`
+ keyword (and any priority cookie / tags) and replace with a timestamp +
+ short description:
- Generate the timestamp with `date "+%Y-%m-%d %a @ %H:%M:%S %z"`.
- Match the original depth (a `**` VERIFY becomes `** YYYY-MM-DD ...`;
- a `***` VERIFY becomes `*** YYYY-MM-DD ...`).
+ *** 2026-05-15 Fri @ 14:00:00 -0500 <what was answered or done>
+
+ Generate the timestamp with `date "+%Y-%m-%d %a @ %H:%M:%S %z"`.
+
+ - **At `**` — terminal keyword, like any top-level task.** Change
+ `VERIFY` to `DONE` (answered / check passed) or `CANCELLED` (abandoned),
+ keep the heading text, priority cookie, and tags, and add a
+ `CLOSED: [YYYY-MM-DD Day]` line. Never a dated heading — a `**` dated
+ header is a defect; repair it to `DONE`/`CANCELLED` + `CLOSED:`.
+
+ ** DONE [#B] <original VERIFY topic> :tags:
+ CLOSED: [2026-05-15 Fri]
2. **Replace the body.** Drop the original question/instruction prose and
replace with either:
@@ -213,16 +225,18 @@ same depth — regardless of whether the VERIFY is at `**` or `***`:
instruction or pending-decision marker — what was done, when, where
the artifact lives).
-The completed VERIFY becomes an in-place event log entry. The original
-question is preserved by the dated heading + body shape; anyone scanning
-the agenda or `git log` can see what was asked and what landed.
-
-**Note on the top-level case.** Regular `**` DONE tasks stay task-shaped
-with a `DONE` keyword + `CLOSED:` line per *Completion — depth-based*
-above. VERIFYs at `**` are the exception — they convert to dated log
-entries on completion because a resolved VERIFY isn't a "done task," it's
-an answered question. The dated-rewrite rule wins for VERIFYs at all
-depths.
+Either way the completed VERIFY records what was asked and what landed: at
+`***` and deeper as a dated event-log entry, at `**` as a `DONE`/`CANCELLED`
+task whose body holds the answer. Anyone scanning the agenda or `git log`
+can see both.
+
+**Note on the top-level case.** A `**` VERIFY completes exactly like a `**`
+`TODO`: a `DONE`/`CANCELLED` keyword + `CLOSED:` line, with the answer or
+action in the body. The earlier habit of dating a resolved top-level VERIFY
+— treating "answered question, not a finished task" as license for a `**`
+dated header — is retired. It put dated headers at level 2, where the agenda
+truncates them out of a clean keyword scan. Dated rewrite is for `***` and
+deeper only; `**` always carries a terminal keyword.
### Don't leave stale placeholders
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 25e56c508..ff8da0035 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -52,15 +52,19 @@
;; picker, even when an agent buffer is currently displayed.
;; Used when the user wants to start a new project session
;; instead of toggling the current one.
+;; - s-F9 `cj/ai-term-next' -- step to the next open agent in the
+;; queue. The queue is the live agent buffers in buffer-name
+;; order (a stable rotation). When an agent window is on
+;; screen, swap it to the next agent and focus it, wrapping
+;; after the last; when none is shown but agents exist, show
+;; the first. This is the "switch among existing agents"
+;; surface F9 deliberately doesn't provide.
;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its
;; tmux session (stopping the agent process), then its terminal
;; buffer. Its window stays in the layout (swapped to the
;; working buffer), so closing never collapses a split. Confirms
;; first. Targets the current agent, the sole live agent, or
;; prompts among several.
-;; - C-S-F9 `cj/ai-term-close' -- same close command, second binding.
-;; (M-F9 is the primary; C-S-F9 may be swallowed by the
-;; Wayland/PGTK layer on some machines.)
;;
;; Existing windmove (Shift-arrows) handles code <-> agent focus
;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither
@@ -181,6 +185,21 @@ recently-selected first. Non-AI-term buffers are filtered out via
`cj/--ai-term-buffer-p'."
(seq-filter #'cj/--ai-term-buffer-p (buffer-list)))
+(defun cj/--ai-term-next-agent-buffer (current buffers)
+ "Return the agent buffer after CURRENT in BUFFERS, wrapping to the first.
+
+BUFFERS is an ordered list of live agent buffers. When CURRENT is the
+last element, wrap to the first. When CURRENT is nil or not a member of
+BUFFERS, return the first buffer. Returns nil when BUFFERS is empty.
+
+Pure decision helper (no buffer or window side effects) so the cycle
+order driving `cj/ai-term-next' (s-F9) is exercisable in tests."
+ (when buffers
+ (if (memq current buffers)
+ (or (cadr (memq current buffers))
+ (car buffers))
+ (car buffers))))
+
(defun cj/--ai-term-most-recent-non-agent-buffer ()
"Return the most-recently-selected live non-agent buffer, or nil.
@@ -882,7 +901,7 @@ With prefix ARG, display the buffer without selecting its window
when a buffer is being shown (no effect on the toggle-off branch).
See `cj/ai-term-pick-project' (C-F9) to force the project picker.
-M-F9 (and C-S-F9) close an agent via `cj/ai-term-close'."
+M-F9 closes an agent via `cj/ai-term-close'."
(interactive "P")
(pcase (cj/--ai-term-dispatch)
(`(toggle-off . ,win)
@@ -952,7 +971,7 @@ buffers; nil when none are alive."
Targets the current agent buffer, the sole live agent, or prompts when
several are alive (see `cj/--ai-term-close-target'). Asks for
confirmation first -- this kills the running agent process, which can
-interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
+interrupt work in progress. Bound to M-<f9>."
(interactive)
(let ((buffer (cj/--ai-term-close-target)))
(unless buffer
@@ -963,10 +982,42 @@ interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(cj/--ai-term-close-buffer buffer)
(message "Closed agent %s." name)))))
+;; ------------------------- Step to the next agent ----------------------------
+
+(defun cj/ai-term-next ()
+ "Step to the next open AI-term agent in the queue.
+
+The queue is the live agent buffers ordered by buffer name -- a stable
+rotation, unaffected by which agent was most recently selected. When an
+agent window is on screen, swap it to the next agent in the queue
+\(wrapping after the last) and select it. When no agent is displayed but
+agents exist, show the first. Signals `user-error' when none are open.
+
+Bound to s-<f9>. Unlike <f9> (toggle the most-recent agent on/off), this
+is the \"switch among existing agents\" surface; C-<f9> opens the project
+picker and M-<f9> closes an agent."
+ (interactive)
+ (let* ((buffers (sort (cj/--ai-term-agent-buffers)
+ (lambda (a b)
+ (string< (buffer-name a) (buffer-name b)))))
+ (win (cj/--ai-term-displayed-agent-window))
+ (current (and win (window-buffer win)))
+ (next (cj/--ai-term-next-agent-buffer current buffers)))
+ (unless next
+ (user-error "No AI-term agent buffers open"))
+ (if win
+ (progn
+ (set-window-buffer win next)
+ (select-window win))
+ (display-buffer next)
+ (let ((w (get-buffer-window next)))
+ (when w (select-window w))))
+ (message "Agent: %s" (buffer-name next))))
+
(keymap-global-set "<f9>" #'cj/ai-term)
(keymap-global-set "C-<f9>" #'cj/ai-term-pick-project)
+(keymap-global-set "s-<f9>" #'cj/ai-term-next)
(keymap-global-set "M-<f9>" #'cj/ai-term-close)
-(keymap-global-set "C-S-<f9>" #'cj/ai-term-close)
;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to
;; the terminal program, so a plain <f9> typed while point is inside an agent
@@ -977,15 +1028,15 @@ interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(with-eval-after-load 'ghostel
(keymap-set ghostel-mode-map "<f9>" #'cj/ai-term)
(keymap-set ghostel-mode-map "C-<f9>" #'cj/ai-term-pick-project)
+ (keymap-set ghostel-mode-map "s-<f9>" #'cj/ai-term-next)
(keymap-set ghostel-mode-map "M-<f9>" #'cj/ai-term-close)
- (keymap-set ghostel-mode-map "C-S-<f9>" #'cj/ai-term-close)
;; The bindings above live in `ghostel-mode-map', but in semi-char mode
;; ghostel's own `ghostel-semi-char-mode-map' forwards every key not in
;; `ghostel-keymap-exceptions' to the pty -- and that map outranks the
;; major-mode map, so it would swallow the F9 family before the bindings
;; above fire. Add the family to the exceptions and rebuild the semi-char
;; map so the keys fall through to `ghostel-mode-map' inside agent buffers.
- (dolist (key '("<f9>" "C-<f9>" "M-<f9>" "C-S-<f9>"))
+ (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>"))
(add-to-list 'ghostel-keymap-exceptions key))
(ghostel--rebuild-semi-char-keymap))
diff --git a/modules/system-utils.el b/modules/system-utils.el
index 7cf958674..254a2f502 100644
--- a/modules/system-utils.el
+++ b/modules/system-utils.el
@@ -102,7 +102,7 @@ detached from Emacs."
(interactive)
(save-some-buffers)
(kill-emacs))
-(keymap-global-set "C-<f10>" #'cj/server-shutdown)
+(keymap-global-set "C-x C" #'cj/server-shutdown)
;;; ---------------------------- History Persistence ----------------------------
diff --git a/modules/term-config.el b/modules/term-config.el
index 0a7991409..c1c28911d 100644
--- a/modules/term-config.el
+++ b/modules/term-config.el
@@ -246,12 +246,13 @@ run its own project-named tmux session instead of a bare, auto-named one.
;; rebuild is what actually lets the key through to `ghostel-mode-map' / the
;; global map. C-; and F12 are the prefix + toggle; the modified arrows are
;; windmove (S-arrows, focus) and buffer-move (C-M-arrows, swap), which the
- ;; ai-term workflow expects to work from inside an agent buffer. F8, F10 and
- ;; C-F10 are global bindings (org agenda, music-playlist toggle, server
- ;; shutdown) that reach Emacs by falling through to the global map once the
- ;; semi-char map stops forwarding them.
+ ;; ai-term workflow expects to work from inside an agent buffer. F8 and F10
+ ;; are global bindings (org agenda, music-playlist toggle) that reach Emacs by
+ ;; falling through to the global map once the semi-char map stops forwarding
+ ;; them. (Server shutdown moved off C-F10 to C-x C, which is deliberately
+ ;; left forwarding to the terminal program inside an agent buffer.)
(with-eval-after-load 'ghostel
- (dolist (key '("C-;" "<f8>" "<f12>" "<f10>" "C-<f10>"
+ (dolist (key '("C-;" "<f8>" "<f12>" "<f10>"
"S-<up>" "S-<down>" "S-<left>" "S-<right>"
"C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>"))
(add-to-list 'ghostel-keymap-exceptions key))
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index f4556ba5b..503d7ea11 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -1,3 +1,38 @@
+// 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
@@ -38,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];
@@ -108,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);
@@ -124,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');
@@ -179,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');
@@ -235,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();
@@ -254,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();}}
@@ -306,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');
@@ -371,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();
@@ -402,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);
@@ -433,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'));
@@ -452,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');
@@ -468,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');
@@ -485,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();
@@ -573,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');
@@ -625,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']];
@@ -661,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;};
@@ -683,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){
@@ -709,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();
@@ -731,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){
@@ -760,39 +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']){
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);}};
+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']);
- 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);}
+ });
// 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');
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']);
- 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);}
+ });
// 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);
@@ -802,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];
@@ -823,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');
@@ -847,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"]');
@@ -891,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');
@@ -912,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);
@@ -930,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));
@@ -944,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')]:[];
@@ -956,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')];
@@ -979,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+'"]');
@@ -998,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');
@@ -1028,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']];
@@ -1049,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']];
@@ -1066,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']];
@@ -1082,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));
@@ -1099,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/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 76cc80a51..4896a2387 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -3053,6 +3053,41 @@ 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
@@ -3093,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];
@@ -3163,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);
@@ -3179,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');
@@ -3234,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');
@@ -3290,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();
@@ -3309,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();}}
@@ -3361,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');
@@ -3426,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();
@@ -3457,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);
@@ -3488,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'));
@@ -3507,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');
@@ -3523,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');
@@ -3540,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();
@@ -3628,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');
@@ -3680,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']];
@@ -3716,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;};
@@ -3738,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){
@@ -3764,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();
@@ -3786,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){
@@ -3815,39 +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']){
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);}};
+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']);
- 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);}
+ });
// 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');
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']);
- 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);}
+ });
// 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);
@@ -3857,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];
@@ -3878,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');
@@ -3902,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"]');
@@ -3946,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');
@@ -3967,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);
@@ -3985,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));
@@ -3999,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')]:[];
@@ -4011,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')];
@@ -4034,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+'"]');
@@ -4053,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');
@@ -4083,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']];
@@ -4104,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']];
@@ -4121,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']];
@@ -4137,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));
@@ -4154,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--f9-in-term.el b/tests/test-ai-term--f9-in-term.el
index dad11ffc0..0477f2517 100644
--- a/tests/test-ai-term--f9-in-term.el
+++ b/tests/test-ai-term--f9-in-term.el
@@ -26,27 +26,29 @@
(should (eq (keymap-lookup ghostel-mode-map "<f9>") #'cj/ai-term)))
(ert-deftest test-ai-term-f9-family-bound-in-ghostel-mode-map ()
- "Normal: the C-/M-/C-S- F9 variants are bound in `ghostel-mode-map' too.
-`M-<f9>' and `C-S-<f9>' both close an agent via `cj/ai-term-close'."
+ "Normal: the C-/s-/M- F9 variants are bound in `ghostel-mode-map' too.
+`s-<f9>' steps to the next agent; `M-<f9>' closes an agent via
+`cj/ai-term-close'."
(should (eq (keymap-lookup ghostel-mode-map "C-<f9>") #'cj/ai-term-pick-project))
- (should (eq (keymap-lookup ghostel-mode-map "M-<f9>") #'cj/ai-term-close))
- (should (eq (keymap-lookup ghostel-mode-map "C-S-<f9>") #'cj/ai-term-close)))
+ (should (eq (keymap-lookup ghostel-mode-map "s-<f9>") #'cj/ai-term-next))
+ (should (eq (keymap-lookup ghostel-mode-map "M-<f9>") #'cj/ai-term-close)))
(ert-deftest test-ai-term-f9-still-bound-globally ()
"Normal: the global F9 family bindings are intact.
`<f9>' toggles the ai-term agent window; `C-<f9>' picks a project
-agent; `M-<f9>' and `C-S-<f9>' close an agent via `cj/ai-term-close'."
+agent; `s-<f9>' steps to the next agent; `M-<f9>' closes an agent
+via `cj/ai-term-close'."
(should (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term))
(should (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project))
- (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close))
- (should (eq (lookup-key (current-global-map) (kbd "C-S-<f9>")) #'cj/ai-term-close)))
+ (should (eq (lookup-key (current-global-map) (kbd "s-<f9>")) #'cj/ai-term-next))
+ (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close)))
(ert-deftest test-ai-term-f9-family-in-keymap-exceptions ()
"Regression: the F9 family is in `ghostel-keymap-exceptions' so semi-char
mode lets it reach Emacs instead of forwarding it to the terminal program.
Binding in `ghostel-mode-map' alone is not enough -- the semi-char map outranks
it and forwards any key not in the exceptions to the pty."
- (dolist (key '("<f9>" "C-<f9>" "M-<f9>" "C-S-<f9>"))
+ (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>"))
(should (member key ghostel-keymap-exceptions)))
;; The rebuilt semi-char map must no longer forward <f9> to the pty.
(should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f9>")
diff --git a/tests/test-ai-term--next-agent-buffer.el b/tests/test-ai-term--next-agent-buffer.el
new file mode 100644
index 000000000..330714a92
--- /dev/null
+++ b/tests/test-ai-term--next-agent-buffer.el
@@ -0,0 +1,73 @@
+;;; test-ai-term--next-agent-buffer.el --- Tests for cj/--ai-term-next-agent-buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The pure decision helper behind `cj/ai-term-next' (s-F9). Given the
+;; current agent buffer and the ordered list of live agent buffers, it
+;; returns the next buffer in the queue, wrapping after the last. A nil
+;; or non-member CURRENT returns the first; an empty list returns nil.
+;; No buffer or window side effects -- list logic only.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(ert-deftest test-ai-term--next-agent-buffer-advances-from-first ()
+ "Normal: current is the first element -> returns the second."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]"))
+ (c (get-buffer-create "agent [c]")))
+ (unwind-protect
+ (should (eq b (cj/--ai-term-next-agent-buffer a (list a b c))))
+ (mapc #'kill-buffer (list a b c)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-advances-from-middle ()
+ "Normal: current in the middle -> returns the following element."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]"))
+ (c (get-buffer-create "agent [c]")))
+ (unwind-protect
+ (should (eq c (cj/--ai-term-next-agent-buffer b (list a b c))))
+ (mapc #'kill-buffer (list a b c)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-wraps-after-last ()
+ "Boundary: current is the last element -> wraps to the first."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]"))
+ (c (get-buffer-create "agent [c]")))
+ (unwind-protect
+ (should (eq a (cj/--ai-term-next-agent-buffer c (list a b c))))
+ (mapc #'kill-buffer (list a b c)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-single-element-returns-itself ()
+ "Boundary: a one-agent queue wraps current back to itself."
+ (let ((a (get-buffer-create "agent [a]")))
+ (unwind-protect
+ (should (eq a (cj/--ai-term-next-agent-buffer a (list a))))
+ (kill-buffer a))))
+
+(ert-deftest test-ai-term--next-agent-buffer-nil-current-returns-first ()
+ "Boundary: nil current (no agent displayed) -> returns the first."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]")))
+ (unwind-protect
+ (should (eq a (cj/--ai-term-next-agent-buffer nil (list a b))))
+ (mapc #'kill-buffer (list a b)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-non-member-current-returns-first ()
+ "Error: current not in the queue -> returns the first rather than nil."
+ (let ((a (get-buffer-create "agent [a]"))
+ (b (get-buffer-create "agent [b]"))
+ (stray (get-buffer-create "agent [stray]")))
+ (unwind-protect
+ (should (eq a (cj/--ai-term-next-agent-buffer stray (list a b))))
+ (mapc #'kill-buffer (list a b stray)))))
+
+(ert-deftest test-ai-term--next-agent-buffer-empty-queue-returns-nil ()
+ "Boundary: an empty queue returns nil (nothing to switch to)."
+ (should (null (cj/--ai-term-next-agent-buffer nil '()))))
+
+(provide 'test-ai-term--next-agent-buffer)
+;;; test-ai-term--next-agent-buffer.el ends here
diff --git a/todo.org b/todo.org
index 575102256..ab941c11a 100644
--- a/todo.org
+++ b/todo.org
@@ -55,27 +55,15 @@ 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:
+** DONE [#B] Codebase refactoring program — remaining batch :refactor:solo:
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.
+Complete 2026-06-20: all 13 scan findings addressed across the day's sessions (see
+=.ai/sessions/= for the logs). 5 medium extractions + 2 big single-file refactors +
+6 theme-studio items including the browser-gates harness rewrite. The only item not
+done is the item-8 plan() factory, consciously skipped as premature abstraction
+(heterogeneous call sites — see "Remaining — item-8 plan() factory" below).
+The original scan: full-codebase 8-agent fan-out over modules/ + scripts/theme-studio/,
+one focused refactor per commit, won't-do items excluded.
*** Working protocol (apply to every item)
- TDD: write/keep a failing-then-green test; harvest new test seams the refactor opens.
@@ -114,22 +102,18 @@ 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).
-*** Remaining — browser-gates harness rewrite (HIGHEST RISK, deferred for review)
-Two parts of the browser-gates.js item are intentionally NOT done in the
-autonomous no-approvals run — they rewrite the harness that verifies everything,
-so a subtle helper bug manufactures silent false-greens across all 44 gates:
-- =gate(name, body)= helper for the ~39 gates' =let ok;notes;A=...; title; result-div=
- boilerplate. LOW value (pure boilerplate; note format drifted 17 " | " vs 24
- " fails="). CRITICAL CONSTRAINT on any attempt: each gate keeps its literal
- =location.hash==='#NAMEtest'= (run-tests.sh greps it to discover gates) — wrap only
- the body, e.g. =if(location.hash==='#x')gate('X','x',A=>{...})=. Verify: all 44 gates
- green AND a deliberately-broken assertion still FAILS (proved feasible — chrome is
- available; the assertPreviewFaces commit ran exactly that check).
-- =withSavedState(keys, body)= for the ~13 mutating gates' inconsistent
- PALETTE/MAP/UIMAP/SYNTAX snapshot-restore (7 mutating gates currently restore
- NOTHING — a real state-pollution bug, not just dedup). Needs per-gate key analysis.
-Both warrant Craig's eye before/after given the harness-rewrite risk. The
-=assertPreviewFaces= part of this item is already done (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
@@ -211,7 +195,8 @@ From the 2026-06 config audit, =modules/transcription-config.el=:
- =:210= — =make-process :stderr= with a file PATH creates a BUFFER named like the path (verified by probe); the "Errored. Logs in <file>" notification points at a log without the error text, and the hidden stderr buffer leaks per transcription. Route stderr into the process buffer or write it out in the sentinel.
- =:370-374= — video path derives txt/log from the temp mp3's /tmp path; the transcript lands in /tmp and dies on reboot, contradicting the "alongside the source" docstring. Pass the video's path as the output base.
-** 2026-06-20 Sat @ 10:29:42 -0400 Dirvish: d duplicates, D force-deletes (guarded)
+** DONE [#C] Dirvish: free D for hard-delete, move duplicate :feature:quick:next:
+CLOSED: [2026-06-20 Sat]
Decided with Craig 2026-06-20: remove delete-to-trash entirely, bind =d= = =cj/dirvish-duplicate-file= and =D= = =cj/dirvish-hard-delete= (sudo rm -rf after a =yes-or-no-p= naming the exact targets). Built in =modules/dirvish-config.el= (=cj/--dirvish-hard-delete-command= pure builder + =cj/dirvish-hard-delete= command; keymap =d=/=D= swap). 4 ERT tests for the command builder; full suite green; live-reloaded into the daemon (=dirvish-mode-map= =d=/=D= rebinding confirmed). Manual keypress + sudo-flow check filed under Manual testing and validation.
** VERIFY [#C] page-signal pager account deregistered — re-registration needs your hands
@@ -220,7 +205,8 @@ Decided with Craig 2026-06-20: remove delete-to-trash entirely, bind =d= = =cj/d
:END:
Reported by .emacs.d 2026-06-12 01:01: the dedicated pager number (+15045173983, the Claude Pager Google Voice number on signal-cli) returns "User ... is not registered" on every send — Signal appears to have deregistered it (GV numbers get periodically re-verified). Re-registration requires captcha/SMS, which only you can do. Until then every page-signal call fails; .emacs.d's config-audit page fell back to email. Wrapper lives at claude-templates/bin/page-signal.
-** 2026-06-20 Sat @ 10:29:42 -0400 C-; b + arrow pulls a window away from a sole window
+** DONE [#C] Pull a fullscreen terminal window away with C-; b + arrow :feature:next:
+CLOSED: [2026-06-20 Sat]
Decided with Craig 2026-06-20: when the selected window is the sole window, =C-; b= + arrow keeps that window on the arrow's edge and slivers =other-buffer= in on the opposite side (=minimize-window=, so the current window keeps almost the whole frame), focus staying put; each further arrow then shrinks it step by step via =windsize=, reading the same as resizing an existing split. Generalizes to any sole window, not just terminals — resize was a no-op there before. Built in =modules/ui-navigation.el= (=cj/window-pull-side= pure mapping + =cj/window--pull-away= + a =one-window-p= branch in =cj/window-resize-sticky=). ERT tests for the mapping and both sticky paths; geometry verified in a headless frame (down -> terminal 37/40 at the bottom, reveal 2 lines slivered on top via window-min-height=1, windsize-down then steps it down); full suite green; live-reloaded into the daemon. Refined from a first cut that split toward the arrow and jumped to 50%, per Craig's feedback. Manual gesture check filed under Manual testing and validation.
** VERIFY [#C] Remove unused system-power keybindings :refactor:quick:next:
@@ -452,24 +438,10 @@ 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.
+*** 2026-06-20 Sat @ 22:11:00 -0400 F9 agent toggle no longer shrinks after a C-; b pull-away
+Craig confirmed in his live GUI frame: the agent window keeps its height across repeated F9 toggles after a C-; b pull-away, even under the WIP theme's near-zero mode-line-inactive. The total-height capture/replay fix holds (dbee95ae).
+*** 2026-06-20 Sat @ 22:11:00 -0400 F9 toggle preserves all windows in a 3-window layout
+Craig confirmed in his live GUI frame: toggling the agent off then on in a 3-window layout returns the same three windows — both working windows survive and the agent re-splits its own bottom strip. The reversible-toggle fix holds (64916462).
** PROJECT [#A] Theme-Studio Open Work
Parent grouping the open theme-studio / theming issues; close each child independently.
@@ -3576,23 +3548,42 @@ Restart the daemon, open a GUI frame, trigger an encrypted decrypt, confirm =pin
*** TODO [#C] Archive the original L3813 task
After this work lands, mark the original "Finish terminal GPG pinentry configuration" task DONE with a =CLOSED:= stamp and a one-line note pointing at this parent task.
-** TODO [#A] Unified popup placement and dismissal rules :feature:
-All transient popups should follow one set of principles. Placement: when the Emacs frame is wider than tall, the popup rises from the right; when square or taller, from the bottom — settle the aspect-ratio threshold and the pop-out percentage. Dismissal: C-c C-c when there's an accept action, C-c C-k when there's a cancel, otherwise =q= closes the window. This generalizes two existing tasks — ai-term adaptive placement (the aspect-ratio docking) and the messenger window/key unification spec (the C-c C-c / C-c C-k dismissal) — into one config-wide policy. From the roam inbox.
-
-** TODO [#A] Unify Signel and All Messengers into one UX :feature:
+** TODO [#A] Unified popup and messenger UX — placement, dismissal, one library :feature:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-16
+:LAST_REVIEWED: 2026-06-20
:END:
+Merged 2026-06-20 from the config-wide popup-policy task and the messenger-unification
+task — they're the same policy at two scopes (the messenger windows are the first
+concrete application of the general popup rules). Two parts:
+
+(A) Config-wide popup policy. All transient popups follow one set of principles.
+Placement: when the Emacs frame is wider than tall, the popup rises from the right;
+when square or taller, from the bottom — settle the aspect-ratio threshold and the
+pop-out percentage. Dismissal: C-c C-c when there's an accept action, C-c C-k when
+there's a cancel, otherwise =q= closes the window. Generalizes ai-term adaptive
+placement (the aspect-ratio docking) and the messenger window/key rules below into
+one config-wide policy. From the roam inbox.
+
+(B) Messenger unification (first application of the policy above).
Spec: [[file:docs/specs/messenger-unification-spec.org][messenger-unification-spec.org]] ([[id:4bfc2011-8ffc-4765-8886-91df12141171][by id]], Draft, 2026-06-11; keybinding-alphabet section + smoke-first parity added 2026-06-16). One library (=cj-messenger-lib.el=) gives every messenger the same shape: chat windows rise from the bottom (the signel rule, generalized), C-c C-c confirms, C-c C-k cancels, C-c C-a attaches — dispatched per backend through a registry + minor mode. Signel already conforms (reference backend); telega and slack join in phases 2-3; ERC later. All eight decisions settled 2026-06-11 (cancel closes an idle window; telega's filter-cancel shadow accepted; slack rooms join the bottom rule). Spec held open — Craig has more ideas to fold in before it's marked Ready.
** TODO [#B] agenda sources: roam Projects missing, no existence filtering :bug:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-20
+:END:
From the 2026-06 config audit, =modules/org-agenda-config.el=:
- =:182-191= — commentary and docstrings promise org-roam nodes tagged "Project" as agenda sources, but =cj/--org-agenda-scan-files= never scans them, and files added by the roam finalize-hook are wiped on the next =cj/build-org-agenda-list= cache rebuild (≤1h). Add a roam Project pass (mirror =org-refile-config.el:101-109=) or correct the docs.
- =:186,456= — agenda file list built unconditionally (inbox/calendars may not exist on a fresh machine) and =org-agenda-skip-unavailable-files= is unset — the exact interactive-prompt class that once hung the chime daemon. Filter with =file-exists-p= + set the var as backstop.
** TODO [#B] Auto-dim: org headings, links, and tags do not dim in unfocused windows :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-20
+:END:
auto-dim-other-buffers-affected-faces (auto-dim-config.el) remaps font-lock and a few org faces to the flat dim face, but not org-level-1..8, org-link, or org-tag, so headings, links (seen in daily-prep.org), and tags like :solo: stay lit when the window loses focus. Decide the dim approach: a flat-dim remap like font-lock (quick) versus dedicated -dim variants surfaced through org-faces / theme-studio (richer, matches the keyword work; Craig flagged org-tags may want the org-faces treatment). Consolidates three roam-inbox captures.
** TODO [#B] "? = curated help menu" convention across modes :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-20
+:END:
From the calibredb keybindings work 2026-06-06. The pattern that worked: in a modal/major-mode buffer (calibredb), bind =?= to a curated transient of the frequent workflows, and move the package's own full dispatch to =H=. It fixes the "I can't discover the keys" problem that which-key can't help with (which-key only pops up after a prefix, not for top-level single keys in a mode-map).
Task: survey the modes/modules Craig works in and identify where a =?= -> curated-help-menu (transient) makes sense. Candidates: any major-mode buffer with single-key bindings and no good discovery affordance -- calibredb (done), nov, dirvish, mu4e, ghostel/term, signel, pearl/linear, ELFeed, etc. For each, note whether =?= is free or already a help dispatch, and whether a curated menu (vs the package's own) adds value. Establish it as a convention (and maybe a small helper/macro to define a curated =?= menu consistently).
@@ -3610,10 +3601,16 @@ Ask:
Reference values -- modus-vivendi: refine-changed bg #4a4a00 fg #efef80, changed bg #363300 fg #efef80. modus-operandi: refine-changed bg #fac090 fg #553d00, changed bg #ffdfa9 fg #553d00.
Side-by-side legibility render: [[file:assets/2026-06-07-dupre-diff-face-legibility-compare.png][assets/2026-06-07-dupre-diff-face-legibility-compare.png]].
-** TODO [#B] erc-yank silently publishes >5-line pastes as public gists :bug:
+** TODO [#A] erc-yank silently publishes >5-line pastes as public gists :bug:quick:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-20
+:END:
=modules/erc-config.el:345= — C-y in any ERC buffer auto-creates a public gist for anything over 5 lines: clipboard content goes to a public URL with no confirmation, and no executable-find guard for =gist= (errors mid-send if absent). Privacy trap. Add a =yes-or-no-p= gate or drop the package for plain C-y. From the 2026-06 config audit.
** TODO [#B] F7 diff-aware coverage classifies every changed file "not tracked" :bug:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-20
+:END:
=modules/coverage-core.el:252= — =cj/--coverage-intersect= joins covered×changed by exact string key, but simplecov.json keys are ABSOLUTE paths while the git-diff parser returns repo-RELATIVE ones — zero matches ever, so working-tree/staged/branch scopes report ":tracked nil" for everything and F7's main feature is inert (whole-project scope works, same-source keys). Unit tests hand-build matching keys so they pass; add one integration test feeding a real undercover report + real diff. Normalize both sides to repo-relative. From the 2026-06 config audit.
** TODO [#B] Fix up test runner :bug:
@@ -8728,3 +8725,14 @@ Dump from the live daemon by default (reflects the packages actually run); the b
** DONE [#C] todo.org org-lint follow-ups :refactor:
CLOSED: [2026-06-20 Sat]
From the lint-org sweeps (2026-06-15, refreshed 2026-06-20). Resolved 2026-06-20: the misplaced-heading false positive was reworded (the bug-capture task's prose quoted heading-like "* TODO" strings), and the broken link was repointed from the missing =~/code/signel/todo.org= to =~/code/smoke/todo.org= (smoke is the evolved Signal package). The obsolete-properties-drawer entries no longer reproduce under a full org-lint pass. Both lint-org --check and the built-in org-lint now report zero.
+** 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.