Timeline menu: web pulldown → native NSMenu (Mac app)

The split control at the end of the transcript's sticky row stays the web's. In the Mac app only, its caret pops a real AppKit menu under the control, with the same choices: the chain/timeline pair, then the timeline detail, each detail with its lanes as a subtitle. Browser and HTML export keep today's web menu.

Artefact: pixels as they would ship (no annotations inside) Commentary: why, and what is still open Paragraphs under the row are sample transcript text, there so the menu's material has something to blur.
Palette Try click the caret to close or reopen a menu; click an item to pick it

1 · Today and proposed, light and dark

Same state in all four: Session timeline on, Standard detail. Left column is the shipped web menu. Right column is the NSMenu the caret would pop in the Mac app, right edge to the control's right edge (part 2 compares the two alignments).

2 · Where the menu hangs: the control sits at the window's trailing edge

The split control is pinned to the right end of the content column. The hatched area is outside the window.

Commentary · alignment

A · left edge under the control's left edge is how a Mac pull-down opens (the Gruber pass recommended it, medium confidence): the menu belongs to the whole control. Here that runs the menu past the window's edge, which AppKit allows; it only keeps a menu on the screen, not inside its window. With the tag sidebar open, it would also cover the sidebar.

B · right edge to the control's right edge is what the web menu does today, and stays inside the window. AppKit will not do it by itself: native computes x = anchor.maxX − menu.size.width before popping, which is one line.

Decided: B (owner, 18 Oct 2026). The control is a trailing-edge control, and B keeps the menu over the content it changes.

3 · Review

Commentary · review before build

Verdict: yes, make the menu native; keep the button web (option A)

  • Why the menu: WebKit already pops an NSMenu for a <select>, so a web control opening a native menu is how a WKWebView normally behaves. The menu gets the material, type-select, VoiceOver menu semantics, dismissal when the app deactivates, and it can extend outside the web view.
  • Why not the button (option B, a SwiftUI Menu with primaryAction in the toolbar): it moves the control away from the slot it changes, adds a per-route toolbar item (the toolbar's overflow rules are documented as fragile), and the menu bar already has View ▸ Show/Hide Session Timeline and View ▸ Timeline Detail ▸.
  • Why not leave it (C): it is the one web-drawn menu left in a place the Mac user reaches for with the pointer every session.

It is mostly wiring that exists

PieceStatusWhere
Actions: toggle, set lanes (2/3/5)shippedmenuAction("toggleSessionTimeline"), ("setSessionTimelineLanes", {lanes}), handled by applyTimelineMenuAction in TranscriptTimelineStore.ts
State mirrorshippedtimeline-state → BridgeHandler.timelineOn / timelineLanes / timelineAvailable
Web rect → web-view coordinatesshippedPersonPickerPresenter.viewRect(for:in:) (safe-area inset + zoom; the trap it fixes is in desktop/CLAUDE.md)
NSMenu popped under an anchor, target/action closuresshipped patternSearchFieldViews.swift ~300–318
Bridge message timeline-menu {anchor}newweb → native, posted by the caret when isEmbedded(), instead of opening the list
Menu buildernew, smalla pure function from (on, lanes, i18n) to items, so it gets a unit test (the house rule: a decision lives in a helper, not a view)
Closed echonewnative → web when the menu closes: aria-expanded back to false and focus back on the caret. popUp is synchronous, so this is one call after it returns

Pick mapping: User journey or Session timeline sends the toggle only when it differs from the current state; a detail sends set-lanes, which also turns the timeline on (the store already does that). Pass positioning: nil: passing the ticked item makes it a pop-up, which centres that item over the control instead of opening below it.

Decided, and still open

  1. Alignment: B, part 2 (owner, 18 Oct 2026).
  2. Casing: the web's sentence case, accepted (owner, 18 Oct 2026). Mac menus are title case and the View menu says Show Session Timeline; the menu reads the web's strings, so it needs no new keys. As built, the section header is the web's Timeline detail (the caret's own name), not the menu bar's Timeline Detail, because the SPA does not read the desktop namespace.
  3. Subtitle wording. Drawn with the shipped hints. As a subtitle, "+ Sentiment" reads as web shorthand; the Gruber pass suggested words ("Adds sentiment"). That is new copy, so not drawn.
  4. The section header. Built. Without it the separator alone groups the details, as many Mac pull-downs do.
  5. Parity. View ▸ Timeline Detail ▸ has no hints. Whether a SwiftUI Commands menu item shows a subtitle needs a build to see; if it doesn't, the hints live only here, which is a fair asymmetry.

Strings

WhereText (en)KeyStatus
Radio 1User journeycommon.sessions.colJourneyshipped
Radio 2Session timelinecommon.sessions.tapestry.labelshipped
Section headerTimeline detailcommon.sessions.tapestry.detailshipped (the caret's own name)
DetailsCompact · Standard · Fullcommon.sessions.tapestry.compact / standard / fullshipped
SubtitlesSections · Moderator · Participant / + Sentiment / + Themescomposed from quotes.sections, sessions.speakerPlaceholder.*, sessions.colSentiment, quotes.themesshipped composition

No new keys.

What this mockup cannot show

The NSMenu is drawn by hand from measured metrics (24 pt rows and a 22 pt check column, PickerMetrics, macOS 27). macOS 15 draws 22 pt rows and a smaller corner radius; the section header's exact type and indent are approximated. Check against a real NSMenu in Diagnostics ▸ Picker Lab before trusting a pixel here.