mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 06:59:42 +00:00
* badges: webapp (#7433) * badges: service migrations, store and catalog * badges: BTCPay provider and settlement poller * badges: web listener and /api endpoints * web: checkout single-page app * badges: tests and BTCPay fixtures * badges: README and ini reference * badges: fix hex16 build on GHC 8.10.7 * badges: Stripe card lane * badges: fix Stripe card checkout, add theming * badges: add a discount row to the order summary * badges: site navbar, embedding, theme, Forget move * badges: use SB code prefix in web checkout * badges: rename sxb app namespace to sb * badges: embed checkout nav via site; keep original app navbar * badges: post iframe height, apply site background when embedded * badges: embed dark surfaces, steadier iframe height * badges: hide app footer when embedded * badges: size embedded body to content, not viewport * badges: declare color-scheme to stop reload flash * badges: fade shell in on load, no reload blank * badges: prerender app shell into index.html * badges: pre-paint theme, hide shell on deep reload * badges: logo returns to landing client-side * badges: embedded wizard back, buy-a-code, resume * badges: signal app-managed screens, resume across reload * badges: rebuild wizard history on deep load so Back walks it * badges: carry welcome-page height as the iframe floor * badges: keep selection on Buy a code; rename to Your codes * badges: read web shell as UTF-8, not locale * badges: resume the exact paid order after Stripe card redirect * badges: move docker deploy under scripts * badges: add serve_webapp toggle and webapp export * badges: wire split webapp deploy in docker config * badges: quiet agent logs by default * badges: resume card redirect in the embedded frame * badges: migrate Stripe adapter to PaymentIntents * badges: correct Stripe restricted key scopes in ini example * badges: card via Payment Element and PaymentIntents * badges: fix stale Checkout Session wording in Stripe adapter * badges: fix stale CheckoutActions reference in card comment * badges: order shell stylesheet before bootstrap script * badges: remove development card stand-in * badges: theme the Stripe card form with the site palette * badges: exclude web from the Haskell build stage * badges: unify invoice cancel and mark canceled * badges: default log level to info * badges: unify closed-invoice buy-again button * badges: mute agent connection logs at info level * badges: show purchase time in local timezone in Your codes * badges: log service events on own channel, quiet agent * badges: fold service migrations into one baseline * badges: run compose on postgres over host network * badges: use high-res hero art * badges: add web CI to catch stale builds * badges: rebuild web shell from committed source * badges: normalize invoice-code link and columns * badges: drop unused columns, rename index * badges: note deferred receipt_hash in migrations * badges: apply code-review fixes * badges: reduce comments across service and web --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: improve web page (#7546) * badges: improve web page * improve layout * improve layout * fix * small changes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: read one issuer key from the ini * badges: move and group the service tests * badges: service fixes (#7567) * badges: match the redeem error wording in tests * badges: drop unused imports in the bot tests * badges: cancel Stripe orders when they expire * badges: correct the Stripe config and docs * badges: refuse to revoke a redeemed code * badges: make the fake Stripe cancel like Stripe * badges: limit replayed webhook deliveries --------- Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum <github.shum@liber.li> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
1253 lines
51 KiB
CSS
1253 lines
51 KiB
CSS
/* The colours are the website's, as its blog pages use them (website/tailwind.config.js,
|
||
website/src/css/blog.css and website/src/_includes/layouts/article.html): the page's ground,
|
||
the article's card, the heading gradient, and the link and button colours, in both themes.
|
||
Every other value below is the published SimpleX design's own; where one is not, the line says why. */
|
||
:root {
|
||
/* Tell the UA which ground to paint before the first message and on a reload's blank frame, and
|
||
which form controls and scrollbars to render; the dark blocks flip it with the palette. */
|
||
color-scheme: light;
|
||
/* primary-light and its hover step; white on it, as on the site's buttons. Pressed is the
|
||
accent one small step darker rather than the site's lighter, greyer #407AD2: a press should
|
||
read as the same button under the finger, not a paler one. */
|
||
--accent: #0053D0;
|
||
--accent-hover: #1661D1;
|
||
--accent-pressed: #004ABB;
|
||
--on-accent: #FFFFFF;
|
||
--ink: #0D1B3A; /* simplex.domains' ink: the navy the page's blues are built on */
|
||
--bg: #F3FAFF; /* secondary-bg-light: the blog page's ground */
|
||
--surface: #FFFFFF; /* the article's card */
|
||
--menu: #FFFFFF;
|
||
--line: rgba(0, 83, 208, .14); /* simplex.domains' card line: the accent, faint, so it sits in the page's blue */
|
||
/* one faint wash of the accent over the flat ground, and nothing else */
|
||
--page: radial-gradient(50% 50% at 50% 50%, rgba(0, 83, 208, .06) 0%, rgba(243, 250, 255, 0) 100%);
|
||
|
||
/* The one secondary text colour, simplex.domains' own: the ink at 65%, so it stays in the
|
||
page's hue. 5.3:1 on #F3FAFF, and set at 500 wherever it is used. */
|
||
--ink-soft: rgba(13, 27, 58, .651);
|
||
/* The ink at 4%: the menu's hover, the landing screen's info panel, and a card the catalog
|
||
cannot price. */
|
||
--tint: rgba(13, 27, 58, .04);
|
||
|
||
/* the blog's heading gradient, top to bottom */
|
||
--title: linear-gradient(to bottom, #53C1FF -50%, #0053D0 160%);
|
||
|
||
/* Settled, waiting and failed, each a foreground on its own ground. */
|
||
--ok-fg: #1c7c3f;
|
||
--ok-bg: #e8f5ec;
|
||
--ok-line: #bfe3cc;
|
||
--warn-fg: #8a6100;
|
||
--warn-bg: #fff8e6;
|
||
--warn-line: #f0dcae;
|
||
--danger-fg: #b3261e;
|
||
--danger-bg: #fdecec;
|
||
--danger-line: #f3c9c6;
|
||
|
||
/* the code screen's code frame, in the accent */
|
||
--code-bg: rgba(0, 83, 208, .06);
|
||
--code-line: rgba(0, 83, 208, .35);
|
||
|
||
/* A primary button with nothing to do: a flat bar with a washed label, legible
|
||
at 4.0:1. See `.primary[disabled]` for why it is not the accent faded. */
|
||
--disabled: rgba(13, 27, 58, .07);
|
||
--on-disabled: rgba(13, 27, 58, .50);
|
||
|
||
/* Switched with the theme rather than tinted by it. The dark wordmark is the
|
||
same file with its `#030749` set to white. */
|
||
--hero: url(hero-light.png);
|
||
--wordmark: url(wordmark-light.svg);
|
||
|
||
/* a card is a 1px border at radius 20 with no shadow, and every button a pill; the
|
||
shadows on the page are the menu's and the primary button's */
|
||
--r-card: 20px;
|
||
--r-pill: 9999px;
|
||
--shadow-pop: 0 2px 6px rgba(0, 0, 0, .06), 0 16px 40px rgba(0, 0, 0, .12);
|
||
/* the primary button: a touch of light across its top and a soft shadow in the accent */
|
||
--sheen: linear-gradient(to bottom, rgba(255, 255, 255, .16), rgba(255, 255, 255, 0) 60%);
|
||
--shadow-button: 0 4px 12px rgba(0, 83, 208, .18);
|
||
--shadow-button-hover: 0 6px 16px rgba(0, 83, 208, .24);
|
||
|
||
/* The faces simplex.domains sets: GT Walsheim for the heading, Manrope for everything else.
|
||
Both are served under the build hash (see the @font-face rules below) and precached. */
|
||
--display: "GT Walsheim", Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||
--font: Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
}
|
||
|
||
/* The four Manrope weights the sheet uses and the one GT Walsheim weight, relative to this file,
|
||
so they sit under the build hash with it. `swap`, so text is never invisible while a face loads. */
|
||
@font-face { font-family: Manrope; src: url(Manrope-Regular.woff2) format("woff2"); font-weight: 400; font-style: normal; font-display: swap; }
|
||
@font-face { font-family: Manrope; src: url(Manrope-Medium.woff2) format("woff2"); font-weight: 500; font-style: normal; font-display: swap; }
|
||
@font-face { font-family: Manrope; src: url(Manrope-SemiBold.woff2) format("woff2"); font-weight: 600; font-style: normal; font-display: swap; }
|
||
@font-face { font-family: Manrope; src: url(Manrope-Bold.woff2) format("woff2"); font-weight: 700; font-style: normal; font-display: swap; }
|
||
@font-face { font-family: "GT Walsheim"; src: url(GT-Walsheim-LC-Bold.woff2) format("woff2"); font-weight: 700; font-style: normal; font-display: swap; }
|
||
|
||
/* The dark palette is written twice on purpose: the media query claims
|
||
everything that is not an explicit light, and the attribute rule claims an
|
||
explicit dark, which is how `system`, `light` and `dark` all win where they
|
||
should. Both blocks must declare the same tokens and values, and
|
||
`design.test.ts` fails if they diverge. */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
color-scheme: dark;
|
||
/* primary-dark, with its hover and pressed steps, and the page's ground on it, as on the
|
||
site's dark buttons */
|
||
--accent: #70F0F9;
|
||
--accent-hover: #66D9E2;
|
||
--accent-pressed: #5CD3DC;
|
||
--on-accent: #000832;
|
||
--ink: #EAF2FF; /* simplex.domains' dark ink */
|
||
--bg: #000832; /* primary-bg-dark: the blog page's ground */
|
||
--surface: #0B2A59; /* secondary-bg-dark: the blog list's card, a clear step off the ground */
|
||
--menu: #0B2A59;
|
||
--line: rgba(112, 240, 249, .18); /* simplex.domains' dark card line: the accent, faint */
|
||
--page: radial-gradient(50% 50% at 50% 50%, rgba(112, 240, 249, .05) 0%, rgba(0, 8, 50, 0) 100%);
|
||
|
||
--ink-soft: rgba(234, 242, 255, .735);
|
||
--tint: rgba(255, 255, 255, .06);
|
||
|
||
--title: linear-gradient(to bottom, #70F0F9 0%, #70F0F9 33%, #50D8F1 100%);
|
||
|
||
/* The light theme's three hues, lightened to sit on #000832, each with its
|
||
own ground at a low alpha of itself. */
|
||
--ok-fg: #7FE3A6;
|
||
--ok-bg: rgba(127, 227, 166, .12);
|
||
--ok-line: rgba(127, 227, 166, .28);
|
||
--warn-fg: #F5C86A;
|
||
--warn-bg: rgba(245, 200, 106, .12);
|
||
--warn-line: rgba(245, 200, 106, .28);
|
||
--danger-fg: #FF9F98;
|
||
--danger-bg: rgba(255, 159, 152, .12);
|
||
--danger-line: rgba(255, 159, 152, .28);
|
||
|
||
--code-bg: rgba(112, 240, 249, .12);
|
||
--code-line: rgba(112, 240, 249, .45);
|
||
|
||
--disabled: rgba(255, 255, 255, .09);
|
||
--on-disabled: rgba(255, 255, 255, .45);
|
||
|
||
--hero: url(hero-dark.png);
|
||
--wordmark: url(wordmark-dark.svg);
|
||
|
||
--shadow-pop: 0 2px 8px rgba(0, 0, 0, .50), 0 16px 40px rgba(0, 0, 0, .55);
|
||
/* White barely shows on the light cyan, so the dark sheen also deepens the pill's foot. */
|
||
--sheen: linear-gradient(to bottom, rgba(255, 255, 255, .30), rgba(255, 255, 255, 0) 50%, rgba(0, 8, 50, .16) 100%);
|
||
--shadow-button: 0 6px 16px rgba(112, 240, 249, .16);
|
||
--shadow-button-hover: 0 8px 20px rgba(112, 240, 249, .22);
|
||
}
|
||
}
|
||
|
||
/* The same palette again, for the menu's explicit Dark. */
|
||
:root[data-theme="dark"] {
|
||
color-scheme: dark;
|
||
/* primary-dark, with its hover and pressed steps, and the page's ground on it, as on the
|
||
site's dark buttons */
|
||
--accent: #70F0F9;
|
||
--accent-hover: #66D9E2;
|
||
--accent-pressed: #5CD3DC;
|
||
--on-accent: #000832;
|
||
--ink: #EAF2FF; /* simplex.domains' dark ink */
|
||
--bg: #000832; /* primary-bg-dark: the blog page's ground */
|
||
--surface: #0B2A59; /* secondary-bg-dark: the blog list's card, a clear step off the ground */
|
||
--menu: #0B2A59;
|
||
--line: rgba(112, 240, 249, .18); /* simplex.domains' dark card line: the accent, faint */
|
||
--page: radial-gradient(50% 50% at 50% 50%, rgba(112, 240, 249, .05) 0%, rgba(0, 8, 50, 0) 100%);
|
||
|
||
--ink-soft: rgba(234, 242, 255, .735);
|
||
--tint: rgba(255, 255, 255, .06);
|
||
|
||
--title: linear-gradient(to bottom, #70F0F9 0%, #70F0F9 33%, #50D8F1 100%);
|
||
|
||
/* The light theme's three hues, lightened to sit on #000832, each with its
|
||
own ground at a low alpha of itself. */
|
||
--ok-fg: #7FE3A6;
|
||
--ok-bg: rgba(127, 227, 166, .12);
|
||
--ok-line: rgba(127, 227, 166, .28);
|
||
--warn-fg: #F5C86A;
|
||
--warn-bg: rgba(245, 200, 106, .12);
|
||
--warn-line: rgba(245, 200, 106, .28);
|
||
--danger-fg: #FF9F98;
|
||
--danger-bg: rgba(255, 159, 152, .12);
|
||
--danger-line: rgba(255, 159, 152, .28);
|
||
|
||
--code-bg: rgba(112, 240, 249, .12);
|
||
--code-line: rgba(112, 240, 249, .45);
|
||
|
||
--disabled: rgba(255, 255, 255, .09);
|
||
--on-disabled: rgba(255, 255, 255, .45);
|
||
|
||
--hero: url(hero-dark.png);
|
||
--wordmark: url(wordmark-dark.svg);
|
||
|
||
--shadow-pop: 0 2px 8px rgba(0, 0, 0, .50), 0 16px 40px rgba(0, 0, 0, .55);
|
||
/* White barely shows on the light cyan, so the dark sheen also deepens the pill's foot. */
|
||
--sheen: linear-gradient(to bottom, rgba(255, 255, 255, .30), rgba(255, 255, 255, 0) 50%, rgba(0, 8, 50, .16) 100%);
|
||
--shadow-button: 0 6px 16px rgba(112, 240, 249, .16);
|
||
--shadow-button-hover: 0 8px 20px rgba(112, 240, 249, .22);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
/* Reserve the scrollbar's width always, so a panel whose height changes (the Payment Element
|
||
autofilling or a 3DS challenge resizing it) cannot toggle the scrollbar and jog the centred
|
||
column sideways, which `#app`'s overflow-x then clips. */
|
||
html { scrollbar-gutter: stable; }
|
||
|
||
/* The page is one column of the viewport's height: the header, then the screen, which is at least
|
||
the rest of the viewport tall (see `.panel`), then the footer below the fold, where a scroll
|
||
reaches it. `100vh` is the fallback for a browser without dynamic viewport units. */
|
||
body {
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
/* The gutter is here, so `#app` is exactly the 640px content column and
|
||
nothing inside it has to subtract padding to reach that width. */
|
||
padding: 0 20px;
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
background: var(--bg);
|
||
/* Fixed, so the wash does not tile or scroll away on a long screen. */
|
||
background-image: var(--page);
|
||
background-attachment: fixed;
|
||
color: var(--ink);
|
||
font-family: var(--font);
|
||
/* the scale: 1rem/1.25 body, .875 small, .75 extra-small, larger steps below */
|
||
font-size: 16px;
|
||
line-height: 1.5;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
#app { width: 100%; max-width: 640px; margin: 0 auto; overflow-x: hidden; flex: 1 0 auto; }
|
||
/* The header's height, which every screen's minimum height is the viewport less: `.chrome-bar`'s
|
||
56px box, its paddings inside it. */
|
||
:root { --chrome-h: 56px; }
|
||
|
||
/* The shell ships a static copy of the first screen (see index.html), so the first paint is the real
|
||
landing, not an empty page, and `main.ts` swaps in its own identical markup with no flash. Embedded,
|
||
the site owns the navbar, so the shell's copy of it is hidden there as the built one would be. */
|
||
.embedded #chrome { display: none; }
|
||
|
||
/* A reload of any screen but the landing can't be prerendered (the screen depends on runtime state),
|
||
so `init.js` marks it `sb-booting` before the first paint: the shell's landing is held hidden over
|
||
the themed ground, and `main.ts` clears the mark once it has painted the real screen, which then
|
||
fades in rather than replacing a flash of the landing. The landing route is never marked, so its
|
||
prerendered first paint stays instant. */
|
||
#app { transition: opacity .2s ease; }
|
||
.sb-booting #app { opacity: 0; }
|
||
|
||
/* Embedded in the site: the site's fixed navbar sits above the frame, so pad the top to clear it,
|
||
and drop the page's own wash so the flat background the site hands in shows through cleanly. The
|
||
site carries its own footer below the frame, so ours would only double the contact link and leave
|
||
its accent line at the frame's edge — hide it embedded. */
|
||
/* Standalone, the body fills the viewport so the footer sits at the bottom; embedded, the host sizes
|
||
the frame to the body, so a `100vh` floor would make the body's height depend on the frame height
|
||
the host derived from it — a loop that ratchets the frame taller and drops the site's footer as the
|
||
zoom shifts. Drop the floor so the body is exactly its content. */
|
||
.embedded body { padding-top: 54px; background-image: none; min-height: 0; display: block; }
|
||
.embedded .panel { min-height: 0; }
|
||
.embedded footer { display: none; }
|
||
|
||
/* Embedded in the site's dark theme, the standalone grey surfaces read as off against the navy the
|
||
site hands in. Follow the site's own dark-card idiom instead: a faint white veil and hairline on
|
||
the navy ground, so a panel is a lighter navy rather than a grey block. Both selectors, to match
|
||
`system` dark (media query) and the explicit Dark choice (data-theme). */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root.embedded:not([data-theme="light"]) {
|
||
--surface: rgba(255, 255, 255, .05);
|
||
--menu: rgba(255, 255, 255, .07);
|
||
--line: rgba(255, 255, 255, .22);
|
||
}
|
||
}
|
||
:root.embedded[data-theme="dark"] {
|
||
--surface: rgba(255, 255, 255, .05);
|
||
--menu: rgba(255, 255, 255, .07);
|
||
--line: rgba(255, 255, 255, .22);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------- chrome */
|
||
|
||
/* The wordmark on the left, and on the right a circular icon button holding the
|
||
hamburger. It spans the page rather than the 640 column, so the column reads
|
||
as content inside a site rather than as the whole site. */
|
||
.chrome-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
min-height: 56px;
|
||
max-width: 1120px;
|
||
margin: 0 auto;
|
||
padding: 8px 4px;
|
||
}
|
||
|
||
/* The official wordmark, drawn from the file. The URL is relative, so it
|
||
resolves inside /assets/<buildHash>/ beside this sheet and is precached with
|
||
everything else. */
|
||
.brand {
|
||
display: block;
|
||
width: 132px;
|
||
aspect-ratio: 161 / 40;
|
||
background-image: var(--wordmark);
|
||
background-size: contain;
|
||
background-position: left center;
|
||
background-repeat: no-repeat;
|
||
/* A link with no text: the accessible name is on the element. */
|
||
text-indent: -9999px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.menu-wrap { position: relative; }
|
||
|
||
/* 40 rather than the reference's 32, which is under every platform's minimum
|
||
touch target and this is the header's only control. */
|
||
.menu-button {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 40px;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
/* The hairline colour as a fill, which on both grounds is the reference's own
|
||
circle without a fourth grey. */
|
||
background: var(--line);
|
||
color: var(--ink);
|
||
cursor: pointer;
|
||
}
|
||
.menu-button:hover, .menu-button[aria-expanded="true"] { color: var(--accent); }
|
||
.bars { display: block; width: 20px; height: 20px; }
|
||
|
||
/* The panel, anchored under the button and right-aligned to it, one step off
|
||
the page ground, with a hairline and the page's only drop shadow. */
|
||
.menu {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
right: 0;
|
||
z-index: 20;
|
||
width: 320px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
background: var(--menu);
|
||
box-shadow: var(--shadow-pop);
|
||
text-align: left;
|
||
overflow: hidden;
|
||
}
|
||
.menu[hidden] { display: none; }
|
||
|
||
/* Full-bleed hairlines between sections, so the padding is on the section and
|
||
never on the panel. */
|
||
.menu-section { padding: 6px; }
|
||
.menu-section + .menu-section { border-top: 1px solid var(--line); }
|
||
|
||
.menu-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 6px 10px;
|
||
}
|
||
.menu-label { font-size: .875rem; font-weight: 700; color: var(--ink); }
|
||
|
||
/* the segmented setting: three values in one pill, the chosen one in the
|
||
accent with white on it. */
|
||
.segmented {
|
||
display: flex;
|
||
padding: 2px;
|
||
border-radius: var(--r-pill);
|
||
background: var(--tint);
|
||
}
|
||
.segment {
|
||
padding: 5px 11px;
|
||
border: 0;
|
||
border-radius: var(--r-pill);
|
||
background: none;
|
||
color: var(--ink-soft);
|
||
font: inherit;
|
||
font-size: .875rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.segment:hover { color: var(--ink); }
|
||
.segment[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); }
|
||
|
||
.menu-item {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 0;
|
||
border-radius: 10px;
|
||
background: none;
|
||
color: var(--ink);
|
||
font: inherit;
|
||
font-size: .875rem;
|
||
font-weight: 500;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
.menu-item[hidden] { display: none; }
|
||
.menu-item:hover { background: var(--tint); color: var(--accent); }
|
||
.menu-item.danger { color: var(--danger-fg); }
|
||
.menu-item.danger:hover { background: var(--danger-bg); color: var(--danger-fg); }
|
||
|
||
/* the footer, on every screen: a hairline across the column, then the
|
||
contact link centred under it in the accent. */
|
||
footer {
|
||
flex: none;
|
||
width: 100%;
|
||
max-width: 640px;
|
||
margin: 0 auto;
|
||
text-align: center;
|
||
}
|
||
footer a {
|
||
display: block;
|
||
border-top: 1px solid var(--line);
|
||
padding: 22px 0 34px;
|
||
color: var(--accent);
|
||
font-size: .875rem;
|
||
text-decoration: none;
|
||
}
|
||
footer a:hover { text-decoration: underline; }
|
||
|
||
/* The keyboard's equivalent of the hover underline, declared rather than left
|
||
to the user agent, whose thin dark outline all but disappears on the accent
|
||
button and on the dark ground. `:focus-visible`, so a mouse press leaves
|
||
nothing behind. */
|
||
:focus-visible {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
/* On the accent ground itself an accent ring is invisible, so it inverts. */
|
||
.primary:not(.outline):focus-visible { outline-color: var(--ink); }
|
||
|
||
/* -------------------------------------------------------------- the wizard */
|
||
|
||
/* the horizontal track, in two elements, because one cannot clip and travel
|
||
at the same time. `.track` clips and is the height of the panel in view;
|
||
`.rail` is the flex row of four panels and is what translates. Both animate,
|
||
so the outgoing panel travels off one edge while the incoming one arrives at
|
||
the other. `main.ts` writes the height and the transform, and sets `--slide`
|
||
to 0ms for any move that is not a step.
|
||
|
||
`overflow: hidden` is also what makes this a stepper rather than a carousel:
|
||
there is no scroll to drag. Unreached panels carry `inert`, so Tab cannot
|
||
enter them either. */
|
||
.track {
|
||
overflow: hidden;
|
||
transition: height var(--slide, 320ms) cubic-bezier(.22, .61, .36, 1);
|
||
}
|
||
.rail {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
width: 100%;
|
||
transition: transform var(--slide, 320ms) cubic-bezier(.22, .61, .36, 1);
|
||
}
|
||
/* Below the column width each panel is the viewport width: the same track is
|
||
the phone flow, with only the padding and the type scale changing. */
|
||
.panel {
|
||
position: relative;
|
||
flex: 0 0 100%;
|
||
min-width: 0;
|
||
padding: 12px 0 22px;
|
||
text-align: center;
|
||
/* A screen is a column at least as tall as the viewport under the header, so that its button
|
||
stands at the same place on every screen - near the bottom, where a thumb reaches it - and the
|
||
content between the heading and the button is spread over the height rather than stacked at
|
||
the top. A screen taller than the viewport grows and scrolls, as the payment screens do. */
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: calc(100vh - var(--chrome-h));
|
||
min-height: calc(100dvh - var(--chrome-h));
|
||
}
|
||
/* On a wide screen the button needs no thumb, so it stands higher off the foot of the viewport and
|
||
the page reads as balanced rather than bottom-heavy. Up to a 1080px-tall viewport the space under
|
||
it grows with the height; beyond that the content stops descending - its foot stays 927px under
|
||
the header, where a 1080px viewport puts it - and the whole surplus goes under it. */
|
||
@media (min-width: 700px) {
|
||
.panel {
|
||
padding-bottom: max(clamp(40px, 9vh, 110px), calc(100vh - var(--chrome-h) - 927px));
|
||
padding-bottom: max(clamp(40px, 9vh, 110px), calc(100dvh - var(--chrome-h) - 927px));
|
||
}
|
||
}
|
||
/* What each screen spreads: the hero, the choice cards, the summary. `auto` above and nothing
|
||
below, so the free height is shared between the space over the block and the space under it,
|
||
which the button's own `auto` margin takes. */
|
||
.panel > .hero, .panel > .choices, .panel > .rows { margin-top: auto; }
|
||
/* The button is the last thing before the invest block, and sinks to the foot of the screen. Where
|
||
a screen keeps notes above its button, the notes sink with it and the button follows them. */
|
||
.panel > .primary { margin-top: auto; }
|
||
.panel > .notes { margin-top: auto; }
|
||
.notes + .primary { margin-top: 20px; }
|
||
.notes .muted { margin: 4px auto 0; max-width: 480px; }
|
||
/* The checkout's note slot holds one line's height whether or not the line is shown, so choosing a
|
||
method moves nothing above or below it. */
|
||
.notes.slot { min-height: calc(4px + .875rem * 1.5); }
|
||
.notes .half { display: inline-block; }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.track, .rail { transition: none; }
|
||
}
|
||
|
||
/* Every payment screen and `#/codes` takes the same shell as a wizard panel.
|
||
The 20px a trailing button adds is the difference between a footer rule under
|
||
a button and one under a block of text. */
|
||
.panel > button:last-child { margin-bottom: 20px; }
|
||
|
||
/* ------------------------------------------------------------------ headings */
|
||
|
||
h1 {
|
||
font-family: var(--display);
|
||
font-size: 2.25rem;
|
||
line-height: 3rem;
|
||
font-weight: 700;
|
||
letter-spacing: -.02em;
|
||
color: var(--ink);
|
||
margin: 12px 0 0;
|
||
}
|
||
/* The heading is the gradient, clipped to its own glyphs, in both themes.
|
||
Guarded, because the fill is transparent: a browser that cannot clip a
|
||
background to text would render every heading invisible. */
|
||
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
|
||
h1 {
|
||
background-image: var(--title);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
}
|
||
}
|
||
/* the payment screen and the code screen take one step down from the landing screen's. */
|
||
h1.tight { font-size: 1.875rem; line-height: 2.5rem; margin-top: 15px; }
|
||
|
||
.lede {
|
||
font-size: 1.125rem;
|
||
line-height: 1.5;
|
||
font-weight: 500;
|
||
color: var(--ink-soft);
|
||
margin: 10px 0 0;
|
||
}
|
||
h1 + .lede { margin-top: 10px; }
|
||
h1.tight + .lede { margin-top: 6px; }
|
||
/* Two ledes are two sentences of one paragraph, a line height apart rather than
|
||
a paragraph gap. */
|
||
.lede + .lede { margin-top: 0; }
|
||
.lede .line { display: block; }
|
||
.lede .half { display: inline-block; }
|
||
|
||
.muted { color: var(--ink-soft); font-size: .875rem; font-weight: 500; margin: 14px 0 0; }
|
||
/* A narrower measure than the column, so the closing footnote breaks in two rather than
|
||
reading as a caption that ran on. Scoped to a direct child, so it cannot reach the same
|
||
class inside a left-aligned field and indent the line it holds. */
|
||
.panel > p.muted { max-width: 332px; margin-left: auto; margin-right: auto; }
|
||
/* Shown beside the status while the browser is offline, not instead of it. */
|
||
.offline { color: var(--warn-fg); }
|
||
.center { text-align: center; }
|
||
|
||
/* Figures that are compared down a column, or that change in place while the
|
||
buyer is looking at them. The default proportional digits are narrower on 1
|
||
than on 0, so the payment screen's held-rate clock and the rate-limited screen's countdown shifted every second
|
||
they ticked, and the duration list's three prices did not line up under one another.
|
||
`.notice .title` is the rate-limited screen's countdown, which is the only figure that line ever
|
||
holds. */
|
||
.row, .choice .price, .rate, .notice .title, .entry .meta {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* Back is out of the flow, so the heading is the first thing in every panel and stands at one
|
||
height whether or not there is a Back. On a desktop `top` centres it on the heading's first
|
||
line, at the left of the column, as a navigation bar draws it. On a phone the heading is nearly
|
||
as wide as the column, so Back is above it and the heading's margin holds that line open on
|
||
every screen. The chevron hangs a little into the gutter so the word aligns with the column's edge. */
|
||
.back {
|
||
position: absolute;
|
||
left: -5px;
|
||
top: 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1px;
|
||
color: var(--accent);
|
||
background: none;
|
||
border: 0;
|
||
font: inherit;
|
||
font-size: 1rem;
|
||
font-weight: 500;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.back .chevron { width: 22px; height: 22px; display: block; }
|
||
|
||
/* -------------------------------------------------------------------- buttons */
|
||
|
||
/* Spans the column because it is the column's one action. */
|
||
.primary {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 48px;
|
||
padding: 8px 16px;
|
||
border: 0;
|
||
border-radius: var(--r-pill);
|
||
background: var(--accent);
|
||
background-image: var(--sheen);
|
||
box-shadow: var(--shadow-button);
|
||
color: var(--on-accent);
|
||
font: inherit;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
line-height: 1.25;
|
||
cursor: pointer;
|
||
margin: 24px 0 0;
|
||
transition: background-color 120ms linear, box-shadow 120ms linear;
|
||
}
|
||
.primary:not([disabled]):not(.copied):hover { background-color: var(--accent-hover); box-shadow: var(--shadow-button-hover); }
|
||
.primary:not([disabled]):not(.copied):active { background-color: var(--accent-pressed); box-shadow: none; }
|
||
/* A different shape, not a faded one: the accent at reduced opacity still reads
|
||
as the live button, so a Continue that cannot continue looks broken rather
|
||
than not-yet-answered. */
|
||
.primary[disabled] {
|
||
background: var(--disabled);
|
||
box-shadow: none;
|
||
color: var(--on-disabled);
|
||
cursor: default;
|
||
}
|
||
|
||
/* The same button as an outline, which is what the closed-window screen's [ New invoice ] and the code screen's
|
||
[ Copy code ] are. */
|
||
.primary.outline {
|
||
background: none;
|
||
box-shadow: none;
|
||
color: var(--accent);
|
||
border: 1.5px solid var(--accent);
|
||
}
|
||
.primary.outline:not([disabled]):not(.copied):hover { background: var(--tint); color: var(--accent-hover); }
|
||
|
||
/* A minimum height and not a fixed one: at the 320px floor a two-line
|
||
label would otherwise put its second line outside the border. */
|
||
.secondary {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
min-height: 48px;
|
||
padding: 8px 16px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--r-pill);
|
||
background: var(--surface);
|
||
color: var(--accent);
|
||
font: inherit;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
text-align: center;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
margin: 12px 0 0;
|
||
line-height: 1.25;
|
||
}
|
||
.secondary:not(.copied):hover { border-color: var(--accent); }
|
||
|
||
/* the payment screen's Copy, which lives inside the address panel rather than under it. */
|
||
.secondary.inline {
|
||
display: inline;
|
||
width: auto;
|
||
min-height: 0;
|
||
height: auto;
|
||
padding: 0;
|
||
border: 0;
|
||
background: none;
|
||
font-size: .875rem;
|
||
line-height: inherit;
|
||
margin: 0;
|
||
}
|
||
.secondary.inline:not(.copied):hover { text-decoration: underline; }
|
||
|
||
/* A copy that landed, confirmed on the control itself for two seconds.
|
||
Every hover rule above excludes it: the pointer is still on the button that
|
||
was just pressed, and a hover colour would overwrite the confirmation. */
|
||
.copied { color: var(--ok-fg); border-color: var(--ok-line); }
|
||
.primary.copied { background: none; box-shadow: none; color: var(--ok-fg); border-color: var(--ok-fg); }
|
||
.secondary.inline.copied { text-decoration: none; }
|
||
/* The failure line only: a success never lands here, so this is empty until
|
||
something goes wrong, and empty it takes up no room. */
|
||
.copy-status:empty, .cancel-status:empty { display: none; }
|
||
|
||
.link {
|
||
background: none;
|
||
border: 0;
|
||
padding: 0;
|
||
font: inherit;
|
||
font-size: .875rem;
|
||
color: var(--accent);
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
}
|
||
.link:hover { text-decoration: underline; }
|
||
.link.danger { color: var(--danger-fg); }
|
||
.link[disabled] { color: var(--ink-soft); cursor: default; text-decoration: none; }
|
||
p.row-line { margin: 16px 0 0; }
|
||
|
||
/* --------------------------------------------------------------- the choices */
|
||
|
||
/* the tier list's tiers and the duration list's durations stay side by side at every width: values being
|
||
compared must stay comparable. Only the type scale shrinks. */
|
||
.choices { display: flex; gap: 14px; margin: 26px 0 0; align-items: stretch; }
|
||
.choice {
|
||
/* A column, explicitly top-aligned: a <button> centres its own content, so a
|
||
card stretched to a taller sibling's height would print its name low. */
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: flex-start;
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
padding: 17px 10px 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--r-card);
|
||
background: var(--surface);
|
||
color: var(--ink);
|
||
font: inherit;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color 120ms linear;
|
||
}
|
||
.choice:not([disabled]):hover { border-color: var(--accent-pressed); }
|
||
/* The chosen card keeps the same box: a 2px accent border drawn inside the
|
||
1px one's place, so nothing moves when the answer changes. */
|
||
.choice[aria-pressed="true"] {
|
||
border-color: var(--accent);
|
||
box-shadow: inset 0 0 0 1px var(--accent);
|
||
}
|
||
/* the provider-unavailable screen's unavailable method, and any tier or duration the catalog cannot price:
|
||
the outline stays and what is inside it greys, so the word "unavailable"
|
||
stays readable, the card being shown precisely so it can be read. */
|
||
.choice[disabled] { cursor: default; color: var(--ink-soft); background: var(--tint); }
|
||
.choice[disabled] .mark, .choice[disabled] .badge-art { opacity: .4; }
|
||
/* A tier leads with its name and states its price plainly under it; a duration leads with
|
||
the price and lets the term step back. The design's 19/17 is not a pair of steps on this
|
||
scale, so the tier takes 1.25/600 over 1.125/400, the same relationship at sizes the
|
||
scale has. */
|
||
.choice .name { font-weight: 600; font-size: 1.125rem; }
|
||
/* On the scale: 1.0625rem stood on no step of it, and the badge-art rule below already
|
||
renders this pair one step up. */
|
||
.choice .price { font-size: 1rem; font-weight: 400; margin-top: 6px; }
|
||
/* On a duration the price is the emphasis, so the term steps back to the normal weight:
|
||
both at 600 is two shouts where the size alone says which is which. */
|
||
.choice.term .name { font-weight: 500; }
|
||
.choice.term .price { font-size: 1.375rem; font-weight: 600; }
|
||
|
||
/* The undiscounted total sits above the charged one at every width, not beside it: three
|
||
cards across a 350px column leave 98px inside each, and two figures a step and a half
|
||
apart do not share that on one line. `.875rem` rather than the `.8em` it carried, which
|
||
computed to 17.6px and moved with whatever `.price` it landed in. */
|
||
.choice .price .was {
|
||
display: block;
|
||
color: var(--ink-soft);
|
||
font-weight: 500;
|
||
font-size: .875rem;
|
||
}
|
||
.choice .feature { color: var(--ink-soft); font-size: .875rem; font-weight: 500; }
|
||
|
||
/* the tier list's cards carry the badge itself, 300×399 in its own box, drawn at 65×86. */
|
||
.badge-art { display: block; width: 65px; aspect-ratio: 300 / 399; margin: 0 auto 8px; }
|
||
/* A tier with artwork takes one step up on both lines. The weight does not move
|
||
with the size: the price is stated plainly whether or not the card has a
|
||
badge on it, and the 500 that used to live here made the same figure read at
|
||
a different weight on a tier this build has no artwork for. */
|
||
.choice .badge-art + .name { font-size: 1.25rem; }
|
||
.choice .badge-art ~ .price { font-size: 1.125rem; margin-top: 2px; }
|
||
.choice .badge-art ~ .feature { margin-top: 6px; }
|
||
.choice .badge-art ~ .feature + .feature { margin-top: 0; }
|
||
|
||
/* the duration list's saving, a pill in the settled palette. */
|
||
.pill {
|
||
display: inline-block;
|
||
margin-top: 10px;
|
||
padding: 2px 10px;
|
||
border-radius: var(--r-pill);
|
||
background: var(--ok-bg);
|
||
color: var(--ok-fg);
|
||
font-size: .75rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* `max-width`, never `width` or `height`: a phone is the same layout at a smaller scale,
|
||
and `aspect-ratio` above keeps a scaled box in proportion. */
|
||
@media (max-width: 560px) {
|
||
.badge-art { max-width: 56px; }
|
||
}
|
||
|
||
/* the order summary's method row: the same three-across grid, with a 30px mark sitting almost
|
||
on its label, the line box's own half-leading being most of the gap. */
|
||
.choice.method { padding: 18px 6px 19px; }
|
||
.mark { display: block; width: 30px; height: 30px; margin: 0 auto 2px; }
|
||
/* No size of its own: a method is a card that leads with its name, which is
|
||
what `.choice .name` above already says. The rule that restated 1.125rem here
|
||
was a second copy of one number, and the phone band still steps it down. */
|
||
.choice.method[aria-pressed="true"] .name { color: var(--accent); }
|
||
.choice.method .feature { margin-top: 2px; }
|
||
|
||
/* ---------------------------------------------------------- the summary card */
|
||
|
||
/* the order summary: one bordered card of label/value rows, a hairline, and the total. The
|
||
card's padding and a row's own are 12 and 8, top and bottom alike, so the first label and
|
||
the total stand the same 20 from the card's edges. */
|
||
.rows {
|
||
margin: 37px 0 0;
|
||
padding: 12px 24px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--r-card);
|
||
background: var(--surface);
|
||
text-align: left;
|
||
}
|
||
.row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
font-size: 1rem;
|
||
padding: 8px 0;
|
||
}
|
||
.row span:first-child { color: var(--ink-soft); font-weight: 500; }
|
||
.row span:last-child { font-weight: 600; color: var(--ink); }
|
||
/* the hairline sits 16 under the last row's text and 14 over the total's taller line, which
|
||
is the same clearance to the glyphs on both sides */
|
||
.row.total {
|
||
font-size: 1.25rem;
|
||
border-top: 1px solid var(--line);
|
||
margin-top: 8px;
|
||
padding-top: 14px;
|
||
}
|
||
.row.total span:first-child { color: var(--ink); font-weight: 600; }
|
||
/* the checkout summary's saving: the reduction is a credit, so its figure takes the settled
|
||
green the duration list's saving pill carries. */
|
||
.row.discount span:last-child { color: var(--ok-fg); }
|
||
|
||
/* A bare field (the reference, the address, where to redeem) is the same card
|
||
with the label above the value, and follows a notice by 16 rather than the 37
|
||
a summary card takes under a heading. */
|
||
.rows.field {
|
||
margin-top: 16px;
|
||
padding: 12px 21px 5px;
|
||
}
|
||
/* the payment screen's Copy sits on the field's own top-right corner. The value is an address
|
||
that wraps, so the room is reserved for the control instead of the two being
|
||
allowed to overlap. */
|
||
.rows.field.copyable { position: relative; }
|
||
.rows.field.copyable .mono { padding-right: 68px; }
|
||
.copy-line { position: absolute; top: 26px; right: 21px; margin: 0; }
|
||
.rows.plain { background: none; border: 0; padding: 0; }
|
||
/* One step below the body size, so "Settings → Supporter perks → Redeem code"
|
||
holds one line in the code screen's 344 column. */
|
||
.rows.plain > div { font-size: .875rem; }
|
||
|
||
.label {
|
||
display: block;
|
||
font-size: .875rem;
|
||
font-weight: 700;
|
||
letter-spacing: .07em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-soft);
|
||
margin-bottom: 4px;
|
||
}
|
||
/* the order summary's PAY WITH stands alone above the row rather than inside a card. It is the
|
||
same label, and only its place changes, so it restates no type. It is the row's title, so it
|
||
stands 28 clear of the summary card and 10 over the row it names. */
|
||
.label.standalone { margin: 28px 0 0; text-align: left; }
|
||
.label.standalone + .methods { margin-top: 10px; }
|
||
.methods + .primary { margin-top: 16px; }
|
||
|
||
/* --------------------------------------------------------------- the notices */
|
||
|
||
/* Three grounds, one shape: a 1px border of the same family as the text, and a
|
||
1rem/600 title over .875rem lines at the page's own leading. `.info` below is
|
||
the fourth ground of the same block, and takes the same two type rules. */
|
||
.notice, .warn {
|
||
margin: 24px 0 0;
|
||
padding: 13px 23px 14px;
|
||
border-radius: var(--r-card);
|
||
text-align: left;
|
||
border: 1px solid var(--line);
|
||
background: var(--tint);
|
||
color: var(--ink);
|
||
}
|
||
/* The block title, on all four grounds. 600 and not 700: 700 is the weight of the
|
||
micro-labels, and a block title is a heading rather than a label. */
|
||
.notice .title, .warn .title, .info .title {
|
||
display: block;
|
||
font-weight: 600;
|
||
font-size: 1rem;
|
||
margin-bottom: 2px;
|
||
}
|
||
/* Block body copy, on all four grounds, at the page's own leading. The 1.35 it
|
||
used to carry was the only leading on the page that was neither the body's
|
||
1.5 nor anything else's, and it set these lines closer together than the same
|
||
size reads anywhere else. */
|
||
.notice p, .warn p, .info p { font-size: .875rem; margin: 0; }
|
||
|
||
/* the catalog-changed screen, the rate-limited screen and the payment screen's "waiting": something is pending, nothing has gone wrong. */
|
||
.notice { background: var(--warn-bg); border-color: var(--warn-line); color: var(--warn-fg); }
|
||
.notice .title, .notice p { color: var(--warn-fg); }
|
||
|
||
/* the provider-unavailable screen, the closed-window screen and the code screen's "only copy": something has failed, or is about to be lost. */
|
||
.warn { background: var(--danger-bg); border-color: var(--danger-line); color: var(--danger-fg); }
|
||
.warn .title, .warn p { color: var(--danger-fg); }
|
||
|
||
/* the landing screen's "Already bought a code?", which is neither a notice nor a warning, and
|
||
so is drawn as a card like every other card on the page. */
|
||
.info {
|
||
margin: 20px 0 0;
|
||
padding: 12px 23px 16px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--r-card);
|
||
background: var(--surface);
|
||
text-align: left;
|
||
}
|
||
/* Only what the ground changes: the type is the block rules above, so this card
|
||
cannot drift a step away from the notice it sits beside. */
|
||
.info .title { color: var(--ink); }
|
||
.info p { color: var(--ink-soft); font-weight: 500; }
|
||
|
||
/* the crowdfunding, under the main button of a screen while the round is open. No frame and no
|
||
second button: a sentence, and under it one link in the accent - "Learn more on" and the
|
||
Wefunder wordmark - the way the site's secondary links are drawn. */
|
||
/* One height for the block under the button, whatever the sentence: one line and the link where
|
||
the column is wide, two lines below 700px, three on a narrow phone, where the longest sentence
|
||
takes three, so the button above it stands at one place on every screen. */
|
||
.invest { min-height: 58px; }
|
||
@media (max-width: 700px) { .invest { min-height: 82px; } }
|
||
@media (max-width: 420px) { .invest { min-height: 106px; } }
|
||
.invest {
|
||
margin: 22px 0 0;
|
||
text-align: center;
|
||
}
|
||
/* One line where the column holds it. Where it does not, the two halves are inline blocks, so the
|
||
line breaks between them - before "and" - and never inside the first; a half too wide for a
|
||
narrow phone wraps within itself, as every lede does. */
|
||
.invest p { font-size: 1rem; margin: 0; color: var(--ink); }
|
||
.invest .half { display: inline-block; }
|
||
.invest-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
margin: 10px auto 0;
|
||
color: var(--accent);
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
white-space: nowrap;
|
||
}
|
||
.invest-link:hover { color: var(--accent-hover); }
|
||
.invest-link:active { color: var(--accent-pressed); }
|
||
.invest-link:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 4px; }
|
||
/* the wordmark, a touch taller than the text and lifted so its baseline sits with the text's */
|
||
.invest-link .wefunder { height: 22px; width: auto; display: block; transform: translateY(-2px); }
|
||
/* `.panel > button:last-child` adds 20px under a trailing button; the invest block is what
|
||
follows a button here, and the panel's own padding is the space under it. */
|
||
|
||
/* --------------------------------------------------------------- the landing screen's hero */
|
||
|
||
/* Switched with the theme rather than filtered. The URL is relative, so it
|
||
resolves inside /assets/<buildHash>/ beside this file and the worker precaches it
|
||
under the same hash as everything else. */
|
||
.hero {
|
||
/* Sized by the viewport's height rather than a fixed width, so on a short screen the picture
|
||
gives way before anything else does; the width follows from the ratio. */
|
||
height: min(337px, 36vh);
|
||
width: auto;
|
||
aspect-ratio: 460 / 578;
|
||
margin: 27px auto 0;
|
||
background-image: var(--hero);
|
||
background-size: contain;
|
||
background-position: center;
|
||
background-repeat: no-repeat;
|
||
}
|
||
@media (max-width: 560px) {
|
||
.hero { height: min(211px, 28vh); margin-top: 20px; }
|
||
}
|
||
/* On a short phone the landing does not fit under its button with the picture; the picture is the
|
||
one thing on that screen that is decoration. */
|
||
@media (max-width: 560px) and (max-height: 700px) {
|
||
.hero { display: none; }
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ the code */
|
||
|
||
/* the code screen's code: mono 24/600 in a wider frame of its own, so it reads as the thing
|
||
the whole page was for. It must wrap at its hyphens rather than overflow. */
|
||
.code {
|
||
font-family: var(--mono);
|
||
font-size: 1.375rem;
|
||
font-weight: 700;
|
||
/* Relative, so the tracking follows the size: at .5px the phone band's 16px
|
||
code was tracked a third harder than the 22px one it steps down from. */
|
||
letter-spacing: .03em;
|
||
background: var(--code-bg);
|
||
border: 2px solid var(--code-line);
|
||
color: var(--ink);
|
||
padding: 27px 16px 22px;
|
||
border-radius: var(--r-card);
|
||
margin: 36px 0 0;
|
||
text-align: center;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
/* [ Copy code ] belongs to the frame above it, so it sits 14 under it rather
|
||
than the 24 a button takes under a row of cards. */
|
||
.code + .primary { margin-top: 14px; }
|
||
|
||
.mono {
|
||
font-family: var(--mono);
|
||
font-size: 1rem;
|
||
color: var(--ink);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
/* The history list's saved code and its copy control on the same row: a value to be
|
||
copied whole, so the code is selectable text and never a button's label. */
|
||
.entry .code-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 8px;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
background: var(--surface);
|
||
}
|
||
|
||
/* A run to be read a character at a time, so it steps one below `.mono`'s body
|
||
size, the size the copy beside it is set at in both boxes. */
|
||
.entry .code-row code {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
font-size: .875rem;
|
||
color: var(--ink);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.entry .code-row .secondary {
|
||
flex: 0 0 auto;
|
||
min-height: 32px;
|
||
padding: 0 12px;
|
||
font-size: .875rem;
|
||
}
|
||
|
||
/* the payment screen's state, under both columns. It is the one thing on that screen that
|
||
moves: the address, the amount and the QR are all fixed, so without it the
|
||
page gives a buyer who has just sent money no sign it is still watching. */
|
||
.awaiting {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
margin: 18px 0 0;
|
||
color: var(--warn-fg);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.pulse {
|
||
width: 8px;
|
||
height: 8px;
|
||
flex: 0 0 auto;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
animation: pulse 1.6s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: .25; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
/* The dot carries no information the sentence beside it does not, so it simply
|
||
holds still where motion is unwelcome. */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.pulse { animation: none; opacity: .85; }
|
||
}
|
||
|
||
/* the code screen's tick: a 54px disc in the settled palette. */
|
||
.tick {
|
||
width: 54px;
|
||
height: 54px;
|
||
line-height: 54px;
|
||
margin: 8px auto 0;
|
||
border-radius: 50%;
|
||
background: var(--ok-bg);
|
||
color: var(--ok-fg);
|
||
font-size: 1.375rem;
|
||
text-align: center;
|
||
}
|
||
|
||
/* -------------------------------------------------------------- the two-column */
|
||
|
||
/* the payment screen and the code screen put the QR beside its details: 196 + 20 + 344 is the 560 column
|
||
exactly. `wrap` rather than a media query, so the phone layout is the
|
||
same rule reflowing, the QR centring itself through its auto margins the
|
||
moment the line has room to spare. */
|
||
.split { display: flex; flex-wrap: wrap; gap: 20px; margin: 18px 0 0; align-items: flex-start; }
|
||
.lede + .split { margin-top: 29px; }
|
||
/* Sized by the symbol inside it: the phone band changes `.qr`'s width alone, so
|
||
there is one number per width and no chance of the two disagreeing. */
|
||
.split > .qr-wrap { flex: 0 0 auto; max-width: 196px; margin: 0 auto; }
|
||
.split > .details { flex: 1 1 300px; min-width: 0; text-align: left; }
|
||
.split > .details > :first-child { margin-top: 0; }
|
||
/* The panels in the details column sit 14 apart, not the 24 a block gets when
|
||
it stands alone on a screen. */
|
||
.split > .details > * + * { margin-top: 14px; }
|
||
|
||
/* the two QRs, drawn by `src/qr.ts` and never fetched. The light background
|
||
is painted into the symbol itself rather than inherited: this page has a dark
|
||
theme, and a QR drawn dark-on-dark does not scan. */
|
||
.qr-wrap { margin: 24px 0 0; text-align: center; }
|
||
.qr {
|
||
display: block;
|
||
width: 196px;
|
||
max-width: 100%;
|
||
height: auto;
|
||
margin: 0 auto;
|
||
/* No padding: the symbol carries its own four-module quiet zone, and adding to
|
||
it would only shrink the modules a camera has to resolve. */
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
background: #ffffff;
|
||
}
|
||
.qr-wrap .muted { margin-top: 8px; }
|
||
/* Under the symbol, in the flow: the QR is what most people use, and this is for
|
||
the wallet on the same device. */
|
||
.qr-wrap .wallet-line { margin: 10px 0 0; }
|
||
|
||
/* ------------------------------------------------------------------ the history list's list */
|
||
|
||
/* A row is a receipt: the badge that was bought, what it cost and how it was
|
||
paid for, the state it is in, and the code where there is one. Every part
|
||
inside it is a shape that already exists: the tier list's badge art, the order summary's payment mark,
|
||
the duration list's pill, and the command box above. */
|
||
.entries { list-style: none; margin: 24px 0 0; padding: 0; text-align: left; }
|
||
.entry {
|
||
padding: 16px 20px 18px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--r-card);
|
||
background: var(--surface);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
/* the tier list's tier card laid on its side: the art beside the name rather than above
|
||
it, and at 44 rather than 65, since a row is not a card to choose from. */
|
||
.entry-head { display: flex; align-items: center; gap: 14px; }
|
||
.entry-head .badge-art { width: 44px; flex: 0 0 auto; margin: 0; }
|
||
.entry-main { flex: 1 1 auto; min-width: 0; }
|
||
/* `Legend, 12 months`: what was bought, built by one function (`orderTitle`)
|
||
and drawn on two screens: every row of this list, and the paid-no-code screen's summary. One rule
|
||
for both, because the paid-no-code screen's copy of it inherited the body and read as a caption
|
||
under a heading while the same string here was a title. Weight 600, which is
|
||
what every card title on the page is set at. */
|
||
.entry .name, .rows.field > .name { font-weight: 600; font-size: 1.125rem; color: var(--ink); }
|
||
|
||
/* Method, price and day on one line. Each is drawn only where the record holds
|
||
it, so the line has to survive holding one of the three: a flex row with a
|
||
gap, and no separators to leave dangling. */
|
||
.entry .meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 3px 14px;
|
||
margin: 5px 0 0;
|
||
color: var(--ink-soft);
|
||
font-size: .875rem;
|
||
font-weight: 500;
|
||
}
|
||
.entry .meta .method { display: inline-flex; align-items: center; gap: 6px; }
|
||
/* the order summary draws the mark at 30 above its name; here it stands beside 14px text. */
|
||
.entry .meta .mark { width: 16px; height: 16px; margin: 0; }
|
||
|
||
/* The state, and the way out of the row. It wraps rather than squeezes: the
|
||
longest of the four lines is a sentence, and on a phone it takes the width
|
||
and leaves [ Open ] below it. */
|
||
/* A row's two lines each carry a fact on the left and a control or a state on
|
||
the right. `margin-left: auto` rather than `space-between`, so the right-hand
|
||
item still sits right when a partial record left nothing on the left. */
|
||
.entry-row { display: flex; align-items: center; gap: 10px; }
|
||
.entry-row:nth-child(2) { margin-top: 5px; }
|
||
.entry-row > .status,
|
||
.entry-row > .secondary { margin-left: auto; }
|
||
/* The state is one short phrase and reads as a label, so it keeps its own line
|
||
and lets the title take the wrap. Allowed to wrap, "this invoice expired"
|
||
becomes a two-line block on a phone and squeezes the title beside it. */
|
||
.entry-row > .status { flex: 0 0 auto; white-space: nowrap; }
|
||
.entry-row > .secondary {
|
||
flex: 0 0 auto;
|
||
width: auto;
|
||
min-height: 32px;
|
||
padding: 0 14px;
|
||
font-size: .875rem;
|
||
}
|
||
.entry .meta { margin: 0; min-width: 0; }
|
||
|
||
/* the duration list's pill, in each of the three grounds. The tone names are its own:
|
||
`.warn` and `.danger` are the notice blocks, and a status that inherited
|
||
their padding and border would be a notice. */
|
||
.status {
|
||
padding: 4px 12px;
|
||
border-radius: var(--r-pill);
|
||
font-size: .75rem;
|
||
font-weight: 700;
|
||
}
|
||
.status.settled { background: var(--ok-bg); color: var(--ok-fg); }
|
||
.status.pending { background: var(--warn-bg); color: var(--warn-fg); }
|
||
.status.lost { background: var(--danger-bg); color: var(--danger-fg); }
|
||
|
||
.entry .code-row { margin-top: 12px; }
|
||
|
||
/* the one irreversible action, under the last code: set apart from the list above it, and centred
|
||
under the column like every button on the page. */
|
||
.forget-line { margin: 22px 0 6px; text-align: center; }
|
||
|
||
.card-mount { min-height: 220px; margin: 24px 0 0; text-align: left; }
|
||
.card-fields { text-align: left; }
|
||
|
||
/* ------------------------------------------------------------ the phone band */
|
||
|
||
/* The same layout at a smaller scale: the type scale steps down and no box is
|
||
re-declared. It sits at the end of the sheet because a media query adds no specificity,
|
||
so a phone rule beside its desktop rule would be beaten by any equally specific rule
|
||
further down the file. */
|
||
@media (max-width: 560px) {
|
||
.chrome-bar { padding: 8px 0; }
|
||
.brand { width: 108px; }
|
||
/* A 320px panel floating inside a 350px column has no room to float, so on a
|
||
phone it is a sheet: pinned under the navbar, across the page. */
|
||
.menu {
|
||
position: fixed;
|
||
top: 60px;
|
||
left: 12px;
|
||
right: 12px;
|
||
width: auto;
|
||
}
|
||
.panel { padding-top: 8px; }
|
||
.back { font-size: .875rem; top: 8px; }
|
||
h1 { font-size: 1.875rem; line-height: 2.5rem; margin-top: 32px; }
|
||
h1.tight { font-size: 1.625rem; line-height: 2.125rem; margin-top: 34px; }
|
||
.lede { font-size: 1rem; }
|
||
h1 + .lede { margin-top: 9px; }
|
||
.notes .half { display: block; }
|
||
/* 16 keeps all 27 characters of the code on one line in a 350 column. */
|
||
.code { font-size: 1rem; padding: 18px 12px; }
|
||
.tick { width: 44px; height: 44px; line-height: 44px; font-size: 1.25rem; }
|
||
.qr { width: 170px; }
|
||
.split > .qr-wrap { max-width: 170px; }
|
||
.choices { gap: 10px; margin-top: 24px; }
|
||
.choice { padding: 14px 6px 16px; }
|
||
.choice .name { font-size: .875rem; }
|
||
.choice .badge-art + .name { font-size: 1rem; }
|
||
.choice .badge-art ~ .price { font-size: .875rem; }
|
||
|
||
/* `.choice .price` is not the selector to step here: `.choice.term .price`
|
||
and `.choice .badge-art ~ .price` both out-specify it, so the rule that
|
||
stood here reached neither screen and the duration list's price stayed at its 22px in a
|
||
110px card, where the struck total beside it ran off the card. A media
|
||
query adds no specificity, so it has to name the same selector the desktop
|
||
rule does. */
|
||
.choice.term .price { font-size: 1.25rem; }
|
||
|
||
.row { font-size: .875rem; }
|
||
.row.total { font-size: 1.125rem; }
|
||
|
||
/* the history list's rows: the type scale steps down, and the art keeps the 44 it has at
|
||
every width. The paid-no-code screen's summary carries the same title and steps with it. */
|
||
.entry { padding: 14px 16px 16px; }
|
||
.entry .name, .rows.field > .name { font-size: 1rem; }
|
||
}
|