﻿/* Walupi app styles, redesign 2026: dense storefront + escrow portal.
   Palette and rules in tokens.css. */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-padding-top: 80px;
}
body {
font-family: var(--font);
font-size: var(--text-md);
line-height: 1.55;
color: var(--ink);
background: var(--paper);
min-height: 100dvh;
display: flex;
flex-direction: column;
overflow-x: clip;
}
main {
flex: 1;
}
img,
svg {
display: block;
max-width: 100%;
}
h1,
h2,
h3 {
font-family: var(--font-display);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
}
h1 {
font-size: var(--text-2xl);
}
h2 {
font-size: var(--text-xl);
}
h3 {
font-size: var(--text-lg);
}
a {
color: var(--royal-700);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
border-radius: 4px;
}
button {
font: inherit;
}
@media (min-width: 980px) {
/* Only h1 grows on a wide screen. Body, h2 and h3 kept a second size here
   for no reason the scale recognises (15px body, and an h2 and h3 one step
   apart from their own mobile values), and a scale with a desktop variant
   per step is two scales. */
h1 {
font-size: var(--text-3xl);
}
}
.wrap {
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 0 16px;
}
.wrap-wide {
max-width: 1440px;
}
@media (min-width: 980px) {
.wrap,
.wrap-wide {
padding: 0 32px;
}
}
main.wrap {
padding-top: 20px;
/* The bottom clearance for the fixed tab bar is set once, further down,
   next to the tab bar itself. It was also set here at 120px, which nothing
   has used since the bar grew. */
}
main.full {
width: 100%;
padding: 0 0 48px;
}
@media (min-width: 980px) {
main.wrap {
padding-top: 32px;
padding-bottom: 64px;
}
}
/* Vertical rhythm. There were only two steps here (16px and 8px), so every
   gap between them got hand-written at the call site: 850 inline style
   attributes across 99 templates, 46 of them margin-top:6px. These five steps
   cover the range, and they read from the spacing tokens. */
.stack > * + * {
margin-top: var(--s-4);
}
.stack-xs > * + * {
margin-top: var(--s-1);
}
.stack-sm > * + * {
margin-top: var(--s-2);
}
.stack-md > * + * {
margin-top: var(--s-3);
}
.stack-lg > * + * {
margin-top: var(--s-6);
}
.section {
margin-top: 32px;
}
@media (min-width: 980px) {
.section {
margin-top: 56px;
}
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.row-between {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.muted {
color: var(--ink-60);
}
.small {
font-size: var(--text-sm);
}
.tiny {
font-size: var(--text-xs);
}
.site-header {
background: var(--indigo-950);
color: #fff;
position: sticky;
top: 0;
z-index: 30;
/* Installed as an app there is no browser chrome, and viewport-fit=cover in
   base.html puts page y=0 at the top of the SCREEN rather than under the
   status bar. Measured in Chromium with an iPhone 14 Pro safe area pushed in
   over CDP (Emulation.setSafeAreaInsetsOverride, inset-top 59px, which the
   page confirmed by measuring an env() probe): the wordmark rendered from
   y=8 to y=52, so the whole brand sat behind the clock and the Dynamic
   Island, on every page. Note for whoever measures this next: CDP's
   display-mode override is inert in this Chromium, neither matchMedia nor a
   @media block sees it, so the insets are the whole of the evidence and they
   are enough, because a non-zero inset IS the installed case.
   The indigo still runs up into that band, which is what the
   manifest's theme_color #2E239B already promises the system; only the
   content moves down. In a browser, and on Android where the system paints
   its own status bar above the page, the inset is 0 and nothing shifts.
   The side insets are the same phone held sideways: inset-left 59px, with
   the brand measured at x=16 and the first tab at x=4. */
padding-top: env(safe-area-inset-top, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
.site-header .bar {
display: flex;
align-items: center;
gap: 16px;
min-height: 60px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
color: #fff;
font-family: var(--font-display);
font-weight: 700;
font-size: 18px;
letter-spacing: -0.02em;
flex: none;
}
.brand:hover {
text-decoration: none;
}
.brand svg {
width: 32px;
height: 22px;
color: var(--indigo-950-soft);
}
.header-search {
flex: 1;
max-width: 640px;
display: none;
}
.header-search input {
min-height: 44px;
border-radius: 999px;
border-color: transparent;
padding: 8px 22px;
background: #fff;
}
@media (min-width: 980px) {
.site-header .bar {
min-height: 72px;
}
.header-search {
display: block;
}
}
.tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
display: flex;
background: var(--surface);
border-top: 1px solid var(--line);
/* Side insets as well as the bottom one: held sideways on a notched phone
   the leading 59px belongs to the system, and the first tab measured from
   x=4, so Home lost more than half its label to the notch. */
padding: 8px calc(4px + env(safe-area-inset-right, 0px))
  calc(8px + env(safe-area-inset-bottom)) calc(4px + env(safe-area-inset-left, 0px));
}
.tabbar a {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--ink-60);
min-height: 44px;
justify-content: center;
}
.tabbar a:hover {
text-decoration: none;
}
.tabbar a svg {
width: 21px;
height: 21px;
}
.tabbar a.active {
color: var(--royal-700);
font-weight: 700;
}
main.wrap,
main.full {
padding-bottom: 140px;
}
/* 140px is clearance for a sticky CTA bar stacked on the tab bar, and only
   four pages have one (checkout, a listing, an order, a run). Everywhere
   else it was 76px of nothing below the last card, and the footer's own
   64px top margin sat under that: a fifth of a phone screen of blank paper
   between the end of a page and its footer, on every page of the site.
   The tab bar is 61px including its safe-area inset, so 76px keeps a
   comfortable 15px under the last element. Behind @supports because a
   browser without :has() must keep the roomy value rather than let a CTA
   bar cover a button. Matched on body, not on main: three of the four
   pages that have a CTA bar render it through {% block cta %}, which base
   puts after </main>, so a main-scoped :has() would have missed them and
   tucked the bar over the page's own last button. */
@supports selector(:has(*)) {
main.wrap,
main.full {
padding-bottom: 76px;
}
body:has(.cta-bar) main.wrap,
body:has(.cta-bar) main.full {
padding-bottom: 140px;
}
}
@media (min-width: 980px) {
.tabbar {
display: none;
}
main.wrap {
padding-bottom: 64px;
}
main.full {
padding-bottom: 48px;
}
}
.stat-row {
display: grid;
/* auto-fit, not two fixed columns: tabular money in a 2-column grid clips
   against the viewport edge on a 320px phone. This was declared a second
   time 3700 lines below with exactly this fix, leaving the file with two
   answers; the wrapping one is the answer. */
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 12px 10px;
margin-top: 28px;
}
.stat {
border-left: 2px solid var(--line);
padding-left: 14px;
}
.stat .num {
font-size: 22px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--royal-900);
white-space: nowrap;
}
.stat .lbl {
font-size: 12px;
color: var(--ink-60);
margin-top: 2px;
}
@media (min-width: 980px) {
.stat-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
}
.stat .num {
font-size: 26px;
}
}
.stat-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
}
.stat-tiles .card {
padding: 14px 16px;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
padding: 16px;
/* Cards are always ink on white, even inside dark sections. */
color: var(--ink);
}
@media (min-width: 980px) {
.card {
padding: 20px;
}
}
.card-link {
display: block;
color: var(--ink);
}
.card-link:hover {
text-decoration: none;
border-color: var(--royal-500);
background: #fdfdff;
}
/* The button, rebuilt 18 Sep 2026.

   It was a rectangle at --r-control with a 1px border: the outline-secondary
   shape every framework ships, which is exactly what the owner meant by
   "bootstrap kind of buttons". The add-and-remove pass replaced 17 controls
   with .iconbtn and the answer was still "nothing changed", and a census of
   fifteen real pages said why: 258 of the app's 366 controls are .btn, and
   the two pages carrying the most of them, the buyer's Drops feed and the
   runner's hub, rendered no bespoke control at all between them.

   So: a pill, to sit in the same family as .iconbtn and .qadd, and a quiet
   face that is a tint rather than an outline. The outline was the generic
   part. A tinted secondary also survives being next to a filled primary,
   which a white-with-grey-border button never really does.

   NOTE, because it looks like it contradicts an owner decision and does not:
   the 14 Sep note that "the corners read too round" stepped the CARD and HERO
   radii down (--r-card 12, --r-hero 16), because a 24px corner on a 390px
   hero curved for most of its own height and a card corner ate into the photo
   inside it. Controls were never the complaint. --r-control still owns fields
   and inputs, where a pill would be wrong; this is the control radius only,
   and it is one line to put back. */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 22px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 14px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.btn-light {
background: #fff;
color: var(--indigo-950);
}
.btn-light:hover {
background: var(--royal-100);
}
.btn:hover {
text-decoration: none;
}
.btn-primary {
background: var(--royal-700);
color: #fff;
/* Lifts it off the card the way .qadd lifts off a photo. Small: this sits
   under long copy on most pages and a heavy shadow would shout. */
box-shadow: 0 2px 10px rgba(23, 16, 64, 0.22);
}
.btn-primary:hover {
background: var(--royal-900);
}
/* The quiet face: a tint, not an outline. */
.btn-quiet {
background: var(--royal-100);
color: var(--royal-900);
border-color: transparent;
}
.btn-quiet:hover {
background: var(--royal-200);
border-color: transparent;
}
/* A quiet button that undoes something. It was an inline
   style="color:var(--danger-700)" on two run controls, which on the new
   tinted ground would have been red text on lavender. */
.btn-quiet.is-danger {
background: var(--danger-100);
color: var(--danger-700);
}
.btn-quiet.is-danger:hover {
background: #f7ded9;
}
.btn-block {
width: 100%;
/* A full-width button has room to wrap, and .btn's nowrap made long labels
   push the page sideways at 320px (caught by scripts/layout_check.py on
   "Edit details (one change, until the first buyer pays)"). Only the block
   variant: an inline button still holds its line. */
white-space: normal;
text-wrap: balance;
padding-block: 8px;
}
.btn-lg {
min-height: 50px;
padding: 0 28px;
font-size: var(--text-md);
}
.btn[disabled] {
opacity: 0.55;
cursor: default;
}
label {
display: block;
font-size: var(--text-sm);
color: var(--ink-60);
margin-bottom: 4px;
}
/* A radio or checkbox in a row is a control beside its label, not a field:
   the blanket width:100% below stretched the three dispute-resolution radios
   to 200px each and pushed their labels to the far edge of the card. */
.choice-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
cursor: pointer;
}
.choice-row input[type="radio"],
.choice-row input[type="checkbox"] {
flex: none;
width: 20px;
height: 20px;
min-height: 0;
margin: 0;
accent-color: var(--royal-700);
}
/* The country code is furniture, not a field: one control, with a fixed plate
   on the left and the local number beside it. The focus ring belongs to the
   whole thing, so it reads as a single box rather than a label and a box. */
/* The abroad page's one action. Copy and button share a row on a desktop and
   stack on a phone, with the button full width there because it is the only
   thing on the page anyone needs to press. */
.abroad-start {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.abroad-start-copy {
flex: 1;
min-width: 240px;
}
.abroad-start .btn {
flex: none;
}
@media (max-width: 560px) {
.abroad-start .btn {
width: 100%;
}
}
.phone-field {
display: flex;
align-items: stretch;
width: 100%;
border: 1px solid var(--line);
border-radius: var(--r-control);
background: var(--surface);
overflow: hidden;
}
.phone-field:focus-within {
border-color: var(--royal-500);
box-shadow: 0 0 0 3px var(--royal-100);
}
.phone-prefix {
display: flex;
align-items: center;
gap: 7px;
flex: none;
padding: 0 10px 0 12px;
background: var(--paper);
border-right: 1px solid var(--line);
color: var(--ink-60);
font-weight: 600;
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
user-select: none;
}
.phone-flag {
display: block;
flex: none;
border-radius: 2px;
}
.phone-field input {
flex: 1;
min-width: 0;
border: 0;
border-radius: 0;
background: transparent;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
.phone-field input:focus,
.phone-field input:focus-visible {
outline: none;
box-shadow: none;
}
.field:has(.field-error) .phone-field {
border-color: var(--danger-700);
}
input,
select,
textarea {
font: inherit;
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-control);
background: var(--surface);
color: var(--ink);
}
textarea {
min-height: 88px;
resize: vertical;
}
input::placeholder,
textarea::placeholder {
color: var(--ink-40);
}
/* The six-digit code field. It is the same control on four pages (sign in,
   verify a number, reset a password, join by link) and was four copies of
   the same inline style, one of them at a size the scale does not have. */
.code-input {
letter-spacing: 0.4em;
text-align: center;
font-size: var(--text-xl);
}
input[type="checkbox"] {
width: 20px;
min-height: 20px;
accent-color: var(--royal-700);
}
.field {
margin-bottom: 16px;
}
.field-error {
color: var(--danger-700);
font-size: 13px;
margin-top: 4px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.chip-sky {
background: var(--sky-100);
color: var(--sky-700);
}
.chip-royal {
background: var(--royal-100);
color: var(--royal-900);
}
.chip-danger {
background: var(--danger-100);
color: var(--danger-700);
}
.chip-plain {
background: var(--paper);
color: var(--ink-60);
border: 1px solid var(--line);
}
.chip-active {
background: var(--indigo-950);
color: #fff;
}
.chip-accent {
background: var(--accent-100);
color: var(--accent);
}
a.chip {
text-decoration: none;
flex: none;
}
.chip-row {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
scrollbar-width: none;
}
.chip-row::-webkit-scrollbar {
display: none;
}
.badge-chip {
display: inline-block;
position: relative;
}
.badge-chip > summary {
list-style: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border-radius: 999px;
background: var(--royal-100);
color: var(--royal-900);
font-size: 12px;
font-weight: 500;
}
.badge-chip > summary::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--royal-500);
}
.badge-chip > summary::-webkit-details-marker {
display: none;
}
/* Measured at 360px and 390px: the pill is 27px tall, against the 44px floor
   in FRONTEND.md, and a badge is the trust element a buyer is most often
   asked to tap ("badges open their justification"). The pill stays 27px
   because a row of them has to read as labels beside a runner's name, not as
   a row of buttons; the thumb target is not the drawing. Same technique as
   .iconbtn and button.trail-dot, and only vertical, so two badges side by
   side cannot steal each other's taps. The popover it opens carries z-index
   40 and so paints, and receives taps, above this. */
.badge-chip > summary::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 44px;
transform: translateY(-50%);
}
.badge-chip[open] > summary {
background: var(--royal-700);
color: #fff;
}
.badge-popover {
position: absolute;
z-index: 40;
top: calc(100% + 6px);
left: 0;
min-width: 240px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
padding: var(--s-4);
/* The justification behind a trust badge. CLAUDE.md makes every badge
   tappable precisely so a buyer can read this, so it is not fine print. */
font-size: var(--text-sm);
}
.trail {
position: relative;
padding: 30px 0 8px;
}
.trail-track {
display: flex;
align-items: center;
list-style: none;
padding: 0;
}
.trail-step {
display: flex;
align-items: center;
flex: 1;
}
.trail-step:first-child {
flex: 0;
}
.trail-seg {
flex: 1;
height: 2px;
background: var(--line);
}
.trail-step.done .trail-seg {
background: var(--sky-500);
}
.trail-dot {
flex: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--surface);
border: 2px solid var(--line);
display: block;
position: relative;
padding: 0;
}
button.trail-dot {
cursor: pointer;
}
.trail-step.done .trail-dot {
background: var(--sky-500);
border-color: var(--sky-500);
}
.trail-step.current .trail-dot {
border-color: var(--royal-500);
background: var(--surface);
}
a.trail-dot:hover,
button.trail-dot:hover {
border-color: var(--sky-700);
}
.trail-snail {
position: absolute;
top: 0;
width: 34px;
height: 24px;
margin-left: -17px;
color: var(--royal-500);
transition: left 400ms ease;
}
/* The dot is 12px because the trail is a hairline; the thing a thumb has to
   hit is not. This pseudo-element gives every dot a 44px target without
   moving a pixel of the drawing. */
