things-api 0.20.0 → 0.20.1

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 (37) 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/cli/help.js +1 -1
  6. package/dist/contracts.d.ts +1 -1
  7. package/dist/contracts.js +1 -1
  8. package/dist/model/mappers.js +11 -9
  9. package/dist/model/mappers.js.map +1 -1
  10. package/dist/model/today-placement.d.ts +65 -0
  11. package/dist/model/today-placement.js +59 -0
  12. package/dist/model/today-placement.js.map +1 -0
  13. package/dist/write/commands.js +3 -1
  14. package/dist/write/commands.js.map +1 -1
  15. package/dist/write/disclosures.d.ts +12 -0
  16. package/dist/write/disclosures.js +12 -0
  17. package/dist/write/disclosures.js.map +1 -1
  18. package/dist/write/guards.js +28 -5
  19. package/dist/write/guards.js.map +1 -1
  20. package/dist/write/move.js +33 -6
  21. package/dist/write/move.js.map +1 -1
  22. package/dist/write/pipeline.js +49 -4
  23. package/dist/write/pipeline.js.map +1 -1
  24. package/dist/write/pre-state.d.ts +9 -9
  25. package/dist/write/pre-state.js +44 -38
  26. package/dist/write/pre-state.js.map +1 -1
  27. package/dist/write/reorder.js +28 -7
  28. package/dist/write/reorder.js.map +1 -1
  29. package/dist/write/vectors/types.d.ts +10 -0
  30. package/dist/write/vectors/ui-drag.d.ts +102 -0
  31. package/dist/write/vectors/ui-drag.js +425 -11
  32. package/dist/write/vectors/ui-drag.js.map +1 -1
  33. package/dist/write/vectors/ui.d.ts +6 -0
  34. package/dist/write/vectors/ui.js +33 -1
  35. package/dist/write/vectors/ui.js.map +1 -1
  36. package/package.json +1 -1
  37. package/skills/things-cli/SKILL.md +1 -1
