things-api 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  3. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  5. package/dist/capability.d.ts +20 -0
  6. package/dist/capability.js +24 -0
  7. package/dist/capability.js.map +1 -1
  8. package/dist/cli/help.js +1 -1
  9. package/dist/cli/skill.d.ts +5 -3
  10. package/dist/cli/skill.js +6 -2
  11. package/dist/cli/skill.js.map +1 -1
  12. package/dist/contracts.d.ts +1 -1
  13. package/dist/contracts.js +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/model/mappers.js +11 -9
  18. package/dist/model/mappers.js.map +1 -1
  19. package/dist/model/today-placement.d.ts +65 -0
  20. package/dist/model/today-placement.js +59 -0
  21. package/dist/model/today-placement.js.map +1 -0
  22. package/dist/rescue.d.ts +15 -2
  23. package/dist/rescue.js +74 -11
  24. package/dist/rescue.js.map +1 -1
  25. package/dist/sync-health.js +22 -1
  26. package/dist/sync-health.js.map +1 -1
  27. package/dist/write/commands.js +3 -1
  28. package/dist/write/commands.js.map +1 -1
  29. package/dist/write/disclosures.d.ts +12 -0
  30. package/dist/write/disclosures.js +12 -0
  31. package/dist/write/disclosures.js.map +1 -1
  32. package/dist/write/guards.js +28 -5
  33. package/dist/write/guards.js.map +1 -1
  34. package/dist/write/move.js +33 -6
  35. package/dist/write/move.js.map +1 -1
  36. package/dist/write/pipeline.js +49 -4
  37. package/dist/write/pipeline.js.map +1 -1
  38. package/dist/write/pre-state.d.ts +9 -9
  39. package/dist/write/pre-state.js +44 -38
  40. package/dist/write/pre-state.js.map +1 -1
  41. package/dist/write/reorder.js +28 -7
  42. package/dist/write/reorder.js.map +1 -1
  43. package/dist/write/vectors/types.d.ts +10 -0
  44. package/dist/write/vectors/ui-drag.d.ts +153 -5
  45. package/dist/write/vectors/ui-drag.js +784 -82
  46. package/dist/write/vectors/ui-drag.js.map +1 -1
  47. package/dist/write/vectors/ui.d.ts +13 -0
  48. package/dist/write/vectors/ui.js +36 -2
  49. package/dist/write/vectors/ui.js.map +1 -1
  50. package/package.json +1 -1
  51. package/skills/things-cli/SKILL.md +4 -3
  52. package/skills/things-cli/references/bug-reports.md +56 -0
@@ -88,43 +88,167 @@ function pidOf(n){ return Application('System Events').processes.byName(n).unixI
88
88
  function sleep(ms){ $.NSThread.sleepForTimeInterval(ms/1000) }
89
89
  function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]) }
90
90
  function sv(el,name){ var v=attr(el,name); return v? v.js : '' }
91
- function frame(el){ var p=attr(el,'AXPosition'), z=attr(el,'AXSize'); if(!p||!z) return null;
91
+ function rectOf(p,z){ if(!p||!z) return null;
92
92
  var pd=ObjC.castRefToObject($.CFCopyDescription(p)).js, zd=ObjC.castRefToObject($.CFCopyDescription(z)).js;
93
93
  var pm=pd.match(/x:([-0-9.]+) y:([-0-9.]+)/), zm=zd.match(/w:([-0-9.]+) h:([-0-9.]+)/);
94
94
  return (pm&&zm)?{x:+pm[1],y:+pm[2],w:+zm[1],h:+zm[2]}:null }
95
+ function frame(el){ return rectOf(attr(el,'AXPosition'), attr(el,'AXSize')) }
95
96
  function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a }
96
97
  function findAll(el, wantRole, depth, acc){ acc=acc||[]; if(depth<0) return acc; var ch=kids(el);
97
98
  for(var i=0;i<ch.length;i++){ if(sv(ch[i],'AXRole')===wantRole) acc.push(ch[i]); findAll(ch[i], wantRole, depth-1, acc) } return acc }
98
99
  function appEl(){ return $.AXUIElementCreateApplication(pidOf('Things3')) }
99
- function stdWindow(){ var ws=kids(appEl()); for(var i=0;i<ws.length;i++){ if(sv(ws[i],'AXSubrole')==='AXStandardWindow') return ws[i] } return ws.length?ws[0]:null }
100
- function sidebarTable(){ var w=stdWindow(); if(!w) return null; var tables=findAll(w,'AXTable',12,[]); var best=null;
101
- for(var i=0;i<tables.length;i++){ var f=frame(tables[i]); if(!f) continue; if(f.w<400){ if(!best||f.w<best.f.w) best={el:tables[i],f:f} } }
102
- return best?best.el:null }
100
+ function mainWindow(){ var ws=kids(appEl()), std=[];
101
+ for(var i=0;i<ws.length;i++){ if(sv(ws[i],'AXRole')==='AXWindow' && sv(ws[i],'AXSubrole')==='AXStandardWindow') std.push(ws[i]) }
102
+ for(var k=0;k<std.length;k++){ if(sv(std[k],'AXMain')===true) return std[k] }
103
+ return std.length? std[0] : null }
104
+ var NODE_ATTRS=$(['AXValue','AXDescription','AXTitle','AXChildren','AXPosition','AXSize','AXRole']);
105
+ function node(el){ var out=Ref();
106
+ if($.AXUIElementCopyMultipleAttributeValues(el,NODE_ATTRS,0,out)!==0) return null;
107
+ var a=ObjC.castRefToObject(out[0]); if(!a||Number(a.count)<7) return null;
108
+ function s(i){ var v=a.objectAtIndex(i); if(!v) return ''; var j; try{ j=v.js }catch(e){ return '' } return typeof j==='string'? j:'' }
109
+ var ch=[], c=a.objectAtIndex(3);
110
+ try{ var n=Number(c.count); for(var i=0;i<n;i++) ch.push(c.objectAtIndex(i)) }catch(e){ ch=[] }
111
+ var f=null; try{ f=rectOf(a.objectAtIndex(4), a.objectAtIndex(5)) }catch(e){ f=null }
112
+ return { value:s(0), desc:s(1), title:s(2), children:ch, frame:f, role:s(6) } }
113
+ function textOf(n, acc, depth){ if(n===null||depth<0) return acc;
114
+ if(n.value) acc.push(n.value); if(n.desc) acc.push(n.desc); if(n.title) acc.push(n.title);
115
+ for(var i=0;i<n.children.length;i++) textOf(node(n.children[i]), acc, depth-1); return acc }
116
+ function isList(role){ return role==='AXTable'||role==='AXOutline'||role==='AXList' }
117
+ function listPanes(el, depth, acc, sa){ if(depth<0) return acc; var ch=kids(el);
118
+ for(var i=0;i<ch.length;i++){ var role=sv(ch[i],'AXRole');
119
+ if(isList(role)){ acc.push({table:ch[i], scroll:sa}); continue }
120
+ if(role==='AXRow'||role==='AXCell') continue;
121
+ listPanes(ch[i], depth-1, acc, role==='AXScrollArea'? ch[i] : sa) }
122
+ return acc }
123
+ function harvestRows(tableEl, depth){ var out=[], ch=kids(tableEl);
124
+ for(var i=0;i<ch.length;i++){ var n=node(ch[i]); if(n===null) continue;
125
+ if(n.role!=='AXRow'&&n.role!=='AXTableRow') continue;
126
+ var f=n.frame;
127
+ out.push({ text: textOf(n,[],depth).join('|'), x:f?f.x:null, y:f?f.y:null, w:f?f.w:null, h:f?f.h:null }) }
128
+ return out }
129
+ function segMatch(text, title){ var segs=text.split('|');
130
+ for(var j=0;j<segs.length;j++){ if(segs[j]===title||segs[j]===title+'.') return true } return false }
131
+ function countTitles(rows, titles){ var n=0;
132
+ for(var t=0;t<titles.length;t++){ for(var r=0;r<rows.length;r++){ if(segMatch(rows[r].text,titles[t])){ n++; break } } }
133
+ return n }
134
+ function overlapPx(a,b){ if(!a||!b) return 0; return Math.min(a.x+a.w,b.x+b.w) - Math.max(a.x,b.x) }
135
+ /*
136
+ * The scroll bar is a DIRECT child of the scroll area (measured), so this reads
137
+ * the children and stops. It used to be a findAll to depth 4, which walked the
138
+ * whole table and every row underneath it — the last full-subtree enumeration
139
+ * in the snapshot, and worth ~0.8s of its ~1.2s on an 85-row sidebar.
140
+ */
141
+ function scrollFraction(sa){ if(!sa) return null; var ch=kids(sa);
142
+ for(var b=0;b<ch.length;b++){ if(sv(ch[b],'AXRole')!=='AXScrollBar') continue;
143
+ var v=attr(ch[b],'AXValue'); if(v===null) continue;
144
+ var d=ObjC.castRefToObject($.CFCopyDescription(v)).js; var m=d.match(/value = ([+\\-0-9.]+)/);
145
+ if(m) return +m[1] }
146
+ return null }
147
+ /*
148
+ * THE SIDEBAR LOCATOR (SBRES1). Structural + semantic, never geometric:
149
+ * - the window is the one carrying AXMain (measured: exactly one does, and it
150
+ * is the front one) — never the 40x40 untitled placeholder that always sits
151
+ * in the app's AXChildren beside the menu bar;
152
+ * - candidate lists are collected by a walk that STOPS at every list container
153
+ * and never enters a row, so its cost is a function of window chrome rather
154
+ * than of the user's data (measured 125 AX calls vs the old walk's ~3,900);
155
+ * - the sidebar is the candidate whose rows carry the caller's own AREA TITLES,
156
+ * which is what a sidebar IS. No width threshold: the old w < 400 test
157
+ * silently unresolved every sidebar dragged past 400pt (issues #665/#651).
158
+ */
159
+ function resolveSidebar(titles, depth){
160
+ var w = mainWindow();
161
+ if (w === null) return { ok:false, why:'no-window' };
162
+ var panes = listPanes(w, 8, [], null);
163
+ if (panes.length === 0) return { ok:false, why:'no-list-candidates', windowFrame:frame(w) };
164
+ var scored = [], i;
165
+ for (i=0;i<panes.length;i++){
166
+ var rows = harvestRows(panes[i].table, depth);
167
+ scored.push({ pane:panes[i], rows:rows, hits:countTitles(rows,titles), frame:frame(panes[i].table) });
168
+ }
169
+ var best=null, tie=false;
170
+ for (i=0;i<scored.length;i++){
171
+ if (best===null || scored[i].hits>best.hits){ best=scored[i]; tie=false }
172
+ else if (scored[i].hits===best.hits && best.hits>0) tie=true;
173
+ }
174
+ if (best===null || best.hits===0){
175
+ var seen=[]; for(i=0;i<scored.length;i++) seen.push({frame:scored[i].frame, rows:scored[i].rows.length});
176
+ return { ok:false, why:'no-title-match', searched:seen, titles:titles.length };
177
+ }
178
+ if (tie) return { ok:false, why:'ambiguous-sidebar', titles:titles.length };
179
+ // HIDDEN-SIDEBAR SIGNATURE (measured): View > Hide Sidebar leaves the sidebar
180
+ // scroll area in the tree with its old frame while the content pane slides
181
+ // over it, so the two list panes OVERLAP horizontally by the sidebar's width.
182
+ // Visible (and full-screen) states never overlap. No AX attribute marks it.
183
+ var vp = best.pane.scroll===null? null : frame(best.pane.scroll);
184
+ for (i=0;i<scored.length;i++){
185
+ if (scored[i]===best || scored[i].pane.scroll===null) continue;
186
+ if (overlapPx(vp, frame(scored[i].pane.scroll)) > 1) return { ok:false, why:'sidebar-hidden' };
187
+ }
188
+ if (vp === null) return { ok:false, why:'no-viewport' };
189
+ if (best.rows.length === 0) return { ok:false, why:'no-rows' };
190
+ return { ok:true, table:best.pane.table, scroll:best.pane.scroll, viewport:vp, rows:best.rows, hits:best.hits };
191
+ }
103
192
  var MOVED=5, DOWN=1, UP=2, DRAG=6;
