# Duplicated React components across the four apps Components only. Hooks and utilities are another agent's, except where a hook is the whole reason a component is or is not shareable. Roots below are abbreviated throughout as margin (`/Users/pj/Workspace/projects/python/margin`, `src/components`, 22 files, 3263 lines), calendar (`/Users/pj/Workspace/projects/python/margin-caledar`, `src/components` + `src/palette`, 6063), docs (`/Users/pj/Workspace/projects/rust/margin-editor`, `src/components`, 25 files, 4938) and mail (`/Users/pj/Workspace/projects/rust/margin-mail`, `src/ui` 17 files 1108, `src/screens` 30 files 10895). Totals: tsx is 3826 / 5758 / 7350 / 12684, CSS 2983 / 3796 / 5089 / 6571. ## The one-line answer margin-mail already built the shared package. `mail/src/ui` is seventeen primitives behind one barrel (`ui/index.ts`), with a Kit page (`screens/Kit.tsx`, 613 lines) rendering every one in every state in both palettes. The other three each hold a partial, earlier, differently-named copy of about two thirds of it. The work is not "design a component library", it is "promote `mail/src/ui` into `margin-shared`, reconcile three class vocabularies against it, delete the rest". ## Two things that block this before any code moves **1. A standing decision says no.** `shared/src/icons.ts:11-13`, in the file itself: > 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. Reasonable when the surface was 24 lines. `margin/src/components/Icon.tsx:1-24`, `calendar/src/components/Icon.tsx:1-24` and `docs/src/components/Icon.tsx:1-24` are byte-identical; mail's (`ui/Icon.tsx:1-29`) adds a class and `aria-hidden`. Below them sit roughly 1600 lines of duplicated component code and 900 of duplicated CSS. That note has to be reopened explicitly. Mechanically the package has no build step (`shared/package.json:8-16`, source-only exports), so each app's tsconfig and Vite config must compile TSX out of `node_modules`, and none does. **2. Calendar is not in the package.** `grep -r margin-shared` over the calendar tree returns nothing; it carries its own 168-line `src/styles/tokens.css` against the shared 91-line one. And calendar would gain most, because mail already forked two of its components. --- ## Sheets, dialogs, confirmation First, the encouraging part. Every app's answer to "a floating panel over the app" is 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 (`margin/App.tsx:105-107`, `calendar:122-135`, `docs:221-238`, `mail:382-432`), and underneath, `src/escape.ts` is **byte-identical in all four**. A shared overlay component composes in all four on day one, provided it takes props rather than reading a store. `useFocusTrap` is margin-only (`src/focus.ts`, 56) and used by seven of its components; share it for dialogs and sheets, not menus, since `docs/WidthMenu.tsx:129-136` wants tab-out to work. margin and docs write `.overlay`/`.panel` inline per dialog (`ConfirmDialog` 48 and 56, `ConflictDialog` 66); calendar has `components/overlayShell.tsx` (137) and mail `ui/Sheet.tsx` (162), each with a `Confirm` in the same file. **mail's `Sheet.tsx` is calendar's `overlayShell.tsx`, forked.** Same props, same DOM, comments verbatim: `overlayShell.tsx:4-6` and `Sheet.tsx:30-31` are both "Nothing is resident, so a closed sheet renders nothing at all and its children mount fresh on the next open"; `overlayShell.tsx:50` and `Sheet.tsx:55` both "Focus has to leave the grid/page or the first keystroke goes to the keymap instead of the panel". The diffs are mail improvements: `onBack`/`backLabel` as props (`Sheet.tsx:15-17`) rather than reading `useOverlays` and a hardcoded `TITLES` map (`overlayShell.tsx:27-43`), 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 (`:23`, `:52`, `:69`, `:102`). `ConfirmDialog` in margin and docs each has half the correct behaviour: margin calls `useFocusTrap` (`:23`) and docs does not; docs sets `role="dialog" aria-modal` (`:32-33`) and margin does not. Class drift: `icon-btn` (`margin:30`) vs `icon-button` (`docs:38`). Button order is consistent everywhere (cancel left, destructive right) but **margin and docs focus the destructive button** (`margin:19`, `docs:24`); calendar and mail focus cancel and say why (`overlayShell.tsx:114`, `Sheet.tsx:143`: "a stray Enter does nothing destructive"). The CSS is one design in four copies: across the four `app.css` files `.panel` and `.panel-body` have exactly one distinct body, `.overlay` two (margin hardcodes `rgba(35,32,27,0.28)` at `margin/app.css:1275`, the rest use `var(--scrim)`), `.panel-foot` two, and `.panel-head` plus `.panel-head h2` three, differing by 2px of padding and a `flex: none`. Shared: `Sheet` and `Confirm` as mail declares them (`ui/Sheet.tsx:7-25`, `:113-122`) plus `panel.css`; `ConfirmDialog` becomes ``. Stays: `ConflictDialog`'s reload/keep semantics (`docs:12-21`), `MoveChapterDialog` (87). ~300 tsx to ~170. **The update dialog** is the same story one level up: four answers, four phase unions. margin `UpdateDialog.tsx` (88) is `checking|downloading|installing|uptodate|error`; docs (147) is `available|downloading|installing|error` with release notes, `bytes()` (`:21-25`), `role="progressbar"` with `aria-valuenow` (`:96-99`) and a Later button; calendar has **no dialog** and says so at `src/keys/updates.ts:1-3` ("Ported from margin's `src/updater.ts`, minus its progress dialog: there is no update UI here yet, so the toast carries the whole story"); mail has a Settings row (`Settings.tsx:2466-2540`), `idle|checking|current|found|installing`. Docs' is the only one showing notes, with a real progressbar role, that lets you decline, and its header comment (`:1-11`) is the design rationale for all four. A shared `` is worth doing, but it is a behaviour decision first and a component second. ## Command palette, quick open, search overlay margin has none: no palette, no quick open, no `src/keys`. Docs has a shell (`Palette.tsx`, 191) with three consumers (`CommandPalette` 78, `QuickOpen` 166, `FindInFiles` 128); mail a shell (`ui/Palette.tsx`, 127) with one (`screens/CommandPalette.tsx`, 191); calendar no shell, one monolith (`palette/CommandPalette.tsx` 190 + `parse.ts` 249). **The command matcher is one function copy-pasted three times, character for character**, down to the names `needle`, `hay`, `at`: `docs/src/keys/commands.ts:422-433`, `mail/src/keys/commands.ts:64-75`, `calendar/src/keys/commands.ts:213-224`. Character subsequence, case-insensitive, whitespace stripped from the query, boolean not a score, and all three render in registry order with no ranking. The content matchers by contrast are three different problems and should not be shared: docs' fzy scorer is Rust (`margin-editor/src-tauri/src/index.rs:1010-1088`, weights at `:990-1003`), find-in-files is FTS5 `bm25` (`index.rs:1187`), calendar's is all-terms substring over three concatenated fields (`AgendaModel.tsx:262-273`), mail's is parsed in Rust (`SearchBar.tsx:14-18`). Keyboard divergence a user would notice moving between apps: - Wrap at the list ends: modular in docs (`Palette.tsx:91-92`) and calendar (`usePalette.ts:21-26`), **clamped in mail** (`CommandPalette.tsx:165`). - Ctrl+N/Ctrl+P and Tab move the selection: calendar only (`CommandPalette.tsx:100-108`). - `scrollIntoView` on the selection: docs only (`Palette.tsx:75-77`). - `aria-activedescendant` and `role="combobox"`: docs only (`Palette.tsx:119-123`). Mail and calendar announce nothing when the arrows move. - Group headers: mail only (`ui/Palette.tsx:16-20`, `:88-91`). - Match highlighting: docs only (`Palette.tsx:169-191`), fed ranges from Rust rather than recomputed. Calendar has the same idea for search results as `splitMatch` (`AgendaModel.tsx:281-303`). **No app has all of these.** That is the strongest argument in the audit: consolidating is a strict upgrade for every consumer, not a wash. Row identity differs too: numeric index in docs and calendar, string id in mail, which mail dispatches by parsing prefixes off (`CommandPalette.tsx:143-151`), while docs puts a `run` closure on the row (`Palette.tsx:19-24`), which is why its shell is generic over three unrelated data sources. One bug worth fixing while it is open: `mail/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, but docs gets that free by handling on the input (`Palette.tsx:86-99`). The CSS meanwhile is shared in fact: `mail/ui/Palette.css` (99) and `calendar/styles/palette.css` (176) have the same `width: min(620px, calc(100vw - 32px))`, `max-height: min(560px, 76vh)`, and identical `.palette-input`, `.palette-list`, `.palette-row`, `.palette-keys`. Docs diverges. ``` interface PaletteItem { id: string; run?: () => void } interface PaletteSection { id: string; label?: string; items: readonly T[] } // calendar's parse preview block ``` `status` is docs' `{text, error?}` machine, into which mail's single string collapses. Plus `commandMatches` (lifts verbatim) and `highlight(text, ranges)` with a `rangesFromTerms` helper for calendar's term form. Stays: the fzy scorer, the FTS path, calendar's `parse.ts`/`create.ts`, mail's id-prefix dispatch and group assembly, every app's status copy. About 250 to 300 lines. ## Find bar margin (285), docs (190), plus docs' `FindInFiles.tsx` (128). Calendar and mail have neither. The render block is the same component. `margin:212-283` against `docs:103-188`: same `.find-bar > .find-expand + .find-stack > .find-row`, same chevron paths `M6 9l6 6 6-6` / `M9 6l6 6-6 6`, same `Aa` and `ab` toggles with `data-on`, same prev/next glyphs at `size={14}`, same "No results" / "3 of 12" label, same Enter and Shift+Enter. Diffing `margin/app.css:2142-2350` against `docs/tree.css:498-658` gives three real changes in 161 lines. The difference is ownership. Docs declares a `DocumentFind` interface (`:34-44`) and takes it as a prop, with `:5-9` explaining that a bar drawing a text field has no business owning a ProseMirror decoration set; margin imports `buildRegex`, `getSearchState`, `useBook` and the chapter model directly (`:2-7`) and carries ~90 lines of cross-chapter scope logic (`:122-210`). Shared: docs' `` plus `scope?: {label, onToggle}` for margin, about 130 tsx and 161 CSS to one copy. `FindInFiles` is a third consumer of docs' `Palette`. ## Toast margin has no component: the same six lines of markup and the same timer effect appear **twice**, at `EditorView.tsx:213-217` + `:505-509` and `Library.tsx:54-58` + `:161-165`. Calendar's `Toast.tsx` (24) and docs' (23) differ by a constant name and a `title="Dismiss"`, and their `useToast.ts` (15 each) are **byte-identical**. Mail splits presentation (`ui/Toast.tsx`, 29) from the store binding (`screens/Toasts.tsx`, 47) and adds what the others lack: an action button with a keycap (`ui/Toast.tsx:8`, `:21-26`) and a `seq` counter so an identical message twice restarts the timer (`store/useToast.ts:18`, `:29`), where the other three do nothing on a repeat. Dwells are 4000, 5000, 4200, 6000. `.toast` CSS is two designs, two apps each: glass (calendar, mail) and inverted `--ink`-on-`--paper` (margin, docs, byte-identical). ~140 lines to ~60. ## Settings margin 215 + `BackupSettings.tsx` 179; calendar 111; docs 346 + 448 css; mail 2551 + 593 css. Three shapes. **Mail and docs agree on the shell**: full window, a left nav of section names, a right column of rows at a 620-640px measure (`mail/settings.css:59`, docs' `.settings-column`); mail at `Settings.tsx:240-275`, docs at `:234-271`. **Calendar has the row but not the shell**: three rows in the shared `Sheet` (`:33-107`). **Margin is a modal form**: `.overlay > .panel` with stacked `` and uppercase small-caps labels (`:126-214`). They agree on the row and disagree on every name: `.set-field`/`.set-field-label`/`.set-field-note` (mail, `:856-874`), `.setting-row`/`.setting-label`/`.setting-note` (docs, `:67-88`), `.setting-row`/`.setting-name`/`.setting-note` (calendar, `overlays.css:511-535`). Docs and calendar are **one word apart**, and mail's is the only one naming the control slot and taking `children` rather than baking a switch into the row. - **Switch.** mail `ui/Toggle.tsx` (46 + 78 css), 9 uses; docs inline in `SettingRow` (`:74-85`), 3 uses. Same `