@@ -293,6 +293,88 @@ if (b0 === null) {
293
293
  JSON.stringify(result)
294
294
  }`;
295
295
  }
296
+ /**
297
+ * SBCOL1 — actuate an area row's DISCLOSURE CHEVRON.
298
+ *
299
+ * The toggle is two nodes: an inert `AXImage d="Source Toggle Template"` (what
300
+ * AXDRAG2-b measured and called "not actuatable") inside an `AXUnknown` wrapper
301
+ * that DOES advertise `AXPress` — and that press is DECORATIVE, exactly as REPX1
302
+ * §1.2 predicts for Things' custom rows (`AXError = 0`, zero census delta). What
303
+ * actuates is a synthesized click at the image's OWN resolved frame; SBCOL1
304
+ * toggled it both directions twice with zero beeps and zero focus steal.
305
+ *
306
+ * Fail-closed twice over: the chevron is resolved from the LIVE tree inside this
307
+ * same script (never a frame carried over from an earlier snapshot generation),
308
+ * and a chevron whose center lies outside the scroll-area band is REFUSED rather
309
+ * than clicked — an off-viewport row still exposes a valid virtualized frame
310
+ * (AXDRAG1), so clicking one would land somewhere else entirely.
311
+ *
312
+ * `ordinal` selects among same-titled rows in visual (y) order, the AXDRAG3
313
+ * disambiguation the rest of the driver uses; -1 means "the only row with this
314
+ * title", and an ambiguous match refuses.
315
+ */
316
+ export function jxaSidebarChevronClickScript(title, ordinal) {
317
+ const want = JSON.stringify(title);
318
+ const ord = Math.trunc(ordinal);
319
+ return `${JXA_PRELUDE}
320
+ function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
321
+ var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
322
+ 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 }
323
+ function matches(el, title){ var segs=allText(el,[],6);
324
+ for(var j=0;j<segs.length;j++){ if(segs[j]===title||segs[j]===title+'.') return true } return false }
325
+ function chevronOf(el, depth){ if(depth<0) return null; var ch=kids(el);
326
+ for(var i=0;i<ch.length;i++){
327
+ if(sv(ch[i],'AXRole')==='AXImage' && sv(ch[i],'AXDescription').indexOf('Toggle')>=0) return ch[i];
328
+ var r=chevronOf(ch[i], depth-1); if(r) return r }
329
+ return null }
330
+ var want=${want}, ord=${ord};
331
+ var t = sidebarTable();
332
+ if (t === null) { JSON.stringify({clicked:false, why:'the sidebar table did not resolve'}) } else {
333
+ var w = stdWindow(), vp = null;
334
+ if (w !== null) { var sas=findAll(w,'AXScrollArea',12,[]);
335
+ for (var i=0;i<sas.length;i++){ var vf=frame(sas[i]); if(vf && vf.w<400){ vp=vf; break } } }
336
+ if (vp === null) { JSON.stringify({clicked:false, why:'the sidebar viewport did not resolve'}) } else {
337
+ var ch = kids(t), hits = [];
338
+ for (var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
339
+ if (role!=='AXRow' && role!=='AXTableRow') continue;
340
+ if (!matches(ch[r], want)) continue;
341
+ var rf = frame(ch[r]); if (rf) hits.push({el:ch[r], f:rf}) }
342
+ hits.sort(function(p,q){ return p.f.y-q.f.y });
343
+ var pick = ord < 0 ? (hits.length === 1 ? hits[0] : null) : (hits[ord] || null);
344
+ if (pick === null) {
345
+ JSON.stringify({clicked:false, why:'the area row did not resolve uniquely', rows:hits.length})
346
+ } else {
347
+ var img = chevronOf(pick.el, 5);
348
+ if (img === null) { JSON.stringify({clicked:false, why:'the row exposes no disclosure chevron'}) }
349
+ else {
350
+ var cf = frame(img);
351
+ if (cf === null) { JSON.stringify({clicked:false, why:'the chevron exposed no frame'}) }
352
+ else {
353
+ var cx = cf.x + cf.w/2, cy = cf.y + cf.h/2;
354
+ if (cy < vp.y + 6 || cy > vp.y + vp.h - 6) {
355
+ JSON.stringify({clicked:false, why:'the chevron is outside the visible sidebar band', y:cy})
356
+ } else {
357
+ // REPX1 §1.2 rig law: flags set EXPLICITLY on EVERY synthetic event
358
+ // (zero included), and a MOVED settle before the press.
359
+ var mv=mev(MOVED,cx,cy,0); $.CGEventSetFlags(mv,0); postHID(mv); sleep(300);
360
+ var dn=mev(DOWN,cx,cy,1); $.CGEventSetFlags(dn,0); postHID(dn); sleep(90);
361
+ var up=mev(UP,cx,cy,1); $.CGEventSetFlags(up,0); postHID(up); sleep(250);
362
+ JSON.stringify({clicked:true, x:cx, y:cy})
363
+ }
364
+ }
365
+ }
366
+ }
367
+ }}`;
368
+ }
369
+ function chevronClickCommand(title, ordinal) {
370
+ return {
371
+ primitive: "sidebar-chevron",
372
+ label: `toggle the disclosure arrow on the area row "${title}"`,
373
+ lang: "javascript",
374
+ script: jxaSidebarChevronClickScript(title, ordinal),
375
+ meta: { title, ordinal },
376
+ };
377
+ }
296
378
  function heldScrollDragCommand(sx, sy, anchorTitle, maxTicks) {
297
379
  return {
298
380
  primitive: "sidebar-held-drag",
@@ -530,6 +612,98 @@ export function correctedDropY(staticY, sourceCenterY, span) {
530
612
  export function inBand(y, viewport, pad = BAND_PAD) {
531
613
  return y >= viewport.y + pad && y <= viewport.y + viewport.h - pad;
532
614
  }
615
+ /**
616
+ * The travel a single certified drag can cover inside this viewport: the band
617
+ * minus the grab/drop margins at both ends. The rung-1 shared-viewport test and
618
+ * the tall-section pre-flight both measure against this ONE number.
619
+ */
620
+ export function usableDragSpan(viewport) {
621
+ return viewport.h - 4 * BAND_PAD;
622
+ }
623
+ /**
624
+ * The TALLEST section the gesture would have to climb over, or null when every
625
+ * one of them fits. #658: an area's projects render beneath it, so a section can
626
+ * be taller than the whole sidebar viewport — and BOTH shipped rungs need the
627
+ * source row and the drop boundary visible AT ONCE, so such a section is a wall
628
+ * no amount of scrolling gets around. Measured over the travel span between the
629
+ * grab point and the aimed boundary; the source's OWN section is excluded (
630
+ * lifting it collapses it) and a section is only counted when the snapshot
631
+ * actually resolved rows inside it, so a partially-materialized AX tree cannot
632
+ * fabricate a wall out of a gap between two distant rows.
633
+ */
634
+ export function sectionsInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle) {
635
+ const lo = Math.min(fromY, toY);
636
+ const hi = Math.max(fromY, toY);
637
+ const tableBottom = allRows.reduce((max, r) => Math.max(max, r.y + r.h), -Infinity);
638
+ const out = [];
639
+ for (let i = 0; i < orderedAreaRows.length; i++) {
640
+ const section = orderedAreaRows[i];
641
+ if (section === undefined || section.title === sourceTitle)
642
+ continue;
643
+ const top = section.row.y;
644
+ const bottom = orderedAreaRows[i + 1]?.row.y ?? tableBottom;
645
+ if (bottom <= lo || top >= hi)
646
+ continue; // outside the travel span
647
+ const rows = allRows.filter((r) => r.y >= top && r.y < bottom).length;
648
+ // Which same-titled row IS this one, in visual order (AXDRAG3) — the
649
+ // chevron primitive needs it to click the right one.
650
+ const ordinal = orderedAreaRows
651
+ .filter((a) => a.title === section.title)
652
+ .findIndex((a) => a.row === section.row);
653
+ out.push({
654
+ title: section.title,
655
+ height: bottom - top,
656
+ rows,
657
+ ordinal: orderedAreaRows.filter((a) => a.title === section.title).length > 1 ? ordinal : -1,
658
+ });
659
+ }
660
+ return out;
661
+ }
662
+ /** The tallest section in the travel span, or null when every one of them fits. */
663
+ export function tallestSectionInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle) {
664
+ let worst = null;
665
+ for (const s of sectionsInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle)) {
666
+ if (worst === null || s.height > worst.height)
667
+ worst = s;
668
+ }
669
+ return worst;
670
+ }
671
+ /**
672
+ * Every section in the travel span the ladder cannot climb — tallest FIRST, so
673
+ * the collapse rung clears the worst obstruction before spending a gesture on a
674
+ * marginal one. SBCOL1 §4 measured a two-wall span: collapsing both turned a
675
+ * ten-position move into an ordinary multi-hop.
676
+ */
677
+ export function blockingSectionsInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle, viewport) {
678
+ return sectionsInSpan(orderedAreaRows, allRows, fromY, toY, sourceTitle)
679
+ .filter((s) => sectionBlocks(s, viewport))
680
+ .toSorted((a, b) => b.height - a.height);
681
+ }
682
+ /**
683
+ * Is this section a WALL for the shipped ladder (rung 1 + the multi-hop floor)?
684
+ * A section taller than one drag's usable span can never be crossed, because
685
+ * every hop must land the source on the far side of it in ONE gesture.
686
+ */
687
+ export function sectionBlocks(section, viewport) {
688
+ return section.height > usableDragSpan(viewport) && section.rows >= 2;
689
+ }
690
+ /**
691
+ * The refusal an unclimbable section earns once the COLLAPSE RUNG has failed on
692
+ * it (SBCOL1) — the honest twin of the old "the viewport is too small to make
693
+ * progress" copy, which blamed the window size for a geometry no window size
694
+ * fixes (#658). `whyCollapseFailed` names what stopped the driver from folding
695
+ * the section away itself, so the advice to do it by hand is not offered blind.
696
+ */
697
+ export function blockedSectionDetail(section, viewport, destination, whyCollapseFailed) {
698
+ return (`the area "${section.title}" and the ${section.rows - 1} row(s) Things renders under it ` +
699
+ `stand between this area and ${destination}, and that block is taller than the sidebar ` +
700
+ `shows at once (about ${Math.round(section.height)}pt of rows against ${Math.round(viewport.h)}pt ` +
701
+ "of visible list) — a drag has to see where it starts and where it lands at the same time, " +
702
+ `so no gesture can cross it. Collapsing "${section.title}" out of the way did not work` +
703
+ `${whyCollapseFailed === undefined ? "" : ` (${whyCollapseFailed})`}. Collapse it in the ` +
704
+ "sidebar with the arrow on its row, or make the Things window taller, then re-run — or drag " +
705
+ "the area by hand");
706
+ }
533
707
  /**
534
708
  * Grab/drop x: a fixed FRACTION of the row's resolved width (≈ the label area
535
709
  * NATIVE1 clicked at x+170 on a 240px row), clear of the leading icon and the
@@ -654,6 +828,159 @@ async function pollState(ctx, check) {
654
828
  }
655
829
  return null;
656
830
  }
831
+ // ------------------------------------------------- the collapse rung (SBCOL1)
832
+ /** How many table rows a titled section currently renders (its own row included). */
833
+ function sectionRowCount(snap, areaTitles, title, ordinal) {
834
+ const ordered = areaRowsInOrder(snap.rows, areaTitles);
835
+ const i = ordinal < 0 ? ordered.findIndex((a) => a.title === title) : nthByTitle(ordered, title, ordinal);
836
+ const section = ordered[i];
837
+ if (i < 0 || section === undefined)
838
+ return null;
839
+ const tableBottom = snap.rows.reduce((max, r) => Math.max(max, r.y + r.h), -Infinity);
840
+ const bottom = ordered[i + 1]?.row.y ?? tableBottom;
841
+ return snap.rows.filter((r) => r.y >= section.row.y && r.y < bottom).length;
842
+ }
843
+ /**
844
+ * Scroll the named area's row into the visible band, then actuate its chevron
845
+ * and CONFIRM the section changed size.
846
+ *
847
+ * The scrutiny doctrine's probe law in production form: a re-census follows the
848
+ * one input step, and a click that did not move the section fast-fails — the
849
+ * ladder must never proceed on the assumption that a gesture worked. `want`
850
+ * says which direction counts as success, so the same primitive collapses and
851
+ * re-expands.
852
+ */
853
+ async function toggleDisclosure(ctx, areaTitles, title, ordinal, want) {
854
+ // The row must be inside the band before the chevron can be clicked: an
855
+ // off-viewport row still exposes a valid virtualized frame (AXDRAG1), so an
856
+ // unscrolled click would land outside the sidebar entirely.
857
+ const ready = await scrollUntil(ctx, (s) => {
858
+ if (s.viewport === null)
859
+ return null;
860
+ const row = resolveAreaRow(s.rows, title, ordinal);
861
+ if (row === null)
862
+ return null;
863
+ const center = row.y + row.h / 2;
864
+ if (inBand(center, s.viewport))
865
+ return null;
866
+ return s.viewport.y + s.viewport.h / 2 - center;
867
+ });
868
+ if (ready === null) {
869
+ return { clicked: false, ok: false, why: `"${title}"'s row could not be scrolled into view` };
870
+ }
871
+ const rowsBefore = sectionRowCount(ready, areaTitles, title, ordinal);
872
+ if (rowsBefore === null) {
873
+ return { clicked: false, ok: false, why: `"${title}"'s row did not resolve` };
874
+ }
875
+ // the gesture must land before the re-census that judges it
876
+ const res = await runCmd(ctx, chevronClickCommand(title, ordinal));
877
+ let clicked = false;
878
+ let why = "the disclosure arrow did not respond";
879
+ if (res.ok) {
880
+ try {
881
+ const parsed = JSON.parse(res.stdout.trim());
882
+ clicked = parsed.clicked === true;
883
+ if (typeof parsed.why === "string")
884
+ why = parsed.why;
885
+ }
886
+ catch {
887
+ /* keep the default reason */
888
+ }
889
+ }
890
+ if (!clicked)
891
+ return { clicked: false, ok: false, why };
892
+ // The click WENT OUT. Everything below reports `clicked: true` even when it
893
+ // fails, because the app has already changed its state and a verification
894
+ // that could not run is not evidence that nothing happened. SBCOL1 §6 found
895
+ // this the hard way: quitting Things during the re-census left the area
896
+ // collapsed on disk while the ledger — written only on success — held
897
+ // nothing, so a durable change to the sidebar went unmentioned. The ledger
898
+ // records what the driver DID, not what it managed to confirm.
899
+ //
900
+ // RE-CENSUS after the input step — a gesture whose effect we cannot see is
901
+ // still never allowed to carry the ladder forward.
902
+ await ctx.sleep(600);
903
+ const after = await takeSnapshot(ctx);
904
+ if (after === null) {
905
+ return { clicked: true, ok: false, why: "the sidebar did not resolve after the click" };
906
+ }
907
+ const rowsAfter = sectionRowCount(after, areaTitles, title, ordinal);
908
+ if (rowsAfter === null) {
909
+ return { clicked: true, ok: false, why: `"${title}"'s row did not resolve after the click` };
910
+ }
911
+ const moved = want === "fewer" ? rowsAfter < rowsBefore : rowsAfter > rowsBefore;
912
+ if (!moved) {
913
+ return {
914
+ clicked: true,
915
+ ok: false,
916
+ why: `the click left "${title}" rendering ${rowsAfter} row(s) — the section did not ${want === "fewer" ? "collapse" : "re-expand"}`,
917
+ };
918
+ }
919
+ return { clicked: true, ok: true, rowsBefore, rowsAfter };
920
+ }
921
+ /**
922
+ * Fold every blocking section away, recording what to put back. Stops at the
923
+ * FIRST section it cannot collapse — a half-cleared path is not a path, and the
924
+ * caller falls through to the honest refusal (with the already-collapsed areas
925
+ * restored by the epilogue like any other exit).
926
+ */
927
+ async function collapseWalls(ctx, walls, areaTitles, collapsed) {
928
+ for (const wall of walls) {
929
+ // Already folded and STILL measuring as a wall: the gesture is not working
930
+ // on this section, so stop rather than clicking at it again.
931
+ if (collapsed.some((c) => c.title === wall.title && c.ordinal === wall.ordinal)) {
932
+ return { ok: false, why: `"${wall.title}" stayed oversized after it was collapsed` };
933
+ }
934
+ // each collapse must be verified before the next one is attempted
935
+ const outcome = await toggleDisclosure(ctx, areaTitles, wall.title, wall.ordinal, "fewer");
936
+ // Ledger on the CLICK, not on the confirmation (SBCOL1 §6): a fold that
937
+ // went out and could not be verified is still a fold to answer for.
938
+ if (outcome.clicked) {
939
+ collapsed.push({
940
+ title: wall.title,
941
+ ordinal: wall.ordinal,
942
+ rowsExpanded: outcome.ok ? outcome.rowsBefore : null,
943
+ });
944
+ }
945
+ if (!outcome.ok)
946
+ return { ok: false, why: outcome.why };
947
+ }
948
+ return { ok: true };
949
+ }
950
+ /**
951
+ * Put the sidebar back — in REVERSE order, so the list reflows the way it was
952
+ * folded. Runs on EVERY exit path (success, refusal, abort, throw): the
953
+ * disclosure state lives in the app's preferences and survives a relaunch
954
+ * (SBCOL1 §3), so an unrestored collapse is a durable change to the user's
955
+ * sidebar that they never asked for.
956
+ */
957
+ async function restoreDisclosure(ctx, areaTitles, collapsed) {
958
+ const failed = [];
959
+ for (const entry of collapsed.toReversed()) {
960
+ // each re-expansion must be verified before the next one is attempted
961
+ const outcome = await toggleDisclosure(ctx, areaTitles, entry.title, entry.ordinal, "more");
962
+ if (!outcome.ok)
963
+ failed.push(entry.title);
964
+ }
965
+ return failed;
966
+ }
967
+ /** Append the collapse rung's own account to whatever the ladder concluded. */
968
+ function withCollapseOutcome(result, collapsed, restoreFailed) {
969
+ if (collapsed.length === 0)
970
+ return result;
971
+ const names = collapsed.map((c) => `"${c.title}"`).join(", ");
972
+ const restored = restoreFailed.length === 0
973
+ ? "and expanded again afterwards"
974
+ : `but ${restoreFailed.map((t) => `"${t}"`).join(", ")} could not be expanded again — ` +
975
+ "the sidebar is left collapsed there until you click the arrow (or Things is relaunched, " +
976
+ "which keeps it collapsed)";
977
+ return {
978
+ ...result,
979
+ detail: `${result.detail} (${names} collapsed to clear the path, ${restored})`,
980
+ collapsed: collapsed.map((c) => c.title),
981
+ ...(restoreFailed.length > 0 && { restoreFailed }),
982
+ };
983
+ }
657
984
  /** Resolve source row + corrected drop Y against ONE snapshot generation. */
