25 KiB
The visual design system across the four apps
Scope: CSS only. Tokens, fonts, the base layer, themes, and the layout idioms that repeat.
Short names below: margin = /Users/pj/Workspace/projects/python/margin, calendar =
/Users/pj/Workspace/projects/python/margin-caledar, editor =
/Users/pj/Workspace/projects/rust/margin-editor, mail = /Users/pj/Workspace/projects/rust/margin-mail.
Between them: margin 3 CSS files and 2983 lines, calendar 11 and 3796, editor 21 and 5089, mail 47
and 6571. 18439 lines total.
What margin-shared already is, and who takes it
/Users/pj/Workspace/projects/python/margin/shared ships css/tokens.css (35 distinct custom
property names), css/fonts.css (12 @font-face rules, 6 families), fonts/ (18 files: 12 TTFs
and 6 OFL notices), src/fonts.ts, src/icons.ts and bin/sync-fonts.mjs.
Three of the four consume it. margin declares "margin-shared": "file:./shared", editor and mail
both declare "file:../../python/margin/shared". Calendar does not depend on it at all and does not
import either stylesheet.
The seams are thin and consistent in the three that do:
- margin/src/styles/tokens.css:3 imports the shared tokens, then adds exactly one line,
--pane-dock: 384px. - editor/src/styles/tokens.css:5 imports, then adds 51 tokens of its own (document scale, sheet padding, code surface).
- mail/src/styles/tokens.css:6 imports, then imports
./mail.css, which adds 46.
Tokens
Calendar is a 49-of-52 copy of the shared file
calendar/src/styles/tokens.css is not a divergent palette. Comparing it block for block against shared/css/tokens.css:
:root: 11 of 11 comparable values byte-identical (--font-ui,--font-heading,--r-sm/md/lg,--titlebar-h,--t-1through--t-4,--ease). Absent:--font-book,--pane-sidebar,--measure, which a calendar has no use for.- light block: 19 of 20 identical. One drift.
- dark block: 19 of 20 identical. Same one drift.
- Absent from both palettes:
--sidebar, correctly, since the grid owns the window and there is no sidebar.
The one drift is --ink-faint. Shared has #9b9484 light and #756d5e dark; calendar
(tokens.css:74, :126) and mail (mail.css:86, :142) both have #6e675b and #8e8677.
Two apps independently moved the same token to the same two values for the same stated reason
(4.5:1 contrast on the surfaces faint ink lands on; the calendar comment names the hour axis, the
mail comment names list times and snippets and says "same reasoning, same value, as the calendar's
hour axis"). Editor and margin still take #9b9484. That is not two apps needing to differ, it is
the shared value being wrong and two apps finding out separately. Move the pair upstream and delete
both overrides.
Tokens defined in more than one app under different names, or defined in one and hardcoded in another
--scrim. Not in shared. Light isrgba(35, 32, 27, 0.28)in all three that have it (calendar tokens.css:84, editor tokens.css:14, mail mail.css:88). Dark: calendar and mailrgba(0, 0, 0, 0.58), editor tokens.css:26rgba(0, 0, 0, 0.5). margin has no token and writes the light literal into.overlayat app.css:1275 and a second, different one,rgba(35, 32, 27, 0.32), into.export-overlayat app.css:1449. This belongs in shared.--shadow-raised. editor tokens.css:150 1px 2px rgba(35, 32, 27, 0.05), dark0 1px 2px rgba(0, 0, 0, 0.35). margin writes that light value as a literal twice, app.css:272 and app.css:1839.--r-pill: 999px. Declared separately in calendar tokens.css:8, editor tokens.css:10 and mail mail.css:12, identically. margin writesborder-radius: 999pxas a literal at app.css:2184. Four apps, one value, three declarations and one literal.--t-5: 16px. calendar tokens.css:34, editor tokens.css:12, mail mail.css:17. Identical, and the comment in calendar and mail is nearly word for word the same (iOS zooms a field under 16px).--touch-h: 44px. calendar tokens.css:22, editor tokens.css:11, mail mail.css:31. Identical.--traffic-pad: 84px. calendar tokens.css:11, editor tokens.css:11, mail mail.css:36. Identical; margin has no token and hardcodes the lane unconditionally, see the drift section.--safe-top/--safe-bottom/--phonebar-h: 48px/--tabbar-h: 56px/--sheet-max-h: 88dvh. calendar tokens.css:16-26 and mail mail.css:26-34, identical values and near-identical comments. A five-token phone chrome block written twice.- calendar
--cal-1through--cal-8(tokens.css:106-113 light, :160-167 dark) and mail--hue-1through--hue-8(mail.css:115-121, :159-166) are the same sixteen hexes under two names. mail's own comment says so: "the calendar's --cal-1..8 under a name that says what they are for here".
App-only tokens that should stay app-only
editor's 51 additions are document typography and sheet geometry (--doc-h1 through --doc-h6,
--measure-*, --sheet-pad-*, --code-*, --pdf-page). mail's 46 are mail geometry (--list-w,
--avatar, --pile-h, --compose-w, --feed-w, the --message-* set that deliberately does not
follow the theme). Calendar's are grid geometry (--gutter-w, --daybar-h, --strip-h,
--event-*, --grid-*, --fold-*). margin's is --pane-dock: 384px. All genuinely single-app.
Note the collision: --row-h means a calendar grid row (48px, calendar tokens.css:44) in one app and
a message list row (46px, mail mail.css:45) in the other. A reason not to promote geometry by name.
Fonts
The bytes are already correct. All 18 files in shared/fonts are byte-identical to the copies in
margin/public/fonts, editor/public/fonts and mail/public/fonts (verified with cmp, 18/18 each).
Calendar vendors only 4 of them, HankenGrotesk-VF.ttf, HankenGrotesk-Italic-VF.ttf,
Literata-VF.ttf, Literata-Italic-VF.ttf, and those 4 are byte-identical to shared too. It ships
no OFL notices, which is the one real problem here: the other three ship all six.
shared/bin/sync-fonts.mjs copies every .ttf and .txt from shared/fonts into
<app>/public/fonts, or with --check compares and exits 1 on any difference; a file present in
the app and absent from the package is reported and left alone rather than deleted (lines 53-59).
The vendored copies exist because both PDF exporters read the same paths with include_bytes!, so
cargo must not wait on an npm install.
Who runs it: margin and editor as node node_modules/margin-shared/bin/sync-fonts.mjs ., mail as
margin-shared-fonts . through the package's bin entry. Calendar has no fonts:sync or
fonts:check script and no way to notice drift.
margin, editor and mail's src/styles/fonts.css are each a comment and one
@import "margin-shared/css/fonts.css"; margin's and editor's are byte-identical including the
comment. calendar/src/styles/fonts.css is 31 lines of hand-written @font-face for the four faces
it vendors, character-for-character the same as shared/css/fonts.css:17-47. Calendar joining costs
one import, one script pair, and 8 more files in public/fonts.
The base layer in app.css
All four start with the same reset. Measured by parsing each app.css into selector/body pairs and comparing bodies exactly:
- margin x editor: 71 selectors in common, 59 with byte-identical bodies.
- calendar x mail: 22 in common, 16 identical.
- calendar x editor: 23 in common, 15 identical.
- margin x calendar: 20 in common, 11 identical.
- editor x mail: 15 in common, 10 identical.
- margin x mail: 15 in common, 7 identical.
Identical in all four, no exceptions: *, html, body, #root, body, ::selection,
:focus-visible. The focus ring is the same three lines everywhere (margin app.css:43, calendar
app.css:80, editor app.css:51, mail app.css:92):
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
And the body block, identical in all four (margin app.css:16, calendar app.css:16, editor app.css:16, mail app.css:26):
body {
margin: 0;
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
background: var(--shell);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--t-3);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
Near-duplicates, with the differences named:
button. margin app.css:34, calendar app.css:63 and editor app.css:34 are identical seven-line blocks. mail app.css:70 adds one line,font-size: inherit. That line is right and the other three are missing it.html. margin and editor stop attext-size-adjust. calendar app.css:37 and mail app.css:23 both add-webkit-tap-highlight-color: transparentwith the same three-line comment about a webview reading every tap as a text selection. Two apps have the fix, two do not.input, textarea, select. Identical in calendar app.css:72, editor app.css:43, mail app.css:80. margin does not have it at all, so its fields fall back to the webview's font..app. calendar app.css:86, editor app.css:58 and mail app.css:100 are identical (display:flex; flex-direction:column; height:100%; overflow:hidden). margin app.css:59 usesheight: 100vh; height: 100dvh, which the other three have deliberately moved away from; the comment at calendar app.css:89-97 explains why.:root[data-touch] .icon-button,.icon-button,.icon-button:hover: identical between calendar app.css:145-170 and editor app.css:138-165. margin calls the same control.icon-btnand draws it 30px instead of 28px (app.css:103). mail folded it into.button[data-icon-only](ui/Button.css:33). Four apps, one control, three names and two sizes.
::-webkit-scrollbar exists in exactly one app, margin app.css:693-712 (11px, thumb --line-strong
with a 3px transparent border and background-clip: content-box, hover --ink-faint, transparent
track). The other three take the webview default. Editor gets the temperature right a different way,
via color-scheme in themes.css. Only editor declares color-scheme at the root
(themes.css:24-31 and once per palette); calendar declares it on one element,
create.css:53 and :57, on the quick-create card.
@media (prefers-reduced-motion: reduce): mail has 11 (app.css:208, Banner.css:64, pane.css:297 and
:358, arriving.css:78, search.css:36, list.css:126, tour.css:29, contacts.css:56, :215, :306), editor
1 (export-preview.css:167), calendar 0, margin 0. Calendar and margin both animate
(quick-create-in, sheet-up, find-drop, drawer-in-right, spin) with no guard.
The overlay and panel shell
The same box in all four, and it is the single largest near-duplicate in the codebase.
.overlay is identical in calendar app.css:330, editor app.css:451 and mail app.css:110:
position:fixed; inset:0; z-index:20; background:var(--scrim); backdrop-filter:blur(2px); display:grid; place-items:center; padding:40px. margin app.css:1271 is the same rule with
background: rgba(35, 32, 27, 0.28) written out instead of a token.
.panel is byte-identical in all four (margin app.css:1283, calendar app.css:342, editor app.css:463,
mail app.css:129): width: min(480px, calc(100vw - 32px)); max-height:100%; flex column; --paper; 1px --line; --r-lg; --shadow-pop; overflow:hidden.
.panel-body is byte-identical in all four. .panel-foot is identical in margin, calendar and
editor; mail adds flex: none. .panel-head: margin, calendar and mail use padding: 16px 14px 16px 22px,
editor uses 16px 16px 16px 22px with a comment at app.css:475-478 explaining the two pixels; mail
also adds flex: none, gap: 10px and a flex: 1; min-width: 0 on the h2. .panel-head h2 is
font-family: var(--font-book) in margin and editor and var(--font-heading) in calendar and mail,
which is a real fork: shared/css/tokens.css sets both to Literata by default, but editor lets a
document override --font-book at runtime and mail lets a setting override --font-heading, so the
same declaration means different things.
Phone docking is written twice, identically, comments included. calendar app.css:356-390 and mail
app.css:179-206 both carry :root[data-phone] .overlay (z-index 50, padding 0,
place-items: end center), :root[data-phone] .panel (full width, --sheet-max-h, border-width
1px 0 0, radius var(--r-lg) var(--r-lg) 0 0, padding-bottom: var(--safe-bottom),
animation: sheet-up 180ms var(--ease)), :root[data-phone] .panel-body { overscroll-behavior: contain }
and @keyframes sheet-up. mail adds the reduced-motion guard, calendar does not.
.overlay[data-align="top"] { align-items: start; padding-top: 12vh } appears three times: calendar
palette.css:4, editor palette.css:9, mail app.css:124.
Panel sizes are the same idiom under two names: calendar overlays.css:9-20
.overlay-panel[data-size="mini"] at min(292px, calc(100vw - 32px)) and wide at 560px, mail
Sheet.css:4-15 .sheet[data-size="mini"] at the identical 292px and wide at 620px. Both give mini
the same .panel-body { gap: 10px; padding: 12px 12px 14px }.
Buttons and fields
Three of the four have converged on the same text button by three different routes.
calendar overlays.css:60-135 .panel-button and mail ui/Button.css:1-98 .button are the same
control: inline-flex, gap: 7px, 1px solid var(--line-strong), var(--r-sm), var(--raised)
ground, --accent-wash hover, and [data-variant="primary" | "danger" | "ghost"] with the same
bodies (primary is accent ground with --accent-contrast ink hovering to --accent-ink; danger is
--danger-ink text hovering to --danger-wash with a --danger border; ghost is transparent border
and --ink-soft). The differences are the selector, the sizing (calendar pins min-height: 30px,
mail has [data-size="sm|md|lg"] at 26/28/32) and mail's [data-icon-only] square. Both end with
:root[data-touch] { min-height: var(--touch-h) }. editor settings.css:319 .btn-quiet and margin
app.css:1769-1805 .btn-primary / .btn-ghost / .btn-danger are a third and fourth spelling of
the same three variants; margin's danger is filled rather than outlined and it pads 9px 20px.
Fields: calendar overlays.css:140-216 (.field-input, .field-select, .field-textarea,
.field-hint, .field-check) and mail ui/Field.css:18-59 are the same rules to within the padding
(6px 9px vs 7px 10px) and mail's added :disabled and ::placeholder blocks. Both hover to
border-color: var(--ink-faint). margin app.css:1333-1348 is a third version on .field input, .field select with padding: 9px 11px and a :focus { border-color: var(--accent) } the other two
lack. .field and .field-label are byte-identical between margin app.css:1318-1331, calendar
app.css:423-435 and mail ui/Field.css:1-16 (uppercase, --t-1, 600, 0.08em, --ink-faint), which
is also exactly mail's .group-head (ui/GroupHead.css:1-11) and margin's and editor's .nav-label.
mail duplicates its own field twice more, at screens/settings.css:448-471 and :306.
The switch: editor settings.css:343-380 .switch / .switch-knob and mail ui/Toggle.css:1-40
.toggle / .toggle-knob are the same control at two sizes (38x22 with a 16px knob travelling 16px,
against 34x20 with a 14px knob travelling 14px). Same --r-pill track, --line-strong border,
[data-on] filling with --accent, knob turning --accent-contrast. mail adds a touch size
(46x28); editor does not.
The keycap: calendar palette.css:105-118 .key and mail ui/Key.css:1-17 .key[data-size="md"] are
byte-identical apart from mail moving the padding onto a size attribute. editor has a third,
.key-cap at tree.css:744, an --accent-wash chip with no border; margin a fourth, .esc-hint kbd
at app.css:2191.
Segmented control: mail ui/Segment.css:3-30 .segment / .segment-option and calendar app.css:483-508
.view-switch / .view-option are the same object (2px padding, 2px gap, --r-md track of
--accent-wash, --r-sm options, active option lifted onto --paper). Calendar has a third copy for
the phone at app.css:244-267 (.tabbar-views / .tabbar-view).
Menus, popovers, row menus, toasts, resizers
- Dropdown menu. margin app.css:1382-1438 and editor app.css:510-588 share
.menu-wrap,.menu-backdrop,.menu,.menu button,.menu-label,.menu-sep; five of those six bodies are byte-identical..menudiffers only inmin-width(156 vs 168) and.menu buttonin editor gaininggrid-template-columns: 14px 1frfor a glyph column. mail's equivalent, ui/Popover.css, is positioned from JS through--pop-left/--pop-top/--pop-wand is genuinely different. - Row menu.
.row-menu-btn,.row-menu-btn:hover,.row-menu-pop,.row-menu-item,.row-menu-item:hover,.row-menu-item.danger,.row-menu-item.danger:hoverare byte-identical between margin app.css:393-460 and editor app.css:363-430. Seven rules, no differences. - Toast. margin app.css:1476 and editor app.css:590 are byte-identical: fixed,
bottom: 26px, centred bytranslateX(-50%),z-index: 40,max-width: 460px,--inkground with--papertext. calendar app.css:437 and mail ui/Toast.css:1 are a different and better toast, also nearly identical to each other:bottom: 24px,z-index: 60,max-width: min(560px, calc(100vw - 48px)),--glasswithblur(8px), a--lineborder and--shadow-pop. Two designs, two apps each. - Resize handle. margin app.css:140-186 and editor tree.css:64-108 are the same rule set:
zero-width flex item, an 8px
::beforehit area atleft: -4px, a 2px::afteraccent line atleft: -1pxgoing toopacity: 0.55on hover. The only difference is the drag hook,body.resizingagainst:root[data-resizing]. editor/src/components/ResizeHandle.tsx writes--pane-sidebaron the root, so the token is already the interface. - Selected-row accent edge.
.chapter[data-active="true"]::before(margin app.css:275, editor app.css:266, byte-identical) and.row[data-selected]::before(mail ui/Row.css:29): an absolutely positioned 2-3px bar of--accentwithborder-radius: 0 2px 2px 0, inset from the row's ends. - Sidebar and nav.
.sidebar,.brand,.brand .back-label,.brand:hover,.nav-label,.nav-scroll,.nav-section + .nav-section,.chapters,.chapterand its nine state rules,.chapter-drop,.add-chapter: all byte-identical between margin and editor. The bulk of the 59 identical bodies, and a straight copy of one app's sidebar into the other. - Settings. editor styles/settings.css and mail screens/settings.css are the same layout (a rail
left, a measured column right, rows of label plus control plus note) at different numbers: rail
var(--pane-sidebar)(248px) against a literal 210px, column 620px against 640px,.settings-nav-itemagainst.settings-tab,.setting-rowagainst.set-row. Both gate the traffic lane with:root[data-traffic] { padding-left: var(--traffic-pad) }. margin and calendar keep settings inside.panel, which is a legitimate difference of kind.
Themes
Four implementations, three of which are the same file.
margin/src/theme.ts, calendar/src/theme.ts and mail/src/theme.ts are the same 16 lines with one
string changed: the localStorage key (margin-theme, margincal-theme, marginmail-theme). Same
initialTheme reading data-theme off the root first, then storage, then
matchMedia("(prefers-color-scheme: dark)"); same applyTheme writing the attribute and the key.
The boot scripts in each index.html are the same shape too, differing in the key and in what else
they set on the root (calendar adds data-phone, data-touch, data-view; mail adds data-phone,
data-touch, data-no-pane and the two font slots).
editor/src/theme.ts is 167 lines and a different design: seven named palettes
(light, sepia, mist, contrast, dark, graphite, midnight), a ThemeChoice that can be
"system", a remembered light half and dark half so "Match system" lands on the two the user
actually picks, storage in try/catch for a webview with storage denied, and watchSystemScheme
listening for the media query as an event. Its palettes live in editor/src/styles/themes.css, one
44-line block each, and editor/src/theme.test.ts reads that file and fails when a block is short.
No app uses @media (prefers-color-scheme) in CSS at all. All four resolve the system preference in
JS and write data-theme on the root. That is one decision, taken four times, and it is the right
one, so it should be taken once.
The multi-theme design is not a candidate for sharing as it stands: mail and calendar's stylesheets
have no idea sepia or midnight exist and would fall through to the light :root block. But the
three-line theme module and the boot script are, with the key as a parameter.
editor themes.css:307-322 is worth flagging: it copies six hexes of the shared light and dark
palettes so the picker's preview tiles can draw them, with a comment saying it is the only copied
colour in the file and that it is copied because "this repo may not reach into" margin-shared. It
can, and does, through margin-shared/css/tokens.css. A shared .theme-swatch[data-theme="light"]
block in the package would delete those two blocks.
Drift: values hardcoded where a token exists
Counted over every CSS file except each app's token layer:
| app | hex literals | rgba() literals |
|---|---|---|
| margin | 15 | 17 |
| calendar | 0 | 1 |
| editor | 0 | 0 |
| 0 | 0 |
Editor and mail are clean. Calendar's one is box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06) at
app.css:507 on .view-option[data-active], the only shadow in the app not from --shadow-page or
--shadow-pop. margin is where the drift lives, and most of it is a token it never adopted:
- app.css:1275
background: rgba(35, 32, 27, 0.28)on.overlay. That is--scrim, which the other three all have. - app.css:1449
background: rgba(35, 32, 27, 0.32)on.export-overlay. A second scrim at a fourth of a percent difference, which nobody chose. - app.css:272 and :1839
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.05). That is editor's--shadow-raisedexactly. - app.css:1547
box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13)on.card:hover. A third shadow that is neither--shadow-pagenor--shadow-pop. - app.css:2933
background: rgba(0, 0, 0, 0.42)on.drawer-scrim. A third scrim. - app.css:1052
0 1px 3px rgba(0, 0, 0, 0.3), app.css:1458 and :1465#fcfbf7(--paper's light value, hardcoded so it survives on the dark overlay), app.css:1464rgba(255, 255, 255, 0.28), app.css:638#fffefb, app.css:678#2b2720, app.css:1238#fff(that last is editor's--pdf-page, which editor tokenised precisely because it was a literal in two stylesheets). - app.css:2184
border-radius: 999px, which is--r-pillin the other three. - app.css:75
padding: 0 14px 0 84pxon.titlebar. The 84px is the macOS traffic-light lane, applied on every platform with nodata-trafficgate. calendar app.css:126, editor app.css:97 and mail header.css:24 all gate it, and the comment at calendar app.css:121-125 says what the ungated version costs on Linux, Windows and iPad. - app.css:2881
--titlebar-h: calc(46px + env(safe-area-inset-top))inside.app[data-compact]. It restates the 46px shared already owns and readsenv()inline where calendar and mail both have a--safe-toptoken for it.
The twelve proofing colours at app.css:2367-2451 (#b4453a, #9c6e16, #2f6e4f and their dark
counterparts, plus six washes) are a real palette with no token, and editor has the same feature
(styles/proofing.css) using --danger and friends. Worth comparing separately; it is the one place
margin's literals encode a design rather than a forgotten token.
What one shared stylesheet would have to contain
Tokens, added to shared/css/tokens.css: --scrim, --shadow-raised, --r-pill, --t-5,
--touch-h, --traffic-pad, --safe-top, --safe-bottom, --phonebar-h, --tabbar-h,
--sheet-max-h, and the eight-hue ramp under one name. Plus the --ink-faint correction. That is
19 names and one fix, and it removes every one of them from calendar, editor and mail's own layers.
A base sheet: *, html, body, #root, html (with the tap-highlight line), body, ::selection,
:focus-visible, button (with font-size: inherit), input, textarea, select, svg, .app, the
data-touch selection rules and the data-phone field-size rule. All of it is already identical or
one line from identical in all four.
An overlay sheet: .overlay, .overlay[data-align="top"], .panel, .panel-head, .panel-head h2,
.panel-body, .panel-foot, the four :root[data-phone] docking rules, @keyframes sheet-up and
its reduced-motion guard. Byte-identical or trivially reconcilable across all four today.
A controls sheet: the button (mail's [data-variant] and [data-size] version, which is the
superset), the field, the label, the toggle, the keycap, the segmented control, the icon button under
one name, the row menu, the toast (calendar and mail's version), the resize handle. Every one of
these exists in at least two apps already and differs by a padding value or a class name.
color-scheme at the root, per theme, which only editor has, and a prefers-reduced-motion guard
convention, which only mail applies consistently.
What each app keeps: editor keeps its document scale, sheet padding, code surface and its seven-palette
themes.css; mail keeps its mail geometry, the --message-* set that deliberately ignores the theme,
and its --check OS blue; calendar keeps its grid geometry and the --grid-*, --fold-* and
--event-* palettes; margin keeps --pane-dock, its scrollbar rule (or that moves up), its proofing
palette and the device-frame --dv-* set. Nothing else in the four apps' CSS is app-specific by need
rather than by accident.