Files
simplex-chat/apps/simplex-badge-service/web/public/styles.css
T
19e70faeec badges: webapp feature branch (#7548)
* 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>
2026-09-25 09:01:51 +00:00

1253 lines
51 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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; }
}