button.trail-dot::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
.trail-labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 12px;
color: var(--ink-40);
}
.trail-labels .done {
color: var(--sky-700);
}
.trail-labels .current {
color: var(--royal-900);
font-weight: 500;
}
.mini-trail .trail {
padding-top: 22px;
}
.mini-trail .trail-snail {
width: 26px;
height: 18px;
margin-left: -13px;
}
@media (prefers-reduced-motion: reduce) {
.trail-snail {
transition: none;
}
}
.milestone-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid var(--line);
background: none;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
}
.milestone-row:last-child {
border-bottom: 0;
}
.milestone-row:hover {
background: var(--paper);
}
.milestone-icon {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--sky-100);
color: var(--sky-700);
font-size: 13px;
}
.milestone-row.pending .milestone-icon {
background: var(--paper);
color: var(--ink-40);
border: 1px solid var(--line);
}
.capacity-label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-sm);
color: var(--ink-60);
margin-bottom: 6px;
}
.capacity-track {
height: 8px;
border-radius: 999px;
background: var(--royal-100);
overflow: hidden;
}
.capacity-fill {
height: 100%;
border-radius: 999px;
background: var(--royal-700);
}
.capacity.urgent .capacity-label {
color: var(--accent);
font-weight: 700;
}
.money {
/* Money keeps Space Grotesk after the Figtree change (owner decision, 14 Sep
   2026): --font-display is Figtree now, and --font-money is the face the
   numerals were chosen for. The fallback is deliberate rather than
   decorative: this line and the token that defines --font-money are landing
   as two commits by two sessions, and whichever arrives first must not leave
   every price on the site rendering in the body face in between. */
font-family: var(--font-money, var(--font-display));
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.price {
color: var(--price);
}
.price-old {
font-size: 12px;
color: var(--ink-40);
text-decoration: line-through;
font-weight: 400;
}
.money-lg {
font-size: var(--text-2xl);
line-height: 1.2;
}
.money-md {
font-size: var(--text-lg);
}
/* A money figure sitting in a list row: body size, so the row stays a row.
   Named rather than inlined because the customer book, the wallet history and
   the order list all want the same thing. */
.money-row {
font-size: var(--text-md);
}
.escrow-notice {
display: flex;
gap: 12px;
align-items: flex-start;
width: 100%;
text-align: left;
background: var(--royal-100);
color: var(--royal-900);
border: 0;
border-radius: var(--r-card);
padding: 14px 16px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
line-height: 1.5;
}
.escrow-notice:hover {
background: #e4dff8;
}
.escrow-notice svg {
flex: none;
width: 18px;
height: 18px;
margin-top: 1px;
}
.runner-card {
display: flex;
gap: 12px;
align-items: center;
/* Shrinkable, or it refuses to fit the row that holds it: the inner
   column already ellipsises, but a flex ITEM without this keeps its
   content width and pushes the page sideways. Found 23 Sep 2026 on
   /my/saved, 19px at 390 and 49px at 360, the moment runners had
   ratings and the compact card grew a star line. */
min-width: 0;
}
.avatar {
flex: none;
width: 46px;
height: 46px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--royal-100);
color: var(--royal-900);
font-weight: 500;
font-size: 16px;
}
.avatar-sm {
width: 32px;
height: 32px;
font-size: 12px;
}
.thumb-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.evidence-thumb {
width: 84px;
text-align: center;
font-size: var(--text-xs);
color: var(--ink-60);
}
.evidence-thumb .frame {
width: 84px;
height: 84px;
border-radius: var(--r-control);
border: 1px solid var(--line);
background: var(--paper);
overflow: hidden;
display: grid;
place-items: center;
}
.evidence-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.evidence-thumb .doc-badge {
font-weight: 700;
font-size: 15px;
letter-spacing: 0.06em;
color: var(--ink-60);
}
.evidence-thumb .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 4px;
}
.grid-cards {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.grid-listings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (min-width: 700px) {
.grid-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-listings {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 980px) {
.grid-cards {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 1060px) {
.grid-listings {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
}
@media (min-width: 1200px) {
.grid-cards {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid-listings {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (min-width: 1440px) {
.grid-cards {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.grid-listings {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.grid-cards > .card,
.grid-cards > .cell-stack > .card {
margin: 0;
display: flex;
flex-direction: column;
gap: 9px;
/* A grid item defaults to min-width:auto, so it refuses to shrink below its
   own min-content: the nowrap run title beside its chip then pushed the card
   wider than its cell and the whole document scrolled sideways on a phone.
   The title already clips with an ellipsis; this lets it.
   Still load-bearing, despite the later card-alignment work appearing to
   cover the same ground: measured by removal (Sep 2026), taking it out
   reopens an 18px sideways scroll on /runs/ at 320px, driven by the h3.
   320px is the width that catches this; a spot-check at 390 says nothing. */
min-width: 0;
}
/* Run cards keep 16px padding at every width so the -16px cover inset
   always sits flush with the card edge (the desktop 20px bump on plain
   cards would leave a 4px seam around the cover). */
.grid-cards > .card,
.grid-cards > .cell-stack > .card {
padding: 16px;
}
.layout-2col {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
/* On the mobile-first single column, lead with the side column (runner
   identity, badges, escrow reassurance) so the trust-first hierarchy the
   buyer run page mandates is not buried under the shelf and milestones. */
.layout-2col > .col-side {
order: -1;
}
@media (min-width: 980px) {
.layout-2col {
grid-template-columns: minmax(0, 1fr) 380px;
gap: 32px;
align-items: start;
}
.layout-2col > .col-side {
order: 0;
position: sticky;
top: 88px;
}
}
.layout-sidebar {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
@media (min-width: 980px) {
.layout-sidebar {
grid-template-columns: 240px minmax(0, 1fr);
gap: 32px;
align-items: start;
}
.layout-sidebar > .col-nav {
position: sticky;
top: 88px;
}
.layout-sidebar .chip-row {
display: none;
}
}
.side-nav {
display: none;
}
@media (min-width: 980px) {
.side-nav {
display: block;
}
}
.side-nav a {
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 12px;
border-radius: var(--r-control);
color: var(--ink-60);
font-size: 14px;
}
.side-nav a:hover {
background: var(--paper);
text-decoration: none;
color: var(--royal-700);
}
.side-nav a.active {
background: var(--royal-100);
color: var(--royal-900);
font-weight: 500;
}
.side-nav .count {
font-size: 12px;
color: var(--ink-40);
}
/* A card, not a flat tile (changed 14 Sep 2026). The flat-tile pattern is
   the right one for Amazon and Walmart because their product grids sit on
   WHITE: the photo's own edge is the only boundary a tile needs there.
   Walupi's grids sit on --paper, and on grey a transparent tile reads as
   loose parts, a white square with text drifting under it, especially next
   to the bordered run cards it shares a page with. Giving the tile the same
   surface and line the rest of the system uses makes it one object.
   Structure below is unchanged: photo, then price first, title, meta. */
.listing-card {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
padding: 8px;
overflow: hidden;
transition: transform var(--dur-fast) var(--ease),
  box-shadow var(--dur-fast) var(--ease),
  border-color var(--dur-fast) var(--ease);
display: flex;
flex-direction: column;
color: inherit;
/* Same reason as .grid-cards > .card: a grid item's default min-width:auto
   lets its min-content (a price line that must not wrap) push it past its
   cell, which scrolls the whole page sideways on a narrow phone. */
min-width: 0;
}
.listing-card .body {
display: flex;
flex-direction: column;
flex: 1;
}
/* Amazon-style fixed zones: photo (square), title (always two lines),
   price (one line), meta (always two lines), action. Every card in a row
   is structurally identical, so nothing floats up or leaves a hole. */
.listing-card .price-line {
margin-top: 4px;
font-size: 16px;
line-height: 1.3;
/* The line may wrap between the price and the shop price it beats; each
   amount stays whole (below). A nowrap line could not fit both on a 320px
   phone two cards wide, and pushed the page into sideways scroll. The zone
   reserves both lines at every width, so a card that wraps and one that
   does not still line up, the way the title and meta zones already do. */
white-space: normal;
min-height: 2.6em;
}
.listing-card .price-line > * {
white-space: nowrap;
}
.listing-card .meta-line {
font-size: var(--text-xs);
line-height: 1.45;
min-height: 2.9em;
margin-top: 1px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.listing-card .body > form {
margin-top: auto;
padding-top: 8px;
}
.listing-card:hover {
text-decoration: none;
}
/* Pointer devices only. On a touch screen :hover sticks after a tap, which
   would leave a lifted card behind every time a buyer goes back to the grid. */
@media (hover: hover) {
.listing-card:hover {
border-color: var(--royal-200);
box-shadow: var(--shadow-lift);
transform: translateY(-2px);
}
.listing-card:hover .photo img {
transform: scale(1.04);
}
}
.listing-card:active {
transform: scale(0.988);
}
/* Fixed square media box, Amazon-style: the whole product, contained and
   centered, whatever the file's own shape. The absolute img is what pins
   the box height even for portrait photos inside a flex column. */
.listing-card .photo {
position: relative;
flex: none;
display: block;
aspect-ratio: 1 / 1;
background: var(--line-soft);
/* No border of its own any more: the card around it supplies the edge, and
   two nested 1px lines 8px apart read as a mistake.

   Truly concentric, not merely smaller: an inner corner sits inside an outer
   one when its radius is the outer radius MINUS the gap between them, here
   --r-card (14) less the card's 8px padding. At 12px against an 18px card it
   was close enough to pass; when the card softened to 14px the two corners
   were 2px apart and the nesting read as a wobble. */
border-radius: calc(var(--r-card) - 8px);
/* A studio cutout is the commonest product photo there is, and on a white
   card a white-background photo had no edge at all: the goods floated in the
   card with nothing to say where the picture stopped. Found by rendering a
   pure-white photo rather than by reading this rule, which said the card
   supplies the edge and was right about the card and wrong about the photo.

   Deliberately faint rather than --line: over a dark or colourful photo it is
   invisible, so it never becomes the second nested hairline the comment above
   is about. It only appears when the photo needs it.

   On ::after rather than as an inset box-shadow on the box itself, because
   the img is absolutely positioned across the whole box and paints over an
   inset shadow. Measured, not assumed: the first attempt moved the white edge
   pixel from 255 to 254, which is nothing, and the screenshot looked the same
   either way. */
overflow: hidden;
}
.listing-card .photo::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 1px rgba(22, 21, 31, 0.06);
pointer-events: none;
}
.listing-card .photo img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur-med) var(--ease);
}
.listing-card .body {
padding: 10px 4px 2px;
}
.quick-add {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
}
/* Exactly two lines reserved: a one-line title occupies the same height
   as a clamped two-line one, so price rows align across a card row. */
.listing-card .title {
/* The title is its own <a> since the card grew a button (a form cannot
   live inside a link), so it would otherwise take the global link
   colour: a grid of royal purple titles, each one shouting as loud as
   the Buy now button under it. Royal is for the action. */
color: inherit;
text-decoration: none;
font-size: var(--text-sm);
line-height: 1.35;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 2.7em;
}
.listing-card a.title:hover,
.listing-card a.title:focus-visible {
text-decoration: underline;
}
.listing-card .money {
font-size: var(--text-md);
color: var(--price);
}
.gallery-thumbs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.gallery-thumb {
width: 64px;
height: 64px;
padding: 0;
border: 2px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: #fff;
cursor: pointer;
}
.gallery-thumb img {
width: 100%;
height: 100%;
/* contain, not cover: the strip exists to tell photos apart, and a square
   crop of a tall photo is the middle of it, which tells you nothing. */
object-fit: contain;
background: var(--paper);
}
.gallery-thumb.active {
border-color: var(--royal-700);
}
/* The cover box. Product photos arrive in every shape a phone and a supplier
   catalogue produce: a 9:16 portrait off a runner's camera, a square
   marketplace crop, a 21:9 banner lifted from a manufacturer's page. A fixed
   4/3 box with object-fit:cover cut every one of them to fit, and what it cut
   was the product: a tall bottle lost its neck and its base, a wide banner
   lost both ends, and the buyer was asked to trust a photo of the middle.
   So the photo is CONTAINED and never cropped, and the space left over is
   filled with a blurred, magnified copy of the same file. The browser already
   has that file, so the fill costs no request, and the box keeps one shape all
   the way down the page whatever shape lands in it. */
.listing-hero {
position: relative;
aspect-ratio: 4 / 3;
border-radius: var(--r-card);
border: 1px solid var(--line);
background: var(--paper);
overflow: hidden;
}
.listing-hero .gallery-zoom {
position: absolute;
inset: 0;
}
/* Both layers are pinned to the box rather than sized in percentages. A
   percentage height needs a containing block whose height is already known,
   and this one's comes from aspect-ratio: Chromium resolved height:100% to
   auto and handed a 900x1600 photo its own intrinsic height, 1425px inside a
   603px box, so object-fit had nothing left to letterbox and the crop was
   back. inset:0 makes the box definite without asking. */
.listing-hero img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Two classes, so this beats .listing-hero img above it. */
.listing-hero .hero-blur {
object-fit: cover;
/* Scaled past the blur radius: an unscaled copy fades to the box colour at
   the edges and leaves a pale halo exactly where the fill is meant to be. */
transform: scale(1.2);
filter: blur(26px) saturate(1.4) brightness(0.96);
opacity: 0.55;
pointer-events: none;
}
/* A portrait photo in a landscape box is mostly fill. On a phone, where
   portrait photos are the common case and width is the scarce thing, the box
   turns square and gives the product back that room. Set in CSS, not from the
   loaded image, so the page never reflows under the reader's thumb. */
@media (max-width: 640px) {
.listing-hero {
aspect-ratio: 1 / 1;
}
}
.page-head {
padding: 4px 0 8px;
}
.page-head .crumbs {
font-size: 12px;
color: var(--ink-40);
margin-bottom: 8px;
}
.page-head .crumbs a {
color: var(--ink-60);
}
.page-head .sub {
color: var(--ink-60);
margin-top: 6px;
max-width: 60ch;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
/* Row gap first: it only ever applies once the trailing item has wrapped. */
gap: 6px 12px;
/* The heading owns this row; the link or hint beside it is the passenger.
   Without wrapping, flex shrank both until neither worked: "Where this run
   is" became a 73px column of three single words so that a 239px sentence
   could sit beside it on a 390px phone, and section links broke into ragged
   two-line stubs ("All open / runs"). The trailing item now keeps its own
   width and drops to its own row when the two cannot share one, which is
   what a reader would do with a pair of scissors. */
flex-wrap: wrap;
margin-bottom: 16px;
}
.section-head > :first-child {
/* Shrinkable, so a long heading still wraps its own text rather than
   pushing the row wider than the page. The basis is 60 percent rather than
   auto (23 Sep 2026): at auto the heading block asked for its eyebrow's
   full natural width, so "Invitation only, verified by Ghana Card" pushed
   an 88px "All open runs" onto a row of its own at 390px, right-aligned
   under nothing. At 60 percent a short link keeps the heading's row and the
   eyebrow wraps instead, while a sentence-length hint (wider than the 40
   percent left) still drops to its own row. */
flex: 1 1 60%;
min-width: 0;
}
.section-head > :first-child ~ * {
/* Not shrinkable: this is what makes the row wrap instead of squeezing. */
flex: 0 0 auto;
max-width: 100%;
margin-left: auto;
}
.section-head h2 {
color: var(--ink);
}
.tabs {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--line);
/* Was 999px. A 40px-tall strip at full pill is a lozenge, not a control. */
border-radius: calc(var(--r-control) + 2px);
padding: 3px;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs a {
display: inline-flex;
align-items: center;
justify-content: center;
/* 44px is the FRONTEND.md touch floor and this strip is the primary filter
   on /runs/, /catalogue/ and the order lists: the control a thumb reaches
   for first on every list page. It measured 36px, so it was the one place
   the floor was missed on a control that matters. Relaxed back to the
   padded height on a pointer device below, the same bargain .ap-row makes:
   44px is what a thumb needs, not what a mouse needs. */
min-height: 44px;
padding: 7px 16px;
/* Sits inside .tabs' 3px padding, so two less than its parent. */
border-radius: var(--r-control);
font-size: var(--text-sm);
font-weight: 500;
color: var(--ink-60);
white-space: nowrap;
}
@media (hover: hover) and (pointer: fine) {
.tabs a {
min-height: 0;
}
}
.tabs a:hover {
text-decoration: none;
color: var(--royal-700);
}
.tabs a.active {
background: var(--royal-700);
color: #fff;
}
.steps {
display: grid;
gap: 16px;
}
@media (min-width: 720px) {
.steps {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.step-num {
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--royal-100);
color: var(--royal-900);
font-weight: 500;
font-size: var(--text-sm);
margin-bottom: 10px;
}
.steps .card h3 + p {
margin-top: 6px;
}
/* Buying-from-abroad: a slim indigo promise band above the standard light
   step cards. The band states the one idea; the cards carry the detail on
   paper, where three columns of body copy stay readable. */
.abroad-band {
padding: 22px 22px 24px;
}
.abroad-promise {
margin: 10px 0 0;
color: #fff;
font-size: var(--text-xl);
line-height: 1.25;
letter-spacing: -0.01em;
max-width: 24ch;
}
.abroad-note {
margin: 8px 0 0;
color: var(--indigo-950-muted);
font-size: 14px;
max-width: 60ch;
}
@media (min-width: 980px) {
.abroad-band {
padding: 28px 32px 30px;
}
.abroad-promise {
font-size: 28px;
}
}
/* The department row: round photo tiles in one swipeable line, sitting
   right under the hero the way the big shops put departments first. The
   same component serves home and catalogue (active state on catalogue). */
.cat-circles {
display: flex;
gap: 14px;
overflow-x: auto;
/* The first label was clipping at the viewport edge ("lectronics"): a
   horizontal rail needs its own leading and trailing room, because the
   page gutter stops at the scroll container. */
padding: 4px 16px 8px;
margin-inline: -16px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.cat-circles::-webkit-scrollbar {
display: none;
}
/* On the catalogue the row is the department nav between header and grid. */
.page-head + .cat-circles {
margin-bottom: 14px;
}
/* ...until the sidebar appears at 980px and lists the same eleven
   departments underneath it, with counts. Two identical navigations stacked
   one above the other is the sidebar layout's own rule, which already hides
   .chip-row for exactly this reason; the circles were simply outside
   .layout-sidebar and escaped it. Below 980px the sidebar is display:none
   and the circles are the only department nav there is, so they stay. */
@media (min-width: 980px) {
.page-head + .cat-circles {
display: none;
}
}
.cat-circle {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
width: 76px;
color: var(--ink);
}
/* Neutral by default, royal only when active (14 Sep 2026). Eleven
   identical royal-100 discs with royal-700 glyphs was the largest single
   block of purple on the storefront, and it spent the brand colour on a
   navigation rail where it signalled nothing: the active department looked
   like the other ten. Neutral discs give the active state something to be
   different FROM, so the same colour now does twice the work. */
.cat-circle .disc {
width: 62px;
height: 62px;
border-radius: 50%;
overflow: hidden;
background: var(--line-soft);
border: 2px solid transparent;
display: flex;
align-items: center;
justify-content: center;
/* transition is set below, with the :active and :focus-visible rules it
   animates. It was also set here, without the transform, and silently
   overridden. */
}
.cat-circle .disc img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cat-circle .disc svg {
width: 28px;
height: 28px;
color: var(--ink-60);
}
.cat-circle .name {
font-size: var(--text-xs);
font-weight: 600;
line-height: 1.25;
text-align: center;
max-width: 76px;
}
.cat-circle:hover {
text-decoration: none;
}
.cat-circle:hover .disc {
box-shadow: var(--shadow-pop);
}
.cat-circle.active .disc {
background: var(--royal-100);
border-color: var(--royal-700);
}
.cat-circle.active .disc svg {
color: var(--royal-700);
}
.cat-circle.active .name {
color: var(--royal-700);
}
.cat-circle:focus-visible {
outline: none;
}
.cat-circle:focus-visible .disc {
box-shadow: 0 0 0 3px var(--royal-500);
}
.cat-circle:active .disc {
transform: scale(0.94);
}
.cat-circle .disc {
transition: box-shadow 150ms ease, transform 100ms ease;
scroll-snap-align: start;
}
.cat-circles {
scroll-snap-type: x proximity;
scroll-padding-left: 16px;
}
@media (min-width: 980px) {
.cat-circles {
gap: 20px;
}
.cat-circle {
width: 92px;
}
.cat-circle .disc {
width: 76px;
height: 76px;
}
.cat-circle .name {
font-size: var(--text-xs);
max-width: 92px;
}
.cat-circle .disc svg {
width: 34px;
height: 34px;
}
}

.list-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--line);
}
.list-row:last-child {
border-bottom: 0;
}
.tap-row {
width: 100%;
background: none;
border: 0;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
}
/* A card that lists places to go.

   The pattern this replaces was a stack of bare <a class="small"> links:
   13px of underlined-on-hover text, no target, no affordance, and on the
   runner hub nine of them wrapped into a single flex row, so the runner's
   whole map of their own business (storefront, shelf, requests, drops,
   customers, handover spots, payouts, reputation, invites) read as a word
   cloud rather than a menu. Four such stacks existed, in three templates.

   Same job as .ap-row in the account popover, so it keeps that row's
   bargain: the FRONTEND.md 44px floor for a thumb, the drawn height for a
   pointer. The chevron is the affordance the bare links never had. */
.linkrows {
display: flex;
flex-direction: column;
}
.linkrows > a {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
padding: 8px 0;
border-bottom: 1px solid var(--line-soft);
color: var(--ink);
font-size: var(--text-sm);
font-weight: 600;
}
.linkrows > a:last-child {
border-bottom: 0;
}
.linkrows > a::after {
content: "";
flex: none;
width: 7px;
height: 7px;
margin-right: 3px;
border-right: 2px solid var(--ink-40);
border-bottom: 2px solid var(--ink-40);
transform: rotate(-45deg);
transition: transform var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.linkrows > a:hover,
.linkrows > a:focus-visible {
color: var(--royal-700);
text-decoration: none;
}
.linkrows > a:hover::after,
.linkrows > a:focus-visible::after {
border-color: var(--royal-700);
transform: rotate(-45deg) translate(2px, 2px);
}
@media (prefers-reduced-motion: reduce) {
.linkrows > a::after,
.linkrows > a:hover::after,
.linkrows > a:focus-visible::after {
transition: none;
transform: rotate(-45deg);
}
}
@media (hover: hover) and (pointer: fine) {
.linkrows > a {
min-height: 36px;
padding: 5px 0;
}
}
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.table th {
text-align: left;
font-weight: 500;
color: var(--ink-60);
font-size: var(--text-xs);
padding: 8px 12px;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.table td {
padding: 12px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: 0;
}
.table tr:hover td {
background: var(--paper);
}
.table .num {
text-align: right;
}
.notice {
border-radius: var(--r-card);
padding: var(--s-3) var(--s-4);
font-size: var(--text-sm);
}
.notice-info {
background: var(--sky-100);
color: var(--sky-700);
}
.notice-success {
background: var(--royal-100);
color: var(--royal-900);
}
.notice-error {
background: var(--danger-100);
color: var(--danger-700);
}
/* The fourth variant, and it was being used before it existed: a notice
   that carries no colour meaning (an expired drop saying so). Without a
   fill it read as loose text in a gap rather than as a notice. */
.notice-quiet {
background: var(--line-soft);
color: var(--ink-60);
}
.empty-state {
text-align: center;
padding: 56px 24px;
color: var(--ink-60);
}
.empty-state svg {
width: 72px;
height: 48px;
margin: 0 auto 16px;
color: var(--royal-500);
}
#toast-region {
position: fixed;
/* 160px clears the tab bar plus a CTA bar in a browser. Installed on a
   notched phone both grow by the 34px bottom inset, which measured the
   toast landing at y=684 against a CTA bar starting at y=682: it painted
   over the very button it was reporting on. */
bottom: calc(160px + env(safe-area-inset-bottom, 0px));
left: 16px;
right: 16px;
z-index: 60;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
}
.toast {
background: var(--ink);
color: #fff;
border-radius: var(--r-control);
padding: 10px 18px;
font-size: 13px;
max-width: 440px;
}
#sheet-region:empty {
display: none;
}
/* Overlays hold the scroll. Measured at 390px with the basket drawer open:
   six scroll gestures with the finger ON the panel took the page behind it
   from y=300 to y=1500, and six more on the backdrop took it to y=2700, so
   closing the basket returned the buyer to a storefront 2400px from where
   they left it. Installed on Android the same drag at the top reaches
   pull-to-refresh, which reloads the page out from under the overlay.

   overscroll-behavior needs a scroll container to act on, which is why the
   backdrops take overflow: hidden as well: they are the element under the
   finger whenever the tap is beside the panel rather than on it. */
.sheet-backdrop {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(27, 27, 36, 0.4);
display: flex;
align-items: flex-end;
justify-content: center;
overflow: hidden;
overscroll-behavior: contain;
}
.sheet {
background: var(--surface);
border-radius: var(--r-sheet) var(--r-sheet) 0 0;
width: 100%;
max-width: 680px;
max-height: 85dvh;
overflow-y: auto;
overscroll-behavior: contain;
padding: 18px 20px calc(28px + env(safe-area-inset-bottom));
}
@media (min-width: 980px) {
.sheet-backdrop {
align-items: center;
padding: 24px;
}
.sheet {
border-radius: var(--r-sheet);
max-width: 520px;
}
}
.sheet-handle {
width: 36px;
height: 4px;
border-radius: 999px;
background: var(--line);
margin: 0 auto 12px;
}
.cta-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
background: var(--surface);
border-top: 1px solid var(--line);
box-shadow: var(--shadow-cta);
/* Side insets for the same reason as the tab bar: this is the one button the
   page exists for, so none of it may sit under a landscape notch. */
padding: 12px calc(16px + env(safe-area-inset-right, 0px))
  calc(12px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left, 0px));
}
/* On mobile the tab bar owns the bottom edge; the CTA bar sits above it. */
@media (max-width: 979.98px) {
.cta-bar {
bottom: calc(60px + env(safe-area-inset-bottom));
padding-bottom: 12px;
}
}
.cta-bar .inner {
max-width: 680px;
margin: 0 auto;
}
.cta-bar .micro {
text-align: center;
font-size: 12px;
color: var(--ink-60);
margin-top: 6px;
}
@media (min-width: 980px) {
.cta-bar.mobile-only {
display: none;
}
}
/* On mobile the sticky CTA bar is the single checkout action; the basket
   rail keeps its own button on desktop only. */
@media (max-width: 979.98px) {
.rail-checkout {
display: none;
}
}
.site-footer {
border-top: 1px solid var(--line);
margin-top: 64px;
background: var(--surface);
}
.footer-grid {
display: grid;
gap: 28px;
padding: 36px 0;
/* Four link groups in one column ran the footer to 809px on a phone: on a
   short page (an empty Drops feed, a settled run) the footer was two thirds
   of the document, and a buyer thumbing to the bottom scrolled through a
   column of eleven links to reach the fine print. Two columns halve it and
   the longest label ("Buying from abroad") still fits a 171px column at
   320px. The brand paragraph keeps the full width above them. */
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 28px 20px;
}
.footer-brand {
grid-column: 1 / -1;
}
@media (min-width: 720px) {
.footer-grid {
grid-template-columns: minmax(0, 2fr) repeat(3, minmax(0, 1fr));
gap: 28px;
padding: 48px 0;
}
.footer-brand {
grid-column: auto;
}
}
.footer-brand svg {
width: 40px;
height: 28px;
color: var(--royal-500);
margin-bottom: 10px;
}
.footer-brand p {
font-size: var(--text-sm);
color: var(--ink-60);
max-width: 32ch;
}
.footer-col h4 {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-40);
margin-bottom: 10px;
}
.footer-col a {
display: flex;
align-items: center;
min-height: 24px;
padding: 4px 0;
font-size: var(--text-sm);
color: var(--ink-60);
}
.footer-col a:hover {
color: var(--royal-700);
}
.footer-base {
border-top: 1px solid var(--line);
padding: 16px 0;
font-size: 12px;
color: var(--ink-40);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.htmx-swapping {
opacity: 0;
transition: opacity 150ms ease-out;
}
.htmx-added {
opacity: 0;
}
.htmx-settling {
opacity: 1;
transition: opacity 150ms ease-in;
}
@media (prefers-reduced-motion: reduce) {
.htmx-swapping,
.htmx-settling,
.htmx-added {
transition: none;
opacity: 1;
}
}
.queue-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 0;
font-size: 13px;
border-bottom: 1px solid var(--line);
}
.queue-item:last-child {
border-bottom: 0;
}
.btn-ghost {
background: transparent;
color: #fff;
border-color: rgba(255, 255, 255, 0.5);
}
.btn-ghost:hover {
border-color: #fff;
}
.auth-shell {
max-width: 920px;
margin: 24px auto 0;
}
.auth-split {
display: grid;
grid-template-columns: 1fr;
border: 1px solid var(--line);
border-radius: var(--r-sheet);
overflow: hidden;
background: var(--surface);
}
.auth-form {
padding: 28px 24px;
}
/* Storefront-style brand panel beside the form: the same indigo trust
   surface as the home hero, so signing in feels like the shop, not a plain
   form. Hidden on mobile (the form leads on a phone). */
.auth-brand {
display: none;
position: relative;
padding: 44px 40px;
background: linear-gradient(150deg, var(--indigo-950), var(--royal-900));
color: #fff;
flex-direction: column;
gap: 22px;
}
.auth-brand h2 {
font-family: var(--font-display);
font-size: 26px;
line-height: 1.15;
margin: 0;
color: #fff;
}
.auth-brand .auth-brand-sub {
font-size: 14px;
color: var(--indigo-950-muted);
margin: 8px 0 0;
line-height: 1.5;
}
.auth-brand .held-badge {
align-self: flex-start;
}
.auth-brand ul {
list-style: none;
margin: auto 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.auth-brand li {
display: flex;
gap: 11px;
align-items: flex-start;
font-size: var(--text-sm);
color: #fff;
}
.auth-brand li svg {
flex: none;
width: 20px;
height: 20px;
color: var(--indigo-950-soft);
margin-top: 1px;
}
.auth-form {
padding: 28px 24px;
}
@media (min-width: 980px) {
.auth-split {
grid-template-columns: 5fr 6fr;
min-height: 540px;
}
.auth-brand {
display: flex;
}
.auth-form {
padding: 48px 52px;
}
}
.photo-banner {
width: 100%;
aspect-ratio: 21 / 8;
object-fit: cover;
border-radius: var(--r-card);
border: 1px solid var(--line);
}
/* Inline-SVG banners (the escrow-journey scene) share the frame but need
   block flow and a paint under the gradient while fonts load. */
svg.photo-banner {
display: block;
background: var(--indigo-950);
}
/* The hidden attribute must always win, even over classes that set their own
   display (flex rows, block images): without this, JS-toggled elements leak
   into view as empty boxes. */
[hidden] {
display: none !important;
}
/* Cover-photo dropzone on the run form: tap or drop, preview in place at the
   same 21/8 the run page paints, so the runner sees the real crop. */
.upload-zone {
position: relative;
display: block;
border: 2px dashed var(--line);
border-radius: var(--r-card);
background: var(--paper);
text-align: center;
cursor: pointer;
overflow: hidden;
transition: border-color 0.15s;
}
.upload-zone:hover,
.upload-zone.dragging,
.upload-zone:focus-within {
border-color: var(--royal-500);
}
.upload-zone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.upload-zone .uz-idle {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 26px 16px;
color: var(--ink-60);
}
.upload-zone .uz-idle svg {
width: 30px;
height: 30px;
color: var(--royal-700);
margin-bottom: 2px;
}
.upload-zone .uz-idle strong {
font-size: 14px;
color: var(--ink);
}
.upload-zone img {
display: block;
width: 100%;
aspect-ratio: 21 / 8;
object-fit: cover;
}
.upload-zone.has-photo {
border-style: solid;
}
.upload-zone-slim .uz-idle {
padding: 16px;
}
/* Listing photo grid: existing and freshly chosen photos share one strip at
   the 4:3 crop buyers see; the first standing photo wears the Cover chip. */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.photo-grid:empty {
display: none;
}
.pg-thumb {
position: relative;
border-radius: var(--r-control);
overflow: hidden;
border: 1px solid var(--line);
}
.pg-thumb img {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
/* contain, to match what the buyer gets: the listing hero no longer crops,
   so a picker that cropped would be showing the runner a photo nobody sees. */
object-fit: contain;
background: var(--paper);
}
.pg-thumb.marked {
opacity: 0.45;
border-color: var(--danger-700);
}
/* The per-photo remove, sitting on top of the photo it removes. A flat dark
   circle on a dark photo is a circle nobody finds, so it carries a white ring
   of its own; and it tapped at 24px, well under the floor, which on a phone
   is how a runner deletes the photo next to the one they meant. */
.pg-x {
position: absolute;
top: 6px;
right: 6px;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 0;
border-radius: 50%;
background: rgba(22, 21, 31, 0.68);
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.92), 0 2px 6px rgba(22, 21, 31, 0.35);
color: #fff;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease),
  transform var(--dur-press) var(--ease);
}
/* 26 + 9 + 9 = the 44px touch floor, without a 44px disc covering the photo. */
.pg-x::after {
content: "";
position: absolute;
inset: -9px;
}
.pg-x:active {
transform: scale(0.92);
}
@media (hover: hover) {
.pg-x:hover {
  background: var(--danger-700);
}
}
/* Already marked for removal: the same control now puts it back, so it stops
   being a warning and becomes the way out of one. */
.pg-thumb.marked .pg-x {
background: var(--royal-700);
}
@media (hover: hover) {
.pg-thumb.marked .pg-x:hover {
  background: var(--royal-900);
}
}
@media (prefers-reduced-motion: reduce) {
.pg-x {
  transition: none;
}
.pg-x:active {
  transform: none;
}
}
/* Slim cover strip at the top of runner-hub run cards. */
.card-cover-strip {
display: block;
width: 100%;
aspect-ratio: 21 / 7;
object-fit: cover;
border-radius: var(--r-control);
margin-bottom: 10px;
border: 1px solid var(--line);
}
/* A run with no cover photo keeps the media block, so titles across a row of
   hub cards start at the same height; the route strip stands in for the
   photograph. Same bargain as .run-cover-blank on the buyer's run card.

   The blank is always rendered and the photo, when there is one, follows it
   and hides it. That way a cover whose FILE has gone missing needs no second
   code path: onerror removes the img, :has stops matching, and the card
   falls back to the genuine no-cover state rather than to a gap. */
.card-cover-blank {
display: flex;
align-items: center;
padding: 0 12px;
background: var(--royal-100);
border-color: var(--royal-200);
}
.card-cover-blank:has(+ .card-cover-photo) {
display: none;
}
.card-cover-blank .route {
width: 100%;
background: transparent;
padding: 0;
/* The shared .route pins its two ends at flex:none, which is right where it
   has a full card width to sit in. Inside a hub card on a 320px phone there
   are only 232px for it, and the destination end pushed 3px past the page.
   Scoped to this strip so the route keeps its roomy shape everywhere else. */
gap: var(--s-2);
}
.card-cover-blank .route .end {
flex: 0 1 auto;
overflow: hidden;
}
.card-cover-blank .route .city {
max-width: 64px;
}
.pg-cover {
position: absolute;
left: 6px;
bottom: 6px;
background: var(--royal-700);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
}
/* What the picker found before anything uploaded (static/js/photo_check.js).
   A refusal means the file was kept out of the form; a warning means it went
   in and may still be refused when the form is saved. Two different facts, so
   two colours: danger for the refusal, the accent (urgency) for the warning,
   which is exactly what each token is for. */
.photo-problems {
display: grid;
gap: 6px;
margin-top: 8px;
}
.pp-row {
font-size: var(--text-xs);
line-height: 1.45;
padding: 8px 10px;
border-radius: var(--r-control);
border: 1px solid;
}
.pp-row.pp-refuse {
color: var(--danger-700);
border-color: var(--danger-700);
background: var(--danger-100);
}
.pp-row.pp-warn {
color: var(--accent);
border-color: var(--accent-line);
background: var(--accent-100);
}
/* The meta line under the cover zone is a line, not a box: colour only. */
.tiny.pp-refuse {
color: var(--danger-700);
font-weight: 600;
}
.tiny.pp-warn {
color: var(--accent);
font-weight: 600;
}
.pg-thumb.pg-soft {
border-color: var(--accent-600);
}
.pg-thumb.pg-soft::after {
content: "soft";
position: absolute;
right: 6px;
bottom: 6px;
background: var(--accent-600);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
}

/* Runner value banner: the pitch band on the runner hub and invite page.
   Same trust surface as the portal hero; royal icon chips carry the list. */
.value-banner {
background: linear-gradient(135deg, var(--indigo-950), var(--royal-900));
color: #fff;
border-radius: var(--r-hero);
padding: 22px 22px 18px;
}
.value-banner h2 {
font-family: var(--font-display);
font-size: 22px;
margin: 4px 0 14px;
color: #fff;
}
.vb-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 14px 18px;
}
.vb-item {
display: flex;
gap: 10px;
align-items: flex-start;
}
.vb-item strong {
font-size: 14px;
}
.vb-item p {
font-size: var(--text-xs);
color: var(--indigo-950-muted);
margin: 3px 0 0;
line-height: 1.45;
}
.vb-icon {
flex: none;
width: 34px;
height: 34px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
color: var(--indigo-950-soft);
display: inline-flex;
align-items: center;
justify-content: center;
}
.vb-icon svg {
width: 20px;
height: 20px;
}
.value-banner-compact {
padding: 16px;
border-radius: var(--r-card);
}
.value-banner-compact h2 {
font-size: 17px;
margin-bottom: 10px;
}
.value-banner-compact .vb-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.value-banner-compact .vb-item p {
font-size: 12px;
}
/* Staging deployments label themselves on every page: terracotta is the
   urgency colour, and a tester never mistakes this for the real thing. */
.staging-strip {
background: var(--accent);
color: #fff;
text-align: center;
font-size: var(--text-xs);
font-weight: 600;
padding: 6px 12px;
}
.route {
display: flex;
align-items: center;
gap: 14px;
background: var(--sky-100);
border-radius: var(--r-control);
padding: 12px 16px;
color: var(--sky-700);
}
.route .end {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
flex: none;
white-space: nowrap;
}
.route .code {
font-size: var(--text-lg);
font-weight: 500;
letter-spacing: 0.03em;
line-height: 1.1;
}
.route .city {
font-size: var(--text-xs);
color: var(--ink-60);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 86px;
}
/* 86px truncated "Guangzhou, China" (it needs 102) everywhere, including a
   700px hero two thirds empty: the buyer read "Guangzhou, C..." on a strip
   with room for the whole world. The cap is a phone measure, so it lifts
   once the strip is wide enough to spend. Cards keep the tight one: theirs
   is a 300px box at every width, not the page. */
@media (min-width: 560px) {
.route .city {
max-width: 200px;
}
.cover-route-panel .city {
max-width: 86px;
}
}
.route .path {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 40px;
}
.route .path .line {
flex: 1;
border-top: 2px dashed var(--sky-500);
opacity: 0.7;
}
.route .path svg {
width: 20px;
height: 20px;
flex: none;
}
.route-royal {
background: var(--royal-100);
color: var(--royal-700);
}
.route-royal .path .line {
border-color: var(--royal-500);
}
.activity-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--line);
color: inherit;
}
.activity-row:last-child {
border-bottom: 0;
}
.activity-row:hover {
text-decoration: none;
background: var(--paper);
}
.activity-dot {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--sky-100);
color: var(--sky-700);
font-size: 13px;
}
.activity-row .when {
font-size: 12px;
color: var(--ink-40);
white-space: nowrap;
}
/* .when is the timestamp slot, and the held-orders list puts money in it.
   Money is not a timestamp: "GHS 350.00" and "GHS 1,900.00" are 10px apart,
   and because the status chip sits directly before it, the chips wandered
   across a 12px range down a list a reader scans straight down. One width
   for the column puts them back in line. */
.activity-row .row-money {
min-width: 78px;
text-align: right;
}
.msg {
display: flex;
padding: 2px 0;
}
.msg.mine {
justify-content: flex-end;
}
.msg .bubble {
max-width: 78%;
background: var(--paper);
border: 1px solid var(--line);
border-radius: 14px 14px 14px 4px;
padding: 8px 12px;
font-size: 13px;
}
.msg.mine .bubble {
background: var(--royal-100);
border-color: #e0daf6;
color: var(--royal-900);
border-radius: 14px 14px 4px 14px;
}
.msg .stamp {
display: block;
font-size: var(--text-xs);
color: var(--ink-40);
margin-top: 3px;
}
/* ---- sentinel: message attachments and composer (apps/messaging) ---- */
/* A photo in a thread answers "is this the right one?" in one message. Capped
   so a tall picture cannot push the conversation off the screen. */
.msg-image {
display: block;
max-width: 100%;
max-height: 260px;
width: auto;
border-radius: 10px;
margin-bottom: 6px;
}
.msg-file {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
margin-bottom: 4px;
}
/* Said to the whole run, not to this person: worth marking, so a general
   notice is not read as a personal promise. */
.msg-tag {
/* Its own line: run alongside the words it reads as the first two of them. */
display: block;
width: fit-content;
font-size: var(--text-xs);
font-weight: 700;
color: var(--royal-700);
background: var(--royal-100);
border-radius: 999px;
padding: 1px 8px;
margin-bottom: 5px;
}
.msg.mine .bubble .msg-tag {
background: var(--surface);
}
/* A chat page is the conversation. The shared two-column rule leads with the
   side column, which is right on a run page (runner identity and escrow
   first) and wrong here: it buries the thread under a policy card and a back
   link, so on a phone you scroll past both to reach your messages. */
.layout-2col.layout-chat > .col-side {
order: 1;
}
/* The panel is bounded by the VIEWPORT, not by the conversation. With only a
   min-height it grew to fit every message, which meant the feed had no scroll
   range at all (scrollHeight == clientHeight), the whole PAGE scrolled
   instead, and on a thread of forty messages the composer sat 3160px below
   the fold: you scrolled the length of the history to reach the reply box.
   Measured, not guessed. */
.chat-panel {
display: flex;
flex-direction: column;
min-height: 420px;
max-height: calc(100vh - 230px);
max-height: calc(100dvh - 230px);
}
/* A flex child defaults to min-height:auto and refuses to shrink below its
   content, so bounding the panel alone leaves the feed spilling out of it and
   still not scrolling. This line is the other half of the fix. */
#message-feed {
min-height: 0;
}
@media (max-width: 979px) {
/* Fill the screen so the composer sits where a thumb expects it, near the
   bottom, instead of floating mid-page with dead space under it. */
.chat-panel {
min-height: 60vh;
/* The page head (crumbs, avatar, who you are talking to) eats the first
   ~190px, so the panel has to stop short of that or the composer lands just
   under the fold and the reply box needs a scroll to find. */
max-height: calc(100vh - 250px);
max-height: calc(100dvh - 250px);
}
}
.composer {
display: flex;
align-items: flex-end;
gap: 8px;
}
/* Thumb-sized, because this is used one-handed on a phone while walking.
   44px is the smallest target most people hit reliably. */
.composer-clip,
.composer-send {
min-height: 44px;
}
.composer-send {
display: inline-flex;
align-items: center;
gap: 7px;
padding-inline: 16px;
}
.enter-switch {
display: flex;
align-items: center;
gap: 7px;
margin-top: 8px;
cursor: pointer;
}
.enter-switch input {
width: 16px;
height: 16px;
flex: none;
}
@media (max-width: 620px) {
/* On a phone the label is noise next to an arrow everyone recognises, and
   the width it frees goes to the message box. */
.composer-send-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.composer-send {
padding-inline: 14px;
}
.composer-clip {
width: 44px;
height: 44px;
}
}
.composer textarea {
flex: 1;
resize: none;
min-height: 40px;
max-height: 160px;
overflow-y: auto;
line-height: 1.4;
}
.composer-clip {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--line);
border-radius: var(--r-control);
cursor: pointer;
color: var(--ink-60);
background: var(--surface);
}
.composer-clip:hover,
.composer-clip:focus-within {
border-color: var(--royal-500);
color: var(--royal-700);
}
.composer .btn {
flex: none;
}
.composer-note {
margin: 6px 0 0;
}
/* ---- end message attachments and composer ---- */
.run-cover {
display: block;
position: relative;
margin: -16px -16px 0;
border-radius: var(--r-card) var(--r-card) 0 0;
overflow: hidden;
}
.cover-badge {
position: absolute;
top: 10px;
left: 10px;
background: var(--accent);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
padding: 4px 10px;
border-radius: 9px;
}
/* Terracotta stays urgency-only: a run closing further out wears a quiet
   white badge instead. */
.cover-badge-neutral {
background: rgba(255, 255, 255, 0.94);
color: var(--ink);
}

.run-cover img {
width: 100%;
aspect-ratio: 21 / 9;
object-fit: cover;
}
/* Shop-window strip on open-run cards: what the shelf holds and the entry
   price, so a card sells the run before the tap. */
.run-shelf {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.run-shelf-thumb {
width: 38px;
height: 38px;
flex: none;
border-radius: var(--r-control);
border: 1px solid var(--line);
background: var(--paper);
overflow: hidden;
}
.run-shelf-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.run-shelf-more {
display: grid;
place-items: center;
font-size: var(--text-xs);
font-weight: 700;
color: var(--royal-700);
background: var(--royal-100);
border-color: var(--royal-200);
}
.run-shelf-from {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.25;
flex: none;
white-space: nowrap;
}
.run-shelf-from .money {
font-family: var(--font-display);
font-weight: 700;
font-size: 14px;
color: var(--price);
white-space: nowrap;
}
.account-menu {
position: relative;
}
.account-menu > summary {
list-style: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
}
.account-menu > summary::-webkit-details-marker {
display: none;
}
.account-menu[open] > summary .avatar {
outline: 2px solid var(--royal-500);
outline-offset: 2px;
}
/* The menu grows with what you are: a buyer has 9 rows, ops 10, a runner 15.
   At 44px a row that is 834px of menu, and on a 390x844 phone the runner's
   menu ended at 896px, so its last rows including LOG OUT were below the
   bottom of the screen with no way to reach them. A menu that grows by role
   has to be bounded by the screen, not by its contents. */
.account-pop {
position: absolute;
right: 0;
top: calc(100% + 10px);
z-index: 60;
width: 258px;
max-width: calc(100vw - 24px);
/* Never taller than what is left below the header, and scroll inside if the
   role earns more rows than that. 96px covers the header plus the gap.
   dvh, not vh: on a mobile browser vh is the height with the URL bar
   retracted, which is more room than the menu actually has. */
max-height: calc(100dvh - 96px);
overflow-y: auto;
overscroll-behavior: contain;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
padding: 6px;
box-shadow: var(--shadow-pop);
color: var(--ink);
}
/* Tightened 16 Sep 2026. The ROWS stay at 44px: this menu sits in the header
   at every width, so it is reachable by thumb and the touch floor applies to
   it. All the slack was in the chrome around them, which is where 22px of
   height came out of. */
.ap-who {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 9px;
border-bottom: 1px solid var(--line);
margin-bottom: 4px;
}
.ap-who-text {
min-width: 0;
}
.ap-name {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--text-sm);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-phone {
font-size: var(--text-xs);
color: var(--ink-60);
font-variant-numeric: tabular-nums;
margin-top: 2px;
}
.ap-group {
padding: 2px 0;
}
.ap-group + .ap-group {
border-top: 1px solid var(--line-soft);
}
.ap-label {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-40);
padding: 4px 12px 1px;
margin: 0;
}
.ap-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
/* 44px, not 40 (14 Sep 2026). FRONTEND.md sets a 44px minimum for touch
   targets and these rows are the account menu's entire contents, Log out
   included, so the one menu a buyer reaches for on a phone was the one place
   the floor was not kept. Four pixels each, and the menu grows by twelve. */
min-height: 44px;
padding: 8px 10px;
border-radius: var(--r-control);
font-size: var(--text-sm);
font-weight: 500;
color: var(--ink);
background: none;
border: 0;
text-align: left;
cursor: pointer;
font-family: inherit;
}
/* ---- account menu on a pointer device (17 Sep 2026) --------------------

   The 44px row is a TOUCH floor, and FRONTEND.md sets it for what a thumb has
   to hit. A mouse does not need it, and on a runner's 15-row menu it is 120px
   of height bought for nothing. Behind @media (hover: hover) so a phone keeps
   every row at 44px and only a pointer gets the compact menu. The same reason
   .shelf-nav is allowed to be a 34px control. */