658
985
  function planDrop(snap, spec, areaTitles, placement,
659
986
  // DUPLICATE-title disambiguation (ORDFIN2 AXDRAG3), recomputed from live state
@@ -739,6 +1066,25 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
739
1066
  };
740
1067
  }
741
1068
  const ctx = { run, state: aux.areaState, sleep };
1069
+ // The collapse rung's ledger, owned OUT HERE so the restore epilogue covers
1070
+ // every way the ladder can end — including a throw (FGRD2 cleanup shape).
1071
+ const collapsed = [];
1072
+ const areaTitlesForRestore = ctx.state().areas.map((a) => a.title);
1073
+ try {
1074
+ const result = await runDragLadder(ctx, spec, collapsed);
1075
+ // the ladder (and any recovery drag inside it) must finish before the
1076
+ // sidebar is folded back — the recovery needs the cleared path too
1077
+ const restoreFailed = await restoreDisclosure(ctx, areaTitlesForRestore, collapsed);
1078
+ return withCollapseOutcome(result, collapsed, restoreFailed);
1079
+ }
1080
+ catch (err) {
1081
+ // the sidebar is put back even when the ladder blew up
1082
+ await restoreDisclosure(ctx, areaTitlesForRestore, collapsed);
1083
+ throw err;
1084
+ }
1085
+ }
1086
+ /** The ladder proper. Its caller owns the collapse ledger and the restore. */
1087
+ async function runDragLadder(ctx, spec, collapsed) {
742
1088
  const pre = ctx.state();
743
1089
  const preTies = hasRankTies(pre);
744
1090
  const areaTitles = pre.areas.map((a) => a.title);
@@ -847,10 +1193,35 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
847
1193
  }