104
193
  function mev(t,x,y,cs){ var e=$.CGEventCreateMouseEvent($(), t, $.CGPointMake(x,y), 0); if(cs) $.CGEventSetIntegerValueField(e,1,cs); return e }
105
194
  function postHID(ev){ $.CGEventPost($.kCGHIDEventTap, ev) }`;
106
- /** Snapshot: sidebar rows (text + frames), viewport rect, scroll fraction. */
107
- export function jxaSidebarSnapshotScript() {
195
+ /**
196
+ * How deep the per-row text walk goes on the FAST path, and on the fallback.
197
+ *
198
+ * MEASURED (SBRES1, 84-row sidebar): the driver consumes a row's text in exactly
199
+ * two ways — `text === ''` (spacer detection) and an exact segment match against
200
+ * a known area title — and depth 2 agrees with the old depth-6 walk on BOTH for
201
+ * every row, at 235 AX calls / 197ms instead of 3,376 / 1,675ms. Depth 4 and up
202
+ * is byte-identical to the old output, so the escalation below can never lose
203
+ * information the ladder used to have.
204
+ */
205
+ const ROW_TEXT_DEPTH_FAST = 2;
206
+ const ROW_TEXT_DEPTH_FULL = 6;
207
+ /**
208
+ * Snapshot: sidebar rows (text + frames), viewport rect, scroll fraction.
209
+ *
210
+ * `areaTitles` is what makes the locator semantic — the sidebar is identified as
211
+ * the list that holds the caller's own areas. It is also the ESCALATION oracle:
212
+ * every area always renders a sidebar row (AXDRAG1: even off-viewport rows
213
+ * expose valid virtualized frames), so a shallow harvest that finds fewer titles
214
+ * than the database holds is re-run at full depth before the ladder sees it.
215
+ */
216
+ export function jxaSidebarSnapshotScript(areaTitles) {
108
217
  return `${JXA_PRELUDE}
109
- function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
110
- var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
111
- var t=sv(el,'AXTitle'); if(t) acc.push(t); var ch=kids(el); for(var i=0;i<ch.length;i++) allText(ch[i],acc,depth-1); return acc }
112
- var t = sidebarTable();
113
- var out = { viewport: null, scroll: null, rows: [] };
114
- if (t !== null) {
115
- var w = stdWindow();
116
- var areas = findAll(w,'AXScrollArea',12,[]);
117
- for (var i=0;i<areas.length;i++){ var f=frame(areas[i]); if(f && f.w<400){ out.viewport=f;
118
- var bars=findAll(areas[i],'AXScrollBar',4,[]);
119
- for (var b=0;b<bars.length;b++){ var v=attr(bars[b],'AXValue'); if(v===null) continue;
120
- var d=ObjC.castRefToObject($.CFCopyDescription(v)).js; var m=d.match(/value = ([+\\-0-9.]+)/);
121
- if(m){ out.scroll = +m[1]; break } }
122
- break } }
123
- var ch = kids(t);
124
- for (var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
125
- if (role==='AXRow'||role==='AXTableRow'){ var rf=frame(ch[r]);
126
- out.rows.push({ text: allText(ch[r],[],6).join('|'), x: rf?rf.x:null, y: rf?rf.y:null, w: rf?rf.w:null, h: rf?rf.h:null }) } }
218
+ var TITLES = ${JSON.stringify([...areaTitles])};
219
+ var r = resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST});
220
+ var out;
221
+ if (r.ok !== true) { out = { ok:false, why:r.why, searched:r.searched||null, titles:r.titles||null, windowFrame:r.windowFrame||null } }
222
+ else {
223
+ var deep = false;
224
+ if (r.hits < TITLES.length) {
225
+ var full = resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FULL});
226
+ if (full.ok === true && full.hits > r.hits) { r = full; deep = true }
227
+ }
228
+ out = { ok:true, viewport:r.viewport, scroll:scrollFraction(r.scroll), rows:r.rows,
229
+ deep:deep, matched:r.hits, expected:TITLES.length };
230
+ }
231
+ JSON.stringify(out)`;
127
232
  }
233
+ /**
234
+ * Show or hide the sidebar through Things' own View menu (SBRES1 normalization
235
+ * rung). English-pinned and fail-closed: a menu without the item — a localized
236
+ * app, or a Things update that moved it — refuses and names what it did find,
237
+ * rather than clicking whatever sits in that position (UIC1 precedent).
238
+ */
239
+ export function jxaSidebarVisibilityScript(want) {
240
+ const wanted = want === "show" ? "Show Sidebar" : "Hide Sidebar";
241
+ return `var se = Application('System Events');
242
+ var out = { clicked:false, why:'', items:[] };
243
+ try {
244
+ var items = se.processes.byName('Things3').menuBars[0].menuBarItems.byName('View').menus[0].menuItems;
245
+ for (var i = 0; i < items.length; i++) {
246
+ var name = items[i].name();
247
+ if (name) out.items.push(name);
248
+ if (name === ${JSON.stringify(wanted)}) { items[i].click(); out.clicked = true }
249
+ }
250
+ if (!out.clicked) out.why = 'the View menu has no "${wanted}" item';
251
+ } catch (e) { out.why = 'the View menu did not respond: ' + e }
128
252
  JSON.stringify(out)`;
129
253
  }
130
254
  /**
@@ -133,13 +257,12 @@ JSON.stringify(out)`;
133
257
  * Positive clicks move the CONTENT down (earlier rows return, row y grows);