@media (hover: hover) and (pointer: fine) {
  .ap-row {
    min-height: 36px;
    padding: 6px 10px;
  }
  .ap-who {
    padding: 6px 10px 8px;
  }
  .ap-label {
    padding: 3px 12px 1px;
  }
  .ap-group {
    padding: 1px 0;
  }
}
/* ---- end account menu ---- */
.ap-row svg {
width: 17px;
height: 17px;
flex: none;
color: var(--ink-40);
}
.ap-row:hover,
.ap-row:focus-visible {
background: var(--royal-100);
color: var(--royal-900);
text-decoration: none;
}
.ap-row:hover svg,
.ap-row:focus-visible svg {
color: var(--royal-700);
}
.ap-count {
margin-left: auto;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
display: grid;
place-items: center;
}
.ap-state {
margin-left: auto;
font-size: var(--text-xs);
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--paper);
color: var(--ink-60);
border: 1px solid var(--line);
}
.ap-state.is-on {
background: var(--royal-100);
color: var(--royal-900);
border-color: var(--royal-200);
}
/* Below 980px the tab bar is fixed over the bottom of the screen, and the
   cap above accounted for the header but not for it. A runner's menu earns
   fifteen rows, which is taller than the cap, so it scrolls, and scrolled to
   the end its last row (Log out) was drawn under the tab bar: about 20 of
   its 44 pixels covered, on the one control you most want to work first
   time. 60px is the bar plus its padding, and the safe-area inset is the
   home indicator below that. */
@media (max-width: 979.98px) {
.account-pop {
max-height: calc(100dvh - 96px - 60px - env(safe-area-inset-bottom));
}
}
.account-pop form {
margin: 0;
}
.ap-foot {
border-top: 1px solid var(--line);
margin-top: 6px;
padding-top: 6px;
}
.ap-logout,
.ap-logout svg {
color: var(--danger-700);
}
.ap-logout:hover,
.ap-logout:focus-visible {
background: var(--danger-100);
color: var(--danger-700);
}
.eyebrow {
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-40);
}
.eyebrow-sky {
color: var(--sky-700);
}
.eyebrow-royal {
color: var(--royal-700);
}
/* The eyebrow on a dark band (the abroad promise band). It lived 1500 lines
   further up, written before .eyebrow itself, and a modifier that precedes
   its own base at the same specificity loses every property they share: the
   colour that actually rendered was .eyebrow's --ink-40, dark grey on dark
   indigo at 2.17:1. Nothing errored and the CSS read correctly in isolation.
   Modifiers belong next to their base, which is where the other two are. */
.eyebrow-light {
color: var(--indigo-950-soft);
display: inline-flex;
align-items: center;
gap: 8px;
}
hr.divider {
height: 1px;
background: var(--line);
border: 0;
margin: 4px 0;
}
.icon {
width: 15px;
height: 15px;
flex: none;
}
.meta {
display: flex;
gap: 6px 16px;
flex-wrap: wrap;
font-size: 12px;
color: var(--ink-60);
}
.meta > span {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.meta .icon {
color: var(--ink-40);
}
.card-foot {
border-top: 1px solid var(--line);
margin-top: auto;
padding-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
/* The foot is one line in every card, so the value on its right sits at the
   same height across a row instead of riding up when the label beside it
   happens to wrap. */
.card-foot > :first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.band {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: var(--surface);
padding: 36px 0;
}
.band-royal {
background: var(--royal-100);
border-color: #e0daf6;
}
.band-royal h2,
.band-royal h3 {
color: var(--royal-900);
}
@media (min-width: 980px) {
.band {
padding: 56px 0;
}
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
background: var(--royal-700);
color: #fff;
padding: 10px 18px;
border-radius: 0 0 var(--r-control) 0;
}
.skip-link:focus {
left: 0;
text-decoration: none;
}
.mobile-search {
padding: 0 16px 12px;
}
.mobile-search input {
min-height: 44px;
border-radius: 999px;
border-color: transparent;
padding: 8px 20px;
background: #fff;
}
@media (min-width: 980px) {
.mobile-search {
display: none;
}
}
.field:has(.field-error) input,
.field:has(.field-error) select,
.field:has(.field-error) textarea {
border-color: var(--danger-700);
}
.btn.is-loading {
opacity: 0.65;
pointer-events: none;
}
.btn.is-loading::after {
content: "\2026";
}
/* Mobile shelves: below 700px a .shelf grid becomes a swipeable row with
   the next card peeking, the way big-retailer mobile homepages read. */
@media (max-width: 699.98px) {
.grid-cards.shelf,
.grid-listings.shelf {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 6px;
scrollbar-width: none;
}
.grid-cards.shelf::-webkit-scrollbar,
.grid-listings.shelf::-webkit-scrollbar {
display: none;
}
.grid-cards.shelf > * {
flex: 0 0 auto;
width: 82vw;
max-width: 320px;
scroll-snap-align: start;
}
.grid-listings.shelf > * {
flex: 0 0 auto;
width: 42vw;
max-width: 185px;
scroll-snap-align: start;
}
}
/* ---- The buyer's add and remove, on every product card ----

   The most-pressed pair in the product, and the last one still wearing a
   generic pill: a square-cornered .btn-primary reading "+ Add" on the photo,
   with a squared royal stepper behind it. Same family as .iconbtn now, in its
   solid face, because this one IS the committing action on its card: a glyph
   in a disc, then the word, fully rounded, lifted off the photo by a shadow
   rather than by a border it cannot have against an unknown image.

   Why the word survives here when the shelf row dropped it: a plus on a photo
   is understood everywhere, but this is the control that puts a buyer's money
   into holding, and the place to spend a word is where somebody is deciding
   to spend. */
.qadd {
position: relative;
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
padding: 0 14px 0 5px;
border: 0;
border-radius: 999px;
background: var(--royal-700);
color: #fff;
font-family: inherit;
font-size: var(--text-sm);
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 12px rgba(23, 16, 64, 0.32);
transition: background-color var(--dur-fast) var(--ease),
  transform var(--dur-press) var(--ease);
}
/* 36 + 4 + 4 = the 44px floor, without a 44px pill over the goods. */
.qadd::after {
content: "";
position: absolute;
inset: -4px;
}
.qadd > svg {
flex: none;
width: 26px;
height: 26px;
padding: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.22);
}
@media (hover: hover) {
.qadd:hover {
  background: var(--royal-900);
}
}
.qadd:active {
transform: scale(0.96);
}
.qadd:focus-visible {
outline: 2px solid #fff;
outline-offset: -3px;
}
/* Sold out is a statement, not a button you can press. It was a disabled
   .btn, which is a control asking to be tapped and then refusing. */
.qadd-out {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
background: rgba(22, 21, 31, 0.72);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
line-height: 1;
}
.stepper {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
background: var(--royal-700);
border-radius: 999px;
box-shadow: 0 2px 12px rgba(23, 16, 64, 0.32);
}
.stepper button {
width: 30px;
height: 30px;
border: 0;
background: rgba(255, 255, 255, 0.18);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
border-radius: 50%;
transition: background-color var(--dur-fast) var(--ease);
}
@media (hover: hover) {
.stepper button:hover {
  background: rgba(255, 255, 255, 0.34);
}
}
.stepper button svg {
width: 14px;
height: 14px;
}
.stepper .qty {
min-width: 24px;
text-align: center;
color: #fff;
font-weight: 700;
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.qadd,
.stepper button {
  transition: none;
}
.qadd:active {
  transform: none;
}
}
.header-basket {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: #fff;
flex: none;
}
.header-basket svg {
width: 21px;
height: 21px;
}
.header-basket .badge {
position: absolute;
top: 2px;
right: 0;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 9px;
background: var(--accent);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
display: grid;
place-items: center;
font-variant-numeric: tabular-nums;
}
.header-basket .badge[hidden] {
display: none;
}
.header-search,
.mobile-search {
position: relative;
}
.suggest-box {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 70;
color: var(--ink);
}
.mobile-search .suggest-box {
left: 16px;
right: 16px;
}
.suggest-box:empty {
display: none;
}
.suggest-panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
box-shadow: var(--shadow-pop);
overflow: hidden;
}
.suggest-row {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 14px;
color: var(--ink);
font-size: 13px;
}
.suggest-row:hover {
background: var(--paper);
text-decoration: none;
}
.suggest-row .thumb {
flex: none;
width: 34px;
height: 34px;
border-radius: 8px;
background: var(--paper);
overflow: hidden;
display: grid;
place-items: center;
}
.suggest-row .thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.suggest-row .thumb-icon {
color: var(--royal-700);
}
.suggest-row .thumb-icon svg {
width: 17px;
height: 17px;
}
.suggest-row .grow {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.suggest-row .t {
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.suggest-row .s {
font-size: var(--text-xs);
color: var(--ink-60);
}
.suggest-all {
display: block;
padding: 10px 14px;
font-size: var(--text-xs);
font-weight: 700;
border-top: 1px solid var(--line-soft);
}
.suggest-all:hover {
background: var(--paper);
text-decoration: none;
}
.suggest-empty {
padding: 12px 14px;
font-size: 13px;
color: var(--ink-60);
}
/* Verified ratings: input stars (reversed-row hover trick) and static
   display stars. Gold is reserved for stars alone. */
.star-input {
display: inline-flex;
flex-direction: row-reverse;
gap: 2px;
}
.star-input input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.star-input label {
cursor: pointer;
color: var(--line);
display: grid;
place-items: center;
width: 34px;
height: 34px;
}
.star-input label svg {
width: 27px;
height: 27px;
fill: currentColor;
}
.star-input input:checked ~ label,
.star-input label:hover,
.star-input label:hover ~ label {
color: var(--gold);
}
.star-input input:focus-visible + label {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
border-radius: 6px;
}
.stars-static {
display: inline-flex;
gap: 1px;
}
.stars-static svg {
width: 16px;
height: 16px;
fill: var(--line);
}
.stars-static svg.on {
fill: var(--gold);
}
.rating-inline {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--ink-60);
}
.rating-inline svg {
width: 12px;
height: 12px;
fill: var(--gold);
}
/* A tappable rating opens the written reviews it is built from. */
a.rating-inline {
text-decoration: none;
}
a.rating-inline:hover strong {
text-decoration: underline;
}
.review-row {
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.review-row:last-child {
border-bottom: 0;
padding-bottom: 4px;
}
.review-row .chip {
font-size: var(--text-xs);
padding: 2px 9px;
}
/* Receipt: a document the buyer can print or save as their record. */
.receipt {
max-width: 560px;
margin: 0 auto;
}
.receipt-snail svg {
width: 44px;
height: 30px;
color: var(--royal-700);
}
.receipt-meta {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
font-size: 14px;
}
.statement-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--line);
}
.statement-row:last-child {
border-bottom: 0;
}
/* Notification feed: unread rows carry the royal dot, read ones go quiet. */
.notif-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid var(--line);
color: inherit;
text-decoration: none;
}
.notif-row:last-child {
border-bottom: 0;
}
.notif-row:hover {
background: var(--paper);
}
.notif-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 7px;
background: var(--line);
}
.notif-row.unread .notif-dot {
background: var(--royal-700);
}
/* Save heart: outline until saved, royal fill once watching (royal marks
   active states; gold and green are reserved). */
