@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.
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +354 -24
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/runtime.js +23 -0
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/install.mjs +0 -15
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +539 -80
- package/mcp/menubar/s4l_menubar.py +305 -199
- package/mcp/menubar/s4l_state.py +228 -33
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +8 -0
- package/package.json +4 -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 +28 -45
- 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 +27 -0
- package/scripts/merge_review_queue.py +42 -16
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/release-mcpb.sh +48 -0
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scan_pii.py +9 -1
- package/scripts/schedule_state.py +146 -69
- package/scripts/scheduled_task_selfheal.py +276 -0
- 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/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/scripts/set-channel.sh +0 -29
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,
|
|
@@ -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
|
-
|
|
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 (
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 —
|
|
835
|
-
#
|
|
836
|
-
#
|
|
837
|
-
#
|
|
838
|
-
#
|
|
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,
|
|
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
|
-
#
|
|
860
|
-
#
|
|
861
|
-
#
|
|
862
|
-
# is
|
|
863
|
-
|
|
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
|
-
|
|
866
|
-
#
|
|
867
|
-
#
|
|
868
|
-
#
|
|
869
|
-
#
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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()
|