@m13v/s4l 1.7.6-rc.2 → 1.7.6-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/mcp/dist/index.js +256 -92
  2. package/mcp/dist/telemetry.js +2 -1
  3. package/mcp/dist/version.json +2 -2
  4. package/mcp/manifest.json +1 -1
  5. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  6. package/mcp/menubar/s4l_card.py +144 -18
  7. package/mcp/menubar/s4l_card_canvas.py +135 -14
  8. package/mcp/menubar/s4l_menubar.py +216 -73
  9. package/mcp/menubar/s4l_state.py +45 -130
  10. package/mcp/package.json +1 -1
  11. package/package.json +1 -1
  12. package/scripts/account_resolver.py +35 -1
  13. package/scripts/autopilot_stall_watch.py +8 -1
  14. package/scripts/browser_lifecycle.py +90 -0
  15. package/scripts/cdp_ready_check.py +81 -50
  16. package/scripts/draft_prompt_core.py +100 -17
  17. package/scripts/engagement_styles.py +135 -98
  18. package/scripts/merge_review_queue.py +192 -129
  19. package/scripts/platform_strike_events.py +54 -5
  20. package/scripts/post_reddit.py +10 -0
  21. package/scripts/preview_reddit_card.py +38 -0
  22. package/scripts/reap_stale_claude_sessions.py +7 -6
  23. package/scripts/reddit_ban_check.py +220 -0
  24. package/scripts/reddit_browser.py +24 -0
  25. package/scripts/release-mcpb.sh +38 -7
  26. package/scripts/setup_twitter_auth.py +20 -96
  27. package/scripts/store_patch.py +111 -0
  28. package/scripts/strike_alert.py +161 -12
  29. package/scripts/test_no_silent_fallbacks.py +11 -0
  30. package/scripts/test_stall_watch_batch_progression.py +99 -0
  31. package/scripts/top_performers.py +24 -0
  32. package/scripts/twitter_browser.py +22 -52
  33. package/skill/archive-old-logs.sh +24 -0
  34. package/skill/lib/reddit-backend.sh +1 -0
  35. package/skill/run-twitter-cycle.sh +146 -66
@@ -519,9 +519,11 @@ class S4LMenuBar(rumps.App):
519
519
  # Which review-surface module ("cards" | "canvas") is driving the
520
520
  # CURRENTLY open panel/window, None when nothing is open. Set at
521
521
  # presentation time from st.read_review_layout(); kept fixed for the
522
- # life of that surface so switching the "View" submenu (Cards/Canvas)
523
- # mid-session never talks to the wrong module for an already-open
524
- # panel. See _review_mod().
522
+ # life of that surface so no call ever talks to the wrong module for
523
+ # an already-open panel. A "View Mode" (Cards/Canvas) switch while a
524
+ # surface is open dismisses it through this pin, then releases the
525
+ # pin and re-presents through the new module. See _review_mod() and
526
+ # _on_review_layout_preset().
525
527
  self._review_mod_name = None
526
528
  # Review snooze: epoch until which draft cards must not auto-present
527
529
  # (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
@@ -577,6 +579,12 @@ class S4LMenuBar(rumps.App):
577
579
  # a hitch every 5s tick.
578
580
  self._resumed = False
579
581
  self._loopback_check_due_at = 0.0
582
+ # Periodic approved-backlog drain cadence (2026-07-17): the menubar is
583
+ # the single drainer now that the MCP boot-time drain is gone. 10 min
584
+ # keeps a transiently-failed approval's retry latency reasonable while
585
+ # staying far below the old effective 5-min/multi-instance churn.
586
+ self.RESUME_PERIOD_S = 600.0
587
+ self._resume_due_at = 0.0
580
588
  # Reliable self-check of our own Accessibility (TCC) grant — this is the
581
589
  # faithful reading (our launchd process identity, not a parent's). Logged
582
590
  # so menubar.err.log records whether keystroke posting will work.
@@ -1140,12 +1148,53 @@ class S4LMenuBar(rumps.App):
1140
1148
  def _on_review_layout_preset(self, layout, _sender=None):
