@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.
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +332 -24
- package/mcp/dist/onboarding.js +3 -10
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/runtime.js +101 -1
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +472 -96
- package/mcp/menubar/s4l_menubar.py +269 -26
- package/mcp/menubar/s4l_state.py +169 -23
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +8 -0
- package/mcp/shared/onboarding-ledger.cjs +1 -0
- package/package.json +3 -1
- package/scripts/active_users.py +3 -6
- package/scripts/autopilot_stall_watch.py +200 -16
- package/scripts/claude_job.py +6 -0
- package/scripts/feedback_digest.py +34 -6
- package/scripts/get-latest-staging-mcpb.sh +50 -0
- package/scripts/harness_overlay.py +45 -0
- package/scripts/identity.py +26 -0
- package/scripts/learned_preferences.py +241 -32
- package/scripts/link_tail.py +167 -91
- package/scripts/mark_event.py +128 -0
- package/scripts/memory_snapshot.py +62 -0
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/release-mcpb.sh +48 -0
- package/scripts/reset-test-machine.sh +85 -3
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scheduled_tasks_snapshot.py +107 -0
- package/scripts/send_gmail_report.py +76 -0
- package/scripts/sentry_digest.py +81 -189
- package/scripts/sentry_init.py +43 -15
- package/scripts/setup_twitter_auth.py +76 -38
- package/scripts/snapshot.py +20 -0
- package/scripts/twitter_post_plan.py +39 -13
- package/skill/dm-outreach-twitter.sh +19 -0
- package/skill/engage-twitter.sh +27 -0
- package/skill/run-draft-and-publish.sh +13 -12
- package/skill/run-linkedin.sh +9 -1
- package/skill/run-twitter-cycle.sh +199 -27
- package/skill/sentry-digest.sh +125 -12
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 —
|
|
835
|
-
#
|
|
836
|
-
#
|
|
837
|
-
#
|
|
838
|
-
#
|
|
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,
|
|
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
|
-
#
|
|
860
|
-
#
|
|
861
|
-
#
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
#
|
|
866
|
-
#
|
|
867
|
-
#
|
|
868
|
-
#
|
|
869
|
-
#
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1086
|
-
#
|
|
1087
|
-
#
|
|
1088
|
-
#
|
|
1089
|
-
#
|
|
1090
|
-
#
|
|
1091
|
-
#
|
|
1092
|
-
#
|
|
1093
|
-
#
|
|
1094
|
-
#
|
|
1095
|
-
if link:
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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()
|