134
258
  * negative clicks reveal lower rows (row y shrinks) — AXDRAG1-b.
135
259
  */
136
- export function jxaSidebarScrollScript(clicks) {
260
+ export function jxaSidebarScrollScript(clicks, areaTitles) {
137
261
  const n = Math.trunc(clicks);
138
262
  return `${JXA_PRELUDE}
139
- var w = stdWindow();
140
- var sb = null;
141
- if (w !== null) { var sas = findAll(w,'AXScrollArea',12,[]);
142
- for (var i=0;i<sas.length;i++){ var f=frame(sas[i]); if(f && f.w<400){ sb=f; break } } }
263
+ var TITLES = ${JSON.stringify([...areaTitles])};
264
+ var r = resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST});
265
+ var sb = r.ok === true ? r.viewport : null;
143
266
  if (sb === null) { 'NO_SIDEBAR' } else {
144
267
  postHID(mev(MOVED, sb.x + sb.w/2, sb.y + sb.h/2, 0)); sleep(50);
145
268
  var n = ${n}, dir = n < 0 ? -1 : 1;
@@ -168,20 +291,28 @@ postHID(mev(DRAG, tx, ty, 1)); sleep(400);
168
291
  postHID(mev(UP, tx, ty, 1));
169
292
  'DONE'`;
170
293
  }
171
- function snapshotCommand() {
294
+ function snapshotCommand(areaTitles) {
172
295
  return {
173
296
  primitive: "sidebar-snapshot",
174
297
  label: "read the sidebar rows and viewport",
175
298
  lang: "javascript",
176
- script: jxaSidebarSnapshotScript(),
299
+ script: jxaSidebarSnapshotScript(areaTitles),
300
+ };
301
+ }
302
+ function sidebarVisibilityCommand(want) {
303
+ return {
304
+ primitive: "sidebar-visibility",
305
+ label: want === "show" ? "show the sidebar (View menu)" : "hide the sidebar again (View menu)",
306
+ lang: "javascript",
307
+ script: jxaSidebarVisibilityScript(want),
177
308
  };
178
309
  }
179
- function scrollCommand(clicks) {
310
+ function scrollCommand(clicks, areaTitles) {
180
311
  return {
181
312
  primitive: "sidebar-scroll",
182
313
  label: `scroll the sidebar (${clicks} clicks)`,
183
314
  lang: "javascript",
184
- script: jxaSidebarScrollScript(clicks),
315
+ script: jxaSidebarScrollScript(clicks, areaTitles),
185
316
  };
186
317
  }
187
318
  function dragCommand(sx, sy, tx, ty) {
@@ -203,22 +334,19 @@ function dragCommand(sx, sy, tx, ty) {
203
334
  * Escape-aborts (AXDRAG1-d: byte-identical index vector) and reports it.
204
335
  */
205
336
  export function jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, // null = drop below the last row (to-last)
206
- maxTicks) {
337
+ maxTicks, areaTitles) {
207
338
  const [a, b] = [sx, sy].map(Math.round);
208
339
  const anchor = JSON.stringify(anchorTitle);
209
340
  return `${JXA_PRELUDE}
210
- function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
211
- var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
212
- var t=sv(el,'AXTitle'); if(t) acc.push(t); var ch=kids(el); for(var i=0;i<ch.length;i++) allText(ch[i],acc,depth-1); return acc }
213
- function liveRows(){ var t=sidebarTable(); if(!t) return []; var out=[]; var ch=kids(t);
214
- for(var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
215
- if(role==='AXRow'||role==='AXTableRow'){ var f=frame(ch[r]);
216
- if(f) out.push({text:allText(ch[r],[],6).join('|'), f:f}) } }
341
+ var TITLES = ${JSON.stringify([...areaTitles])};
342
+ function liveRows(){ var r=resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST}); if(r.ok!==true) return [];
343
+ var out=[]; for(var i=0;i<r.rows.length;i++){ var w=r.rows[i];
344
+ if(w.y===null||w.h===null) continue;
345
+ out.push({text:w.text, f:{x:w.x,y:w.y,w:w.w,h:w.h}}) }
217
346
  out.sort(function(p,q){ return p.f.y-q.f.y }); return out }
218
347
  function matches(text, title){ var segs=text.split('|');
219
348
  for(var j=0;j<segs.length;j++){ if(segs[j]===title||segs[j]===title+'.') return true } return false }
220
- function viewportRect(){ var w=stdWindow(); if(!w) return null; var sas=findAll(w,'AXScrollArea',12,[]);
221
- for(var i=0;i<sas.length;i++){ var f=frame(sas[i]); if(f && f.w<400) return f } return null }
349
+ function viewportRect(){ var r=resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST}); return r.ok===true? r.viewport : null }
222
350
  var sx=${a}, sy=${b}, anchorTitle=${anchor}, maxTicks=${Math.trunc(maxTicks)};
223
351
  var vp = viewportRect();
224
352
  if (vp === null) { JSON.stringify({aborted:true, why:'no sidebar viewport'}) } else {
@@ -265,8 +393,8 @@ if (b0 === null) {
265
393
  // Post-wheel SETTLE: the list can drift a few px after the last tick
266
394
  // (AXDRAG2-a saw ~8px). Wait until the live boundary is stable across
267
395
  // two consecutive reads before aiming.
268
- var stable = 0, lastY = null, w = 0;
269
- for (w = 0; w < 12 && stable < 2; w++) {
396
+ var stable = 0, lastY = null, settleTick = 0;
397
+ for (settleTick = 0; settleTick < 12 && stable < 2; settleTick++) {
270
398
  postHID(mev(DRAG, sx, sy, 1)); sleep(140);
271
399
  var bs = boundaryNow(); if (bs === null) break;
272
400
  if (lastY !== null && Math.abs(bs.y - lastY) < 1) stable++;
@@ -293,12 +421,93 @@ if (b0 === null) {
293
421
  JSON.stringify(result)
294
422
  }`;
295
423
  }
296
- function heldScrollDragCommand(sx, sy, anchorTitle, maxTicks) {
424
+ /**
425
+ * SBCOL1 — actuate an area row's DISCLOSURE CHEVRON.
426
+ *
427
+ * The toggle is two nodes: an inert `AXImage d="Source Toggle Template"` (what
428
+ * AXDRAG2-b measured and called "not actuatable") inside an `AXUnknown` wrapper
429
+ * that DOES advertise `AXPress` — and that press is DECORATIVE, exactly as REPX1
430
+ * §1.2 predicts for Things' custom rows (`AXError = 0`, zero census delta). What
431
+ * actuates is a synthesized click at the image's OWN resolved frame; SBCOL1
432
+ * toggled it both directions twice with zero beeps and zero focus steal.
433
+ *
434
+ * Fail-closed twice over: the chevron is resolved from the LIVE tree inside this
435
+ * same script (never a frame carried over from an earlier snapshot generation),
436
+ * and a chevron whose center lies outside the scroll-area band is REFUSED rather
437
+ * than clicked — an off-viewport row still exposes a valid virtualized frame
438
+ * (AXDRAG1), so clicking one would land somewhere else entirely.
439
+ *
440
+ * `ordinal` selects among same-titled rows in visual (y) order, the AXDRAG3
441
+ * disambiguation the rest of the driver uses; -1 means "the only row with this
442
+ * title", and an ambiguous match refuses.
443
+ */
444
+ export function jxaSidebarChevronClickScript(title, ordinal, areaTitles) {
445
+ const want = JSON.stringify(title);
446
+ const ord = Math.trunc(ordinal);
447
+ return `${JXA_PRELUDE}
448
+ var TITLES = ${JSON.stringify([...areaTitles])};
449
+ function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
450
+ var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
451
+ var t=sv(el,'AXTitle'); if(t) acc.push(t); var ch=kids(el); for(var i=0;i<ch.length;i++) allText(ch[i],acc,depth-1); return acc }
452
+ function matches(el, title){ var segs=allText(el,[],6);
453
+ for(var j=0;j<segs.length;j++){ if(segs[j]===title||segs[j]===title+'.') return true } return false }
454
+ function chevronOf(el, depth){ if(depth<0) return null; var ch=kids(el);
455
+ for(var i=0;i<ch.length;i++){
456
+ if(sv(ch[i],'AXRole')==='AXImage' && sv(ch[i],'AXDescription').indexOf('Toggle')>=0) return ch[i];
457
+ var r=chevronOf(ch[i], depth-1); if(r) return r }
458
+ return null }
459
+ var want=${want}, ord=${ord};
460
+ var sb = resolveSidebar(TITLES, ${ROW_TEXT_DEPTH_FAST});
461
+ if (sb.ok !== true) { JSON.stringify({clicked:false, why:'the sidebar did not resolve (' + sb.why + ')'}) } else {
462
+ var t = sb.table, vp = sb.viewport;
463
+ if (vp === null) { JSON.stringify({clicked:false, why:'the sidebar viewport did not resolve'}) } else {
464
+ var ch = kids(t), hits = [];
465
+ for (var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
466
+ if (role!=='AXRow' && role!=='AXTableRow') continue;
467
+ if (!matches(ch[r], want)) continue;
468
+ var rf = frame(ch[r]); if (rf) hits.push({el:ch[r], f:rf}) }
469
+ hits.sort(function(p,q){ return p.f.y-q.f.y });
470
+ var pick = ord < 0 ? (hits.length === 1 ? hits[0] : null) : (hits[ord] || null);
471
+ if (pick === null) {
472
+ JSON.stringify({clicked:false, why:'the area row did not resolve uniquely', rows:hits.length})
473
+ } else {
474
+ var img = chevronOf(pick.el, 5);
475
+ if (img === null) { JSON.stringify({clicked:false, why:'the row exposes no disclosure chevron'}) }
476
+ else {
477
+ var cf = frame(img);
478
+ if (cf === null) { JSON.stringify({clicked:false, why:'the chevron exposed no frame'}) }
479
+ else {
480
+ var cx = cf.x + cf.w/2, cy = cf.y + cf.h/2;
481
+ if (cy < vp.y + 6 || cy > vp.y + vp.h - 6) {
482
+ JSON.stringify({clicked:false, why:'the chevron is outside the visible sidebar band', y:cy})
483
+ } else {
484
+ // REPX1 §1.2 rig law: flags set EXPLICITLY on EVERY synthetic event
485
+ // (zero included), and a MOVED settle before the press.
486
+ var mv=mev(MOVED,cx,cy,0); $.CGEventSetFlags(mv,0); postHID(mv); sleep(300);
487
+ var dn=mev(DOWN,cx,cy,1); $.CGEventSetFlags(dn,0); postHID(dn); sleep(90);
488
+ var up=mev(UP,cx,cy,1); $.CGEventSetFlags(up,0); postHID(up); sleep(250);
489
+ JSON.stringify({clicked:true, x:cx, y:cy})
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }}`;
495
+ }
496
+ function chevronClickCommand(title, ordinal, areaTitles) {
497
+ return {
498
+ primitive: "sidebar-chevron",
499
+ label: `toggle the disclosure arrow on the area row "${title}"`,
500
+ lang: "javascript",
501
+ script: jxaSidebarChevronClickScript(title, ordinal, areaTitles),
502
+ meta: { title, ordinal },
503
+ };
504
+ }
505
+ function heldScrollDragCommand(sx, sy, anchorTitle, maxTicks, areaTitles) {
297
506
  return {
298
507
  primitive: "sidebar-held-drag",
299
508
  label: "held-scroll drag toward the destination",
300
509
  lang: "javascript",
301
- script: jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, maxTicks),
510
+ script: jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, maxTicks, areaTitles),
302
511
  meta: { sx, sy, anchorTitle, maxTicks },
303
512
  };
304
513
  }
@@ -370,20 +579,51 @@ export function boundaryBelowLast(allRows) {
370
579
  const half = (medianSpacerHeight(allRows) ?? last.h / 2) / 2;
371
580
  return last.y + last.h + half;
372
581
  }
582
+ const SNAPSHOT_FAILURES = new Set([
583
+ "no-window",
584
+ "no-list-candidates",
585
+ "no-title-match",
586
+ "ambiguous-sidebar",
587
+ "sidebar-hidden",
588
+ "no-viewport",
589
+ "no-rows",
590
+ ]);
373
591
  export function parseSidebarSnapshot(stdout) {
592
+ let parsed;
374
593
  try {
375
- const parsed = JSON.parse(stdout.trim());
376
- if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.rows))
377
- return null;
378
- return {
379
- viewport: parsed.viewport ?? null,
380
- scroll: parsed.scroll ?? null,
381
- rows: parsed.rows.filter((r) => typeof r.y === "number" && typeof r.x === "number" && typeof r.h === "number"),
382
- };
594
+ parsed = JSON.parse(stdout.trim());
383
595
  }
384
596
  catch {
385
- return null;
597
+ return { ok: false, why: "unparsable" };
598
+ }
599
+ if (typeof parsed !== "object" || parsed === null)
600
+ return { ok: false, why: "unparsable" };
601
+ const obj = parsed;
602
+ if (obj["ok"] !== true) {
603
+ const why = typeof obj["why"] === "string" && SNAPSHOT_FAILURES.has(obj["why"]) ? obj["why"] : null;
604
+ if (why === null)
605
+ return { ok: false, why: "unparsable" };
606
+ return {
607
+ ok: false,
608
+ why: why,
609
+ ...(Array.isArray(obj["searched"]) && {
610
+ searched: obj["searched"],
611
+ }),
612
+ ...(typeof obj["titles"] === "number" && { titles: obj["titles"] }),
613
+ };
386
614
  }
615
+ if (!Array.isArray(obj["rows"]))
616
+ return { ok: false, why: "unparsable" };
617
+ const rows = obj["rows"].filter((r) => typeof r.y === "number" && typeof r.x === "number" && typeof r.h === "number");
618
+ const viewport = obj["viewport"] ?? null;
619
+ if (viewport === null)
620
+ return { ok: false, why: "no-viewport" };
621
+ if (rows.length === 0)
622
+ return { ok: false, why: "no-rows" };
623
+ return {
624
+ ok: true,
625
+ snapshot: { viewport, scroll: obj["scroll"] ?? null, rows },
626
+ };
387
627
  }
388
628
  /**
389
629
  * Does a row's static-text carry this exact title as a segment? Sidebar row
@@ -530,6 +770,98 @@ export function correctedDropY(staticY, sourceCenterY, span) {
530
770
  export function inBand(y, viewport, pad = BAND_PAD) {
531
771
  return y >= viewport.y + pad && y <= viewport.y + viewport.h - pad;
532
772
  }
773
+ /**
774
+ * The travel a single certified drag can cover inside this viewport: the band
775
+ * minus the grab/drop margins at both ends. The rung-1 shared-viewport test and
776
+ * the tall-section pre-flight both measure against this ONE number.
777
+ */
778
+ export function usableDragSpan(viewport) {
779
+ return viewport.h - 4 * BAND_PAD;
780
+ }
781
+ /**
782
+ * The TALLEST section the gesture would have to climb over, or null when every
783
+ * one of them fits. #658: an area's projects render beneath it, so a section can
784
+ * be taller than the whole sidebar viewport — and BOTH shipped rungs need the
785
+ * source row and the drop boundary visible AT ONCE, so such a section is a wall
786
+ * no amount of scrolling gets around. Measured over the travel span between the
787
+ * grab point and the aimed boundary; the source's OWN section is excluded (
788
+ * lifting it collapses it) and a section is only counted when the snapshot
789
+ * actually resolved rows inside it, so a partially-materialized AX tree cannot
790
+ * fabricate a wall out of a gap between two distant rows.
791
+ */
792
+ export function sectionsInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle) {
793
+ const lo = Math.min(fromY, toY);
794
+ const hi = Math.max(fromY, toY);
795
+ const tableBottom = allRows.reduce((max, r) => Math.max(max, r.y + r.h), -Infinity);
796
+ const out = [];
797
+ for (let i = 0; i < orderedAreaRows.length; i++) {
798
+ const section = orderedAreaRows[i];
799
+ if (section === undefined || section.title === sourceTitle)
800
+ continue;
801
+ const top = section.row.y;
802
+ const bottom = orderedAreaRows[i + 1]?.row.y ?? tableBottom;
803
+ if (bottom <= lo || top >= hi)
804
+ continue; // outside the travel span
805
+ const rows = allRows.filter((r) => r.y >= top && r.y < bottom).length;
806
+ // Which same-titled row IS this one, in visual order (AXDRAG3) — the
807
+ // chevron primitive needs it to click the right one.
808
+ const ordinal = orderedAreaRows
809
+ .filter((a) => a.title === section.title)
810
+ .findIndex((a) => a.row === section.row);
811
+ out.push({
812
+ title: section.title,
813
+ height: bottom - top,
814
+ rows,
815
+ ordinal: orderedAreaRows.filter((a) => a.title === section.title).length > 1 ? ordinal : -1,
816
+ });
817
+ }
818
+ return out;
819
+ }
820
+ /** The tallest section in the travel span, or null when every one of them fits. */
821
+ export function tallestSectionInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle) {
822
+ let worst = null;
823
+ for (const s of sectionsInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle)) {
824
+ if (worst === null || s.height > worst.height)
825
+ worst = s;
826
+ }
827
+ return worst;
828
+ }
829
+ /**
830
+ * Every section in the travel span the ladder cannot climb — tallest FIRST, so
831
+ * the collapse rung clears the worst obstruction before spending a gesture on a
832
+ * marginal one. SBCOL1 §4 measured a two-wall span: collapsing both turned a
833
+ * ten-position move into an ordinary multi-hop.
834
+ */
835
+ export function blockingSectionsInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle, viewport) {
836
+ return sectionsInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle)
837
+ .filter((s) => sectionBlocks(s, viewport))
838
+ .toSorted((a, b) => b.height - a.height);
839
+ }
840
+ /**
841
+ * Is this section a WALL for the shipped ladder (rung 1 + the multi-hop floor)?
842
+ * A section taller than one drag's usable span can never be crossed, because
843
+ * every hop must land the source on the far side of it in ONE gesture.
844
+ */
845
+ export function sectionBlocks(section, viewport) {
846
+ return section.height > usableDragSpan(viewport) && section.rows >= 2;
847
+ }
848
+ /**
849
+ * The refusal an unclimbable section earns once the COLLAPSE RUNG has failed on
850
+ * it (SBCOL1) — the honest twin of the old "the viewport is too small to make
851
+ * progress" copy, which blamed the window size for a geometry no window size
852
+ * fixes (#658). `whyCollapseFailed` names what stopped the driver from folding
853
+ * the section away itself, so the advice to do it by hand is not offered blind.
854
+ */
855
+ export function blockedSectionDetail(section, viewport, destination, whyCollapseFailed) {
856
+ return (`the area "${section.title}" and the ${section.rows - 1} row(s) Things renders under it ` +
857
+ `stand between this area and ${destination}, and that block is taller than the sidebar ` +
858
+ `shows at once (about ${Math.round(section.height)}pt of rows against ${Math.round(viewport.h)}pt ` +
859
+ "of visible list) — a drag has to see where it starts and where it lands at the same time, " +
860
+ `so no gesture can cross it. Collapsing "${section.title}" out of the way did not work` +
861
+ `${whyCollapseFailed === undefined ? "" : ` (${whyCollapseFailed})`}. Collapse it in the ` +
862
+ "sidebar with the arrow on its row, or make the Things window taller, then re-run — or drag " +
863
+ "the area by hand");
864
+ }
533
865
  /**
534
866
  * Grab/drop x: a fixed FRACTION of the row's resolved width (≈ the label area
535
867
  * NATIVE1 clicked at x+170 on a 240px row), clear of the leading icon and the
@@ -585,13 +917,61 @@ async function runCmd(ctx, cmd) {
585
917
  return ctx.run(cmd, STEP_TIMEOUT_MS);
586
918
  }
587
919
  async function takeSnapshot(ctx) {
588
- const res = await runCmd(ctx, snapshotCommand());
589
- if (!res.ok)
590
- return null;
591
- const snap = parseSidebarSnapshot(res.stdout);
592
- if (snap === null || snap.viewport === null || snap.rows.length === 0)
593
- return null;
594
- return snap;
920
+ const res = await runCmd(ctx, snapshotCommand(ctx.areaTitles));
921
+ if (res.timedOut === true)
922
+ return { ok: false, why: "timeout" };
923
+ if (!res.ok) {
924
+ return { ok: false, why: "dispatch-failed", ...(res.stderr.trim() && { stderr: res.stderr }) };
925
+ }
926
+ return parseSidebarSnapshot(res.stdout);
927
+ }
928
+ /** The snapshot when only its presence matters (scroll loops, re-censuses). */
929
+ async function snapshotOrNull(ctx) {
930
+ const out = await takeSnapshot(ctx);
931
+ return out.ok ? out.snapshot : null;
932
+ }
933
+ /**
934
+ * One honest sentence per real cause, each naming the thing to change. The
935
+ * remediation matters as much as the diagnosis: the old copy told a user with a
936
+ * plainly-open sidebar to check whether the sidebar was open.
937
+ */
938
+ export function describeSnapshotFailure(refusal) {
939
+ switch (refusal.why) {
940
+ case "timeout":
941
+ return (`reading the sidebar took longer than ${Math.round(STEP_TIMEOUT_MS / 1000)}s and was ` +
942
+ "stopped — nothing was dragged. This is a very large sidebar, a busy Mac, or both; " +
943
+ "collapse some areas or close other windows and re-run");
944
+ case "dispatch-failed":
945
+ return `the sidebar read did not run${refusal.stderr ? `: ${refusal.stderr.trim()}` : ""}`;
946
+ case "unparsable":
947
+ return "the sidebar read returned output this version cannot read";
948
+ case "no-window":
949
+ return ("Things is running but has no open window — only the placeholder it keeps in the " +
950
+ "background. Open the Things window (click its Dock icon) and re-run");
951
+ case "no-list-candidates":
952
+ return ("the Things window exposes no list at all — it may still be opening. Wait for it to " +
953
+ "finish drawing and re-run");
954
+ case "sidebar-hidden":
955
+ return ("the sidebar is hidden in this Things window — show it with View ▸ Show Sidebar (⌘/) " +
956
+ "and re-run");
957
+ case "no-title-match": {
958
+ const where = refusal.searched === undefined
959
+ ? ""
960
+ : ` (searched ${refusal.searched.length} list(s): ${refusal.searched
961
+ .map((s) => `${s.rows} row(s)${s.frame ? ` at ${Math.round(s.frame.w)}pt wide` : ""}`)
962
+ .join(", ")})`;
963
+ return (`none of the lists in the Things window holds a row for any of your ${refusal.titles ?? 0} ` +
964
+ `area(s)${where} — the sidebar may be scrolled inside a different window, or a Things ` +
965
+ "update may have changed how it exposes rows");
966
+ }
967
+ case "ambiguous-sidebar":
968
+ return ("two lists in the Things window both look like the sidebar, so nothing was dragged — " +
969
+ "close the extra Things window (File ▸ Close) and re-run");
970
+ case "no-viewport":
971
+ return "the sidebar's scrolling container did not resolve";
972
+ case "no-rows":
973
+ return "the sidebar resolved but exposed no rows — quit and reopen Things, then retry";
974
+ }
595
975
  }
596
976
  /**
597
977
  * Scroll until `wanted(snapshot)` returns a zero-ish error, re-resolving
@@ -608,7 +988,7 @@ async function scrollUntil(ctx, wanted, goodEnough) {
608
988
  let stalls = 0;
609
989
  for (let iter = 0; iter < MAX_SCROLL_ITER; iter++) {
610
990
  // each scroll must observe the frames the previous scroll produced
611
- const snap = await takeSnapshot(ctx);
991
+ const snap = await snapshotOrNull(ctx);
612
992
  if (snap === null)
613
993
  return null;
614
994
  const err = wanted(snap);
@@ -637,7 +1017,7 @@ async function scrollUntil(ctx, wanted, goodEnough) {
637
1017
  const clicks = Math.max(-12, Math.min(12, Math.round(err / pxPerClick) || Math.sign(err))) * dirFactor;
638
1018
  lastClicks = clicks;
639
1019
  // strictly sequential scroll-and-remeasure loop
640
- const res = await runCmd(ctx, scrollCommand(clicks));
1020
+ const res = await runCmd(ctx, scrollCommand(clicks, ctx.areaTitles));
641
1021
  if (!res.ok)
642
1022
  return null;
643
1023
  }
@@ -654,6 +1034,159 @@ async function pollState(ctx, check) {
654
1034
  }
655
1035
  return null;
656
1036
  }
1037
+ // ------------------------------------------------- the collapse rung (SBCOL1)
1038
+ /** How many table rows a titled section currently renders (its own row included). */
1039
+ function sectionRowCount(snap, areaTitles, title, ordinal) {
1040
+ const ordered = areaRowsInOrder(snap.rows, areaTitles);
1041
+ const i = ordinal < 0 ? ordered.findIndex((a) => a.title === title) : nthByTitle(ordered, title, ordinal);
1042
+ const section = ordered[i];
1043
+ if (i < 0 || section === undefined)
1044
+ return null;
1045
+ const tableBottom = snap.rows.reduce((max, r) => Math.max(max, r.y + r.h), -Infinity);
1046
+ const bottom = ordered[i + 1]?.row.y ?? tableBottom;
1047
+ return snap.rows.filter((r) => r.y >= section.row.y && r.y < bottom).length;
1048
+ }
1049
+ /**
1050
+ * Scroll the named area's row into the visible band, then actuate its chevron
1051
+ * and CONFIRM the section changed size.
1052
+ *
1053
+ * The scrutiny doctrine's probe law in production form: a re-census follows the
1054
+ * one input step, and a click that did not move the section fast-fails — the
1055
+ * ladder must never proceed on the assumption that a gesture worked. `want`
1056
+ * says which direction counts as success, so the same primitive collapses and
1057
+ * re-expands.
1058
+ */
1059
+ async function toggleDisclosure(ctx, areaTitles, title, ordinal, want) {
1060
+ // The row must be inside the band before the chevron can be clicked: an
1061
+ // off-viewport row still exposes a valid virtualized frame (AXDRAG1), so an
1062
+ // unscrolled click would land outside the sidebar entirely.
1063
+ const ready = await scrollUntil(ctx, (s) => {
1064
+ if (s.viewport === null)
1065
+ return null;
1066
+ const row = resolveAreaRow(s.rows, title, ordinal);
1067
+ if (row === null)
1068
+ return null;
1069
+ const center = row.y + row.h / 2;
1070
+ if (inBand(center, s.viewport))
1071
+ return null;
1072
+ return s.viewport.y + s.viewport.h / 2 - center;
1073
+ });
1074
+ if (ready === null) {
1075
+ return { clicked: false, ok: false, why: `"${title}"'s row could not be scrolled into view` };
1076
+ }
1077
+ const rowsBefore = sectionRowCount(ready, areaTitles, title, ordinal);
1078
+ if (rowsBefore === null) {
1079
+ return { clicked: false, ok: false, why: `"${title}"'s row did not resolve` };
1080
+ }
1081
+ // the gesture must land before the re-census that judges it
1082
+ const res = await runCmd(ctx, chevronClickCommand(title, ordinal, ctx.areaTitles));
1083
+ let clicked = false;
1084
+ let why = "the disclosure arrow did not respond";
1085
+ if (res.ok) {
1086
+ try {
1087
+ const parsed = JSON.parse(res.stdout.trim());
1088
+ clicked = parsed.clicked === true;
1089
+ if (typeof parsed.why === "string")
1090
+ why = parsed.why;
1091
+ }
1092
+ catch {
1093
+ /* keep the default reason */
1094
+ }
1095
+ }
1096
+ if (!clicked)
1097
+ return { clicked: false, ok: false, why };
1098
+ // The click WENT OUT. Everything below reports `clicked: true` even when it
1099
+ // fails, because the app has already changed its state and a verification
1100
+ // that could not run is not evidence that nothing happened. SBCOL1 §6 found
1101
+ // this the hard way: quitting Things during the re-census left the area
1102
+ // collapsed on disk while the ledger — written only on success — held
1103
+ // nothing, so a durable change to the sidebar went unmentioned. The ledger
1104
+ // records what the driver DID, not what it managed to confirm.
1105
+ //
1106
+ // RE-CENSUS after the input step — a gesture whose effect we cannot see is
1107
+ // still never allowed to carry the ladder forward.
1108
+ await ctx.sleep(600);
1109
+ const after = await takeSnapshot(ctx);
1110
+ if (!after.ok) {
1111
+ return { clicked: true, ok: false, why: describeSnapshotFailure(after) };
1112
+ }
1113
+ const rowsAfter = sectionRowCount(after.snapshot, areaTitles, title, ordinal);
1114
+ if (rowsAfter === null) {
1115
+ return { clicked: true, ok: false, why: `"${title}"'s row did not resolve after the click` };
1116
+ }
1117
+ const moved = want === "fewer" ? rowsAfter < rowsBefore : rowsAfter > rowsBefore;
1118
+ if (!moved) {
1119
+ return {
1120
+ clicked: true,
1121
+ ok: false,
1122
+ why: `the click left "${title}" rendering ${rowsAfter} row(s) — the section did not ${want === "fewer" ? "collapse" : "re-expand"}`,
1123
+ };
1124
+ }
1125
+ return { clicked: true, ok: true, rowsBefore, rowsAfter };
1126
+ }
1127
+ /**
1128
+ * Fold every blocking section away, recording what to put back. Stops at the
1129
+ * FIRST section it cannot collapse — a half-cleared path is not a path, and the
1130
+ * caller falls through to the honest refusal (with the already-collapsed areas
1131
+ * restored by the epilogue like any other exit).
1132
+ */
1133
+ async function collapseWalls(ctx, walls, areaTitles, collapsed) {
1134
+ for (const wall of walls) {
1135
+ // Already folded and STILL measuring as a wall: the gesture is not working
1136
+ // on this section, so stop rather than clicking at it again.
1137
+ if (collapsed.some((c) => c.title === wall.title && c.ordinal === wall.ordinal)) {
1138
+ return { ok: false, why: `"${wall.title}" stayed oversized after it was collapsed` };
1139
+ }
1140
+ // each collapse must be verified before the next one is attempted
1141
+ const outcome = await toggleDisclosure(ctx, areaTitles, wall.title, wall.ordinal, "fewer");
1142
+ // Ledger on the CLICK, not on the confirmation (SBCOL1 §6): a fold that
1143
+ // went out and could not be verified is still a fold to answer for.
1144
+ if (outcome.clicked) {
1145
+ collapsed.push({
1146
+ title: wall.title,
1147
+ ordinal: wall.ordinal,
1148
+ rowsExpanded: outcome.ok ? outcome.rowsBefore : null,
1149
+ });
1150
+ }
1151
+ if (!outcome.ok)
1152
+ return { ok: false, why: outcome.why };
1153
+ }
1154
+ return { ok: true };
1155
+ }
1156
+ /**
1157
+ * Put the sidebar back — in REVERSE order, so the list reflows the way it was
1158
+ * folded. Runs on EVERY exit path (success, refusal, abort, throw): the
1159
+ * disclosure state lives in the app's preferences and survives a relaunch
1160
+ * (SBCOL1 §3), so an unrestored collapse is a durable change to the user's
1161
+ * sidebar that they never asked for.
1162
+ */
1163
+ async function restoreDisclosure(ctx, areaTitles, collapsed) {
1164
+ const failed = [];
1165
+ for (const entry of collapsed.toReversed()) {
1166
+ // each re-expansion must be verified before the next one is attempted
1167
+ const outcome = await toggleDisclosure(ctx, areaTitles, entry.title, entry.ordinal, "more");
1168
+ if (!outcome.ok)
1169
+ failed.push(entry.title);
1170
+ }
1171
+ return failed;
1172
+ }
1173
+ /** Append the collapse rung's own account to whatever the ladder concluded. */
1174
+ function withCollapseOutcome(result, collapsed, restoreFailed) {
1175
+ if (collapsed.length === 0)
1176
+ return result;
1177
+ const names = collapsed.map((c) => `"${c.title}"`).join(", ");
1178
+ const restored = restoreFailed.length === 0
1179
+ ? "and expanded again afterwards"
1180
+ : `but ${restoreFailed.map((t) => `"${t}"`).join(", ")} could not be expanded again — ` +
1181
+ "the sidebar is left collapsed there until you click the arrow (or Things is relaunched, " +
1182
+ "which keeps it collapsed)";
1183
+ return {
1184
+ ...result,
1185
+ detail: `${result.detail} (${names} collapsed to clear the path, ${restored})`,
1186
+ collapsed: collapsed.map((c) => c.title),
1187
+ ...(restoreFailed.length > 0 && { restoreFailed }),
1188
+ };
1189
+ }
657
1190
  /** Resolve source row + corrected drop Y against ONE snapshot generation. */