1141
1149
  """Menu callback shim (mirrors _on_split_preset): rumps passes the
1142
1150
  clicked MenuItem last. Persisted (st.write_review_layout), so it
1143
- survives a restart; an already-open surface keeps driving through
1144
- whichever module it started with (_review_mod_name) -- this only
1145
- changes what the NEXT presentation uses. See _review_mod()."""
1151
+ survives a restart. If a review surface is on screen when the mode
1152
+ flips, swap it live: close the old surface WITHOUT firing its
1153
+ callbacks (decisions already made were shipped per-card; the
1154
+ undecided remainder must not be treated as a snoozed early-close)
1155
+ and immediately re-present the same pending set through the new
1156
+ module (2026-07-16, replacing the old next-presentation-only
1157
+ behavior)."""
1146
1158
  if st.read_review_layout() == layout:
1147
1159
  return
1148
1160
  st.write_review_layout(layout)
1161
+ if self._panel_open:
1162
+ try:
1163
+ # _review_mod() is still pinned to the module that owns the
1164
+ # open surface (the pin releases below), so this dismisses
1165
+ # the OLD surface, not the freshly-persisted preference.
1166
+ _, mod = self._review_mod()
1167
+
1168
+ mod.dismiss_active()
1169
+ except Exception as e:
1170
+ sys.stderr.write(f"[s4l-menubar] view mode swap dismiss failed: {e}\n")
1171
+ sys.stderr.flush()
1172
+ with self._review_lock:
1173
+ self._panel_open = False
1174
+ self._review_mod_name = None
1175
+ if self._posts_outstanding <= 0:
1176
+ self._review_active = False
1177
+ self._reset_posting_progress_locked()
1178
+ # Changing the View Mode is a deliberate ask to see the pending drafts in
1179
+ # the newly chosen surface, whether or not a panel was open at the time.
1180
+ # When one was open we just dismissed it above; when none was (the user
1181
+ # had closed/snoozed the stack, THEN switched mode) the old behavior left
1182
+ # nothing on screen and the new surface never appeared until a separate
1183
+ # "Review N pending drafts" click -- the 2026-07-17 Nhat report where a
1184
+ # flip to Canvas seemed to do nothing. Present in the new surface either
1185
+ # way, same posture as that menu click: focus=True clears the snooze and
1186
+ # bypasses the reveal-cadence hold. Drop the dedup signature first so the
1187
+ # same pending set presents fresh on the new surface. With no pending
1188
+ # drafts _maybe_start_review no-ops, so setting the mode ahead of time
1189
+ # still doesn't pop anything; if posting is draining it defers and the
1190
+ # backlog presents on the new surface once the drain completes.
1191
+ self._last_review_sig = None
1192
+ try:
1193
+ self._set_snooze(0.0)
1194
+ self._maybe_start_review(focus=True)
1195
+ except Exception as e:
1196
+ sys.stderr.write(f"[s4l-menubar] view mode present failed: {e}\n")
1197
+ sys.stderr.flush()
1149
1198
  self._sig = None
1150
1199
  try:
1151
1200
  self._tick(None)
@@ -1173,7 +1222,9 @@ class S4LMenuBar(rumps.App):
1173
1222
  # as drafts are ready). Manual mode.json edits can set any value; the
1174
1223
  # checkmark then lands on an exact match or none, same as the lane split.
1175
1224
  REVEAL_CADENCE_PRESETS = (
1176
- (0, "Immediately"),
1225
+ # "Immediately" alone read as "clicking this pops cards right now";
1226
+ # it only sets the pacing for when the NEXT draft lands (2026-07-16).
1227
+ (0, "Immediately when a new draft is ready"),
1177
1228
  (1800, "Every 30 minutes"),
1178
1229
  (3600, "Every hour"),
1179
1230
  (14400, "Every 4 hours"),
@@ -1202,7 +1253,7 @@ class S4LMenuBar(rumps.App):
1202
1253
  if round(secs) == preset:
1203
1254
  return label
1204
1255
  m = round(secs / 60)
1205
- return f"Every {m} min" if m else "Immediately"
1256
+ return f"Every {m} min" if m else "Immediately when a new draft is ready"
1206
1257
 
1207
1258
  def _on_cadence_preset(self, secs, _sender=None):
1208
1259
  """Menu callback shim: rumps passes the clicked MenuItem last."""
@@ -2258,16 +2309,14 @@ class S4LMenuBar(rumps.App):
2258
2309
  never confirmed posted (the in-memory _post_q died with the old process).
2259
2310
  Skip any the plan already shows as posted, so a card that landed on X just
2260
2311
  before the kill — but whose status update was lost — isn't posted twice."""
