`.
## First run, onboarding, help
calendar `FirstRun.tsx` (38), `Accounts.tsx` (159); docs `Recents.tsx` (86), `DocumentSetup.tsx` (354);
mail `Onboarding.tsx` (208), `Connect.tsx` (333), `ConnectMail.tsx` (644), `Tour.tsx` (431),
`Guide.tsx` (113), `Help.tsx` (155), `ui/EmptyState.tsx` (16); margin `Library.tsx` (181) as its start
screen. **Margin has no first-run, welcome or tour screen at all.**
**There is no shared step-by-step setup flow.** Exactly one component in four apps has numbered steps
and it is a slideshow: `Tour.tsx:355` (`useState(0)`), `:392-404` (step dots), `:405-414` (the only
Back/Next pair anywhere), `:411` (the only terminal success screen). Everywhere else the flow ends by
unmounting and every other screen has a single primary button. `Connect.tsx` and `ConnectMail.tsx` look
like wizards and are not: their states are phases of an external process the user cannot navigate, and
`Connect` has no way back once the account is written (`:276-278`). Four screens appearing at the same
moment in a product's life, sharing an aesthetic, not a shape.
- **The empty-stage anatomy: four apps, four class vocabularies, one layout.** A mark, an `h1`, one
line of prose, a row of buttons, one line of fine print. `mail/Connect.tsx:78-125`
(`welcome-mark`/`welcome-title`/`welcome-line`/`welcome-actions`/`welcome-privacy`),
`docs/Recents.tsx:40-57` (`start-title`/`start-line`/`start-open`), `calendar/FirstRun.tsx:19-33`
(`first-run-title`/`first-run-note`), `margin/Library.tsx:109-123` (`card-action`).
- **The Google connect-pending block**, the strongest single duplication here.
`calendar/Accounts.tsx:88-114` and `mail/Connect.tsx:136-171` are the same block: a "waiting in your
browser" line, a note conditional on `authUrl`, and Open link / Copy link / Cancel wired to
`openAuthUrl`/`copyAuthUrl`/`cancelConnect` on a store called `useAccounts` with the same phase
names, both guarding Escape identically with
`useEscapeLayer(phase === "connecting", cancelConnect)` (`Accounts.tsx:44`, `Connect.tsx:51`).
- **The recents shelf** (`docs/Recents.tsx:62-80`, `margin/Library.tsx:124-151`) and **the progress
bar**, five copies of which four are inside mail (`Connect.tsx:310-318`, `Arriving.tsx:113`,
`ListColumn.tsx:68-76`, `Settings.tsx:1135`, `docs/UpdateDialog.tsx:96`), two concepts sharing
markup: sync progress and download progress.
- **Empty-list placeholders.** Mail extracted it (`ui/EmptyState.tsx`, 16 lines, one `` and a 7-line
rule). The others hand-roll a one-line paragraph under a different class each time: `.move-empty` and
`.dock-empty` (margin), `.panel-empty` and `.palette-empty` (calendar), `.pane-empty` (docs).
Shared: ``, ``,
``, ``, ``.
`` only if a second app wants a tour. Stays: every phase machine, all copy, `ConnectMail`'s
644 lines of IMAP discovery, `DocumentSetup`'s font model, `Tour`'s nine slides.
## Virtualised lists and keyboard row navigation
Only mail virtualises (`react-virtuoso` at `ListColumn.tsx:2`, `:180`, `:451`); the other three render
everything and say so (`calendar/AgendaList.tsx:4-6`). Nothing to share about windowing.
A great deal to share about the keyboard, and this is the largest instance of copied code in the audit.
**Five distinct expressions for "move the selection by one", and the split is not by app:** clamp with
a seed from whichever end the delta came from, four near-identical copies
(`mail/store/useMail.ts:292-300`, `useFeed.ts:96-102`, `useScreener.ts:161-168`,
`calendar/store/useCalendarView.ts:96-97`); clamp with no seed, where `docs/Outline.tsx:100` and
`docs/Backlinks.tsx:92` are literally the same line and `Outline.tsx:95` says so, plus
`mail/Guide.tsx:53` and `mail/CommandPalette.tsx:164-165`; clamp by indexing off the end and guarding
`undefined` (`docs/Sidebar.tsx:225-240`, `margin/Sidebar.tsx:155-165`); true modulo wrap
(`docs/Palette.tsx:91-92`, `margin/Menu.tsx:28-30`, `margin/AddPageMenu.tsx:56`,
`margin/RowMenu.tsx:52`, `docs/RowMenu.tsx:80`, `docs/WidthMenu.tsx:116`, `docs/Titlebar.tsx:198`); and
wrap plus seed (`mail/Help.tsx:110-111` and `mail/MoreMenu.tsx:108`, identical lines).
margin-docs alone contains three of the five. A list wraps or does not depending on which app and which
surface you are in, which is precisely what a shared design language is supposed to settle.
Selection is four models: a key in a zustand store with DOM focus never moving and rows at
`tabIndex={-1}` (all mail lists, calendar's agenda); an index or id in local state
(`docs/Palette.tsx:57`, `mail/CommandPalette.tsx:321`, `mail/Guide.tsx:22`); roving DOM focus with a
`tabIndex` shadow (`docs/Outline.tsx:102`; `docs/Backlinks.tsx:94`; `docs/Sidebar.tsx:101-105` and
`margin/Sidebar.tsx:142-145`, the same rAF + `CSS.escape` + `querySelector` trick); and pure DOM focus
off `document.activeElement` with no index (every menu). Keeping the row on screen is five mechanisms,
and one found a bug the rest still carry: `docs/Outline.tsx:80-90` deliberately does **not** use
`scrollIntoView`, doing the arithmetic by hand, because as `:79` says it "is free to scroll every
ancestor of the row as well". The other four use it (`calendar/AgendaList.tsx:40-44`,
`mail/Feed.tsx:127-130`, `docs/Palette.tsx:75-77`, `mail/ListColumn.tsx:281-285`). Two gaps: **no
type-ahead anywhere in any app**, and Home/End exists in only three places, all trees or menus
(`docs/Sidebar.tsx:233-240`, `margin/Sidebar.tsx:156-159`, `margin/Menu.tsx:24-27`).
```
useRovingFocus(ref, { selector | refs, wrap, homeEnd, seedFromEnd, onMove })
stepIndex(count, at, delta, { wrap }): number | null
useScrollSelectedIntoView(scrollerRef, selectedId, { attr: "data-id" })
```
`useRovingFocus` has ten call sites, eight of them the same eight lines differing only in the CSS class
queried and whether they wrap: `margin/Menu.tsx:17-32`, `margin/AddPageMenu.tsx:51-59`,
`margin/RowMenu.tsx:48-54`, `docs/RowMenu.tsx:75-81`, `docs/WidthMenu.tsx:112-117`,
`docs/Titlebar.tsx:193-199`, `mail/Help.tsx:105-123`, `mail/MoreMenu.tsx:104-116`, plus
`docs/Outline.tsx:99-109` and `docs/Backlinks.tsx:91-101` on refs. `stepIndex` replaces seven copies;
the scroll hook replaces three and should use `Outline`'s manual arithmetic.
Stays: all ordering (`useCalendarView.ts:82-91`); ListColumn's virtualiser handle, so the shared hook
must take a scroll strategy rather than assume the DOM; the tree's ArrowLeft/ArrowRight expand-collapse
(`docs/Sidebar.tsx:241-251`); multi-select, only in mail (`ListColumn.tsx:209-222`). The hook must take
key predicates rather than hardcode `e.key`, because mail and docs route list keys through a remappable
binding table (`Guide.tsx:55-58`, `ListColumn.tsx:224-269`) while every menu listens for literal
`ArrowDown`. `margin/Library.tsx` is the one screen that would gain a feature rather than lose
duplication: a card grid with no arrow navigation, only Enter and Space (`:138-143`).
---
## Ranked, with the size of the win
| Rank | Cluster | Apps | Now | After | Argument needed |
|---|---|---|---|---|---|
| 1 | `Sheet` + `Confirm` + panel CSS | 4 | ~300 tsx, ~240 css | ~170, ~60 | none, mail already forked calendar |
| 2 | `useRovingFocus` + `stepIndex` + scroll hook | 4 | ~200 over 20 sites | ~60 | wrap or clamp must be settled |
| 3 | PDF export preview | 2 | 784 | ~450 | none |
| 4 | Primitives: Button, Toggle, Segment, Key, Field, Icon, EmptyState, Pill, GroupHead | 4 | ~660 tsx, ~600 css | ~410, ~350 | one class vocabulary wins |
| 5 | Find bar | 2 | 475 tsx, 370 css | ~320, 161 | none |
| 6 | Shortcuts sheet | 3 | ~170 tsx, ~130 css | ~60, ~50 | docs adopts the sheet |
| 7 | Palette shell + `commandMatches` + `highlight` | 3 | ~510 | ~250 | wrap vs clamp, row identity |
| 8 | Menu and anchored positioning | 4 | ~890 | ~450 | six behaviours to reconcile |
| 9 | Toast | 4 | ~140 | ~60 | two visual designs |
| 10 | `Stage`, `RecentList`, `ProgressBar`, `OAuthPending` | 4 | ~250 | ~130 | none |
| 11 | ResizeHandle and pane width | 2 | 177 | ~110 | none, plus three bugs fixed |
| 12 | `SearchHighlight` + `positions.ts` | 2 | ~590 | ~300 | none |
| 13 | Settings shell | 2 | ~340 | ~250 | not worth it, see above |
About 1600 lines of tsx and 900 of CSS collapse to roughly 900 and 500, but the number is not the
point. The four apps disagree about whether Escape closes a menu, whether a list wraps at its ends,
whether a separator can be focused, whether a repeated toast restarts its timer, and whether the safe
button gets focus in a delete confirmation, none of it visible until someone uses two of them in one
afternoon. And several clusters have one app that got a detail right and three that did not:
`docs/Outline.tsx:80-90` alone avoids `scrollIntoView` scrolling every ancestor, `docs/Palette.tsx`
alone has `aria-activedescendant`, `margin/ResizeHandle.tsx` alone has a focusable separator, and
`mail/store/useToast.ts` alone restarts on a repeat. Extraction is how those stop being luck.
## What should not be shared
- **Section bodies of Settings.** 1385 lines in mail alone, all Gmail scopes and mbox export.
- **Content matchers.** A Rust fzy scorer, SQLite FTS, substring over event fields, a backend parse.
- **Any `` or shared tiptap extension list.** Three content types, three schema policies, three
lifecycles, and `docs/editor/extensions.ts:1-20` argues against the list specifically. Likewise
`calendar/RichText.tsx` (84) and `EventDetailsHtml.ts` (449), a read-only renderer and a sanitiser
for HTML the app did not author.
- **Setup flows.** No shared wizard shape exists. Share the empty-stage anatomy and the OAuth pending
block, not the flows. And **`src/width.ts`**, which shares a filename across margin and docs while
meaning unrelated things; rename one instead.
- **Icon paths** beyond the handful in `margin-shared/icons`. Mail's `ui/icons.ts` (73) has 29 paths
and shares five names with the shared set by re-export (`:14`), which is the right pattern.
- **`calendar/ColorPicker.tsx` (94) and `mail/ui/Avatar.tsx` (87).** Superficially both derive a
colour; actually a Google `colorId` radio group and a hashed-hue initials badge. Similarly
`margin/Dock.tsx` (223) is Typst compile plus device frames, only `DockHead` (`:60-70`) is chrome.
- **`ProofPopover`, docs 292 against margin 76.** Same feature and the same `.proof-pop` /
`.proof-suggestion` / `.proof-action` classes, but docs has grown a keyboard walk, an escape layer, a
focus-return policy and a flip-above fallback margin has not. Share the anchored-menu primitive
underneath; leave the issue rendering in each app.