848
1194
  resolveRetried = false;
849
1195
  const grab = grabPoint(finalPlan.source);
1196
+ const spanNeeded = Math.abs(grab.y - finalPlan.dropY);
1197
+ // PRE-FLIGHT (#658, AXDRAG5): every shipped rung needs the grab point and
1198
+ // the drop boundary inside the viewport AT ONCE, so a sidebar SECTION taller
1199
+ // than one drag's usable span can never be crossed — not by rung 1, and not
1200
+ // one hop at a time. Detect it from the geometry BEFORE any gesture and
1201
+ // refuse honestly; the old behavior was to hop until no anchor fit and then
1202
+ // blame the window size after minutes of AX round-trips.
1203
+ if (spanNeeded >= usableDragSpan(viewport)) {
1204
+ const orderedNow = areaRowsInOrder(snap.rows, areaTitles);
1205
+ const walls = blockingSectionsInSpan(orderedNow, snap.rows, grab.y, finalPlan.dropY, spec.targetTitle, viewport);
1206
+ if (walls.length > 0) {
1207
+ // COLLAPSE RUNG (SBCOL1). A wall is not a dead end: fold the blocking
1208
+ // section(s) away with their disclosure chevrons — each collapse
1209
+ // verified by a re-census — and re-plan against the shorter sidebar.
1210
+ // The epilogue puts every one of them back.
1211
+ // the sidebar must actually fold before the ladder re-plans against it
1212
+ const folded = await collapseWalls(ctx, walls, areaTitles, collapsed);
1213
+ if (folded.ok)
1214
+ continue; // re-plan from a fresh snapshot
1215
+ const wall = walls[0];
1216
+ const detail = blockedSectionDetail(wall, viewport, describeAnchor(finalPlan.anchor), folded.why);
1217
+ return hops === 0
1218
+ ? { ok: false, detail }
1219
+ : abortPartial(ctx, spec, hops, `${detail} — so the move stopped part-way`);
1220
+ }
1221
+ }
850
1222
  // Rung 1: both the grab point and the drop boundary visible (or scrollable