.btn-save {
width: 44px;
height: 44px;
flex: none;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 50%;
background: var(--surface);
color: var(--ink-60);
cursor: pointer;
}
.btn-save svg {
width: 20px;
height: 20px;
fill: none;
}
.btn-save:hover {
border-color: var(--royal-700);
color: var(--royal-700);
}
.btn-save.active {
border-color: var(--royal-700);
color: var(--royal-700);
}
.btn-save.active svg {
fill: var(--royal-100);
}
.btn-save:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.filter-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 96px;
}
.filter-field input,
.filter-field select {
/* 44, not 42: the shared input rule above already sets the FRONTEND.md
   floor and this block was re-declaring it two pixels short, so the
   catalogue filters were the only form controls on the site below it. */
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--r-control);
padding: 0 12px;
font: inherit;
background: var(--surface);
color: inherit;
}
.filter-field input:focus-visible,
.filter-field select:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
/* Price presets and deal toggles: chips that act like buttons. */
button.preset-chip,
.filter-toggle {
border: 0;
font-family: inherit;
cursor: pointer;
}
.filter-toggle {
position: relative;
}
button.preset-chip:focus-visible,
.filter-toggle:focus-within {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
.chip-divider {
width: 1px;
align-self: stretch;
background: var(--line);
margin: 2px 4px;
flex: none;
}
/* The basket drawer: a right-hand slide-over. Functional motion only (it is
   how the panel arrives), 200ms, snapped away under reduced motion. */
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(27, 27, 36, 0.4);
display: flex;
justify-content: flex-end;
/* Held sideways the trailing 59px is the notch; the panel stops short of it
   rather than putting the quantity steppers under the camera cutout. */
padding-right: env(safe-area-inset-right, 0px);
/* See .sheet-backdrop: this is the element under the finger when the drag
   lands beside the panel, and without it the storefront scrolled 1200px
   behind an open basket. */
overflow: hidden;
overscroll-behavior: contain;
animation: drawer-fade 150ms ease-out;
}
.drawer {
background: var(--surface);
width: min(420px, 94vw);
height: 100dvh;
overflow-y: auto;
overscroll-behavior: contain;
/* The drawer is fixed to the full screen height, so installed on a phone its
   top edge is the top of the DISPLAY. Measured with a 59px status-bar inset:
   the close X rendered at y=16 to y=60, all but a pixel of it behind the
   clock, which is the control that gets you out of the basket. One custom
   property because .drawer-head cancels this padding with a negative margin
   and has to cancel exactly as much as was applied; when the inset is 0 both
   resolve to the 16px they always were. */
--drawer-pad-top: calc(16px + env(safe-area-inset-top, 0px));
padding: var(--drawer-pad-top) 18px calc(24px + env(safe-area-inset-bottom));
animation: drawer-in 200ms ease-out;
}
@keyframes drawer-fade {
from { opacity: 0; }
}
@keyframes drawer-in {
from { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.drawer-backdrop,
.drawer {
animation: none;
}
}
.drawer-head {
position: sticky;
top: calc(-1 * var(--drawer-pad-top));
margin: calc(-1 * var(--drawer-pad-top)) -18px 8px;
padding: var(--drawer-pad-top) 18px 10px;
background: var(--surface);
border-bottom: 1px solid var(--line);
z-index: 1;
}
.drawer-close {
width: 44px;
height: 44px;
flex: none;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-60);
cursor: pointer;
}
.drawer-close svg {
width: 18px;
height: 18px;
}
.drawer-close:hover {
background: var(--paper);
color: var(--ink);
}
.drawer-close:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
.drawer-group {
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.drawer-group:last-of-type {
border-bottom: 0;
}
.drawer-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 0;
}
.drawer-thumb {
flex: none;
width: 52px;
height: 52px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--paper);
}
.drawer-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.drawer-step {
width: 30px;
height: 30px;
flex: none;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
font: inherit;
cursor: pointer;
}
/* 30px to look at, 44px to hit: the invisible ring takes the tap target to
   the floor without pushing the quantity away from its buttons. */
.drawer-step {
position: relative;
}
.drawer-step::after {
content: "";
position: absolute;
inset: -8px; /* from the padding box, inside the 1px border: 28 + 16 = 44 */
}
.drawer-step:hover {
border-color: var(--royal-500);
color: var(--royal-700);
}
.drawer-step:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
/* Avatar with an uploaded photo: the frame stays, the photo fills it. */
.avatar-photo {
padding: 0;
overflow: hidden;
}
.avatar-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-anon::before {
content: "";
width: 58%;
height: 58%;
background: currentColor;
-webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'><circle cx='12' cy='8' r='4'/><path d='M4.5 20a7.5 7.5 0 0 1 15 0'/></svg>") center / contain no-repeat;
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'><circle cx='12' cy='8' r='4'/><path d='M4.5 20a7.5 7.5 0 0 1 15 0'/></svg>") center / contain no-repeat;
}
/* Listing page: the buy box keeps itself in view on desktop. */
@media (min-width: 980px) {
.col-side-sticky {
position: sticky;
top: 84px;
align-self: start;
max-height: calc(100dvh - 100px);
overflow-y: auto;
}
}
.gallery-zoom {
display: block;
cursor: zoom-in;
}
/* Gallery carousel controls. */
.carousel {
position: relative;
}
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.92);
color: var(--ink);
display: grid;
place-items: center;
cursor: pointer;
z-index: 1;
}
.carousel-arrow svg {
width: 18px;
height: 18px;
}
.carousel-arrow.prev {
left: 10px;
}
.carousel-arrow.next {
right: 10px;
}
.carousel-arrow:hover {
border-color: var(--royal-500);
color: var(--royal-700);
}
.carousel-arrow:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
.carousel-count {
position: absolute;
right: 12px;
bottom: 10px;
z-index: 1;
font-size: var(--text-xs);
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 3px 10px;
border-radius: 999px;
background: rgba(23, 16, 64, 0.72);
color: #fff;
}
/* Full-screen photo lightbox; shares the carousel's index and arrows. */
.lightbox-backdrop {
position: fixed;
inset: 0;
z-index: 90;
background: rgba(23, 16, 64, 0.94);
display: grid;
place-items: center;
/* A full-screen photo that scrolls the page behind it means closing it puts
   the buyer somewhere else in the catalogue. See .sheet-backdrop. */
overflow: hidden;
overscroll-behavior: contain;
animation: drawer-fade 150ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.lightbox-backdrop {
animation: none;
}
}
.lightbox-backdrop img {
max-width: 94vw;
max-height: 86dvh;
object-fit: contain;
border-radius: 8px;
}
.lightbox-backdrop .carousel-arrow {
position: fixed;
top: 50%;
}
.lightbox-backdrop .carousel-arrow.prev {
left: calc(14px + env(safe-area-inset-left, 0px));
}
.lightbox-backdrop .carousel-arrow.next {
right: calc(14px + env(safe-area-inset-right, 0px));
}
.lightbox-close {
position: fixed;
/* The photo viewer covers the screen, and the image fills it to 94vw, so
   this X is the only practical way out (the backdrop leaves a 3vw strip).
   Measured installed on a notched phone: it rendered at y=14 to y=58 inside
   a 59px status bar, so the exit was under the clock. */
top: calc(14px + env(safe-area-inset-top, 0px));
right: calc(14px + env(safe-area-inset-right, 0px));
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.14);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
}
.lightbox-close svg {
width: 18px;
height: 18px;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.26);
}
.lightbox-close:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.lightbox-count {
position: fixed;
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%);
right: auto;
}
/* Buy-box quantity stepper. */
.qty-stepper {
display: flex;
align-items: stretch;
border: 1px solid var(--line);
border-radius: var(--r-control);
overflow: hidden;
flex: none;
}
.qty-stepper button {
width: 44px;
border: 0;
background: var(--paper);
color: var(--ink);
font-size: 17px;
cursor: pointer;
}
.qty-stepper button:hover {
color: var(--royal-700);
}
.qty-stepper button:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: -2px;
}
.qty-stepper input {
width: 44px;
border: 0;
text-align: center;
font: inherit;
font-weight: 700;
font-variant-numeric: tabular-nums;
background: var(--surface);
color: inherit;
}
.qty-stepper input:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: -2px;
}
/* At-a-glance spec grid. */
.glance-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
margin-top: 10px;
}
.glance-cell {
background: var(--paper);
border-radius: var(--r-control);
padding: 9px 12px;
}
.glance-cell dd {
margin: 2px 0 0;
}
/* Star histogram beside review lists. */
.rating-bars {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 260px;
margin: 8px 0 4px;
}
.rating-bar-row {
display: flex;
align-items: center;
gap: 8px;
}
.rating-bar-track {
flex: 1;
height: 8px;
border-radius: 4px;
background: var(--paper);
overflow: hidden;
}
.rating-bar-fill {
display: block;
height: 100%;
border-radius: 4px;
background: var(--gold);
}
/* Honest merchandising flags overlaid on card photos. */
.card-flag {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
font-size: var(--text-xs);
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
color: #fff;
}
.flag-accent {
/* --accent-600, not --accent. --accent is the text-on-light value; as a
   FILL at pill size it reads brown and muddy, which is why the saving was
   the quietest thing on a deal card. White on --accent-600 is 5.18:1. */
background: var(--accent-600);
}
.flag-royal {
background: var(--royal-700);
}
/* Sold out. .card-flag sets white text and leaves the fill to the variant,
   and this variant was never written, so the one flag that reports bad news
   rendered as white text on the product photo: invisible over anything pale,
   which most product photos are. Neutral rather than red, because sold out
   is a fact and not a problem, and the same treatment .cover-badge-neutral
   already uses for a quiet badge over a photo. */
.flag-plain {
background: rgba(255, 255, 255, 0.94);
color: var(--ink);
box-shadow: 0 1px 3px rgba(23, 16, 64, 0.16);
}
/* The journey strip: how an item physically reaches the buyer. */
.journey-steps {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 12px;
}
@media (max-width: 699px) {
.journey-steps {
grid-template-columns: 1fr 1fr;
}
}
.journey-step {
display: flex;
flex-direction: column;
gap: 6px;
}
.journey-num {
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--royal-100);
color: var(--royal-700);
font-weight: 700;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* The On-the-move journey card: the trail sits in its own quiet panel,
   everything else stays on two calm rails above and below it. */
.journey-card .journey-trail {
background: var(--paper);
border-radius: var(--r-control);
padding: 2px 16px 10px;
margin-top: 14px;
}
.journey-card .journey-trail .trail-dot {
border-color: var(--ink-40);
}
.journey-card .journey-trail .trail-step.done .trail-dot {
border-color: var(--sky-500);
}
.journey-card .journey-trail .trail-step.current .trail-dot {
border-color: var(--royal-500);
}
.journey-latest {
display: flex;
align-items: flex-start;
gap: 7px;
margin-top: 10px;
}
.journey-foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
/* Micro progress on compact in-flight rows: the run's stage at a glance. */
.row-progress {
display: block;
height: 3px;
border-radius: 2px;
background: var(--line);
margin-top: 7px;
overflow: hidden;
max-width: 260px;
}
.row-progress span {
display: block;
height: 100%;
border-radius: 2px;
background: var(--sky-500);
}
/* Travelling run cards: the mini trail gets the same quiet panel. */
.card-trail {
background: var(--paper);
border-radius: 10px;
padding: 2px 12px 8px;
}
.travel-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--ink-60);
}
.travel-meta svg {
width: 13px;
height: 13px;
flex: none;
color: var(--sky-700);
}
/* Feel: press feedback and considered hovers, all functional and tiny. */
.btn:active,
.chip:active,
a.chip.chip-plain:hover,
button.preset-chip.chip-plain:hover,
.filter-toggle.chip-plain:hover {
background: var(--line);
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.btn:active,
.chip:active {
transform: none;
}
}
/* Capacity bar as a tap-open trust element (run page only). */
.capacity-open {
display: block;
width: 100%;
border: 0;
background: none;
padding: 0;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
}
.capacity-open:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
border-radius: 6px;
}
/* Data saver: the placeholder that stands where a photo would load. It fills
   whatever photo frame contains it so layouts hold. */
.ds-photo {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 100%;
min-height: 72px;
background: var(--paper);
color: var(--ink-40);
text-decoration: none;
text-align: center;
padding: 10px;
}
.ds-photo svg {
width: 22px;
height: 22px;
}
a.ds-photo:hover {
color: var(--royal-700);
}
a.ds-photo:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: -2px;
}
/* Standalone text links are controls, not prose: 24px of height is the
   minimum a thumb can find (WCAG 2.5.8). Primary actions get 44 elsewhere. */
.section-head a,
.row > a.small,
.row-between > a.small,
.spot-foot,
.footer-toggle {
display: inline-flex;
align-items: center;
/* Data saver is not a footnote on this product: it is how a buyer on a
   metered connection stops the page fetching photos, so it has to be
   tappable. 24px was a text link wearing a button's job. The negative inline
   margin keeps the underline optically aligned with the fine print beside
   it now that the box is taller than the text. */
min-height: 44px;
padding: 0 2px;
margin-inline: -2px;
}
.footer-toggle {
border: 0;
background: none;
/* No padding reset here: the rule above buys 2px of it, with -2px of
   inline margin to keep the underline optically aligned with the fine
   print beside it. Zeroing it left the margin behind and shifted the
   control 2px left of everything it lines up with. */
color: inherit;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.footer-toggle:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
/* Card-level section heading: one size everywhere, still steps up on desktop. */
/* One size, on the scale. It was 17 then 18, which is a step the scale does
   not have and a desktop variant it does not need. */
.h-card {
font-size: var(--text-lg);
}
/* Numeric stats that are counts, not money: same display face, no GHS. */
.stat-num {
font-family: var(--font-display);
font-weight: 700;
font-variant-numeric: tabular-nums;
font-size: 22px;
}
/* Compact button that still honors the 44px touch floor. */
.btn-sm {
min-height: 44px;
padding: 0 16px;
flex: none;
}
/* ===========================================================================
   Add and remove: one bespoke control (18 Sep 2026)

   Every add and every remove on the runner side was either a generic pill
   (.btn .btn-quiet .btn-sm) or an inline-styled <button> pretending to be a
   link: "Remove" on a shelf row carried the same weight as "Publish this
   run", and "Delete" on a listing was a bare red word with no target at all.
   Same shape for two very different sizes of act, which is the wrong promise
   in both directions.

   The shape: a glyph in a tinted disc, then a short label. The disc carries
   the colour, so a list of ten of these stays quiet and the icon-only variant
   is the same object with the label dropped rather than a second component.
   No extra span: the disc IS the svg, padded.

   Destructive controls are NOT red at rest. Ten red buttons down a shelf make
   the shelf look broken; the red arrives on hover, focus and press, which is
   when it is a warning rather than decoration. On touch, where :hover sticks
   to whatever was tapped last, it arrives on focus only.

   Touch: 38px tall so it sits inside a dense row, with a -3px inset
   pseudo-element taking the tap target to the 44px floor, the same trick
   button.trail-dot already uses.
   ======================================================================== */
.iconbtn {
position: relative;
display: inline-flex;
align-items: center;
/* Never stretch. Dropped into a flex row with the default align-items, a
   38px control silently grows to whatever the tallest thing on that line is,
   which is how the two cover controls came out 38px and 42px side by side. */
align-self: center;
gap: 8px;
min-height: 38px;
padding: 0 14px 0 6px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink);
font-family: inherit;
font-size: var(--text-sm);
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
text-decoration: none;
transition: border-color var(--dur-fast) var(--ease),
  background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
  transform var(--dur-press) var(--ease);
}
/* The tap target, invisible: 38 + 3 + 3 = 44. */
.iconbtn::after {
content: "";
position: absolute;
inset: -3px;
}
.iconbtn > svg {
flex: none;
width: 26px;
height: 26px;
padding: 5px;
border-radius: 50%;
background: var(--line-soft);
color: var(--ink-60);
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.iconbtn:hover {
text-decoration: none;
}
.iconbtn:active {
transform: scale(0.96);
}
.iconbtn:focus-visible {
outline-offset: 3px;
border-radius: 999px;
}
.iconbtn[disabled],
.iconbtn[aria-disabled="true"] {
opacity: 0.55;
pointer-events: none;
}

/* Add. Royal, because adding a product or a photo is the thing this page
   wants you to do; quiet enough that four of them in a column are still a
   column and not four calls to action. */
.iconbtn.is-add {
color: var(--royal-900);
}
.iconbtn.is-add > svg {
background: var(--royal-100);
color: var(--royal-700);
}
@media (hover: hover) {
.iconbtn.is-add:hover {
  border-color: var(--royal-500);
  background: #fbfaff;
}
.iconbtn.is-add:hover > svg {
  background: var(--royal-200);
}
}

/* Remove. Neutral until touched. */
.iconbtn.is-remove:focus-visible {
color: var(--danger-700);
border-color: var(--danger-700);
}
.iconbtn.is-remove:focus-visible > svg {
background: var(--danger-100);
color: var(--danger-700);
}
@media (hover: hover) {
.iconbtn.is-remove:hover {
  color: var(--danger-700);
  border-color: var(--danger-700);
}
.iconbtn.is-remove:hover > svg {
  background: var(--danger-100);
  color: var(--danger-700);
}
}

/* Icon only, for a dense row where the thing being removed is named right
   beside the control. The label still ships, in .visually-hidden, because a
   screen reader needs to hear WHICH row this removes. */
.iconbtn.is-bare {
width: 38px;
padding: 0;
justify-content: center;
border-color: transparent;
background: none;
}
@media (hover: hover) {
.iconbtn.is-bare:hover {
  background: none;
  border-color: var(--line);
}
}

/* On a dark surface (the run hero carries the cover controls). */
.iconbtn.on-dark {
border-color: rgba(255, 255, 255, 0.24);
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.iconbtn.on-dark > svg {
background: rgba(255, 255, 255, 0.16);
color: #fff;
}
.iconbtn.on-dark:focus-visible {
outline-color: #fff;
}
@media (hover: hover) {
.iconbtn.on-dark:hover {
  border-color: rgba(255, 255, 255, 0.5);
  background: rgba(255, 255, 255, 0.16);
  color: #fff;
}
.iconbtn.on-dark:hover > svg {
  background: rgba(255, 255, 255, 0.28);
}
/* Danger on an indigo ground: --danger-700 is a text-on-light red and
   disappears into it, so the warning is carried by a light fill instead. */
.iconbtn.on-dark.is-remove:hover {
  border-color: var(--danger-100);
  color: var(--danger-100);
}
.iconbtn.on-dark.is-remove:hover > svg {
  background: var(--danger-100);
  color: var(--danger-700);
}
}
.iconbtn.on-dark.is-remove:focus-visible {
border-color: var(--danger-100);
color: var(--danger-100);
}
.iconbtn.on-dark.is-remove:focus-visible > svg {
background: var(--danger-100);
color: var(--danger-700);
}

@media (prefers-reduced-motion: reduce) {
.iconbtn,
.iconbtn > svg {
  transition: none;
}
.iconbtn:active {
  transform: none;
}
}
/* The trail's completed dots stay 12px visually but tap at 44px. */
button.trail-dot {
position: relative;
}
button.trail-dot::after {
content: "";
position: absolute;
inset: -16px;
}
/* Rows borrowing milestone-row's look without its tap behavior. */
.milestone-row.static {
cursor: default;
}
.milestone-row.static:hover {
background: none;
}
/* Disabled pagination ends share the standard disabled look. */
.btn[aria-disabled="true"] {
opacity: 0.55;
pointer-events: none;
}
/* Visually hidden form input behind a styled label (radio groups, capture
   zones); replaces six inline copies of the same style. */
.vh-input {
position: absolute;
opacity: 0;
width: 1px;
min-height: 1px;
pointer-events: none;
}
@media print {
.hd-shell,
.site-header,
.site-footer,
.tabbar,
.cta-bar,
.no-print,
#toast-region,
#sheet-region {
display: none !important;
}
body {
background: #fff;
}
.receipt {
border: 0;
box-shadow: none;
max-width: none;
}
}
/* Loading affordance on swapped regions: quiet dim, no shimmer. */
#listing-grid.htmx-request,
#basket-box.htmx-request {
opacity: 0.5;
transition: opacity 150ms ease;
}
/* Hide on small screens without dictating a display value on large ones.
   The old pair set display:none then display:revert at the breakpoint, and
   revert rolls back past the author origin: an <a class="btn hide-mobile">
   lost .btn's inline-flex and rendered its label in the top-left corner of a
   full-width pill. Hiding only inside the mobile query leaves every element's
   own display intact. */
