← all docs · north-os-meeting-card-pill-spec8 sept. 2026

Meeting recording card — the pill — design spec

Status: implemented Author: Claude (Opus), for Jeremy Date: 2026-09-08 Repo: north-os Related: NOS-332 (the North card replaces the meeting toasts), NOS-338 (the recording card is restored after any dismissal), NOS-341 (per-kind dwell), PR #278 (the live level bar), PR #382 (notification identity)

Why

Jeremy reported a visual bug on the Windows meeting recorder: the notification bottom-right does not size correctly around its text and its call to action, and clicking Record does not minify anything — one large card is replaced by another large card that then sits on screen for the whole meeting.

Two separate defects came out of reading apps/polaris-win/NorthCardWindow.xaml and reproducing the card at 1:1 against the real strings.

D1 — the title can never wrap, so long titles are clipped

TitleText lives in a horizontal StackPanel (NorthCardWindow.xaml, the title row). A horizontal StackPanel measures its children with infinite width in the stacking direction, so TextWrapping="Wrap" on that TextBlock can never engage: the text lays out on one line at its natural width and whatever runs past the card's fixed 332 px is clipped by CardRoot.

The macOS twin does not have this bug — MeetingRecordPromptPanel.swift's titleRow uses .fixedSize(horizontal: false, vertical: true), which is exactly "wrap horizontally, grow vertically".

The titles at risk are the longest ones, and they are the ones that matter most because they carry failures:

Whether a given title clips depends on Georgia's real metrics at 16 px SemiBold against 302 px of content width (332 − 2 border − 28 padding), minus ~15 px when a glyph is present. That measurement is not reproducible on Linux CI, which is precisely why the fix must be structural rather than a copy trim: the title must be allowed to wrap, and then no title can ever be cut off regardless of the display language or a future longer app name.

D2 — the recording card never minifies

MeetingRecording.ShowRecordingCard shows a NorthCardKind.Recording card with autoDismissSeconds: null — it persists for the entire meeting. Its body is MeetingConsentCopy.RecordingStartedBody(...), the full scope-and-destination disclosure, and its single "Stop recording" action falls into the actions.Count <= 2 branch of PopulateButtons, which builds a one-column Grid — so the button stretches the full card width.

The result is the tallest card in the app, pinned bottom-right for two hours, restating a sentence the lawyer consented to before the recording started.

The decision

The recording indicator becomes two stages. Jeremy reviewed four minified candidates rendered live at 1:1 and picked the pill.

Stage 1 — the confirmation, unchanged, for 8 seconds

Exactly today's card: context, "Recording", the full RecordingStartedBody disclosure, the pulsing dot, the running timer and the live level bar, and the Stop action.

This stage is not negotiable down to zero. The sentence it carries is the consent disclosure — what is being captured and where it goes — and MeetingConsentCopy exists specifically so that disclosure is derived from real runtime state rather than a literal. It must be readable at least once per recording. Eight seconds is the dwell: the sentence runs to about 25 words, which is over seven seconds of reading at an ordinary pace.

Stage 2 — the pill, for the rest of the meeting

A compact bar carrying only what is live information:

It drops the North mark, the NORTH eyebrow, the context, the title and the body. Which call is being recorded stays available on the tray tooltip, which MeetingRecording already keeps current every second (North — recording {app} · {elapsed}).

What stage 2 must keep, and why

The level bar survives the minification. It is the only element on this surface that distinguishes "the recorder is running" from "the recorder is hearing something", and that distinction is the entire reason PR #278 shipped: the Windows client lost roughly one recording in four between v1.4 and v1.6 while looking perfectly healthy, because the only in-recording health rule asked whether audio frames arrived, never whether they carried signal. A minified indicator that shows a pulsing dot and a running clock reproduces exactly the reassuring-but-empty surface that failure mode already defeated once.

Geometry

PropertyValueNote
Height38 px24 px stop button + 6 px vertical padding × 2 + 2 px border
Widthnatural — ~180 px Windows, ~182 px macOSthe window is sized to the content. The two platforms differ by design: the clock is Consolas 11 px on Windows and SF Mono 11 pt on macOS, whose advance width is wider. Matching the numbers would mean padding one platform to the other's font metrics.
Padding6, 8, 6, 12tighter on the right, where the stop button already carries its own inset
Gap9 pxsame rhythm as the card's recording row
Corner radius8 pxsee the constraint below — not a true capsule
Dot7 pxunchanged from the card
TimerConsolas 11 px / SF Mono 11 pt, ink-softmin-width sized to 0:00:00 so the pill does not resize as the clock rolls past the hour: 44 px on Windows (7 glyphs × ~6.05 px), 48 pt on macOS (7 × ~6.6 pt). One number for both would be wrong on one of them.
Level track / fill56 × 4 px, radius 2same brushes: #29C0442E track, #C0442E fill
Stop button24 × 24, radius 6hairline border, 8 px ink square glyph, ink wash on hover
PlacementunchangedNorthCardPlacement.BottomRight already handles any size

Constraint: the corner radius cannot be a capsule

The card window is opaque. Its silhouette is rounded by DWM (DWMWA_WINDOW_CORNER_PREFERENCE = DWMWCP_ROUND in WindowChrome.MakeBorderless), which on Windows 11 is an 8 px radius and is not adjustable. Today's CornerRadius="8" on CardRoot coincides with it, which is why the card reads as a single clean shape.

A 19 px XAML radius on a 38 px-tall window would draw a capsule inside a window whose corners DWM still cuts at 8 px, exposing the window's own backdrop in the four gaps. A true capsule needs a layered window (WS_EX_LAYERED + UpdateLayeredWindow), which cannot host WinUI XAML content — a different rendering path entirely, and out of scope here.

So the pill ships at radius 8. At 38 px tall that reads as a soft-cornered chip rather than a capsule. This is a platform constraint, not a design preference; if the capsule silhouette turns out to matter, it is a separate piece of work with a real cost.

The same constraint explains an apparent drift that is not one: the macOS card uses radius 14 because its NSPanel is genuinely transparent. Windows cannot match it. The width difference — 332 on Windows against 340 on macOS — is real drift and is aligned to 340 in this change.

Invariants the implementation must hold

These are the parts where a naive implementation produces a visible regression.

  1. The collapse never restarts the timer or the meter. Going through Populate again would call StopRecordingVisuals() — which zeroes _meterDisplayLevel and LevelBarScale.ScaleX and stops the storyboard — and re-seed _recordingStartedAt. The elapsed time would jump and the meter would blink to flat. The collapse must swap presentation inside the same window, leaving _recordingStartedAt, _meterProvider, _recordingTimer, _meterTimer and _dotStoryboard untouched and running.
  2. A restore comes back as stage 2, not stage 1. NorthCard.OnCardDismissedMeetingRecording.RestoreRecordingCardIfRecording fires after every card dismissal while a recording is live (NOS-338): a capture warning, the "You've left the meeting" Keep, a recovery-sweep Done. Re-showing the full disclosure card each time is the regression this change exists to remove. ShowRecordingCard takes the stage, and the restore path passes stage 2.
  3. Stage 1 always runs for a genuinely new recording. The disclosure is shown once per recording start, never skipped because a previous recording had already shown it.
  4. The stage-1 timer is cancelled on stop. If a recording ends inside the 8 s window, nothing may collapse a card that is gone. HideCard and StopRecordingVisuals stop it like every other timer.
  5. The card re-anchors after the collapse. The window resizes to the visible stage and pins bottom-right. This invariant was written wrong and was corrected during implementation — see “Corrections to this spec” below. It originally said the existing CardRoot.SizeChangedFitAndPlace path would carry the change unmodified. It cannot: a collapsed CardRoot measures 0.
  6. The window still never takes the foreground. The collapse must not call Activate(), and must not re-enter the first-show path — _renderedOnce stays true.
  7. Reduce-motion is respected the way the app already respects it. The dot's pulse stops when system animations are off; the level meter keeps running, because it is information rather than decoration. The collapse itself should not animate under reduce-motion.

Shape of the change

FileChange
apps/polaris-win/NorthCardWindow.xaml The title row becomes a two-column Grid (Auto for the glyph, * for the title) so TextWrapping="Wrap" finally applies — D1. CardRoot width 332 → 340. A second visual tree for the pill, collapsed by default, as a sibling of CardRoot.
apps/polaris-win/NorthCardKind.cs A pure NorthCardStage next to NorthCardDwell and NorthCardSlot: the stage enum, the stage-1 dwell constant, and the decision function for which stage a given show lands in. Pure means it link-compiles into tests/Polaris.Win.Tests and is covered on Linux CI, like every other pure piece of this card.
apps/polaris-win/NorthCardWindow.xaml.cs A collapse timer armed when a stage-1 recording card is shown, swapping visibility between the two trees without re-entering Populate. ShowCard carries the stage through. CardState carries it too, so a stashed-prompt restore replays the right stage.
apps/polaris-win/Meetings/MeetingRecording.cs ShowRecordingCard takes a stage; Start passes stage 1, RestoreRecordingCardIfRecording passes stage 2.
apps/polaris-win/tests/Polaris.Win.Tests/NorthCardTests.cs Stage selection, the stage-1 dwell, and the "a restore is stage 2" rule, next to the existing slot and dwell tests.
apps/polaris/leanring-buddy/Meetings/MeetingRecordPromptPanel.swift The same two stages on macOS, or the two platforms drift apart again a week after being aligned. Radius stays 14 there — see the constraint above.

Out of scope

Corrections to this spec

Written after implementation and three rounds of adversarial review. Kept here rather than silently edited, because each one is a thing the next reader would otherwise re-derive the hard way.

Invariant 5 was wrong

It said the existing CardRoot.SizeChangedFitAndPlace path would carry the collapse "unmodified. Verify it, do not reimplement it." That is false: once CardRoot is Collapsed its ActualWidth/ActualHeight are 0, and the fallback would have sized the window to the card while the pill floated inside it. A host container was genuinely required.

The first attempt — wrapping both stages in a bare Grid and measuring that — replaced the defect with a worse one. A parent panel never sees its child's UnclippedDesiredSize; it reads DesiredSize, which the framework clips to the availableSize passed to Measure. The window became a ratchet that could only shrink: after the first collapse it was 176 × 38, and every later card — a capture warning, "Sign in to send", an upload failure, the NOS-338 restore — would have been measured into that strip for the rest of the process lifetime. The whole notification surface, lost eight seconds into a recording.

As built: RootHost carries explicit Auto row and column definitions (an Auto track measures its child at infinity, so nothing is clipped), and FitAndPlace stopped reading any container: it measures the visible stage root itself at infinity and takes its DesiredSize. Either half alone is sufficient; both are present because no one on this project can run a WinUI layout pass.

The two halves hold each other. Putting the tracks back to 1* while keeping the infinite measure makes the layout pass return the clipped size and FitAndPlace the natural one, each invalidating the other's answer — WinUI ends that with Layout cycle detected. The same coupling applies to CardRoot's fixed Width="340": an adaptive width becomes window-dependent, and the loop returns. Change one only with the other.

Stage 1 does not mean the same thing on both platforms

The spec described stage 2 as a strict subset of stage 1. True on Windows; false on macOS, where the recording card has never had a Stop button at all (showRecordingCard passes no actions — stopping goes through the menu bar). The pill's stop therefore rides a separate onStop: channel used only by stage 2, and the macOS pill adds an affordance its stage 1 lacks.

Open decision for kwiss: whether the macOS stage-1 card should gain a Stop button for parity. Deliberately not taken in this change — the macOS card never had one, so the pill is a net gain rather than a regression, and adding a control to a surface that was signed off visually is a product call, not an implementation detail.

macOS replays stage 1 on a path Windows does not have

onSystemAudioResolved re-shows the card with a corrected disclosure ("Recording — microphone only") when the system resolves capture scope late. That is a consent sentence the user has not read, so it opens on stage 1 with a fresh 8 s dwell. Windows has no equivalent — its scope is known synchronously at start. The two platforms therefore replay stage 1 at different moments, on purpose.

The NOS-338 restore hook does not exist on macOS

Invariant 2 is written as though the global "restore the recording card after any dismissal" hook were present on both platforms. It is not. On Windows NorthCard.OnCardDismissed fires after every dismissal; on macOS restoreRecordingCardIfRecording() is called from exactly two places, both tied to the "Still recording?" check. A capture warning or an upload failure that auto-dismisses does not bring the recording card back at all. Pre-existing, out of scope here, and stated so the "same two stages on both platforms" claim is not read as full parity.

What the tests prove, and what they do not

The stage and collapse policy is pure (NorthCardStaging, NorthCardCollapse and their Swift twins) and covered on Linux CI, including a generation ticket that makes a queued collapse from one recording unable to reach the next — the failure mode that would skip a consent disclosure. Those tests were checked by mutation: reintroducing each defect fails them.

Invariant 1 is argued, not proven. Replacing the collapse body with a Populate(...) — exactly the regression it forbids — leaves every test green, because no pure test can see inside a WinUI method. The preservation is structural instead (one storyboard driving both dots, one _recordingStartedAt, one smoothed level written to both stages every tick), which is a strong reading and not a proof. Neither WinUI nor Xcode builds on the machine this was written on: no pixel of this change has been seen. First run on a real Windows machine should watch the collapse at t=8 s, then trigger any second card.