2261
- # Store lane (canonical): approved-but-unposted rows in the review store.
2262
- # Legacy lane: pre-store approved-queue.json entries (read-only now; the
2263
- # ledger is no longer written). Union, store first, dedup by (batch, n).
2312
+ # Store lane ONLY (2026-07-17): approved-but-unposted rows in the review
2313
+ # store, the single source of truth. The legacy approved-queue.json lane
2314
+ # (a read-only third truth source that the ledger stopped writing long
2315
+ # ago) was removed — its entries could never settle, so it re-enqueued
2316
+ # the same dead approvals on every resume forever. If a genuinely
2317
+ # pre-store approval ever resurfaces, it is visible in the store lane
2318
+ # or it is gone; do not re-add the union.
2264
2319
  pending = list(st.store_pending_posts())
2265
- seen = {(it.get("batch"), it.get("n")) for it in pending}
2266
- legacy = [
2267
- it for it in st.approved_queue_pending()
2268
- if (it.get("batch"), it.get("n")) not in seen
2269
- ]
2270
- pending.extend(legacy)
2271
2320
  if not pending:
2272
2321
  return
2273
2322
  posted_ns = set()
@@ -2284,7 +2333,6 @@ class S4LMenuBar(rumps.App):
2284
2333
  for it in pending:
2285
2334
  batch, n = it.get("batch"), it.get("n")
2286
2335
  if n in posted_ns:
2287
- st.approved_queue_set_status(batch, n, "posted") # reconcile lost update
2288
2336
  continue
2289
2337
  decision = {
2290
2338
  "n": n,
@@ -2419,8 +2467,19 @@ class S4LMenuBar(rumps.App):
2419
2467
  if now >= self._loopback_check_due_at:
2420
2468
  self._loopback_check_due_at = now + 60.0
2421
2469
  if st.loopback_reachable():
2422
- if not self._resumed:
2470
+ # Periodic backlog drain (2026-07-17): the menubar is now the
2471
+ # SINGLE owner of approved-backlog recovery. The MCP server's
2472
+ # boot-time drainApprovedBacklog was removed (each queue-worker
2473
+ # session boots an MCP, so "boot-time" had become a 5-minute
2474
+ # cron across concurrent instances). Here it runs on the one
2475
+ # long-lived process instead: on loopback recovery and every
2476
+ # RESUME_PERIOD_S thereafter, but only while nothing is
2477
+ # in-flight (avoids enqueueing a card twice while an earlier
2478
+ # drain still holds it in _post_q). _resume_approved_queue is
2479
+ # idempotent against the store (skips posted/terminal rows).
2480
+ if (not self._resumed or now >= self._resume_due_at) and self._posts_outstanding == 0:
2423
2481
  self._resumed = True
2482
+ self._resume_due_at = now + self.RESUME_PERIOD_S
2424
2483
  try:
2425
2484
  self._resume_approved_queue()
2426
2485
  except Exception as e:
@@ -2755,6 +2814,9 @@ class S4LMenuBar(rumps.App):
2755
2814
  # around HH:MM" line in _state_c).
2756
2815
  round(st.read_reveal_cadence()),
2757
2816
  int(self._reveal_hold_until(pending_count)),
2817
+ # View mode: rebuilds when the review layout changes (checkmark
2818
+ # in the "View Mode" submenu + whether the cadence item shows).
2819
+ st.read_review_layout(),
2758
2820
  )
2759
2821
  if sig != self._sig:
2760
2822
  self._sig = sig
