@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.
- package/mcp/dist/index.js +256 -92
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +144 -18
- package/mcp/menubar/s4l_card_canvas.py +135 -14
- package/mcp/menubar/s4l_menubar.py +216 -73
- package/mcp/menubar/s4l_state.py +45 -130
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +35 -1
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/draft_prompt_core.py +100 -17
- package/scripts/engagement_styles.py +135 -98
- package/scripts/merge_review_queue.py +192 -129
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/post_reddit.py +10 -0
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/reap_stale_claude_sessions.py +7 -6
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/reddit_browser.py +24 -0
- package/scripts/release-mcpb.sh +38 -7
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/store_patch.py +111 -0
- package/scripts/strike_alert.py +161 -12
- package/scripts/test_no_silent_fallbacks.py +11 -0
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/skill/archive-old-logs.sh +24 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-twitter-cycle.sh +146 -66
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
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
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
NSApp.activateIgnoringOtherApps_(True)
|
|
292
|
+
panel.setLevel_(NSNormalWindowLevel)
|
|
241
293
|
except Exception:
|
|
242
294
|
pass
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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:
|