@m13v/s4l 1.7.6-rc.2 → 1.7.6-rc.21

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 (35) hide show
  1. package/mcp/dist/index.js +256 -92
  2. package/mcp/dist/telemetry.js +2 -1
  3. package/mcp/dist/version.json +2 -2
  4. package/mcp/manifest.json +1 -1
  5. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  6. package/mcp/menubar/s4l_card.py +144 -18
  7. package/mcp/menubar/s4l_card_canvas.py +135 -14
  8. package/mcp/menubar/s4l_menubar.py +216 -73
  9. package/mcp/menubar/s4l_state.py +45 -130
  10. package/mcp/package.json +1 -1
  11. package/package.json +1 -1
  12. package/scripts/account_resolver.py +35 -1
  13. package/scripts/autopilot_stall_watch.py +8 -1
  14. package/scripts/browser_lifecycle.py +90 -0
  15. package/scripts/cdp_ready_check.py +81 -50
  16. package/scripts/draft_prompt_core.py +100 -17
  17. package/scripts/engagement_styles.py +135 -98
  18. package/scripts/merge_review_queue.py +192 -129
  19. package/scripts/platform_strike_events.py +54 -5
  20. package/scripts/post_reddit.py +10 -0
  21. package/scripts/preview_reddit_card.py +38 -0
  22. package/scripts/reap_stale_claude_sessions.py +7 -6
  23. package/scripts/reddit_ban_check.py +220 -0
  24. package/scripts/reddit_browser.py +24 -0
  25. package/scripts/release-mcpb.sh +38 -7
  26. package/scripts/setup_twitter_auth.py +20 -96
  27. package/scripts/store_patch.py +111 -0
  28. package/scripts/strike_alert.py +161 -12
  29. package/scripts/test_no_silent_fallbacks.py +11 -0
  30. package/scripts/test_stall_watch_batch_progression.py +99 -0
  31. package/scripts/top_performers.py +24 -0
  32. package/scripts/twitter_browser.py +22 -52
  33. package/skill/archive-old-logs.sh +24 -0
  34. package/skill/lib/reddit-backend.sh +1 -0
  35. package/skill/run-twitter-cycle.sh +146 -66
@@ -331,7 +331,7 @@ def _truncate(s, n=320):
331
331
  return s if len(s) <= n else s[: n - 1] + "…"
332
332
 
333
333
 
