Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7385a6c1c47153b1728160ff
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
public/kit.css
/* OpenApps free apps: one look across the store and every app.
Same tokens as openapps.xyz: warm paper, Newsreader headlines, Archivo for everything else,
one solid ink button, cobalt for links and focus. */
@font-face {
font-family: "Archivo Variable";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/fonts/ArchivoVariable-var.woff2) format("woff2-variations");
}
@font-face {
font-family: "Newsreader Variable";
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url(/fonts/NewsreaderVariable-var.woff2) format("woff2-variations");
}
:root {
--ink: #15171b;
--ink-solid: #0b0b0c;
--muted: #5d6169;
--faint: #8a8e96;
--line: #e3e6ea;
--line-strong: #cfd5dd;
--paper: #fdfdfc;
--paper-warm: #f8f3ea;
--paper-deep: #f1e9da;
--panel: #f0f3f8;
--panel-line: #dfe5ee;
--cobalt: #2e5ec4;
--cobalt-ink: #1f4597;
--cobalt-wash: #e9effa;
--sage: #4f7a5c;
--sage-wash: #e8f1ea;
--brick: #a8442c;
--brick-wash: #f8e9e4;
--gold: #b8892c;
--serif: "Newsreader Variable", Georgia, "Times New Roman", serif;
--sans: "Archivo Variable", system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--radius: 10px;
--radius-lg: 18px;
--shadow: 0 1px 2px #1a1d2410, 0 8px 24px -12px #1a1d2426;
--shadow-pop: 0 18px 50px -14px #1a1d2459;
--page: min(1180px, calc(100% - 48px));
/* Type roles: one weight and size per role. Use as `font: var(--type-control)`. */
--type-control: 600 14.5px/1 var(--sans); /* buttons, nav links, menu items, tabs */
--type-label: 600 13.5px/1.2 var(--sans); /* field labels */
--type-caps: 650 11.5px/1.3 var(--sans); /* eyebrows, table and footer heads, in uppercase */
--type-input: 400 16px/1.35 var(--sans); /* 16px, so iOS does not zoom on focus */
--type-title: 380 26px/1.2 var(--serif); /* dialog titles and empty-state titles, with -.02em */
/* Motion: quick, ease-out on the way in, ease-in on the way out. */
--motion-fast: 150ms;
--motion: 200ms;
--motion-slow: 250ms;
--ease-out: cubic-bezier(.2, .8, .2, 1);
--ease-in: cubic-bezier(.4, 0, 1, 1);
/* Safe areas: non-zero on notched phones when the viewport meta has viewport-fit=cover. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
color-scheme: light;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 400 16px/1.6 var(--sans);
-webkit-font-smoothing: antialiased;
overflow-wrap: break-word; /* a long word or link breaks instead of pushing the page sideways */
}
img, svg, video, canvas { display: block; max-width: 100%; }
a { color: var(--cobalt); text-decoration: none; }
a:hover { color: var(--cobalt-ink); }
:is(a, button, input, select, textarea, summary):focus-visible { outline: 2px solid var(--cobalt); outline-offset: 3px; }
:where([tabindex]:not([tabindex="-1"])):focus-visible { outline: 2px solid var(--cobalt); outline-offset: 3px; }
h1, h2, h3, h4, p, ul, ol, figure, blockquote { margin: 0; }
button, input, select, textarea { font: inherit; color: inherit; }
::selection { background: #cddcf6; }
.wrap { width: var(--page); margin-inline: auto; }
.serif { font-family: var(--serif); }
.muted { color: var(--muted); }
.eyebrow { font: var(--type-caps); letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.display { font: 330 clamp(40px, 5.6vw, 72px)/1.04 var(--serif); letter-spacing: -.035em; text-wrap: balance; }
.h1 { font: 350 clamp(34px, 4.2vw, 54px)/1.06 var(--serif); letter-spacing: -.03em; text-wrap: balance; }
.h2 { font: 360 clamp(28px, 3vw, 40px)/1.1 var(--serif); letter-spacing: -.028em; text-wrap: balance; }
.h3 { font: 600 19px/1.3 var(--sans); letter-spacing: -.012em; }
.lede { font-size: clamp(17px, 1.5vw, 19.5px); line-height: 1.6; color: #4a4e56; max-width: 60ch; }
/* Header */
.site-head { position: sticky; top: 0; z-index: 20; padding-top: var(--safe-top); background: #fdfdfcee; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-bottom: 1px solid #0000; transition: border-color var(--motion) var(--ease-out); }
.site-head.scrolled { border-bottom-color: var(--line); }
.site-head .wrap { display: flex; align-items: center; gap: 22px; min-height: 64px; }
.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--ink-solid); font: 650 16px/1 var(--sans); letter-spacing: -.02em; }
.brand:hover { color: var(--ink-solid); }
.brand img { width: 26px; height: 26px; }
.brand .brand-app { color: var(--muted); font-weight: 500; }
.brand .brand-sep { color: var(--line-strong); font-weight: 300; }
.site-nav { display: flex; align-items: center; gap: 4px; margin-left: auto; }
.site-nav a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 12px; border-radius: 8px; color: #33363c; font: var(--type-control); transition: background-color var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); }
@media (hover: hover) { .site-nav a:hover { color: var(--ink-solid); background: #0000000a; } }
.site-nav a[aria-current="page"] { color: var(--ink-solid); }
@media (max-width: 720px) {
.site-nav .nav-optional { display: none; }
.site-head .wrap { gap: 10px; }
}
/* Buttons. One size of text for every button; size classes change height and padding only. */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; padding: 0 20px; border: 1px solid var(--ink-solid); border-radius: var(--radius); background: var(--ink-solid); color: #fff; font: var(--type-control); cursor: pointer; transition: background-color var(--motion-fast) var(--ease-out), border-color var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out), box-shadow var(--motion-fast) var(--ease-out), transform var(--motion-fast) var(--ease-out); white-space: nowrap; text-decoration: none; }
.btn:active { transform: translateY(1px) scale(.985); }
.btn:disabled, .btn[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
.btn-line { background: transparent; color: var(--ink-solid); border-color: var(--line-strong); }
.btn-quiet { background: transparent; color: var(--ink); border-color: transparent; }
.btn-sm { min-height: 36px; padding: 0 14px; border-radius: 8px; }
.btn-lg { min-height: 54px; padding: 0 26px; }
.btn-danger { color: var(--brick); }
.btn-block { width: 100%; }
.btn-icon { width: 46px; padding: 0; }
.btn-icon.btn-sm { width: 36px; }
.btn-icon.btn-lg { width: 54px; }
/* Hover only where a pointer hovers, so taps on phones do not leave buttons tinted. */
@media (hover: hover) {
.btn:hover { background: #2a2c31; color: #fff; }
.btn-line:hover { background: #00000008; color: var(--ink-solid); border-color: #b5bdc8; }
.btn-quiet:hover { background: #0000000a; color: var(--ink-solid); }
:where(.btn-line, .btn-quiet).btn-danger:hover { color: var(--brick); }
/* A disabled button does not react to hover. Kept at the hover rules' weight so app variants still win. */
:where(.btn):is(:disabled, [aria-disabled="true"]):hover { background: var(--ink-solid); }
:where(.btn-line, .btn-quiet):is(:disabled, [aria-disabled="true"]):hover { background: transparent; }
:where(.btn-line):is(:disabled, [aria-disabled="true"]):hover { border-color: var(--line-strong); }
}
:where(.btn):is(:disabled, [aria-disabled="true"]):active { transform: none; }
/* Loading: set aria-busy="true" on the button. The label stays in place (no width change) and a spinner covers it. */
:where(.btn[aria-busy="true"]) { position: relative; } /* zero weight, so a fixed or sticky button keeps its place */
.btn[aria-busy="true"] { -webkit-text-fill-color: transparent; pointer-events: none; }
.btn[aria-busy="true"] > * { visibility: hidden; }
.btn[aria-busy="true"]::after { content: ""; position: absolute; inset: 0; width: 18px; height: 18px; margin: auto; border: 2px solid color-mix(in srgb, currentColor 28%, transparent); border-top-color: currentColor; border-radius: 50%; animation: spin .7s linear infinite; }
/* Fields */
.field { display: grid; grid-template-columns: minmax(0, 1fr); align-content: start; gap: 7px; }
.field > span, .label { font: var(--type-label); color: #33363c; }
.field small, .hint { font-size: 13px; color: var(--muted); }
.input, .select, .textarea { width: 100%; min-height: 46px; padding: 11px 13px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: #fff; color: var(--ink); font: var(--type-input); transition: border-color var(--motion-fast) var(--ease-out), box-shadow var(--motion-fast) var(--ease-out); }
.textarea { min-height: 96px; resize: vertical; line-height: 1.5; }
@media (hover: hover) { .input:hover, .select:hover, .textarea:hover { border-color: #b5bdc8; } }
.input:focus, .select:focus, .textarea:focus { outline: none; border-color: var(--cobalt); box-shadow: 0 0 0 3px #2e5ec41f; }
.input::placeholder, .textarea::placeholder { color: #9a9ea6; }
/* The value stays readable: apps lock a checked answer with disabled, and faint text looked like an empty placeholder. */
.input:disabled, .select:disabled, .textarea:disabled { border-color: var(--line); background: #f5f6f8; color: var(--muted); cursor: not-allowed; }
/* Invalid: set aria-invalid="true" on the input and put the reason in .field-error under it. */
:is(.input, .select, .textarea)[aria-invalid="true"] { border-color: var(--brick); }
:is(.input, .select, .textarea)[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px #a8442c24; }
.field-error, .field .field-error { font: 500 13px/1.45 var(--sans); color: var(--brick); overflow-wrap: anywhere; }
.check { display: inline-flex; align-items: center; gap: 9px; font-size: 15px; cursor: pointer; }
.check input { width: 18px; height: 18px; accent-color: var(--ink-solid); }
.check:has(input:disabled) { opacity: .55; cursor: not-allowed; }
/* Touch targets: on touch screens, small controls get an invisible 44px hit area without changing their size. */
@media (pointer: coarse) {
.btn-sm, label.check, :is(a, button).chip { position: relative; }
.btn-sm::before, label.check::before, :is(a, button).chip::before { content: ""; position: absolute; inset: min(0px, (100% - 44px) / 2); }
}
/* Surfaces */
.card { background: #fff; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow); }
.panel { background: var(--panel); border: 1px solid var(--panel-line); border-radius: var(--radius-lg); }
.paper { background: var(--paper-warm); border: 1px solid #ece2cf; border-radius: var(--radius-lg); }
.chip { display: inline-flex; align-items: center; gap: 6px; min-height: 26px; padding: 0 10px; border-radius: 999px; background: var(--panel); border: 1px solid var(--panel-line); color: #3c4048; font: 600 12.5px/1 var(--sans); white-space: nowrap; }
.chip-free { background: var(--sage-wash); border-color: #cfe2d4; color: #2f5a3c; }
.chip-cobalt { background: var(--cobalt-wash); border-color: #d1ddf5; color: var(--cobalt-ink); }
.rule { height: 1px; background: var(--line); border: 0; margin: 0; }
.notice { padding: 12px 14px; border-radius: var(--radius); background: var(--panel); border: 1px solid var(--panel-line); font-size: 14.5px; color: #3a3e46; overflow-wrap: anywhere; }
.notice-error { background: var(--brick-wash); border-color: #efcfc5; color: #7c2f1d; }
.notice-ok { background: var(--sage-wash); border-color: #cfe2d4; color: #2f5a3c; }
/* Tables */
.table { width: 100%; border-collapse: collapse; font-size: 15px; }
.table th, .table td { padding: 13px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
.table th { font: var(--type-caps); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.table td.yes { color: #2f5a3c; font-weight: 600; }
.table td.no { color: #8b4a3a; }
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-lg); background: #fff; }
.table-wrap .table tr:last-child td { border-bottom: 0; }
/* Prose (blog, docs) */
.prose { font-size: 18px; line-height: 1.72; color: #24272d; max-width: 68ch; }
.prose > * + * { margin-top: 1.1em; }
.prose h2 { margin-top: 2em; font: 380 30px/1.18 var(--serif); letter-spacing: -.022em; color: var(--ink-solid); }
.prose h3 { margin-top: 1.7em; font: 650 19px/1.35 var(--sans); letter-spacing: -.01em; }
.prose a { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; text-decoration-color: #2e5ec466; }
.prose ul, .prose ol { padding-left: 1.3em; }
.prose li + li { margin-top: .4em; }
.prose code { font: 500 .86em/1 var(--mono); padding: .15em .38em; border-radius: 5px; background: var(--panel); border: 1px solid var(--panel-line); }
.prose pre { overflow-x: auto; padding: 16px 18px; border-radius: 12px; background: #16181d; color: #e8eaee; font: 13.5px/1.6 var(--mono); }
.prose pre code { padding: 0; background: none; border: 0; color: inherit; font-size: inherit; }
.prose blockquote { padding-left: 18px; border-left: 3px solid var(--cobalt); color: #3e424a; font-style: italic; }
.prose table { width: 100%; border-collapse: collapse; font-size: 15.5px; }
.prose th, .prose td { padding: 10px 12px; border-bottom: 1px solid var(--line); text-align: left; }
.prose th { font-weight: 650; }
.prose strong { font-weight: 650; color: var(--ink-solid); }
/* Code block outside prose */
.code { position: relative; overflow-x: auto; padding: 16px 18px; border-radius: 12px; background: #16181d; color: #e8eaee; font: 13px/1.65 var(--mono); white-space: pre; }
.code .c { color: #8b93a3; }
/* FAQ */
.faq { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
.faq details { background: #fff; border: 1px solid var(--line); border-radius: 14px; padding: 4px 20px; }
.faq summary { cursor: pointer; list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 58px; font: 600 16.5px/1.35 var(--sans); }
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after { content: "+"; font: 300 24px/1 var(--sans); color: var(--muted); transition: transform var(--motion) var(--ease-out); }
.faq details[open] summary::after { transform: rotate(45deg); }
.faq details > div { padding: 0 0 18px; color: #3d4148; line-height: 1.65; }
.faq details[open] > div { animation: oa-enter var(--motion) var(--ease-out); }
/* Footer */
.site-foot { margin-top: 96px; padding: 56px 0 40px; border-top: 1px solid var(--line); background: var(--paper); font-size: 14.5px; color: var(--muted); }
.site-foot .foot-grid { display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: 32px; }
.site-foot h4 { margin-bottom: 12px; font: var(--type-caps); letter-spacing: .12em; text-transform: uppercase; color: var(--ink); }
.site-foot ul { list-style: none; padding: 0; display: grid; gap: 9px; }
.site-foot a { color: #4a4e56; }
.site-foot a:hover { color: var(--ink-solid); }
.site-foot .foot-note { margin-top: 40px; padding-top: 22px; border-top: 1px solid var(--line); display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; font-size: 13.5px; }
@media (max-width: 760px) { .site-foot .foot-grid { grid-template-columns: 1fr 1fr; } }
/* Utilities */
/* Grids hold one minmax(0, 1fr) column, so a long word or a wide input never pushes past the screen. */
.stack { display: grid; grid-template-columns: minmax(0, 1fr); align-content: start; gap: var(--gap, 16px); }
.row { display: flex; align-items: center; gap: var(--gap, 12px); flex-wrap: wrap; }
.spread { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.center { text-align: center; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.mono { font-family: var(--mono); }
.nowrap { white-space: nowrap; }
.section { padding-block: clamp(56px, 8vw, 96px); }
.section-tight { padding-block: clamp(36px, 5vw, 56px); }
.art { mix-blend-mode: multiply; }
.spinner { width: 18px; height: 18px; border: 2px solid #0000001f; border-top-color: currentColor; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Toast: rises in when it mounts. Add .leaving, then remove it on animationend. */
.toast { position: fixed; left: 50%; bottom: calc(24px + var(--safe-bottom)); z-index: 50; width: max-content; max-width: calc(100% - 32px - var(--safe-left) - var(--safe-right)); transform: translateX(-50%); padding: 12px 18px; border-radius: 10px; background: var(--ink-solid); color: #fff; font: 600 14px/1.3 var(--sans); box-shadow: 0 10px 30px #0004; overflow-wrap: anywhere; animation: oa-toast-in var(--motion) var(--ease-out); }
.toast.leaving { animation: oa-toast-out var(--motion-fast) var(--ease-in) forwards; }
@keyframes oa-toast-in { from { opacity: 0; transform: translate(-50%, 10px) scale(.98); } }
@keyframes oa-toast-out { to { opacity: 0; transform: translate(-50%, 10px) scale(.98); } }
/* Enter and leave: add .enter to an element as it mounts (set --i to its index to stagger a list);
add .leaving to fade it out, then remove it on animationend. .press gives custom tappable things a press. */
.enter { animation: oa-enter var(--motion) var(--ease-out) backwards; animation-delay: calc(min(var(--i, 0), 12) * 30ms); }
.leaving { animation: oa-leave var(--motion-fast) var(--ease-in) forwards; pointer-events: none; }
.press { transition: transform var(--motion-fast) var(--ease-out); }
.press:active { transform: scale(.98); }
@keyframes oa-enter { from { opacity: 0; transform: translateY(6px); } }
@keyframes oa-leave { to { opacity: 0; transform: scale(.98); } }
@keyframes oa-fade-in { from { opacity: 0; } }
@keyframes oa-fade-out { to { opacity: 0; } }
/* Dialog: a centered card on wide screens, a bottom sheet on phones.
.dialog-backdrop > .dialog > .dialog-head (.dialog-title + close .btn-icon), .dialog-body, .dialog-foot.
To close, add .leaving to .dialog-backdrop and remove it on animationend. */
.dialog-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: #15171b66; animation: oa-fade-in var(--motion) var(--ease-out); }
.dialog { display: flex; flex-direction: column; width: min(560px, 100%); max-height: min(88vh, 900px); max-height: min(88dvh, 900px); border-radius: var(--radius-lg); background: #fff; box-shadow: 0 30px 80px -20px #0006; animation: oa-dialog-in var(--motion) var(--ease-out); }
.dialog:focus { outline: none; }
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 12px 2px 20px; }
.dialog-title { font: var(--type-title); letter-spacing: -.02em; color: var(--ink-solid); }
.dialog-body { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: start; gap: 16px; padding: 10px 20px 20px; overflow-y: auto; overscroll-behavior: contain; }
.dialog-foot { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 12px 20px; border-top: 1px solid var(--line); }
.dialog-backdrop.leaving { animation: oa-fade-out var(--motion-fast) var(--ease-in) forwards; }
.dialog-backdrop.leaving .dialog { animation: oa-dialog-out var(--motion-fast) var(--ease-in) forwards; }
@keyframes oa-dialog-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
@keyframes oa-dialog-out { to { opacity: 0; transform: translateY(8px) scale(.98); } }
@keyframes oa-sheet-in { from { transform: translateY(100%); } }
@keyframes oa-sheet-out { to { transform: translateY(100%); } }
@media (max-width: 600px) {
.dialog-backdrop { place-items: end stretch; padding: 0; }
.dialog { width: 100%; max-height: 92vh; max-height: 92dvh; border-radius: var(--radius-lg) var(--radius-lg) 0 0; animation-name: oa-sheet-in; animation-duration: var(--motion-slow); }
.dialog-backdrop.leaving .dialog { animation-name: oa-sheet-out; animation-duration: var(--motion); }
.dialog-body:last-child { padding-bottom: calc(20px + var(--safe-bottom)); }
.dialog-foot { padding-bottom: calc(12px + var(--safe-bottom)); }
.dialog-foot > .btn { flex: 1 1 0; }
}
/* Dropdown: a menu under its button. .dropdown (a div, or a <details> with the button as <summary>) > .dropdown-menu.
Items are the menu's links and buttons; <hr> separates groups; .danger marks a destructive item; .dropdown-menu-end aligns right. */
.dropdown { position: relative; display: inline-block; }
.dropdown > summary { list-style: none; }
.dropdown > summary::-webkit-details-marker { display: none; }
.dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 40; display: grid; width: max-content; min-width: 200px; max-width: calc(100vw - 32px); padding: 6px; border: 1px solid var(--line); border-radius: 12px; background: #fff; box-shadow: var(--shadow-pop); transform-origin: top left; animation: oa-pop-in var(--motion-fast) var(--ease-out); }
.dropdown-menu-end { left: auto; right: 0; transform-origin: top right; }
.dropdown-menu :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 8px; background: none; color: var(--ink); font: var(--type-control); line-height: 1.3; text-align: left; cursor: pointer; transition: background-color var(--motion-fast) var(--ease-out); }
.dropdown-menu :where(a, button):hover { background: #0000000a; }
.dropdown-menu :where(a, button):focus-visible { outline-offset: -2px; }
.dropdown-menu :where(a, button):disabled { color: var(--faint); cursor: not-allowed; background: none; }
.dropdown-menu :where(.danger) { color: var(--brick); }
.dropdown-menu :where(hr) { height: 1px; margin: 6px 4px; border: 0; background: var(--line); }
.dropdown-menu.leaving { animation: oa-pop-out var(--motion-fast) var(--ease-in) forwards; }
@media (pointer: coarse) { .dropdown-menu :where(a, button) { min-height: 44px; } }
@keyframes oa-pop-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } }
@keyframes oa-pop-out { to { opacity: 0; transform: translateY(-4px) scale(.97); } }
/* Skeleton: placeholder shapes while content loads. Size .skeleton with width and height; .skeleton-text is one line.
Put aria-busy="true" on the region that is loading. */
.skeleton { display: block; border-radius: 8px; background: linear-gradient(90deg, #edf0f4 40%, #f7f8fa 50%, #edf0f4 60%) 0 0 / 300% 100%; animation: oa-shimmer 1.6s linear infinite; }
.skeleton-text { height: .75em; margin-block: .45em; border-radius: 6px; }
.skeleton-text:last-child:not(:first-child) { width: 64%; }
@keyframes oa-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
/* Empty and error states: optional art, a short title (h2 or h3), one sentence, then the next step as a button. */
.empty-state, .error-state { display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; justify-items: center; gap: 8px; padding: 40px 24px; border: 1px dashed var(--line-strong); border-radius: var(--radius-lg); text-align: center; overflow-wrap: anywhere; }
.error-state { border-style: solid; border-color: #efcfc5; background: var(--brick-wash); }
:where(.empty-state, .error-state) > img { width: min(180px, 60%); height: auto; margin-bottom: 4px; }
:where(.empty-state, .error-state) > :is(h2, h3) { font: var(--type-title); letter-spacing: -.02em; color: var(--ink-solid); text-wrap: balance; }
:where(.empty-state, .error-state) > p { max-width: 46ch; color: var(--muted); text-wrap: pretty; }
:where(.error-state) > :is(h2, h3) { color: #7c2f1d; }
:where(.error-state) > p { color: #8b4a3a; }
:where(.empty-state, .error-state) > :is(.btn, .row) { margin-top: 10px; }
/* Action bar: the screen's main buttons. Pinned to the bottom on phones (above the home bar), inline on wider screens. */
.action-bar { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
@media (max-width: 720px) {
.action-bar { position: sticky; bottom: 0; z-index: 30; padding: 12px 0 calc(12px + var(--safe-bottom)); background: #fdfdfcf2; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-top: 1px solid var(--line); }
.action-bar > .btn { flex: 1 1 0; }
body:has(.action-bar) .toast { bottom: calc(88px + var(--safe-bottom)); }
}
/* On touch phones the bar runs edge to edge (their scrollbars take no width, so 100vw is the screen). */
@media (max-width: 720px) and (pointer: coarse) {
.action-bar { margin-inline: calc(50% - 50vw); padding-inline: max(16px, var(--safe-left)) max(16px, var(--safe-right)); }
}
/* Safe-area padding for edge-to-edge bars and full-screen views (put on a wrapper; it replaces that side's padding). */
.safe-top { padding-top: var(--safe-top); }
.safe-bottom { padding-bottom: var(--safe-bottom); }
.safe-x { padding-left: var(--safe-left); padding-right: var(--safe-right); }
/* Reduced motion: transitions and animations finish at once. Spinners keep turning slowly, since they mean "working". */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
.enter { animation-delay: 0s !important; }
.spinner, .btn[aria-busy="true"]::after { animation-duration: 1.6s !important; animation-iteration-count: infinite !important; }
}
/* The OpenApps free apps network, in every app's footer. */
.oa-network { display: grid; gap: 16px; padding-bottom: 28px; margin-bottom: 4px; border-bottom: 1px solid var(--line); }
.oa-network-title { display: grid; gap: 4px; }
.oa-network-title a { color: var(--ink-solid); font: 400 22px/1.2 var(--serif); letter-spacing: -.015em; }
.oa-network-title span { font-size: 14px; color: var(--muted); }
.oa-network ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 8px 18px; }
.oa-network li { display: grid; gap: 1px; }
.oa-network li a { color: var(--ink); font: 600 14.5px/1.3 var(--sans); }
.oa-network li a:hover { color: var(--cobalt); }
.oa-network li a[aria-current="page"] { color: var(--muted); }
.oa-network li span { font-size: 13px; color: var(--faint); }
.oa-network-more { font-size: 13.5px; color: var(--faint); }
.oa-network-more a { color: #4a4e56; }
Change history
Add favicon.ico and link tag
Quality pass: guest reschedule, days off, calendar feed, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync