@m13v/s4l 1.7.1-rc.2 → 1.7.1-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 (45) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +332 -24
  3. package/mcp/dist/onboarding.js +3 -10
  4. package/mcp/dist/panel.html +42 -31
  5. package/mcp/dist/product-link.html +1 -1
  6. package/mcp/dist/runtime.js +101 -1
  7. package/mcp/dist/telemetry.js +80 -3
  8. package/mcp/dist/version.json +2 -2
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +472 -96
  12. package/mcp/menubar/s4l_menubar.py +269 -26
  13. package/mcp/menubar/s4l_state.py +169 -23
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/mcp/shared/onboarding-ledger.cjs +1 -0
  17. package/package.json +3 -1
  18. package/scripts/active_users.py +3 -6
  19. package/scripts/autopilot_stall_watch.py +200 -16
  20. package/scripts/claude_job.py +6 -0
  21. package/scripts/feedback_digest.py +34 -6
  22. package/scripts/get-latest-staging-mcpb.sh +50 -0
  23. package/scripts/harness_overlay.py +45 -0
  24. package/scripts/identity.py +26 -0
  25. package/scripts/learned_preferences.py +241 -32
  26. package/scripts/link_tail.py +167 -91
  27. package/scripts/mark_event.py +128 -0
  28. package/scripts/memory_snapshot.py +62 -0
  29. package/scripts/pick_twitter_thread_target.py +1 -1
  30. package/scripts/release-mcpb.sh +48 -0
  31. package/scripts/reset-test-machine.sh +85 -3
  32. package/scripts/s4l_mode.py +7 -5
  33. package/scripts/scheduled_tasks_snapshot.py +107 -0
  34. package/scripts/send_gmail_report.py +76 -0
  35. package/scripts/sentry_digest.py +81 -189
  36. package/scripts/sentry_init.py +43 -15
  37. package/scripts/setup_twitter_auth.py +76 -38
  38. package/scripts/snapshot.py +20 -0
  39. package/scripts/twitter_post_plan.py +39 -13
  40. package/skill/dm-outreach-twitter.sh +19 -0
  41. package/skill/engage-twitter.sh +27 -0
  42. package/skill/run-draft-and-publish.sh +13 -12
  43. package/skill/run-linkedin.sh +9 -1
  44. package/skill/run-twitter-cycle.sh +199 -27
  45. package/skill/sentry-digest.sh +125 -12
@@ -82,6 +82,7 @@ from AppKit import (
82
82
  NSWindowStyleMaskClosable,
83
83
  NSWindowStyleMaskUtilityWindow,
84
84
  NSLineBreakByWordWrapping,
85
+ NSLineBreakByTruncatingTail,
85
86
  NSBezelStyleRounded,
86
87
  NSFontAttributeName,
87
88
  NSForegroundColorAttributeName,
@@ -103,12 +104,34 @@ from AppKit import (
103
104
  NSViewWidthSizable,
104
105
  )
105
106
 
107
+ # Styling extras that may be missing on older AppKit; every consumer degrades
108
+ # to the stock look when these are None (same posture as the SF Symbols
109
+ # fallback in _eye_button).
110
+ try:
111
+ from AppKit import NSFontWeightSemibold
112
+ except Exception:
113
+ NSFontWeightSemibold = None
114
+ try:
115
+ from AppKit import NSVisualEffectView
116
+ except Exception:
117
+ NSVisualEffectView = None
118
+ try:
119
+ # Registers the CGColor bridge type; without it every NSColor.CGColor()
120
+ # call in _round_rect logs an ObjCPointerWarning to menubar.err.log.
121
+ import Quartz # noqa: F401
122
+ except Exception:
123
+ pass
124
+
106
125
  # Strong reference to the live controller so pyobjc doesn't GC it mid-review
107
126
  # (the classic "button click crashes" footgun).
108
127
  _active = None
109
128
 
110
129
  W = 380
111
- H = 300
130
+ # 2026-07-08: raised 300 -> 420 so two-draft cards can show both drafts at
131
+ # full, comfortable editing height simultaneously (see _render()'s dual
132
+ # branch); single-draft cards get the same extra room, which is a net
133
+ # improvement there too, not a regression.
134
+ H = 420
112
135
  M = 16
113
136
  NS_BEZEL_BORDER = 2 # NSBezelBorder
114
137
 
@@ -298,15 +321,52 @@ def _engagement_line(stats):
298
321
  return " · ".join(parts)
299
322
 
300
323
 
324
+ def _reply_heading_suffix(d):
325
+ """Concise 'project/lane · viral N' readout appended straight onto the
326
+ 'Reply (editable):' heading (2026-07-07), so the two facts a reviewer
327
+ checks first don't require opening the details eye. Pulled from the same
328
+ fields _details_lines used to render as 'Project:'/'Lane:'/'Virality
329
+ score:' rows; those three are omitted from the popover now to avoid
330
+ showing the same numbers twice. Empty string when neither is known."""
331
+ d = d or {}
332
+ project = (d.get("project") or "").strip()
333
+ lane = ((d.get("experiments") or {}).get("lane") or "").strip()
334
+ bits = []
335
+ # Project and lane are independent concepts most of the time (project
336
+ # "fazm" drafted under lane "personal_brand"), but the operator's own
337
+ # PersonalBrand project IS the personal_brand lane, so project=="PersonalBrand"
338
+ # + lane=="personal_brand" is the SAME fact twice, not two facts — compare
339
+ # with punctuation/case stripped so "PersonalBrand" vs "personal_brand"
340
+ # collapses to one token instead of "PersonalBrand/personal_brand".
341
+ same = project and lane and re.sub(r"[^a-z0-9]", "", project.lower()) == re.sub(
342
+ r"[^a-z0-9]", "", lane.lower()
343
+ )
344
+ if same:
345
+ bits.append(project)
346
+ elif project and lane:
347
+ bits.append(f"{project}/{lane}")
348
+ elif project:
349
+ bits.append(project)
350
+ elif lane:
351
+ bits.append(lane)
352
+ v = (d.get("stats") or {}).get("virality_score")
353
+ if v is not None:
354
+ try:
355
+ bits.append(f"viral {float(v):g}")
356
+ except (TypeError, ValueError):
357
+ pass
358
+ return " · ".join(bits)
359
+
360
+
301
361
  def _details_lines(d):
302
362
  """Drafting metadata for the reply row's eye popover: how this draft came
303
- to be (project routing, engagement style, discovery query, link choice).
304
- Everything comes from fields the plan candidate already carries; fields the
305
- pipeline didn't stamp are omitted, and an empty list skips the icon."""
363
+ to be (engagement style, discovery query, link choice). Project, lane,
364
+ and virality score live inline on the heading itself (_reply_heading_suffix)
365
+ and are deliberately NOT repeated here. Everything else comes from fields
366
+ the plan candidate already carries; fields the pipeline didn't stamp are
367
+ omitted, and an empty list skips the icon."""
306
368
  d = d or {}
307
369
  lines = []
308
- if d.get("project"):
309
- lines.append(f"Project: {d['project']}")
310
370
  style = (d.get("engagement_style") or "").strip()
311
371
  if style:
312
372
  lines.append(f"Style: {style}")
@@ -332,10 +392,10 @@ def _details_lines(d):
332
392
  def _exp_describe(_name, _variant):
333
393
  return None
334
394
  for name in sorted(exps):
335
- label = (
336
- "Lane" if name == "lane"
337
- else f"Experiment {str(name).replace('_', ' ')}"
338
- )
395
+ if name == "lane":
396
+ # Already surfaced inline on the heading via _reply_heading_suffix.
397
+ continue
398
+ label = f"Experiment {str(name).replace('_', ' ')}"
339
399
  line = f"{label}: {exps[name]}"
340
400
  exp_desc = _exp_describe(name, exps[name])
341
401
  if exp_desc:
@@ -366,12 +426,6 @@ def _details_lines(d):
366
426
  lines.append(f"Link: {kw}" if kw else f"Link: {d['link_url']}")
367
427
  elif (d.get("link_source") or "").strip():
368
428
  lines.append(f"Link: none ({d['link_source'].strip().replace('_', ' ')})")
369
- v = (d.get("stats") or {}).get("virality_score")
370
- if v is not None:
371
- try:
372
- lines.append(f"Virality score: {float(v):g}")
373
- except (TypeError, ValueError):
374
- pass
375
429
  return lines
376
430
 
377
431
 
@@ -398,18 +452,95 @@ def _age_str(iso):
398
452
  return f"{hours // 24}d"
399
453
 
400
454
 
401
- def _label(frame, text, *, size=12, bold=False, muted=False):
455
+ # ---- contemporary styling helpers --------------------------------------------
456
+ # Style-only layer (2026-07-07): frames, sizes, and control positions are
457
+ # untouched; these helpers change nothing but the skin. The card reads as a
458
+ # modern frosted macOS panel (vibrancy background, quiet semibold section
459
+ # labels, a quote-style fill behind the thread text, a rounded-rect reply
460
+ # editor, destructive-red reject) instead of the stock square-bezel utility
461
+ # look. Every hook degrades to the stock look on AppKit versions that lack
462
+ # the API, so none of these try/excepts may be "simplified" away.
463
+
464
+
465
+ def _font(size, bold=False):
466
+ """Semibold (not heavy bold) for emphasis, matching modern macOS forms.
467
+ Used by BOTH _label and _add_link so link-width measurement in _render
468
+ stays in sync with the rendered title font."""
469
+ if bold and NSFontWeightSemibold is not None:
470
+ try:
471
+ return NSFont.systemFontOfSize_weight_(size, NSFontWeightSemibold)
472
+ except Exception:
473
+ pass
474
+ return NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
475
+
476
+
477
+ def _frosted(content):
478
+ """Wrap a content view in a behind-window vibrancy underlay (the frosted
479
+ look of Notification Center widgets). Same frame as the content, so
480
+ nothing moves. Bare view when NSVisualEffectView is unavailable."""
481
+ if NSVisualEffectView is None:
482
+ return content
483
+ try:
484
+ v = NSVisualEffectView.alloc().initWithFrame_(content.frame())
485
+ v.setMaterial_(6) # NSVisualEffectMaterialPopover: adaptive light/dark
486
+ v.setBlendingMode_(0) # behind-window
487
+ v.setState_(1) # active even while this accessory app is inactive
488
+ v.addSubview_(content)
489
+ return v
490
+ except Exception:
491
+ return content
492
+
493
+
494
+ def _fill_color():
495
+ """Subtle adaptive fill for the thread quote block."""
496
+ try:
497
+ return NSColor.quaternarySystemFillColor()
498
+ except Exception:
499
+ return NSColor.labelColor().colorWithAlphaComponent_(0.06)
500
+
501
+
502
+ def _round_rect(view, *, border=True):
503
+ """Rounded-rect skin: 8px corners, optional hairline border. Returns True
504
+ on success so callers can restore their square-bezel fallback when the
505
+ layer API is unavailable. Border color is resolved to a CGColor at render
506
+ time; a mid-card system appearance flip keeps the stale shade until the
507
+ next card renders, which is acceptable for a short-lived panel."""
508
+ try:
509
+ view.setWantsLayer_(True)
510
+ layer = view.layer()
511
+ layer.setCornerRadius_(8.0)
512
+ layer.setMasksToBounds_(True)
513
+ if border:
514
+ layer.setBorderWidth_(1.0)
515
+ layer.setBorderColor_(NSColor.separatorColor().CGColor())
516
+ return True
517
+ except Exception:
518
+ return False
519
+
520
+
521
+ def _label(frame, text, *, size=12, bold=False, muted=False, truncates=False):
402
522
  f = NSTextField.alloc().initWithFrame_(frame)
403
523
  f.setStringValue_(text or "")
404
524
  f.setBezeled_(False)
405
525
  f.setDrawsBackground_(False)
406
526
  f.setEditable_(False)
407
527
  f.setSelectable_(False)
408
- f.setFont_(
409
- NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
410
- )
528
+ f.setFont_(_font(size, bold))
411
529
  if muted:
412
530
  f.setTextColor_(NSColor.secondaryLabelColor())
531
+ # truncates=True: single line, "…" at the end when it overflows its frame,
532
+ # instead of word-wrapping into a second line the fixed-height frame then
533
+ # silently clips. For tight one-line strips (e.g. the reply heading's
534
+ # inline project/lane/virality tag) where an exact pixel width can't be
535
+ # guaranteed against every value.
536
+ if truncates:
537
+ f.setLineBreakMode_(NSLineBreakByTruncatingTail)
538
+ try:
539
+ f.cell().setWraps_(False)
540
+ f.cell().setScrollable_(False)
541
+ except Exception:
542
+ pass
543
+ return f
413
544
  f.setLineBreakMode_(NSLineBreakByWordWrapping)
414
545
  try:
415
546
  f.cell().setWraps_(True)
@@ -420,15 +551,25 @@ def _label(frame, text, *, size=12, bold=False, muted=False):
420
551
 
421
552
 
422
553
  def _editable_scroll(frame, text=""):
423
- """Bezel-bordered scrollable text editor. The document view must be sized
424
- to the scroll view's contentSize (NOT the outer frame) and track its width;
425
- sized to the outer frame the text runs underneath the scroller. The
426
- scroller itself auto-hides so it only appears when the text overflows.
554
+ """Rounded-rect scrollable text editor (hairline border, solid text
555
+ background over the frosted panel; falls back to the old square bezel when
556
+ layers are unavailable). The document view must be sized to the scroll
557
+ view's contentSize (NOT the outer frame) and track its width; sized to the
558
+ outer frame the text runs underneath the scroller. The scroller itself
559
+ auto-hides so it only appears when the text overflows.
427
560
  Returns (scroll, textview)."""
428
561
  scroll = NSScrollView.alloc().initWithFrame_(frame)
429
562
  scroll.setHasVerticalScroller_(True)
430
563
  scroll.setAutohidesScrollers_(True)
431
- scroll.setBorderType_(NS_BEZEL_BORDER)
564
+ if _round_rect(scroll):
565
+ scroll.setBorderType_(0) # NSNoBorder; the layer draws the outline
566
+ try:
567
+ scroll.setDrawsBackground_(True)
568
+ scroll.setBackgroundColor_(NSColor.textBackgroundColor())
569
+ except Exception:
570
+ pass
571
+ else:
572
+ scroll.setBorderType_(NS_BEZEL_BORDER)
432
573
  cs = scroll.contentSize()
433
574
  tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, cs.width, cs.height))
434
575
  tv.setFont_(NSFont.systemFontOfSize_(12))
@@ -442,6 +583,11 @@ def _editable_scroll(frame, text=""):
442
583
  tv.setAutoresizingMask_(NSViewWidthSizable)
443
584
  tv.textContainer().setWidthTracksTextView_(True)
444
585
  tv.textContainer().setContainerSize_(NSMakeSize(cs.width, 1e7))
586
+ try:
587
+ # Breathing room inside the rounded rect; text no longer hugs the border.
588
+ tv.setTextContainerInset_(NSMakeSize(4, 6))
589
+ except Exception:
590
+ pass
445
591
  if text:
446
592
  tv.setString_(text)
447
593
  scroll.setDocumentView_(tv)
@@ -470,6 +616,21 @@ class _ReviewController(NSObject):
470
616
  self._interactions = []
471
617
  self._card_shown_at = None
472
618
  self._reason_field = None
619
+ # Two-draft cards (2026-07-08 redesign, no-recommendation pass same
620
+ # day): both drafts render at once as separate editable boxes;
621
+ # `_selected_draft` (0=a, 1=b) is "whichever box the reviewer is
622
+ # currently in", driven by focus (see textDidBeginEditing_ below),
623
+ # not a button. None = not yet chosen this card, _render() defaults
624
+ # it to slot 0 (Draft A) — the model never picks a favorite, so
625
+ # there's no recommendation to default to instead. Reset to None on
626
+ # every NEW card (see _render()'s "fresh card" branch below), so a
627
+ # switch made on one card never bleeds into the next.
628
+ # _draft_textviews/_draft_scrolls map slot -> its NSTextView/
629
+ # NSScrollView so the delegate callback and border-styling helper can
630
+ # find them; both empty on single-draft cards.
631
+ self._selected_draft = None
632
+ self._draft_textviews = {}
633
+ self._draft_scrolls = {}
473
634
  # Attention anchors for the unattended-review watchdog: the stack counts
