@m13v/s4l 1.7.1-rc.8 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +354 -24
  3. package/mcp/dist/panel.html +42 -31
  4. package/mcp/dist/product-link.html +1 -1
  5. package/mcp/dist/runtime.js +23 -0
  6. package/mcp/dist/telemetry.js +80 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/install.mjs +0 -15
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +539 -80
  12. package/mcp/menubar/s4l_menubar.py +305 -199
  13. package/mcp/menubar/s4l_state.py +228 -33
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/package.json +4 -1
  17. package/scripts/active_users.py +3 -6
  18. package/scripts/autopilot_stall_watch.py +200 -16
  19. package/scripts/claude_job.py +6 -0
  20. package/scripts/feedback_digest.py +34 -6
  21. package/scripts/get-latest-staging-mcpb.sh +28 -45
  22. package/scripts/harness_overlay.py +45 -0
  23. package/scripts/identity.py +26 -0
  24. package/scripts/learned_preferences.py +241 -32
  25. package/scripts/link_tail.py +167 -91
  26. package/scripts/mark_event.py +128 -0
  27. package/scripts/memory_snapshot.py +27 -0
  28. package/scripts/merge_review_queue.py +42 -16
  29. package/scripts/pick_twitter_thread_target.py +1 -1
  30. package/scripts/release-mcpb.sh +48 -0
  31. package/scripts/s4l_mode.py +7 -5
  32. package/scripts/scan_pii.py +9 -1
  33. package/scripts/schedule_state.py +146 -69
  34. package/scripts/scheduled_task_selfheal.py +276 -0
  35. package/scripts/scheduled_tasks_snapshot.py +107 -0
  36. package/scripts/send_gmail_report.py +76 -0
  37. package/scripts/sentry_digest.py +81 -189
  38. package/scripts/sentry_init.py +43 -15
  39. package/scripts/snapshot.py +20 -0
  40. package/scripts/twitter_post_plan.py +39 -13
  41. package/skill/dm-outreach-twitter.sh +19 -0
  42. package/skill/engage-twitter.sh +27 -0
  43. package/skill/run-draft-and-publish.sh +13 -12
  44. package/skill/run-linkedin.sh +9 -1
  45. package/skill/run-twitter-cycle.sh +199 -27
  46. package/skill/sentry-digest.sh +125 -12
  47. package/scripts/set-channel.sh +0 -29
@@ -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,
@@ -101,14 +102,37 @@ from AppKit import (
101
102
  NSEventModifierFlagShift,
102
103
  NSEventModifierFlagDeviceIndependentFlagsMask,
103
104
  NSViewWidthSizable,
105
+ NSColorSpace,
104
106
  )
105
107
 
108
+ # Styling extras that may be missing on older AppKit; every consumer degrades
109
+ # to the stock look when these are None (same posture as the SF Symbols
110
+ # fallback in _eye_button).
111
+ try:
112
+ from AppKit import NSFontWeightSemibold
113
+ except Exception:
114
+ NSFontWeightSemibold = None
115
+ try:
116
+ from AppKit import NSVisualEffectView
117
+ except Exception:
118
+ NSVisualEffectView = None
119
+ try:
120
+ # Registers the CGColor bridge type; without it every NSColor.CGColor()
121
+ # call in _round_rect logs an ObjCPointerWarning to menubar.err.log.
122
+ import Quartz # noqa: F401
123
+ except Exception:
124
+ pass
125
+
106
126
  # Strong reference to the live controller so pyobjc doesn't GC it mid-review
107
127
  # (the classic "button click crashes" footgun).
108
128
  _active = None
109
129
 
110
130
  W = 380
111
- H = 300
131
+ # 2026-07-08: raised 300 -> 420 so two-draft cards can show both drafts at
132
+ # full, comfortable editing height simultaneously (see _render()'s dual
133
+ # branch); single-draft cards get the same extra room, which is a net
134
+ # improvement there too, not a regression.
135
+ H = 420
112
136
  M = 16
113
137
  NS_BEZEL_BORDER = 2 # NSBezelBorder
114
138
 
@@ -298,15 +322,52 @@ def _engagement_line(stats):
298
322
  return " · ".join(parts)
299
323
 
300
324
 
325
+ def _reply_heading_suffix(d):
326
+ """Concise 'project/lane · viral N' readout appended straight onto the
327
+ 'Reply (editable):' heading (2026-07-07), so the two facts a reviewer
328
+ checks first don't require opening the details eye. Pulled from the same
329
+ fields _details_lines used to render as 'Project:'/'Lane:'/'Virality
330
+ score:' rows; those three are omitted from the popover now to avoid
331
+ showing the same numbers twice. Empty string when neither is known."""
332
+ d = d or {}
333
+ project = (d.get("project") or "").strip()
334
+ lane = ((d.get("experiments") or {}).get("lane") or "").strip()
335
+ bits = []
336
+ # Project and lane are independent concepts most of the time (project
337
+ # "fazm" drafted under lane "personal_brand"), but the operator's own
338
+ # PersonalBrand project IS the personal_brand lane, so project=="PersonalBrand"
339
+ # + lane=="personal_brand" is the SAME fact twice, not two facts — compare
340
+ # with punctuation/case stripped so "PersonalBrand" vs "personal_brand"
341
+ # collapses to one token instead of "PersonalBrand/personal_brand".
342
+ same = project and lane and re.sub(r"[^a-z0-9]", "", project.lower()) == re.sub(
343
+ r"[^a-z0-9]", "", lane.lower()
344
+ )
345
+ if same:
346
+ bits.append(project)
347
+ elif project and lane:
348
+ bits.append(f"{project}/{lane}")
349
+ elif project:
350
+ bits.append(project)
351
+ elif lane:
352
+ bits.append(lane)
353
+ v = (d.get("stats") or {}).get("virality_score")
354
+ if v is not None:
355
+ try:
356
+ bits.append(f"viral {float(v):g}")
357
+ except (TypeError, ValueError):
358
+ pass
359
+ return " · ".join(bits)
360
+
361
+
301
362
  def _details_lines(d):