334
- def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
334
+ def _set_thread_text(thread_tv, text, link_url, *, font_size=12, title=None):
335
335
  """Set the thread-quote text on `thread_tv`: the FULL text, never
336
336
  trimmed (2026-07-16 user direction -- the box scrolls instead when it
337
337
  doesn't fit; see its NSScrollView wrapper in _render()). A leading
@@ -340,8 +340,15 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
340
340
  of how long the thread is or where the box happens to be scrolled to
341
341
  (the old trailing-link placement could end up scrolled out of view, or
342
342
  -- before scrolling existed at all -- get silently truncated off the
343
- end; see the superseded _fit_thread_body this replaced)."""
343
+ end; see the superseded _fit_thread_body this replaced).
344
+
345
+ `title` (2026-07-17 user direction, Reddit): when given, it's drawn BOLD
346
+ on its own line ahead of `text`, so a Reddit card shows the post title in
347
+ bold with the selftext body beneath it in the same quote box. Twitter and
348
+ title-only Reddit threads pass title=None and render `text` alone,
349
+ exactly as before."""
344
350
  text = (text or "").strip()
351
+ title = (title or "").strip()
345
352
  body = NSMutableAttributedString.alloc().initWithString_attributes_("", {})
346
353
  if link_url:
347
354
  body.appendAttributedString_(
@@ -356,6 +363,19 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
356
363
  },
357
364
  )
358
365
  )
366
+ if title:
367
+ # Bold title on its own line; a trailing newline separates it from the
368
+ # body only when there IS a body (a title with no selftext shouldn't
369
+ # leave a dangling blank line).
370
+ body.appendAttributedString_(
371
+ NSAttributedString.alloc().initWithString_attributes_(
372
+ title + ("\n" if text else ""),
373
+ {
374
+ NSFontAttributeName: NSFont.boldSystemFontOfSize_(font_size),
375
+ NSForegroundColorAttributeName: NSColor.labelColor(),
376
+ },
377
+ )
378
+ )
359
379
  body.appendAttributedString_(
360
380
  NSAttributedString.alloc().initWithString_attributes_(
361
381
  text,
@@ -425,8 +445,26 @@ def _engagement_line(stats):
425
445
  return " · ".join(parts)
426
446
 
427
447
 
448
+ def _compact_arm(variant):
449
+ """Shrink a draft_prompt A/B variant to a heading-sized token so the arm
450
+ fits inline next to project/lane/viral: 'treatment_v4' -> 't v4',
451
+ 'control_v4' -> 'c v4'. Anything that isn't a treatment/control_vN shape
452
+ (future arm names, retired bare 'treatment'/'control') falls back to the
453
+ raw variant, so no arm goes unlabelled. The full one-sentence meaning
454
+ still lives in the details popover via active_experiments.describe()."""
455
+ v = (variant or "").strip()
456
+ if not v:
457
+ return ""
458
+ m = re.match(r"(treatment|control)(?:_v?(\w+))?$", v)
459
+ if not m:
460
+ return v
461
+ arm = "t" if m.group(1) == "treatment" else "c"
462
+ ver = m.group(2)
463
+ return f"{arm} v{ver}" if ver else arm
464
+
465
+
428
466
  def _reply_heading_suffix(d):
429
- """Concise 'project/lane · viral N' readout appended straight onto the
467
+ """Concise 'project/lane · viral N · t v4' readout appended straight onto the
430
468
  'Reply (editable):' heading (2026-07-07), so the two facts a reviewer
431
469
  checks first don't require opening the details eye. Pulled from the same
432
470
  fields _details_lines used to render as 'Project:'/'Lane:'/'Virality
@@ -465,6 +503,15 @@ def _reply_heading_suffix(d):
465
503
  bits.append(f"viral {float(v):g}")
466
504
  except (TypeError, ValueError):
467
505
  pass
506
+ # The drafting A/B arm (treatment vs control), compacted to 't v4'/'c v4'
507
+ # so the reviewer sees which prompt variant produced this draft without
508
+ # opening the details eye. Only draft_prompt rides the heading; other
509
+ # experiments stay solely in the popover. draft_prompt ALSO keeps its full
510
+ # one-sentence meaning in the popover (unlike 'lane', which is skipped
511
+ # there) because the compact token alone doesn't say what the arm does.
512
+ arm = _compact_arm((d.get("experiments") or {}).get("draft_prompt"))
513
+ if arm:
514
+ bits.append(arm)
468
515
  return " · ".join(bits)
469
516
 
470
517
 
@@ -801,6 +848,31 @@ def _label(frame, text, *, size=12, bold=False, muted=False, truncates=False):
801
848
  return f
802
849
 
803
850
 
851
+ class _TileScroll(NSScrollView):
852
+ """Scroll box that yields the wheel to an enclosing canvas grid. Inside
853
+ the canvas (s4l_card_canvas.py) most of a tile's area is one of these
854
+ boxes, and AppKit routes the whole wheel gesture (momentum included) to
855
+ the innermost scroll view under the pointer -- so the canvas simply
856
+ would not scroll whenever the pointer sat over a thread quote or draft
857
+ editor (2026-07-16 user report). Forward the event to the outer scroll
858
+ view instead, UNLESS this box's own text view holds the caret (the
859
+ reviewer clicked into it, so inner scrolling is what they want). In the
860
+ corner card there is no enclosing scroll view and behavior is unchanged."""
861
+
862
+ def scrollWheel_(self, event):
863
+ try:
864
+ outer = self.enclosingScrollView()
865
+ if outer is not None:
866
+ win = self.window()
867
+ fr = win.firstResponder() if win is not None else None
868
+ if fr is None or fr is not self.documentView():
869
+ outer.scrollWheel_(event)
870
+ return
871
+ except Exception:
872
+ pass
873
+ objc.super(_TileScroll, self).scrollWheel_(event)
874
+
875
+
804
876
  def _editable_scroll(frame, text=""):
805
877
  """Rounded-rect scrollable text editor (hairline border, solid text
806
878
  background over the frosted panel; falls back to the old square bezel when
@@ -809,7 +881,7 @@ def _editable_scroll(frame, text=""):
809
881
  outer frame the text runs underneath the scroller. The scroller itself
810
882
  auto-hides so it only appears when the text overflows.
811
883
  Returns (scroll, textview)."""
812
- scroll = NSScrollView.alloc().initWithFrame_(frame)
884
+ scroll = _TileScroll.alloc().initWithFrame_(frame)
813
885
  scroll.setHasVerticalScroller_(True)
814
886
  scroll.setAutohidesScrollers_(True)
815
887
  if _round_rect(scroll):
@@ -902,6 +974,7 @@ class _ReviewController(NSObject):
902
974
  self._eye_btn = None
903
975
  self._details_btn = None
904
976
  self._stats_popover = None
977
+ self._pending_hover_kind = None
905
978
  self._age_expiry_label = None
906
979
  self._age_expiry_timer = None
907
980
  # Per-card telemetry, reset when a NEW card renders (not on the
@@ -1378,6 +1451,7 @@ class _ReviewController(NSObject):
1378
1451
  _label(NSMakeRect(M, H - 70, 78, 18), "Replying to", size=12, bold=True, muted=True)
1379
1452
  )
1380
1453
  right_x = W - M
1454
+ self._cancel_pending_hover()
1381
1455
  self._close_stats_popover()
1382
1456
  self._stop_age_expiry_timer()
1383
1457
  self._eye_btn = None
@@ -1511,7 +1585,7 @@ class _ReviewController(NSObject):
1511
1585
  # direction); the box scrolls instead of shrinking the text to fit,
1512
1586
  # and the link sits at the START so it's always visible regardless
1513
1587
  # of scroll position or thread length.
1514
- thread_scroll = NSScrollView.alloc().initWithFrame_(
1588
+ thread_scroll = _TileScroll.alloc().initWithFrame_(
1515
1589
  NSMakeRect(M, H - 150, W - 2 * M, 74)
1516
1590
  )
1517
1591
  thread_scroll.setHasVerticalScroller_(True)
@@ -1544,7 +1618,19 @@ class _ReviewController(NSObject):
1544
1618
  pass
1545
1619
  if thread_url:
1546
1620
  thread_tv.setDelegate_(self)
1547
- _set_thread_text(thread_tv, thread_en or d.get("thread_text"), thread_url)
1621
+ # Reddit (2026-07-17 user direction): the thread has a TITLE and a
1622
+ # BODY (selftext). Render the title bold with the body beneath it in
1623
+ # this same quote box. thread_text carries the title (see
1624
+ # merge_review_queue._reddit_plan_to_candidates); thread_selftext is
1625
+ # the body, empty for link posts / title-only threads (then this falls
1626
+ # through to the plain title-only render). Twitter has no selftext, so
1627
+ # it always takes the else branch and renders exactly as before.
1628
+ _thread_title = thread_en or d.get("thread_text")
1629
+ _thread_body = (d.get("thread_selftext") or "").strip() if _plat == "reddit" else ""
1630
+ if _thread_body:
1631
+ _set_thread_text(thread_tv, _thread_body, thread_url, title=_thread_title)
1632
+ else:
1633
+ _set_thread_text(thread_tv, _thread_title, thread_url)
1548
1634
  thread_scroll.setDocumentView_(thread_tv)
1549
1635
  content.addSubview_(thread_scroll)
1550
1636
  # Reply heading — bold. A concise "project/lane · viral N" tag rides
@@ -1847,10 +1933,12 @@ class _ReviewController(NSObject):
1847
1933
  pop.setBehavior_(NSPopoverBehaviorApplicationDefined)
1848
1934
  pop.setContentViewController_(vc)
1849
1935
  pop.setContentSize_((pw, ph))
1850
- try:
1851
- NSApp.activateIgnoringOtherApps_(True)
1852
- except Exception:
1853
- pass
1936
+ # No activateIgnoringOtherApps_ here (removed 2026-07-16): with
1937
+ # ApplicationDefined behavior the popover shows fine from an inactive
1938
+ # accessory app, and yanking app activation on a HOVER (these popovers
1939
+ # open from mouseEntered_) stole focus from whatever the user was
1940
+ # doing -- worst on the canvas, where scrolling sweeps anchors under
1941
+ # the pointer.
1854
1942
  # Anchor to the eye's frame in the (non-flipped) content view, where
1855
1943
  # NSRectEdge 1 = NSMinYEdge is unambiguously the BOTTOM edge, so the
1856
1944
  # popover reliably opens below the icon. Anchoring to the button's own
@@ -1948,13 +2036,18 @@ class _ReviewController(NSObject):
1948
2036
  if kind == "draft":
1949
2037
  self._draft_hover_open[slot] = time.time()
1950
2038
  return
1951
- _log(f"{kind} eye hover enter" if kind != "expiry" else "expiry label hover enter")
1952
- if kind == "details":
1953
- self._show_details_popover()
1954
- elif kind == "expiry":
1955
- self._show_expiry_popover()
1956
- else:
1957
- self._show_stats_popover()
2039
+ # Dwell-gated (2026-07-16 canvas scroll-perf fix): scrolling the
2040
+ # canvas sweeps these tracking areas under a stationary pointer,
2041
+ # and opening a popover synchronously per enter event (text
2042
+ # measurement + NSPopover build) hitched the scroll. A short
2043
+ # delayed perform only fires if the pointer actually STAYS on the
2044
+ # anchor; enter/exit pairs from content moving underneath cancel
2045
+ # out. Bonus: the delayed perform is queued in the default run-loop
2046
+ # mode, which does not run during a wheel gesture (event-tracking
2047
+ # mode), so a popover can never open mid-scroll at all.
2048
+ self._cancel_pending_hover()
2049
+ self._pending_hover_kind = kind
2050
+ self.performSelector_withObject_afterDelay_("showHoverPopover:", kind, 0.2)
1958
2051
 
1959
2052
  def mouseExited_(self, event):
1960
2053
  kind, slot = self._hover_info(event)
@@ -1965,9 +2058,41 @@ class _ReviewController(NSObject):
1965
2058
  (time.time() - started) * 1000
1966
2059
  )
1967
2060
  return
1968
- _log("eye hover exit")
2061
+ self._cancel_pending_hover()
1969
2062
  self._close_stats_popover()
1970
2063
 
2064
+ @objc.python_method
2065
+ def _cancel_pending_hover(self):
2066
+ pending = getattr(self, "_pending_hover_kind", None)
2067
+ if pending is not None:
2068
+ try:
2069
+ NSObject.cancelPreviousPerformRequestsWithTarget_selector_object_(
2070
+ self, "showHoverPopover:", pending
2071
+ )
2072
+ except Exception:
2073
+ pass
2074
+ self._pending_hover_kind = None
2075
+
2076
+ def showHoverPopover_(self, kind):
2077
+ """Delayed-perform target for a dwelled hover (see mouseEntered_)."""
2078
+ self._pending_hover_kind = None
2079
+ kind = str(kind)
2080
+ try:
2081
+ _log(
2082
+ f"{kind} eye hover enter" if kind != "expiry" else "expiry label hover enter"
2083
+ )
2084
+ if kind == "details":
2085
+ self._show_details_popover()
2086
+ elif kind == "expiry":
2087
+ self._show_expiry_popover()
2088
+ else:
2089
+ self._show_stats_popover()
2090
+ except Exception:
2091
+ # The card may have re-rendered (anchor replaced) between the
2092
+ # hover and the dwell firing; a popover that can't anchor is
2093
+ # simply skipped.
2094
+ pass
2095
+
1971
2096
  @objc.python_method
1972
2097
  def _flush_draft_hovers(self):
1973
2098
  """Bank any hover still in progress (pointer inside a draft box at
@@ -2481,6 +2606,7 @@ class _ReviewController(NSObject):
2481
2606
  @objc.python_method
2482
2607
  def _finish(self):
2483
2608
  global _active
2609
+ self._cancel_pending_hover()
2484
2610
  self._close_stats_popover()
2485
2611
  self._stop_age_expiry_timer()
2486
2612
  if self._host_view is not None:
@@ -62,17 +62,24 @@ from Foundation import NSObject, NSMakeRect, NSMakeSize
62
62
  from AppKit import (
63
63
  NSApp,
64
64
  NSButton,
65
+ NSColor,
65
66
  NSFont,
66
67
  NSScrollView,
67
68
  NSView,
68
69
  NSWindowOcclusionStateVisible,
69
70
  NSBackingStoreBuffered,
70
71
  NSNormalWindowLevel,
72
+ NSFloatingWindowLevel,
71
73
  NSApplicationActivationPolicyAccessory,
72
74
  NSWindowStyleMaskTitled,
73
75
  NSWindowStyleMaskClosable,
74
76
  NSWindowStyleMaskUtilityWindow,
77
+ NSWindowStyleMaskResizable,
75
78
  NSBezelStyleRounded,
79
+ NSViewWidthSizable,
80
+ NSViewHeightSizable,
81
+ NSViewMinXMargin,
82
+ NSViewMinYMargin,
76
83
  )
77
84
 
78
85
  # Reuse the corner card's panel class (Cmd+V/C/X/A/Z routing for a status-bar
@@ -84,7 +91,6 @@ from s4l_card import (
84
91
  _ReviewPanel,
85
92
  _ReviewController,
86
93
  _label,
87
- _frosted,
88
94
  _round_rect,
89
95
  _fill_color,
90
96
  _mouse_screen,
@@ -207,6 +213,7 @@ class _CanvasController(NSObject):
207
213
  NSWindowStyleMaskTitled
208
214
  | NSWindowStyleMaskClosable
209
215
  | NSWindowStyleMaskUtilityWindow
216
+ | NSWindowStyleMaskResizable
210
217
  | NSWindowStyleMaskNonactivatingPanel
211
218
  )
212
219
  panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
@@ -229,19 +236,69 @@ class _CanvasController(NSObject):
229
236
  panel.setBecomesKeyOnlyIfNeeded_(True)
230
237
  panel.setHidesOnDeactivate_(False)
231
238
  panel.setReleasedWhenClosed_(False)
239
+ # Don't let a resize (user drag or heal_active moving to a smaller
240
+ # screen) shrink below one column + the header.
241
+ try:
242
+ panel.setContentMinSize_(NSMakeSize(TILE_W + 2 * WIN_MARGIN + 40, 420))
243
+ except Exception:
244
+ pass
232
245
  panel.setDelegate_(self)
233
246
  self._panel = panel
234
247
  self._render()
235
248
  panel.orderFrontRegardless()
236
249
  self._log_surface("presented")
237
250
  if self._focus:
251
+ self.raise_to_front()
252
+
253
+ @objc.python_method
254
+ def raise_to_front(self):
255
+ """Reliably bring the canvas above the current front app ONCE. The panel
256
+ is a NON-activating panel at NSNormalWindowLevel (so it behaves like a
257
+ normal window the reviewer works IN, per 2026-07-16), which means a plain
258
+ makeKeyAndOrderFront on our accessory app can leave it BEHIND the
259
+ frontmost app: window-level ordering, not activation, decides what covers
260
+ what, and a normal-level window can't jump above another normal-level
261
+ window that belongs to the active app. That is exactly the 2026-07-17
262
+ Nhat report -- the canvas 'presented' occluded ("didn't surface"). Fix:
263
+ briefly promote to NSFloatingWindowLevel so orderFrontRegardless is
264
+ guaranteed to place it in front regardless of activation, then demote
265
+ back to normal a beat later so other apps can cover it again (keeping the
266
+ "work IN it like a normal window" posture; always-on-top is the corner
267
+ card's job, not this one)."""
268
+ panel = self._panel
269
+ if panel is None:
270
+ return
271
+ try:
272
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
273
+ NSApp.activateIgnoringOtherApps_(True)
274
+ except Exception:
275
+ pass
276
+ try:
277
+ panel.setLevel_(NSFloatingWindowLevel)
278
+ except Exception:
279
+ pass
280
+ try:
281
+ panel.makeKeyAndOrderFront_(None)
282
+ panel.orderFrontRegardless()
283
+ except Exception:
284
+ pass
285
+ # Drop back to a normal window level once it's on screen. performSelector
286
+ # keeps it above other windows just long enough to land in front; if
287
+ # scheduling ever fails, demote inline so it can never stay stuck on top.
288
+ try:
289
+ self.performSelector_withObject_afterDelay_("demoteWindowLevel:", None, 0.6)
290
+ except Exception:
238
291
  try:
239
- NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
240
- NSApp.activateIgnoringOtherApps_(True)
292
+ panel.setLevel_(NSNormalWindowLevel)
241
293
  except Exception:
242
294
  pass
243
- panel.makeKeyAndOrderFront_(None)
244
- panel.orderFrontRegardless()
295
+
296
+ def demoteWindowLevel_(self, _):
297
+ try:
298
+ if self._panel is not None:
299
+ self._panel.setLevel_(NSNormalWindowLevel)
300
+ except Exception:
301
+ pass
245
302
 
246
303
  @objc.python_method
247
304
  def _render(self):
@@ -259,6 +316,12 @@ class _CanvasController(NSObject):
259
316
  approve_x = w - WIN_MARGIN - btn_w
260
317
  discard_x = approve_x - btn_gap - btn_w
261
318
 
319
+ # Header widgets pin to the TOP edge (flexible bottom margin) so a
320
+ # window-frame change after build -- heal_active recentering on a
321
+ # different-sized screen, or a user resize -- can never clip the
322
+ # header under the title bar again (2026-07-16 user report: "the top
323
+ # bar gets stuck"; layout was built once and never followed the
324
+ # frame). Buttons additionally pin RIGHT (flexible left margin).
262
325
  discard_btn = NSButton.alloc().initWithFrame_(NSMakeRect(discard_x, btn_y, btn_w, btn_h))
263
326
  discard_btn.setTitle_("Discard All")
264
327
  discard_btn.setBezelStyle_(NSBezelStyleRounded)
@@ -269,6 +332,7 @@ class _CanvasController(NSObject):
269
332
  discard_btn.setFont_(NSFont.systemFontOfSize_(12))
270
333
  discard_btn.setTarget_(self)
271
334
  discard_btn.setAction_("discardAllClicked:")
335
+ discard_btn.setAutoresizingMask_(NSViewMinXMargin | NSViewMinYMargin)
272
336
  content.addSubview_(discard_btn)
273
337
 
274
338
  approve_btn = NSButton.alloc().initWithFrame_(NSMakeRect(approve_x, btn_y, btn_w, btn_h))
@@ -277,6 +341,7 @@ class _CanvasController(NSObject):
277
341
  approve_btn.setFont_(NSFont.systemFontOfSize_(12))
278
342
  approve_btn.setTarget_(self)
279
343
  approve_btn.setAction_("approveAllClicked:")
344
+ approve_btn.setAutoresizingMask_(NSViewMinXMargin | NSViewMinYMargin)
280
345
  content.addSubview_(approve_btn)
281
346
 
282
347
  self._count_label = _label(
@@ -285,6 +350,7 @@ class _CanvasController(NSObject):
285
350
  size=13,
286
351
  bold=True,
287
352
  )
353
+ self._count_label.setAutoresizingMask_(NSViewWidthSizable | NSViewMinYMargin)
288
354
  content.addSubview_(self._count_label)
289
355
 
290
356
  body_y = WIN_MARGIN
@@ -295,13 +361,29 @@ class _CanvasController(NSObject):
295
361
  )
296
362
  scroll.setHasVerticalScroller_(True)
297
363
  scroll.setAutohidesScrollers_(True)
298
- scroll.setDrawsBackground_(False)
364
+ # OPAQUE background, deliberately (2026-07-16 scroll-perf fix): a
365
+ # transparent scroll view can't blit already-drawn pixels on scroll
366
+ # (copy-on-scroll and responsive-scrolling overdraw are both off for
367
+ # it), so every wheel tick re-rendered the entire visible grid --
368
+ # dozens of tiles, each with 2-3 nested text views -- and scrolling
369
+ # visibly lagged. Same reason the canvas window skips the corner
370
+ # card's _frosted vibrancy underlay below: compositing the whole
371
+ # grid over a live behind-window blur was most of the per-frame
372
+ # cost, for an effect barely visible behind a full grid of tiles.
373
+ scroll.setDrawsBackground_(True)
374
+ try:
375
+ scroll.setBackgroundColor_(NSColor.windowBackgroundColor())
376
+ except Exception:
377
+ pass
378
+ scroll.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable)
299
379
  content.addSubview_(scroll)