474
635
  # as "touched" on present, on any tracked interaction, and on any
475
636
  # decision. No touch past the watchdog threshold = the user is not
@@ -673,6 +834,7 @@ class _ReviewController(NSObject):
673
834
  self._rendered_idx = self._idx
674
835
  self._interactions = []
675
836
  self._card_shown_at = time.time()
837
+ self._selected_draft = None
676
838
  self._reason_field = None
677
839
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
678
840
 
@@ -680,8 +842,11 @@ class _ReviewController(NSObject):
680
842
  # row extending horizontally at the left (one click on an emoji =
681
843
  # approve at that strength), Reject at the right. Overall feedback
682
844
  # moved to the menu bar item.
845
+ # Section labels render quiet (semibold + secondary color): the modern
846
+ # macOS form look where chrome recedes and content (thread, draft)
847
+ # carries the full-strength label color.
683
848
  content.addSubview_(
684
- _label(NSMakeRect(M, H - 38, 60, 20), "Approve", size=12, bold=True)
849
+ _label(NSMakeRect(M, H - 38, 60, 20), "Approve", size=12, bold=True, muted=True)
685
850
  )
686
851
  x = M + 62
687
852
  for i, (emoji, tip) in enumerate(APPROVE_EMOJIS):
@@ -707,6 +872,10 @@ class _ReviewController(NSObject):
707
872
  reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
708
873
  reject.setTitle_("Reject")
709
874
  reject.setBezelStyle_(NSBezelStyleRounded)
875
+ try:
876
+ reject.setHasDestructiveAction_(True) # red title on macOS 12+
877
+ except Exception:
878
+ pass
710
879
  reject.setTarget_(self)
711
880
  reject.setAction_("reject:")
712
881
  content.addSubview_(reject)
@@ -722,7 +891,7 @@ class _ReviewController(NSObject):
722
891
  stats = d.get("stats") or {}
723
892
  thread_url = d.get("thread_url")
724
893
  content.addSubview_(
725
- _label(NSMakeRect(M, H - 70, 78, 18), "Replying to", size=12, bold=True)
894
+ _label(NSMakeRect(M, H - 70, 78, 18), "Replying to", size=12, bold=True, muted=True)
726
895
  )
727
896
  right_x = W - M
728
897
  self._close_stats_popover()
@@ -757,7 +926,7 @@ class _ReviewController(NSObject):
757
926
  # after the handle instead of at a fixed column.
758
927
  text = f"@{handle}"
759
928
  measured = NSAttributedString.alloc().initWithString_attributes_(
760
- text, {NSFontAttributeName: NSFont.boldSystemFontOfSize_(12)}
929
+ text, {NSFontAttributeName: _font(12, True)}
761
930
  ).size().width
762
931
  link_w = min(int(measured) + 8, handle_w)
763
932
  self._add_link(
@@ -781,7 +950,7 @@ class _ReviewController(NSObject):
781
950
  )
782
951
  else:
783
952
  content.addSubview_(
784
- _label(NSMakeRect(M + 78, H - 70, handle_w, 18), "thread", size=12, bold=True)
953
+ _label(NSMakeRect(M + 78, H - 70, handle_w, 18), "thread", size=12, bold=True, muted=True)
785
954
  )
786
955
  # Non-English drafts: the prep step stamps display-only English
787
956
  # translations (thread_text_en / reply_text_en) on the candidate. The
@@ -801,7 +970,18 @@ class _ReviewController(NSObject):
801
970
  )
802
971
  thread_tv.setEditable_(False)
803
972
  thread_tv.setSelectable_(True) # links only respond when selectable
804
- thread_tv.setDrawsBackground_(False)
973
+ # Quote-style block (subtle rounded fill, like a quoted tweet) so the
974
+ # thread reads as *their* content and the editor below as *ours*.
975
+ # Same frame as the old flat text; only the skin changed.
976
+ if _round_rect(thread_tv, border=False):
977
+ thread_tv.setDrawsBackground_(True)
978
+ thread_tv.setBackgroundColor_(_fill_color())
979
+ try:
980
+ thread_tv.setTextContainerInset_(NSMakeSize(6, 5))
981
+ except Exception:
982
+ pass
983
+ else:
984
+ thread_tv.setDrawsBackground_(False)
805
985
  # An NSTextView grows vertically by default; long threads inflated the
806
986
  # frame over the author row above (non-flipped superview: growth goes
807
987
  # UP) and pushed the trailing ↗ out of the box. Pin the frame and
@@ -831,23 +1011,59 @@ class _ReviewController(NSObject):
831
1011
  thread_tv.setDelegate_(self)
832
1012
  thread_tv.textStorage().setAttributedString_(body)
833
1013
  content.addSubview_(thread_tv)
834
- # Reply heading — black. Right-aligned on the same line: an eye icon
835
- # whose hover/click popover carries the DRAFTING metadata (project
836
- # routing, engagement style, discovery query, link choice), mirroring
837
- # the author row's engagement-stats eye. Skipped when the plan carries
838
- # none of it (older plans).
1014
+ # Reply heading — bold. A concise "project/lane · viral N" tag rides
1015
+ # right after it in a SEPARATE, regular-weight label (2026-07-08:
1016
+ # was folded into the same bold string, which made the metadata read
1017
+ # as loudly as the heading itself; split + measured-width placement
1018
+ # mirrors the "@handle · followers" pattern in the author row above).
1019
+ # Right-aligned on the same line: an eye icon whose hover/click
1020
+ # popover carries the remaining DRAFTING metadata (style, discovery
1021
+ # query, link choice), mirroring the author row's engagement-stats
1022
+ # eye. Skipped when the plan carries none of it (older plans).
1023
+ heading_text = (
1024
+ f"Reply (posts in {_lang_name(card_lang)}, editable):"
1025
+ if is_foreign
1026
+ else "Reply (editable):"
1027
+ )
1028
+ # +8 (not +4): NSTextField's own cellSize() for this text measures
1029
+ # ~4px wider than the raw NSAttributedString width used here, so a
1030
+ # tight +4 pad was landing sub-pixel under the real render width and
1031
+ # wrapping "(editable):" onto a clipped, invisible second line.
1032
+ # Matches the +8 pad the @handle link width uses below for the same
1033
+ # reason.
1034
+ heading_w = min(
1035
+ int(
1036
+ NSAttributedString.alloc().initWithString_attributes_(
1037
+ heading_text, {NSFontAttributeName: _font(12, True)}
1038
+ ).size().width
1039
+ ) + 8,
1040
+ W - 2 * M - 24,
1041
+ )
839
1042
  content.addSubview_(
840
1043
  _label(
841
- NSMakeRect(M, H - 172, W - 2 * M - 24, 16),
842
- (
843
- f"Reply (posts in {_lang_name(card_lang)}, editable):"
844
- if is_foreign
845
- else "Reply (editable):"
846
- ),
1044
+ NSMakeRect(M, H - 172, heading_w, 16),
1045
+ heading_text,
847
1046
  size=12,
848
1047
  bold=True,
1048
+ muted=True,
849
1049
  )
850
1050
  )
1051
+ # Long headings (foreign-language variant) can leave very little room
1052
+ # for the suffix; truncates=True ellipsizes it instead of the old
1053
+ # word-wrap silently clipping mid-word ("fazm/" with no "…") into the
1054
+ # fixed-height frame's invisible second line.
1055
+ heading_suffix = _reply_heading_suffix(d)
1056
+ suffix_w = W - 2 * M - 24 - heading_w
1057
+ if heading_suffix and suffix_w > 20:
1058
+ content.addSubview_(
1059
+ _label(
1060
+ NSMakeRect(M + heading_w, H - 172, suffix_w, 16),
1061
+ f" · {heading_suffix}",
1062
+ size=11,
1063
+ muted=True,
1064
+ truncates=True,
1065
+ )
1066
+ )
851
1067
  if _details_lines(d):
852
1068
  # No y nudge needed here (unlike the author row): this label is
853
1069
  # 16px, so an 18px button at the same y already centers its image
@@ -856,36 +1072,95 @@ class _ReviewController(NSObject):
856
1072
  content.addSubview_(deye)
857
1073
  self._details_btn = deye
858
1074
 
859
- # Editable reply, with the attached link folded in as it'll post (no
860
- # separate link field). The trailing link is stripped on send so the
861
- # pipeline still mints the tracked /r/<code> short link (no double link).
862
- reply = d.get("reply_text") or ""
863
- link = d.get("link_url")
864
- composed = f"{reply} {link}" if link else reply
865
- # Non-English draft with a stamped translation: a muted read-only
866
- # "EN:" block sits between the heading and the editable field so the
867
- # reviewer reads the draft in English while still editing (and
868
- # posting) the original-language text below. Full translation lives
869
- # in the details popover; inline is truncated to what ~3 lines fit.
1075
+ # Two-draft cards (2026-07-08 redesign; no-recommendation pass same
1076
+ # day): show BOTH drafts at once, stacked, each independently
1077
+ # editable, rather than a toggle that swaps one field's content. No
1078
+ # buttons: selection is "whichever box the reviewer is currently in",
1079
+ # shown via that box's own border (an accent outline vs a plain
1080
+ # hairline, mirroring a standard focus ring) and updated live by
1081
+ # textDidBeginEditing_ below. The model never picks a favorite
1082
+ # (removed 2026-07-08 per user: no ask-the-model-to-recommend), so
1083
+ # Draft A (slot 0) is simply the fixed default until the reviewer
1084
+ # clicks into Draft B. Absent/short (reused stale draft, legacy plan)
1085
+ # falls back to one full-height editable box, same as before.
1086
+ drafts = d.get("drafts")
1087
+ dual = isinstance(drafts, list) and len(drafts) == 2
1088
+ sel_idx = self._selected_draft if (dual and self._selected_draft in (0, 1)) else 0
1089
+ if dual:
1090
+ self._selected_draft = sel_idx
1091
+
870
1092
  edit_top = H - 172 - 6