@media (max-width: 979.98px) {
.hide-mobile {
display: none !important;
}
}
@media (min-width: 980px) {
#toast-region {
bottom: 92px;
}
}
.hero-dark {
background: var(--indigo-950);
color: #fff;
}
.hero-dark .eyebrow {
color: var(--indigo-950-soft);
}
.hero-dark .stat {
border-left-color: rgba(255, 255, 255, 0.25);
}
.hero-dark .stat .num {
color: #fff;
}
.hero-dark .stat .lbl {
color: var(--indigo-950-muted);
}
.held-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 999px;
padding: 7px 14px;
font-size: var(--text-xs);
font-weight: 600;
color: #fff;
}
.held-badge svg {
width: 15px;
height: 15px;
flex: none;
color: var(--indigo-950-soft);
}
/* The guarantees band, moved off indigo onto --sand (owner decision 14 Sep
   2026). The old note argued indigo because the system reserves that surface
   for trust. That was true of the token and false of the page: the header,
   this band, the sign-in band and the escrow hero were all indigo-950, so
   the signed-out home carried three full-width indigo slabs and the surface
   reserved for trust marked nothing in particular. FRONTEND.md's actual rule
   is header and escrow/wallet hero only, and this is the second of the two
   bands being handed back.

   It still reads as the page's spine rather than one more white shelf,
   because --sand is the only warm ground on the site and nothing else uses
   it. Ink on sand is 16.63:1. */
.trust-band {
background: var(--sand);
border-top: 1px solid var(--sand-line);
border-bottom: 1px solid var(--sand-line);
color: var(--ink);
padding: 22px 0 26px;
margin-top: 18px;
}
.trust-band-title {
font-family: var(--font-display);
font-size: var(--text-lg);
line-height: 1.25;
margin: 0 0 14px;
color: var(--ink);
}
.trust-band-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
@media (min-width: 700px) {
.trust-band-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.trust-band-title {
font-size: var(--text-xl);
}
}
/* Each claim is a link, because a guarantee the buyer cannot check is a
   slogan. The whole cell is the target, well past the 44px minimum. */
.trust-claim {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border-radius: var(--r-card);
background: var(--surface);
border: 1px solid var(--sand-line);
color: var(--ink);
transition: border-color var(--dur-fast) var(--ease),
  box-shadow var(--dur-fast) var(--ease);
}
.trust-claim:hover,
.trust-claim:focus-visible {
border-color: var(--royal-200);
box-shadow: var(--shadow-lift);
text-decoration: none;
}
/* The one place royal is spent in this band, and it is spent on the mark
   rather than the card, so four claims read as four objects of one kind
   instead of four purple panels. */
.trust-mark {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--royal-100);
color: var(--royal-700);
}
.trust-mark svg {
width: 17px;
height: 17px;
}
.trust-claim-title {
font-size: var(--text-sm);
font-weight: 600;
line-height: 1.35;
}
.trust-claim-link {
margin-top: auto;
font-size: var(--text-xs);
font-weight: 600;
color: var(--royal-700);
}
.trust-claim:hover .trust-claim-link,
.trust-claim:focus-visible .trust-claim-link {
color: var(--royal-900);
}
.trust-claim-link::after {
content: " \2192";
}
.portal-hero {
background: var(--indigo-950);
color: #fff;
border-radius: var(--r-hero);
padding: 24px 22px;
}
@media (min-width: 980px) {
.portal-hero {
padding: 32px;
}
}
.portal-hero .money-lg {
font-size: 36px;
letter-spacing: -0.02em;
}
.portal-hero a:hover {
text-decoration: none;
}
.ceiling-track {
height: 7px;
/* Track on the indigo hero: the muted indigo tint at low opacity. The
   solid fallback covers engines without color-mix. */
background: var(--indigo-950-muted);
background: color-mix(in srgb, var(--indigo-950-muted) 30%, transparent);
border-radius: 4px;
overflow: hidden;
}
.ceiling-track .fill {
height: 100%;
background: var(--indigo-950-soft);
border-radius: 4px;
}
@media (min-width: 980px) {
.portal-hero .money-lg {
font-size: 44px;
}
}
.pay-method {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
padding: 14px 16px;
cursor: pointer;
font: inherit;
text-align: left;
color: inherit;
}
.pay-method:focus-within {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
.pay-method.selected {
border: 2px solid var(--royal-700);
padding: 13px 15px;
}
.pay-method .logo {
flex: none;
width: 40px;
height: 40px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: var(--text-xs);
font-weight: 800;
color: #fff;
}
/* A wordmark longer than three letters gets a wider plate rather than
   smaller type; the row keeps its height either way. */
.pay-method .logo-wide {
width: auto;
min-width: 40px;
padding: 0 8px;
}
.pay-method .tick {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--royal-200);
display: grid;
place-items: center;
}
.pay-method.selected .tick {
background: var(--royal-700);
border-color: var(--royal-700);
color: #fff;
}
.milestone-banner {
background: var(--indigo-950);
color: #fff;
border-radius: var(--r-card);
padding: 16px 18px;
display: flex;
gap: 13px;
align-items: center;
}
.milestone-banner svg {
flex: none;
width: 24px;
height: 24px;
color: var(--indigo-950-soft);
}
.milestone-banner .eyebrow {
color: var(--indigo-950-soft);
}
.capture-zone {
position: relative;
border: 2px dashed var(--royal-200);
border-radius: var(--r-card);
padding: 26px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
cursor: pointer;
}
.capture-zone:hover {
border-color: var(--royal-500);
}
.capture-zone:focus-within {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
.capture-zone .lens {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--royal-100);
display: grid;
place-items: center;
color: var(--royal-700);
}
.capture-zone .lens svg {
width: 26px;
height: 26px;
}

/* ---- Run-card polish: the cover carries the route, the card sheds its
   boilerplate. The scrim sits under the badges; the slots chip moves to
   the top corner so the route owns the bottom edge. ---- */
.run-cover img {
display: block;
}
.cover-scrim {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(23, 16, 64, 0) 55%, rgba(23, 16, 64, 0.45));
pointer-events: none;
}
/* The route rides the photo as a contained pill, never bare text: it can
   ellipsize inside its own background and cannot bleed past the image. */
.cover-route {
position: absolute;
left: 10px;
bottom: 10px;
max-width: calc(100% - 20px);
background: rgba(23, 16, 64, 0.62);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.01em;
padding: 4px 11px;
border-radius: 999px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cover-badge,
.run-cover .cover-badge {
z-index: 1;
}
/* Scarcity chip: top RIGHT, opposite the closes badge at top left, so the
   two never collide. An earlier rule put it bottom right and was overridden
   here; the comment on it described a layout nobody was looking at. */
.cover-badge.cover-slots {
top: 10px;
right: 10px;
bottom: auto;
left: auto;
}
/* One thin line under the title; no reserved second line. */
.run-lands {
font-size: 12px;
line-height: 1.45;
color: var(--ink-60);
margin-top: -4px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

/* Micro-delight: on full-size trails the snail crawls out to its milestone
   when the page loads. Mini trails on cards stay still, and so does every
   reduced-motion user. */
@keyframes snail-crawl {
from { left: 2%; opacity: 0.35; }
}
.trail-snail {
animation: snail-crawl 1100ms ease-out both;
}
.mini-trail .trail-snail {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.trail-snail {
animation: none;
}
}

/* The shop-window strip must never push its price past the card edge: the
   row wraps, so on tight cards the from-price drops to its own line, still
   right-aligned, instead of overflowing the card. */
.run-shelf {
flex-wrap: wrap;
row-gap: 4px;
}

/* Owner call, 3 Sep: shelf thumbnails are circular, like a stack of product
   coins; the +N chip matches so the row reads as one family. */
/* The 999px override that used to live here is gone (16 Sep 2026). It was a
   SECOND .run-shelf-thumb rule 1,800 lines below the first, so a 38px product
   photograph in a run's shelf strip was cropped to a circle while the rule
   that reads like the definition says 9px. Goods are not avatars. The shape
   now comes from the first rule only. */

/* Milestone timeline: the whole road, one stage per row. Recorded rows open
   the evidence sheet, the current row is ringed in royal, upcoming rows say
   which proof the runner still has to seal. Tap targets stay at 44px. */
.mtl {
list-style: none;
padding: 0;
margin: 4px 0 0;
}
.mtl-step {
position: relative;
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
gap: 0 12px;
padding-bottom: 18px;
}
.mtl-step::before {
content: "";
position: absolute;
left: 15px;
top: 34px;
bottom: -2px;
width: 2px;
background: var(--line);
}
.mtl-step.done::before {
background: var(--sky-500);
}
.mtl-step.current::before {
background: var(--line);
}
.mtl-step:last-child::before {
display: none;
}
.mtl-step:last-child {
padding-bottom: 4px;
}
.mtl-node {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--paper);
border: 2px solid var(--line);
color: var(--ink-40);
font-size: 13px;
font-weight: 600;
font-family: var(--font-display);
}
.mtl-step.done .mtl-node {
/* sky-700, not 500: this node carries a white tick, and white on sky-500 is
   3.79:1. Sky-500 stays the right fill everywhere nothing is written on it. */
background: var(--sky-700);
border-color: var(--sky-700);
color: #fff;
}
.mtl-step.current .mtl-node {
background: var(--surface);
border-color: var(--royal-500);
color: var(--royal-700);
box-shadow: 0 0 0 4px var(--royal-100);
}
.mtl-body {
min-width: 0;
padding-top: 5px;
}
.mtl-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mtl-label {
font-weight: 600;
color: var(--ink);
}
.mtl-step.upcoming .mtl-label {
color: var(--ink-60);
font-weight: 500;
}
.mtl-now {
padding: 2px 8px;
font-size: var(--text-xs);
}
.mtl-when {
margin-left: auto;
font-size: 12px;
color: var(--ink-40);
white-space: nowrap;
}
.mtl-meaning {
margin: 2px 0 0;
font-size: 13px;
color: var(--ink-60);
}
.mtl-step.upcoming .mtl-meaning {
color: var(--ink-40);
}
.mtl-proof {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 48px;
margin-top: 8px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: var(--r-control);
background: var(--surface);
color: var(--ink);
text-align: left;
cursor: pointer;
font: inherit;
}
.mtl-proof:hover {
border-color: var(--sky-700);
background: var(--paper);
}
.mtl-proof:focus-visible {
outline: 2px solid var(--royal-500);
outline-offset: 2px;
}
.mtl-thumbs {
display: flex;
gap: 4px;
flex: none;
}
.mtl-thumbs img,
.mtl-doc {
width: 36px;
height: 36px;
border-radius: 6px;
object-fit: cover;
background: var(--paper);
border: 1px solid var(--line);
}
.mtl-doc {
display: grid;
place-items: center;
font-size: var(--text-xs);
font-weight: 600;
color: var(--ink-60);
}
.mtl-seal {
flex: none;
width: 36px;
height: 36px;
border-radius: 6px;
display: grid;
place-items: center;
background: var(--sky-100);
color: var(--sky-700);
font-size: 16px;
}
.mtl-proof-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.mtl-proof-title {
font-size: 13px;
font-weight: 500;
}
.mtl-proof-text .tiny {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mtl-arrow {
color: var(--ink-40);
font-size: 20px;
line-height: 1;
}
.mtl-needs {
margin: 6px 0 0;
font-size: 12px;
color: var(--ink-40);
}

/* Photo quality tag: set once at upload on evidence that is too small or
   blurry. Honest, not alarming: terracotta text on its own tint. */
.quality-tag {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
line-height: 1.5;
color: var(--accent);
background: var(--accent-100);
white-space: nowrap;
}
/* Evidence sheet seals: one row per file with its fingerprint. */
.seal-list {
list-style: none;
padding: 0;
margin: 14px 0 0;
border-top: 1px solid var(--line);
}
.seal-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 10px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
align-items: center;
}
.seal-kind {
font-size: 13px;
font-weight: 500;
}
.seal-fp {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
}
.seal-fp code {
font-size: 12px;
color: var(--ink-60);
background: var(--paper);
padding: 2px 6px;
border-radius: 4px;
}
.seal-copy {
min-height: 32px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--royal-700);
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.seal-how {
margin-top: 12px;
}
.seal-how summary {
cursor: pointer;
font-size: 13px;
color: var(--royal-700);
font-weight: 500;
min-height: 44px;
display: flex;
align-items: center;
}

/* Rich trail (run page, order tracking): every step named, every dot
   tappable, and the now-line saying whose move it is. Compact trails on
   cards keep the original three-label form. */
.trail.full {
padding-bottom: 2px;
}
.trail.full .trail-track {
padding: 0;
}
.trail.full button.trail-dot {
cursor: pointer;
}
.trail.full .trail-step:not(.done) button.trail-dot {
border-style: dashed;
border-color: var(--ink-40);
}
.trail.full .trail-step:not(.done) button.trail-dot:hover,
.trail.full .trail-step:not(.done) button.trail-dot:focus-visible {
border-color: var(--royal-500);
border-style: solid;
}
.trail.full .trail-step.current .trail-dot {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--royal-500) 45%, transparent);
animation: trail-pulse 2.2s ease-out infinite;
}
@keyframes trail-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--royal-500) 45%, transparent); }
70% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--royal-500) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--royal-500) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
.trail.full .trail-step.current .trail-dot {
animation: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--royal-500) 25%, transparent);
}
}
.trail-labels-all {
position: relative;
height: 34px;
margin-top: 8px;
font-size: var(--text-xs);
line-height: 1.25;
color: var(--ink-40);
}
.trail-labels-all span {
position: absolute;
top: 0;
transform: translateX(-50%);
text-align: center;
white-space: nowrap;
}
/* Seven labels cannot share one row on a phone: below 700px alternate
   labels drop to a second row, and a faint tick ties each lower label back
   to its dot. Wide screens keep the single calm row. */
@media (max-width: 700px) {
.trail-labels-all {
height: 48px;
}
.trail-labels-all span:nth-child(even) {
top: 22px;
}
.trail-labels-all span:nth-child(even)::before {
content: "";
position: absolute;
left: 50%;
top: -8px;
width: 1px;
height: 7px;
background: var(--line);
}
.trail-labels-all span.last::before {
left: auto;
right: 14px;
}
}
.trail-labels-all span.first {
left: 0;
transform: none;
text-align: left;
}
.trail-labels-all span.last {
left: auto;
right: 0;
transform: none;
text-align: right;
}
.trail-labels-all span em {
display: block;
font-style: normal;
color: var(--ink-40);
font-size: var(--text-xs);
margin-top: 1px;
}
.trail-labels-all span.done {
color: var(--sky-700);
font-weight: 600;
}
.trail-labels-all span.current {
color: var(--royal-700);
font-weight: 700;
font-size: var(--text-xs);
}
.trail-now {
display: flex;
gap: 10px;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 10px;
border-top: 1px dashed var(--line);
padding-top: 10px;
}
.trail-now p {
margin: 0;
flex: 1;
min-width: 200px;
color: var(--ink-60);
}
.trail-now strong {
color: var(--ink);
}
button.trail-next {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
font-weight: 700;
color: var(--royal-700);
}
button.trail-next:hover,
button.trail-next:focus-visible {
text-decoration: underline;
}
@media (max-width: 420px) {
.trail-labels-all {
font-size: var(--text-xs);
}
.trail-labels-all span.current {
font-size: var(--text-xs);
}
}
/* Run page hero: cover, title, the four facts a buyer asks first (runner,
   close, landing, room), and the money line naming the runner. One surface,
   so the answer to "should I join" needs no scrolling. */
.run-crumbs {
font-size: 12px;
color: var(--ink-40);
margin-bottom: 12px;
}
.run-crumbs a {
color: var(--ink-60);
}
.run-hero {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-hero);
overflow: hidden;
margin-bottom: 24px;
}
.run-hero-cover {
position: relative;
background: var(--royal-100);
}
.run-hero-cover img {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.run-hero-cover .cover-badge {
font-size: 12px;
padding: 5px 12px;
}
.run-hero-ds {
padding: 18px 16px;
}
/* No cover photo: the route banner stands in, so it starts below the
   badge line instead of under the closes pill, and the slots pill joins
   that top line rather than sitting on the destination code. */
.run-hero-route {
padding: 52px 16px 18px;
}
/* The banner is always in the markup so a lost cover file falls back to it;
   has-cover (dropped by onerror) hides it whenever a photo is really there. */
.run-hero-cover.has-cover .run-hero-route {
display: none;
}
/* Scoped to the blank state: the banner is now a sibling on every hero, so
   unscoped this would lift the badges on photo heroes too. */
.run-hero-cover:not(.has-cover) .run-hero-route ~ .cover-slots {
top: 10px;
bottom: auto;
}
.run-hero-body {
padding: 16px;
}
.run-hero-title {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.run-hero-title h1 {
margin-top: 4px;
font-size: 24px;
line-height: 1.15;
}
.run-share {
flex: none;
}
.run-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 16px;
}
.fact {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
min-height: 64px;
padding: 10px 12px;
border-radius: var(--r-control);
background: var(--paper);
border: 1px solid transparent;
color: var(--ink);
text-align: left;
font-family: inherit;
cursor: default;
}
a.fact,
button.fact {
cursor: pointer;
}
a.fact:hover,
button.fact:hover,
a.fact:focus-visible,
button.fact:focus-visible {
border-color: var(--royal-500);
text-decoration: none;
}
/* Identity is the trust element on this page, so the runner's cell takes the
   full row: at 390px a half-width cell gave the name 90px and clipped
   "Kojo Mensah" mid-word. Everything else still pairs up two to a row. */
.fact-runner {
grid-column: 1 / -1;
}
.fact-runner .fact-value,
.fact-runner .fact-sub {
white-space: normal;
overflow: visible;
}
.fact-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.fact-label {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-40);
}
.fact-value {
font-family: var(--font-display);
font-weight: 700;
font-size: 15px;
line-height: 1.2;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fact-value.is-urgent {
color: var(--accent);
}
.fact-sub {
font-size: 12px;
color: var(--ink-60);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fact-track {
display: block;
height: 6px;
margin-top: 6px;
}
.run-hero-foot {
margin-top: 14px;
}
.run-hero-foot .escrow-notice {
width: 100%;
}
@media (min-width: 720px) {
.run-hero-cover img {
aspect-ratio: 21 / 8;
}
/* 21/8 of a 1214px content column is 462px of photograph, and with the
   header and the breadcrumb above it the runner's name fell below the fold
   on a 1366x768 laptop, which is the commonest screen this is read on after
   a phone. FRONTEND.md puts runner identity first on this page; a stock
   container-ship photo taking the whole first screen is the opposite of
   that. object-fit is already cover, so this crops rather than squashes. */
@media (min-width: 980px) {
.run-hero-cover img {
max-height: 340px;
}
}
.run-hero-body {
padding: 20px 24px 22px;
}
.run-hero-title h1 {
font-size: 30px;
}
.run-facts {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
}
/* On the run page the hero already leads with the runner and the money
   line, so the phone keeps the shelf next and the side column after. */
.run-layout > .col-side {
order: 0;
}
/* The shelf inside a two-column page: cards stay a readable size instead
   of the storefront's five-up, and the ask tile sits as one more product. */
@media (min-width: 720px) {
.run-shelf-grid {
grid-template-columns: repeat(auto-fill, minmax(172px, 1fr));
}
}
/* The invitation to order something off-shelf. It used to be a 220px dashed
   cell inside the product grid, which read as a missing product; it is a row
   under the shelf now, so the shelf stays a shelf. */
.ask-panel {
display: flex;
align-items: center;
gap: 14px;
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--royal-200);
border-radius: var(--r-card);
background: var(--royal-100);
color: var(--ink);
}
.ask-panel:hover,
.ask-panel:focus-visible {
border-color: var(--royal-500);
text-decoration: none;
}
.ask-mark {
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--surface);
color: var(--royal-700);
}
.ask-mark svg {
width: 20px;
height: 20px;
}
.ask-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.ask-go {
flex: none;
margin-left: auto;
color: var(--royal-700);
display: grid;
place-items: center;
}
.ask-go svg {
width: 18px;
height: 18px;
}
.ask-title {
font-weight: 700;
font-size: var(--text-sm);
color: var(--royal-900);
}
.ask-sub {
font-size: var(--text-xs);
line-height: 1.45;
color: var(--ink-60);
}
@media (max-width: 480px) {
.ask-go {
display: none;
}
}
/* The full step list sits behind a disclosure: open runs collapse it (seven
   future steps are not news yet), travelling runs open it (the record is). */
.mtl-details > summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mtl-details > summary::-webkit-details-marker {
display: none;
}
.mtl-summary-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.mtl-chevron {
width: 20px;
height: 20px;
flex: none;
color: var(--ink-40);
transition: transform 150ms ease;
}
.mtl-details[open] > summary .mtl-chevron {
transform: rotate(180deg);
}
.mtl-details-body {
margin-top: 14px;
}
@media (prefers-reduced-motion: reduce) {
.mtl-chevron {
transition: none;
}
}
/* Empty basket as the join panel. */
.join-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.join-micro {
margin: 0;
}

/* Run manage redesign: the run's next action sits at the top full-width,
   and the add-product form folds behind one unmissable button. */
.manage-next .btn-block,
.manage-next form,
.manage-next > a.btn {
max-width: 460px;
}
details.add-product > summary {
list-style: none;
cursor: pointer;
}
details.add-product > summary::-webkit-details-marker {
display: none;
}
details.add-product > summary:focus-visible .btn {
outline: 2px solid var(--royal-500);
outline-offset: 2px;
}
details.add-product[open] > summary .btn {
background: var(--royal-100);
color: var(--royal-700);
}

/* The buyer pages lead with the trust side-column on mobile (order:-1);
   the manage cockpit must not: the shelf is the work surface, share and
   view-as-buyer belong at the bottom of a phone screen. */
.manage-layout > .col-side {
order: 0;
}
/* Run form (new run, draft edit): the form leads on phones, the tips
   follow; two-up fields; one-tap route chips; percent affixes; the deposit
   field shows only once the toggle is on. */
.form-layout > .col-side {
order: 0;
}
.field-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
@media (min-width: 620px) {
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.route-picks {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.route-pick {
min-height: 32px;
}
.input-affix {
position: relative;
}
.input-affix input {
padding-right: 44px;
}
.input-affix > span {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-weight: 600;
color: var(--ink-40);
pointer-events: none;
}
.deposit-block {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--line-soft);
}
.toggle-row {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
color: var(--ink);
font-size: 14px;
margin: 0;
}
.toggle-row input[type="checkbox"] {
flex: none;
margin-top: 2px;
}
.toggle-title {
display: block;
font-weight: 600;
}
.deposit-block .reveal {
margin-top: 12px;
padding-left: 32px;
}
.deposit-block:not(:has(input[type="checkbox"]:checked)) .reveal {
display: none;
}
/* Page chrome seams (Sep 2026).
   1. A page that ends with a full-bleed band runs straight into the footer:
      no strip of dead paper between two coloured surfaces.
   2. The tab bar is fixed over the page, so the footer keeps its own
      clearance; without it the last line sits under the bar on phones. */
main:has(> .band-flush:last-child) {
padding-bottom: 0;
}
main:has(> .band-flush:last-child) + .site-footer {
margin-top: 0;
}
.site-footer {
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
@media (min-width: 980px) {
.site-footer {
padding-bottom: 0;
}
}

/* The walkthrough (static/js/tour.js). A ring around the real control, a
   bubble beside it, and nothing else: no dimmed screenshot of the product,
   because the point is that the buyer is looking at the product. */
.tour-invite {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin: 14px auto 0;
max-width: 1200px;
padding: 12px 16px;
border: 1px solid var(--royal-100);
border-radius: var(--r-card);
background: var(--royal-100);
}
.tour-invite p {
margin: 0;
min-width: 0;
}
.tour-invite-actions {
gap: 8px;
flex: none;
flex-wrap: wrap;
}
.tour-launch {
flex: none;
}
.tour-launch .icon {
width: 15px;
height: 15px;
margin-right: 5px;
}
.tour-root {
position: fixed;
inset: 0;
z-index: 120;
}
/* No background (14 Sep 2026): this element is the click-to-skip catcher, and
   the dimming is entirely .tour-ring's box-shadow below, which has a HOLE at
   the highlighted element. When the scrim painted too, that hole was filled
   back in by a full-viewport 0.55 wash, so the one thing the tour was pointing
   at was dimmed along with everything else and the ring outlined a murky box.
   A spotlight that dims its subject is not a spotlight. */
.tour-scrim {
position: absolute;
inset: 0;
}
.tour-ring {
position: fixed;
/* Matches --r-card, so the ring hugs the card it is usually drawn around
   instead of cutting its corners. */
border-radius: var(--r-card);
border: 2px solid var(--royal-500);
/* Carries the dimming alone now, so it takes the weight the two used to
   share. The hole in the middle is what makes the subject readable. */
box-shadow: 0 0 0 9999px rgba(23, 16, 64, 0.62);
pointer-events: none;
/* Named properties, not `all`: `all` also transitions the 9999px box-shadow,
   which is the most expensive thing on the page to repaint. */
transition: top var(--dur-med) var(--ease), left var(--dur-med) var(--ease),
  width var(--dur-med) var(--ease), height var(--dur-med) var(--ease);
}
.tour-bubble {
position: fixed;
width: min(320px, calc(100vw - 16px));
padding: 16px;
border-radius: var(--r-card);
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow-pop);
/* The bubble is placed from JS against the viewport, which does not know
   about the bottom tab bar; on a phone it settled on top of it, so Next and
   Skip shared a strip with Hub and Wallet. z-index keeps it above, and the
   scroll margin keeps a long step readable rather than clipped. */
max-height: calc(100vh - 32px);
overflow-y: auto;
z-index: 1;
}
.tour-count {
margin: 0;
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--royal-700);
}
.tour-title {
margin: 4px 0 0;
font-size: var(--text-lg);
line-height: 1.25;
}
.tour-body {
margin: 6px 0 0;
font-size: var(--text-sm);
color: var(--ink-60);
}
.tour-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
}
.tour-nav {
display: flex;
gap: 8px;
}
@media (prefers-reduced-motion: reduce) {
.tour-ring {
transition: none;
}
}
/* Applied to <html> by tour.js while the walkthrough is open, and styled by
   nothing until now: the class was added and removed for no effect. The ring
   tracks the page via a scroll listener (tour.js), so scrolling is not broken
   without it and this is not a bug fix; it stops the page drifting under a
   walkthrough the reader is being led through, which is what the class was
   plainly for. */