302
363
  """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."""
364
+ to be (engagement style, discovery query, link choice). Project, lane,
365
+ and virality score live inline on the heading itself (_reply_heading_suffix)
366
+ and are deliberately NOT repeated here. Everything else comes from fields
367
+ the plan candidate already carries; fields the pipeline didn't stamp are
368
+ omitted, and an empty list skips the icon."""
306
369
  d = d or {}
307
370
  lines = []
308
- if d.get("project"):
309
- lines.append(f"Project: {d['project']}")
310
371
  style = (d.get("engagement_style") or "").strip()
311
372
  if style:
312
373
  lines.append(f"Style: {style}")
@@ -332,10 +393,10 @@ def _details_lines(d):
332
393
  def _exp_describe(_name, _variant):
333
394
  return None
334
395
  for name in sorted(exps):
335
- label = (
336
- "Lane" if name == "lane"
337
- else f"Experiment {str(name).replace('_', ' ')}"
338
- )
396
+ if name == "lane":
397
+ # Already surfaced inline on the heading via _reply_heading_suffix.
398
+ continue
399
+ label = f"Experiment {str(name).replace('_', ' ')}"
339
400
  line = f"{label}: {exps[name]}"
340
401
  exp_desc = _exp_describe(name, exps[name])
341
402
  if exp_desc:
@@ -366,12 +427,6 @@ def _details_lines(d):
366
427
  lines.append(f"Link: {kw}" if kw else f"Link: {d['link_url']}")
367
428
  elif (d.get("link_source") or "").strip():
368
429
  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
430
  return lines
376
431
 
377
432
 
@@ -398,18 +453,111 @@ def _age_str(iso):
398
453
  return f"{hours // 24}d"
399
454
 
400
455
 
401
- def _label(frame, text, *, size=12, bold=False, muted=False):
456
+ # ---- contemporary styling helpers --------------------------------------------
457
+ # Style-only layer (2026-07-07): frames, sizes, and control positions are
458
+ # untouched; these helpers change nothing but the skin. The card reads as a
459
+ # modern frosted macOS panel (vibrancy background, quiet semibold section
460
+ # labels, a quote-style fill behind the thread text, a rounded-rect reply
461
+ # editor, destructive-red reject) instead of the stock square-bezel utility
462
+ # look. Every hook degrades to the stock look on AppKit versions that lack
463
+ # the API, so none of these try/excepts may be "simplified" away.
464
+
465
+
466
+ def _font(size, bold=False):
467
+ """Semibold (not heavy bold) for emphasis, matching modern macOS forms.
468
+ Used by BOTH _label and _add_link so link-width measurement in _render
469
+ stays in sync with the rendered title font."""
470
+ if bold and NSFontWeightSemibold is not None:
471
+ try:
472
+ return NSFont.systemFontOfSize_weight_(size, NSFontWeightSemibold)
473
+ except Exception:
474
+ pass
475
+ return NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
476
+
477
+
478
+ def _frosted(content):
479
+ """Wrap a content view in a behind-window vibrancy underlay (the frosted
480
+ look of Notification Center widgets). Same frame as the content, so
481
+ nothing moves. Bare view when NSVisualEffectView is unavailable."""
482
+ if NSVisualEffectView is None:
483
+ return content
484
+ try:
485
+ v = NSVisualEffectView.alloc().initWithFrame_(content.frame())
486
+ v.setMaterial_(6) # NSVisualEffectMaterialPopover: adaptive light/dark
487
+ v.setBlendingMode_(0) # behind-window
488
+ v.setState_(1) # active even while this accessory app is inactive
489
+ v.addSubview_(content)
490
+ return v
491
+ except Exception:
492
+ return content
493
+
494
+
495
+ def _fill_color():
496
+ """Subtle adaptive fill for the thread quote block."""
497
+ try:
498
+ return NSColor.quaternarySystemFillColor()
499
+ except Exception:
500
+ return NSColor.labelColor().colorWithAlphaComponent_(0.06)
501
+
502
+
503
+ def _solid(color):
504
+ """Bake a dynamic/semantic NSColor (textBackgroundColor, controlAccentColor,
505
+ etc.) down to concrete sRGB components. The card sits inside an
506
+ NSVisualEffectView (see _frosted); dynamic system colors drawn in that
507
+ vibrant context render partially see-through against whatever is behind
508
+ the window instead of the flat opaque/solid color they look like in a
509
+ normal window (2026-07-08 feedback: the selection ring and its "opaque"
510
+ backing both still blended into a dark desktop behind the card). Once
511
+ converted to a plain sRGB color it is no longer a vibrancy-aware dynamic
512
+ color, so CALayer draws it as flat, fully opaque pixels."""
513
+ try:
514
+ return color.colorUsingColorSpace_(NSColorSpace.sRGBColorSpace()) or color
515
+ except Exception:
516
+ return color
517
+
518
+
519
+ def _round_rect(view, *, border=True):
520
+ """Rounded-rect skin: 8px corners, optional hairline border. Returns True
521
+ on success so callers can restore their square-bezel fallback when the
522
+ layer API is unavailable. Border color is resolved to a CGColor at render
523
+ time; a mid-card system appearance flip keeps the stale shade until the
524
+ next card renders, which is acceptable for a short-lived panel."""
525
+ try:
526
+ view.setWantsLayer_(True)
527
+ layer = view.layer()
528
+ layer.setCornerRadius_(8.0)
529
+ layer.setMasksToBounds_(True)
530
+ if border:
531
+ layer.setBorderWidth_(1.0)
532
+ layer.setBorderColor_(NSColor.separatorColor().CGColor())
533
+ return True
534
+ except Exception:
535
+ return False
536
+
537
+
538
+ def _label(frame, text, *, size=12, bold=False, muted=False, truncates=False):
402
539
  f = NSTextField.alloc().initWithFrame_(frame)
403
540
  f.setStringValue_(text or "")
404
541
  f.setBezeled_(False)
405
542
  f.setDrawsBackground_(False)
406
543
  f.setEditable_(False)
407
544
  f.setSelectable_(False)
408
- f.setFont_(
409
- NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
410
- )
545
+ f.setFont_(_font(size, bold))
411
546
  if muted:
412
547
  f.setTextColor_(NSColor.secondaryLabelColor())
548
+ # truncates=True: single line, "…" at the end when it overflows its frame,
549
+ # instead of word-wrapping into a second line the fixed-height frame then
550
+ # silently clips. For tight one-line strips (e.g. the reply heading's
551
+ # inline project/lane/virality tag) where an exact pixel width can't be
552
+ # guaranteed against every value.
553
+ if truncates:
554
+ f.setLineBreakMode_(NSLineBreakByTruncatingTail)
555
+ try:
556
+ f.cell().setWraps_(False)
557
+ f.cell().setScrollable_(False)
558
+ except Exception:
559
+ pass
560
+ return f
413
561
  f.setLineBreakMode_(NSLineBreakByWordWrapping)
414
562
  try:
415
563
  f.cell().setWraps_(True)
@@ -420,15 +568,25 @@ def _label(frame, text, *, size=12, bold=False, muted=False):
420
568
 
421
569
 
422
570
  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.
571
+ """Rounded-rect scrollable text editor (hairline border, solid text
572
+ background over the frosted panel; falls back to the old square bezel when
573
+ layers are unavailable). The document view must be sized to the scroll
574
+ view's contentSize (NOT the outer frame) and track its width; sized to the
575
+ outer frame the text runs underneath the scroller. The scroller itself
576
+ auto-hides so it only appears when the text overflows.
427
577
  Returns (scroll, textview)."""
428
578
  scroll = NSScrollView.alloc().initWithFrame_(frame)
429
579
  scroll.setHasVerticalScroller_(True)
430
580
  scroll.setAutohidesScrollers_(True)
431
- scroll.setBorderType_(NS_BEZEL_BORDER)
581
+ if _round_rect(scroll):
582
+ scroll.setBorderType_(0) # NSNoBorder; the layer draws the outline
583
+ try:
584
+ scroll.setDrawsBackground_(True)
585
+ scroll.setBackgroundColor_(NSColor.textBackgroundColor())
586
+ except Exception:
587
+ pass
588
+ else:
589
+ scroll.setBorderType_(NS_BEZEL_BORDER)
432
590
  cs = scroll.contentSize()
433
591
  tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, cs.width, cs.height))
434
592
  tv.setFont_(NSFont.systemFontOfSize_(12))
@@ -442,6 +600,11 @@ def _editable_scroll(frame, text=""):
442
600
  tv.setAutoresizingMask_(NSViewWidthSizable)
443
601
  tv.textContainer().setWidthTracksTextView_(True)
444
602
  tv.textContainer().setContainerSize_(NSMakeSize(cs.width, 1e7))
603
+ try:
604
+ # Breathing room inside the rounded rect; text no longer hugs the border.
605
+ tv.setTextContainerInset_(NSMakeSize(4, 6))
606
+ except Exception:
607
+ pass
445
608
  if text:
446
609
  tv.setString_(text)
447
610
  scroll.setDocumentView_(tv)
@@ -470,6 +633,21 @@ class _ReviewController(NSObject):
470
633
  self._interactions = []
471
634
  self._card_shown_at = None
472
635
  self._reason_field = None
636
+ # Two-draft cards (2026-07-08 redesign, no-recommendation pass same
637
+ # day): both drafts render at once as separate editable boxes;
638
+ # `_selected_draft` (0=a, 1=b) is "whichever box the reviewer is
639
+ # currently in", driven by caret movement (see textViewDidChangeSelection_ below),
640
+ # not a button. None = not yet chosen this card, _render() defaults
641
+ # it to slot 0 (Draft A) — the model never picks a favorite, so
642
+ # there's no recommendation to default to instead. Reset to None on
643
+ # every NEW card (see _render()'s "fresh card" branch below), so a
644
+ # switch made on one card never bleeds into the next.
645
+ # _draft_textviews/_draft_scrolls map slot -> its NSTextView/
646
+ # NSScrollView so the delegate callback and border-styling helper can
647
+ # find them; both empty on single-draft cards.
648
+ self._selected_draft = None
649
+ self._draft_textviews = {}
650
+ self._draft_scrolls = {}
473
651
  # Attention anchors for the unattended-review watchdog: the stack counts
474
652
  # as "touched" on present, on any tracked interaction, and on any
475
653
  # decision. No touch past the watchdog threshold = the user is not
@@ -673,6 +851,7 @@ class _ReviewController(NSObject):
673
851
  self._rendered_idx = self._idx
674
852
  self._interactions = []
675
853
  self._card_shown_at = time.time()
854
+ self._selected_draft = None
676
855
  self._reason_field = None
677
856
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
678
857
 
@@ -680,8 +859,11 @@ class _ReviewController(NSObject):
680
859
  # row extending horizontally at the left (one click on an emoji =
681
860
  # approve at that strength), Reject at the right. Overall feedback
682
861
  # moved to the menu bar item.
862
+ # Section labels render quiet (semibold + secondary color): the modern
863
+ # macOS form look where chrome recedes and content (thread, draft)
864
+ # carries the full-strength label color.
683
865
  content.addSubview_(
684
- _label(NSMakeRect(M, H - 38, 60, 20), "Approve", size=12, bold=True)
866
+ _label(NSMakeRect(M, H - 38, 60, 20), "Approve", size=12, bold=True, muted=True)
685
867
  )
686
868
  x = M + 62
687
869
  for i, (emoji, tip) in enumerate(APPROVE_EMOJIS):
@@ -707,6 +889,10 @@ class _ReviewController(NSObject):
707
889
  reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
708
890
  reject.setTitle_("Reject")
709
891
  reject.setBezelStyle_(NSBezelStyleRounded)
892
+ try:
893
+ reject.setHasDestructiveAction_(True) # red title on macOS 12+
894
+ except Exception:
895
+ pass
710
896
  reject.setTarget_(self)
711
897
  reject.setAction_("reject:")
712
898
  content.addSubview_(reject)
@@ -722,7 +908,7 @@ class _ReviewController(NSObject):
722
908
  stats = d.get("stats") or {}
723
909
  thread_url = d.get("thread_url")
724
910
  content.addSubview_(
725
- _label(NSMakeRect(M, H - 70, 78, 18), "Replying to", size=12, bold=True)
911
+ _label(NSMakeRect(M, H - 70, 78, 18), "Replying to", size=12, bold=True, muted=True)
726
912
  )
727
913
  right_x = W - M
728
914
  self._close_stats_popover()
@@ -757,7 +943,7 @@ class _ReviewController(NSObject):
757
943
  # after the handle instead of at a fixed column.
758
944
  text = f"@{handle}"
759
945
  measured = NSAttributedString.alloc().initWithString_attributes_(
760
- text, {NSFontAttributeName: NSFont.boldSystemFontOfSize_(12)}
946
+ text, {NSFontAttributeName: _font(12, True)}
761
947
  ).size().width
762
948
  link_w = min(int(measured) + 8, handle_w)
763
949
  self._add_link(
@@ -781,7 +967,7 @@ class _ReviewController(NSObject):
781
967
  )
782
968
  else:
783
969
  content.addSubview_(
784
- _label(NSMakeRect(M + 78, H - 70, handle_w, 18), "thread", size=12, bold=True)
970
+ _label(NSMakeRect(M + 78, H - 70, handle_w, 18), "thread", size=12, bold=True, muted=True)
785
971
  )
786
972
  # Non-English drafts: the prep step stamps display-only English
787
973
  # translations (thread_text_en / reply_text_en) on the candidate. The
@@ -801,7 +987,18 @@ class _ReviewController(NSObject):
801
987
  )
802
988
  thread_tv.setEditable_(False)
803
989
  thread_tv.setSelectable_(True) # links only respond when selectable
804
- thread_tv.setDrawsBackground_(False)
990
+ # Quote-style block (subtle rounded fill, like a quoted tweet) so the
991
+ # thread reads as *their* content and the editor below as *ours*.
992
+ # Same frame as the old flat text; only the skin changed.
993
+ if _round_rect(thread_tv, border=False):
994
+ thread_tv.setDrawsBackground_(True)
995
+ thread_tv.setBackgroundColor_(_fill_color())
996
+ try:
997
+ thread_tv.setTextContainerInset_(NSMakeSize(6, 5))
998
+ except Exception:
999
+ pass
1000
+ else:
1001
+ thread_tv.setDrawsBackground_(False)
805
1002
  # An NSTextView grows vertically by default; long threads inflated the
806
1003
  # frame over the author row above (non-flipped superview: growth goes
807
1004
  # UP) and pushed the trailing ↗ out of the box. Pin the frame and
@@ -831,23 +1028,59 @@ class _ReviewController(NSObject):
831
1028
  thread_tv.setDelegate_(self)
832
1029
  thread_tv.textStorage().setAttributedString_(body)
833
1030
  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).
1031
+ # Reply heading — bold. A concise "project/lane · viral N" tag rides
1032
+ # right after it in a SEPARATE, regular-weight label (2026-07-08:
1033
+ # was folded into the same bold string, which made the metadata read
1034
+ # as loudly as the heading itself; split + measured-width placement
1035
+ # mirrors the "@handle · followers" pattern in the author row above).
1036
+ # Right-aligned on the same line: an eye icon whose hover/click
1037
+ # popover carries the remaining DRAFTING metadata (style, discovery
1038
+ # query, link choice), mirroring the author row's engagement-stats
1039
+ # eye. Skipped when the plan carries none of it (older plans).
1040
+ heading_text = (
1041
+ f"Reply (posts in {_lang_name(card_lang)}, editable):"
1042
+ if is_foreign
1043
+ else "Reply (editable):"
1044
+ )
1045
+ # +8 (not +4): NSTextField's own cellSize() for this text measures
1046
+ # ~4px wider than the raw NSAttributedString width used here, so a
1047
+ # tight +4 pad was landing sub-pixel under the real render width and
1048
+ # wrapping "(editable):" onto a clipped, invisible second line.
1049
+ # Matches the +8 pad the @handle link width uses below for the same
1050
+ # reason.
1051
+ heading_w = min(
1052
+ int(
1053
+ NSAttributedString.alloc().initWithString_attributes_(
1054
+ heading_text, {NSFontAttributeName: _font(12, True)}
1055
+ ).size().width
1056
+ ) + 8,
1057
+ W - 2 * M - 24,
1058
+ )
839
1059
  content.addSubview_(
840
1060
  _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
- ),
1061
+ NSMakeRect(M, H - 172, heading_w, 16),
1062
+ heading_text,
847
1063
  size=12,
848
1064
  bold=True,
1065
+ muted=True,
849
1066
  )
850
1067
  )
1068
+ # Long headings (foreign-language variant) can leave very little room
1069
+ # for the suffix; truncates=True ellipsizes it instead of the old
1070
+ # word-wrap silently clipping mid-word ("fazm/" with no "…") into the
1071
+ # fixed-height frame's invisible second line.
1072
+ heading_suffix = _reply_heading_suffix(d)
1073
+ suffix_w = W - 2 * M - 24 - heading_w
1074
+ if heading_suffix and suffix_w > 20:
1075
+ content.addSubview_(
1076
+ _label(
1077
+ NSMakeRect(M + heading_w, H - 172, suffix_w, 16),
1078
+ f" · {heading_suffix}",
1079
+ size=11,
1080
+ muted=True,
1081
+ truncates=True,
1082
+ )
1083
+ )
851
1084
  if _details_lines(d):
852
1085
  # No y nudge needed here (unlike the author row): this label is
853
1086
  # 16px, so an 18px button at the same y already centers its image
@@ -856,37 +1089,135 @@ class _ReviewController(NSObject):
856
1089
  content.addSubview_(deye)
857
1090
  self._details_btn = deye
858
1091
 
859
- # Editable reply. Since 2026-07-06 the tail link is folded into
860
- # reply_text at DRAFT time (scripts/twitter_gen_links.py::apply_tail_link),
861
- # so it's normally already there — only append link_url when reply_text
862
- # is genuinely link-free (older/fallback plans), else it shows twice.
863
- reply = d.get("reply_text") or ""
1092
+ # Two-draft cards (2026-07-08 redesign; no-recommendation pass same
1093
+ # day): show BOTH drafts at once, stacked, each independently
1094
+ # editable, rather than a toggle that swaps one field's content. No
1095
+ # buttons: selection is "whichever box the reviewer is currently in",
1096
+ # shown via a dedicated outline view wrapping that box (an accent
1097
+ # outline vs a plain hairline, mirroring a standard focus ring) and
1098
+ # updated live by textViewDidChangeSelection_ below. The model never picks a favorite
1099
+ # (removed 2026-07-08 per user: no ask-the-model-to-recommend), so
1100
+ # Draft A (slot 0) is simply the fixed default until the reviewer
1101
+ # clicks into Draft B. Absent/short (reused stale draft, legacy plan)
1102
+ # falls back to one full-height editable box, same as before.
1103
+ drafts = d.get("drafts")
1104
+ dual = isinstance(drafts, list) and len(drafts) == 2
1105
+ sel_idx = self._selected_draft if (dual and self._selected_draft in (0, 1)) else 0
1106
+ if dual:
1107
+ self._selected_draft = sel_idx
1108
+
1109
+ edit_top = H - 172 - 3
864
1110
  link = d.get("link_url")
865
- composed = reply if (not link or link in reply) else f"{reply} {link}"
866
- # Non-English draft with a stamped translation: a muted read-only
867
- # "EN:" block sits between the heading and the editable field so the
868
- # reviewer reads the draft in English while still editing (and
869
- # posting) the original-language text below. Full translation lives
870
- # in the details popover; inline is truncated to what ~3 lines fit.
871
- edit_top = H - 172 - 6
872
- if reply_en:
873
- tr_h = 42
874
- content.addSubview_(
875
- _label(
876
- NSMakeRect(M, edit_top - tr_h, W - 2 * M, tr_h),
877
- f"EN: {_truncate(reply_en, 150)}",
878
- size=11,
879
- muted=True,
1111
+
1112
+ # Tail link baked at draft time is normally already in each draft's
1113
+ # text (scripts/twitter_gen_links.py + the drafts-link-sync step in
1114
+ # run-twitter-cycle.sh); only append link_url when a text is
1115
+ # genuinely link-free (older/fallback plans), else it'd show twice.
1116
+ def _compose(text):
1117
+ text = text or ""
1118
+ return text if (not link or link in text) else f"{text} {link}"
1119
+
1120
+ self._draft_textviews = {}
1121
+ self._draft_scrolls = {}
1122
+ self._draft_outlines = {}
1123
+ if dual:
1124
+ avail_h = edit_top - M
1125
+ gap = 5
1126
+ # Reserved only for foreign-language cards, which show a muted
1127
+ # "EN:" line above each box; English drafts (the common case)
1128
+ # have nothing to put there, so reserving it unconditionally used
1129
+ # to leave a dead 15px gap above every box for no reason.
1130
+ label_h = 15 if is_foreign else 0
1131
+ unit = (avail_h - gap) / 2.0
1132
+ box_h = unit - label_h
1133
+ for slot in (0, 1):
1134
+ # slot 0 (Draft A) always renders on top, slot 1 (Draft B)
1135
+ # always on the bottom: a fixed, predictable order so cards
1136
+ # don't reshuffle as the reviewer switches between them.
1137
+ box_y = M if slot == 1 else M + unit + gap
1138
+ label_y = box_y + box_h
1139
+ draft = drafts[slot]
1140
+ draft_text_en = (draft.get("text_en") or "").strip()
1141
+ if is_foreign and draft_text_en:
1142
+ content.addSubview_(
1143
+ _label(
1144
+ NSMakeRect(M, label_y, W - 2 * M, label_h),
1145
+ f"EN: {_truncate(draft_text_en, 90)}",
1146
+ size=10,
1147
+ muted=True,
1148
+ truncates=True,
1149
+ )
1150
+ )
1151
+ # The selection ring can't be drawn on the scroll view's own
1152
+ # layer: NSScrollView's opaque clip/document view fully paints
1153
+ # over its parent layer's border, so setBorderWidth/Color on
1154
+ # scroll.layer() is accepted (no error) but never visibly
1155
+ # renders, at any width or color (verified empirically). A
1156
+ # separate outline view, sized to the box and holding the
1157
+ # scroll view inset a few px inside it, keeps the ring
1158
+ # unobstructed since nothing opaque reaches its edge.
1159
+ # The inset gap between this wrapper's edge (where the ring is
1160
+ # drawn) and the scroll view's own background otherwise sits
1161
+ # directly on the translucent frosted panel behind it, so a
1162
+ # plain hairline there read as barely visible (2026-07-08
1163
+ # feedback: "outline not obvious, background is mostly
1164
+ # transparent"). _update_draft_borders (called once the
1165
+ # content view is installed, below) backs the whole wrapper a
1166
+ # solid color so box + ring read as one opaque card; no need
1167
+ # to pre-seed it here since that call always follows.
1168
+ outline_frame = NSMakeRect(M, box_y, W - 2 * M, box_h)
1169
+ outline = NSView.alloc().initWithFrame_(outline_frame)
1170
+ _round_rect(outline)
1171
+ inset = 4
1172
+ scroll, tv = _editable_scroll(
1173
+ NSMakeRect(
1174
+ inset,
1175
+ inset,
1176
+ outline_frame.size.width - 2 * inset,
1177
+ outline_frame.size.height - 2 * inset,
1178
+ ),
1179
+ _compose(draft.get("text")),
1180
+ )
1181
+ tv.setDelegate_(self)
1182
+ outline.addSubview_(scroll)
1183
+ content.addSubview_(outline)
1184
+ self._draft_scrolls[slot] = scroll
1185
+ self._draft_outlines[slot] = outline
1186
+ self._draft_textviews[slot] = tv
1187
+ tv = self._draft_textviews[sel_idx]
1188
+ else:
1189
+ reply = d.get("reply_text") or ""
1190
+ # Non-English draft with a stamped translation: a muted read-only
1191
+ # "EN:" block sits between the heading and the editable field so
1192
+ # the reviewer reads the draft in English while still editing
1193
+ # (and posting) the original-language text below. Full
1194
+ # translation lives in the details popover; inline is truncated
1195
+ # to what ~3 lines fit.
1196
+ if reply_en:
1197
+ tr_h = 42
1198
+ content.addSubview_(
1199
+ _label(
1200
+ NSMakeRect(M, edit_top - tr_h, W - 2 * M, tr_h),
1201
+ f"EN: {_truncate(reply_en, 150)}",
1202
+ size=11,
1203
+ muted=True,
1204
+ )
880
1205
  )
1206
+ edit_top -= tr_h + 4
1207
+ scroll, tv = _editable_scroll(
1208
+ NSMakeRect(M, M, W - 2 * M, edit_top - M), _compose(reply)
881
1209
  )
882
- edit_top -= tr_h + 4
883
- scroll, tv = _editable_scroll(
884
- NSMakeRect(M, M, W - 2 * M, edit_top - M), composed
885
- )
886
- content.addSubview_(scroll)
1210
+ content.addSubview_(scroll)
887
1211
  self._textview = tv
888
1212
 
889
- self._panel.setContentView_(content)
1213
+ self._panel.setContentView_(_frosted(content))
1214
+ if dual:
1215
+ # Layer border properties set before a view is attached to its
1216
+ # eventual window get silently dropped when AppKit backs the view
1217
+ # for real on attach, so the accent outline must be (re)applied
1218
+ # only after setContentView_ installs the view tree, not during
1219
+ # construction above.
1220
+ self._update_draft_borders()
890
1221
  # Counter lives in the native title bar, not inside the content, with
891
1222
  # the product name so a stray card is identifiable at a glance.
892
1223
  self._panel.setTitle_(
@@ -1029,7 +1360,7 @@ class _ReviewController(NSObject):
1029
1360
  _link_targets so one openLink: selector serves every link on the card."""