658
1191
  function planDrop(snap, spec, areaTitles, placement,
659
1192
  // DUPLICATE-title disambiguation (ORDFIN2 AXDRAG3), recomputed from live state
@@ -738,7 +1271,91 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
738
1271
  "only run through the full client",
739
1272
  };
740
1273
  }
741
- const ctx = { run, state: aux.areaState, sleep };
1274
+ const areaTitlesForRestore = aux.areaState().areas.map((a) => a.title);
1275
+ const ctx = {
1276
+ run,
1277
+ state: aux.areaState,
1278
+ sleep,
1279
+ areaTitles: areaTitlesForRestore,
1280
+ };
1281
+ // The collapse rung's ledger, owned OUT HERE so the restore epilogue covers
1282
+ // every way the ladder can end — including a throw (FGRD2 cleanup shape).
1283
+ const collapsed = [];
1284
+ // The chrome ledger, same shape and for the same reason: a sidebar this drive
1285
+ // revealed is hidden again on EVERY exit path. The user's window chrome is
1286
+ // theirs; a move must not silently leave it changed (SBCOL1 precedent).
1287
+ const chrome = { revealedSidebar: false };
1288
+ try {
1289
+ const result = await runDragLadder(ctx, spec, collapsed, chrome);
1290
+ // the ladder (and any recovery drag inside it) must finish before the
1291
+ // sidebar is folded back — the recovery needs the cleared path too
1292
+ const restoreFailed = await restoreDisclosure(ctx, areaTitlesForRestore, collapsed);
1293
+ const chromeNote = await restoreChrome(ctx, chrome);
1294
+ return withChromeOutcome(withCollapseOutcome(result, collapsed, restoreFailed), chromeNote);
1295
+ }
1296
+ catch (err) {
1297
+ // the sidebar is put back even when the ladder blew up
1298
+ await restoreDisclosure(ctx, areaTitlesForRestore, collapsed);
1299
+ await restoreChrome(ctx, chrome);
1300
+ throw err;
1301
+ }
1302
+ }
1303
+ /**
1304
+ * NORMALIZATION RUNG (SBRES1): a hidden sidebar is not a dead end.
1305
+ *
1306
+ * Things' View ▸ Show Sidebar is a real, documented command, so the drive uses
1307
+ * it — and then CLOSES THE LOOP, exactly as the determinism doctrine requires:
1308
+ * the reveal only counts once a fresh snapshot resolves. A click that went out
1309
+ * is ledgered whether or not it verified, because the app has already changed.
1310
+ */
1311
+ async function revealSidebar(ctx, chrome) {
1312
+ const res = await runCmd(ctx, sidebarVisibilityCommand("show"));
1313
+ let clicked = false;
1314
+ let why = "the View menu did not respond";
1315
+ if (res.ok) {
1316
+ try {
1317
+ const parsed = JSON.parse(res.stdout.trim());
1318
+ clicked = parsed.clicked === true;
1319
+ if (typeof parsed.why === "string" && parsed.why !== "")
1320
+ why = parsed.why;
1321
+ }
1322
+ catch {
1323
+ /* keep the default reason */
1324
+ }
1325
+ }
1326
+ if (!clicked)
1327
+ return { ok: false, why };
1328
+ chrome.revealedSidebar = true;
1329
+ await ctx.sleep(600);
1330
+ const after = await takeSnapshot(ctx);
1331
+ if (after.ok)
1332
+ return { ok: true, snapshot: after.snapshot };
1333
+ return { ok: false, why: describeSnapshotFailure(after) };
1334
+ }
1335
+ /** Put the window chrome back. Runs on every exit path. */
1336
+ async function restoreChrome(ctx, chrome) {
1337
+ if (!chrome.revealedSidebar)
1338
+ return null;
1339
+ const res = await runCmd(ctx, sidebarVisibilityCommand("hide"));
1340
+ let clicked = false;
1341
+ if (res.ok) {
1342
+ try {
1343
+ clicked = JSON.parse(res.stdout.trim()).clicked === true;
1344
+ }
1345
+ catch {
1346
+ clicked = false;
1347
+ }
1348
+ }
1349
+ return clicked
1350
+ ? "the sidebar was shown to run the move and hidden again afterwards"
1351
+ : "the sidebar was shown to run the move and could NOT be hidden again — hide it with " +
1352
+ "View ▸ Hide Sidebar (⌘/)";
1353
+ }
1354
+ function withChromeOutcome(result, note) {
1355
+ return note === null ? result : { ...result, detail: `${result.detail} (${note})` };
1356
+ }
1357
+ /** The ladder proper. Its caller owns the collapse ledger and the restore. */
1358
+ async function runDragLadder(ctx, spec, collapsed, chrome) {
742
1359
  const pre = ctx.state();
743
1360
  const preTies = hasRankTies(pre);
744
1361
  const areaTitles = pre.areas.map((a) => a.title);
@@ -822,10 +1439,27 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
822
1439
  let hopCap = Math.min(MAX_HOPS_CEILING, pre.areas.length + 2);
823
1440
  for (let attempt = 0; attempt <= MAX_HOPS_CEILING; attempt++) {
824
1441
  // every hop depends on the layout the previous hop produced
825
- const snap = await takeSnapshot(ctx);
826
- if (snap === null) {
827
- return refuseOrRecover(ctx, pre, spec, hops, "the sidebar did not resolve (is the window open and the sidebar visible?)");
1442
+ let snapOutcome = await takeSnapshot(ctx);
1443
+ // A HIDDEN sidebar is normalized, not refused: Things' own View menu shows
1444
+ // it, the epilogue hides it again, and the refusal below is reached only
1445
+ // when that fails. Everything else is reported as the cause it actually is.
1446
+ if (!snapOutcome.ok && snapOutcome.why === "sidebar-hidden") {
1447
+ const revealed = await revealSidebar(ctx, chrome);
1448
+ snapOutcome = revealed.ok
1449
+ ? { ok: true, snapshot: revealed.snapshot }
1450
+ : {
1451
+ ok: false,
1452
+ why: "sidebar-hidden",
1453
+ ...{ stderr: revealed.why },
1454
+ };
828
1455
  }
1456
+ if (!snapOutcome.ok) {
1457
+ const why = snapOutcome.why === "sidebar-hidden" && snapOutcome.stderr !== undefined
1458
+ ? `the sidebar is hidden in this Things window and showing it did not work (${snapOutcome.stderr})`
1459
+ : describeSnapshotFailure(snapOutcome);
1460
+ return refuseOrRecover(ctx, pre, spec, hops, why);
1461
+ }
1462
+ const snap = snapOutcome.snapshot;
829
1463
  const viewport = snap.viewport;
830
1464
  {
831
1465
  // ceil(areas / visible-slots) + 2, from the frame-derived slot pitch.
@@ -847,10 +1481,35 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
847
1481
  }
848
1482
  resolveRetried = false;
849
1483
  const grab = grabPoint(finalPlan.source);
1484
+ const spanNeeded = Math.abs(grab.y - finalPlan.dropY);
1485
+ // PRE-FLIGHT (#658, AXDRAG5): every shipped rung needs the grab point and
1486
+ // the drop boundary inside the viewport AT ONCE, so a sidebar SECTION taller
1487
+ // than one drag's usable span can never be crossed — not by rung 1, and not
1488
+ // one hop at a time. Detect it from the geometry BEFORE any gesture and
1489
+ // refuse honestly; the old behavior was to hop until no anchor fit and then
1490
+ // blame the window size after minutes of AX round-trips.
1491
+ if (spanNeeded >= usableDragSpan(viewport)) {
1492
+ const orderedNow = areaRowsInOrder(snap.rows, areaTitles);
1493
+ const walls = blockingSectionsInSpan(orderedNow, snap.rows, grab.y, finalPlan.dropY, spec.targetTitle, viewport);
1494
+ if (walls.length > 0) {
1495
+ // COLLAPSE RUNG (SBCOL1). A wall is not a dead end: fold the blocking
1496
+ // section(s) away with their disclosure chevrons — each collapse
1497
+ // verified by a re-census — and re-plan against the shorter sidebar.
1498
+ // The epilogue puts every one of them back.
1499
+ // the sidebar must actually fold before the ladder re-plans against it
1500
+ const folded = await collapseWalls(ctx, walls, areaTitles, collapsed);
1501
+ if (folded.ok)
1502
+ continue; // re-plan from a fresh snapshot
1503
+ const wall = walls[0];
1504
+ const detail = blockedSectionDetail(wall, viewport, describeAnchor(finalPlan.anchor), folded.why);
1505
+ return hops === 0
1506
+ ? { ok: false, detail }
1507
+ : abortPartial(ctx, spec, hops, `${detail} — so the move stopped part-way`);
1508
+ }
1509
+ }
850
1510
  // Rung 1: both the grab point and the drop boundary visible (or scrollable
851
1511
  // into simultaneous view) → one certified drag.
852
- const spanNeeded = Math.abs(grab.y - finalPlan.dropY);
853
- if (spanNeeded < viewport.h - 4 * BAND_PAD) {
1512
+ if (spanNeeded < usableDragSpan(viewport)) {
854
1513
  let ready = snap;
855
1514
  if (!inBand(grab.y, viewport) || !inBand(finalPlan.dropY, viewport)) {
856
1515
  // pre-scroll must land before the drag
@@ -948,7 +1607,7 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
948
1607
  continue;
949
1608
  const maxTicks = Math.min(400, Math.max(20, Math.ceil(travel / 15)));
950
1609
  // the held gesture must complete before its DB assert
951
- const res = await runCmd(ctx, heldScrollDragCommand(g.x, g.y, anchor, maxTicks));
1610
+ const res = await runCmd(ctx, heldScrollDragCommand(g.x, g.y, anchor, maxTicks, ctx.areaTitles));
952
1611
  const parsed = parseHeldDragResult(res);
953
1612
  if (parsed.dropped) {
954
1613
  // the final assert gates success
@@ -1039,8 +1698,27 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
1039
1698
  }
1040
1699
  }
1041
1700
  if (hopAnchor === null) {
1042
- return refuseOrRecover(ctx, pre, spec, hops, "no drop slot toward the destination fits the visible sidebar — the viewport is too " +
1043
- "small to make progress");
1701
+ // Name the real cause (#658): normally the next area toward the
1702
+ // destination begins a section taller than the visible list, which no
1703
+ // window size fixes. Only fall back to the generic sentence when the
1704
+ // geometry does NOT show such a section.
1705
+ const planNow = planDrop(parked, spec, areaTitles, spec.placement, ranks);
1706
+ const wallsHere = "error" in planNow
1707
+ ? []
1708
+ : blockingSectionsInSpan(ordered, parked.rows, grabPoint(planNow.source).y, planNow.dropY, spec.targetTitle, parked.viewport);
1709
+ if (wallsHere.length > 0) {
1710
+ // The same collapse rung, reached from the hop side: the next area
1711
+ // toward the destination begins a section taller than the visible list.
1712
+ // the fold must land before the ladder re-plans
1713
+ const folded = await collapseWalls(ctx, wallsHere, areaTitles, collapsed);
1714
+ if (folded.ok) {
1715
+ hops -= 1; // the fold is not a hop; the re-plan gets the slot back
1716
+ continue;
1717
+ }
1718
+ return refuseOrRecover(ctx, pre, spec, hops, blockedSectionDetail(wallsHere[0], parked.viewport, describeAnchor("error" in planNow ? "the requested position" : planNow.anchor), folded.why));
1719
+ }
1720
+ return refuseOrRecover(ctx, pre, spec, hops, "no drop slot toward the destination fits the visible sidebar — the nearest area row " +
1721
+ "toward it sits more than one drag away, so the gesture has nowhere to land");
1044
1722
  }
1045
1723
  const anchorUuid = hopAnchor.uuid;
1046
1724
  // the hop gesture must land before its DB assert
@@ -1097,6 +1775,20 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
1097
1775
  }
1098
1776
  return abortPartial(ctx, spec, hops, "exceeded the hop budget without converging");
1099
1777
  }
1778
+ /** The destination, as the refusal copy names it. */
1779
+ function describeAnchor(anchor) {
1780
+ return anchor === "the end of the sidebar list" || anchor === "the requested position"
1781
+ ? anchor
1782
+ : `"${anchor}"`;
1783
+ }
1784
+ /** Where the area sits RIGHT NOW, for a report that never has to guess. */
1785
+ function describePosition(state, uuid) {
1786
+ const pos = positionOf(state, uuid);
1787
+ if (pos < 0)
1788
+ return "the area no longer resolves in the database";
1789
+ return (`the area now sits at sidebar position ${pos + 1} of ${state.areas.length}` +
1790
+ (pos > 0 ? `, below "${state.areas[pos - 1]?.title ?? "?"}"` : ", at the top"));
1791
+ }
1100
1792
  /**
1101
1793
  * Abort mid-ladder WITHOUT recovery (design amendment: a partially-moved area
1102
1794
  * is benign) — send Escape as a safety valve and report exactly where the
@@ -1108,12 +1800,7 @@ async function abortPartial(ctx, spec, hops, why) {
1108
1800
  label: "abort (Escape)",
1109
1801
  script: `tell application "System Events" to key code 53`,
1110
1802
  });
1111
- const state = ctx.state();
1112
- const pos = positionOf(state, spec.targetUuid);
1113
- const where = pos < 0
1114
- ? "the area no longer resolves in the database"
1115
- : `the area now sits at sidebar position ${pos + 1} of ${state.areas.length}` +
1116
- (pos > 0 ? `, below "${state.areas[pos - 1]?.title ?? "?"}"` : ", at the top");
1803
+ const where = describePosition(ctx.state(), spec.targetUuid);
1117
1804
  return {
1118
1805
  ok: false,
1119
1806
  detail: `${why} after ${hops} hop(s); ${where}. Its to-dos and projects are untouched — ` +
@@ -1128,12 +1815,27 @@ async function abortPartial(ctx, spec, hops, why) {
1128
1815
  async function refuseOrRecover(ctx, pre, spec, hops, why) {
1129
1816
  const now = ctx.state();
1130
1817
  const orderChanged = now.areas.map((a) => a.uuid).join(",") !== pre.areas.map((a) => a.uuid).join(",");
1131
- if (!orderChanged || hasRankTies(pre)) {
1818
+ if (!orderChanged) {
1132
1819
  return {
1133
1820
  ok: false,
1134
1821
  detail: `${why}. No sidebar change was left behind${hops > 0 ? ` after ${hops} hop(s)` : ""}.`,
1135
1822
  };
1136
1823
  }
1824
+ // The order DID change. Tied pre-op ranks (every never-dragged area sits at
1825
+ // `index` 0, so ties are the norm on real data) make the restore anchor
1826
+ // ambiguous, so no recovery drag is attempted — but #658: the change must be
1827
+ // REPORTED, never claimed away. The old code short-circuited on the tie and
1828
+ // emitted "No sidebar change was left behind" over a move that had landed,
1829
+ // was visible in the sidebar, and was syncing to the user's other devices.
1830
+ if (hasRankTies(pre)) {
1831
+ return {
1832
+ ok: false,
1833
+ detail: `${why}. The sidebar order DID change${hops > 0 ? ` over ${hops} hop(s)` : ""} and was ` +
1834
+ `left in place: ${describePosition(now, spec.targetUuid)}. Several areas share the same ` +
1835
+ "stored rank, so putting it back automatically could move the wrong one — re-run the " +
1836
+ "move, or drag it back in the app.",
1837
+ };
1838
+ }
1137
1839
  // Which area was actually displaced? Normally the target; but a DUPLICATE-title
1138
1840
  // positional grab may have moved a SAME-TITLED SIBLING instead (the target's
1139
1841
  // own slot is unchanged). Restore whichever uuid drifted farthest from its