.tour-open,
.tour-open body {
overscroll-behavior: contain;
}
/* Card grids where a cell carries its own action (saved items, my orders).
   The card grows to fill the cell, so every card in a row ends on the same
   line; the action sits in a slot that is reserved whether or not this cell
   has one, so the buttons line up too. */
.cell-stack {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
min-width: 0;
}
.cell-stack > .card,
.cell-stack > .listing-card {
flex: 1;
}
.cell-stack > .cell-action {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 44px;
}
/* The note between the card and the action is reserved for the same reason
   the action is, and it was not. The card above takes flex:1, so every line
   the note does NOT use is handed back to the card: on /my/saved, "On a run
   now, closes Sat 26 Sep" fits one line while "Not on a run yet, you will
   hear the moment it is" takes two, and the one-line cell's card grew 18px
   taller than the cards beside it. Two lines is what the longest note needs.
   Expressed in em so it scales with the note's own size, and not in lh,
   which a cheap Android's browser may not know. */
.cell-stack > .cell-note {
margin: 0;
min-height: calc(2 * 1.55em);
}
.cell-stack > .cell-action form {
margin: 0;
}
/* A run with no cover photo keeps the media block, so titles across a row
   start at the same height; the route strip stands in for the photograph. */
.run-cover-blank {
aspect-ratio: 21 / 9;
background: var(--royal-100);
}
/* The stand-in route sits where the photo card's route chip sits, so the
   badges keep the top corners to themselves in both variants. */
.cover-route-panel {
left: 12px;
right: 12px;
bottom: 12px;
max-width: none;
background: transparent;
padding: 0;
}
.cover-route-panel .route {
width: 100%;
background: rgba(255, 255, 255, 0.72);
}
/* Both routes are always in the markup and this class is the only switch, so
   a cover whose FILE is missing (the template's {% if %} proves the row, not
   the file; onerror adds the class) lands on exactly the no-cover state
   above, not a half-state. The scrim goes with them: it is a dark gradient
   drawn for a photograph, and over the flat royal ground it reads as dirt.
   display:none so the hidden half costs no layout. */
.run-cover:not(.run-cover-blank) .cover-route-panel {
display: none;
}
.run-cover-blank .cover-route:not(.cover-route-panel) {
display: none;
}
.run-cover-blank .cover-scrim {
display: none;
}
/* The title row keeps the title on its own line, so a status chip beside it
   can never re-centre it and break the row's shared baseline. */
.run-card-title {
align-items: flex-start;
}

/* Policy pages: a rail between the three, and an unmissable marker for a
   fact only the owner can supply. The marker must never read as settled
   policy, so it carries the accent, not the calm ink. */
.legal-rail {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.legal-rail a {
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 14px;
font-size: var(--text-sm);
font-weight: 600;
color: var(--ink-60);
text-decoration: none;
/* This rail is how a reader gets between the terms, the privacy note and
   the refund rules, so it is navigation and the 44px floor applies. It was
   34px. Relaxed on a pointer, as the tab strip and the account menu are. */
min-height: 44px;
display: inline-flex;
align-items: center;
}
@media (hover: hover) and (pointer: fine) {
.legal-rail a {
min-height: 34px;
}
}
.legal-rail a:hover {
border-color: var(--royal-500);
color: var(--royal-700);
}
.legal-rail a[aria-current="page"] {
background: var(--royal-100);
border-color: var(--royal-100);
color: var(--royal-700);
}
.legal-todo {
display: inline-block;
background: var(--accent-100);
/* --accent-700 was never a token, so this always fell through to the
   fallback. Naming the real one rather than keeping a variable nobody
   defines: tokens.css is explicit that colours are not invented. */
color: var(--accent);
border: 1px dashed var(--accent);
border-radius: var(--r-control);
padding: var(--s-2) var(--s-3);
font-size: var(--text-sm);
font-weight: 600;
}

/* Delivery: pickup points as searchable cards, and the optional map pin.
   Cards beat a select on a phone: the area and the directions are the
   things a buyer actually chooses between. */
.point-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 340px;
overflow-y: auto;
}
.point-card {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid var(--line);
border-radius: var(--r-control);
padding: 11px 13px;
cursor: pointer;
background: var(--surface);
}
.point-card:hover {
border-color: var(--royal-500);
}
.point-card.selected,
.point-card:focus-within {
border-color: var(--royal-500);
box-shadow: inset 0 0 0 1px var(--royal-500);
}
.point-body {
flex: 1;
min-width: 0;
}
.point-name {
display: block;
font-size: 14px;
font-weight: 700;
}
.point-area {
display: block;
font-size: 12px;
color: var(--ink-60);
margin-top: 1px;
}
.point-dir {
display: block;
font-size: 12px;
color: var(--ink-40);
margin-top: 3px;
}
.point-far {
flex: none;
font-size: var(--text-xs);
font-weight: 700;
color: var(--royal-700);
background: var(--royal-100);
border-radius: 999px;
padding: 3px 8px;
white-space: nowrap;
}
.point-card .tick {
flex: none;
}
/* The location block on a delivery form. The tap comes first and the result
   is a card that says a place name, because "Dzorwulu, Accra" is something a
   buyer can check and "5.652479, -0.247153" is not. */
.locate {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.locate [data-pin-note] {
margin: 0;
}
.pin-card {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid var(--royal-500);
background: var(--royal-100);
border-radius: var(--r-control);
padding: 11px 13px;
}
.pin-card .icon {
flex: none;
margin-top: 2px;
}
.pin-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.pin-card-body strong {
font-size: 14px;
}
.pin-card-acts {
flex: none;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.pin-card-acts .btn-linkish {
padding: 0;
}
.pin-rough {
color: var(--accent);
font-weight: 600;
}
/* An offered area, never an imposed one: the field keeps whatever the buyer
   typed until they tap this. */
.chip-suggest {
margin-top: 6px;
border: 1px solid var(--royal-500);
background: var(--surface);
color: var(--royal-700);
border-radius: 999px;
padding: 4px 11px;
font: inherit;
font-size: var(--text-xs);
font-weight: 700;
cursor: pointer;
}
.chip-suggest:hover,
.chip-suggest:focus-visible {
background: var(--royal-100);
}
/* An optional field most buyers do not have an answer for, folded away so it
   stops competing with the two that matter. */
.quiet-details > summary {
cursor: pointer;
color: var(--ink-60);
font-weight: 600;
padding: 4px 0;
}
.quiet-details > summary:hover {
color: var(--royal-700);
}
@media (max-width: 480px) {
.pin-card {
flex-wrap: wrap;
}
.pin-card-acts {
flex-basis: 100%;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
}
/* A button that reads as a plain underlined link: quiet enough to sit under
   a primary action without competing with it, still a button. Checkout wrote
   this out again as six inline declarations under a class name (.btn-plainlink)
   that no stylesheet has ever defined. */
.btn-linkish {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
background: none;
border: none;
padding: 6px 2px;
cursor: pointer;
font: inherit;
font-size: var(--text-sm);
font-weight: 600;
color: var(--ink-60);
text-decoration: underline;
}
@media (hover: hover) and (pointer: fine) {
.btn-linkish {
min-height: 0;
}
}
.btn-linkish:hover,
.btn-linkish:focus-visible {
color: var(--royal-700);
}

/* ---- sentinel: runner onboarding card (apps/accounts/onboarding.py) ---- */
.onboarding-card {
border-color: var(--royal-100);
background: var(--royal-100);
}
.onboarding-card .chip {
background: var(--surface);
}
/* On a phone the sentence and the action each get the full width: side by
   side, the sentence is squeezed into a four-word column. */
.onboarding-card .row-between {
flex-direction: column;
align-items: stretch;
}
.onboarding-card .btn {
width: 100%;
text-align: center;
}
@media (min-width: 620px) {
.onboarding-card .row-between {
flex-direction: row;
align-items: center;
}
.onboarding-card .btn {
width: auto;
}
}
/* ---- end runner onboarding ---- */

/* ---- sentinel: the in-stock shelf (apps/runs/shelf.py) ---- */
/* A parcel already in Ghana has four steps and no journey to draw, so this is
   a list rather than the trip trail's map. */
.shelf-steps {
list-style: none;
margin: 6px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.shelf-step {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 7px 0;
}
.shelf-dot {
flex: none;
width: 20px;
height: 20px;
margin-top: 1px;
border-radius: 50%;
border: 2px solid var(--line);
background: var(--surface);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
}
.shelf-dot svg {
width: 11px;
height: 11px;
}
.shelf-step.is-done .shelf-dot {
background: var(--royal-500);
border-color: var(--royal-500);
}
.shelf-step.is-now .shelf-dot {
background: var(--surface);
border-color: var(--royal-500);
box-shadow: 0 0 0 3px var(--royal-100);
}
.shelf-body {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.shelf-label {
font-size: 14px;
font-weight: 600;
color: var(--ink-40);
}
.shelf-step.is-done .shelf-label {
color: var(--ink);
}
.shelf-step.is-now .shelf-label {
color: var(--royal-700);
font-weight: 700;
}
/* Counted and low is a fact worth acting on; never counted says "ask", which
   must not look like either plenty or nothing. */
.stock-left {
font-weight: 700;
color: var(--accent);
}
.listing-card.is-out .photo {
opacity: 0.55;
}
/* ---- end in-stock shelf ---- */

/* ---- motion (14 Sep 2026) -------------------------------------------------

   FRONTEND.md allows the HTMX swap fade, the snail marker easing to its
   milestone, and nothing decorative. Everything here is one of two things
   that spec did not have a word for and that a storefront is judged on:
   telling the buyer their tap landed, and telling them what is tappable.

   Three rules held throughout:
   - Press feedback (:active) is for every pointer, because it answers "did
     that register" on a slow 3G connection where nothing else will for a
     second or more. It is the single cheapest quality signal on the site.
   - Lift feedback (:hover) is behind @media (hover: hover). On a touch
     screen :hover latches after a tap, so an unguarded lift leaves a raised
     card sitting behind every time a buyer returns to a grid.
   - Transform and opacity only. No animation here can trigger layout, which
     is what keeps this affordable on the low-end Android the whole build is
     budgeted for.

   The reduced-motion block at the end is the authority: it removes movement
   without removing the feedback, so a buyer who has asked for stillness
   still sees a pressed control change colour and still sees focus. */

.btn:active {
  transform: scale(0.97);
}
.btn {
  transition: transform var(--dur-press) var(--ease),
    background-color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease);
}

/* .card-link already changed border and background on hover; it just did it
   instantly, and did it on touch too. */
.card-link {
  transition: transform var(--dur-fast) var(--ease),
    box-shadow var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease);
}
@media (hover: hover) {
  .card-link:hover {
    box-shadow: var(--shadow-lift);
    transform: translateY(-2px);
  }
  /* The run cover photo leans in with the card it belongs to. Scoped to the
     cover inside a lifted card so no other image on the site inherits it. */
  .card-link:hover .run-cover img {
    transform: scale(1.03);
  }
}
.card-link:active {
  transform: scale(0.992);
}
.run-cover img {
  transition: transform var(--dur-med) var(--ease);
}

/* Chips only. The category circles are deliberately NOT here: .cat-circle
   .disc already carries its own :active scale(0.94), and adding a second
   transform to the parent would compound to about 0.90, which reads as a
   flinch rather than a press. Checked rather than assumed. */
.chip:active {
  transform: scale(0.96);
}
.chip {
  transition: transform var(--dur-press) var(--ease),
    background-color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease);
}
/* A chip is two different things wearing one class: a filter you tap (the
   origins on /runs/, the categories on /catalogue/) and a label you read
   (the status on an order row). Only the first needs the 44px touch floor,
   so this selects the tappable ones by element rather than by class, and
   buys the height as an invisible hit area rather than as padding: a
   27px pill that a thumb can miss is the defect, a 44px pill would be a
   different, fatter design. The row keeps its density and the target
   grows underneath it. Vertical only, so neighbours in the same row are
   never covered. */
a.chip,
button.chip {
position: relative;
}
a.chip::after,
button.chip::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 44px;
transform: translateY(-50%);
}
@media (hover: hover) and (pointer: fine) {
a.chip::after,
button.chip::after {
content: none;
}
}

@media (prefers-reduced-motion: reduce) {
  .btn,
  .btn:active,
  .card-link,
  .card-link:active,
  .chip,
  .chip:active,
  .listing-card,
  .listing-card:active,
  .trust-claim,
  .run-cover img,
  .listing-card .photo img {
    transition: none;
    transform: none;
  }
  @media (hover: hover) {
    .card-link:hover,
    .listing-card:hover {
      transform: none;
    }
    .card-link:hover .run-cover img,
    .listing-card:hover .photo img {
      transform: none;
    }
  }
}
/* ---- end motion ---- */

/* ---- touch targets: the 44px floor, kept (14 Sep 2026) -------------------

   FRONTEND.md sets 44px as the minimum for anything a buyer taps. A sweep of
   22 page views found three classes of control below it on every page that
   carried them, so the floor was stated but not held. Appended here rather
   than edited in place because both selectors need to be narrower than the
   base rules they qualify, and narrowing in place would have changed the
   static variants too. */

/* A chip is two different things wearing one class: a static LABEL ("Released",
   "Verified runner", "Closes in 2 days") and a CONTROL (the catalogue's price
   presets at 31px, the runs list's origin filters at 29px). Only the control
   has to clear 44px; forcing it on the labels would bloat every status pill on
   the site. The element type is the honest test of which one it is. */
a.chip,
button.chip {
  min-height: 44px;
}

/* The trail dot is 12px because the trail reads as a line of small marks, and
   it must stay 12px. But every milestone opens its evidence, so it is also a
   button, and it was a 12px one. The pseudo-element gives it a 44px hit area
   centred on the dot without changing anything you can see: the dot keeps its
   size, the tap keeps its floor. */
button.trail-dot::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
}
/* ---- end touch targets ---- */

/* ---- the invitation (14 Sep 2026) ---------------------------------------

   Walupi's supply side is invite-only, so this page is the first thing a
   runner ever sees of the platform, and it was a form with a sentence above
   it. The sentence carried the whole meaning: somebody is putting their own
   reputation behind you, and if you let people down it costs THEM. That is
   the product in one line and it was set as muted 14px body copy.

   So the invitation is given a face and a seal. Nothing here is decoration
   for its own sake: the avatar and badges are the inviter's real, checkable
   standing, and the pledge line is the vouch the ledger will actually hold
   them to. */

.invite-card {
  background: var(--royal-100);
  border: 1px solid var(--royal-200);
  border-radius: var(--r-hero);
  padding: 26px 22px 22px;
  text-align: center;
  position: relative;
  /* Clear of whatever follows: the runner pitch is a dark band and the two
     were rendering flush, reading as one panel with a colour change. */
  margin-bottom: var(--s-4);
}
/* The seal sits ON the card's top edge, half in and half out, the way a wax
   seal sits on an envelope rather than inside the letter. */
.invite-seal {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 46px;
  height: 46px;
  border-radius: 50%;
  background: var(--surface);
  border: 1px solid var(--royal-200);
  display: grid;
  place-items: center;
  color: var(--royal-700);
}
.invite-seal svg {
  width: 26px;
  height: 18px;
}
.invite-eyebrow {
  margin: 8px 0 0;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--royal-700);
}
.invite-who {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  margin-top: 14px;
}
/* The avatar disc is royal-100 by default, which is this card's own ground,
   so the inviter's face was an initial floating on nothing. On this one
   surface it takes the white the rest of the site gives it against paper. */
.invite-who .avatar {
  background: var(--surface);
  border: 1px solid var(--royal-200);
  color: var(--royal-900);
}
.invite-name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--text-xl);
  line-height: 1.2;
  letter-spacing: -0.01em;
}
.invite-badges {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
}
/* The pledge. Divided from the identity above it because it is a different
   kind of statement: who they are, then what they are risking. */
.invite-pledge {
  margin: 16px 0 0;
  padding-top: 14px;
  border-top: 1px solid var(--royal-200);
  font-size: var(--text-sm);
  line-height: 1.55;
  color: var(--ink);
}
.invite-pledge b {
  font-weight: 700;
}

/* The one-time code. Was three inline styles on the input; it appears on the
   invite and at sign-in, so it is a component. Wide tracking and a centred
   line make six digits readable as six digits rather than a number. */
.code-input {
  width: 100%;
  text-align: center;
  font-family: var(--font-money);
  font-size: var(--text-2xl);
  font-weight: 700;
  letter-spacing: 0.34em;
  /* The tracking is applied to the right of each glyph including the last,
     which pushes the string visibly off-centre; half of it back as padding
     re-centres what the eye actually sees. */
  padding-left: 0.34em;
}

/* A link the user is meant to copy, not read: the field and its actions read
   as one control instead of an input with buttons loose beside it. */
.linkbox {
  display: flex;
  align-items: stretch;
  gap: 8px;
  flex-wrap: wrap;
}
.linkbox input {
  flex: 1 1 220px;
  min-width: 0;
  font-size: var(--text-sm);
  color: var(--ink-60);
  background: var(--line-soft);
}
/* Destructive and regenerative actions on a link: real buttons, not text
   with six inline declarations pretending to be one. */
.link-action {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  font-size: var(--text-xs);
  font-weight: 700;
  cursor: pointer;
  color: var(--royal-700);
  min-height: 44px;
}
.link-action.is-danger {
  color: var(--danger-700);
}
.link-action:hover {
  text-decoration: underline;
}
/* The shared auth shell, moved out of five inline style attributes so the
   invitation can drop its masthead without fighting them. */
.auth-card-wrap {
  max-width: 440px;
  margin: 32px auto 0;
}
.auth-card-head {
  text-align: center;
  margin-bottom: 20px;
}
.auth-card-mark {
  display: inline-block;
  width: 48px;
  color: var(--royal-500);
}
.auth-card-head h1 {
  margin-top: 10px;
}
.auth-card-head p {
  margin-top: 6px;
}
.auth-card-body {
  padding: 24px;
}
.auth-card-foot {
  text-align: center;
  margin-top: 12px;
}
/* ---- end invitation ---- */

/* Drops: the runner feed (templates/buyer/drops.html, components/drop_card).
   A phone-shaped card that carries a photo, who posted it, what it costs and
   one way in. The media box is a fixed 4:5 so nothing jumps while a thumb
   loads; the caption and the runner sit ON the photo under a gradient, which
   is what makes it read as a feed rather than a list of tiles; and the price,
   the countdown and the CTA sit below it on solid surface, because those are
   the parts a buyer has to read in sunlight, on a phone, one-handed.

   A clip is a <video> swapped in on tap (static/js/drops.js). Nothing here
   autoplays and nothing moves on its own. */
.drop-wrap { max-width: 540px; }
.drop-head { margin-bottom: 8px; }
.drop-title { font-size: var(--text-xl); margin: 0 0 2px; }
.drop-sub { margin: 0; font-size: var(--text-sm); color: var(--ink-60); }
.drop-lanes { margin-bottom: 10px; }
.drop-feed {
display: flex;
flex-direction: column;
gap: 18px;
scroll-snap-type: y proximity;
}
.drop-card {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
overflow: hidden;
scroll-snap-align: start;
box-shadow: var(--shadow-lift);
}
.drop-card-closed { opacity: 0.82; }