@@ -2878,6 +2940,14 @@ class S4LMenuBar(rumps.App):
2878
2940
  flight, and not snoozed (the snooze label and gate take precedence)."""
2879
2941
  if pending_count <= 0 or self._review_active:
2880
2942
  return 0.0
2943
+ # Canvas never auto-pops (2026-07-17, user direction): the explicit
2944
+ # "Review N pending drafts" click is the ONLY way to open it. Report
2945
+ # the same indefinite hold as the cards-mode "Never" preset so the
2946
+ # menu shows the courtesy line. The reveal cadence itself remains a
2947
+ # cards-mode concept (its menu control is hidden in canvas mode,
2948
+ # 2026-07-16), so a stale cadence value is never consulted here.
2949
+ if st.read_review_layout() != "cards":
2950
+ return -1.0
2881
2951
  now = time.time()
2882
2952
  if now < self._review_snooze_until:
2883
2953
  return 0.0
@@ -2890,8 +2960,9 @@ class S4LMenuBar(rumps.App):
2890
2960
  def _review_mod(self):
2891
2961
  """Which review-surface module drives the review-drafts flow: the
2892
2962
  module that owns the currently open panel/window when one is open
2893
- (so a mid-session switch of the "View" submenu never talks to
2894
- the wrong surface for an already-open one), else the persisted
2963
+ (so nothing ever talks to the wrong surface for an already-open
2964
+ one; a "View Mode" switch swaps the open surface itself, see
2965
+ _on_review_layout_preset), else the persisted
2895
2966
  st.read_review_layout() preference for a FRESH presentation. Both
2896
2967
  modules expose the exact same present_review*/extend_active/
2897
2968
  prune_active/active_status/heal_active/focus_active/dismiss_active
@@ -2997,7 +3068,20 @@ class S4LMenuBar(rumps.App):
2997
3068
  # card keeps growing live) and skipped for focus=True (the menu's
2998
3069
  # "Review N pending drafts" is an explicit ask). _last_review_sig is NOT
2999
3070
  # stamped on a hold, so the same set still presents fresh when due.
3071
+ # No panel is open past the _review_active branch above, so the
3072
+ # preference IS the layout that is about to present.
3000
3073
  if not focus:
3074
+ if st.read_review_layout() != "cards":
3075
+ # Canvas never auto-pops (2026-07-17, user direction): the
3076
+ # explicit "Review N pending drafts" click (focus=True) is the
3077
+ # ONLY way to open the canvas. The extend-open branch above
3078
+ # still grows an already-open canvas live; _last_review_sig is
3079
+ # NOT stamped here, so the whole backlog presents fresh on the
3080
+ # click.
3081
+ return
3082
+ # Cards mode: pace fresh pop-ups by the reveal cadence. The
3083
+ # cadence control is a cards-only concept (hidden while View Mode
3084
+ # is Canvas, 2026-07-16), so it is never consulted for canvas.
3001
3085
  cadence = st.read_reveal_cadence()
3002
3086
  if cadence < 0:
3003
3087
  # "Never" preset: cards never auto-pop, only an explicit
@@ -3286,8 +3370,7 @@ class S4LMenuBar(rumps.App):
3286
3370
  with self._review_lock:
3287
3371
  self._panel_open = False
3288
3372
  # The surface is confirmed gone: release the pin so the NEXT
3289
- # presentation re-resolves from st.read_review_layout() (picks up
3290
- # a "View" submenu switch made while this one was open).
3373
+ # presentation re-resolves from st.read_review_layout().
3291
3374
  self._review_mod_name = None
3292
3375
  if self._posts_outstanding <= 0:
3293
3376
  self._review_active = False
@@ -3510,7 +3593,6 @@ class S4LMenuBar(rumps.App):
3510
3593
  # No "Posting draft N…" banner: the menu-bar spinner already shows
3511
3594
  # live posting progress, so a Notification Center toast per approved
3512
3595
  # card is pure noise. Only failures (below) raise a notification.
3513
- st.approved_queue_set_status(batch, n, "posting")
3514
3596
  with self._review_lock:
3515
3597
  activity_label = self._posting_activity_label_locked()
3516
3598
  cl = [n] if decision.get("drop_link") else None
@@ -3542,32 +3624,25 @@ class S4LMenuBar(rumps.App):
3542
3624
  # one registered). Unlike a real posting failure, nothing
3543
3625
  # was ever attempted here — post_drafts never reached a
3544
3626
  # server — so there's no double-post risk in retrying.
3545
- # Deliberately do NOT call store_mark_post_failed / set
3546
- # status "failed": both would exclude this card from
3547
- # store_pending_posts()/approved_queue_pending(), which is
3627
+ # Deliberately do NOT call store_mark_post_failed: it would
3628
+ # exclude this card from store_pending_posts(), which is
3548
3629
  # exactly what _resume_approved_queue() reads on the next
3549
- # unreachable->reachable edge. Leaving it "queued" lets
3550
- # that existing restart-recovery path retry it
3551
- # automatically once a server re-registers, instead of
3552
- # stranding it for manual dashboard retry.
3553
- st.approved_queue_set_status(batch, n, "queued", error="loopback_unreachable")
3630
+ # unreachable->reachable edge. Leaving the store row
3631
+ # approved-unposted lets that existing restart-recovery
3632
+ # path retry it automatically once a server re-registers,
3633
+ # instead of stranding it for manual dashboard retry.
3554
3634
  self._notify(
3555
3635
  "S4L", "Server unreachable — will retry automatically once Claude Desktop reconnects."
3556
3636
  )
3557
3637
  else:
3558
3638
  posted = res.get("posted") if isinstance(res, dict) else None
3559
3639
  if posted == 0:
3560
- st.approved_queue_set_status(batch, n, "failed", error="posted_0")
3561
3640
  st.store_mark_post_failed(batch, n, decision.get("candidate_id"), "posted_0")
3562
3641
  self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
3563
- else:
3564
- # Success is silent: the spinner + dashboard already reflect
3565
- # it. No per-card "Posted draft N." banner. The server
3566
- # stamps posted/our_url into the store itself; the legacy
3567
- # set_status only settles pre-store ledger entries.
3568
- st.approved_queue_set_status(batch, n, "posted")
3642
+ # Success is silent: the spinner + dashboard already reflect
3643
+ # it. No per-card "Posted draft N." banner. The server
3644
+ # stamps posted/our_url into the store itself.
3569
3645
  except Exception as e:
3570
- st.approved_queue_set_status(batch, n, "failed", error=str(e)[:200])
3571
3646
  st.store_mark_post_failed(batch, n, decision.get("candidate_id"), str(e)[:200])
3572
3647
  sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
3573
3648
  sys.stderr.flush()
@@ -3634,20 +3709,79 @@ class S4LMenuBar(rumps.App):
3634
3709
  pass
3635
3710
 
3636
3711
  def _render_title(self, setup_complete, ob, blocker, attention=False, pending_count=0):
3637
- if blocker or attention:
3638
- base = "S4L ⚠" # warning (setup blocked OR autopilot needs attention)
3639
- elif not setup_complete and ob and not ob.get("complete"):
3712
+ # Base label: onboarding progress ("S4L 2/5") while setup is still
3713
+ # running, plain "S4L" once complete.
3714
+ if not setup_complete and ob and not ob.get("complete"):
3640
3715
  done = sum(1 for m in ob["milestones"] if m.get("status") == "complete")
3641
3716
  base = f"S4L {done}/{len(ob['milestones'])}"
3642
- elif self._update_available:
3643
- base = "S4L ⬆" # update available — open the menu to update
3644
3717
  else:
3645
3718
  base = "S4L"
3719
+ # Status markers are INDEPENDENT — each appends its own glyph when active
3720
+ # so a warning and an available update (and anything added later) surface
3721
+ # at the same time (user request 2026-07-17). They used to be mutually
3722
+ # exclusive if/elif branches, where a warning hid a pending update and an
3723
+ # update hid the onboarding count. Order is fixed (warning before update)
3724
+ # so the bar doesn't reshuffle its glyphs as states toggle.
3725
+ if blocker or attention:
3726
+ base += " ⚠" # warning (setup blocked OR autopilot needs attention)
3727
+ if self._update_available:
3728
+ base += " ⬆" # update available — open the menu to update
3646
3729
  # Concise inline backlog count, right on the bar (not just the dropdown's
