mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-03 19:47: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,336 @@
|
||||
/* The agenda, and search results, which are the same list. There is no time axis here at all: a
|
||||
row says when in words, so nothing is measured, nothing is solved, and the list is free to be as
|
||||
long as the span. It is the one part of the app that scrolls.
|
||||
|
||||
Surfaces, the coloured edge and the status rules are the grid's, so the two views read as one
|
||||
product rather than two. */
|
||||
|
||||
.agenda {
|
||||
/* Agenda-local geometry. The shared block tokens live in tokens.css. */
|
||||
--agenda-w: 900px;
|
||||
--agenda-time-w: 124px;
|
||||
--agenda-row-h: 24px;
|
||||
--agenda-gap-h: 22px;
|
||||
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
:root[data-compact] .agenda {
|
||||
--agenda-time-w: 96px;
|
||||
--agenda-row-h: 22px;
|
||||
}
|
||||
|
||||
.agenda-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.agenda-inner {
|
||||
width: min(var(--agenda-w), 100%);
|
||||
margin: 0 auto;
|
||||
padding: 0 16px 48px;
|
||||
}
|
||||
|
||||
.agenda-note {
|
||||
margin: 0;
|
||||
padding: 14px 12px 4px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* Day heading
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.agenda-day-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 13px 4px 4px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 400;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.agenda-day-name {
|
||||
font-size: var(--t-2);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.agenda-day-date {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--t-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.agenda-day-month {
|
||||
font-size: var(--t-2);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.agenda-day-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
align-self: center;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.agenda-day-count {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Today is the one heading that carries the accent, same pill the day bar uses. */
|
||||
.agenda-day[data-today] .agenda-day-date {
|
||||
padding: 1px 7px 2px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
/* What has already happened is still worth seeing, just not worth looking at. */
|
||||
.agenda-day[data-past] .agenda-day-name,
|
||||
.agenda-day[data-past] .agenda-day-date,
|
||||
.agenda-day[data-past] .agenda-day-count {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* Rows
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.agenda-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 3px 0 12px;
|
||||
}
|
||||
|
||||
/* The row is filled with its calendar's colour, the same as a block on the grid, a step further up
|
||||
the lightness scale and with less of the hue's chroma: a row is several times the area of a
|
||||
block, and a fortnight of them at the grid's strength is the wall of highlighter this view exists
|
||||
to avoid. Everything the two numbers feed is derived in grid.css, which is where the two views
|
||||
agree on what a calendar's colour means. */
|
||||
.agenda-row {
|
||||
--cal-fill: hsl(var(--cal-h) calc(46% * var(--cal-sat)) calc(91.5% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-fill-strong: hsl(var(--cal-h) calc(48% * var(--cal-sat)) calc(87.5% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-fill-quiet: hsl(var(--cal-h) calc(40% * var(--cal-sat)) calc(94% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-wash: hsl(var(--cal-h) calc(52% * var(--cal-sat)) 97.5%);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--agenda-time-w) minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: var(--agenda-row-h);
|
||||
padding: 3px 8px 3px 10px;
|
||||
text-align: left;
|
||||
background: var(--cal-fill);
|
||||
outline: 1px solid var(--cal-line-quiet);
|
||||
outline-offset: -1px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--cal-ink);
|
||||
transition: background 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .agenda-row {
|
||||
--cal-fill: hsl(var(--cal-h) calc(24% * var(--cal-sat)) calc(19% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-fill-strong: hsl(var(--cal-h) calc(26% * var(--cal-sat)) calc(24% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-fill-quiet: hsl(var(--cal-h) calc(20% * var(--cal-sat)) calc(15.5% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-wash: hsl(var(--cal-h) calc(22% * var(--cal-sat)) 12%);
|
||||
}
|
||||
|
||||
.agenda-row:hover {
|
||||
background: var(--cal-fill-strong);
|
||||
}
|
||||
|
||||
.agenda-row[data-readonly] {
|
||||
background: var(--cal-fill-quiet);
|
||||
}
|
||||
|
||||
.agenda-row[data-selected] {
|
||||
background: var(--cal-fill-strong);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.agenda-row-time {
|
||||
font-size: var(--t-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--cal-ink-soft);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agenda-row[data-allday] .agenda-row-time {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.agenda-row-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agenda-row-title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agenda-row-where {
|
||||
flex: 0 5 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--t-1);
|
||||
/* The row is a coloured surface now, so the quiet layer is the block's own quiet ink. A neutral
|
||||
grey that was readable on paper is not readable on this. */
|
||||
color: var(--cal-ink-soft);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Not confirmed yet: the fill drops back to a wash and the rim breaks, same as on the grid. */
|
||||
.agenda-row[data-status="tentative"] {
|
||||
background: var(--cal-wash);
|
||||
outline-style: dashed;
|
||||
outline-color: var(--cal-line);
|
||||
}
|
||||
|
||||
.agenda-row[data-status="tentative"] .agenda-row-title {
|
||||
color: var(--cal-ink-soft);
|
||||
}
|
||||
|
||||
/* Written locally, still in the outbox, so it is provisional in a way tentative is not: filled,
|
||||
because the hour is real, with a broken rim and italic type because the event is not whole. */
|
||||
.agenda-row[data-pending] {
|
||||
outline-style: dotted;
|
||||
outline-color: var(--cal-line);
|
||||
}
|
||||
|
||||
.agenda-row[data-pending] .agenda-row-title {
|
||||
font-style: italic;
|
||||
color: var(--cal-ink-soft);
|
||||
}
|
||||
|
||||
.agenda-row[data-pending] .agenda-row-time {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* What has already happened keeps its colour, drawn at the strength somebody else's calendar is
|
||||
drawn at. Greying the type instead would put a neutral on a coloured surface. */
|
||||
.agenda-day[data-past] .agenda-row {
|
||||
background: var(--cal-fill-quiet);
|
||||
}
|
||||
|
||||
.agenda-day[data-past] .agenda-row-title {
|
||||
color: var(--cal-ink-soft);
|
||||
}
|
||||
|
||||
/* Empty days
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.agenda-gap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: var(--agenda-gap-h);
|
||||
padding: 0 4px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
.agenda-gap[data-past] {
|
||||
color: var(--line-strong);
|
||||
}
|
||||
|
||||
.agenda-gap-dates {
|
||||
flex: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.agenda-gap-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.agenda-gap-text {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Search
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.agenda-search {
|
||||
--agenda-w: 100%;
|
||||
|
||||
width: min(680px, calc(100vw - 32px));
|
||||
max-height: 100%;
|
||||
flex: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agenda-search .agenda-inner {
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
|
||||
.agenda-search-field {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.agenda-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.agenda-search-input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.agenda-search-count {
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.agenda-mark {
|
||||
padding: 0 1px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/* The in-place create card. It hangs off the range that was dragged, so the geometry comes from
|
||||
the same two custom properties the rest of the grid uses: --days for the column count and --i
|
||||
for the column this one is on. JS only hands it the offsets it had to measure.
|
||||
|
||||
The `.swatch` family at the bottom is the one thing here that is not the card: the full editor
|
||||
imports this file for it, so the colour choice is the same control in both places.
|
||||
|
||||
--qc-w is in tokens.css now, and overridden here: the card grew a description and a palette, and
|
||||
292px was a column of squeezed rows. */
|
||||
|
||||
.quick-create {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
left: calc(100% * var(--i) / var(--days));
|
||||
width: calc(100% / var(--days) - var(--event-gap));
|
||||
pointer-events: none;
|
||||
--qc-w: 340px;
|
||||
}
|
||||
|
||||
:root[data-compact] .quick-create {
|
||||
--qc-w: 276px;
|
||||
}
|
||||
|
||||
/* What the user drew, still visible under the card. */
|
||||
.quick-create-range {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-height: var(--event-min-h);
|
||||
background: var(--accent-wash);
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.quick-create-range[data-elsewhere] {
|
||||
border-style: dotted;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.quick-create-card {
|
||||
position: absolute;
|
||||
width: var(--qc-w);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 12px 13px 11px;
|
||||
pointer-events: auto;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-pop);
|
||||
color-scheme: light;
|
||||
animation: quick-create-in 120ms var(--ease);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .quick-create-card {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Measured before it is placed, so it must not be seen at the wrong corner first. Transparent
|
||||
rather than hidden, because the title field takes focus on that first frame. */
|
||||
.quick-create-card:not([data-placed]) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes quick-create-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-create-title {
|
||||
width: 100%;
|
||||
padding: 1px 1px 4px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: var(--t-4);
|
||||
transition: border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
/* This field is focused the instant the card opens, so the app's focus ring would be on screen
|
||||
before the user has done anything. It underlines itself instead. */
|
||||
.quick-create-title:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quick-create-title:focus {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.quick-create-title::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* When
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
/* Wraps rather than squeezing: a narrow window that clips the meridiem off a time field is worse
|
||||
than one that puts the all-day button on its own line. */
|
||||
.quick-create-when {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.quick-create-time {
|
||||
flex: none;
|
||||
padding: 3px 5px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
font-size: var(--t-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.quick-create-time:hover {
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* The field is small and the picker is a WebKit lump. Typing is the point here. */
|
||||
.quick-create-time::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-create-to {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
.quick-create-length {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-create-day {
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-create-day[data-moved] {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.quick-create-toggle {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease), border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.quick-create-toggle:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.quick-create-toggle[data-on] {
|
||||
background: var(--accent-wash);
|
||||
border-color: var(--line-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Where
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.quick-create-where {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quick-create-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--cal);
|
||||
}
|
||||
|
||||
.quick-create-calendar,
|
||||
.quick-create-place {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
font-size: var(--t-2);
|
||||
transition: border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.quick-create-calendar:hover,
|
||||
.quick-create-place:hover {
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.quick-create-calendar {
|
||||
appearance: none;
|
||||
padding-right: 20px;
|
||||
background-image: linear-gradient(45deg, transparent 50%, currentcolor 50%),
|
||||
linear-gradient(135deg, currentcolor 50%, transparent 50%);
|
||||
background-position: calc(100% - 11px) 10px, calc(100% - 7px) 10px;
|
||||
background-size: 4px 4px, 4px 4px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.quick-create-place::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* Colour and description
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.quick-create-colour {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-create-label {
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
/* Narrow: the label takes its own line rather than pushing two swatches onto a second one, which
|
||||
is the difference between a palette and a ragged pile of dots. */
|
||||
:root[data-compact] .quick-create-colour {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Three rows deep, which is enough to see a paragraph without the card becoming the editor. It
|
||||
still grows by hand, and the anchor re-measures when it does. */
|
||||
.quick-create-note {
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
font-family: inherit;
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.45;
|
||||
resize: vertical;
|
||||
transition: border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.quick-create-note:hover {
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.quick-create-note::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* What it will make
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.quick-create-hint,
|
||||
.quick-create-warning {
|
||||
margin: 0;
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.quick-create-hint {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.quick-create-hint code {
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.quick-create-warning {
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
/* Foot
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.quick-create-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 1px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.quick-create-link {
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 2px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.quick-create-link:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.quick-create-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease), opacity 120ms var(--ease);
|
||||
}
|
||||
|
||||
.quick-create-button:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.quick-create-button[data-variant="primary"] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.quick-create-button[data-variant="primary"]:hover {
|
||||
background: var(--accent-ink);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.quick-create-button:disabled,
|
||||
.quick-create-link:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.quick-create-button:disabled:hover,
|
||||
.quick-create-link:disabled:hover {
|
||||
background: none;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.quick-create-button[data-variant="primary"]:disabled:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.quick-create-key {
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.quick-create-button[data-variant="primary"] .quick-create-key {
|
||||
background: var(--selection);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
/* Narrow: the keycaps are the first thing that can go. */
|
||||
:root[data-compact] .quick-create-key {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The editor's when row
|
||||
---------------------------------------------------------------------------------------------
|
||||
The two date fields take the slack in that row, and `flex: 0 1 auto` let the time fields give it
|
||||
to them: at the editor's width they were squeezed to about 48px, which in Chromium is the picker
|
||||
icon and none of the digits. A time you cannot read is not a field. They hold their content
|
||||
width now and the dates, which have far more room than they need, do the shrinking. */
|
||||
|
||||
.editor .field-row .field-input[type="time"] {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Swatches
|
||||
---------------------------------------------------------------------------------------------
|
||||
Shared with the event editor, which imports this file for them. Twelve dots on one line: the
|
||||
eleven Google keeps against an event and the one that is not a colour, which is a ring in the
|
||||
calendar's own hue rather than a filled dot, because following the calendar is not a choice of
|
||||
paint. They wrap rather than shrink when the surface is too narrow for a line of them. */
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--swatch);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.swatch[data-default] {
|
||||
background: none;
|
||||
box-shadow: inset 0 0 0 2px var(--swatch);
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
/* The ring is drawn in the card's own surface first so it reads as a gap, then in ink. Two shadows
|
||||
rather than an outline, because an outline on a round button is a square on WebKit. */
|
||||
.swatch[data-on] {
|
||||
box-shadow: 0 0 0 2px var(--paper), 0 0 0 3px var(--ink);
|
||||
}
|
||||
|
||||
.swatch[data-default][data-on] {
|
||||
box-shadow: inset 0 0 0 2px var(--swatch), 0 0 0 2px var(--paper), 0 0 0 3px var(--ink);
|
||||
}
|
||||
|
||||
.swatch:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--paper), 0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
.swatch[data-default]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--swatch), 0 0 0 2px var(--paper), 0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
.swatch:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/* The event details card. It is a popover pinned to the block that was clicked, not an overlay:
|
||||
no backdrop, no centring, and it positions itself in JS because only JS knows where the block
|
||||
is. Everything visual it needs beyond this file comes from the shell in app.css and the
|
||||
`.panel-button`, `.scope-*`, `.guest-*` and `.confirm-*` families in overlays.css, which it
|
||||
shares with the editor rather than restating. */
|
||||
|
||||
/* Wide enough for a paragraph rather than a caption: a description is the only thing in here that
|
||||
is prose, and at 336px a pasted link was four cramped lines. Tall enough to show one without
|
||||
scrolling, and capped well short of the window so the thing stays a popover pinned to its block
|
||||
instead of drifting into being a modal. It only grows to what is in it, so an event with a title
|
||||
and a time is still a short card. */
|
||||
.details-card {
|
||||
position: fixed;
|
||||
z-index: 18;
|
||||
width: min(408px, calc(100vw - 16px));
|
||||
max-height: min(560px, calc(100vh - var(--titlebar-h) - 16px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-left: var(--edge-w) solid var(--cal, var(--line-strong));
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
/* Invisible for the one frame between mounting and being measured, so it never flashes at 0,0.
|
||||
Opacity rather than visibility, because a hidden element cannot take the focus. */
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.details-card[data-placed] {
|
||||
opacity: 1;
|
||||
animation: details-in 110ms var(--ease);
|
||||
}
|
||||
|
||||
@keyframes details-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-compact] .details-card {
|
||||
width: min(344px, calc(100vw - 16px));
|
||||
max-height: min(480px, calc(100vh - var(--titlebar-h) - 16px));
|
||||
}
|
||||
|
||||
/* The one scroll region. The footer is outside it, so however long the description runs, Edit,
|
||||
Delete and Close are where they were. */
|
||||
.details-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 15px 17px 16px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.details-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.details-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: var(--t-4);
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.details-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.details-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
/* One icon column, one text column, so the rows line up whatever is in them. */
|
||||
.details-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.details-row[data-block] {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.details-row[data-block] > svg {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.details-row > svg {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.details-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--cal, var(--line-strong));
|
||||
}
|
||||
|
||||
.details-text {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.details-text[data-soft] {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
/* The description, which arrives from Google as html and is rendered from a parsed tree rather
|
||||
than as source. It is the only prose in the card, so it gets the larger of the two type sizes
|
||||
and a looser line. It does not scroll on its own: a region that scrolls inside a card that also
|
||||
scrolls is two things to aim at with a trackpad and a half-cut line of text saying neither. */
|
||||
.details-desc {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.55;
|
||||
/* Pasted Drive URLs are one word two hundred characters long. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Plain text keeps the spacing it was written with. Html has had its own collapsed already and
|
||||
carries its structure in elements, where pre-wrap would double every gap in the source. */
|
||||
.details-desc[data-plain] {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.details-desc :is(div, ul, ol) {
|
||||
margin: 7px 0 0;
|
||||
}
|
||||
|
||||
.details-desc :is(div, ul, ol):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.details-desc :is(ul, ol) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.details-desc li + li {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.details-desc code {
|
||||
padding: 0 4px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* A shade heavier than the conference link's underline, because these sit inside a paragraph and
|
||||
have to be findable in the middle of a sentence rather than on a line of their own. */
|
||||
.rich-link,
|
||||
.desc-link {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--ink-faint);
|
||||
text-underline-offset: 2px;
|
||||
transition: text-decoration-color 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.rich-link:hover,
|
||||
.desc-link:hover {
|
||||
color: var(--accent-ink);
|
||||
text-decoration-color: currentcolor;
|
||||
}
|
||||
|
||||
.details-link {
|
||||
justify-self: start;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
color: var(--ink);
|
||||
font-size: var(--t-2);
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--line-strong);
|
||||
text-underline-offset: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: text-decoration-color 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.details-link:hover {
|
||||
color: var(--accent-ink);
|
||||
text-decoration-color: currentcolor;
|
||||
}
|
||||
|
||||
.details-guests {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.details-note {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.details-foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.details-foot .panel-button[data-side="trail"] {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* The two families borrowed from the editor are sized for a 560px panel. */
|
||||
.details-card .scope-option {
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.details-card .scope-name {
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.details-card .guest-list {
|
||||
max-height: 148px;
|
||||
}
|
||||
|
||||
/* Escape and a click outside both close the card, but neither is discoverable, and the footer
|
||||
Close sits below a description long enough to scroll. */
|
||||
.details-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-faint);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.details-close:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.details-head {
|
||||
padding-right: 34px;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/* Both directions of a fold, with the mouse.
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
/* The chip on an empty run. It hangs off the left edge of the columns rather than sitting in the
|
||||
middle of the run, because the middle of an empty afternoon is where you drag out an event and
|
||||
a control there would take the gesture the gap exists for. It is also out of the gutter, whose
|
||||
hour labels are right-aligned into exactly the space a chip would want.
|
||||
|
||||
The run itself is inert: the wash and the rule are a preview of the strip, not a target. */
|
||||
|
||||
.grid-gap {
|
||||
/* The one value this layer needs that tokens.css does not have: how brightly an empty run is lit
|
||||
while you are pointing at it, which is most of a hovered control's wash spread over a band a
|
||||
hundred times the size. Derived from --accent-wash rather than written as a literal, and it
|
||||
belongs in tokens.css once that file can move. --fold-surface would have been the literal
|
||||
preview of the strip, but in the dark palette it is within a shade of the paper and shows
|
||||
nothing at all. */
|
||||
--fold-hint: color-mix(in srgb, var(--accent-wash) 60%, transparent);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: var(--gutter-w);
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
background: var(--fold-hint);
|
||||
animation: grid-gap-in 110ms var(--ease);
|
||||
}
|
||||
|
||||
@keyframes grid-gap-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-gap-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding-right: 10px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* Where the strip would land, drawn the way the strip draws its own rule. */
|
||||
.grid-gap-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
/* The one interactive thing on this layer, and the only part of it that is not a preview. */
|
||||
.grid-gap-fold {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 7px;
|
||||
padding: 2px 8px 2px 6px;
|
||||
pointer-events: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--raised);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-1);
|
||||
white-space: nowrap;
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease),
|
||||
border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-gap-fold:hover {
|
||||
background: var(--fold-surface);
|
||||
border-color: var(--fold-line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The other direction. The strip is already the button, so this is a label rather than a target,
|
||||
and it takes its emphasis from the strip it sits on. */
|
||||
.grid-strip-mark {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-strip:hover .grid-strip-mark {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The strip already says "12am to 8am" and its whole surface is the button, so the verb is a third
|
||||
signal for the same thing. The icon stays, and the word waits until you are actually pointing at
|
||||
it, which is when a first-timer needs telling that a strip is clickable. */
|
||||
.grid-strip .grid-fold-verb {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: max-width 140ms var(--ease), opacity 140ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-strip:hover .grid-fold-verb,
|
||||
.grid-strip:focus-visible .grid-fold-verb {
|
||||
max-width: 6em;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* At the smallest window a column is 119px wide and a chip carrying a word is most of it, so the
|
||||
chip drops to its arrows and keeps the whole phrase in its title. The strip has the width either
|
||||
way and keeps its verb. */
|
||||
:root[data-compact] .grid-gap-fold .grid-fold-verb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-compact] .grid-gap-fold {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
/* With a finger
|
||||
-------------------------------------------------------------------------------------------
|
||||
|
||||
Both halves of this file were written for a pointer that can rest on something without pressing
|
||||
it. A touchscreen has no such pointer: the verb never arrives, and the chip is drawn only for
|
||||
the run under the cursor, of which there is never one. So both stop waiting to be pointed at.
|
||||
GridGaps puts a chip on every empty run to match, since there is no hovered run to choose. */
|
||||
|
||||
:root[data-touch] .grid-strip .grid-fold-verb {
|
||||
max-width: 6em;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-touch] .grid-strip-mark {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* The run is empty on every visible day, which is what makes it a run, so the chip is over nothing
|
||||
and can take the width it needs. That is also why this outranks the compact rule above: the word
|
||||
was dropped there to spare a 119px column, and there is no column under this one to spare. */
|
||||
:root[data-touch] .grid-gap {
|
||||
background: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
:root[data-touch] .grid-gap-fold .grid-fold-verb {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
:root[data-touch] .grid-gap-fold {
|
||||
min-height: var(--touch-target);
|
||||
padding: 4px 12px 4px 9px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@font-face {
|
||||
font-family: "Hanken Grotesk";
|
||||
src: url("/fonts/HankenGrotesk-VF.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Hanken Grotesk";
|
||||
src: url("/fonts/HankenGrotesk-Italic-VF.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Literata";
|
||||
src: url("/fonts/Literata-VF.ttf") format("truetype-variations");
|
||||
font-weight: 200 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Literata";
|
||||
src: url("/fonts/Literata-Italic-VF.ttf") format("truetype-variations");
|
||||
font-weight: 200 900;
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
/* The week and day grid. Geometry comes from the tokens and from two custom properties the fit
|
||||
writes on `.grid`: --row-h for the solved row height and --days for the column count. Nothing
|
||||
here measures anything; JS hands CSS the numbers and CSS does the arithmetic. */
|
||||
|
||||
/* Two numbers the finger layer needs that tokens.css does not have yet, and that belong there
|
||||
once that file can move: the smallest control a thumb lands on reliably, and the all-day row's
|
||||
touch height. The second is scoped to the band rather than written over --allday-h at the root,
|
||||
because that token is read in three other places that have their own reasons for their size. */
|
||||
:root {
|
||||
--touch-target: 32px;
|
||||
--allday-h-touch: 32px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* A press on a block is a gesture, not a text selection, and iOS answers a long press over
|
||||
anything selectable with a callout menu right where the drag was meant to start. Overridden
|
||||
on the draft's input below, which is the one piece of real text on the grid. */
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* Day bar
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
.grid-head {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: var(--gutter-w) minmax(0, 1fr);
|
||||
height: var(--daybar-h);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.grid-head-gutter {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.grid-head-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--days), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* A button, because tapping a day opens it. That is the escape hatch that makes a seven column
|
||||
week on a phone worth putting up at all, and it costs the mouse nothing: the same click does
|
||||
the same thing with a pointer. */
|
||||
.grid-head-day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-head-day:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.grid-head-day[data-past] {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.grid-head-day[data-today],
|
||||
.grid-head-day:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.grid-head-name {
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.grid-head-date {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--t-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.grid-head-day[data-today] .grid-head-date {
|
||||
padding: 1px 7px 2px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
/* All-day band
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
.grid-allday {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: var(--gutter-w) minmax(0, 1fr);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.grid-allday-gutter {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 7px 0 0;
|
||||
border-right: 1px solid var(--line);
|
||||
font-size: var(--t-1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.grid-allday-wrap {
|
||||
position: relative;
|
||||
height: calc(var(--allday-h) * var(--rows) + 5px);
|
||||
}
|
||||
|
||||
/* A chip is a row tall and the row is fixed, so the only way to make one hittable is to make the
|
||||
row taller. That is safe here and nowhere else on the grid: the band is measured, not computed,
|
||||
and the axis below is solved from whatever is left, so the six pixels come out of the day's
|
||||
scale rather than out of the fit's arithmetic. */
|
||||
:root[data-touch] .grid-allday-wrap {
|
||||
--allday-h: var(--allday-h-touch);
|
||||
}
|
||||
|
||||
.grid-allday-rows {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--days), minmax(0, 1fr));
|
||||
grid-auto-rows: var(--allday-h);
|
||||
padding: 2px 0 3px;
|
||||
}
|
||||
|
||||
.grid-allday-rows[data-expanded] {
|
||||
z-index: 15;
|
||||
max-height: 42vh;
|
||||
overflow-y: auto;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
/* Every surface and every piece of type on a block, built from the numbers `calTint` hands it.
|
||||
These belong in tokens.css beside --cal-1..8 once that file can move; they are declared here for
|
||||
the same reason --cal-quiet was, which is that the colour is set per block and cannot be resolved
|
||||
anywhere above it. `.agenda-row` is in the
|
||||
list because the two views are meant to read as one product; agenda.css has always taken its
|
||||
surfaces from here, and it sets its own fills below.
|
||||
|
||||
The whole pill carries its calendar's colour, but not the raw hue: eight saturated blocks make a
|
||||
week of grid unreadable, and --cal-1..8 were chosen to sit on warm paper rather than to be read
|
||||
off. So a block is that hue held at a fixed lightness, rather than the hue mixed towards the
|
||||
surface. Mixing was the first attempt and it fails on the cool half of the palette: #4e6484
|
||||
blended into #f6f2e8 comes out literally grey, because the blue and the paper's yellow cancel.
|
||||
Holding the hue and choosing the lightness keeps eight calendars telling themselves apart, and a
|
||||
fixed lightness per level means every calendar clears the same contrast rather than eight hues
|
||||
each landing somewhere different.
|
||||
|
||||
Every level is written in hsl() rather than color-mix() or oklch() on purpose. hsl() with a
|
||||
substituted hue computes to plain rgb(), which is what anything reading a block back out of the
|
||||
DOM and compositing the layers behind it, the contrast harness included, can actually see.
|
||||
|
||||
Three numbers arrive per block, from `calTint`: --cal-h, the hue; --cal-sat, how much of it,
|
||||
which is zero for a colour that has no hue so Graphite comes out grey; and --cal-lift, how deep,
|
||||
in points of lightness towards the paper. An event that follows its calendar lifts by nothing
|
||||
and one of the eight hues at the levels below is exactly what it gets. An event with a colour of
|
||||
its own arrives with the depth of that colour, which is the only thing separating Lavender from
|
||||
Blueberry or Sage from Basil in Google's palette. A point of lift is worth a point of lightness
|
||||
on paper and rather less in the dark, where the band a readable fill can live in is narrower. */
|
||||
.grid-event,
|
||||
.grid-chip,
|
||||
.agenda-row {
|
||||
--cal-lift-step: 1%;
|
||||
|
||||
/* The rim. Carries more of the hue than the fill on purpose: it is what separates two blocks
|
||||
that touch, and what still reads at the height a five minute event renders at. */
|
||||
--cal-line: hsl(var(--cal-h) calc(34% * var(--cal-sat)) calc(64% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-line-quiet: hsl(var(--cal-h) calc(30% * var(--cal-sat)) calc(78% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
/* The type does not lift. It is the one thing that has to clear the same contrast whichever
|
||||
colour the event carries, so it stays at a fixed lightness and only takes the hue. */
|
||||
--cal-ink: hsl(var(--cal-h) calc(24% * var(--cal-sat)) 20%);
|
||||
/* The second line, one step quieter. A filled surface has less room to be quiet in than a
|
||||
neutral one had: much further than this and the time stops clearing 4.5:1. */
|
||||
--cal-ink-soft: hsl(var(--cal-h) calc(20% * var(--cal-sat)) 27%);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] :is(.grid-event, .grid-chip, .agenda-row) {
|
||||
--cal-lift-step: -0.6%;
|
||||
|
||||
--cal-line: hsl(var(--cal-h) calc(30% * var(--cal-sat)) calc(45% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-line-quiet: hsl(var(--cal-h) calc(24% * var(--cal-sat)) calc(33% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-ink: hsl(var(--cal-h) calc(30% * var(--cal-sat)) 90%);
|
||||
--cal-ink-soft: hsl(var(--cal-h) calc(22% * var(--cal-sat)) 81%);
|
||||
}
|
||||
|
||||
/* The four surfaces a block is drawn on. The two themes are not mirror images of each other, so
|
||||
neither the lightness nor the saturation carries over; the agenda sets its own four a step
|
||||
quieter, because a row is several times the area of a block. */
|
||||
.grid-event,
|
||||
.grid-chip {
|
||||
--cal-fill: hsl(var(--cal-h) calc(52% * var(--cal-sat)) calc(87% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
/* Hover, and the surface under the selection. */
|
||||
--cal-fill-strong: hsl(var(--cal-h) calc(54% * var(--cal-sat)) calc(82% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
/* Somebody else's calendar. Still plainly that calendar's colour, just not drawn at the strength
|
||||
of an event you can pick up. */
|
||||
--cal-fill-quiet: hsl(var(--cal-h) calc(44% * var(--cal-sat)) calc(91% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
/* An event that is not solid yet: enough hue to name the calendar, not enough to look booked.
|
||||
This one does not lift, because it is already as close to the surface as a fill can get. */
|
||||
--cal-wash: hsl(var(--cal-h) calc(56% * var(--cal-sat)) 96.5%);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] :is(.grid-event, .grid-chip) {
|
||||
--cal-fill: hsl(var(--cal-h) calc(27% * var(--cal-sat)) calc(21% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-fill-strong: hsl(var(--cal-h) calc(29% * var(--cal-sat)) calc(26% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-fill-quiet: hsl(var(--cal-h) calc(23% * var(--cal-sat)) calc(16.5% + var(--cal-lift) * var(--cal-lift-step)));
|
||||
--cal-wash: hsl(var(--cal-h) calc(25% * var(--cal-sat)) 12.5%);
|
||||
}
|
||||
|
||||
/* Pinned to the band's right edge rather than given a grid row of its own, so the count cannot
|
||||
make the band taller. The band's height is the axis's input and has to stay constant. */
|
||||
.grid-chip-more {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
background: var(--paper);
|
||||
box-shadow: -8px 0 8px -4px var(--paper);
|
||||
}
|
||||
|
||||
.grid-chip,
|
||||
.grid-chip-more,
|
||||
.grid-chip-less {
|
||||
min-width: 0;
|
||||
margin: 1px 2px;
|
||||
padding: 0 8px;
|
||||
height: calc(var(--allday-h) - 4px);
|
||||
line-height: calc(var(--allday-h) - 4px);
|
||||
text-align: left;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--t-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.grid-chip {
|
||||
background: var(--cal-fill);
|
||||
outline: 1px solid var(--cal-line);
|
||||
outline-offset: -1px;
|
||||
color: var(--cal-ink);
|
||||
cursor: pointer;
|
||||
transition: background 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-chip:hover {
|
||||
background: var(--cal-fill-strong);
|
||||
}
|
||||
|
||||
/* A calendar shared free/busy only. Same rule as the blocks below: it keeps its surface, because
|
||||
the day really is taken, and gives up its ink, because it carries no information. */
|
||||
.grid-chip[data-busy] {
|
||||
color: var(--cal-ink-soft);
|
||||
}
|
||||
|
||||
.grid-chip[data-readonly] {
|
||||
background: var(--cal-fill-quiet);
|
||||
outline-color: var(--cal-line-quiet);
|
||||
}
|
||||
|
||||
.grid-chip[data-status="tentative"] {
|
||||
background: var(--cal-wash);
|
||||
outline-style: dashed;
|
||||
}
|
||||
|
||||
.grid-chip[data-declined] {
|
||||
background: transparent;
|
||||
outline-color: var(--line-strong);
|
||||
color: var(--cal-ink-soft);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.grid-chip[data-pending] {
|
||||
outline-style: dotted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.grid-chip[data-selected] {
|
||||
background: var(--cal-fill-strong);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.grid-chip-more,
|
||||
.grid-chip-less {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-chip-more:hover,
|
||||
.grid-chip-less:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Body
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
.grid-body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The escape hatch: the row height hit its floor, so this is the one case that scrolls. */
|
||||
.grid[data-overflow] .grid-body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Every gesture on this surface is the grid's, so the webview is told to keep its hands off: a
|
||||
press that the browser decides halfway through was a pan arrives as a pointercancel, and the
|
||||
drag it was three pixels into disappears. */
|
||||
.grid-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Except in the one state where the body really does scroll, which is the fit giving up. There a
|
||||
finger has to be able to reach the hours below, so vertical panning goes back to the browser and
|
||||
the sideways half of the gesture stays ours. */
|
||||
.grid[data-overflow] .grid-canvas {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.grid-gutter {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: var(--gutter-w);
|
||||
border-right: 1px solid var(--line);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-gutter-label {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
transform: translateY(1px);
|
||||
font-size: var(--t-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.grid-rules {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--gutter-w);
|
||||
right: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-rule {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--grid-hour);
|
||||
}
|
||||
|
||||
.grid-rule[data-half] {
|
||||
background: var(--grid-half);
|
||||
}
|
||||
|
||||
/* Folded strips
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
.grid-strip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding-right: 10px;
|
||||
background: var(--fold-surface);
|
||||
border-top: 1px solid var(--fold-line);
|
||||
border-bottom: 1px solid var(--fold-line);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-strip:hover {
|
||||
background: var(--event-surface-strong);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.grid-strip-range {
|
||||
flex: none;
|
||||
padding-left: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grid-strip-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--fold-line);
|
||||
}
|
||||
|
||||
.grid-strip-count {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Columns
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
.grid-cols {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--gutter-w);
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--days), minmax(0, 1fr));
|
||||
/* Empty grid is a creation surface, so pointer events fall through to the canvas. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-col {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.grid-col:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.grid-col[data-today] {
|
||||
background: var(--grid-today);
|
||||
}
|
||||
|
||||
.grid-col[data-past] {
|
||||
background: var(--grid-past);
|
||||
}
|
||||
|
||||
/* Event blocks
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
.grid-event {
|
||||
/* Two more values that belong in tokens.css: how wide a block has to be before it is worth
|
||||
reading, and how long the pointer has to mean it. The width is set above the 150px the title
|
||||
gives up its second line at, so a block that leans forward clears the breakpoint rather than
|
||||
landing on it. */
|
||||
--lean-w: 200px;
|
||||
--lean-delay: 110ms;
|
||||
|
||||
/* The selection ring and the lift are separate layers of one shadow so that a block which is
|
||||
both selected and leaning keeps both, without either rule having to know about the other. */
|
||||
--ring: 0 0 0 0 transparent;
|
||||
--lift: 0 0 0 0 transparent;
|
||||
|
||||
--w-own: calc(var(--w) * 100% - var(--event-gap));
|
||||
|
||||
position: absolute;
|
||||
left: calc(var(--l) * 100%);
|
||||
width: var(--w-own);
|
||||
min-height: var(--event-min-h);
|
||||
/* The block's own width is the only thing that knows how many lines a title can really use,
|
||||
and in a three deep cluster that is a quarter of what the column suggests. */
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 2px 6px;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
background: var(--cal-fill);
|
||||
/* An outline rather than a border, so the rim costs the block no width and every state below can
|
||||
restyle it without moving the text a pixel. */
|
||||
outline: 1px solid var(--cal-line);
|
||||
outline-offset: -1px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--cal-ink);
|
||||
box-shadow: var(--ring), var(--lift);
|
||||
/* No delay here, so a block falls back the moment the pointer leaves. The delay going the other
|
||||
way is in the lean rule below. */
|
||||
transition: width 130ms var(--ease), left 130ms var(--ease), background 120ms var(--ease),
|
||||
box-shadow 120ms var(--ease), opacity 120ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-event:hover {
|
||||
z-index: 3;
|
||||
background: var(--cal-fill-strong);
|
||||
}
|
||||
|
||||
/* Leaning forward
|
||||
-------------------------------------------------------------------------------------------
|
||||
|
||||
A three deep cluster splits a day column into thirds and no title survives that. So a block
|
||||
too narrow to read grows, on demand and temporarily, to the width it needs, over its
|
||||
neighbours rather than pushing them: every block on the axis is absolutely positioned, so
|
||||
nothing else moves, the row height is untouched and the axis does not shift by a pixel.
|
||||
|
||||
The arithmetic is the whole rule. --w-lean is the block's own width, or --lean-w if that is
|
||||
wider, and never more than the column: a block that is already wide enough resolves to its own
|
||||
width and does not move at all, and one in a narrow window grows to the column and stops. The
|
||||
left edge stays put unless the wider block would hang off the column, in which case it slides
|
||||
back by exactly the overhang. Both are pure `min`/`max`, so there is nothing to measure and
|
||||
nothing to keep in sync with the fit.
|
||||
|
||||
Selection leans too, so `j` and `k` are worth as much as the pointer, but only while the
|
||||
pointer is out of the grid. With a mouse in play hover is what says which block you mean, and
|
||||
a block held open under the pointer would cover the one you were reaching for.
|
||||
|
||||
On a touchscreen selection is the only one of the two that exists, and the guard has to go with
|
||||
the hover it was guarding: a tap leaves :hover stuck on whatever was tapped, so the canvas would
|
||||
count as hovered for the rest of the session and a tapped block would never open. */
|
||||
.grid-event:not([data-dragging]):hover,
|
||||
.grid-canvas:not(:hover) .grid-event:not([data-dragging])[data-selected],
|
||||
:root[data-touch] .grid-event:not([data-dragging])[data-selected] {
|
||||
--w-lean: min(calc(100% - var(--event-gap)), max(var(--w-own), var(--lean-w)));
|
||||
--lift: var(--shadow-pop);
|
||||
|
||||
z-index: 5;
|
||||
width: var(--w-lean);
|
||||
left: min(calc(var(--l) * 100%), calc(100% - var(--w-lean) - var(--event-gap)));
|
||||
/* Long enough that sweeping the pointer across a busy day does not set every block off, short
|
||||
enough that meaning it feels immediate. */
|
||||
transition: width 130ms var(--ease) var(--lean-delay), left 130ms var(--ease) var(--lean-delay),
|
||||
background 120ms var(--ease), box-shadow 120ms var(--ease) var(--lean-delay),
|
||||
opacity 120ms var(--ease);
|
||||
}
|
||||
|
||||
/* A five minute event. Small type, centred, one line. The fill does the work the thickened edge
|
||||
used to: at 12 pixels tall the block is mostly colour, which is exactly when colour reads. */
|
||||
.grid-event[data-size="bar"] {
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.grid-event[data-size="bar"] .grid-event-title {
|
||||
font-size: var(--t-1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid-event[data-dragging] {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Free/busy. A calendar shared at freeBusyReader returns no title at all, and on a real account
|
||||
that is most of the grid. The block keeps its full surface, because the hour really is taken,
|
||||
and gives up its ink and its second line, because it carries nothing else: a wall you cannot
|
||||
book over rather than a thing you attend. */
|
||||
.grid-event[data-busy] .grid-event-title {
|
||||
color: var(--cal-ink-soft);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Somebody else's calendar: no grips, and the whole block is drawn a shade under an event you can
|
||||
pick up. Still obviously that calendar's colour; most of a real account is read-only, and a grid
|
||||
that greyed all of it out would have no colour left in it. */
|
||||
.grid-event[data-readonly] {
|
||||
background: var(--cal-fill-quiet);
|
||||
outline-color: var(--cal-line-quiet);
|
||||
}
|
||||
|
||||
.grid-event[data-readonly]:hover {
|
||||
background: var(--cal-fill);
|
||||
}
|
||||
|
||||
/* Not confirmed yet: the fill drops back to a wash and a dashed rim holds the shape, so the block
|
||||
reads as an outline of an event rather than a booked hour. */
|
||||
.grid-event[data-status="tentative"] {
|
||||
background: var(--cal-wash);
|
||||
outline-style: dashed;
|
||||
outline-color: var(--cal-line);
|
||||
}
|
||||
|
||||
.grid-event[data-status="tentative"]:hover {
|
||||
background: var(--cal-fill);
|
||||
}
|
||||
|
||||
/* Turned down. Still on the grid, because the hole in the day is real, but plainly not happening:
|
||||
no fill at all, a neutral rim, and the title struck through. It is the only state that gives up
|
||||
its colour, which is what makes it the one that has stopped being an event. */
|
||||
.grid-event[data-declined] {
|
||||
background: transparent;
|
||||
outline-color: var(--line-strong);
|
||||
color: var(--cal-ink-soft);
|
||||
}
|
||||
|
||||
.grid-event[data-declined] .grid-event-title {
|
||||
color: var(--cal-ink-soft);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
.grid-event[data-declined]:hover {
|
||||
background: var(--cal-wash);
|
||||
}
|
||||
|
||||
/* Written locally, still in the outbox. Filled, because it is a real hour of your day, with a
|
||||
broken rim and italic type because it is not whole yet. Tentative is hollow and dashed; this is
|
||||
solid and dotted, so the two never read as the same thing. */
|
||||
.grid-event[data-pending] {
|
||||
outline-style: dotted;
|
||||
}
|
||||
|
||||
.grid-event[data-pending] .grid-event-title {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Last, so selection wins over every state above it. */
|
||||
.grid-event[data-selected] {
|
||||
--ring: 0 0 0 1px var(--accent);
|
||||
|
||||
z-index: 4;
|
||||
background: var(--cal-fill-strong);
|
||||
}
|
||||
|
||||
.grid-event-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* Clamped to the lines the block has room for, never wrapped into a column of fragments. */
|
||||
.grid-event-title {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--lines, 1);
|
||||
overflow: hidden;
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.grid-event-time {
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.2;
|
||||
color: var(--cal-ink-soft);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Narrow enough that a second line would be one word long: one line and an ellipsis reads better
|
||||
than six fragments, and the whole title is a click away in the card. This is the block's own
|
||||
width, not the window's, which is why it is a container query and not a media query. */
|
||||
@container (max-width: 150px) {
|
||||
.grid-event-title {
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
/* "11am · Church Str…" is worse than "11am". The place goes, the time stays. */
|
||||
.grid-event-where {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Three deep in a narrow week. A truncated "11…" under a truncated title is two ellipses saying
|
||||
nothing, so the title keeps whatever room there is. */
|
||||
@container (max-width: 92px) {
|
||||
.grid-event-time {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Full width to grab, with a small handle that only appears on a block you are allowed to resize.
|
||||
That is also the quickest read of whether an event is yours. */
|
||||
.grid-event-grip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--grip-h);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.grid-event-grip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
margin-left: -11px;
|
||||
border-radius: var(--r-pill);
|
||||
/* On a filled block a neutral grey handle disappears into the surface, so the grip is drawn in
|
||||
the same hue as the rim. Ink would read as a rule struck through the title. */
|
||||
background: var(--cal-line);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms var(--ease);
|
||||
}
|
||||
|
||||
.grid-event:hover .grid-event-grip::after,
|
||||
.grid-event[data-selected] .grid-event-grip::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.grid-event-grip[data-edge="start"] {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.grid-event-grip[data-edge="start"]::after {
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.grid-event-grip[data-edge="end"] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.grid-event-grip[data-edge="end"]::after {
|
||||
bottom: 1px;
|
||||
}
|
||||
|
||||
/* Dropped on a touchscreen rather than enlarged. A grip a finger could hit is around 24px, which
|
||||
is most of an hour long block and all of a short one, and two of them would swallow the tap that
|
||||
opens the event: the block would become a thing you can only resize. A press is also no longer
|
||||
free here, since it has to survive the long press before it means anything, so trading the
|
||||
resize for a whole-block target is the better half of the trade. Resizing on a phone is the
|
||||
editor's job, where an end time is a field rather than seven pixels of edge. */
|
||||
:root[data-touch] .grid-event-grip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Now line, and the wash over what has already gone
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
.grid-now {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: calc(100% * var(--i) / var(--days));
|
||||
width: calc(100% / var(--days));
|
||||
height: 0;
|
||||
border-top: 1px solid var(--grid-now);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-now::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--grid-now);
|
||||
}
|
||||
|
||||
.grid-now-past {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: calc(100% * var(--i) / var(--days));
|
||||
width: calc(100% / var(--days));
|
||||
background: var(--grid-past);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Drag preview and the in-place create input
|
||||
------------------------------------------------------------------------------------------- */
|
||||
|
||||
/* The ghost is the range you are dragging out, and the label is the only thing in it, so the label
|
||||
sits on the box's centre line rather than at the top of it.
|
||||
That is also what stops it being sliced in half. A fifteen minute drag is a thirteen pixel box
|
||||
with about nine pixels inside the border, which is less than a line of type; the box is the
|
||||
truth about the range and must not be padded out to fit its own caption, so the caption is
|
||||
centred and allowed to overhang by the pixel or so it needs. It overhangs evenly, which keeps it
|
||||
on screen at the bottom of the grid as well as the top. Sideways it still has to be contained,
|
||||
or a long range would print across the next day, so the clipping moved onto the label itself,
|
||||
where it costs the glyphs nothing. */
|
||||
.grid-ghost {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
left: calc(100% * var(--i) / var(--days));
|
||||
width: calc(100% / var(--days) - var(--event-gap));
|
||||
min-height: var(--event-min-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
background: var(--event-surface-strong);
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.grid-ghost-time {
|
||||
max-width: 100%;
|
||||
font-size: var(--t-1);
|
||||
/* Tall enough that the label's own box holds the whole glyph, descenders included, because that
|
||||
box is where the sideways clipping happens. */
|
||||
line-height: 1.3;
|
||||
color: var(--ink-soft);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.grid-draft {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: calc(100% * var(--i) / var(--days));
|
||||
width: calc(100% / var(--days) - var(--event-gap));
|
||||
min-height: var(--allday-h);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
padding: 3px 7px;
|
||||
pointer-events: auto;
|
||||
background: var(--raised);
|
||||
border-left: var(--edge-w) solid var(--accent);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
/* The one piece of text on the grid you are meant to be able to put a cursor in, so it takes back
|
||||
both of the things `.grid` turned off for the surface around it. */
|
||||
.grid-draft-input {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: var(--t-2);
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.grid-draft-input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.grid-draft-time {
|
||||
font-size: var(--t-1);
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A week on a phone
|
||||
-------------------------------------------------------------------------------------------
|
||||
|
||||
Seven columns in three hundred and ninety pixels is fifty pixels a day. Nothing is readable at
|
||||
that width and pretending otherwise is what produces a week of ellipses, so the week stops
|
||||
trying to be a reading view and becomes what it can be: where the events are, which days are
|
||||
busy, and a header you tap to open the day itself.
|
||||
|
||||
The day view is untouched. It has a whole phone to itself and reads exactly as it does on a
|
||||
desktop. */
|
||||
|
||||
:root[data-phone] .grid[data-view="week"] .grid-head-day {
|
||||
gap: 4px;
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
/* One letter, and it is a letter rather than a lowercase abbreviation cut short: M T W T F S S is
|
||||
a shape you read across, which is all a single character can be. */
|
||||
:root[data-phone] .grid[data-view="week"] .grid-head-name {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
:root[data-phone] .grid[data-view="week"] .grid-head-date {
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
:root[data-phone] .grid[data-view="week"] .grid-head-day[data-today] .grid-head-date {
|
||||
padding: 1px 5px 2px;
|
||||
}
|
||||
|
||||
/* The block gives up its padding before it gives up its title. The time line and the place are
|
||||
already gone by this width, dropped by the container queries above, which measure the block
|
||||
itself rather than the window and so were right about this before a phone existed. */
|
||||
:root[data-phone] .grid[data-view="week"] .grid-event {
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
:root[data-phone] .grid[data-view="week"] .grid-event-title {
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
/* The overlays: the mini-month, the calendar list, the event editor, settings and accounts. The
|
||||
shell itself (.overlay, .panel, .panel-head, .panel-body, .panel-foot, .field, .field-label)
|
||||
lives in app.css and is shared with everything else; what follows is only what those five
|
||||
panels add on top of it. */
|
||||
|
||||
/* Panel shell
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.overlay-panel[data-size="mini"] {
|
||||
width: min(292px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.overlay-panel[data-size="wide"] {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.overlay-panel[data-size="mini"] .panel-body {
|
||||
gap: 10px;
|
||||
padding: 12px 12px 14px;
|
||||
}
|
||||
|
||||
/* Four inputs in a row is one too many once the window is narrow. */
|
||||
:root[data-compact] .field-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.panel-note[data-icon] svg {
|
||||
flex: none;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.panel-note[data-error] {
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.panel-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Buttons. app.css only carries .icon-button, so the panels bring their own text button and
|
||||
keep it in the same .panel-* family. */
|
||||
|
||||
.panel-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-height: 30px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
color: var(--ink);
|
||||
font-size: var(--t-3);
|
||||
white-space: nowrap;
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease), border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.panel-button:hover {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
/* A panel's own buttons are the last thing left at pointer size once the chrome has grown. */
|
||||
:root[data-touch] .panel-button {
|
||||
min-height: var(--touch-h);
|
||||
}
|
||||
|
||||
.panel-button:disabled {
|
||||
color: var(--ink-faint);
|
||||
cursor: default;
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.panel-button[data-variant="primary"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.panel-button[data-variant="primary"]:hover {
|
||||
background: var(--accent-ink);
|
||||
}
|
||||
|
||||
.panel-button[data-variant="primary"]:disabled {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--raised);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.panel-button[data-variant="danger"] {
|
||||
border-color: var(--line-strong);
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
.panel-button[data-variant="danger"]:hover {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-wash);
|
||||
}
|
||||
|
||||
.panel-button[data-variant="ghost"] {
|
||||
border-color: transparent;
|
||||
background: none;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.panel-button[data-variant="ghost"]:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The one action that is not a reply to the others sits away from them. */
|
||||
.panel-button[data-side="lead"] {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.panel-button[data-align="lead"] {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Fields
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.field-input,
|
||||
.field-select,
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
transition: border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.field-input:hover,
|
||||
.field-select:hover,
|
||||
.field-textarea:hover {
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field-select {
|
||||
appearance: none;
|
||||
padding-right: 26px;
|
||||
background-image: linear-gradient(45deg, transparent 50%, currentcolor 50%),
|
||||
linear-gradient(135deg, currentcolor 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 13px, calc(100% - 10px) 13px;
|
||||
background-size: 4px 4px, 4px 4px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-row .field-input[type="date"] {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.field-row .field-input[type="time"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.field-to {
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.field-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.field-check input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.field-hint[data-error] {
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
.field-static {
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field-static[data-wrap] {
|
||||
white-space: pre-wrap;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Mini month
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.mini {
|
||||
--mini-cell: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mini-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.mini-week,
|
||||
.mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mini-weekday {
|
||||
padding-bottom: 2px;
|
||||
text-align: center;
|
||||
font-size: var(--t-1);
|
||||
color: var(--ink-faint);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.mini-grid {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.mini-day {
|
||||
height: var(--mini-cell);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink);
|
||||
font-size: var(--t-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.mini-day[data-outside] {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.mini-day[data-span] {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.mini-day:hover {
|
||||
background: var(--selection);
|
||||
}
|
||||
|
||||
.mini-day[data-today] {
|
||||
color: var(--grid-now);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mini-day[data-cursor] {
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.mini:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mini:focus-visible .mini-day[data-cursor] {
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.mini-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* The month is where the phone's date button goes, so its cells are the most tapped thing in the
|
||||
app on a device with no keys to jump with. A 32px cell is a mis-tap on a neighbouring day. */
|
||||
:root[data-touch] .mini {
|
||||
--mini-cell: var(--touch-h);
|
||||
}
|
||||
|
||||
/* The hover form, for a header that wants the month without the backdrop. */
|
||||
.mini-pop {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
padding: 10px 12px 12px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
/* Calendars
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.cal-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cal-group .field-label {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
padding: 5px 7px;
|
||||
margin: 0 -7px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.cal-row:hover {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.cal-check {
|
||||
flex: none;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.cal-dot {
|
||||
flex: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.cal-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cal-role {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
/* Event editor
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.scope-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scope-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 13px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
transition: background 120ms var(--ease), border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.scope-option:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.scope-option[data-variant="danger"]:hover {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-wash);
|
||||
}
|
||||
|
||||
.scope-option:disabled {
|
||||
color: var(--ink-faint);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scope-name {
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.guest-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.guest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.guest-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.guest[data-response="accepted"] .guest-dot {
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.guest[data-response="declined"] .guest-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.guest[data-response="tentative"] .guest-dot {
|
||||
background: var(--ink-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--paper);
|
||||
}
|
||||
|
||||
.guest-name {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.guest-tag {
|
||||
flex: none;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
.guest-response {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
.guest[data-response="declined"] .guest-name {
|
||||
color: var(--ink-soft);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Settings
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.setting-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-name {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.setting-note {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
|
||||
.segment {
|
||||
display: flex;
|
||||
flex: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segment-option {
|
||||
padding: 5px 13px;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
border-left: 1px solid var(--line-strong);
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.segment-option:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
:root[data-touch] .segment-option {
|
||||
min-height: var(--touch-h);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.segment-option:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.segment-option[data-on] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
/* Accounts
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.account-row:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.account-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.connect-pending {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 13px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.connect-line {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.connect-note {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.connect-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Menu
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
/* The phone's overflow: rows with labels rather than the header's row of icons, because on a bar
|
||||
that holds five things there is no room left to guess what a glyph meant. */
|
||||
.menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: -6px -12px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: var(--touch-h);
|
||||
padding: 0 12px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
text-align: left;
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.menu-item:hover,
|
||||
.menu-item:active {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
color: var(--ink);
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
/* Confirmations
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-body p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Only present when a panel was opened from another one, so the head has to cope with two or
|
||||
three children without the title jumping. */
|
||||
.panel-head:has(.panel-back) {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* The head is space-between, so a third child would centre the title. Keep it beside the back
|
||||
control and let the close button hold the right edge. */
|
||||
.panel-head:has(.panel-back) h2 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel-back {
|
||||
flex: none;
|
||||
margin-right: 2px;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/* The command palette and the generated shortcut sheet. Both lean on `.overlay` and `.panel` from
|
||||
app.css and only add what they genuinely need on top. */
|
||||
|
||||
.overlay[data-align="top"] {
|
||||
align-items: start;
|
||||
padding-top: 12vh;
|
||||
}
|
||||
|
||||
:root[data-compact] .overlay[data-align="top"] {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel.palette {
|
||||
width: min(620px, calc(100vw - 32px));
|
||||
max-height: min(560px, 76vh);
|
||||
}
|
||||
|
||||
.palette-input {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
padding: 18px 22px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: none;
|
||||
font-size: var(--t-4);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.palette-input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.palette-preview {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.palette-sentence {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.palette-warning {
|
||||
margin: 0;
|
||||
color: var(--danger-ink);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.palette-empty {
|
||||
margin: 0;
|
||||
padding: 16px 22px 20px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.palette-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.palette-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: background 100ms var(--ease);
|
||||
}
|
||||
|
||||
.palette-row[data-active] {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.palette-row[data-kind="create"] .palette-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.palette-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.palette-keys {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* One keycap, shared by the palette rows and the sheet. */
|
||||
.key {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 22px 30px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
:root[data-compact] .shortcuts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shortcuts-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shortcuts-heading {
|
||||
margin: 0 0 8px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shortcuts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.shortcuts-keys {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shortcuts-label {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.shortcuts-note {
|
||||
margin: 0;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/* A description printed as elements, wherever one is read rather than edited. The rules are the
|
||||
same shape as the details card's, kept here so the component carries its own look and does not
|
||||
depend on whichever panel happened to load first. */
|
||||
|
||||
.rich {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
line-height: 1.55;
|
||||
/* Pasted Drive URLs are one word two hundred characters long. */
|
||||
overflow-wrap: anywhere;
|
||||
/* Long enough to read a paragraph of, short enough that the buttons under it stay on screen. */
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Plain text keeps the spacing it was written with. Html has had its own collapsed already and
|
||||
carries its structure in elements, where pre-wrap would double every gap in the source. */
|
||||
.rich[data-plain] {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.rich :is(div, ul, ol) {
|
||||
margin: 7px 0 0;
|
||||
}
|
||||
|
||||
.rich :is(div, ul, ol):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich :is(ul, ol) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.rich li + li {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.rich code {
|
||||
padding: 0 4px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* Findable in the middle of a sentence rather than only on a line of its own. */
|
||||
.rich-link {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--ink-faint);
|
||||
text-underline-offset: 2px;
|
||||
transition: text-decoration-color 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.rich-link:hover {
|
||||
color: var(--accent-ink);
|
||||
text-decoration-color: currentcolor;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
:root {
|
||||
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "Literata", Georgia, "Times New Roman", serif;
|
||||
|
||||
--r-sm: 5px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--r-pill: 999px;
|
||||
|
||||
--titlebar-h: 46px;
|
||||
--traffic-pad: 84px;
|
||||
|
||||
/* Phone chrome. The bars are the only thing standing between the app and a notch or a home
|
||||
indicator, so the insets are read once here and everything else pads with these. A browser
|
||||
that has never heard of env() falls back to nothing, which is the desktop. */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--phonebar-h: 48px;
|
||||
--tabbar-h: 56px;
|
||||
/* Apple's floor for anything a finger has to hit, and the reason phone chrome is as tall as it
|
||||
is. Desktop keeps its 28px controls. */
|
||||
--touch-h: 44px;
|
||||
/* Enough of the page left showing that a sheet reads as a layer over it rather than a screen.
|
||||
A cap rather than a height, and on a phone it rarely binds: the sheet's bottom is pinned by
|
||||
the fixed overlay, so this only bites when the content is tall enough to want more. */
|
||||
--sheet-max-h: 88dvh;
|
||||
|
||||
--t-1: 11px;
|
||||
--t-2: 12px;
|
||||
--t-3: 13px;
|
||||
--t-4: 15px;
|
||||
/* iOS Safari zooms the page when a field under 16px takes the focus, and a locked viewport
|
||||
leaves no way back out of that zoom. Phone fields take this one. */
|
||||
--t-5: 16px;
|
||||
|
||||
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
|
||||
/* Grid geometry. --row-h is written by the fit engine on every layout pass; the rest are
|
||||
fixed and the fit maths reads them back. */
|
||||
--gutter-w: 58px;
|
||||
--daybar-h: 40px;
|
||||
--allday-h: 26px;
|
||||
--strip-h: 22px;
|
||||
--row-h: 48px;
|
||||
--row-h-min: 22px;
|
||||
|
||||
/* Event blocks and agenda rows. --event-min-h is mirrored as MIN_BLOCK_H in GridModel.tsx the
|
||||
way --strip-h is mirrored in fit.ts, so the two must move together. */
|
||||
--qc-w: 292px;
|
||||
--event-min-h: 12px;
|
||||
--event-gap: 3px;
|
||||
--edge-w: 2px;
|
||||
--grip-h: 7px;
|
||||
}
|
||||
|
||||
:root[data-compact] {
|
||||
--gutter-w: 44px;
|
||||
--daybar-h: 34px;
|
||||
--qc-w: 236px;
|
||||
--traffic-pad: 84px;
|
||||
}
|
||||
|
||||
:root,
|
||||
:root[data-theme="light"] {
|
||||
--paper: #fcfbf7;
|
||||
--shell: #f1ece2;
|
||||
--raised: #fbfaf6;
|
||||
|
||||
--ink: #23201b;
|
||||
--ink-soft: #6b6458;
|
||||
/* Clears 4.5:1 on every light surface it lands on, including the folded strip, which is
|
||||
the darkest. The hour axis is the text you read most in a calendar. */
|
||||
--ink-faint: #6e675b;
|
||||
|
||||
--line: #e3ddce;
|
||||
--line-strong: #d6cfbd;
|
||||
|
||||
--accent: #2a2622;
|
||||
--accent-ink: #100e0b;
|
||||
--accent-wash: rgba(35, 32, 27, 0.08);
|
||||
--accent-contrast: #faf7f0;
|
||||
--selection: rgba(35, 32, 27, 0.14);
|
||||
--glass: rgba(252, 251, 247, 0.86);
|
||||
--scrim: rgba(35, 32, 27, 0.28);
|
||||
|
||||
--danger: #b4453a;
|
||||
--danger-ink: #963327;
|
||||
--danger-wash: rgba(180, 69, 58, 0.1);
|
||||
--danger-contrast: #faf7f0;
|
||||
|
||||
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
|
||||
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
|
||||
|
||||
--grid-hour: #e6e0d1;
|
||||
--grid-half: #efeade;
|
||||
--grid-now: #b4453a;
|
||||
--grid-past: rgba(35, 32, 27, 0.03);
|
||||
--grid-today: rgba(35, 32, 27, 0.035);
|
||||
--fold-surface: #ece6da;
|
||||
--fold-line: #ddd6c6;
|
||||
|
||||
--event-surface: #f6f2e8;
|
||||
--event-surface-strong: #efe9dc;
|
||||
--event-ink: #23201b;
|
||||
|
||||
--cal-1: #6f6194;
|
||||
--cal-2: #47705f;
|
||||
--cal-3: #a06044;
|
||||
--cal-4: #4e6484;
|
||||
--cal-5: #856434;
|
||||
--cal-6: #955767;
|
||||
--cal-7: #3a6b78;
|
||||
--cal-8: #667141;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--paper: #1d1a16;
|
||||
--shell: #151310;
|
||||
--raised: #232019;
|
||||
|
||||
--ink: #ece6da;
|
||||
--ink-soft: #a89f8e;
|
||||
/* Clears 4.5:1 on every dark surface, including a folded strip. The old #756d5e was
|
||||
3.39:1, which only went unnoticed while today was always the first column and the dimmer
|
||||
past-day headers never led the week. */
|
||||
--ink-faint: #8e8677;
|
||||
|
||||
--line: #2c2823;
|
||||
--line-strong: #3a352d;
|
||||
|
||||
--accent: #efe9dc;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-wash: rgba(239, 233, 220, 0.12);
|
||||
--accent-contrast: #1a1714;
|
||||
--selection: rgba(239, 233, 220, 0.18);
|
||||
--glass: rgba(33, 30, 25, 0.88);
|
||||
/* The light scrim over a dark page dimmed nothing, so panels floated on shadow alone. */
|
||||
--scrim: rgba(0, 0, 0, 0.58);
|
||||
|
||||
--danger: #d97a6f;
|
||||
--danger-ink: #e58e84;
|
||||
--danger-wash: rgba(217, 122, 111, 0.16);
|
||||
--danger-contrast: #1a1714;
|
||||
|
||||
--shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5);
|
||||
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
|
||||
|
||||
--grid-hour: #2e2a24;
|
||||
--grid-half: #24211c;
|
||||
--grid-now: #d97a6f;
|
||||
--grid-past: rgba(0, 0, 0, 0.16);
|
||||
--grid-today: rgba(239, 233, 220, 0.04);
|
||||
--fold-surface: #1a1713;
|
||||
--fold-line: #302b24;
|
||||
|
||||
--event-surface: #272319;
|
||||
--event-surface-strong: #2f2a20;
|
||||
--event-ink: #ece6da;
|
||||
|
||||
--cal-1: #a396c8;
|
||||
--cal-2: #7fae98;
|
||||
--cal-3: #d19a7c;
|
||||
--cal-4: #8ba2c4;
|
||||
--cal-5: #c4a267;
|
||||
--cal-6: #cb909e;
|
||||
--cal-7: #75a9b6;
|
||||
--cal-8: #a2ae77;
|
||||
}
|
||||
Reference in new issue
Block a user