25 KiB
Icons and the smallest UI primitives
Audit of margin (/Users/pj/Workspace/projects/python/margin), margin-calendar
(/Users/pj/Workspace/projects/python/margin-caledar), margin-docs
(/Users/pj/Workspace/projects/rust/margin-editor) and margin-mail
(/Users/pj/Workspace/projects/rust/margin-mail) against the partial shared package at
/Users/pj/Workspace/projects/python/margin/shared.
The shared package as it stands
shared/src/icons.ts exports twelve paths plus SUN_DISC. Its header comment says the two apps
kept drifting, that a path is a design decision, and that Icon is deliberately not shared because
sharing it "would make this package depend on React for twenty four lines, and a component is where
an app is entitled to differ."
Two of those three claims no longer hold.
The React argument is wrong on the mechanics. shared/package.json has no dependencies block at
all, no build step, and every app resolves the TypeScript source through its own bundler. All four
apps are on react: ^19.1.0. A peerDependencies entry costs zero bytes and installs nothing;
it is a version assertion, not a dependency.
"An app is entitled to differ" is contradicted by the code. Three of the four Icon.tsx files are
byte identical (md5 0ec1a568818f20ed8eed8ad46fbaa2b1):
/Users/pj/Workspace/projects/python/margin/src/components/Icon.tsx,
/Users/pj/Workspace/projects/python/margin-caledar/src/components/Icon.tsx,
/Users/pj/Workspace/projects/rust/margin-editor/src/components/Icon.tsx. In two years nobody has
exercised the entitlement.
Also worth noting: margin-calendar does not consume margin-shared at all. There is no
margin-shared line in /Users/pj/Workspace/projects/python/margin-caledar/package.json, and
src/styles/tokens.css is a hand copy of the shared file's values. Everything below that looks
like calendar drifting away from the family traces back to this one fact.
The Icon component, line by line
All four render the same SVG: viewBox="0 0 24 24", fill="none", stroke="currentColor",
strokeWidth="1.6", strokeLinecap="round", strokeLinejoin="round", size = 16 default,
{children ?? <path d={d} />}. Props are d?: string, size?: number, children?: ReactNode.
margin-mail's at /Users/pj/Workspace/projects/rust/margin-mail/src/ui/Icon.tsx:12 is the only
one that differs, in four ways, all of them improvements:
export interface IconPropsrather than a privateinterface(line 4).className="icon"(line 15), which is what lets CSS reach the element.aria-hidden="true"(line 24). The other three emit an unlabelled SVG into the accessibility tree at every one of their 142 combined call sites.import "./Icon.css"(line 2), whose entire contents are.icon { flex: none; }(Icon.css:2-4).
There is no alignment handling in any of the four components. No display, no vertical-align,
no shape-rendering, no vector-effect, no transform.
Alignment: the thing that keeps being fixed four times
Across all four repos there are zero occurrences of shape-rendering, vector-effect,
crispEdges, geometricPrecision, or a translate(0.5 0.5) style half pixel offset. The
alignment problem is not sub-pixel rasterisation. It is the two ordinary CSS facts about an inline
SVG: it sits on the text baseline, and it is a flex item that will shrink.
Five different fixes exist for those two facts, and only one app fixes them centrally.
margin-mail fixes both once:
/Users/pj/Workspace/projects/rust/margin-mail/src/styles/app.css:88svg { display: block; }This is the only global SVG rule in the suite. The other three apps have nosvgselector at document level at all./Users/pj/Workspace/projects/rust/margin-mail/src/ui/Icon.css:2.icon { flex: none; }
The other three patch it per site:
/Users/pj/Workspace/projects/python/margin-caledar/src/styles/overlays.css:36.panel-note[data-icon] svg { flex: none; transform: translateY(2px); }/Users/pj/Workspace/projects/python/margin-caledar/src/styles/details.css:114.details-row[data-block] > svg { margin-top: 2px; }/Users/pj/Workspace/projects/rust/margin-editor/src/styles/tree.css:473.start-row svg { align-self: center; color: var(--ink-faint); }
A translateY(2px) and a margin-top: 2px in the same repo, for the same symptom, four files
apart. Neither is wrong; both exist because the baseline was never dealt with at the root.
The residual case is real and survives the global fix: an icon inside an align-items: baseline
row still needs align-self: center. margin-mail hits it too, at
/Users/pj/Workspace/projects/rust/margin-mail/src/ui/Row.css:127 (.row-mark { flex: none; display: inline-flex; align-self: center; }), which is the same declaration as margin-docs'
tree.css:473. There are 20 align-items: baseline rules across the four apps, so this is a
recurring shape, not an exception.
Icon size is not a shared decision and probably should not become one. margin-mail never uses the
16px default (zero bare <Icon d=... />, nine distinct explicit sizes from 10 to 20). The other
three lean on the default heavily: 33 bare call sites in margin-docs, 14 in calendar, 12 in margin.
Glyph inventory
151 path definitions across the suite, 132 distinct strings, 13 of which appear in more than one app. The shared set covers 13. Per app, unique path strings: shared 13, margin 30, calendar 31, margin-docs 47, margin-mail 30.
Only margin-mail keeps its glyphs in a module (src/ui/icons.ts, 30 named constants, five
re-exported from margin-shared/icons at line 14). margin-docs names its toolbar and titlebar
glyphs as module constants but writes six more inline. margin and margin-calendar are almost
entirely inline d="M..." in JSX.
Shared-set uptake is thin: margin uses eleven of the twelve; margin-docs uses seven
(SIDEBAR, SEARCH, SPELLING, GRAMMAR, EXPORT, MORE, CHECK, WIDTH); margin-mail
re-exports five; margin-calendar uses none.
Same concept, different path
The important cases, with the exact strings.
SEARCH. Shared icons.ts:21 is M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4. Calendar
components/Header.tsx:22 is M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35. Different lens
radius (7 vs 8) and a different handle. This is the exact divergence the shared package's header
comment says it exists to prevent, still present because calendar never joined.
MORE. Shared icons.ts:57 is three dots, M5 12h.01M12 12h.01M19 12h.01. Calendar
components/PhoneBar.tsx:29 uses the same name for a hamburger, M4 7h16M4 12h16M4 17h16. A
straight name collision on two unrelated glyphs.
SUN. Shared splits it: SUN_RAYS (icons.ts:53) with a SUN_DISC circle at r: 4, rays
starting at M12 2v2. Calendar Header.tsx:24 is one path with an r=5 disc and rays at
M12 1v2M12 21v2M4.2 4.2.... Different construction and different geometry.
HEADING. margin/src/editor/FloatingToolbar.tsx:126 is M5 5v14M5 12h8M13 5v14.
margin-editor/src/editor/Toolbar.tsx:184 is M7 5v14M7 12h10M17 5v14, with a comment at line 182
that says exactly why: "The H used to run from x=5 to x=13 in a 24 unit box, so it sat left of
centre in a round button that every other glyph here is centred in." One app fixed the optical
centring; the other still has the bug. This is the "fix the alignment separately in each app"
complaint, at the glyph level, with the fix already written down in one repo.
BULLET LIST. margin/src/editor/FloatingToolbar.tsx:128 puts the bullets at x=3.5:
M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01.
margin-editor/src/editor/Toolbar.tsx:185 puts them at x=4: ...M4 6h.01M4 12h.01M4 18h.01. A half
unit apart on otherwise identical rules.
TRASH. margin/src/components/RowMenu.tsx:153 and margin-editor/src/components/Sidebar.tsx:46
agree: M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6. margin-mail/src/ui/icons.ts:41 is a
different drawing: M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13M10 11v6M14 11v6. Wider (4 to 20 rather than 5 to 19) and with rounded corners.
LINK. margin FloatingToolbar.tsx:153 and margin-docs Toolbar.tsx:192 agree on l2-2.
Calendar EventDetails.tsx:46 and EventEditor.tsx:42 use l3-3, a longer link arm.
REFRESH. margin BackupSettings.tsx:67 is M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5. Calendar
Header.tsx:23 is M21 12a9 9 0 11-3-6.7M21 3v6h-6. Different arc endpoint and a different arrow.
CHECK. Shared icons.ts:61 is M20 6L9 17l-5-5, used by margin-docs WidthMenu.tsx:177 at
size 14. margin-docs also draws its own at components/Settings.tsx:131,
d="M5 12.5l4.5 4.5L19 7" at size 13. One app, two ticks.
BOLD and ITALIC. margin renders letterforms, <b>B</b> and <i>I</i>
(FloatingToolbar.tsx:123-124). margin-docs draws paths, BOLD_D and ITALIC_D
(Toolbar.tsx:177-178). Same toolbar, same button, two different answers to what a bold button is.
Same drawing, different spelling
These render identically and are only string-level drift, but they are what makes a grep for
duplication useless.
- CLOSE, five spellings: shared
M6 6l12 12M18 6L6 18;M18 6L6 18M6 6l12 12in calendarEventDetails.tsx:38, margin-docsRecents.tsx:23,Sidebar.tsx:47,Toolbar.tsx:194,Settings.tsx:253, marginFindBar.tsx:248;M18 6 6 18M6 6l12 12in calendaroverlayShell.tsx:14. - MOON: shared
A9 9 0 1 1 11.2 3versus calendarA9 9 0 1111.2 3. Packed arc flags, same curve. - CLOCK: calendar
EventDetails.tsx:44M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0M12 7v5l3 2versus mailicons.ts:46M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v5l3 2. - CHEVRON_RIGHT: calendar
M9 18l6-6-6-6versus mailM9 6l6 6-6 6. Drawn from opposite ends. - DUPLICATE: margin
RowMenu.tsx:142M9 9h11v11h-11z M6 15V5h9versus margin-docsSidebar.tsx:42M9 9h11v11H9z M6 15V5h9.
Exact duplicates that are not in the shared set
PLUS (M12 5v14M5 12h14) is defined independently in all four apps. CHEVRON_UP/CHEVRON_DOWN
(M6 15l6-6 6 6 / M6 9l6 6 6-6) three times. Vertical dots (M12 5h.01M12 12h.01M12 19h.01),
MINUS, HR, IMAGE, BLOCKQUOTE twice each, always margin and margin-docs.
The icon button: eleven rules for one control
All four share a byte-identical button reset (margin app.css:34, calendar app.css:63,
docs app.css:34, mail app.css:70, the last adding font-size: inherit). On top of it:
| App | Class | Size | Radius | Idle | Hover |
|---|---|---|---|---|---|
| margin | .icon-btn (app.css:103) |
30 | --r-sm |
--ink-soft |
--accent-wash |
| margin | .find-btn (app.css:2255) |
26 | --r-sm |
--ink-soft |
--accent-wash |
| margin | .row-menu-btn (app.css:393) |
22 | --r-sm |
--ink-faint |
--accent-wash |
| calendar | .icon-button (app.css:145) |
28 | --r-sm |
--ink-soft |
--accent-wash |
| calendar | .details-close (details.css:269) |
26 | --r-sm |
--ink-faint |
--accent-wash |
| docs | .icon-button (app.css:138) |
28 | --r-sm |
--ink-soft |
--accent-wash |
| docs | .find-btn (tree.css:585) |
26 | --r-sm |
--ink-soft |
--accent-wash |
| docs | .start-forget (tree.css:450) |
26 | --r-sm |
--ink-faint |
--accent-wash |
| docs | .row-menu-btn (app.css:363) |
22 | --r-sm |
--ink-faint |
--accent-wash |
| docs | .tree-twisty (tree.css:189) |
16 | --r-sm |
--ink-faint |
--accent-wash |
.button[data-icon-only][data-variant="ghost"] |
28 via aspect-ratio: 1 |
--r-sm |
--ink-soft |
--accent-wash |
Every one of them is display: grid; place-items: center (except .start-forget, which spells it
out as flex, and mail, which is inline-flex) with the same radius token, the same hover wash and
one of two colour tokens. margin's .row-menu-btn and margin-docs' .row-menu-btn are the same
block copied verbatim into two repos. 26px appears four times across three apps.
The name is the only thing that reliably differs: .icon-btn in margin, .icon-button in the
other two.
The "on" state is where they genuinely disagree. margin app.css:118 and margin-docs
app.css:156 are the same three declarations (color: var(--accent); background: var(--accent-wash); box-shadow: inset 0 0 0 1px var(--line-strong)) under two attribute names,
data-on="true" and data-active="true". Calendar app.css:160 drops the ring and uses
--ink. Mail Button.css:69 makes [data-active] identical to :hover, so an open panel's
button and a hovered button are the same picture. Four apps, four answers, two of them pixel
identical under different attribute names. Attribute usage is mixed inside every app too: margin
21 data-on and 2 data-active, calendar 6 and 4, docs 12 and 9, mail 9 and 8.
There is no icon-button component anywhere except margin-mail. 54 call sites across the three
older apps hand-write <button className="icon-btn|icon-button" title=... onClick=...><Icon d={...} /></button>: margin 18, calendar 16, margin-docs 20. margin-mail has 115 <Button>
usages and 16 iconOnly ones, and Button.tsx:57 supplies the accessible name automatically:
aria-label={label ?? (iconOnly ? title : undefined)}.
The floating toolbar button is forked in the worst way. margin app.css:893 .tool and
margin-editor app.css:623 .tool are the same rule except one writes border-radius: 999px and
the other border-radius: var(--r-pill). The same literal-versus-token split repeats on
.editor-toolbar (margin app.css:889 vs docs app.css:619). The JS helpers differ only in
arity: margin/src/editor/FloatingToolbar.tsx:109 is a render-scoped arrow with four positional
params; margin-editor/src/editor/Toolbar.tsx:156 is a module function with the same four plus
disabled. Both carry the identical onMouseDown={(e) => e.preventDefault()}.
Focus, disabled, tooltips, badges, spinners, keycaps
Focus rings are the one thing all four already agree on, byte for byte:
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } at
margin app.css:43, calendar app.css:80, docs app.css:51, mail app.css:92. Nobody ships a
polyfill or does keyboard-versus-mouse detection. The divergence is in the exceptions: 18 sites
across the suite write outline: none with no replacement, and only margin-mail invents a second
ring colour (screens/settings.css:467, outline: 2px solid var(--accent-wash) at 1px offset).
The round-control case is handled twice and missed once: calendar create.css:445 and docs
toolbar.css:157 both use a two-layer box-shadow (0 0 0 2px var(--paper), 0 0 0 3px var(--accent) and 0 0 0 1.5px ... respectively, radii disagree), while mail's 15px round swatch
has no focus rule and gets the square outline that calendar's comment at create.css:435 warns
about. /Users/pj/Workspace/projects/python/margin/src/focus.ts is the only focus-trap module in
the family; the other three have none.
Disabled has no agreement at all: five opacity values across four apps. margin uses 0.4, 0.5
and 0.6 in one file; calendar uses 0.45; margin-docs uses 0.4, 0.45 and 0.5; margin-mail mostly
abandons opacity for color: var(--ink-faint); background: var(--raised)
(ui/Button.css:84), which is the same recipe calendar reached independently at
overlays.css:85. Only one site in the suite pairs :disabled with pointer-events: none
(docs export-preview.css:75).
Tooltips do not exist as a component in any app. All four use the native title attribute:
44, 40, 66 and 71 occurrences. margin is the outlier on labelling, 44 title against 5
aria-label, so most of its icon buttons are unnamed to a screen reader; the other three run
21/43/42. The text generator is forked: margin-docs Titlebar.tsx:105 shortcutTitle(id) returns
a whole string, calendar Header.tsx:33 hint(command) returns a leading-space suffix, and margin
hardcodes title="Find (⌘F)" (EditorView.tsx:294) and "Link (⌘K)"
(FloatingToolbar.tsx:153), which are not platform aware.
Badges share one recipe and disagree on every number: a wash-tinted micro chip at
padding: 1px 5|6|9px; background: var(--accent-wash); color: var(--ink-faint); font-size: var(--t-1), in calendar details.css:93, overlays.css:487, agenda.css:90 and mail
tour.css:127, with the radius --r-sm in calendar and --r-pill in mail. Status dots come in
5, 6, 7, 8 and 9px, and border-radius: 50% and var(--r-pill) are both used within one repo
(docs app.css:132 vs toolbar.css:311). margin and margin-docs share three copy-pasted classes
verbatim: .dirty-dot, .preview-count, .find-count. font-variant-numeric: tabular-nums on
counts is used by all four.
Loading is the deepest split, and it is a product decision rather than an oversight. margin
and margin-docs have rotating spinners (margin app.css:1461 .spinner, plus a byte-identical
backup-spin duplicate of spin at :2711; docs export-preview.css:139 .preview-spinner).
margin-calendar has no spinner, no skeleton and no loading keyframes at all; it expresses
pending state as [data-busy] and [data-pending] on the content itself. margin-mail bans
spinners in three separate comments and uses bars and skeletons instead. Reduced motion is handled
in three different ways: margin has no guard at all, docs slows the spinner from 0.7s to 2.4s, mail
disables outright in five places. Do not try to unify this; the four apps mean different things.
Keycaps. calendar palette.css:104 .key and mail ui/Key.css:1 + [data-size="md"] are the
same chip: bordered, --raised, --r-sm, --t-1, min-width: 20px, padding: 2px 6px,
line-height: 1.4. margin-docs tree.css:744 .key-cap is a different chip, wash-filled with no
border at --t-2 and weight 600. margin has no chip, one rule
(app.css:2191 .esc-hint kbd). Only margin-mail has a Key component
(ui/Key.tsx:15), only mail puts a cap on ordinary buttons, and only mail hides caps on phones
(Key.css:28). Underneath, keys/bindings.ts in calendar, docs and mail declare identical isMac
and PRIMARY_LABEL lines and an identical eight-entry NAMED map, then implement keyLabel()
three different ways: calendar (:110) cannot express ⌘⇧F at all, docs (:243) infers shift
from case, mail (:579) treats shift as a first-class modifier. margin has no bindings table.
Titlebar and window chrome
All four are Tauri v2 with "titleBarStyle": "Overlay" and native traffic lights. Nobody draws
window controls, nobody sets decorations, hiddenTitle, transparent or macOSPrivateApi, and
nobody uses startDragging or -webkit-app-region; every drag region is the
data-tauri-drag-region attribute.
The .titlebar rule is the same nine declarations in all four
(margin app.css:67, calendar app.css:102, docs app.css:76, mail header.css:7):
flex: none; position: relative; z-index: 45; height: var(--titlebar-h); display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; background: var(--shell); border-bottom: 1px solid var(--line). Differences: margin and calendar and mail set user-select: none, docs
does not; calendar folds --safe-top into the height and padding.
The lane for the traffic lights is 84px in all four and is reserved three different ways.
margin app.css:74 hardcodes it in padding: 0 14px 0 84px, unconditionally, with no token and no
platform gate, so Linux and Windows get a dead 84px lane. Calendar (app.css:126) and mail
(header.css:24) put padding-left: var(--traffic-pad) on the row under :root[data-traffic].
margin-docs puts it on the child instead, :root[data-traffic] .titlebar .lead { margin-left: calc(var(--traffic-pad) - 14px) } (app.css:97), with a comment explaining that padding on the
row pushed the centred title 35px right of the middle. Calendar's view switcher and mail's
<Segment> are both in centre columns and are subject to exactly that offset.
Only margin-docs has native code. /Users/pj/Workspace/projects/rust/margin-editor/src-tauri/src/titlebar.rs
resizes the NSTitlebarContainerView on Resized, Focused and ThemeChanged so the lights
centre in a 46px row, with a const TITLEBAR_H: f64 = 46.0 at line 79 that duplicates
--titlebar-h: 46px from shared/css/tokens.css:24. Calendar and mail instead set
"trafficLightPosition": { "x": 9, "y": 25 } in tauri.conf.json and never reapply. margin does
neither, so its lights sit at the macOS default, roughly 7px high in a 46px row, which is the
misalignment titlebar.rs was written to fix.
--traffic-pad: 84px is declared four times (calendar tokens.css:11 and :60,
docs tokens.css:11, mail mail.css:36) and is not in margin-shared. So is
--r-pill: 999px and --touch-h: 44px, three copies each. margin declares none of them and
inlines the literals.
What to share, and what not to
Share, high confidence:
Iconitself. Three byte-identical copies plus one strictly better fourth. Movemargin-mail's version (className,aria-hidden, exported props type) toshared/src/Icon.tsxwithreactas a peer dependency. The stated reason not to has no mechanical basis.- The two lines of alignment that go with it:
svg { display: block }and.icon { flex: none }, asshared/css/icon.css. This is the fix that has been made five different ways in four repos and is the direct answer to "I keep fixing icon alignment separately." - The rest of the glyphs. Promote
PLUS,CHEVRON_UP/DOWN/LEFT/RIGHT, vertical dots,MINUS,HR,IMAGE,BLOCKQUOTE,TRASH,LINK,REFRESH,CLOCK,DOCUMENT,COPY,EXTERNAL,BOLD,ITALIC,HEADING,BULLET_LISTintoshared/src/icons.ts, picking the better drawing where they have drifted (margin-docs'HEADING_DandBULLET_LIST_D, the margin/margin-docsTRASHandLINK, the sharedSEARCHandMOREandSUN). Then delete every inlined="M..."from JSX. This turns 151 definitions into roughly 60. --r-pill,--touch-hand--traffic-padintoshared/css/tokens.css. Three copies each of a single number, and in--traffic-pad's case a number that the Rust in one repo has to agree with.- The keycap. Move
margin-mail'sKey.tsxandKey.css; calendar's.keyis already the same chip, and margin-docs'.key-capis a divergence that should be resolved rather than kept. keyLabel,normalizeCombo,PRIMARY_LABELand theNAMEDmap. Three near-identical implementations of the same twenty lines with three different bugs.margin-mail's is the correct one. This is not strictly a UI primitive, but it is why the caps and titles disagree.
Share, but the shape needs deciding first:
- The icon button. Eleven rules for one control is the clearest duplication in the audit, but
margin-mail'sButtonbundles size, variant, keycap and icon into one component, while the other three want a flat class they can put on any element. The tractable move is to share the CSS (a.icon-buttonat 28px with--r-sm,--accent-washhover, and a settled[data-on]ring) and let each app keep its own JSX for now. Renaming margin's.icon-btnand settling on one ofdata-onordata-activeis a prerequisite either way. - The
.titlebargrid rule and the traffic lane. The nine declarations are common; the lane mechanism is not, and margin-docs' child-margin version is the correct one.titlebar.rsbelongs in a shared Rust crate eventually, but that is a bigger move than this audit covers.
Do not share:
- Spinners and loading states. margin spins, calendar refuses to have any loading affordance, margin-mail bans spinners on the record. These are four different product positions, not four copies of one decision.
- Badges, chips and pills. The wash-chip recipe recurs, but every app's numbers are tuned to its own density (a calendar all-day chip is a layout unit, not a badge). Sharing the tokens is enough.
- Focus ring exceptions. The global rule is already shared through the tokens; the 18
outline: nonesites are each local judgement calls, and margin-docs is the only app that writes down why. - The floating editor toolbar. It exists in two apps only, and its
.toolis a different control from.icon-button(a min-width pill that holds a letterform as often as a glyph). Worth de-duplicating between margin and margin-docs, not worth putting in a package the calendar and mail apps import.
Prerequisite for all of it: margin-calendar has to depend on margin-shared. It is one line
in its package.json ("margin-shared": "file:../margin/shared") and deleting its hand-copied
tokens.css values. Every calendar-specific divergence in this document, SEARCH, MORE, SUN,
MOON, LINK, REFRESH, the duplicated palette, follows from the fact that it never joined.