mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 19:17:04 +00:00
Margin Calendar: a Google Calendar client for desktop and phone
Tauri 2, React 19 and zustand on the front, Rust behind. Rust owns auth, all HTTP to Google, the SQLite store, the sync loop, recurrence expansion and timezone maths. TypeScript owns rendering and never talks to Google, which keeps the content security policy locked to ipc:. Week, day and agenda views, and no month view: it would be a second layout engine, and the fit and fold logic that makes a day fit the window without scrolling is the whole point of the app. Runs on macOS, Linux, Android and iOS. Desktop catches Google's OAuth redirect on a loopback port. A phone cannot, and Google rejects loopback for mobile client types anyway, so it redirects to a custom URI scheme and needs its own public OAuth clients, which docs/mobile.md covers. Refresh tokens are sealed with XChaCha20-Poly1305 in the app data directory on every platform, with no OS credential store in the picture. On a phone the chrome becomes a top bar and a bottom tab bar, overlays become sheets, hover affordances become taps, and dragging out an event waits for a long press. Navigation moves one day at a time everywhere, a swipe included.
This commit is contained in:
commit
661100dfdc
243 files changed
+35200
No files matched your search
@@ -0,0 +1,469 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection);
|
||||
}
|
||||
|
||||
/* A webview reads every tap as the start of a text selection: a grey flash over the button, a
|
||||
callout on a long press, and a caret in whatever it decided you meant. None of that belongs on
|
||||
chrome. All of it belongs in a field, so the fields ask for it back. */
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:root[data-touch] body {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
:root[data-touch] input,
|
||||
:root[data-touch] textarea,
|
||||
:root[data-touch] [contenteditable] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
/* iOS Safari zooms the page in on a focused field under 16px, and the viewport is locked, so
|
||||
there is no gesture that undoes it. */
|
||||
:root[data-phone] input,
|
||||
:root[data-phone] textarea,
|
||||
:root[data-phone] select {
|
||||
font-size: var(--t-5);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The grid owns the window: one header row, no permanent sidebar, nothing else resident. */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Fill whatever the layout viewport turns out to be, rather than naming a unit that claims to
|
||||
know it. html, body and #root are already exactly that box, so this inherits the right answer
|
||||
on every platform without being told.
|
||||
|
||||
Deliberately not a viewport unit. UIKit shrinks that box by the safe areas while leaving it
|
||||
anchored at y 0, and `stop_uikit_shrinking_the_viewport` in lib.rs is the only thing stopping
|
||||
it. Should that ever regress, a unit naming the whole screen would lay the tab bar out below
|
||||
the clip `body { position: fixed }` imposes, where it is not merely misplaced but invisible
|
||||
and untappable. `100%` cannot drift that way. */
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 45;
|
||||
height: var(--titlebar-h);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 0 14px 0 var(--traffic-pad);
|
||||
background: var(--shell);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.titlebar .lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titlebar .trail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.icon-button[data-active="true"] {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* 28px is a pointer's target. A finger is nearer 44, and a tablet gets the desktop layout with
|
||||
none of the desktop's aim. */
|
||||
:root[data-touch] .icon-button {
|
||||
width: var(--touch-h);
|
||||
height: var(--touch-h);
|
||||
}
|
||||
|
||||
/* Phone chrome
|
||||
----------------------------------------------------------------------------------------------
|
||||
The header's three groups do not fit a 390px row, so they split: where you are goes to the top,
|
||||
what you are looking at goes to the bottom where the thumb already is, and the rest goes behind
|
||||
the overflow. Both bars are sized for a finger whether or not the pointer says it is one, since
|
||||
a phone-width window is a phone-shaped layout either way. */
|
||||
|
||||
.phonebar {
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 45;
|
||||
height: calc(var(--phonebar-h) + var(--safe-top));
|
||||
padding: var(--safe-top) 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--shell);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.phonebar .icon-button {
|
||||
flex: none;
|
||||
width: var(--touch-h);
|
||||
height: var(--touch-h);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Takes what the fixed controls leave and trims the range rather than pushing them off the edge. */
|
||||
.phonebar-range {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: var(--touch-h);
|
||||
padding: 0 6px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--t-4);
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.phonebar-today {
|
||||
flex: none;
|
||||
min-width: var(--touch-h);
|
||||
height: var(--touch-h);
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* There is no hover to light up, so the press itself is the feedback. */
|
||||
.phonebar-range:active,
|
||||
.phonebar-today:active,
|
||||
.tabbar-view:active {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.tabbar {
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 45;
|
||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||
padding: 0 10px var(--safe-bottom);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--shell);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabbar-views {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.tabbar-view {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: var(--touch-h);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.tabbar-view[data-active="true"] {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The only action with a permanent button of its own. Everything else is a row in the overflow. */
|
||||
.tabbar-new {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--touch-h);
|
||||
height: var(--touch-h);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.tabbar-new:active {
|
||||
background: var(--accent-ink);
|
||||
}
|
||||
|
||||
.stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
/* Overlays, margin's idiom: summoned by a key, dismissed with Escape, never resident. */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: var(--scrim);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* On a phone the same panel docks to the bottom edge: a centred box with a 40px moat around it
|
||||
spends the only screen there is on scrim, and puts its buttons at the far end of the reach. */
|
||||
:root[data-phone] .overlay {
|
||||
/* Over the bars rather than under them. The desktop leaves its titlebar lit above the scrim
|
||||
because the traffic lights live there, but a sheet on the bottom edge would otherwise come up
|
||||
underneath the tab bar and lose its last row to it. */
|
||||
z-index: 50;
|
||||
padding: 0;
|
||||
place-items: end center;
|
||||
}
|
||||
|
||||
/* Search is the one overlay child that is not a `.panel`: it brings its own surface and its own
|
||||
width, so all it wants from the phone is to be clear of the home indicator. */
|
||||
:root[data-phone] .overlay > :not(.panel) {
|
||||
margin-bottom: var(--safe-bottom);
|
||||
}
|
||||
|
||||
:root[data-phone] .panel {
|
||||
width: 100%;
|
||||
max-height: var(--sheet-max-h);
|
||||
border-width: 1px 0 0;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
/* The home indicator sits over the foot of the sheet, so the last row stops above it. */
|
||||
padding-bottom: var(--safe-bottom);
|
||||
animation: sheet-up 180ms var(--ease);
|
||||
}
|
||||
|
||||
/* A list that has run out of rows must not start scrolling the page underneath it. */
|
||||
:root[data-phone] .panel-body {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 14px 16px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 20px 22px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
max-width: min(560px, calc(100vw - 48px));
|
||||
padding: 10px 16px;
|
||||
background: var(--glass);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-pop);
|
||||
color: var(--ink);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Header. The grid owns the window, so this row is the only persistent chrome there is. */
|
||||
.header-today,
|
||||
.header-range {
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
white-space: nowrap;
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.header-today:hover,
|
||||
.header-range:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* At the minimum window size the agenda's range is long enough to overrun its 1fr column and
|
||||
paint under the view switcher, so it ellipsizes rather than collides. */
|
||||
.header-range {
|
||||
font-size: var(--t-4);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.view-switch {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.view-option {
|
||||
height: 22px;
|
||||
padding: 0 11px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--t-2);
|
||||
color: var(--ink-soft);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.view-option:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.view-option[data-active="true"] {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.header-hours {
|
||||
font-size: var(--t-1);
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-compact] .header-hours {
|
||||
display: none;
|
||||
}
|
||||
Reference in new issue
Block a user