871
- if reply_en:
872
- tr_h = 42
873
- content.addSubview_(
874
- _label(
875
- NSMakeRect(M, edit_top - tr_h, W - 2 * M, tr_h),
876
- f"EN: {_truncate(reply_en, 150)}",
877
- size=11,
878
- muted=True,
1093
+ link = d.get("link_url")
1094
+
1095
+ # Tail link baked at draft time is normally already in each draft's
1096
+ # text (scripts/twitter_gen_links.py + the drafts-link-sync step in
1097
+ # run-twitter-cycle.sh); only append link_url when a text is
1098
+ # genuinely link-free (older/fallback plans), else it'd show twice.
1099
+ def _compose(text):
1100
+ text = text or ""
1101
+ return text if (not link or link in text) else f"{text} {link}"
1102
+
1103
+ self._draft_textviews = {}
1104
+ self._draft_scrolls = {}
1105
+ if dual:
1106
+ avail_h = edit_top - M
1107
+ gap = 5
1108
+ label_h = 15
1109
+ unit = (avail_h - gap) / 2.0
1110
+ box_h = unit - label_h
1111
+ for slot in (0, 1):
1112
+ # slot 0 (Draft A) always renders on top, slot 1 (Draft B)
1113
+ # always on the bottom: a fixed, predictable order so cards
1114
+ # don't reshuffle as the reviewer switches between them.
1115
+ box_y = M if slot == 1 else M + unit + gap
1116
+ label_y = box_y + box_h
1117
+ draft = drafts[slot]
1118
+ draft_text_en = (draft.get("text_en") or "").strip()
1119
+ if is_foreign and draft_text_en:
1120
+ content.addSubview_(
1121
+ _label(
1122
+ NSMakeRect(M, label_y, W - 2 * M, label_h),
1123
+ f"EN: {_truncate(draft_text_en, 90)}",
1124
+ size=10,
1125
+ muted=True,
1126
+ truncates=True,
1127
+ )
1128
+ )
1129
+ scroll, tv = _editable_scroll(
1130
+ NSMakeRect(M, box_y, W - 2 * M, box_h), _compose(draft.get("text"))
879
1131
  )
1132
+ tv.setDelegate_(self)
1133
+ content.addSubview_(scroll)
1134
+ self._draft_scrolls[slot] = scroll
1135
+ self._draft_textviews[slot] = tv
1136
+ self._update_draft_borders()
1137
+ tv = self._draft_textviews[sel_idx]
1138
+ else:
1139
+ reply = d.get("reply_text") or ""
1140
+ # Non-English draft with a stamped translation: a muted read-only
1141
+ # "EN:" block sits between the heading and the editable field so
1142
+ # the reviewer reads the draft in English while still editing
1143
+ # (and posting) the original-language text below. Full
1144
+ # translation lives in the details popover; inline is truncated
1145
+ # to what ~3 lines fit.
1146
+ if reply_en:
1147
+ tr_h = 42
1148
+ content.addSubview_(
1149
+ _label(
1150
+ NSMakeRect(M, edit_top - tr_h, W - 2 * M, tr_h),
1151
+ f"EN: {_truncate(reply_en, 150)}",
1152
+ size=11,
1153
+ muted=True,
1154
+ )
1155
+ )
1156
+ edit_top -= tr_h + 4
1157
+ scroll, tv = _editable_scroll(
1158
+ NSMakeRect(M, M, W - 2 * M, edit_top - M), _compose(reply)
880
1159
  )
881
- edit_top -= tr_h + 4
882
- scroll, tv = _editable_scroll(
883
- NSMakeRect(M, M, W - 2 * M, edit_top - M), composed
884
- )
885
- content.addSubview_(scroll)
1160
+ content.addSubview_(scroll)
886
1161
  self._textview = tv
887
1162
 
888
- self._panel.setContentView_(content)
1163
+ self._panel.setContentView_(_frosted(content))
889
1164
  # Counter lives in the native title bar, not inside the content, with
890
1165
  # the product name so a stray card is identifiable at a glance.
891
1166
  self._panel.setTitle_(
@@ -1028,7 +1303,7 @@ class _ReviewController(NSObject):
1028
1303
  _link_targets so one openLink: selector serves every link on the card."""
1029
1304
  btn = NSButton.alloc().initWithFrame_(frame)
1030
1305
  btn.setBordered_(False)
1031
- font = NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
1306
+ font = _font(size, bold)
1032
1307
  attrs = {
1033
1308
  NSFontAttributeName: font,
1034
1309
  NSForegroundColorAttributeName: NSColor.linkColor(),
@@ -1067,38 +1342,94 @@ class _ReviewController(NSObject):
1067
1342
  pass
1068
1343
  return True
1069
1344
 
1345
+ # NSTextView/NSText delegate: fires when a text view becomes the one
1346
+ # being edited, i.e. right when it gains focus (click or tab), before any
1347
+ # keystroke. Two-draft cards use this as the ONLY selection mechanism:
1348
+ # whichever draft box the reviewer is in IS the selected one. Only the
1349
+ # two draft boxes set self as delegate for this notification (the
1350
+ # read-only thread quote never fires it), so no candidate_id lookup is
1351
+ # needed, just a slot match against self._draft_textviews.
1352
+ def textDidBeginEditing_(self, notification):
1353
+ try:
1354
+ tv = notification.object()
1355
+ except Exception:
1356
+ return
1357
+ for slot, cand_tv in (self._draft_textviews or {}).items():
1358
+ if cand_tv is tv and slot != self._selected_draft:
1359
+ self._selected_draft = slot
1360
+ self._textview = cand_tv
1361
+ self._update_draft_borders()
1362
+ break
1363
+
1364
+ @objc.python_method
1365
+ def _update_draft_borders(self):
1366
+ """Redraw the two draft boxes' borders in place (no re-render, so an
1367
+ in-progress edit/caret in either box is never disturbed): the
1368
+ selected box gets a thicker system-accent outline (the platform's own
1369
+ selection color, respects the user's macOS accent choice and
1370
+ light/dark mode), the other a plain hairline, matching the existing
1371
+ thread-quote/editable-field border look."""
1372
+ for slot, scroll in (self._draft_scrolls or {}).items():
1373
+ try:
1374
+ layer = scroll.layer()
1375
+ if layer is None:
1376
+ continue
1377
+ if slot == self._selected_draft:
1378
+ layer.setBorderWidth_(2.0)
1379
+ layer.setBorderColor_(NSColor.controlAccentColor().CGColor())
1380
+ else:
1381
+ layer.setBorderWidth_(1.0)
1382
+ layer.setBorderColor_(NSColor.separatorColor().CGColor())
1383
+ except Exception:
1384
+ pass
1385
+
1070
1386
  @objc.python_method
1071
1387
  def _current_text(self):
1072
1388
  try:
1073
1389
  return str(self._textview.string())
1074
1390
  except Exception:
1075
- return self._drafts[self._idx].get("reply_text") or ""
1391
+ d = self._drafts[self._idx]
1392
+ drafts = d.get("drafts")
1393
+ if isinstance(drafts, list) and len(drafts) == 2 and self._selected_draft in (0, 1):
1394
+ return drafts[self._selected_draft].get("text") or ""
1395
+ return d.get("reply_text") or ""
1076
1396
 
1077
1397
  @objc.python_method
1078
1398
  def _record(self, approved, reject_category=None, reject_note=None, loved=False):
1079
1399
  d = self._drafts[self._idx]
1080
- orig = (d.get("reply_text") or "").strip()
1400
+ # Two-draft cards: `orig` (what counts as "unedited") is whichever
1401
+ # draft is CURRENTLY SELECTED, not always the plan's default
1402
+ # reply_text. Otherwise picking the non-recommended draft without
1403
+ # further hand-editing would show up as `edited=True` with the
1404
+ # recommended draft as `original_text`, which would look exactly
1405
+ # like a human rewrite to the edit-learning digest and pollute it.
1406
+ drafts = d.get("drafts")
1407
+ dual = isinstance(drafts, list) and len(drafts) == 2
1408
+ sel_idx = self._selected_draft if (dual and self._selected_draft in (0, 1)) else None
1409
+ if sel_idx is not None:
1410
+ chosen_draft = drafts[sel_idx]
1411
+ orig = (chosen_draft.get("text") or "").strip()
1412
+ draft_variant = chosen_draft.get("variant") or ("a" if sel_idx == 0 else "b")
1413
+ else:
1414
+ orig = (d.get("reply_text") or "").strip()
1415
+ draft_variant = None
1081
1416
  link = d.get("link_url") or ""
1082
1417
  drop_link = False
1083
1418
  if approved:
1084
1419
  text = self._current_text()
1085
- # The card folds link_url into the editable field (see _render). On
1086
- # send we must reconcile what the user did with that link:
1087
- # - link still present anywhere -> remove ALL occurrences so the
1088
- # poster mints the single tracked /r/<code> short link (no double
1089
- # link, no bare URL). Generalizes the old endswith() strip, which
1090
- # missed the link whenever the user typed anything after it.
1091
- # - link gone (user deleted it while editing) -> drop_link=True so
1092
- # the poster does NOT re-append it. Without this signal the post
1093
- # pipeline's forced TWITTER_TAIL_LINK_RATE=1.0 silently revived a
1094
- # link the user intentionally removed.
1095
- if link:
1096
- if link in text:
1097
- text = text.replace(link, "")
1098
- else:
1099
- drop_link = True
1100
- # Collapse only horizontal whitespace left by the removal; preserve
1101
- # any newlines the user intended.
1420
+ # reply_text already carries the link embedded at draft time (see
1421
+ # _render) — post it as-is, don't strip it back out. tail_link_variant
1422
+ # is stamped before the card is ever shown, so the post pipeline now
1423
+ # trusts reply_text verbatim and never re-adds a missing link; the old
1424
+ # strip-then-hope-the-poster-revives-it logic predates that move and
1425
+ # was mislabeling every approval as "edited" (body != orig once the
1426
+ # link was stripped) and occasionally shipping a tweet with no link
1427
+ # at all. Only signal drop_link when the user actually deleted the
1428
+ # link while editing, so the poster clears link_url instead of
1429
+ # reviving it.
1430
+ if link and link not in text:
1431
+ drop_link = True
1432
+ # Collapse only horizontal whitespace runs; preserve intended newlines.
1102
1433
  body = re.sub(r"[ \t]{2,}", " ", text).strip()
1103
1434
  else:
1104
1435
  body = orig
@@ -1129,6 +1460,14 @@ class _ReviewController(NSObject):
1129
1460
  # edit/reject patterns per language. draft_text/original_text
1130
1461
  # stay in the ORIGINAL language (that is what posts).
1131
1462
  "language": d.get("language"),
1463
+ # Two-draft cards: which draft ("a"|"b") was showing when this
1464
+ # decision was made, its 0/1 index, and whether it was the
1465
+ # fixed default (Draft A / slot 0) rather than a reviewer
1466
+ # switch to Draft B. None/False on single-draft candidates.
1467
+ # Kept separate from `edited` above by design.
1468
+ "draft_variant": draft_variant,
1469
+ "draft_index": sel_idx,
1470
+ "draft_auto_selected": bool(dual and sel_idx == 0),
1132
1471
  }
1133
1472
  )
1134
1473
  self._last_decision_at = time.time()
@@ -1226,6 +1565,10 @@ class _ReviewController(NSObject):
1226
1565
  skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 150, H - 42, 150, 30))
1227
1566
  skip.setTitle_("Reject, no reason")
1228
1567
  skip.setBezelStyle_(NSBezelStyleRounded)
1568
+ try:
1569
+ skip.setHasDestructiveAction_(True) # red title on macOS 12+
1570
+ except Exception:
1571
+ pass
1229
1572
  skip.setTarget_(self)
1230
1573
  skip.setAction_("rejectSkip:")
1231
1574
  content.addSubview_(skip)
@@ -1243,20 +1586,50 @@ class _ReviewController(NSObject):
1243
1586
  btn.setAction_("rejectReason:")
1244
1587
  content.addSubview_(btn)
1245
1588
  y -= 32
1246
- note = NSTextField.alloc().initWithFrame_(NSMakeRect(M, 14, W - 2 * M, y + 28 - 14 - 8))
1589
+ # Optional-note field, skinned like the reply editor: a rounded-rect
1590
+ # wrapper carries the fill + hairline border while the borderless
1591
+ # field sits inset inside it so the text doesn't hug the border (an
1592
+ # unbezeled NSTextField has zero internal padding). Same outer frame
1593
+ # as the old bezeled field, which remains the fallback.
1594
+ note_frame = NSMakeRect(M, 14, W - 2 * M, y + 28 - 14 - 8)
1595
+ wrap = NSView.alloc().initWithFrame_(note_frame)
1596
+ if _round_rect(wrap):
1597
+ try:
1598
+ wrap.layer().setBackgroundColor_(
1599
+ NSColor.textBackgroundColor().CGColor()
1600
+ )
1601
+ except Exception:
1602
+ pass
1603
+ note = NSTextField.alloc().initWithFrame_(
1604
+ NSMakeRect(
1605
+ 6, 4, note_frame.size.width - 12, note_frame.size.height - 8
1606
+ )
1607
+ )
1608
+ note.setBezeled_(False)
1609
+ note.setDrawsBackground_(False)
1610
+ try:
1611
+ # The square focus ring fights the rounded skin (renders as a
1612
+ # heavy outline); the border already marks the field.
1613
+ note.setFocusRingType_(1) # NSFocusRingTypeNone
1614
+ except Exception:
1615
+ pass
1616
+ wrap.addSubview_(note)
1617
+ content.addSubview_(wrap)
1618
+ else:
1619
+ note = NSTextField.alloc().initWithFrame_(note_frame)
1620
+ note.setBezeled_(True)
1621
+ content.addSubview_(note)
1247
1622
  note.setEditable_(True)
1248
- note.setBezeled_(True)
1249
1623
  note.setFont_(NSFont.systemFontOfSize_(12))
1250
1624
  try:
1251
1625
  note.setPlaceholderString_("Optional note (sent with whichever reason you pick)")
1252
1626
  note.cell().setWraps_(True)
1253
1627
  except Exception:
1254
1628
  pass
1255
- content.addSubview_(note)
1256
1629
  self._reason_field = note
1257
1630
 
1258
1631
  self._textview = None
1259
- self._panel.setContentView_(content)
1632
+ self._panel.setContentView_(_frosted(content))
1260
1633
  self._panel.makeFirstResponder_(note)
1261
1634
 
1262
1635
  @objc.python_method
@@ -1332,11 +1705,14 @@ def present_review(drafts, on_decision=None, on_complete=None):
1332
1705
  draft time): the card then shows the English thread text, a muted read-only
1333
1706
  "EN:" translation of the reply, and a "posts in <language>" heading, while
1334
1707
  the editable field keeps the original-language reply_text (what posts).
1335
- Optional drafting metadata (project, engagement_style, style_description,
1336
- search_topic, language, link_source, link_keyword, experiments) feeds a
1337
- second eye on the "Reply (editable):" row whose popover explains how the
1338
- draft was made; experiments is a generic {name: variant} dict rendered
1339
- as-is, one line per active experiment/scenario arm.
1708
+ project, the experiments["lane"] arm, and stats.virality_score render
1709
+ inline on the "Reply (editable):" heading itself as a concise
1710
+ "project/lane · viral N" tag (2026-07-07). The rest of the optional
1711
+ drafting metadata (engagement_style, style_description, search_topic,
1712
+ language, link_source, link_keyword, remaining experiments) feeds a
1713
+ second eye on that same row whose popover explains how the draft was
1714
+ made; experiments other than "lane" render generically, one line per
1715
+ active experiment/scenario arm.
1340
1716
  on_decision(decision) fires the instant each card is approved/rejected (so an
1341
1717
  approved draft posts right away); on_complete(decisions) fires when the user
1342
1718
  finishes the last card or closes the window. Both run on the main thread."""
@@ -1521,7 +1897,7 @@ class _FeedbackController(NSObject):
1521
1897
  send.setAction_("feedbackSend:")
1522
1898
  content.addSubview_(send)
1523
1899
 
1524
- panel.setContentView_(content)
1900
+ panel.setContentView_(_frosted(content))
1525
1901
  self._panel = panel
1526
1902
  panel.makeKeyAndOrderFront_(None)
1527
1903
  panel.orderFrontRegardless()