1030
1361
  btn = NSButton.alloc().initWithFrame_(frame)
1031
1362
  btn.setBordered_(False)
1032
- font = NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
1363
+ font = _font(size, bold)
1033
1364
  attrs = {
1034
1365
  NSFontAttributeName: font,
1035
1366
  NSForegroundColorAttributeName: NSColor.linkColor(),
@@ -1068,17 +1399,100 @@ class _ReviewController(NSObject):
1068
1399
  pass
1069
1400
  return True
1070
1401
 
1402
+ # NSTextView delegate: fires the instant the caret/selection moves inside
1403
+ # a text view, which includes a plain click-to-place-cursor with no
1404
+ # keystroke (unlike NSTextDidBeginEditingNotification/textDidBeginEditing_,
1405
+ # which only fires once an actual edit starts and does NOT fire from
1406
+ # clicking into a box to merely place the cursor, verified empirically —
1407
+ # that gap was the "clicking the other draft doesn't select it" bug).
1408
+ # Two-draft cards use this as the ONLY selection mechanism: whichever
1409
+ # draft box the reviewer is in IS the selected one. Only the two draft
1410
+ # boxes set self as delegate for this notification (the read-only thread
1411
+ # quote never fires it), so no candidate_id lookup is needed, just a slot
1412
+ # match against self._draft_textviews.
1413
+ def textViewDidChangeSelection_(self, notification):
1414
+ try:
1415
+ tv = notification.object()
1416
+ except Exception:
1417
+ return
1418
+ for slot, cand_tv in (self._draft_textviews or {}).items():
1419
+ if cand_tv is tv and slot != self._selected_draft:
1420
+ self._selected_draft = slot
1421
+ self._textview = cand_tv
1422
+ self._update_draft_borders()
1423
+ break
1424
+
1425
+ @objc.python_method
1426
+ def _update_draft_borders(self):
1427
+ """Redraw the two draft boxes' selection ring in place (no re-render,
1428
+ so an in-progress edit/caret in either box is never disturbed): the
1429
+ selected box's outline view gets a thick, solid black outline
1430
+ (deliberately NOT the user's system accent color — on a Graphite
1431
+ accent it renders as plain gray and is indistinguishable from chrome;
1432
+ 2026-07-08 feedback wanted "a stronger color", then specifically
1433
+ black), the other a plain hairline. Every color here is baked via
1434
+ _solid() first: drawn as-is, dynamic system colors render partially
1435
+ see-through against whatever is behind the card's frosted/vibrant
1436
+ panel, which was why an earlier pass still looked washed out over a
1437
+ dark desktop. Applied to the dedicated outline wrapper, not the
1438
+ scroll view itself; see the comment at its construction in _render
1439
+ for why."""
1440
+ selected_color = NSColor.blackColor()
1441
+ for slot, outline in (self._draft_outlines or {}).items():
1442
+ try:
1443
+ layer = outline.layer()
1444
+ if layer is None:
1445
+ continue
1446
+ if slot == self._selected_draft:
1447
+ layer.setBorderWidth_(3.0)
1448
+ layer.setBorderColor_(selected_color.CGColor())
1449
+ # A ring alone read as barely-there against the frosted
1450
+ # panel. The margin between this wrapper's edge and the
1451
+ # inset scroll view is otherwise plain background, so
1452
+ # tinting it toward black turns that margin into a visible
1453
+ # halo, not just a hairline — obvious at a glance, not
1454
+ # just on close inspection.
1455
+ tint = _solid(
1456
+ NSColor.textBackgroundColor()
1457
+ ).blendedColorWithFraction_ofColor_(0.30, selected_color)
1458
+ layer.setBackgroundColor_((tint or selected_color).CGColor())
1459
+ else:
1460
+ layer.setBorderWidth_(1.0)
1461
+ layer.setBorderColor_(_solid(NSColor.separatorColor()).CGColor())
1462
+ layer.setBackgroundColor_(_solid(NSColor.textBackgroundColor()).CGColor())
1463
+ except Exception:
1464
+ pass
1465
+
1071
1466
  @objc.python_method
1072
1467
  def _current_text(self):
1073
1468
  try:
1074
1469
  return str(self._textview.string())
1075
1470
  except Exception:
1076
- return self._drafts[self._idx].get("reply_text") or ""
1471
+ d = self._drafts[self._idx]
1472
+ drafts = d.get("drafts")
1473
+ if isinstance(drafts, list) and len(drafts) == 2 and self._selected_draft in (0, 1):
1474
+ return drafts[self._selected_draft].get("text") or ""
1475
+ return d.get("reply_text") or ""
1077
1476
 
1078
1477
  @objc.python_method
1079
1478
  def _record(self, approved, reject_category=None, reject_note=None, loved=False):
1080
1479
  d = self._drafts[self._idx]
1081
- orig = (d.get("reply_text") or "").strip()
1480
+ # Two-draft cards: `orig` (what counts as "unedited") is whichever
1481
+ # draft is CURRENTLY SELECTED, not always the plan's default
1482
+ # reply_text. Otherwise picking the non-recommended draft without
1483
+ # further hand-editing would show up as `edited=True` with the
1484
+ # recommended draft as `original_text`, which would look exactly
1485
+ # like a human rewrite to the edit-learning digest and pollute it.
1486
+ drafts = d.get("drafts")
1487
+ dual = isinstance(drafts, list) and len(drafts) == 2
1488
+ sel_idx = self._selected_draft if (dual and self._selected_draft in (0, 1)) else None
1489
+ if sel_idx is not None:
1490
+ chosen_draft = drafts[sel_idx]
1491
+ orig = (chosen_draft.get("text") or "").strip()
1492
+ draft_variant = chosen_draft.get("variant") or ("a" if sel_idx == 0 else "b")
1493
+ else:
1494
+ orig = (d.get("reply_text") or "").strip()
1495
+ draft_variant = None
1082
1496
  link = d.get("link_url") or ""
1083
1497
  drop_link = False
1084
1498
  if approved:
@@ -1126,6 +1540,14 @@ class _ReviewController(NSObject):
1126
1540
  # edit/reject patterns per language. draft_text/original_text
1127
1541
  # stay in the ORIGINAL language (that is what posts).
1128
1542
  "language": d.get("language"),
1543
+ # Two-draft cards: which draft ("a"|"b") was showing when this
1544
+ # decision was made, its 0/1 index, and whether it was the
1545
+ # fixed default (Draft A / slot 0) rather than a reviewer
1546
+ # switch to Draft B. None/False on single-draft candidates.
1547
+ # Kept separate from `edited` above by design.
1548
+ "draft_variant": draft_variant,
1549
+ "draft_index": sel_idx,
1550
+ "draft_auto_selected": bool(dual and sel_idx == 0),
1129
1551
  }