300
380
  self._scroll = scroll
301
381
 
302
382
  self._build_grid(scroll)
303
383
 
304
- self._panel.setContentView_(_frosted(content))
384
+ # Plain content view, no _frosted wrapper (see the opaque-background
385
+ # comment above). NSPanel's own windowBackgroundColor fills the rest.
386
+ self._panel.setContentView_(content)
305
387
  self._refresh_header()
306
388
 
307
389
  # ---- grid layout ---------------------------------------------------------
@@ -325,6 +407,15 @@ class _CanvasController(NSObject):
325
407
  # visible in the window, we need a scroll bar").
326
408
  self._cols = max(1, min(GRID_COLS, int((self._doc_w + GRID_GAP) // (TILE_W + GRID_GAP))))
327
409
  doc = _FlippedView.alloc().initWithFrame_(NSMakeRect(0, 0, self._doc_w, cs.height))
410
+ # Layer-backed explicitly: scrolling then moves composited layers
411
+ # instead of re-drawing views through the window backing store,
412
+ # which with this many tiles is the difference between smooth and
413
+ # laggy (2026-07-16 scroll-perf fix; pairs with the opaque scroll
414
+ # background in _render).
415
+ try:
416
+ doc.setWantsLayer_(True)
417
+ except Exception:
418
+ pass
328
419
  self._doc = doc
329
420
  self._slots = []
330
421
  scroll.setDocumentView_(doc)
@@ -411,6 +502,15 @@ class _CanvasController(NSObject):
411
502
  self._reflow_from(idx)
412
503
  self._resize_doc()
413
504
  self._refresh_header()
505
+ # Last card decided -> nothing left to review, so close the canvas
506
+ # instead of leaving an empty "0 pending drafts" window on screen.
507
+ # _finish() fires on_complete with every decision made this session,
508
+ # exactly like the corner card auto-finishing past its final draft
509
+ # (s4l_card._advance). Reached from a tile completing (approve/reject)
510
+ # and from prune_drafts draining the last candidate; either way an
511
+ # empty canvas has no reason to stay up.
512
+ if not self._order and self._panel is not None:
513
+ self._finish()
414
514
 
415
515
  # Approve All / Discard All buttons live in the header row (see
416
516
  # _render()), not a dedicated card -- these two actions just target the
@@ -571,6 +671,30 @@ class _CanvasController(NSObject):
571
671
  )
572
672
  _write_review_state(controller=self, last_event=event)
573
673
 
674
+ @objc.python_method
675
+ def _relayout_grid(self):
676
+ """Recompute geometry after the window's frame changed (user resize,
677
+ or heal_active recentering on a different-sized screen): column
678
+ count and slot POSITIONS only -- tiles are subviews of their slot
679
+ views and move with them, so in-progress edits survive a resize
680
+ untouched (unlike _reflow_from, which rebuilds content)."""
681
+ if self._scroll is None or self._doc is None:
682
+ return
683
+ cs = self._scroll.contentSize()
684
+ self._doc_w = cs.width
685
+ self._cols = max(
686
+ 1, min(GRID_COLS, int((self._doc_w + GRID_GAP) // (TILE_W + GRID_GAP)))
687
+ )
688
+ for i, slot in enumerate(self._slots):
689
+ slot["view"].setFrame_(self._slot_frame(i))
690
+ self._resize_doc()
691
+
692
+ def windowDidResize_(self, notification):
693
+ try:
694
+ self._relayout_grid()
695
+ except Exception as e:
696
+ _log(f"canvas relayout failed: {e}")
697
+
574
698
  def windowDidMove_(self, notification):
575
699
  now = time.time()
576
700
  if now - self._last_move_log < 1.0:
@@ -676,13 +800,10 @@ def focus_active():
676
800
  if c is None or c._panel is None:
677
801
  return False
678
802
  try:
679
- NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
680
- NSApp.activateIgnoringOtherApps_(True)
681
- except Exception:
682
- pass
683
- try:
684
- c._panel.makeKeyAndOrderFront_(None)
685
- c._panel.orderFrontRegardless()
803
+ # Same reliable front-raise as the initial focused present: a brief
804
+ # window-level promotion so an already-open canvas comes above the front
805
+ # app even though it is a non-activating normal-level panel.
806
+ c.raise_to_front()
686
807
  c._panel.makeMainWindow()
687
808
  return True
688
809
  except Exception: