# UI kit The plan for `@margin/ui`, the React primitives. Hooks and the IPC wrapper are [hooks.md](hooks.md); tokens and stylesheets are [design-system.md](design-system.md). ## The kit already exists Margin Mail's `src/ui` is seventeen primitives behind one barrel, with `screens/Kit.tsx` (613 lines) rendering every one in every state in both palettes. It was built as a component library and it behaves like one. The other three apps each hold a partial, earlier, differently named copy of about two thirds of it. So the work is not designing a component library. It is promoting `margin-mail/src/ui` into `@margin/ui`, reconciling three class vocabularies against it, and deleting the rest. That framing matters, because it turns a design exercise into a mechanical one with a reference implementation and a page that proves it renders. Sizes for scale: components are 3,263 lines in Margin, 6,063 in Margin Calendar, 4,938 in Margin Docs, and 12,003 across Margin Mail's `ui` and `screens`. Roughly 1,600 lines of TSX and 900 of CSS collapse to about 900 and 500. ## Two things block this before any code moves **A standing decision says no.** `shared/src/icons.ts:11-13` states it in the file: > Each app renders these through its own `Icon` component. The two components are identical today > and are deliberately not shared: one is React, which would make this package depend on React for > twenty four lines, and a component is where an app is entitled to differ. That was reasonable when the surface was 24 lines. It is not now: `margin/src/components/Icon.tsx`, `margin-caledar/src/components/Icon.tsx` and `margin-editor/src/components/Icon.tsx` are byte identical (md5 `0ec1a568818f20ed8eed8ad46fbaa2b1`), Margin Mail's `ui/Icon.tsx` adds a class and `aria-hidden`, and beneath them sit 1,600 lines of duplicated component code. The React dependency argument is also mechanically wrong: `shared/package.json` has no dependencies block, all four apps are on `react ^19.1.0`, and a peer dependency costs nothing. Reopen that note explicitly, in the file, with the new reasoning. Do not quietly contradict it. The mechanical consequence: the package is source only with no build step, so each app's tsconfig and Vite config has to compile TSX out of `node_modules`, and none does today. That is a [toolchain.md](toolchain.md) change and it gates everything here. **Margin Calendar is not in the package.** `grep -r margin-shared` over its tree returns nothing. It carries its own 168 line `tokens.css` against the shared 91 line one. It also has the most to gain, because Margin Mail already forked two of its components. One dependency line is the prerequisite for every item below. ## Icon, and the alignment problem The alignment problem the same fix keeps being applied to is not sub-pixel. There is not one use of `shape-rendering`, `vector-effect`, `crispEdges` or a half-pixel translate anywhere in the four repos. It is two ordinary CSS facts: an inline SVG sits on the text baseline, and it shrinks as a flex item. Four apps fix that five different ways. Margin Mail alone fixes both centrally, with `svg { display: block }` at `app.css:88` and `.icon { flex: none }` in `Icon.css`. The other three patch per call site with `transform: translateY(2px)`, `margin-top: 2px` and `align-self: center`. `@margin/ui` ships Margin Mail's `Icon` and Margin Mail's two rules. Every per-site nudge in the other three comes out. This is the single change that stops the icon alignment tax, and it is four lines. Glyph paths stay in `@margin/icons` as bare strings. Margin Mail's `ui/icons.ts` shows the right pattern for app-specific glyphs: 29 paths, five of which re-export from the shared set rather than redeclaring them. Where paths have drifted, the shared set takes the corrected one. The clearest case is documented in the code: `margin-editor/src/components/Toolbar.tsx:182` explains that the H moved from x=5 to x=7 because it "sat left of centre in a round button", and Margin still has the uncentred version. Same for the bullet list glyph, x=3.5 in Margin against x=4 in Margin Docs. ## The order of work Ranked by payoff against the amount of argument required, not by line count. | Rank | Cluster | Apps | Now | After | Argument needed | |---|---|---|---|---|---| | 1 | `Sheet` and `Confirm` plus panel CSS | 4 | ~300 tsx, ~240 css | ~170, ~60 | none, Mail already forked Calendar's | | 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 | 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 versus 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, and three bugs fixed | | 12 | `SearchHighlight` and `positions.ts` | 2 | ~590 | ~300 | none | Settings is deliberately absent. See the end of this document. ## Sheets, dialogs and confirmation Every app's answer to a floating panel over the app is already the same idiom: a flat list of self mounting overlay components at the end of `App.tsx`, each reading its own store and returning `null` when closed. Underneath, `src/escape.ts` is byte identical in all four. Margin Mail's `ui/Sheet.tsx` is Margin Calendar's `components/overlayShell.tsx`, forked, with the comments carried over verbatim. The differences are Mail's improvements: `onBack` and `backLabel` as props rather than reading a store and a hardcoded titles map, with the reason given at `Sheet.tsx:33-36` ("a primitive that imports one cannot be rendered on a Kit page"), and `busy`, which makes the close control, the scrim and Escape all refuse while a command is in flight. That `busy` prop is what makes [guidelines/errors-and-feedback.md](guidelines/errors-and-feedback.md) enforceable rather than aspirational, so it belongs in the primitive. `ConfirmDialog` in Margin and Margin Docs each has half the correct behaviour. Margin calls `useFocusTrap` and Docs does not; Docs sets `role="dialog"` and `aria-modal` and Margin does not. The class names drift by one letter, `icon-btn` against `icon-button`. One behaviour has to be settled rather than merged: Margin and Margin Docs focus the destructive button in a confirmation, Margin Calendar and Margin Mail focus cancel and say why in a comment ("a stray Enter does nothing destructive"). Cancel wins. The CSS is one design in four copies. Across the four `app.css` files, `.panel` and `.panel-body` have exactly one distinct body between them, `.overlay` has two (Margin hardcodes `rgba(35,32,27,0.28)` at `app.css:1275` where the rest use `var(--scrim)`), `.panel-foot` two, and `.panel-head` three, differing by 2px of padding and a `flex: none`. Ship `Sheet` and `Confirm` as Mail declares them plus `panel.css`. `ConfirmDialog` becomes ``. Keep `ConflictDialog`'s reload and keep semantics and `MoveChapterDialog`. ## List navigation, which is the largest copied thing in the audit There are five distinct expressions of "move the selection by one" across the four apps, and the split is not by app: Margin Docs alone contains three of the five. Clamp with a seed from whichever end the delta came from, four near-identical copies. Clamp with no seed, where two Docs files are literally the same line and one says so in a comment. Clamp by indexing off the end and guarding `undefined`. True modulo wrap, in seven places. Wrap plus seed, in two. The result is that 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. ```ts 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 which are the same eight lines differing only in the CSS class queried and whether they wrap. `stepIndex` replaces seven copies. The scroll hook must use Margin Docs' manual arithmetic, not `scrollIntoView`. `margin-editor/src/components/Outline.tsx:80-90` deliberately avoids `scrollIntoView` because, as the comment at `:79` says, it "is free to scroll every ancestor of the row as well". Four other places still use it and still have that bug. Two constraints on the API. It must take key predicates rather than hardcoding `e.key`, because Mail and Docs route list keys through a remappable binding table while every menu listens for a literal `ArrowDown`. And it must take a scroll strategy rather than assuming the DOM, because Mail's `ListColumn` drives a react-virtuoso handle. Two gaps worth closing while this is open: there is no type-ahead anywhere in any app, and Home and End exist in only three places. `margin/src/components/Library.tsx` is the one screen that gains a feature rather than loses duplication, since its card grid has no arrow navigation at all. ## The primitives Margin Mail has the component in every case below; the others have the markup. **Button.** Mail's `ui/Button.tsx` with `default | primary | ghost | danger`, 21 uses in its Settings alone. Calendar expresses the same vocabulary as a CSS attribute, `.panel-button[data-variant]`; Margin uses `.btn-primary`, `.btn-ghost`, `.btn-danger`; Docs adds `.btn-quiet`. Four conventions, one control. Mail's names win because they are already a component API rather than a class convention. **Icon button.** Eleven independent square-icon-button rules across the four apps, all `place-items: center` with `--r-sm` and an `--accent-wash` hover, differing only in size (16, 22, 26, 28, 30) and in whether they are called `.icon-btn` or `.icon-button`. There are 54 hand-written call sites in the three older apps. Mail's is the only component and the only one that supplies `aria-label` automatically, which is why the other three have unlabelled icon buttons. **Toggle.** Mail's `ui/Toggle.tsx` (46 lines plus 78 of CSS), nine uses. Docs inlines the same `