1130
1552
  )
1131
1553
  self._last_decision_at = time.time()
@@ -1223,6 +1645,10 @@ class _ReviewController(NSObject):
1223
1645
  skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 150, H - 42, 150, 30))
1224
1646
  skip.setTitle_("Reject, no reason")
1225
1647
  skip.setBezelStyle_(NSBezelStyleRounded)
1648
+ try:
1649
+ skip.setHasDestructiveAction_(True) # red title on macOS 12+
1650
+ except Exception:
1651
+ pass
1226
1652
  skip.setTarget_(self)
1227
1653
  skip.setAction_("rejectSkip:")
1228
1654
  content.addSubview_(skip)
@@ -1240,20 +1666,50 @@ class _ReviewController(NSObject):
1240
1666
  btn.setAction_("rejectReason:")
1241
1667
  content.addSubview_(btn)
1242
1668
  y -= 32
1243
- note = NSTextField.alloc().initWithFrame_(NSMakeRect(M, 14, W - 2 * M, y + 28 - 14 - 8))
1669
+ # Optional-note field, skinned like the reply editor: a rounded-rect
1670
+ # wrapper carries the fill + hairline border while the borderless
1671
+ # field sits inset inside it so the text doesn't hug the border (an
1672
+ # unbezeled NSTextField has zero internal padding). Same outer frame
1673
+ # as the old bezeled field, which remains the fallback.
1674
+ note_frame = NSMakeRect(M, 14, W - 2 * M, y + 28 - 14 - 8)
1675
+ wrap = NSView.alloc().initWithFrame_(note_frame)
1676
+ if _round_rect(wrap):
1677
+ try:
1678
+ wrap.layer().setBackgroundColor_(
1679
+ NSColor.textBackgroundColor().CGColor()
1680
+ )
1681
+ except Exception:
1682
+ pass
1683
+ note = NSTextField.alloc().initWithFrame_(
1684
+ NSMakeRect(
1685
+ 6, 4, note_frame.size.width - 12, note_frame.size.height - 8
1686
+ )
1687
+ )
1688
+ note.setBezeled_(False)
1689
+ note.setDrawsBackground_(False)
1690
+ try:
1691
+ # The square focus ring fights the rounded skin (renders as a
1692
+ # heavy outline); the border already marks the field.
1693
+ note.setFocusRingType_(1) # NSFocusRingTypeNone
1694
+ except Exception:
1695
+ pass
1696
+ wrap.addSubview_(note)
1697
+ content.addSubview_(wrap)
1698
+ else:
1699
+ note = NSTextField.alloc().initWithFrame_(note_frame)
1700
+ note.setBezeled_(True)
1701
+ content.addSubview_(note)
1244
1702
  note.setEditable_(True)