3647
3730
  # "Review N pending drafts") — per user request 2026-07-15, most useful
3648
3731
  # when the reveal cadence is "Never" and this count is the only signal
3649
3732
  # that drafts are waiting.
3733
+ # Setting self.title first lays down the PLAIN title (rumps clears any
3734
+ # prior attributed title via setTitle_), so a backlog that drops back
3735
+ # under the threshold automatically loses its highlight without extra
3736
+ # bookkeeping. _highlight_title_count then re-decorates just the number
3737
+ # when the backlog is large enough to warrant a colored pill.
3650
3738
  self.title = f"{base} · {pending_count}" if pending_count > 0 else base
3739
+ if pending_count > 5:
3740
+ self._highlight_title_count(base, pending_count)
3741
+
3742
+ def _highlight_title_count(self, base, pending_count):
3743
+ """Draw a colored background pill behind the pending-draft NUMBER in the
3744
+ menu-bar title once the backlog gets large: yellow for >5 (6-10), red
3745
+ for >10 (11+). Per user request 2026-07-17 — a plain count is easy to
3746
+ miss, a colored one pulls the eye when drafts are piling up. Applied as
3747
+ an NSAttributedString on the status item's button; on any failure we
3748
+ leave the plain title (already set by the caller) untouched."""
3749
+ try:
3750
+ from AppKit import (
3751
+ NSColor,
3752
+ NSMutableAttributedString,
3753
+ NSForegroundColorAttributeName,
3754
+ NSBackgroundColorAttributeName,
3755
+ )
3756
+ from Foundation import NSMakeRange
3757
+
3758
+ item = getattr(self._nsapp, "nsstatusitem", None)
3759
+ if item is None:
3760
+ return
3761
+ button = item.button() if hasattr(item, "button") else None
3762
+ if button is None:
3763
+ return
3764
+
3765
+ if pending_count > 10:
3766
+ bg = NSColor.systemRedColor()
3767
+ fg = NSColor.whiteColor()
3768
+ else:
3769
+ bg = NSColor.systemYellowColor()
3770
+ fg = NSColor.blackColor()
3771
+
3772
+ num = str(pending_count)
3773
+ # Pad the number with hair spaces so the colored pill has a little
3774
+ # breathing room around the digits instead of hugging them tight.
3775
+ pill = f" {num} "
3776
+ full = f"{base} · {pill}"
3777
+ attr = NSMutableAttributedString.alloc().initWithString_(full)
3778
+ start = full.rfind(pill)
3779
+ rng = NSMakeRange(start, len(pill))
3780
+ attr.addAttribute_value_range_(NSBackgroundColorAttributeName, bg, rng)
3781
+ attr.addAttribute_value_range_(NSForegroundColorAttributeName, fg, rng)
3782
+ button.setAttributedTitle_(attr)
3783
+ except Exception:
3784
+ pass
3651
3785
 
3652
3786
  # ---- menu construction ------------------------------------------------
3653
3787
  def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok", pending_count=0):
@@ -3811,21 +3945,37 @@ class S4LMenuBar(rumps.App):
3811
3945
  split_menu.add(it)
3812
3946
  items.append(split_menu)
3813
3947
 
3948
+ # View mode: which review surface presents drafts — the one-at-a-time
3949
+ # corner card or the large centered multi-select canvas. ALWAYS
3950
+ # visible (2026-07-16, moved out of _state_c where it only appeared
3951
+ # with pending drafts) so the preference can be set ahead of time;
3952
+ # flipping it while a surface is open swaps that surface live (see
3953
+ # _on_review_layout_preset).
3954
+ layout_now = st.read_review_layout()
3955
+ view_menu = rumps.MenuItem("View Mode")
3956
+ for key, label in (("cards", "Cards"), ("canvas", "Canvas")):
3957
+ it = rumps.MenuItem(label, callback=functools.partial(self._on_review_layout_preset, key))
3958
+ it.state = 1 if layout_now == key else 0
3959
+ view_menu.add(it)
3960
+ items.append(view_menu)
3961
+
3814
3962
  # Reveal cadence: how often fresh draft cards may pop (the drafting
