Cloud import — window states

Mockup for docs/design-cloud-import.md §6/§9. Teams, v1, macOS-only. Image-Capture-shaped window, not a sheet — a sheet is window-modal, so it would hide the sidebar-row progress behind itself and destroy the per-row outcomes that recovery depends on.

14 Aug 2026 · nine states · light/dark aware · not pixel-final, shape-final

1Not signed in — the resting state

This is what most launches see. Absence is information: one line of what it does, one button, no billboard.

Import from Teams
􀈄
Not signed in
Use your work or school account to see meetings you recorded in Teams, and bring them into a project.
Sign in with Microsoft

This button is Microsoft's, not ours. Their branding guidelines permit exactly two strings — “Sign in with Microsoft”, or “Sign in” if space is tight — so the earlier “Sign in to Microsoft” was not a permitted variant. The logo is required, unaltered, and ships as an official SVG to download rather than redraw (the one above is a stand-in for the mockup). Light and dark schemes are both provided.

“Work or school account” is also Microsoft's term, and it is mandatory. They ask for it alongside the button so users recognise whether it applies to them, and explicitly forbid “enterprise account”, “business account” and “corporate account”. Also forbidden in end-user UI: Azure and Active Directory — fine in this doc and with IT admins, never on screen.

Before that button: the TCC.loctable pre-announcement of macOS's own “Bristlenose” Wants to Use “microsoftonline.com” to Sign In alert — quoting Apple's exact wording per the shipped MCP pattern, because recognition is the whole mechanism. Microsoft publishes the equivalent for their own strings — a Terminology Search and a UI String Search — so the 20 localisations of this button should be looked up, not machine-translated. Same trick, second vendor.

2Loading the list

A 30-day calendar window joined against /Recordings is not instant. In-place progress, not a spinner over blank.

Import from Teams
martin@144a.org
Looking at the last 30 days…

3The list — the hero state

Most-recent-first by default, because the researcher opened this to find last week. Expiry is a column, not the sort — the triage queue is a second intent. Ticks are intent; the blue row highlight is gone.

Import from Teams
St Mary's Trust · martin@144a.org · last 30 days
MeetingDate ▾ · BST LengthExpiresStatus
P07 Interview — ward handover
Sarah Chen · J. Whitfield +4
Wed 12 Aug
16:00
58 minin 58 days
P06 Interview — ward handover
Margarethe Okafor-Whitcombe · A. Bianchi +3
Wed 12 Aug
14:30
1 h 04in 57 days
Weekly sync — design
6 attendees
Wed 12 Aug
09:30
27 minin 57 days
P05b Interview — ward handover
D. Achebe · S. Chen +2
Tue 11 Aug
16:30
1 h 09in 56 daysView only
P05 Interview — ward handover
J. Whitfield
Tue 11 Aug
11:00
51 minin 56 days
P04 Interview — triage
D. Achebe
Thu 6 Aug
15:15
1 h 11in 52 days
P03 Interview — triage
L. Fitzgerald
Tue 4 Aug
10:00
55 minin 50 daysDamaged
P02 Interview — triage
K. Lindqvist
Fri 27 Jun
13:00
1 h 02in 12 daysOn Dropbox
P01b Interview — triage
R. Nakamura
Mon 23 Jun
14:00
44 minin 8 daysOn “T7”
P01 Interview — triage
R. Nakamura
Fri 20 Jun
09:00
49 minin 5 days
11 meetings in window · 8 you can fetch · 3 organised by someone else
Project: Ward Handover Study Import 3 Recordings

Checkboxes, not row selection. Image Capture and Photos use selection alone — but those are transient: pick and immediately import. This list carries durable per-row state and, more decisively, the workflow is filter, then pick. Under selection semantics, ticking three under “Interview”, clearing the filter and adding two more is fragile and invisible. Ticks make it obviously durable. The discipline that survives from the selection argument is one model only: ticks are intent, highlight is keyboard focus (row 1), ⌘A ticks the filtered set, space toggles the focused row.

Who was in it, free. We're already fetching the roster for §1's priority 3, so attendees can show before import — which also patches the naming-discipline hole: a call titled “Weekly sync” or “Chat with Sarah” won't survive a title filter, but you'll recognise it from the people. Displaying is not persisting — only attendees the researcher promotes to participants ever reach disk.

One line, always — the content bends, the geometry doesn't. A realistic session is a moderator, two observers and three participants, with names that don't fit. Wrapping gives variable row heights and kills scanning; silent truncation loses the count. So it degrades in a fixed order, and the order comes from asking what the line is for: identifying which call this is.

Names in the list, emails never. Emails are a re-identification key (§9) and are also unscannable — a column of firstname.lastname@clientco.com is uniform noise. They earn their place at the “who is p1?” promotion step, where telling two Sarahs apart actually needs one. Full attendee list on hover as a tooltip; the real disclosure with roles and addresses belongs to the promotion step, which isn't drawn yet.

Known lever, not built: two-line rows cost vertical space at 40 recordings. Mail's preview-lines setting is the precedent if a compact mode is ever wanted — noted so it isn't rediscovered as a bug.

“View only” is a seventh state and the only one with no remedy. An admin policy (BlockDownloadFileTypeIds TeamsMeetingRecording, SharePoint Advanced Management) gives browser-only playback with, in Microsoft's own words, “no ability to download or sync files or access them through apps” — that last clause is Graph, so the API is blocked, not just the button. For channel meetings under it, even the organiser is view-only unless they're also a channel owner. So: no checkbox at all — there is nothing to tick, and offering one would be a lie. It must be resolved at list time, not as a 403 after the user ticked twenty rows. Note this row also has no manual fallback: the researcher can't download it by hand either, so drag-drop rescues nothing.

“Project:” labels the destination. Unlabelled, a project name sitting in the footer reads as a status caption rather than a control. Colon-terminated noun to the left of the control is the standard form — Print's Printer: and Presets:, the save panel's Where:.

Button placement, checked against HIG rather than assumed. “Always place the default button on the trailing side of a row… Cancel buttons are typically on the leading side.” So Import sits trailing and prominent, and there is no Cancel — this is a window, so you close it. Two consequences already drawn: Stop during a fetch is a plain button, not prominent, because HIG says not to give the primary role to a destructive action; and titles are verbs carrying the count (Import 3 Recordings, Retry 2) rather than OK, since “a specific button title… helps people understand the action they're taking.”

The three-way import state, in three rows. P04 is imported and present — checked, disabled, nothing to do. P03 is imported but missing-or-unverifiable — unchecked and enabled, because a moved or truncated file is exactly the case that should re-fetch. Everything else is not imported. Re-importing a file that is present and fine is a deliberate rarity: right-click ▸ Import Again, not a default affordance. And the tick means “already in this destination”, so changing the project below re-evaluates the column.

“Meeting” is the calendar event title — remote data, not editable here, and not a description field. With no calendar match it falls back to the title parsed from the recording filename. Renaming happens after import, on the session.

Time, because dates don't disambiguate. UR batches sessions — P06 and P07 are both Wednesday, which is the normal case, not the edge one. The zone is stated once in the header rather than on every row; times render in the researcher's current local zone, as Calendar.app does. Internally the window must be computed in a pinned zone: calendarView honours Prefer: outlook.timezone, and a UTC-vs-local mismatch shifts the boundary by up to a day, silently dropping a 9am Monday interview out of “last 30 days”.

Year appears only when it differs from the current one — Finder and Mail's rule. Eleven months of the year it is noise; across the New Year it is essential. A 20 Dec – 19 Jan window renders Fri 27 Dec 202513:00 beside Tue 7 Jan09:30 and the boundary needs no explaining. The same rule covers §9's “go back further” search, which can span years — there every row carries its year, which is uniform rather than cluttered. Format through the system formatter, never a hand-rolled pattern: day-name and month ordering differ by locale, and the expiry countdown beside it (“in 5 days” / “in 1 day”) needs CLDR plurals rather than string concatenation.

Resizeable, with a remembered frame (setFrameAutosaveName) — Image Capture's behaviour. Columns are sortable and their widths persist too.

Status is its own column, and mostly empty on purpose. It was inline in the title cell a draft ago, which made the title do two jobs and hid the exceptions among long meeting names. It also means the table keeps one shape — states 8 and 9 already had a Status column, so the previous version reshaped the moment you pressed Import. Empty is the common case: the checkbox already says imported or not, so this column carries only what the checkbox cannot — Damaged, On Dropbox, On “T7”. Two rows lit out of nine is scannable; nine rows saying “Imported” would not be. Size came out to make room — it is derivable from length, the footer totals it, and the disk precheck surfaces it when it actually matters; available as an optional column.

The unmatched count is a disclosure, not a list. An earlier draft said to list unmatched meetings inline so the arithmetic could be verified — but on a busy calendar that buries 8 real recordings under 90 stand-ups. The count stays permanently visible; clicking it reveals them. Legible absence without the flood.

3cUnreachable meetings — name the owner, because that's the fix

Clicking the footer's “organised by someone else” reveals these. They're dead weight as a count; as rows with a name on them they're a workflow — ping the owner and ask.

Import from Teams
St Mary's Trust · martin@144a.org · last 30 days
MeetingDate ▾ · BSTLengthExpiresStatus
P07 Interview — ward handover
Sarah Chen · J. Whitfield +4
Wed 12 Aug
16:00
58 minin 58 days
Discharge pathway — session 2
A. Bianchi · D. Achebe +3
Tue 11 Aug
10:00
􀉭 A. Bianchi
P05b Interview — ward handover
D. Achebe · S. Chen +2
Tue 11 Aug
16:30
1 h 09in 56 days􀎡 View only
􀉭 A. Bianchi organised this. Bristlenose can't see whether it was recorded — ask them.
Copy Email

The footer becomes contextual on focus rather than using a tooltip. Tooltips need a mouse and a dwell; the footer is already there, is keyboard-reachable, and has room for a whole sentence plus the action. Focus a normal row and it returns to the arithmetic.

Two unreachable cases, opposite remedies — so never one message. A view-only recording is yours, blocked by your own tenant's policy: the fix is asking IT for a security-group exemption, and pinging the organiser does nothing because that's you. A not-yours meeting needs the organiser: ask them to share it, or to download it and drop it somewhere. Same “can't fetch”, entirely different sentence and entirely different person.

Honesty limit, drawn deliberately. The not-yours row shows “—” for length and expiry, because those come from the recording and we can't see it. We know the meeting and the organiser from the calendar; whether a recording exists would need Files.Read.All, the admin wall §3 refuses. So the copy is “ask them”, never “they have it”.

Product guidance this implies, beyond the UI: the reliable answer is organise the sessions yourself. Google Meet has the same shape. That belongs in onboarding as a recommendation, not buried as a limitation — a week lost to someone else's permissions model is the failure this feature exists to prevent. A Teams deep link (msteams:/l/chat/…) to open the chat with the owner is a plausible nicety; flagged, not specified.

3bThe destination picker

Two cadences, one control: a new study imports five at once; a longitudinal one adds two or three over weeks. So it's a picker with the current project pre-selected — never a default.

✓  Ward Handover Study
Triage Pilot
Discharge Study 2025
New Project…

New Project… creates immediately with a provisional name taken from the meeting series, and you rename it in place in the sidebar — no naming dialog stacked on this window, which would be a modal inside a modal.

Changing the destination re-evaluates the tick column, because “already imported” means “already in this project”. The same recording legitimately belonging to two studies is a real case, not an error.

After the fetch, nothing runs automatically. Files land and the project offers to analyse. The rhythm incremental analysis was built for is import 3 → analyse → import 3 more, so auto-running would fight the workflow and spend an LLM budget the researcher didn't authorise.

The footer line is the most important element here. This feature's success output and its failure output are both “a shorter list” — an unfollowed pagination link, a timezone-shifted window and a fuzzy join key all produce one, and the researcher reads it as “it didn't record” while the expiry clock runs. Stating the arithmetic is what makes the organiser-only constraint honest rather than silent, and it's the only place in the design where a data-losing failure becomes visible.

Earn the red. Only the row inside the danger window is warning-coloured. A countdown on every row is a wall of countdowns, and then none of them mean anything.

4Filtered

Title comes free from the recording filename — no calendar scope needed. Select-All operates on the filtered set, never the window.

Import from Teams
martin@144a.org · last 30 days
MeetingDate ▾ · BSTLengthExpiresStatus
P07 Interview — ward handover
Sarah Chen · J. Whitfield +4
Wed 12 Aug
16:00
58 minin 58 days
P06 Interview — ward handover
Margarethe Okafor-Whitcombe · A. Bianchi +3
Wed 12 Aug
14:30
1 h 04in 57 days
P05 Interview — ward handover
J. Whitfield
Tue 11 Aug
11:00
51 minin 56 days
P04 Interview — triage
D. Achebe
Thu 6 Aug
15:15
1 h 11in 52 days
P03 Interview — triage
L. Fitzgerald
Tue 4 Aug
10:00
55 minin 50 daysDamaged
P02 Interview — triage
K. Lindqvist
Fri 27 Jun
13:00
1 h 02in 12 daysOn Dropbox
P01 Interview — triage
R. Nakamura
Fri 20 Jun
09:00
49 minin 5 days
7 of 9 shown · filtered on “Interview”
Project: Ward Handover Study Import 3 Recordings

Title filtering rides on meeting-naming discipline — “Chat with Sarah” won't match. Say so here rather than hiding it; attendee and @domain search is v1.1 and needs the scope upgrade.

5No recordings in window

Import from Teams
􀉉
No recordings in the last 30 days
11 meetings, none with a recording you organised.
Look back 60 days

6Filter matches nothing

Import from Teams
􀊫
No results for “Diary study”
6 recordings in this window. Titles come from the meeting name.

Two different states, deliberately worded differently. ContentUnavailableView covers both — and .search quotes the term back automatically.

7All already imported

Keep the rows, ticked and dimmed. Removing them makes the researcher doubt the meeting existed — the opposite of reassurance.

Import from Teams
martin@144a.org · last 30 days
MeetingDate ▾ · BSTLengthExpiresStatus
P07 Interview — ward handover
Sarah Chen · J. Whitfield +4
Wed 12 Aug
16:00
58 minin 58 days
P06 Interview — ward handover
Margarethe Okafor-Whitcombe · A. Bianchi +3
Wed 12 Aug
14:30
1 h 04in 57 days
P05 Interview — ward handover
J. Whitfield
Tue 11 Aug
11:00
51 minin 56 daysOn “T7”
P03 Interview — triage
L. Fitzgerald
Tue 4 Aug
10:00
55 minin 50 daysDamaged
3 already in Ward Handover Study · 1 needs re-fetching
Project: Ward Handover StudyImport 1 Recording

Rows 3 and 4 are why imported-state is derived, not stored. A boolean would call both of these “already imported” and offer nothing. Row 3's file is on an unplugged T7 — still checked and disabled, because re-fetching from Teams would be the wrong fix for a drive that just needs plugging in. Row 4's file is the wrong size — unchecked and enabled, because that one genuinely does need re-fetching. Same past fact, opposite present remedy, and only a derived state can tell them apart. You can see a file; you must trust a flag.

8Fetching

The window stays open and non-modal — which is exactly what a sheet couldn't do. Per-row state lives in the row; the project's aggregate rides its sidebar row.

Import from Teams
Fetching 2 of 4 · about 6 min left
MeetingDate · BSTLengthExpiresStatus
P01 Interview — triage
R. Nakamura
Fri 20 Jun
09:00
49 minin 5 daysImported
P05 Interview — ward handover
J. Whitfield
Tue 11 Aug
11:00
51 minin 56 days 64%
P06 Interview — ward handover
Margarethe Okafor-Whitcombe · A. Bianchi +3
Wed 12 Aug
14:30
1 h 04in 57 daysQueued
P07 Interview — ward handover
Sarah Chen · J. Whitfield +4
Wed 12 Aug
16:00
58 minin 58 daysQueued
Fetching soonest-expiring first · 2.4 GB of 4.7 GB
Stop

Display order is the user's; fetch order is expiry's. The list is date-sorted but P01 (5 days left) went first — otherwise an interrupted batch loses exactly the files closest to the recycle bin. Note the engine is URLSession background downloads, not CopyMachinery, whose cancel deletes every already-written file.

Ward Handover Study
Fetching 2 of 4 · about 6 min left
􀉚
Triage Pilot

Same state at two fidelities — per-file in the list, per-project on the row. Not double-reporting: they describe different things, and the row is what survives closing the window, so nothing is lost by getting on with something else. Prose stays minimal: extend the RunProgressSubtitle ladder (stage · N of M · ETA), not ProjectSubtitle.copying(fraction:), which holds a single 0–1 number with no item identity, count or ETA and so cannot say “Fetching 2 of 4 · about 6 min left”.

Getting back in. Right-click the project ▸ Import from Teams…, or File ▸ Import from Teams… — Apple puts reopening a closed window in the File menu, and reserves the Window menu for listing currently open windows alphabetically. So the scene takes .commandsRemoved() to suppress SwiftUI's automatic Window-menu reopen entry, and appears there only while actually open. Double-click on the row isn't available — that gesture is already reserved for opening the project itself. HIG's “avoid listing panels or other modal views” in that same section is a third independent argument against the sheet.

9Partial failure

No “7 of 7 done!” toast, and no alert per failure. The ones that landed say nothing; the ones that didn't carry their reason and stay selected for a retry.

Import from Teams
4 requested · 2 imported · 2 failed
MeetingDate · BSTLengthExpiresStatus
P01 Interview — triage
R. Nakamura
Fri 20 Jun
09:00
49 minin 5 daysImported
P05 Interview — ward handover
J. Whitfield
Tue 11 Aug
11:00
51 minin 56 daysImported
P06 Interview — ward handover
Margarethe Okafor-Whitcombe · A. Bianchi +3
Wed 12 Aug
14:30
1 h 04in 57 daysLost connection at 71%
P07 Interview — ward handover
Sarah Chen · J. Whitfield +4
Wed 12 Aug
16:00
58 minin 58 daysNot enough disk space
2 imported · 2 failed — 8.1 GB free, 2.6 GB needed
Project: Ward Handover StudyRetry 2

Partial failure isn't only a list problem. Stages 10 and 11 cluster across sessions, so analysing 19 of 20 doesn't give “the report minus one” — it gives different themes, in a report that looks complete. The failure count has to survive this window closing, and Analyse should mention it before running.

The disk-space row shouldn't reach here at all in the normal case — the precheck runs before a byte moves, using sizes the listing already carries. It's drawn as a failure state because a mid-batch ENOSPC is still reachable when something else fills the disk.

Decisions this mockup encodes, and where they come from