23 KiB
UI kit
The plan for @margin/ui, the React primitives. Hooks and the IPC wrapper are hooks.md;
tokens and stylesheets are 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
Iconcomponent. 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 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 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
<Sheet size="mini"><Confirm/></Sheet>. 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.
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
<button role="switch" aria-checked data-on> with a knob on translateX inside its settings row.
Margin uses a raw checkbox, Calendar has none.
Segment. Mail's ui/Segment.tsx, ten uses, against Calendar's two inline copies. Same class
names, opposite visual models: Calendar paints the active option --accent, Mail lifts it onto
--paper in an --accent-wash track. Mail has role="tablist", Calendar has no ARIA. Mail's model
wins on both counts.
Field, Key, EmptyState, Pill, GroupHead. Mail has all five. Note that Mail's own Settings uses
ui/Field.tsx inconsistently, with a local commit-on-blur draft and three raw inputs beside it; the
extraction is the moment to settle that rather than carry it across.
Select. Nobody abstracted it. It is written five times across two apps, all
<select className="settings-select"> with the same "unknown current value gets its own leading
option" hatch. Mail's font picker and Margin's FontSelect are near duplicates and both already
driven by the shared font catalogue, so they collapse first.
Settle one class vocabulary at the same time. Docs and Calendar are one word apart on the settings row
(.setting-label against .setting-name), and Mail's is the only one that names the control slot and
takes children rather than baking a switch into the row. Mail's wins.
Menus and anchored positioning
Six implementations of one object, and no two agree on the table below.
| positioning | edge clamp | portal | dismiss | Esc | trap | restores focus | |
|---|---|---|---|---|---|---|---|
margin RowMenu (161) |
anchor rect | none | yes | mousedown capture | yes | yes | yes |
margin Menu (42) |
CSS only | none | no | backdrop div | no | yes | via teardown |
margin AddPageMenu (105) |
anchor rect | none | no | mousedown | yes | yes | yes |
docs RowMenu (187) |
point | both axes | yes | mousedown capture | yes | no | yes |
docs WidthMenu (187) |
CSS only | none | no | backdrop and blur | yes | no | keyboard only |
mail Popover (108) |
anchor rect | x only | no | pointerdown capture | yes | no | no |
Two of these are bugs rather than differences. margin/src/components/RowMenu.tsx:33-37 sets
top: r.bottom + 4 with no clamping, so a row low in a long chapter list opens a menu off the bottom
of the window. margin/src/components/Menu.tsx has no escape layer at all, so Escape does not close
it.
The best placement maths in the suite is not in a menu. It is
margin-caledar/src/components/EventDetailsModel.ts:71-104, a pure, unit-tested function that tries
right, left, below, above, then centre, clamps the cross axis and returns the side it chose. That
becomes useAnchoredPosition.
Two ideas only one app has, both worth keeping. Docs' caret bargain, where e.detail === 0 detects
keyboard activation and only then moves focus, with mouse presses preventDefaulted so the caret
stays in the sentence. And Mail's reposition-rather-than-close on scroll and resize, which is right
for a contact card in a scrolling thread and wrong for a menu.
Share the placement hook and the menu body. Do not share the dismissal policy: Popover stays a
separate primitive from Menu because they answer different questions.
Palette
Margin has none. Docs has a shell with three consumers, Mail a shell with one, Calendar no shell and one monolith.
The command matcher is one function copy-pasted three times, character for character, down to the
variable names needle, hay and at. It lifts verbatim as commandMatches.
The keyboard model diverges in ways a user would notice moving between the apps: lists wrap in Docs
and Calendar and clamp in Mail; Ctrl+N, Ctrl+P and Tab move the selection in Calendar only;
scrollIntoView on the selection is Docs only; aria-activedescendant and role="combobox" are Docs
only, so Mail and Calendar announce nothing when the arrows move; group headers are Mail only; match
highlighting is Docs only.
No app has all of these, which is the strongest single argument in this document: consolidating is a strict upgrade for every consumer rather than a wash.
interface PaletteItem { id: string; run?: () => void }
interface PaletteSection<T extends PaletteItem> { id: string; label?: string; items: readonly T[] }
<Palette label placeholder query onQuery sections status renderItem onChoose onClose
wrap = true // Mail's clamp becomes opt-out
extraKeys = true // ctrl+n/p and Tab, Calendar's model
header /> // Calendar's parse preview block
Fix while it is open: margin-mail/src/screens/CommandPalette.tsx:158-173 registers a window keydown
listener with no dependency array, detaching and reattaching every render. The comment at :156 says
this keeps the closure fresh; Docs gets that for free by handling on the input instead.
The CSS is already shared in fact. Mail's ui/Palette.css and Calendar's styles/palette.css have
the same width: min(620px, calc(100vw - 32px)) and max-height: min(560px, 76vh) and identical row,
input, list and keys rules. Docs diverges and adopts.
Keep the content matchers out. They are three different problems: a Rust fzy scorer, SQLite FTS5
bm25, all-terms substring over three concatenated fields, and a backend parse.
The PDF export preview
The largest single-file duplicate in the tree, and the cleanest win with no design argument attached.
margin/src/components/ExportPreview.tsx (336) against
margin-editor/src/components/ExportPreview.tsx (448). Docs says so at :3-5: "The sibling book app
answers Export with this same panel, and both apps answer it this way for the same reason."
It is the same code, not the same idea. The Frame interface and the pane measurement, the toolbar
with the same glyphs and the same ZOOM_MIN and ZOOM_STEP, a primary button whose label is the same
ternary (saving ? "Saving…" : compact ? "Save" : "Save PDF…"), the fit arithmetic character for
character (Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / ratio))), and the lazy
page renderer down to the 1400px rootMargin, the device pixel ratio clamp of 2 and the
task?.cancel() teardown.
About 200 of Margin's 336 lines and 220 of Docs' 448 are one component. App-specific are the compile call, the save path and the warning text.
<PdfPreview bytes title onSave saving warning onClose />
Toast, find bar, shortcuts sheet, empty stages
Toast. Margin has no component at all: the same six lines of markup and the same timer effect
appear twice, in EditorView.tsx and Library.tsx. Calendar's and Docs' components differ by a
constant name and a title="Dismiss", and their useToast.ts files are byte identical. Mail splits
presentation from the store binding and adds what the others lack: an action button with a keycap, and
a seq counter so an identical message twice restarts the timer where the other three do nothing on a
repeat. Dwell times are 4000, 5000, 4200 and 6000; pick one. Two visual designs exist, glass in
Calendar and Mail, inverted --ink on --paper in Margin and Docs; that one needs a decision.
Find bar. Margin and Docs only. The render block is the same component, and diffing the CSS gives
three real changes in 161 lines. The difference is ownership: Docs declares a DocumentFind interface
and takes it as a prop, explaining that a bar drawing a text field has no business owning a ProseMirror
decoration set, while Margin imports the book store and the chapter model directly and carries 90
lines of cross-chapter scope logic. Docs' shape wins, plus a scope?: {label, onToggle} for Margin.
Shortcuts sheet. Calendar's and Mail's open with the identical three-line comment and close with a note whose first sentence is word for word "Nothing is modal and nothing is chorded." Docs' is the earlier form with its own vocabulary. Margin has no sheet and no binding table to generate one from, so it gains this only when it gains a binding table.
Empty stages. Four apps, four class vocabularies, one layout: a mark, an h1, one line of prose,
a row of buttons, one line of fine print. Ship <Stage>, <RecentList>, <ProgressBar> (five copies
today, four of them inside Mail) and <EmptyState>.
The OAuth pending block is the strongest single duplication in this group.
margin-caledar/src/components/Accounts.tsx:88-114 and margin-mail/src/screens/Connect.tsx:136-171
are the same block: a "waiting in your browser" line, a note conditional on authUrl, and Open link,
Copy link and Cancel wired to the same three store actions with the same phase names, both guarding
Escape with an identical useEscapeLayer(phase === "connecting", cancelConnect). It becomes
<OAuthPending ready onOpen onCopy onCancel> and pairs with the crate in accounts.md.
Pane resize
Two apps, not four: Calendar has no sidebar and no resizable pane, and Mail's --list-w is a
constant. Margin's ResizeHandle.tsx plus panes.ts against Docs' ResizeHandle.tsx. The drag body
is the same algorithm line for line, with the same MIN 200, MAX 460 and DEFAULT 248, and the CSS is
near verbatim.
Each has half the correct behaviour, and both are missing three things:
- Margin has keyboard resize with a 16px step and Home to reset, an
aria-labelandtabIndex={0}. Docs' separator cannot be focused at all. - Docs wraps storage in try and catch.
margin/src/panes.ts:41throws on a webview that denies localStorage. - Neither handles
pointercancelor callsreleasePointerCapture, so a cancelled pointer leaves the listeners attached andcursor: col-resizepinned on the document. - Neither debounces. A 120Hz drag issues 120 synchronous
localStorage.setItemcalls per second, with no rAF anywhere. - Docs flashes 248px and jumps on boot, because its boot script restores theme, sidebar and width but
not the pane width, leaving that to a
useLayoutEffect.
Per app
Margin Calendar first, because it is not in the package and everything else depends on that. Add
the dependency, adopt the tokens, then take Sheet and Confirm back in the form Mail forked them
into, then the primitives.
Margin Mail second, and its work is mostly outward: move src/ui into the package, keep Kit.tsx
in the repo as the app's own proof page, and re-import. It also settles the class vocabulary, since
its names win almost everywhere.
Margin Docs third: the palette shell, the find bar, the row menu body, the resize handle, and the export preview shared with Margin. It gives up its settings row vocabulary and its shortcuts sheet form.
Margin last and largest, because it has the most hand-rolled markup and the least structure: no toast component, no palette, no binding table, no first-run screen, and the two worst menu bugs. Its export preview is the one item it can do early and independently.
What deliberately stays
The Settings shell. Generic chrome is 9% of Mail's 2,551 line Settings, 22% of Docs' 346, 16% of
Margin's 215, and 0% of Calendar's because its chrome is already in Sheet. Under 200 lines saved out
of 3,402, and a SettingsShell would have two consumers who disagree about a header, a close button
and a drag region. Ship the row primitives, leave the shell. Mail's keyboard section navigation does
not port either, because it re-points the app's own j and k at the rail to get it.
Any <Editor> or shared tiptap extension list. Three content types, three schema policies, three
lifecycles, and margin-editor/src/editor/extensions.ts:1-20 is a written argument against the list
specifically. Extractable instead: SearchHighlight and searchStateOf (a 73-line diff across 233
and 264 lines, with Docs' header saying the only change of substance is a rename), positions.ts, the
toolbar primitives, and the install-then-restore-position helper with its document.fonts.ready pass.
A small editor kit, not an editor.
Spinners and loading states. Four different product positions, not four copies of one. Mail bans spinners on the record. Calendar has none.
Setup flows. Exactly one component in four apps has numbered steps and it is a slideshow.
Connect.tsx and ConnectMail.tsx look like wizards and are not: their states are phases of an
external process the user cannot navigate. Four screens appearing at the same moment in a product's
life, sharing an aesthetic, not a shape.
Things that only look alike. Calendar's ColorPicker is a Google colorId radio group and Mail's
Avatar is a hashed-hue initials badge. ProofPopover is the same feature with the same classes, but
Docs has grown a keyboard walk, an escape layer, a focus-return policy and a flip-above fallback that
Margin has not; share the anchored-menu primitive underneath and leave the issue rendering in each app.
And src/width.ts shares a filename across Margin and Docs while meaning unrelated things: rename one
rather than reconcile them.