851
1223
  // into simultaneous view) → one certified drag.
852
- const spanNeeded = Math.abs(grab.y - finalPlan.dropY);
853
- if (spanNeeded < viewport.h - 4 * BAND_PAD) {
1224
+ if (spanNeeded < usableDragSpan(viewport)) {
854
1225
  let ready = snap;
855
1226
  if (!inBand(grab.y, viewport) || !inBand(finalPlan.dropY, viewport)) {
856
1227
  // pre-scroll must land before the drag
@@ -1039,8 +1410,27 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
1039
1410
  }
1040
1411
  }
1041
1412
  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");
1413
+ // Name the real cause (#658): normally the next area toward the
1414
+ // destination begins a section taller than the visible list, which no
1415
+ // window size fixes. Only fall back to the generic sentence when the
1416
+ // geometry does NOT show such a section.
1417
+ const planNow = planDrop(parked, spec, areaTitles, spec.placement, ranks);
1418
+ const wallsHere = "error" in planNow
1419
+ ? []
1420
+ : blockingSectionsInSpan(ordered, parked.rows, grabPoint(planNow.source).y, planNow.dropY, spec.targetTitle, parked.viewport);
1421
+ if (wallsHere.length > 0) {
1422
+ // The same collapse rung, reached from the hop side: the next area
1423
+ // toward the destination begins a section taller than the visible list.
1424
+ // the fold must land before the ladder re-plans
1425
+ const folded = await collapseWalls(ctx, wallsHere, areaTitles, collapsed);
1426
+ if (folded.ok) {
1427
+ hops -= 1; // the fold is not a hop; the re-plan gets the slot back
1428
+ continue;
1429
+ }
1430
+ return refuseOrRecover(ctx, pre, spec, hops, blockedSectionDetail(wallsHere[0], parked.viewport, describeAnchor("error" in planNow ? "the requested position" : planNow.anchor), folded.why));
1431
+ }
1432
+ return refuseOrRecover(ctx, pre, spec, hops, "no drop slot toward the destination fits the visible sidebar — the nearest area row " +
1433
+ "toward it sits more than one drag away, so the gesture has nowhere to land");
1044
1434
  }