1245
- note.setBezeled_(True)
1246
1703
  note.setFont_(NSFont.systemFontOfSize_(12))
1247
1704
  try:
1248
1705
  note.setPlaceholderString_("Optional note (sent with whichever reason you pick)")
1249
1706
  note.cell().setWraps_(True)
1250
1707
  except Exception:
1251
1708
  pass
1252
- content.addSubview_(note)
1253
1709
  self._reason_field = note
1254
1710
 
1255
1711
  self._textview = None
1256
- self._panel.setContentView_(content)
1712
+ self._panel.setContentView_(_frosted(content))
1257
1713
  self._panel.makeFirstResponder_(note)
1258
1714
 
1259
1715
  @objc.python_method
@@ -1329,11 +1785,14 @@ def present_review(drafts, on_decision=None, on_complete=None):
1329
1785
  draft time): the card then shows the English thread text, a muted read-only
1330
1786
  "EN:" translation of the reply, and a "posts in <language>" heading, while
1331
1787
  the editable field keeps the original-language reply_text (what posts).
1332
- Optional drafting metadata (project, engagement_style, style_description,
1333
- search_topic, language, link_source, link_keyword, experiments) feeds a
1334
- second eye on the "Reply (editable):" row whose popover explains how the
1335
- draft was made; experiments is a generic {name: variant} dict rendered
1336
- as-is, one line per active experiment/scenario arm.
1788
+ project, the experiments["lane"] arm, and stats.virality_score render
1789
+ inline on the "Reply (editable):" heading itself as a concise
1790
+ "project/lane · viral N" tag (2026-07-07). The rest of the optional
1791
+ drafting metadata (engagement_style, style_description, search_topic,
1792
+ language, link_source, link_keyword, remaining experiments) feeds a
1793
+ second eye on that same row whose popover explains how the draft was
1794
+ made; experiments other than "lane" render generically, one line per
1795
+ active experiment/scenario arm.
1337
1796
  on_decision(decision) fires the instant each card is approved/rejected (so an
1338
1797
  approved draft posts right away); on_complete(decisions) fires when the user
1339
1798
  finishes the last card or closes the window. Both run on the main thread."""
@@ -1518,7 +1977,7 @@ class _FeedbackController(NSObject):
1518
1977
  send.setAction_("feedbackSend:")
1519
1978
  content.addSubview_(send)
1520
1979
 
1521
- panel.setContentView_(content)
1980
+ panel.setContentView_(_frosted(content))
1522
1981
  self._panel = panel
1523
1982
  panel.makeKeyAndOrderFront_(None)
1524
1983
  panel.orderFrontRegardless()