3815
- # pipeline is unchanged; this only paces the pop-up). Always offered:
3816
- # it is the "don't interrupt me every few minutes" control.
3817
- cadence = st.read_reveal_cadence()
3818
- cadence_menu = rumps.MenuItem(
3819
- f"Show draft cards: {self._cadence_label(cadence).lower()}"
3820
- )
3821
- for preset, label in self.REVEAL_CADENCE_PRESETS:
3822
- it = rumps.MenuItem(
3823
- label,
3824
- callback=functools.partial(self._on_cadence_preset, preset),
3963
+ # pipeline is unchanged; this only paces the pop-up). Cards mode only
3964
+ # (2026-07-16): the pacing control is about the corner card
3965
+ # interrupting, so it is hidden while View Mode is Canvas.
3966
+ if layout_now == "cards":
3967
+ cadence = st.read_reveal_cadence()
3968
+ cadence_menu = rumps.MenuItem(
3969
+ f"Show draft cards: {self._cadence_label(cadence).lower()}"
3825
3970
  )
3826
- it.state = 1 if round(cadence) == preset else 0
3827
- cadence_menu.add(it)
3828
- items.append(cadence_menu)
3971
+ for preset, label in self.REVEAL_CADENCE_PRESETS:
3972
+ it = rumps.MenuItem(
3973
+ label,
3974
+ callback=functools.partial(self._on_cadence_preset, preset),
3975
+ )
3976
+ it.state = 1 if round(cadence) == preset else 0
3977
+ cadence_menu.add(it)
3978
+ items.append(cadence_menu)
3829
3979
 
3830
3980
  # Posting volume: server-side per-install throttle (virality bar).
3831
3981
  # read_posting_mode() is cached and non-blocking; unknown/unset shows
@@ -3973,20 +4123,8 @@ class S4LMenuBar(rumps.App):
3973
4123
  callback=self._review_now,
3974
4124
  )
3975
4125
  ]
3976
- # Which review surface "Review N pending drafts" opens: the one-at-a-
3977
- # time corner card, or the large centered multi-select canvas
3978
- # (2026-07-15). A submenu of checkmarked options (same pattern as
3979
- # "Lane split" above), not a standalone on/off checkbox, and grouped
3980
- # with the review action itself rather than the always-visible
3981
- # engagement-lane toggles, since it only matters while there's
3982
- # something to review.
3983
- view_menu = rumps.MenuItem("View")
3984
- layout_now = st.read_review_layout()
3985
- for key, label in (("cards", "Cards"), ("canvas", "Canvas")):
3986
- it = rumps.MenuItem(label, callback=functools.partial(self._on_review_layout_preset, key))
3987
- it.state = 1 if layout_now == key else 0
3988
- view_menu.add(it)
3989
- items.append(view_menu)
4126
+ # The review-surface picker ("View Mode") lives in _build_menu now
4127
+ # (2026-07-16): always visible, not just while something is pending.
3990
4128
  if time.time() < self._review_snooze_until:
3991
4129
  items.append(
3992
4130
  self._label(
@@ -4003,7 +4141,12 @@ class S4LMenuBar(rumps.App):
4003
4141
  # now regardless.
4004
4142
  hold_until = self._reveal_hold_until(pending_count)
4005
4143
  if hold_until < 0:
4006
- items.append(self._label("Cards hidden — see count in the menu bar"))
4144
+ if st.read_review_layout() == "cards":
4145
+ items.append(self._label("Cards hidden — see count in the menu bar"))
4146
+ else:
4147
+ # Canvas mode: it never auto-opens; the review item above
4148
+ # is the one way in.
4149
+ items.append(self._label("Canvas opens from the review item above"))
4007
4150
  elif hold_until:
4008
4151
  items.append(
4009
4152
  self._label(