1045
1435
  const anchorUuid = hopAnchor.uuid;
1046
1436
  // the hop gesture must land before its DB assert
@@ -1097,6 +1487,20 @@ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => ne
1097
1487
  }
1098
1488
  return abortPartial(ctx, spec, hops, "exceeded the hop budget without converging");
1099
1489
  }
1490
+ /** The destination, as the refusal copy names it. */
1491
+ function describeAnchor(anchor) {
1492
+ return anchor === "the end of the sidebar list" || anchor === "the requested position"
1493
+ ? anchor
1494
+ : `"${anchor}"`;
1495
+ }
1496
+ /** Where the area sits RIGHT NOW, for a report that never has to guess. */
1497
+ function describePosition(state, uuid) {
1498
+ const pos = positionOf(state, uuid);
1499
+ if (pos < 0)
1500
+ return "the area no longer resolves in the database";
1501
+ return (`the area now sits at sidebar position ${pos + 1} of ${state.areas.length}` +
1502
+ (pos > 0 ? `, below "${state.areas[pos - 1]?.title ?? "?"}"` : ", at the top"));
1503
+ }
1100
1504
  /**
1101
1505
  * Abort mid-ladder WITHOUT recovery (design amendment: a partially-moved area
1102
1506
  * is benign) — send Escape as a safety valve and report exactly where the
@@ -1108,12 +1512,7 @@ async function abortPartial(ctx, spec, hops, why) {
1108
1512
  label: "abort (Escape)",
1109
1513
  script: `tell application "System Events" to key code 53`,
1110
1514
  });
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");
1515
+ const where = describePosition(ctx.state(), spec.targetUuid);
1117
1516
  return {
1118
1517
  ok: false,
1119
1518
  detail: `${why} after ${hops} hop(s); ${where}. Its to-dos and projects are untouched — ` +
@@ -1128,12 +1527,27 @@ async function abortPartial(ctx, spec, hops, why) {
1128
1527
  async function refuseOrRecover(ctx, pre, spec, hops, why) {
1129
1528
  const now = ctx.state();
1130
1529
  const orderChanged = now.areas.map((a) => a.uuid).join(",") !== pre.areas.map((a) => a.uuid).join(",");
1131
- if (!orderChanged || hasRankTies(pre)) {
1530
+ if (!orderChanged) {
1132
1531
  return {
1133
1532
  ok: false,
1134
1533
  detail: `${why}. No sidebar change was left behind${hops > 0 ? ` after ${hops} hop(s)` : ""}.`,
1135
1534
  };
1136
1535
  }
1536
+ // The order DID change. Tied pre-op ranks (every never-dragged area sits at
1537
+ // `index` 0, so ties are the norm on real data) make the restore anchor
1538
+ // ambiguous, so no recovery drag is attempted — but #658: the change must be
1539
+ // REPORTED, never claimed away. The old code short-circuited on the tie and
1540
+ // emitted "No sidebar change was left behind" over a move that had landed,
1541
+ // was visible in the sidebar, and was syncing to the user's other devices.
1542
+ if (hasRankTies(pre)) {
1543
+ return {
1544
+ ok: false,
1545
+ detail: `${why}. The sidebar order DID change${hops > 0 ? ` over ${hops} hop(s)` : ""} and was ` +
1546
+ `left in place: ${describePosition(now, spec.targetUuid)}. Several areas share the same ` +
1547
+ "stored rank, so putting it back automatically could move the wrong one — re-run the " +
1548
+ "move, or drag it back in the app.",
1549
+ };
1550
+ }
1137
1551
  // Which area was actually displaced? Normally the target; but a DUPLICATE-title
1138
1552
  // positional grab may have moved a SAME-TITLED SIBLING instead (the target's
1139
1553
  // own slot is unchanged). Restore whichever uuid drifted farthest from its