/* ---- the photo, and what sits on it ---- */
.drop-media {
position: relative;
aspect-ratio: 4 / 5;
max-width: 100%;
background: var(--night);
}
.drop-media img, .drop-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.drop-video { object-fit: contain; background: #000; }
.drop-photo { display: block; height: 100%; }
.drop-play {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: none;
cursor: pointer;
position: relative;
}
.drop-play:focus-visible { outline: 3px solid var(--royal-500); outline-offset: -3px; }
/* The play mark is the only thing on the card that says "there is more here",
   so it is big enough to be the obvious tap target and dark enough to survive
   a bright photo behind it. */
.drop-playmark {
position: absolute;
left: 50%;
top: 44%;
transform: translate(-50%, -50%);
width: 62px;
height: 62px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.46);
border: 2px solid rgba(255, 255, 255, 0.9);
display: grid;
place-items: center;
}
.drop-playmark svg { width: 28px; height: 28px; fill: #fff; margin-left: 3px; }
.drop-clip {
position: absolute;
right: 10px;
top: 10px;
padding: 4px 9px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: var(--text-xs);
font-weight: 600;
}
.drop-flags {
position: absolute;
left: 10px;
top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
max-width: calc(100% - 92px);
}
.drop-flag {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 5px 10px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
background: rgba(255, 255, 255, 0.94);
color: var(--ink);
box-shadow: var(--shadow-pop);
}
/* Green is for product prices and nothing else (FRONTEND.md), which is why
   the money flag is the only green thing on the card. */
.drop-price { color: var(--price); font-family: var(--font-money); font-size: var(--text-sm); }
.drop-unit { font-family: var(--font); font-size: var(--text-xs); font-weight: 600; color: var(--ink-60); }
.drop-flag-royal { background: var(--royal-700); color: #fff; }
.drop-flag-urgent { background: var(--accent); color: #fff; }
.drop-flag-plain { background: rgba(26, 24, 38, 0.72); color: #fff; }

/* The rail: keep and share, thumb-height on the right, where a hand holding a
   phone already is. */
.drop-rail {
position: absolute;
right: 8px;
bottom: 96px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.drop-rail-form { margin: 0; }
.drop-rail-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 54px;
padding: 6px 0;
border: 0;
border-radius: var(--r-card);
background: rgba(26, 24, 38, 0.5);
color: #fff;
text-decoration: none;
font-size: var(--text-xs);
font-weight: 700;
cursor: pointer;
}
.drop-rail-btn svg {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.drop-rail-btn.is-on { color: var(--accent-100); }
.drop-rail-btn.is-on svg { fill: var(--accent); stroke: var(--accent); }
.drop-rail-btn:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }

/* The veil: identity and words legible over any photo. */
.drop-veil {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 40px 12px 12px;
background: linear-gradient(to top, rgba(12, 10, 24, 0.88), rgba(12, 10, 24, 0.55) 45%, rgba(12, 10, 24, 0));
color: #fff;
pointer-events: none;
}
.drop-veil a { pointer-events: auto; }
.drop-runner {
display: inline-flex;
align-items: center;
gap: 9px;
color: #fff;
text-decoration: none;
max-width: 100%;
min-width: 0;
}
.drop-runner-who { min-width: 0; }
.drop-runner-who strong {
display: block;
font-size: var(--text-sm);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.drop-runner-line { font-size: var(--text-xs); color: rgba(255, 255, 255, 0.82); }
.drop-caption {
margin: 8px 0 0;
font-size: var(--text-sm);
line-height: 1.45;
overflow-wrap: anywhere;
/* Three lines on the photo, the rest on the page it opens: a caption that
   covers the product is an advert nobody can check. */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}

/* ---- the half a buyer acts on ---- */
.drop-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; }
.drop-note { margin: 0; font-size: var(--text-sm); color: var(--ink-60); }
.drop-cta { margin: 0; }
.drop-escrow { margin: 0; text-align: center; font-size: var(--text-xs); color: var(--ink-60); }
.drop-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: var(--text-xs);
color: var(--ink-60);
}
.drop-reach { font-variant-numeric: tabular-nums; }
.drop-report summary { cursor: pointer; list-style: none; color: var(--ink-60); }
.drop-report summary::-webkit-details-marker { display: none; }
.drop-more { margin-top: 16px; }

/* The runner's own numbers, above their composer. Money face, because these
   are the figures they will judge a photo by. */
.drop-reach-band {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
background: var(--night);
color: #fff;
border-radius: var(--r-card);
padding: 14px 16px;
margin-bottom: 16px;
}
.drop-reach-n {
display: block;
font-family: var(--font-money);
font-size: var(--text-xl);
line-height: 1.1;
}
.drop-reach-k { font-size: var(--text-xs); color: var(--night-muted); }

/* A strip of finds on the home page and on a runner's storefront: the same
   photo, small, with the price kept and everything else dropped. */
.drop-strip { display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x proximity; padding-bottom: 4px; }
.drop-strip::-webkit-scrollbar { display: none; }
.drop-tile {
position: relative;
flex: none;
width: 150px;
scroll-snap-align: start;
border-radius: var(--r-card);
overflow: hidden;
background: var(--night);
text-decoration: none;
color: #fff;
border: 1px solid var(--line);
}
.drop-tile img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; display: block; }
.drop-tile-veil {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 26px 8px 8px;
background: linear-gradient(to top, rgba(12, 10, 24, 0.9), rgba(12, 10, 24, 0));
font-size: var(--text-xs);
font-weight: 700;
}
/* The only green that is not --price: on a dark tile the ink-on-white price
   green fails contrast, so this is its lightened twin, same hue family. */
.drop-tile-price { font-family: var(--font-money); color: #7ee2ad; display: block; }
.drop-tile-who {
display: block;
font-weight: 600;
color: rgba(255, 255, 255, 0.84);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.drop-tile-clip {
position: absolute;
right: 6px;
top: 6px;
padding: 2px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.6);
font-size: var(--text-xs);
font-weight: 700;
}
@media (min-width: 700px) {
/* No bottom tab bar to clear on a desk, so the rail drops to the corner. */
.drop-rail { bottom: 12px; }
}

/* The Drops player (templates/buyer/drops_watch.html, static/js/drops_watch.js).
   Owner decision, 17 Sep 2026: a full-screen auto-advancing viewer, the shape
   WhatsApp taught this market. It is the one surface on Walupi that spends a
   buyer's data without being asked first, which is why it never renders under
   data saver and why the clip's weight is printed on it in the same breath as
   the price. Everything is one dark stage: chrome floats on the media rather
   than sitting beside it, because a phone screen has no room for both. */
/* The reels player. One drop a screen, snapped, with everything you can do to
   it on a rail down the right where a thumb already rests (owner, 18 Sep
   2026). It replaces a stories-shaped player: progress bars across the top,
   tap zones over the media, actions stacked under the caption.

   The scrolling is the browser's, not JavaScript's: scroll-snap is handled off
   the main thread, and a hand-rolled transform player fights the compositor on
   exactly the cheap Android this is built for. */
body.watching { background: #000; overscroll-behavior: none; overflow: hidden; }
.watch-main { padding: 0; max-width: none; }
/* Fixed, not relative: anything the base template puts above this (a message
   banner, an offline notice) would otherwise push the player down the page and
   the PAGE would scroll instead of the track, which kills snapping outright. */
.reels {
position: fixed;
inset: 0;
z-index: 20;
background: #000;
color: #fff;
/* dvh, not vh: on a phone the URL bar collapses mid-scroll and vh leaves a
   band of the next reel showing under the current one for ever. */
height: 100dvh;
overflow: hidden;
}
.reels-track {
height: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
.reels-track::-webkit-scrollbar { display: none; }
.reel {
position: relative;
height: 100dvh;
scroll-snap-align: start;
scroll-snap-stop: always;
display: block;
overflow: hidden;
}
.reel-media {
position: absolute;
inset: 0;
background: #000;
}
.reel-media video,
.reel-media img {
width: 100%;
height: 100%;
/* contain, so a 4:3 photo from a phone is not cropped to a 9:16 slot: the
   same rule the listing hero follows, for the same reason. */
object-fit: contain;
position: relative;
z-index: 1;
}
/* The rest of the screen: the same photo, blown up and blurred, behind the
   contained one, the way the listing hero fills its box. A 4:5 photo in a
   9:19 slot used to sit in a black letterbox with the foot on black; now the
   frame is full and the foot sits on the shade. The variable is set inline
   per reel from the poster URL, which is already loading, so it costs no
   extra request. */
.reel-media::before {
content: "";
position: absolute;
inset: -24px;
background: #000 var(--bg, none) center / cover no-repeat;
filter: blur(28px) brightness(0.55);
}
/* The gradient the caption and the rail sit on, so white text stays legible
   over a photo that happens to be white. */
.reel-shade {
position: absolute;
inset: 0;
pointer-events: none;
/* Weighted for a WHITE photo, not a dark one, because product-on-white is the
   most common shot in the catalogue rather than an edge case. The first
   version faded to 0.3 by 28% up, which is where the runner's name and the
   caption actually sit: white text on ~70% luminance, about 1.4:1. It read
   fine in review only because the drop I looked at happened to be dark. */
background:
  linear-gradient(
    to top,
    rgba(0, 0, 0, 0.88) 0%,
    rgba(0, 0, 0, 0.62) 20%,
    rgba(0, 0, 0, 0.28) 38%,
    rgba(0, 0, 0, 0) 58%
  ),
  linear-gradient(to bottom, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 20%);
}
.reel-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 76px;
height: 76px;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.92);
color: var(--indigo-950, #171040);
display: grid;
place-items: center;
cursor: pointer;
}
.reel-play svg { width: 34px; height: 34px; margin-left: 4px; }
.reel.is-playing .reel-play { display: none; }
.reels-close {
position: absolute;
top: calc(12px + env(safe-area-inset-top, 0px));
left: 12px;
z-index: 3;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
color: #fff;
display: grid;
place-items: center;
}
.reels-close svg { width: 22px; height: 22px; }
.reels-hint {
position: absolute;
left: 50%;
/* In the band above the photo, not 22% up from the bottom, which on a 4:5
   photo at phone width is exactly where the runner's name and the price
   flags sit: for four seconds the name of the person you are trusting was
   under a pill saying swipe up. Hidden on the first flick (drops_watch.js). */
top: calc(64px + env(safe-area-inset-top, 0px));
transform: translateX(-50%);
z-index: 3;
margin: 0;
padding: 7px 14px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: var(--text-xs);
pointer-events: none;
}

/* The rail. */
.reel-rail {
position: absolute;
right: 8px;
bottom: calc(190px + env(safe-area-inset-bottom, 0px));
z-index: 2;
display: flex;
flex-direction: column;
gap: 18px;
align-items: center;
/* isolate so the halo below can sit behind the buttons without escaping to
   the media underneath. */
isolation: isolate;
}
/* A soft halo behind the rail, and it is not decoration.

   .reel-shade darkens the top and the bottom of the frame, which is where the
   caption and the close button live. The rail sits in the MIDDLE of the right
   edge, which that gradient never reaches, so white icons with a drop-shadow
   were white-on-white the moment a photo happened to be pale: a screenshot of
   a light lavender product shot had Keep and Comment effectively invisible.

   A radial fade rather than a panel: on a dark photo it is unnoticeable, and
   on a light one it buys the contrast without putting a grey box on somebody's
   goods. */
.reel-rail::before {
content: "";
position: absolute;
inset: -18px -12px -18px -20px;
z-index: -1;
border-radius: 44px;
background: radial-gradient(
  ellipse at 60% 50%,
  rgba(0, 0, 0, 0.42) 0%,
  rgba(0, 0, 0, 0.26) 45%,
  rgba(0, 0, 0, 0) 74%
);
pointer-events: none;
}
.reel-act {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 54px;
padding: 0;
border: 0;
background: none;
color: #fff;
font-size: var(--text-xs);
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.reel-act svg {
width: 30px;
height: 30px;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
}
.reel-act b { font-size: 12px; font-variant-numeric: tabular-nums; }
.reel-act[aria-pressed="true"] svg { fill: var(--accent-600); stroke: var(--accent-600); }
.reel-act.is-done span::after { content: " copied"; }
.reel-act:focus-visible { outline: 2px solid #fff; outline-offset: 3px; border-radius: 8px; }
.reel-act:active { transform: scale(0.92); }

/* The caption block, left of the rail so long captions never run under it. */
.reel-foot {
position: absolute;
left: 0;
right: 74px;
bottom: 0;
z-index: 2;
padding: 0 14px calc(16px + env(safe-area-inset-bottom, 0px));
display: grid;
gap: 8px;
}
.reel-name {
color: #fff;
font-weight: 700;
font-size: var(--text-sm);
text-decoration: none;
}
.reel-name em {
display: block;
font-style: normal;
font-weight: 400;
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.78);
}
.reel-flags { display: flex; flex-wrap: wrap; gap: 6px; }
.reel-flag {
padding: 3px 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
font-size: var(--text-xs);
font-weight: 600;
}
.reel-flag.is-price { color: #7ef0b0; font-family: var(--font-money); }
.reel-flag.is-urgent { background: var(--accent-600); }
.reel-flag.is-cost { background: rgba(255, 255, 255, 0.14); font-weight: 500; }
.reel-caption {
margin: 0;
font-size: var(--text-sm);
line-height: 1.45;
/* Three lines, then it stops: a caption that fills the screen is a caption
   nobody reads, and the door underneath is what matters. */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.reel-cta { margin: 2px 0 0; }
.reel-escrow {
margin: 0;
text-align: center;
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.76);
}

/* The comments sheet. */
body.reel-sheet-open { overflow: hidden; }
.reel-sheet {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: flex-end;
}
.reel-sheet-scrim {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.reel-sheet-body {
position: relative;
width: 100%;
max-height: 72dvh;
display: flex;
flex-direction: column;
background: var(--surface);
color: var(--ink);
border-radius: var(--r-hero) var(--r-hero) 0 0;
padding: 4px 14px calc(12px + env(safe-area-inset-bottom, 0px));
animation: sheet-up 180ms ease-out;
}
@keyframes sheet-up { from { transform: translateY(16px); opacity: 0.6; } }
@media (prefers-reduced-motion: reduce) { .reel-sheet-body { animation: none; } }
.reel-sheet-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0 8px;
}
.reel-sheet-top h2 { font-size: var(--text-sm); margin: 0; }
.reel-sheet-x {
width: 34px;
height: 34px;
border: 0;
background: none;
color: var(--ink-60);
cursor: pointer;
}
.reel-sheet-x svg { width: 18px; height: 18px; }
.reel-sheet-list {
flex: 1;
overflow-y: auto;
display: grid;
gap: 12px;
padding-bottom: 10px;
}
.reel-comment b { font-size: var(--text-xs); }
.reel-comment b em {
font-style: normal;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
background: var(--royal-700);
color: #fff;
font-size: var(--text-xs);
}
.reel-comment p { margin: 2px 0 0; font-size: var(--text-sm); line-height: 1.45; }
.reel-comment time { font-size: var(--text-xs); color: var(--ink-40); }
.reel-sheet-empty { color: var(--ink-60); font-size: var(--text-sm); text-align: center; padding: 18px 0; }
.reel-sheet-empty.is-bad { color: var(--danger-700); font-weight: 600; }
.reel-sheet-form { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid var(--line); }
.reel-sheet-form input { flex: 1; }
.reel-sheet-signin { text-align: center; font-size: var(--text-sm); padding: 12px 0 4px; }

.drop-watch-cta { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 10px; }
.drop-watch-cta svg { width: 18px; height: 18px; fill: currentColor; }
@media (min-width: 700px) {
/* On a desk the player is a phone-shaped column on a dark ground rather than
   a stretched cinema: the media was shot in portrait. */
.watch { max-width: 460px; left: 50%; transform: translateX(-50%); }
}
/* A drop whose media the sweep has taken. Deliberately quiet rather than an
   error: nothing went wrong, the thing was always going to go. */
.drop-gone {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--night-muted);
text-align: center;
padding: 20px;
}
.drop-gone svg { width: 28px; height: 28px; }

/* Two ways to pay, side by side in the sticky bar (buyer/checkout.html).
   Pay-small-small used to be a card halfway up the page with a quiet button
   under it, so a run that accepted deposits still took full payments: the bar
   is the only part of checkout that does not scroll away, and it offered one
   amount. Both live here now, the same size, with the full amount still the
   primary. They wrap to two rows rather than squeezing when a figure is long,
   because a truncated amount on a payment button is worse than a taller bar. */
.cta-pair {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.cta-pair-btn {
flex: 1 1 148px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
line-height: 1.2;
text-decoration: none;
}
.cta-pair-what {
font-size: 13px;
font-weight: 600;
opacity: 0.9;
}
.cta-pair-sum {
font-size: 17px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cta-pair .btn-quiet {
border: 1px solid var(--royal-700);
color: var(--royal-700);
background: var(--surface);
}
.cta-pair .btn-quiet:hover {
background: var(--royal-100);
}

/* ---- Tab rail: filters that do not fit, and say so -----------------------
   The notifications bell has six tabs for staff ("All", "Money", "Orders and
   runs", "Messages", "People", "Ops"). At 390px the last two sit outside the
   strip and at 360px so does Messages, with nothing on screen suggesting
   there is more: an operator could not see that an Ops tab existed, let alone
   that it held two unread. A rail that scrolls is fine on a phone; a rail
   that scrolls SECRETLY is a menu with items missing.

   So the rail says so. A fade at whichever edge has more behind it, the
   active tab scrolled into view on load (a staff user landing on ?tab=ops
   must not arrive looking at "All"), and the count beside its label instead
   of underneath it.

   Scoped away from .tabs on purpose: that control is also the login page and
   the runs list, where three short labels fit and none of this is needed. */
.tabrail {
position: relative;
}
.tabrail-strip {
display: flex;
gap: 4px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--r-control) + 2px);
padding: 3px;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
/* Matches the padding above. Without it the snapport starts inside the
   padding, so the first tab snaps to scrollLeft 3 rather than 0, the rail
   never reports itself as "at start", and a fade sits over "All" implying
   there is something to its left. */
scroll-padding-inline: 3px;
}
.tabrail-strip::-webkit-scrollbar {
display: none;
}
/* The fade is a sibling overlay rather than a ::after on the scroller, which
   would scroll away with the content it is meant to sit over. */
.tabrail::before,
.tabrail::after {
content: "";
position: absolute;
top: 1px;
bottom: 1px;
width: 28px;
pointer-events: none;
opacity: 0;
transition: opacity 140ms ease;
border-radius: var(--r-control);
}
.tabrail::before {
left: 1px;
background: linear-gradient(to right, var(--surface), rgba(255, 255, 255, 0));
}
.tabrail::after {
right: 1px;
background: linear-gradient(to left, var(--surface), rgba(255, 255, 255, 0));
}
/* Set by static/js/tabrail.js. With no JavaScript neither fade shows and the
   rail still scrolls, which is exactly what it did before. */
.tabrail[data-overflow="start"]::before,
.tabrail[data-overflow="both"]::before {
opacity: 1;
}
.tabrail[data-overflow="end"]::after,
.tabrail[data-overflow="both"]::after {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.tabrail::before,
.tabrail::after {
transition: none;
}
}

.tabrail-tab {
display: inline-flex;
align-items: center;
gap: 7px;
/* 44px is the touch floor in FRONTEND.md; the old strip was 36. */
min-height: 44px;
padding: 0 14px;
border-radius: var(--r-control);
font-size: var(--text-sm);
font-weight: 600;
color: var(--ink-60);
white-space: nowrap;
scroll-snap-align: start;
flex: none;
}
.tabrail-tab:hover {
text-decoration: none;
color: var(--royal-700);
background: var(--royal-100);
}
.tabrail-tab:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: -2px;
}
.tabrail-tab.active {
background: var(--royal-700);
color: #fff;
}
.tabrail-tab.active:hover {
color: #fff;
}

/* Beside the label, never under it. .ap-count is display:grid with
   margin-left:auto because it lives in a row elsewhere; inherited here it
   became a block on its own line and stretched "Orders and runs" into a
   full-width terracotta bar. */
.tabrail-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
/* Terracotta on royal is unreadable, and the tab you are already on does not
   need to shout the number at you. */
.tabrail-tab.active .tabrail-count {
background: #fff;
color: var(--royal-700);
}

/* ---- A runner's own drops, as a grid of the drops ------------------------
   This page listed them as grey rows with a 72px thumbnail, which is an odd
   way to review a surface whose entire content is a photograph: four drops
   ran 2331px down a phone, and the pictures, the thing a runner is judging,
   were the smallest element on screen. It is their grid now, the shape every
   creator surface uses, because the question being asked here is "which of
   these worked" and that is answered by looking.

   Reach rides ON the tile for the same reason it rides on the card in the
   feed: seen and opened are what decide whether a runner posts again, and a
   number under a photo in 11px grey is a number nobody reads. */
.dropgrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (min-width: 720px) {
.dropgrid {
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
}
}
.dropgrid-cell {
min-width: 0;
}
.dropgrid-tile {
position: relative;
display: block;
aspect-ratio: 4 / 5;
border-radius: var(--r-card);
overflow: hidden;
background: var(--night);
border: 1px solid var(--line);
}
.dropgrid-tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.dropgrid-tile:hover {
text-decoration: none;
}
.dropgrid-tile:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
/* Media the expiry sweep has taken. The row outlives its files on purpose
   (counts and moderation trail), so this is a normal end state, not an
   error, and it should not read like a broken image. */
.dropgrid-gone {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px;
text-align: center;
color: rgba(255, 255, 255, 0.72);
font-size: var(--text-xs);
line-height: 1.4;
}
.dropgrid-gone svg {
width: 22px;
height: 22px;
opacity: 0.8;
}
.dropgrid-reach {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 10px;
padding: 22px 10px 8px;
background: linear-gradient(to top, rgba(22, 21, 31, 0.82), rgba(22, 21, 31, 0));
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dropgrid-reach span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.dropgrid-reach svg {
width: 13px;
height: 13px;
opacity: 0.9;
}
/* Only when it is NOT simply live: a chip on every tile would be noise, and
   the states that matter are the two a runner has to act on. */
.dropgrid-state {
position: absolute;
top: 8px;
left: 8px;
padding: 3px 8px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
background: rgba(22, 21, 31, 0.78);
color: #fff;
}
.dropgrid-state.is-down {
background: var(--danger-700);
}
.dropgrid-clip {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 7px;
border-radius: 999px;
background: rgba(22, 21, 31, 0.78);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dropgrid-clip svg {
width: 11px;
height: 11px;
}
.dropgrid-caption {
display: block;
margin: 7px 0 0;
font-size: var(--text-xs);
line-height: 1.4;
color: var(--ink);
/* Two lines, then stop: the caption is here to tell one drop from another,
   not to be read again. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dropgrid-meta {
display: block;
margin-top: 3px;
font-size: var(--text-xs);
color: var(--ink-40);
}
/* Layout only. The two controls inside are .iconbtn now, and a
   `.dropgrid-acts button` rule (type plus class) outranks `.iconbtn` (class),
   so anything left here would silently win against the component. */
.dropgrid-acts {
display: flex;
align-items: center;
gap: 4px;
margin-top: 5px;
margin-left: -6px;
flex-wrap: wrap;
}
.dropgrid-why {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
line-height: 1.4;
color: var(--danger-700);
}

/* ---- The run MANAGE hero (.rmhero, not .run-hero) -------------------------------------------------------
   The run page opened on plain paper: a crumb, a title, a couple of grey
   chips, then a stack of white cards of identical weight, with the numbers a
   runner came to check scattered down it. The runner's own dashboard already
   has the right answer for "this is yours and here is where it stands", the
   indigo wallet hero, so this is that pattern applied to one run.

   Same tokens, same radius, same inverted type as .portal-hero. Nothing new
   is invented here: what makes it read is that the identity, the state and
   the four numbers finally sit together on one surface instead of being
   four cards in a column.

   NAMED .rmhero because .run-hero is already the BUYER's run page: a cover
   photo, the route banner over it, and the facts grid. Called .run-hero this
   block loaded later and painted that page indigo, hiding the cover the
   runner uploaded. Second time this session a shared class name did that
   (.composer was the chat input row); grep app.css before taking a name. */
.rmhero {
background: var(--indigo-950);
color: #fff;
border-radius: var(--r-hero);
padding: 22px 20px;
margin-bottom: 16px;
}
@media (min-width: 980px) {
.rmhero {
padding: 30px 32px;
}
}
.rmhero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* The route takes what is left and truncates. Allowed to wrap, a long lane
   ("Guangzhou, China to Accra" at 390px) pushed the status pill onto a line
   of its own, where it read as a stray label rather than as this run's
   state. */
.rmhero-route {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--indigo-950-soft);
}
.rmhero-route svg {
width: 15px;
height: 15px;
opacity: 0.75;
}
.rmhero-state {
flex: none;
padding: 4px 11px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
.rmhero-state.is-live {
background: #fff;
color: var(--indigo-950);
}
.rmhero h1 {
margin: 10px 0 0;
font-size: 25px;
line-height: 1.2;
letter-spacing: -0.015em;
}
@media (min-width: 980px) {
.rmhero h1 {
font-size: 31px;
}
}
.rmhero-money {
margin-top: 16px;
}
.rmhero-money .money {
font-size: 30px;
letter-spacing: -0.02em;
display: block;
}
.rmhero-money p {
margin: 4px 0 0;
font-size: var(--text-sm);
color: var(--indigo-950-muted);
}

/* The four numbers, on the hero rather than in four white cards below it. */
.rmhero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 10px;
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
}
@media (min-width: 560px) {
.rmhero-stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.rmhero-stat b {
display: block;
font-size: 21px;
font-weight: 800;
line-height: 1.15;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.rmhero-stat b.is-words {
font-size: 15px;
font-weight: 700;
line-height: 1.3;
}
.rmhero-stat span {
display: block;
margin-top: 3px;
font-size: var(--text-xs);
color: var(--indigo-950-muted);
}
.rmhero-stat.is-urgent b {
/* Terracotta is the urgency token, and it holds its contrast on indigo. */
color: #F2A184;
}
.rmhero-track {
height: 7px;
background: rgba(255, 255, 255, 0.18);
border-radius: 4px;
overflow: hidden;
margin-top: 16px;
}
.rmhero-track .fill {
height: 100%;
background: #fff;
border-radius: 4px;
}
.rmhero-track .fill.is-urgent {
background: #F2A184;
}

/* A run's shelf, with the products visible in it. They were text rows: a
   title, a grey line of settings and a royal line of demand, stacked, with no
   picture of the thing being sold. A runner scanning their own shelf mid-trip
   recognises the goods faster than they read the names. */
.shelfrow {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px 0;
border-top: 1px solid var(--line);
}
.shelfrow:first-of-type {
border-top: 0;
}
.shelfrow-pic {
position: relative;
flex: none;
width: 54px;
height: 54px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--royal-100);
display: grid;
place-items: center;
}
/* The glyph is always there and the photo sits over it, so a listing with no
   cover AND a cover whose file has gone both land on the same tidy box. The
   first version only drew the glyph when the field was empty, so a missing
   file left an empty lilac square. */
.shelfrow-pic img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.shelfrow-pic svg {
width: 20px;
height: 20px;
color: var(--royal-700);
opacity: 0.55;
}
.shelfrow-body {
flex: 1;
min-width: 0;
}
.shelfrow-title {
display: block;
font-size: var(--text-sm);
font-weight: 700;
line-height: 1.3;
}
.shelfrow-price {
display: inline-block;
margin-top: 2px;
font-size: var(--text-sm);
font-weight: 700;
color: var(--price);
font-variant-numeric: tabular-nums;
}
.shelfrow-unit {
font-weight: 400;
color: var(--ink-60);
}
.shelfrow-terms {
display: block;
margin-top: 2px;
font-size: var(--text-xs);
color: var(--ink-40);
line-height: 1.45;
}
/* Demand is the number a runner is actually watching on their own shelf, so
   it gets the royal weight rather than another grey line. */
.shelfrow-demand {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 5px;
padding: 3px 9px;
border-radius: 999px;
background: var(--royal-100);
color: var(--royal-700);
font-size: var(--text-xs);
font-weight: 700;
}
.shelfrow-demand.is-quiet {
background: transparent;
color: var(--ink-40);
padding-left: 0;
}

/* ---- Comments under a drop ---------------------------------------------
   A drop is a runner showing what they have; the comment is usually the
   question that decides whether it sells, and the answer belongs beside the
   door into escrow rather than in a WhatsApp thread nobody else can read.

   Plain text, never linkified: an automatic link under a runner's photo is a
   door out of the platform sitting next to the one door in. */
.dropcomments {
margin-top: 22px;
}
.dropcomment {
display: flex;
gap: 10px;
padding: 12px 0;
border-top: 1px solid var(--line);
}
.dropcomment:first-of-type {
border-top: 0;
}
.dropcomment-body {
flex: 1;
min-width: 0;
}
.dropcomment-who {
font-size: var(--text-sm);
font-weight: 700;
}
.dropcomment-who .is-runner {
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
background: var(--royal-100);
color: var(--royal-700);
font-size: var(--text-xs);
}
.dropcomment-when {
margin-left: 6px;
font-size: var(--text-xs);
font-weight: 400;
color: var(--ink-40);
}
.dropcomment-text {
margin: 3px 0 0;
font-size: var(--text-sm);
line-height: 1.5;
/* A pasted wall of characters must not push the page sideways. */
overflow-wrap: anywhere;
}
.dropcomment-form {
display: flex;
gap: 8px;
align-items: flex-end;
margin-top: 12px;
}
.dropcomment-form textarea {
flex: 1;
min-height: 46px;
max-height: 140px;
border: 1px solid var(--line);
border-radius: var(--r-control);
padding: 12px 14px;
font-family: inherit;
font-size: 16px;
line-height: 1.4;
resize: none;
}
.dropcomment-form textarea:focus {
outline: none;
border-color: var(--royal-700);
box-shadow: inset 0 0 0 1px var(--royal-700);
}

/* The run cover, swappable from the run's own page. It sits in the indigo
   hero, so the frame and the note are light-on-dark rather than the ordinary
   card treatment. */
.rmhero-cover {
margin: 14px 0 4px;
}
.rmhero-cover img {
display: block;
width: 100%;
max-height: 180px;
object-fit: cover;
border-radius: var(--r-card);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.rmhero-cover-none {
margin: 0 0 10px;
padding: 14px;
border: 1px dashed rgba(255, 255, 255, 0.28);
border-radius: var(--r-card);
color: rgba(255, 255, 255, 0.72);
font-size: var(--text-sm);
}
.rmhero-cover-act {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
/* A file input styled by its own label: the label IS the button, so the input
   is taken out of the layout without being taken out of the accessibility
   tree the way display:none would. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.rmhero-cover-note {
margin: 8px 0 0;
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.78);
}
.rmhero-cover-note.is-bad {
color: #ffc9c2;
font-weight: 600;
}

/* The drop composer's media step: a clip or a photo, the clip offered first. */
.drop-picks {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.drop-pick {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 22px 14px;
border: 1px solid var(--line);
border-radius: var(--r-card);
background: var(--surface);
cursor: pointer;
text-align: center;
}
.drop-pick.is-primary {
border-color: var(--royal-500);
background: var(--surface);
}
.drop-pick input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.drop-pick svg {
width: 26px;
height: 26px;
color: var(--royal-700);
}
.drop-pick strong {
font-size: var(--text-sm);
}
.drop-pick span {
font-size: var(--text-xs);
color: var(--ink-60);
}
.drop-pick:focus-within {
outline: 2px solid var(--royal-700);
outline-offset: 2px;
}
/* The chosen clip or photo, at the shape the feed will show it. */
.drop-stage {
position: relative;
aspect-ratio: 9 / 16;
max-height: 46vh;
margin: 0 auto;
border-radius: var(--r-card);
overflow: hidden;
background: var(--indigo-950, #171040);
}
.drop-stage video,
.drop-stage img {
width: 100%;
height: 100%;
object-fit: contain;
}
.drop-media-note {
margin: 10px 0 0;
font-size: var(--text-xs);
color: var(--ink-60);
}
.drop-media-note.is-bad {
color: var(--danger-700);
font-weight: 600;
}

/* The status row: one ringed face per runner with something live, the way a
   WhatsApp status reads. The ring is a conic gradient with a dashed mask, so a
   runner with four drops gets four segments without four extra elements on the
   busiest page on the site. */
.rings {
margin: 0 0 18px;
}
.rings-row {
display: flex;
gap: 14px;
overflow-x: auto;
padding: 2px 0 6px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.rings-row::-webkit-scrollbar { display: none; }
.ring {
flex: none;
width: 68px;
display: grid;
justify-items: center;
gap: 6px;
text-decoration: none;
color: inherit;
}
.ring-disc {
position: relative;
display: grid;
place-items: center;
width: 64px;
height: 64px;
border-radius: 50%;
/* The quiet state: a plain hairline, for a runner whose drops this viewer has
   already seen. */
background: var(--line);
padding: 2px;
}
.ring-disc::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--line);
}
.ring.is-fresh .ring-disc::before {
/* One dash per drop. The conic gradient makes the segments and the repeating
   mask cuts the gaps between them; --seg is the number of drops behind the
   face, capped in the service so eight posts do not become a dotted line. */
background: conic-gradient(var(--royal-500), var(--royal-700), var(--accent-600), var(--royal-500));
-webkit-mask: repeating-conic-gradient(
  #000 0deg calc((360deg / var(--seg, 1)) - 6deg),
  transparent calc((360deg / var(--seg, 1)) - 6deg) calc(360deg / var(--seg, 1))
);
mask: repeating-conic-gradient(
  #000 0deg calc((360deg / var(--seg, 1)) - 6deg),
  transparent calc((360deg / var(--seg, 1)) - 6deg) calc(360deg / var(--seg, 1))
);
}
.ring-disc .avatar {
position: relative;
z-index: 1;
width: 56px;
height: 56px;
font-size: 20px;
/* The gap that makes it read as a ring rather than a border. */
box-shadow: 0 0 0 3px var(--paper);
}
.ring-name {
font-size: var(--text-xs);
line-height: 1.25;
text-align: center;
color: var(--ink-60);
max-width: 68px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.ring.is-fresh .ring-name { color: var(--ink); font-weight: 600; }
.ring:focus-visible { outline: 2px solid var(--royal-700); outline-offset: 3px; border-radius: 10px; }

/* Tap floors for the small text controls a thumb has to hit (20 Sep 2026).
   A scroll-aware hit test at 390px found these under the 44px floor in
   FRONTEND.md, and every one is a control a buyer or runner actually uses:
   the breadcrumb back to the run or the hub (14px tall), the "Next: Locked"
   button under the trail (22px), and the disclosure rows on checkout and the
   track record (22px to 34px). None of them may grow on screen, because each
   sits in a line of text whose rhythm is set: an inline anchor takes vertical
   padding without changing its line box, and a block summary takes padding
   cancelled by an equal negative margin, so the hit box grows and nothing
   visible moves. */
.page-head .crumbs a,
.run-crumbs a,
.crumbs a {
padding-block: 15px;
}
button.trail-next {
padding-block: 12px;
margin-block: -12px;
position: relative;
}
summary.small,
summary.tiny,
.trust-record details > summary,
.drop-report > summary {
padding-block: 13px;
margin-block: -13px;
/* Positioned so the grown box paints above the text of the paragraph before
   it: an in-flow block with no background loses a hit test to an earlier
   sibling's glyphs, and the top 13px of the area were going to the line
   above. */
position: relative;
}
/* The step names under the full trail sit inside the dots' 44px hit areas
   and, being later in the document, painted over the lower half of every
   one of them: a tap on "Locked" under its dot landed on an aria-hidden
   span and did nothing. The labels are decoration, so taps pass through. */
.trail-labels-all {
pointer-events: none;
}

/* Form fields never smaller than 16px on a phone. iOS Safari zooms the whole
   page into any field whose text is under 16px the moment it is focused, and
   the page stays zoomed after the keyboard goes away. Fields inherit their
   size from their container, so the 14px tables and console rows (the stock
   count on the storefront, every ops note box) all zoomed. The floor is set
   here once, at the width where a touch keyboard is likely, rather than
   remembered at each call site; max() keeps the fields that are already
   larger, such as the sign-in code. */
@media (max-width: 979.98px) {
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]):not([type="submit"]):not([type="button"]),
select:not([multiple]),
textarea:not([hidden]) {
/* The :not() on select and textarea is for specificity, not meaning: a bare
   element selector loses to .tiny, which is how a 12px report select
   slipped under the floor after this rule first landed. */
font-size: max(16px, 1em);
}
}

/* A table that becomes a list of rows on a phone. .table is fine at a desk;
   at 390px the storefront table scrolled sideways and the one control the
   page exists for (put it on the shelf, with a count) sat off the right edge
   where a runner could not see it existed. Each row becomes a small card:
   the thumbnail in the first column, the title beside it, then the rest of
   the cells one per line, labelled from data-label because the header row is
   gone. Cells marked .pair sit two to a line. .has-thumb reserves the first
   column for a thumbnail that runs down the left of the row; without it the
   first cell is a title and takes the full width. */
@media (max-width: 719.98px) {
.table-stack thead {
display: none;
}
.table-stack tr {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 10px;
row-gap: 4px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
align-items: start;
}
.table-stack tr:last-child {
border-bottom: 0;
}
.table-stack td {
display: block;
padding: 0;
border: 0;
grid-column: 1 / -1;
max-width: none;
}
.table-stack td.num {
text-align: left;
}
.table-stack td.pair {
grid-column: auto;
}
.table-stack.has-thumb tr {
grid-template-columns: 56px 1fr 1fr;
}
.table-stack.has-thumb td {
grid-column: 2 / -1;
}
.table-stack.has-thumb td:first-child {
grid-column: 1;
grid-row: 1 / span 8;
width: auto;
}
.table-stack.has-thumb td.pair {
grid-column: auto;
}
.table-stack td[colspan] {
grid-column: 1 / -1;
}
.table-stack td[data-label]::before {
content: attr(data-label) ": ";
color: var(--ink-40);
font-size: var(--text-xs);
}
.table-stack tr:hover td {
background: none;
}
/* A text link in a stacked row is an action (Message this buyer), so it
   takes the same 44px the crumbs take, without moving. */
.table-stack td > a {
display: inline-block;
padding-block: 12px;
margin-block: -12px;
}
}

/* The player's own entrance, first in the rings row (21 Sep 2026). */
.ring-play .ring-disc {
background: var(--royal-700);
}
.ring-play .ring-disc::before {
background: var(--royal-700);
}
.ring-play-disc {
position: relative;
z-index: 1;
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--royal-700);
color: #fff;
box-shadow: 0 0 0 3px var(--paper);
}
.ring-play-disc svg {
width: 26px;
height: 26px;
margin-left: 3px;
}
.ring-play .ring-name {
color: var(--royal-700);
font-weight: 700;
}

/* Report in the player: quieter than the four actions above it, and the
   sheet it opens is a list of the four reasons. */
.reel-act-quiet {
opacity: 0.72;
}
.reel-act-quiet span {
font-size: 11px;
}
.reel-report-list {
display: grid;
gap: 8px;
padding: 4px 14px 10px;
}
.reel-report-btn {
min-height: 48px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: var(--r-control);
background: var(--surface);
color: var(--ink);
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.reel-report-btn:disabled {
opacity: 0.5;
cursor: default;
}

/* The card foot's overflow: three dots where the word Report stood under
   every one of eight cards. The form inside is unchanged. */
.drop-report > summary {
font-size: 20px;
line-height: 1;
padding-inline: 8px;
}

/* The player plays by itself (21 Sep 2026: "fun like TikTok"). */
.reel-progress {
position: absolute;
left: 10px;
right: 10px;
top: calc(8px + env(safe-area-inset-top, 0px));
height: 3px;
z-index: 3;
border-radius: 3px;
background: rgba(255, 255, 255, 0.3);
overflow: hidden;
pointer-events: none;
}
.reel-progress > i {
display: block;
width: 0;
height: 100%;
background: #fff;
}
.reel-heart,
.reel-pausemark {
position: absolute;
left: 50%;
top: 50%;
z-index: 3;
width: 96px;
height: 96px;
margin: -48px 0 0 -48px;
color: #fff;
opacity: 0;
pointer-events: none;
filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.45));
}
.reel-heart { color: var(--accent-600); }
.reel-heart.is-on {
animation: reel-heart 720ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes reel-heart {
0% { opacity: 0; transform: scale(0.4); }
20% { opacity: 1; transform: scale(1.15); }
60% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.1) translateY(-16px); }
}
.reel.is-paused .reel-pausemark {
opacity: 0.9;
width: 76px;
height: 76px;
margin: -38px 0 0 -38px;
padding: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
transition: opacity 120ms;
}
/* With autoplay on, the play button is only for a clip that is not playing:
   refused by the browser, paused by a tap, or the whole thing switched off. */
.reels[data-autoplay="1"] .reel.is-playing .reel-play { display: none; }
.reels[data-autoplay="1"] .reel.is-paused .reel-play { display: none; }
.reel-broken {
position: absolute;
left: 14px;
right: 84px; /* clear of the rail */
bottom: 34%;
z-index: 3;
margin: 0;
padding: 10px 14px;
border-radius: var(--r-card);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: var(--text-sm);
text-align: center;
}
.reel-sound .reel-sound-off { display: block; }
.reel-sound .reel-sound-wave { display: none; }
.reels.has-sound .reel-sound .reel-sound-off { display: none; }
.reels.has-sound .reel-sound .reel-sound-wave { display: block; }
.reel-end {
display: grid;
place-items: center;
background: var(--indigo-950);
color: #fff;
}
.reel-end-body {
width: min(100% - 48px, 360px);
display: grid;
gap: 14px;
text-align: center;
}
.reel-end-body svg { width: 56px; height: 40px; margin: 0 auto; color: var(--indigo-950-soft); }
.reel-end-body h2 { font-size: var(--text-xl); }
.reel-end-body p { color: var(--indigo-950-muted); margin: 0; }
.reel-end-body .btn-quiet { background: rgba(255, 255, 255, 0.12); color: #fff; }
@media (prefers-reduced-motion: reduce) {
.reel-heart.is-on { animation: none; opacity: 1; }
.reel-progress > i { transition: none !important; }
}

/* Posting: the words a runner reaches for, one tap each; and the strip they
   land on afterwards. */
.drop-starts {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.drop-start {
min-height: 40px;
}
.drop-posted {
background: var(--indigo-950);
color: #fff;
border-radius: var(--r-hero);
padding: 16px 18px;
margin-bottom: 14px;
}
.drop-posted-head { margin: 0 0 12px; color: var(--indigo-950-muted); }
.drop-posted-head strong { color: #fff; }
.drop-posted-acts { display: flex; flex-wrap: wrap; gap: 8px; }
.drop-posted-acts .btn-quiet { background: rgba(255, 255, 255, 0.12); color: #fff; }

/* Buy tiles on the home strips (22 Sep 2026): a listing card with one
   button on it. The card is a div now, since a form cannot live inside an
   anchor; the photo and the title carry the link instead. */
.buy-tile {
cursor: default;
}
.buy-tile .photo,
.buy-tile-title {
cursor: pointer;
}
.buy-tile-title {
color: inherit;
text-decoration: none;
}
.buy-tile-title:hover {
text-decoration: underline;
}
.buy-act {
margin-top: 8px;
min-height: 40px;
display: flex;
align-items: center;
gap: 8px;
}
.buy-act .btn-block {
width: 100%;
}
.buy-act .btn-sm {
min-height: 44px;
padding: 0 10px;
font-weight: 700;
}
.buy-act.is-added {
justify-content: space-between;
}
.buy-act-done {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-sm);
font-weight: 700;
color: var(--royal-700);
}
.buy-act-done svg {
width: 18px;
height: 18px;
}
.buy-act.is-added .btn {
flex: 1;
}

/* Comments under a product share the drop comment styles; the section only
   needs its own top margin on the listing page. */
.listing-comments {
margin-top: 24px;
}

/* Stars on a product card: confirmed deliveries only, absent rather than
   zeroed when nobody has rated it yet. */
.stars {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 2px;
font-size: var(--text-xs);
color: var(--ink-60);
}
.stars svg {
width: 13px;
height: 13px;
fill: var(--gold);
}
.stars b {
color: var(--ink);
font-weight: 700;
}
.stars-n {
color: var(--ink-40);
}

/* ---- Reviews and questions, one card (22 Sep 2026, reframed 23 Sep) ------ */

/* One card, the same card every other block on the product page is. It used
   to be a bare heading, a dashed empty box and a page-wide textarea under
   the two-column layout, which on a desk made a 1,300px input the loudest
   thing below the fold. Inside the card the parts sit on hairlines rather
   than in boxes of their own: a card of cards is noise. */
.cmt {
margin-top: 24px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
}
/* In the product page's main column it is one card among the others and
   keeps their rhythm. */
.stack > .cmt {
margin-top: var(--s-4);
}
@media (min-width: 980px) {
.cmt {
padding: 20px;
}
}
.cmt-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.cmt-head-copy {
min-width: 0;
}
.cmt-sub {
margin: 3px 0 0;
font-size: var(--text-sm);
color: var(--ink-60);
max-width: 60ch;
}
.cmt-count {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
min-width: 26px;
height: 26px;
margin-top: 1px;
padding: 0 8px;
border-radius: 999px;
background: var(--royal-100);
color: var(--royal-700);
font-size: var(--text-xs);
font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* The score: a numeral, its stars and the histogram, on the page's ground
   inside the card so it reads as a summary block and not as another card. */
.cmt-rating {
display: grid;
gap: 12px;
padding: 14px;
margin-bottom: 12px;
background: var(--paper);
border-radius: var(--r-control);
}
@media (min-width: 700px) {
.cmt-rating {
grid-template-columns: auto 1fr;
align-items: center;
gap: 28px;
}
}
.cmt-score {
display: flex;
align-items: center;
gap: 12px;
}
.cmt-avg {
font-family: var(--font-money);
font-size: var(--text-3xl);
font-weight: 700;
line-height: 1;
}
.cmt-score-side {
display: grid;
gap: 4px;
}
.cmt-of {
font-size: var(--text-xs);
color: var(--ink-60);
}
.cmt-reviews {
margin-bottom: 4px;
}
/* The ask box: a pill on the page's ground, the avatar of whoever is asking,
   and Send inside it. Focus lifts it to white with the royal ring. */
.cmt-form {
margin: 0;
}
.cmt-write {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 6px 6px 6px 12px;
background: var(--paper);
border: 1px solid var(--paper);
border-radius: 28px;
}
.cmt-write:focus-within {
background: var(--surface);
border-color: var(--royal-500);
box-shadow: 0 0 0 3px var(--royal-100);
}
.cmt-write .avatar {
flex: none;
margin-bottom: 6px;
}
/* On a phone the pill is 358px wide: avatar, placeholder and Send did not
   share it, and "Ask the runner a question" wrapped onto a second line in a
   one-row box. The buyer knows who they are; the avatar goes. */
@media (max-width: 479.98px) {
.cmt-write .avatar {
display: none;
}
}
.cmt-write textarea {
flex: 1;
min-width: 0;
min-height: 44px;
max-height: 160px;
padding: 11px 4px;
border: 0;
background: none;
resize: none;
line-height: 1.45;
}
.cmt-write textarea:focus {
outline: none;
}
.cmt-send {
flex: none;
min-height: 44px;
padding-inline: 20px;
border-radius: 999px;
}
.cmt-openers {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
/* Suggestions, not buttons: on a hairline in the surface colour, so the
   row reads as things you might ask rather than as four commands. */
.cmt-opener {
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
font-weight: 600;
cursor: pointer;
}
.cmt-opener:hover,
.cmt-opener:focus-visible {
border-color: var(--royal-500);
color: var(--royal-700);
}
/* The thread: rows on hairlines under the ask box. */
.cmt-list {
list-style: none;
margin: 14px 0 0;
padding: 0;
border-top: 1px solid var(--line);
}
.cmt-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: start;
padding: 12px 0;
border-bottom: 1px solid var(--line-soft);
}
.cmt-row:last-child {
border-bottom: 0;
padding-bottom: 4px;
}
.cmt-row .avatar {
flex: none;
}
.cmt-body {
min-width: 0;
}
.cmt-who {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
margin: 0 0 3px;
font-size: var(--text-sm);
}
.cmt-who b {
font-weight: 700;
}
.cmt-badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 7px;
border-radius: 999px;
background: var(--royal-700);
color: #fff;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.cmt-when {
color: var(--ink-40);
font-size: var(--text-xs);
}
.cmt-text {
margin: 0;
font-size: var(--text-md);
line-height: 1.5;
overflow-wrap: anywhere;
}
/* The runner's answer is the line a buyer came to read: a tinted bubble on
   the text, not a tinted row, so it reads as speech. */
.cmt-row.is-runner .cmt-text {
display: inline-block;
padding: 8px 12px;
background: var(--royal-100);
border-radius: 4px 14px 14px 14px;
}
.cmt-hide {
align-self: center;
}
.cmt-empty {
margin: 14px 0 0;
font-size: var(--text-sm);
color: var(--ink-60);
}
.cmt-note {
margin: 10px 0 0;
font-size: var(--text-xs);
color: var(--ink-40);
}
.cmt-foot {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--line-soft);
}

/* Rate it from the product's own page: a row of stars that are each a submit
   button, so it works with no script and on a keyboard. */
.cmt-rate {
display: grid;
gap: 8px;
padding: 14px;
margin-bottom: 14px;
background: var(--royal-100);
border: 1px solid var(--royal-200);
border-radius: var(--r-card);
}
.cmt-rate-ask {
margin: 0;
font-size: var(--text-sm);
}
.cmt-rate textarea {
min-height: 44px;
max-height: 140px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-control);
background: var(--surface);
resize: none;
line-height: 1.45;
}
.cmt-stars {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
}
.cmt-star {
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: none;
color: var(--ink-40);
cursor: pointer;
display: grid;
place-items: center;
}
.cmt-star svg {
width: 26px;
height: 26px;
fill: currentColor;
}
/* Fill up to the star under the pointer, the way a rating row should read.
   :hover only on a pointer device; a thumb just taps the one it means. */
@media (hover: hover) and (pointer: fine) {
.cmt-stars:hover .cmt-star {
color: var(--gold);
}
.cmt-star:hover ~ .cmt-star {
color: var(--ink-40);
}
}
.cmt-star:focus-visible {
outline: 2px solid var(--royal-700);
outline-offset: -4px;
border-radius: 50%;
}
.cmt-rate-note {
margin-left: 6px;
font-size: var(--text-xs);
color: var(--ink-60);
}

/* The card's own action sits at the foot of every tile, so a row of cards
   ends on a row of buttons rather than on ragged text. */
.listing-card .buy-act {
margin-top: auto;
padding-top: 8px;
}
