/*
 * The app's stylesheet. Hand-written, no build step, on design v2 (ETE-182).
 *
 * base.html links static/css/tokens.css first: every colour, radius, shadow,
 * font and duration comes from there, in light and in dark. This file adds
 * only what is the app's own (the type scale, touch targets, page widths) and
 * holds no colour literal of its own -- the QR's black on white is the one
 * exception, and config.tests.StylesheetHealthTests holds both rules.
 *
 * Three rules from the v2 report decide most of what is below:
 *
 * - One action colour. The brand red is the main action of a view, progress
 *   and key figures. Everything else is a neutral. One primary button per
 *   view; the rest are secondary, ghost or link.
 * - Error is not the brand red. --danger is a rust, always with an icon and a
 *   word. A destructive button is an outline; filled only on the final
 *   confirmation.
 * - Everything subtle ("queremos cosas sutiles"). 1px lines, shadows you
 *   barely see, motion of a few pixels and 100-150ms. Hover lives inside
 *   @media (hover: hover), so a phone never shows a stuck hover.
 *
 * Mobile-first: this app is used one-handed at a counter. 360px is the base
 * case; the wide layout is the exception.
 *
 * -------------------------------------------------------------------------
 * SECTIONS -- each follow-up of ETE-181 edits its own and nothing else
 * -------------------------------------------------------------------------
 *   1. Base          tokens for the app, reset, type, layout, links, icons
 *   2. Components    buttons, forms, errors, alerts, cards, badges, empty
 *                    state, rows, header, footer, htmx       (ETE-182)
 *   3. Customer      code panel, points, history, invite      (ETE-183)
 *   4. Counter       scanner, scan result, tier, undo         (ETE-184)
 *   5. Owner         shop, menu, customers, insights, chart   (ETE-185)
 *   6. Account and system   install prompt, profile, errors   (ETE-186)
 *   (Brand assets -- icons, manifest, poster.css -- are ETE-187 and live
 *    outside this file.)
 *
 * -------------------------------------------------------------------------
 * COMPONENT CLASSES for templates (see README, "Design v2 in the app")
 * -------------------------------------------------------------------------
 *   Buttons   .btn + one of .btn--primary (one per view) .btn--secondary
 *             .btn--ghost .btn--link .btn--danger (outline)
 *             .btn--danger-solid (final confirmation only) .btn--dark
 *             sizes .btn--sm (44px) .btn--lg (56px, the counter)
 *             .btn--block (full width) .btn--icon (square, needs aria-label)
 *             loading: aria-busy="true" on the button, or htmx's
 *             .htmx-request on it or on its form; add hx-disabled-elt="this"
 *             .btn--icon.btn--ghost.btn--danger-quiet: a destructive
 *             action in a list (Remove a reward), grey until hover/focus
 *   A <button> with no class is a plain neutral button: every button names
 *             its variant (ETE-204 removed the legacy red fallback).
 *   Layout    <main> is --measure wide; {% block body_class %}layout-wide
 *             {% endblock %} widens header, messages, main and footer to
 *             --wide for owner screens. .stack (vertical rhythm),
 *             .button-row (actions in a row), .grid-2 (from 48rem)
 *   Headings  (ETE-204) One rule: page and section titles are Instrument
 *             Serif 400, card titles and everything under 24px are Inter.
 *             <a class="back-link"> (partials/back_link.html) first, then
 *             <header class="page-head"> > div > h1.page-head__title +
 *             p.page-head__lede, and an optional .page-head__actions.
 *             .page-head--center for the sign-in and system pages.
 *             .section-head > h2 (+ an optional control) between cards;
 *             .card__title (or a card's own h2/h3) inside one.
 *   Forms     partials/field.html renders .field > label, control, .helptext,
 *             .errorlist. A form-level error is
 *             <p class="form-error" role="alert"><svg class="icon">...</svg>
 *             <span>...</span></p>
 *   Feedback  .alert .alert--success|--error|--warning|--info (icon + text)
 *             .badge .badge--brand|--solid|--success|--warning|--danger|--info
 *             .ready / .failure for one line of status text
 *             .delta .delta--up|--down|--flat (+ .delta--plain): a change
 *             against a period before, via partials/delta.html (ETE-202)
 *   Content   .card (.card--quiet without the shadow)
 *             .empty > .empty__icon, h2/h3, p, actions; .empty--compact
 *             inside a card that already has its heading
 *             .muted (secondary text) .meta (tertiary) .num (tabular figures)
 *   Icons     <svg class="icon" aria-hidden="true"><use href="#i-name"/></svg>
 *             from templates/partials/icons.html; .icon--sm .icon--lg
 */

/* ==========================================================================
   1. BASE
   ========================================================================== */

:root {
  /* Native controls, scrollbars and autofill follow the theme. tokens.css
     flips to `dark` under prefers-color-scheme; this says the page supports
     both, so the browser does not paint a light control on a dark page. */
  color-scheme: light dark;

  /* The app's one type scale (v1 b.4, kept in v2 §2.4). Body is 17px:
     customers of 50+ read their balance in a café, and 16 is the floor for
     an input that iOS will not zoom into. tokens.css also carries the
     landing's scale (--fs-h1, --fs-body, --fs-small...); this file never
     uses those, so a token picked by mistake cannot change a size quietly
     (ETE-204, audit B-08; StylesheetHealthTests holds it). */
  --fs-xs: 0.8125rem; /* 13 metadata */
  --fs-sm: 0.875rem; /* 14 help text, labels, secondary */
  --fs-base: 1.0625rem; /* 17 body, inputs, buttons */
  --fs-md: 1.25rem; /* 20 h2, card titles */
  --fs-lg: 1.5625rem; /* 25 h1 on a phone, panel headlines */
  --fs-xl: 1.9375rem; /* 31 h1, the counter's status word */
  --fs-2xl: 2.5rem; /* 40 the balance */
  --lh-tight: 1.2;

  /* Display steps, in Instrument Serif 400 and never below 24px, where its
     hairlines vanish (ETE-204, audit B-02/B-09). The approved gallery sets
     every page and section title in the serif; card titles, labels, body
     and the customer's balance stay in Inter. */
  --fs-title: clamp(2rem, 1.6rem + 1.6vw, 2.5rem); /* 32->40 page title */
  --fs-section: clamp(1.5rem, 1.35rem + 0.5vw, 1.75rem); /* 24->28 section */
  --fs-figure: 2.5rem; /* 40 a statistic's number */
  /* The wordmark in the header pill: fixed, because the pill's height is
     (ETE-208, QA B-09 -- it was the last serif size written by hand). */
  --fs-wordmark: 1.5rem; /* 24 */

  /* Touch targets: 48px everywhere, 56px at the counter, where the phone is
     held at arm's length by someone serving a queue. */
  --target: 48px;
  --target-counter: 56px;
  /* The floor for anything smaller (.btn--sm, a segment, a switch): WCAG
     2.5.5's 44px, never less (ETE-204, audit B-18). */
  --target-sm: 44px;

  /* A card's padding on a phone: 20px is the gallery's, between --s-4 and
     --s-5 on the 4px grid, so it is named once here rather than typed into
     every card-like component (audit B-22). */
  --s-4-5: 20px;

  /* Page widths. --measure for reading and for anything a customer opens;
     --wide for the owner's screens on a laptop. --page is the one in force,
     and the header, the messages, main and the footer all use it, so their
     edges line up at every width. */
  --measure: 36rem;
  --wide: 60rem;
  --page: var(--measure);
  --gutter: 16px;

  /* The error mark, as a mask: the shape only, so it takes currentColor.
     A filled circle with the cross cut out of it -- the ✕ the v2 report puts
     beside every error (§2.2), so rust is never the only signal. Used by
     .errorlist; img-src allows data: in the CSP. */
  --mark-alert: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' d='M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM8.46 7.05 12 10.59l3.54-3.54 1.41 1.41L13.41 12l3.54 3.54-1.41 1.41L12 13.41l-3.54 3.54-1.41-1.41L10.59 12 7.05 8.46z'/%3E%3C/svg%3E");
}

@media (min-width: 48rem) {
  :root {
    --gutter: 24px;
  }
}

.layout-wide {
  --page: var(--wide);
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

/* Anything hidden stays hidden, whatever display a component class gives it.
   Without !important a `.btn { display: inline-flex }` beats the browser's
   own [hidden] rule, and a button the script means to reveal later is on the
   page from the start. */
[hidden] {
  display: none !important;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100dvh;
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 400 var(--fs-base) / 1.5 var(--font-ui);
  font-feature-settings: "cv11";
  -webkit-font-smoothing: antialiased;
}

main {
  flex: 1 0 auto;
  width: 100%;
  max-width: calc(var(--page) + 2 * var(--gutter));
  margin: 0 auto;
  /* env(safe-area-inset-*) keeps content clear of the iPhone home indicator
     once the app is installed and running without browser chrome. */
  padding: var(--s-5) max(var(--gutter), env(safe-area-inset-right)) var(--s-7)
    max(var(--gutter), env(safe-area-inset-left));
}

h1,
h2,
h3 {
  color: var(--ink);
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: -0.01em;
  text-wrap: balance;
}

/* Page titles are Instrument Serif, as the approved gallery sets them and as
   the sign-in pages already did (ETE-204, audit B-02 and D-05): one face for
   every page title rather than Inter on thirty screens and the serif on
   eleven. It has no bold, so hierarchy is size, and never under 24px. */
h1 {
  margin: 0 0 var(--s-4);
  font-family: var(--font-display);
  font-size: var(--fs-title);
  font-weight: 400;
  line-height: 1.05;
}

h2 {
  margin: var(--s-6) 0 var(--s-3);
  font-size: var(--fs-md);
}

h3 {
  margin: var(--s-5) 0 var(--s-2);
  font-size: var(--fs-base);
}

p {
  margin: 0 0 var(--s-4);
}

/* Links are ink, underlined by a hairline at 45% of their own colour, which
   turns solid on hover (ETE-208, QA N-04). They were brand red, and with the
   rest of the page quieted down a customer's dashboard showed seven red
   links around its one red button -- against the founder's rule that the
   red is the main action, progress and key figures, and "queremos cosas
   sutiles". The underline is what keeps them links, so it is never removed
   from a link that is only text; links that are rows or buttons draw their
   own affordance (a chevron, an outline). Without color-mix the underline is
   simply solid. */
a {
  color: var(--ink);
  text-decoration-thickness: 1px;
  text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
  text-underline-offset: 3px;
  transition: text-decoration-color var(--dur-2) var(--ease-standard);
}

@media (hover: hover) {
  a:hover {
    text-decoration-color: currentColor;
  }
}

/* The focus ring is the one line that is not subtle: whoever is tabbing has
   to find it. Ink, so it is the same strong edge on every colour it can sit
   on, the red button included. */
:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

code {
  padding: 0.1em 0.35em;
  border: 1px solid var(--line);
  border-radius: var(--r-xs);
  background: var(--sunk);
  font-family: var(--font-mono);
  font-size: 0.9em;
}

img,
svg {
  max-width: 100%;
}

.muted {
  color: var(--body);
}

/* Tertiary text: dates, counts, the line under a line. Still 4.5:1. */
.meta {
  color: var(--muted-text);
  font-size: var(--fs-sm);
}

.num {
  font-variant-numeric: tabular-nums;
}

/* Off the screen and still in the accessibility tree. `display: none` and
   `visibility: hidden` take an element out of both, which is the opposite of
   what the chart's table is for -- it exists precisely to be read aloud. The
   1px clipped box rather than a negative text-indent because it works for a
   <table>, and `white-space: nowrap` stops a long cell being wrapped into a
   column of single characters, which some screen readers then spell out. */
.sr-only,
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* And the one part of that recipe a <table> does not honour. A table's used
   width is at least its min-content width under automatic layout, so
   `width: 1px` above left the chart's hidden table around 360px wide,
   absolutely positioned and past the right edge of a 380px screen -- which put
   the whole insights page into horizontal scroll. `table-layout: fixed` makes
   the declared width authoritative (ETE-50) -- in theory. In practice the
   page still scrolled sideways at 360px (audit D-01: 485px wide), so the
   chart's table is now wrapped in a hidden <div> instead: a block's width
   is its width, and its overflow is clipped rather than scrollable
   (ETE-204). `max-width` keeps any other element from doing the same. */
.sr-only,
.visually-hidden {
  max-width: 1px;
}

table.visually-hidden {
  table-layout: fixed;
}

/* -- Icons ----------------------------------------------------------------- */

/* The sprite in partials/icons.html. Not display:none, which some engines
   also apply to what <use> clones out of it. */
.sprite {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* Lucide's 2px stroke thinned a little: the founder's "hairlines" rule. */
.icon {
  flex: none;
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentcolor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
  vertical-align: middle;
}

.icon--sm {
  width: 16px;
  height: 16px;
}

.icon--lg {
  width: 28px;
  height: 28px;
  stroke-width: 1.5;
}

/* -- Layout helpers ---------------------------------------------------------- */

/* Vertical rhythm for a column of blocks: the gap belongs to the parent, so
   no child has to know whether it is first or last. */
.stack {
  display: grid;
  gap: var(--s-4);
}

.stack > * {
  margin: 0;
}

/* Two across once there is room; one column on a phone. */
.grid-2 {
  display: grid;
  gap: var(--s-4);
}

@media (min-width: 48rem) {
  .grid-2 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* ==========================================================================
   2. COMPONENTS
   ========================================================================== */

/* -- Buttons ----------------------------------------------------------------- */

/* Every button shares the shape: a pill, 48px tall, the label's own width,
   wrapping rather than overflowing on a narrow phone. Colour comes from a
   variant. A <button> with no class is neutral: the old fallback made it a
   red primary, so any new button that forgot its variant became a second
   main action on the page (ETE-204, audit B-23). */
.btn,
button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  min-height: var(--target);
  padding: var(--s-2) 22px;
  border: 1px solid transparent;
  border-radius: var(--r-pill);
  background: transparent;
  color: inherit;
  font: inherit;
  font-weight: 500;
  line-height: var(--lh-tight);
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition:
    transform var(--dur-2) var(--ease-standard),
    background-color var(--dur-1) var(--ease-standard),
    border-color var(--dur-1) var(--ease-standard),
    color var(--dur-1) var(--ease-standard);
}

.btn--primary {
  background: var(--brand);
  color: var(--on-brand);
}

button:not([class]) {
  border-color: var(--control-line);
  background: var(--surface);
  color: var(--ink);
}

.btn--secondary {
  border-color: var(--control-line);
  background: var(--surface);
  color: var(--ink);
}

/* Ink, like every other secondary (ETE-208, QA N-04): "Edit", "See your
   page", "Download the whole list as CSV" were red text beside the owner's
   one red button. The icon steps back to the text grey; the hover fill is
   the neutral sunk tone, not the brand tint. */
.btn--ghost {
  padding-inline: var(--s-4);
  color: var(--ink);
}

.btn--ghost .icon {
  color: var(--muted-text);
}

/* Looks like a link and is still a thumb's worth tall (QA-MOB-1): the height
   is invisible, so only the target grows, not the line. */
.btn--link,
button.link {
  min-height: var(--target);
  padding: 0 var(--s-1);
  border: 0;
  border-radius: var(--r-xs);
  background: none;
  color: var(--ink);
  font-weight: 400;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
  text-underline-offset: 3px;
}

/* Destructive: rust, outlined. The filled one is for the last step of a
   confirmation, never next to a primary. */
.btn--danger {
  border-color: var(--danger);
  background: transparent;
  color: var(--danger);
}

.btn--danger-solid {
  background: var(--danger);
  color: var(--on-danger);
}

.btn--dark {
  background: var(--dark);
  color: var(--on-dark);
}

.btn--sm {
  min-height: var(--target-sm);
  padding-inline: 18px;
  font-size: var(--fs-sm);
}

.btn--lg {
  min-height: var(--target-counter);
  padding-inline: 28px;
}

.btn--block {
  display: flex;
  width: 100%;
}

.btn--icon {
  width: var(--target);
  padding: 0;
}

.btn--sm.btn--icon {
  width: var(--target-sm);
}

/* A destructive action in a list -- Remove a reward, a collaborator -- as a
   grey bin that turns rust on hover and focus (ETE-204, audit B-13/D-08).
   Five rust outlines in one card made the destructive the loudest thing on
   the owner's page; the rust outline stays for the one destructive action of
   a view ("Close my account"). Needs an aria-label that names the thing. */
.btn--danger-quiet {
  color: var(--muted-text);
}

.btn--danger-quiet:focus-visible {
  background: var(--danger-bg);
  color: var(--danger);
}

@media (hover: hover) {
  .btn--primary:hover:not(:disabled) {
    background: var(--brand-hover);
  }

  .btn--secondary:hover:not(:disabled) {
    border-color: var(--ink);
  }

  .btn--ghost:hover:not(:disabled) {
    background: var(--sunk);
  }

  .btn--ghost:hover:not(:disabled) .icon {
    color: var(--ink);
  }

  .btn--link:hover,
  button.link:hover {
    text-decoration-color: currentColor;
  }

  .btn--danger:hover:not(:disabled),
  .btn--danger-quiet:hover:not(:disabled) {
    background: var(--danger-bg);
  }

  .btn--danger-quiet:hover:not(:disabled) {
    color: var(--danger);
  }
}

/* A press you can feel: 3% smaller for 100ms, and the red one a shade
   darker. Reduced motion keeps the tone and drops the scale. */
.btn:active:not(:disabled, [aria-disabled="true"]),
button:active:not(:disabled) {
  transform: scale(0.97);
  transition-duration: var(--dur-1);
}

.btn--primary:active:not(:disabled) {
  background: var(--brand-press);
}

.btn--link:active,
button.link:active {
  transform: none;
}

/* Disabled without opacity: a faded red is unreadable in sunlight and fails
   contrast. A neutral fill with 4.5:1 text says "not now" instead. */
.btn:disabled,
.btn[aria-disabled="true"],
button:disabled {
  border-color: var(--line);
  background: var(--sunk);
  color: var(--muted-text);
  cursor: not-allowed;
}

button.link:disabled,
.btn--link:disabled {
  background: none;
}

/* Loading. The label stays (the width does not jump) and a spinner joins it.
   Set aria-busy="true", or let htmx's .htmx-request mark the button or the
   form that sent. */
.btn[aria-busy="true"],
.htmx-request.btn,
.htmx-request > button[type="submit"] {
  pointer-events: none;
}

.btn[aria-busy="true"]::before,
.htmx-request.btn::before,
.htmx-request > button[type="submit"]::before {
  content: "";
  flex: none;
  width: 16px;
  height: 16px;
  border: 1.5px solid currentcolor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 750ms linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}

@keyframes pulse {
  50% {
    opacity: 0.35;
  }
}

/* Actions side by side, wrapping on a narrow phone. */
.button-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
  margin: 0 0 var(--s-4);
}

.button-row form {
  margin: 0;
}

/* Links that stand on their own as a thing to tap -- "Edit" beside a button,
   "Back to your shop", the account links under the dashboard, the sort links
   -- are a thumb's worth tall (R2-MOB-2), like button.link above. A link
   inside a sentence is left as text, which WCAG 2.5.8 exempts. */
.button-row > a:not(.btn),
.links a,
.sorts a,
.sorts .current,
.pager a {
  display: inline-flex;
  align-items: center;
  min-height: var(--target);
}

.button-row > a:not(.btn) {
  padding-inline: var(--s-1);
}

/* A link's icon is the text grey, so the ink words are what reads as the
   link (ETE-208, QA N-04). */
.links a .icon {
  color: var(--muted-text);
}

/* -- Forms ------------------------------------------------------------------- */

/* A form that is a block of its own leaves the same air under it as a card.
   Forms inside a row of buttons or a list keep their zero margin. */
main > form,
.card > form {
  margin: 0 0 var(--s-5);
}

.field {
  margin: 0 0 var(--s-5);
}

/* A radio group's wrapper is a <fieldset>, which browsers give a border,
   padding and a floating legend by default. */
fieldset.field {
  min-inline-size: 0;
  padding: 0;
  border: 0;
}

label,
legend {
  display: block;
  margin: 0 0 6px;
  padding: 0;
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 500;
}

/* Every kind of text box, by exclusion rather than by listing types: the old
   list left out search, tel and date, and each one got a copy of the rule. */
input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="file"], [type="range"], [type="color"], [type="submit"], [type="button"], [type="reset"], [type="image"]),
textarea,
select {
  width: 100%;
  min-height: var(--target);
  padding: 0 var(--s-4);
  border: 1px solid var(--control-line);
  border-radius: var(--r-sm);
  background-color: var(--surface);
  color: var(--ink);
  /* 17px, never under 16: iOS Safari zooms the viewport on focus below 16px. */
  font: inherit;
  transition:
    border-color var(--dur-1) var(--ease-standard),
    box-shadow var(--dur-1) var(--ease-standard);
}

textarea {
  min-height: 6rem;
  padding: var(--s-3) var(--s-4);
  resize: vertical;
}

/* The chevron is drawn with two gradients in a token colour rather than an
   SVG data URL, which would carry a colour literal and miss dark mode. */
select {
  padding-right: 44px;
  appearance: none;
  background-image:
    linear-gradient(45deg, transparent 50%, var(--muted-text) 50%),
    linear-gradient(135deg, var(--muted-text) 50%, transparent 50%);
  background-position:
    calc(100% - 22px) 50%,
    calc(100% - 17px) 50%;
  background-size: 5px 5px;
  background-repeat: no-repeat;
}

input::placeholder,
textarea::placeholder {
  color: var(--muted-text);
}

@media (hover: hover) {
  input:hover:not(:focus, :disabled),
  textarea:hover:not(:focus, :disabled),
  select:hover:not(:focus, :disabled) {
    border-color: var(--body);
  }
}

input:focus-visible,
textarea:focus-visible,
select:focus-visible {
  border-color: var(--ink);
  outline: none;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--ink) 14%, transparent);
}

input[type="checkbox"],
input[type="radio"] {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: var(--brand);
  vertical-align: middle;
}

/* Lined up with the label above it: the old 1rem indent left it hanging. */
.helptext {
  display: block;
  margin: 6px 0 0;
  color: var(--muted-text);
  font-size: var(--fs-sm);
}

/* Password validator help arrives as a <ul> inside the help <span>. */
.helptext ul {
  margin: var(--s-1) 0 0;
  padding-left: 1.1em;
}

.field.has-error input,
.field.has-error select,
.field.has-error textarea {
  border-color: var(--danger);
}

/* A field's errors, under it, in rust and each with the alert mark: colour is
   never the only signal. Any element, not only <ul> -- the login page's error
   is a <p>, and `ul.errorlist` left it unstyled (audit 4.5). forms.js still
   finds `ul.errorlist` for the field errors it hides when the answer changes. */
.errorlist {
  margin: 6px 0 0;
  padding: 0;
  list-style: none;
  color: var(--danger);
  font-size: var(--fs-sm);
  font-weight: 500;
}

.errorlist li,
p.errorlist {
  display: flex;
  align-items: flex-start;
  gap: 6px;
}

/* The login page's error stands above the form rather than under a field. */
p.errorlist {
  margin: 0 0 var(--s-4);
}

.errorlist li + li {
  margin-top: var(--s-1);
}

.errorlist li::before,
p.errorlist::before {
  content: "";
  flex: none;
  width: 14px;
  height: 14px;
  margin-top: 0.2em;
  background: currentcolor;
  -webkit-mask: var(--mark-alert) center / contain no-repeat;
  mask: var(--mark-alert) center / contain no-repeat;
}

/* A form-level error -- the login that did not match, a form's
   non_field_errors -- as a tinted box above the fields, announced on arrival.
   Markup: <p class="form-error" role="alert"><svg class="icon">…</svg>
   <span>…</span></p>. Django's own non-field list gets the same look. */
.form-error,
.errorlist.nonfield {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  margin: 0 0 var(--s-5);
  padding: var(--s-3) 14px;
  border: 1px solid var(--danger-line);
  border-radius: var(--r-sm);
  background: var(--danger-bg);
  color: var(--danger);
  font-size: var(--fs-sm);
  font-weight: 500;
}

.form-error .icon {
  margin-top: 1px;
}

.errorlist.nonfield {
  display: block;
}

/* -- Alerts (flash messages) ------------------------------------------------ */

/* Colour, icon and words, always the three. The background is a tint and the
   border a hairline in the same hue; the words are the state's own text
   colour, which holds 4.5:1 on its tint in both schemes. */
.messages {
  display: grid;
  gap: var(--s-2);
  width: 100%;
  max-width: calc(var(--page) + 2 * var(--gutter));
  margin: var(--s-4) auto 0;
  padding: 0 max(var(--gutter), env(safe-area-inset-right)) 0
    max(var(--gutter), env(safe-area-inset-left));
  list-style: none;
}

.alert {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  margin: 0;
  padding: 14px var(--s-4);
  border: 1px solid;
  border-radius: var(--r-sm);
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1.45;
}

.alert .icon {
  margin-top: -1px;
}

.alert--success {
  border-color: var(--success-line);
  background: var(--success-bg);
  color: var(--success);
}

.alert--error,
.alert--danger {
  border-color: var(--danger-line);
  background: var(--danger-bg);
  color: var(--danger);
}

.alert--warning {
  border-color: var(--warning-line);
  background: var(--warning-bg);
  color: var(--warning);
}

.alert--info,
.alert--debug {
  border-color: var(--info-line);
  background: var(--info-bg);
  color: var(--info);
}

/* One line of status in running text: "Ready to redeem", "That code has
   expired". Weight as well as colour, so it survives greyscale. */
.ready {
  color: var(--success);
  font-weight: 600;
}

.failure {
  color: var(--danger);
  font-weight: 600;
}

/* -- Cards ------------------------------------------------------------------- */

/* White on the warm page, a hairline edge and a shadow you would only miss
   if it went. */
.card {
  margin: 0 0 var(--s-4);
  padding: var(--s-4-5);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  box-shadow: var(--sh-1);
}

@media (min-width: 30rem) {
  .card {
    padding: var(--s-5);
  }
}

.card--quiet {
  box-shadow: none;
}

.card > :first-child,
.card h2:first-child,
.card h3:first-child {
  margin-top: 0;
}

.card > :last-child {
  margin-bottom: 0;
}

/* A title inside a card: Inter, never the serif (ETE-204). The card's own
   h2/h3 get the same from the global rules; the class is for a title that
   is not a heading element. */
.card__title {
  margin: 0 0 var(--s-2);
  color: var(--ink);
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: var(--lh-tight);
}

/* -- Page head, section head, back link (ETE-204) --------------------------- */

/* Every page opens the same way (audit B-07, D-18): the back link if there
   is a page above, then the title in the serif, an optional one-line lede,
   and the page's actions -- beside the title on a laptop, wrapping under it
   on a phone. It replaces .owner-head, .insights-lead and .auth__lede, and
   the negative margin that glued a subtitle to its h1. */
.page-head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--s-3) var(--s-5);
  margin: 0 0 var(--s-5);
}

.page-head > div:first-child {
  min-width: 0;
}

.page-head__title,
.page-head h1 {
  margin: 0;
  overflow-wrap: anywhere;
}

.page-head__lede {
  max-width: 52ch;
  margin: var(--s-2) 0 0;
  color: var(--body);
}

.page-head__actions,
.page-head .button-row {
  margin: 0;
}

/* The place a page is about, under its title: the counter's shop, with its
   icon, in ink (ETE-208, QA B-07: what was the scanner's own .counter-head). */
.page-head__lede--place {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-1);
  color: var(--ink);
  font-weight: 500;
}

/* A person's page (one customer): their initial beside the title, inside the
   card, so a smaller gap under it (ETE-208, QA B-07: was .customer-head). */
.page-head--avatar {
  margin-bottom: var(--s-3);
}

.page-head--avatar > div:first-child {
  display: flex;
  align-items: center;
  gap: var(--s-4);
}

.page-head--avatar .page-head__title {
  min-width: 0;
}

/* Sign-in, sign-up, the password pages and the system pages: one narrow
   column, so the head is centred over it. */
.page-head--center {
  justify-content: center;
  text-align: center;
}

.page-head--center .page-head__lede {
  margin-inline: auto;
}

/* Between cards: the serif at the section step, and an optional control at
   the far end (a period selector, a link). One component for what used to
   be .section-heading (Inter 20) and .section-head (serif 28) -- two names
   one letter apart with opposite styles (audit B-03). A title inside a card
   is not this: see .card__title. */
.section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  margin: var(--s-7) 0 var(--s-3);
}

.section-head h2,
.section-head__title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-section);
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: 0;
}

/* The first thing in <main> when there is a page above this one (audit
   B-06): one look and one place, where there used to be five looks in three
   places. Ink, not the brand red -- it is navigation, not the page's action
   -- 44px tall, and pulled left by its own padding so the arrow lines up
   with the content edge at every width. partials/back_link.html. */
.back-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: var(--target-sm);
  margin: calc(-1 * var(--s-2)) 0 var(--s-2) calc(-1 * var(--s-3));
  padding: 0 var(--s-3);
  border-radius: var(--r-pill);
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 500;
  text-decoration: none;
  transition: background-color var(--dur-1) var(--ease-standard);
}

@media (hover: hover) {
  .back-link:hover {
    background: var(--sunk);
  }
}

/* -- Badges ------------------------------------------------------------------ */

.badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 24px;
  padding: 0 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--sunk);
  color: var(--body);
  font-size: var(--fs-xs);
  font-weight: 600;
  white-space: nowrap;
  vertical-align: middle;
}

.badge .icon {
  width: 13px;
  height: 13px;
}

.badge--brand {
  border-color: transparent;
  background: var(--brand-tint);
  color: var(--brand-text);
}

.badge--solid {
  border-color: transparent;
  background: var(--brand);
  color: var(--on-brand);
}

.badge--success {
  border-color: var(--success-line);
  background: var(--success-bg);
  color: var(--success);
}

.badge--warning {
  border-color: var(--warning-line);
  background: var(--warning-bg);
  color: var(--warning);
}

.badge--danger {
  border-color: var(--danger-line);
  background: var(--danger-bg);
  color: var(--danger);
}

.badge--info {
  border-color: var(--info-line);
  background: var(--info-bg);
  color: var(--info);
}

/* -- Delta: a change against a period before (ETE-202) ---------------------- */

/* The founder could not tell a fall from the text around it ("Las
   estadísticas ... con indicadores de subida y caída deberían tener un color
   que los diferencie"). So a change is a pill of its own: up in the success
   green, down in the rust of --danger, no change in a neutral grey -- each a
   tint with a hairline, the text in the state's own colour (4.5:1 on its
   tint in both schemes, StylesheetHealthTests) -- and an arrow plus a sign,
   so it reads in greyscale too. Down is rust, never the brand red: red is
   the action colour, and a red "−12%" reads as "números rojos". Always on a
   line of its own under the number (partials/delta.html). */
.delta {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  min-height: 24px;
  padding: 0 var(--s-2);
  border: 1px solid transparent;
  border-radius: var(--r-pill);
  font-size: var(--fs-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  white-space: nowrap;
  vertical-align: middle;
}

.delta .icon {
  width: 14px;
  height: 14px;
  stroke-width: 2;
}

.delta--up {
  border-color: var(--success-line);
  background: var(--success-bg);
  color: var(--success);
}

.delta--down {
  border-color: var(--danger-line);
  background: var(--danger-bg);
  color: var(--danger);
}

.delta--flat {
  border-color: var(--line);
  background: var(--sunk);
  color: var(--body);
}

/* Amounts down a list (the history's "+1 point"): the colour and the arrow
   without the pill, which on every row would be noise (audit B-12). */
.delta--plain {
  min-height: 0;
  padding: 0;
  border: 0;
  background: none;
  font-size: inherit;
}

/* A spend down a list already carries its "−"; a dash icon beside it would
   say the same thing twice. */
.delta--plain.delta--flat .icon {
  display: none;
}

/* -- Empty state -------------------------------------------------------------- */

/* Says what is going on and what to do now. No loop, no illustration: a
   light ring with one icon is enough. */
.empty {
  display: grid;
  justify-items: center;
  gap: var(--s-2);
  text-align: center;
}

.empty > * {
  margin: 0;
}

.empty__icon {
  display: grid;
  place-items: center;
  width: 64px;
  height: 64px;
  margin-bottom: var(--s-2);
  border: 1px dashed var(--brand-halo);
  border-radius: 50%;
  background: var(--brand-tint);
  color: var(--brand-text);
}

.empty p {
  max-width: 34ch;
}

/* Inside a card that already has its heading: the same icon and sentence,
   in a row and with less air (audit B-15). Replaces the bare grey
   `p.empty-list`, which said what was going on and nothing else. */
.empty--compact {
  grid-template-columns: auto minmax(0, 1fr);
  justify-items: start;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) 0;
  text-align: left;
}

.empty--compact .empty__icon {
  width: 40px;
  height: 40px;
  margin: 0;
  border-style: solid;
  border-color: var(--line);
  background: var(--sunk);
  color: var(--body);
}

.empty--compact .empty__icon .icon {
  width: 20px;
  height: 20px;
}

/* -- Rows ------------------------------------------------------------------ */

/* A short list with something at each end: a collaborator and their Remove,
   a reward and its price. Used by the shop page, the public page and the
   customer's catalogue, so it lives here rather than under one screen. */
.team,
.reward-menu {
  margin: 0 0 var(--s-4);
  padding: 0;
  list-style: none;
}

.team li,
.reward-menu li {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s-2);
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--line);
}

.team li:last-child,
.reward-menu li:last-child {
  border-bottom: 0;
}

.team form,
.reward-menu form {
  margin: 0;
}

/* The balance, as a number. Shared by the customer's card, the counter's
   result and the customers list; each screen sizes it in its own section. */
.points {
  margin: 0 0 var(--s-3);
  color: var(--ink);
  font-size: var(--fs-lg);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* -- Header ------------------------------------------------------------------ */

/* A quieter cousin of the landing's pill: the same surface, wordmark and
   hairline, exactly as wide as the content under it.

   It stays put while the page scrolls (ETE-210, audit B-17): the founder
   wants the header "to go with the user always", and the approved gallery
   (block 08) draws it sticky. Long lists -- fifty customers, the activity
   log -- used to carry "Cerrar sesión" off the top of the screen. Sticky
   rather than fixed, so it keeps its place in the flow and nothing under it
   has to be padded down by a guessed height.

   The surface is opaque and unblurred. backdrop-filter is the expensive part
   on the low-end Android phones a café's customers carry, and a see-through
   pill without it let the rows passing beneath read through the wordmark in
   dark mode. The pill floats over the content the way the landing's does. */
.site-header {
  position: sticky;
  z-index: 20;
  top: max(var(--s-3), env(safe-area-inset-top));
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: calc(100% - 2 * var(--gutter));
  max-width: var(--page);
  min-height: 60px;
  margin: max(var(--s-3), env(safe-area-inset-top)) auto 0;
  padding: 0 6px 0 18px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--surface);
  box-shadow: var(--sh-1);
}

/* The strip above the pill (ETE-208, QA N-07). With the pill floating 12px
   down -- or under the status bar's safe area on an iPhone -- the rows
   scrolling past showed through that gap, and on iOS and Android the clock
   sat on top of somebody's name. A band of the page's own colour, fixed
   behind the pill and in front of the content, fills the gap and fades out
   by the pill's middle, so there is no hard edge for the rows to vanish
   at and the pill still reads as floating. Only on a page that has the
   header (`:has`; a browser without it simply keeps the gap), never on
   paper. */
body:has(> .site-header)::before {
  content: "";
  position: fixed;
  z-index: 19;
  inset: 0 0 auto;
  height: calc(max(var(--s-3), env(safe-area-inset-top)) + 30px);
  background: linear-gradient(var(--bg) 40%, transparent);
  pointer-events: none;
}

@media print {
  body:has(> .site-header)::before {
    content: none;
  }
}

/* An in-page link (#section) must land below the header rather than under
   it: the pill's height, its offset from the top, and a little air. */
html {
  scroll-padding-top: calc(60px + max(var(--s-3), env(safe-area-inset-top)) + var(--s-3));
}

/* On paper a sticky header has nothing to stick to, and a printed page
   should not carry it over the first lines. */
@media print {
  .site-header {
    position: static;
  }
}

.site-header nav {
  display: flex;
  align-items: center;
  gap: var(--s-1);
  min-width: 0;
  margin-left: auto;
}

.site-header form {
  margin: 0;
}

/* -- The header's places (ETE-210, audit B-14) --------------------------------

   Mi tarjeta, Mi negocio and Escanear, for an account with more than one
   place to be (base.html; apps/accounts/context_processors.py). The header
   is its own container, so what decides between the row and the menu is the
   pill's width rather than the screen's: the same owner gets the row on the
   wide owner pages and the menu on their narrow card page if it does not fit,
   and nobody has to keep two breakpoints in step with two layouts.

   Below 34rem of header: the menu. From 34rem: the links in a row, and "Cerrar
   sesión" as its icon (its words stay as the button's accessible name). From
   52rem there is room for the name and the words too. */
.site-header {
  container: site-header / inline-size;
}

.site-header .site-links {
  display: none;
  gap: 2px;
  margin-left: var(--s-3);
}

.site-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  padding: 0 12px;
  border: 0;
  border-radius: var(--r-pill);
  background: none;
  color: var(--muted-text);
  font: inherit;
  font-size: var(--fs-sm);
  font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color var(--dur-2) var(--ease-standard),
    color var(--dur-2) var(--ease-standard);
}

.site-link:hover {
  background: var(--bg);
  color: var(--ink);
}

/* Where you are: ink on the quiet tint, never red -- red is the page's main
   action, and "you are here" is not an action. */
.site-link[aria-current="page"] {
  background: var(--brand-tint);
  color: var(--ink);
}

/* The menu, a native <details>: open and closed with no script. */
.site-menu summary {
  list-style: none;
}

.site-menu summary::-webkit-details-marker {
  display: none;
}

.site-menu[open] .site-menu__open,
.site-menu:not([open]) .site-menu__close {
  display: none;
}

.site-header .site-menu .btn--icon {
  width: 44px;
  padding: 0;
}

/* Hangs under the pill, as wide as it, and moves with it while it sticks. */
.site-menu__panel {
  position: absolute;
  z-index: 1;
  top: calc(100% + var(--s-2));
  right: 0;
  left: 0;
  display: grid;
  gap: 2px;
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  box-shadow: var(--sh-3);
  animation: site-menu-in var(--dur-4) var(--ease-enter);
}

.site-menu__panel .site-link {
  justify-content: flex-start;
  min-height: 48px;
  padding: 0 16px;
  border-radius: var(--r-sm);
  color: var(--ink);
  font-size: var(--fs-base);
}

.site-menu__panel form {
  margin-top: var(--s-1);
  padding-top: var(--s-1);
  border-top: 1px solid var(--line);
}

.site-menu__panel .site-link--out {
  width: 100%;
}

.site-menu__who {
  overflow: hidden;
  margin: 0;
  padding: var(--s-2) 16px var(--s-1);
  color: var(--muted-text);
  font-size: var(--fs-sm);
  text-overflow: ellipsis;
  white-space: nowrap;
}

@keyframes site-menu-in {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .site-menu__panel {
    animation: none;
  }
}

/* With the menu in use, the name and the way out live inside it. */
.site-header:has(.site-menu) .who,
.site-header:has(.site-menu) .site-account__out {
  display: none;
}

@container site-header (min-width: 34rem) {
  .site-header .site-links {
    display: flex;
  }

  .site-header .site-menu {
    display: none;
  }

  .site-header:has(.site-menu) .site-account__out {
    display: block;
  }

  .site-header:has(.site-menu) .site-account__label {
    position: absolute;
    overflow: hidden;
    width: 1px;
    height: 1px;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .site-header:has(.site-menu) .site-account__out .btn {
    width: 44px;
    padding: 0;
    justify-content: center;
  }
}

@container site-header (min-width: 52rem) {
  .site-header:has(.site-menu) .who {
    display: block;
  }

  .site-header:has(.site-menu) .site-account__label {
    position: static;
    overflow: visible;
    width: auto;
    height: auto;
    clip-path: none;
  }

  .site-header:has(.site-menu) .site-account__out .btn {
    width: auto;
    padding-inline: var(--s-3);
  }
}

/* The wordmark: the mark in red and "Repite" in the display serif, which is
   24px and so allowed (Instrument Serif never goes under 24). */
.brand {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: var(--s-2);
  min-height: 44px;
  color: var(--ink);
  font-family: var(--font-display);
  font-size: var(--fs-wordmark);
  line-height: 1;
  letter-spacing: -0.01em;
  text-decoration: none;
}

.brand .icon {
  width: 22px;
  height: 22px;
  color: var(--brand);
  stroke-width: 2;
}

/* The signed-in name: a quiet label that gives way first on a small phone. */
.site-header .who {
  overflow: hidden;
  max-width: 9rem;
  padding-inline: var(--s-2);
  color: var(--muted-text);
  font-size: var(--fs-sm);
  text-overflow: ellipsis;
  white-space: nowrap;
}

@media (max-width: 24rem) {
  .site-header .who {
    display: none;
  }
}

.site-header .btn--ghost {
  color: var(--ink);
}

.site-header .btn {
  white-space: nowrap;
}

/* Signed out on a phone, the wordmark and the two ways in ("Iniciar sesión",
   "Regístrate") share one line. Tighter padding is what makes them fit with
   the mark still beside the name at 360px and up (ETE-188: the mark used to
   go below 384px, and between 384 and ~400px the Spanish header overflowed
   its own pill). Only on a phone narrower than 352px does the mark give way;
   the words always stay. */
@media (max-width: 26rem) {
  .site-header {
    padding-left: 14px;
  }

  .brand {
    gap: 6px;
  }

  .site-header .btn--sm {
    padding-inline: var(--s-2);
  }
}

@media (max-width: 22rem) {
  .site-header {
    gap: var(--s-1);
  }

  .site-header:has(.btn--secondary) .brand .icon {
    display: none;
  }
}

/* Skip straight past the header to the page (WCAG 2.4.1). Off screen until
   it has focus, then a pill in the corner. */
.skip {
  position: absolute;
  z-index: 100;
  top: var(--s-2);
  left: var(--s-2);
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-pill);
  background: var(--dark);
  color: var(--on-dark);
  font-weight: 500;
  text-decoration: none;
  transform: translateY(-200%);
}

.skip:focus-visible {
  transform: none;
}

/* -- Footer ------------------------------------------------------------------ */

.site-footer {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  justify-content: center;
  width: calc(100% - 2 * var(--gutter));
  max-width: var(--page);
  margin: 0 auto;
  padding: var(--s-5) 0 max(var(--s-5), env(safe-area-inset-bottom));
  border-top: 1px solid var(--line);
  color: var(--muted-text);
  font-size: var(--fs-sm);
}

/* The language switch as a small segmented pill: the current language is the
   raised segment, the other is a plain button beside it. */
.lang-switch {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  margin: 0;
  padding: 3px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--sunk);
}

.lang-switch .current,
.lang-switch button.link {
  display: inline-flex;
  align-items: center;
  min-height: var(--target-sm);
  padding: 0 var(--s-4);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm);
}

.lang-switch .current {
  background: var(--surface);
  box-shadow: var(--sh-1);
  color: var(--ink);
  font-weight: 600;
}

.lang-switch button.link {
  color: var(--body);
  text-decoration: none;
}

@media (hover: hover) {
  .lang-switch button.link:hover {
    color: var(--ink);
  }
}

/* -- Confirmation dialog (ETE-211) ------------------------------------------ */

/* The one dialog every destructive action asks through
   (partials/confirm_dialog.html, static/js/confirm.js). A card lifted over a
   dimmed page: centred on a wide screen, a sheet from the bottom edge on a
   phone, where the thumb already is. Cancel is first in the source and takes
   focus; the action sits above it on a phone and to its right on a wide
   screen. Filled rust only when data-confirm-tone="danger" -- the one place
   besides "Close my account" that the filled destructive button appears,
   because this is the final step. */
.confirm {
  width: 100%;
  max-width: 100%;
  max-height: calc(100dvh - var(--s-5));
  margin: auto 0 0;
  padding: 0;
  border: 1px solid var(--line);
  border-bottom: 0;
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  background: var(--surface);
  color: var(--body);
  box-shadow: var(--sh-3);
  overscroll-behavior: contain;
}

.confirm[open] {
  animation: confirm-rise var(--dur-3) var(--ease-enter);
}

.confirm::backdrop {
  background: color-mix(in srgb, var(--ink) 34%, transparent);
  animation: confirm-fade var(--dur-3) var(--ease-standard);
}

.confirm__panel {
  display: grid;
  gap: var(--s-2);
  padding: var(--s-5) var(--s-5) calc(var(--s-5) + env(safe-area-inset-bottom));
}

.confirm__icon {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  margin-bottom: var(--s-1);
  border-radius: 50%;
  background: var(--sunk);
  color: var(--ink);
}

.confirm--danger .confirm__icon {
  background: var(--danger-bg);
  color: var(--danger);
}

.confirm__title {
  margin: 0;
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: var(--lh-tight);
  text-wrap: balance;
}

.confirm__body {
  margin: 0;
  font-size: var(--fs-base);
  text-wrap: pretty;
}

.confirm__actions {
  display: flex;
  flex-direction: column-reverse;
  gap: var(--s-2);
  margin-top: var(--s-4);
}

.confirm__actions .btn {
  width: 100%;
}

/* Busy keeps its colour: a grey "disabled" in place of the rust would read as
   "refused" in the second before the next page arrives. */
.confirm .btn--danger-solid[aria-busy="true"] {
  border-color: transparent;
  background: var(--danger);
  color: var(--on-danger);
}

.confirm .btn--primary[aria-busy="true"] {
  border-color: transparent;
  background: var(--brand);
  color: var(--on-brand);
}

@media (min-width: 36rem) {
  .confirm {
    width: calc(100% - var(--s-6));
    max-width: 30rem;
    margin: auto;
    border-bottom: 1px solid var(--line);
    border-radius: var(--r-lg);
  }

  .confirm[open] {
    animation-name: confirm-in;
  }

  .confirm__panel {
    padding: var(--s-5);
  }

  /* In a row, the action on the right. A pair too long for one line (the
     Spanish "Conservar mi cuenta" beside "Cerrar mi cuenta") wraps the
     action above the way back, as on a phone, rather than breaking a label
     over two lines. */
  .confirm__actions {
    flex-flow: row wrap-reverse;
    justify-content: flex-end;
  }

  .confirm__actions .btn {
    width: auto;
    white-space: nowrap;
  }
}

@media (prefers-color-scheme: dark) {
  .confirm::backdrop {
    background: color-mix(in srgb, var(--bg) 72%, transparent);
  }
}

@keyframes confirm-fade {
  from {
    opacity: 0;
  }
}

@keyframes confirm-rise {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
}

@keyframes confirm-in {
  from {
    opacity: 0;
    transform: scale(0.97);
  }
}

/* -- htmx ---------------------------------------------------------------------- */

/* htmx's own indicator rules, shipped here instead of injected (QA-SEC-1).
   htmx adds them as an inline <style> at load, which `style-src 'self'` blocks
   and reports on every page; base.html turns that off with
   `includeIndicatorStyles: false`, so the rules live in this file. */
.htmx-indicator {
  opacity: 0;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  opacity: 1;
  transition: opacity var(--dur-3) var(--ease-standard);
}

/* -- Reduced motion ------------------------------------------------------------- */

/* Reduce, do not remove: tone changes stay, travel and scale go, and the
   spinner becomes a slow pulse. */
@media (prefers-reduced-motion: reduce) {
  .btn,
  button {
    transition-property: background-color, border-color, color;
  }

  .confirm[open],
  .confirm::backdrop {
    animation: none;
  }

  .btn:active:not(:disabled, [aria-disabled="true"]),
  button:active:not(:disabled) {
    transform: none;
  }

  .btn[aria-busy="true"]::before,
  .htmx-request.btn::before,
  .htmx-request > button[type="submit"]::before {
    border-right-color: currentcolor;
    animation: pulse 1s ease-in-out infinite;
  }
}

/* ==========================================================================
   3. CUSTOMER (ETE-183)
   The code panel, the card and its points, the reward catalogue, the visit
   history, referrals and the public shop page, on the approved gallery
   (blocks 07, 09, 16, 17, 19 and 20).

   Subtle everywhere but one place. Hairlines of 1-1.5px, the brand red only
   for the main action, progress and the balance's own mark, and motion of a
   few pixels in 150-400ms. The exception is a reward being unlocked, which is
   meant to be noticed: 1.1s, a burst of 24 points and the trophy jumping.

   Every animation here is played by a class that static/js/code-panel.js puts
   on once; the markup is always drawn in its end state, so a page without the
   script, or under reduced motion, is complete.
   ========================================================================== */

/* -- The loyalty code (gallery block 17) ------------------------------------ */

/* A centred column: the title, the code, the countdown, the spoken code and a
   quiet way to ask for another. Clipped sideways so the celebration's burst
   can fly over the card's top edge without widening a 360px page. */
.check-in {
  display: grid;
  justify-items: center;
  gap: var(--s-3);
  overflow-x: clip;
  text-align: center;
}

.check-in > *,
.check-in h2 {
  margin: 0;
}

.check-in__title {
  font-size: var(--fs-md);
}

.check-in__lead {
  max-width: 30ch;
  color: var(--body);
  font-size: var(--fs-sm);
}

/* The frame: one round white ground in both themes, the QR at 56% of it, and
   the ring lying just inside the ground's edge.

   The white disc is the code's quiet zone. The QR's own SVG carries two
   modules of white (visits.qr); the disc adds the rest. Its radius is 47% of
   the frame and the QR's corner sits 39.6% from the centre (56% / 2 x
   sqrt 2), so even at the corners -- the tightest place -- there are another
   five modules of white before the ring, and four times that at the middle of
   each edge: at least the four modules ISO/IEC 18004 asks for, everywhere. */
.qr-frame {
  position: relative;
  display: grid;
  place-items: center;
  width: min(100%, 17.5rem);
  aspect-ratio: 1;
}

.qr-frame::before {
  position: absolute;
  inset: 3%;
  border-radius: 50%;
  background: #ffffff;
  content: "";
}

/* The code is always black on white, in both themes: the one colour literal
   this file allows. Inverting it for dark mode produces a code many scanners
   refuse to read. */
.qr {
  width: 100%;
  max-width: 18rem;
  aspect-ratio: 1;
  margin: 0 auto;
  background: #ffffff;
}

.qr-frame .qr {
  position: relative;
  width: 56%;
  max-width: none;
}

.qr svg {
  display: block;
}

.qr path {
  fill: #000000;
}

/* A rotation fades the new code in where the old one was: same place, same
   size, 150ms, so a scan half-way through is not knocked off its target. */
.qr.is-new svg {
  animation: fade-in var(--dur-2) linear;
}

/* The ring: a fine line of 64 soft waves (templates/visits/_ring_defs.html).
   1.25 units, the weight the approved gallery (block 17, v2.3) and the
   landing's demo draw. In the ring's 102-unit viewBox that is 1.2% of the
   frame, ~3.4px at its full 280px and the gallery's ~2.9px at its 232px
   (ETE-188; 0.48 in 104 units had made it a third of that). A wider viewBox
   would push the ring into the code's four-module quiet zone, which
   config.test_customer_screens measures.
   The track is a warm grey mixed against the white it always lies on, so it
   reads the same in the dark theme as in the light one. */
.ring {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  transform: rotate(-90deg);
}

.ring use {
  fill: none;
  stroke-width: 1.25;
  stroke-linejoin: round;
}

.qr-frame .ring__track {
  stroke: color-mix(in srgb, var(--red-900) 16%, #ffffff);
}

/* `--ring-period` and `--ring-delay` are the script's to set (code-panel.js,
   through the CSSOM, on the frame): how long the code's step is and how far
   into it the code already is. Declared here with the values a full, fresh
   step would have, and inherited by the mask's circle below. */
.qr-frame {
  --ring-period: 60s;
  --ring-delay: 0s;
}

.ring__prog {
  stroke: var(--brand);
  transition: stroke var(--dur-3) var(--ease-standard);
}

/* What drains is not the wave but the circle in its mask (visits/_ring.html,
   ETE-209): Safari never repaints a dash moving along the wave's 640 points,
   and it does along a circle's. White is where the wave shows; the width
   covers the waves' swing and the line's own. Scoped to the QR frame -- the
   only place the ring is drawn -- because a mask's white is a luminance, not
   a colour anybody reads, and the stylesheet's one literal-colour exception
   is the QR's (config.tests, StylesheetHealthTests). */
.qr-frame .ring__sweep {
  fill: none;
  stroke: #ffffff;
  stroke-width: 4;
  stroke-dasharray: 100;
  stroke-dashoffset: 0;
}

/* Draining, in real time and linearly, once the script has said where in its
   period the code already is. Until then the ring is full and still rather
   than draining from the wrong place. It keeps draining under reduced motion:
   it is information, not decoration. */
.qr-frame.is-live .ring__sweep {
  animation: ring-drain var(--ring-period) linear var(--ring-delay) forwards;
}

/* A new code (ETE-190): the line sweeps back round to full, then drains. The
   sweep is listed second so it wins while it runs, and its missing `to` is the
   drain's own value, so it lands exactly where the countdown says. The number
   fades in with it: the two together are how the customer sees that the
   timer started again. */
.qr-frame.is-live.is-renewed .ring__sweep {
  animation:
    ring-drain var(--ring-period) linear var(--ring-delay) forwards,
    ring-refill var(--dur-6) var(--ease-enter);
}

.qr-frame.is-renewed ~ .countdown [data-countdown] {
  animation: fade-in var(--dur-5) var(--ease-standard);
}

@keyframes ring-refill {
  from {
    stroke-dashoffset: 100;
  }
}

/* The last three seconds, in amber: a code about to change is worth a glance
   before it is held up to a scanner.

   Kept on purpose (ETE-208, QA C-26). It is information, not decoration: a
   code shown in its last seconds can turn over while the counter is still
   aiming, and the scan then reads a code that has just been replaced. Amber
   is the warning tone, not the brand red, it is only the ring's thin stroke,
   and it changes colour without moving, so reduced motion needs nothing
   different. The countdown under the code says the same in words. */
.qr-frame.is-ending .ring__prog {
  stroke: var(--warning-500);
}

@keyframes ring-drain {
  to {
    stroke-dashoffset: 100;
  }
}

/* The countdown is shared with the counter's panels, which keep the plain
   line; on the customer's code the number is the part worth reading. */
.countdown {
  margin: 0 0 var(--s-3);
  color: var(--muted-text);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
}

.check-in .countdown {
  margin: 0;
}

.check-in [data-countdown] {
  color: var(--ink);
  font-weight: 600;
}

/* The spoken code (ETE-40), in a quiet sunk pill: read across a counter in a
   noisy room, character by character, so it is monospaced and spaced. 25px
   keeps twelve characters and two dashes on one line at 360px. */
.spoken {
  display: grid;
  justify-items: center;
  gap: 2px;
  padding: var(--s-2) var(--s-5);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--sunk);
}

.spoken__label {
  margin: 0;
  color: var(--muted-text);
  font-size: var(--fs-xs);
}

.short-code {
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink);
  font: 600 var(--fs-lg) / 1.2 var(--font-mono);
  letter-spacing: 0.08em;
  white-space: nowrap;
}

/* "Show a different code" and "Another code": centred under the code, at
   their own width (QA-DASH-2), with the warning close under the first. */
.check-in form {
  display: grid;
  justify-items: center;
  gap: var(--s-1);
  margin: 0;
}

.disclaimer {
  margin: 0;
  color: var(--muted-text);
  font-size: var(--fs-xs);
}

/* -- A visit landing (gallery block 19) ------------------------------------- */

.counted {
  display: grid;
  justify-items: center;
  gap: var(--s-3);
  width: 100%;
}

.counted > *,
.counted h2 {
  margin: 0;
}

/* The brand's one gesture: a red point with a white check, dropped in with a
   spring and an ink ring spreading from it. Every check is white, the goal's
   included (approval, 2 November). */
.stamp {
  position: relative;
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--brand);
  box-shadow: inset 0 -3px 0 color-mix(in srgb, var(--red-900) 22%, transparent);
  color: var(--on-brand);
}

.stamp::before {
  width: 46%;
  height: 46%;
  background: currentcolor;
  content: "";
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5 9.5 18 20 6.5'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5 9.5 18 20 6.5'/%3E%3C/svg%3E") center / contain no-repeat;
}

.counted.is-landing .stamp {
  animation: stamp-drop 450ms var(--ease-spring-pop) both;
}

.counted.is-landing .stamp::after {
  position: absolute;
  inset: -2px;
  border: 1.5px solid var(--brand);
  border-radius: 50%;
  content: "";
  animation: ink-ring var(--dur-5) var(--ease-enter) 260ms both;
}

@keyframes stamp-drop {
  0% {
    opacity: 0;
    transform: scale(1.4) rotate(-12deg);
  }

  60% {
    opacity: 1;
  }

  100% {
    transform: none;
  }
}

@keyframes ink-ring {
  from {
    opacity: 0.6;
    transform: scale(1);
  }

  to {
    opacity: 0;
    transform: scale(1.6);
  }
}

/* -- The balance -------------------------------------------------------------- */

/* The number a customer opens a card to read: Inter in tabular figures at the
   top of the scale (DV6), and the word after it quiet. `.points` itself is a
   shared component; these are the customer's sizes for it. */
.balance {
  display: grid;
  gap: var(--s-3);
}

.balance > * {
  margin: 0;
}

.balance .points,
.balance-card .points,
.counted__points .points {
  position: relative;
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  margin: 0;
  color: var(--body);
  font-size: var(--fs-base);
  font-weight: 500;
}

.balance .points strong,
.balance-card .points strong,
.counted__points .points strong {
  color: var(--ink);
  font-size: var(--fs-2xl);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}

/* The odometer: the old number leaves upwards as the new one arrives from
   below, in one line of tabular figures so nothing beside it shifts. */
.odo {
  display: inline-grid;
  height: 1em;
  overflow: hidden;
  line-height: 1;
  vertical-align: bottom;
}

.odo span {
  grid-area: 1 / 1;
}

.odo .out {
  animation: odo-out var(--dur-4) var(--ease-exit) forwards;
}

.odo .in {
  animation: odo-in var(--dur-4) var(--ease-spring-soft) both;
}

@keyframes odo-out {
  to {
    opacity: 0;
    transform: translateY(-100%);
  }
}

@keyframes odo-in {
  from {
    opacity: 0;
    transform: translateY(100%);
  }
}

@keyframes fade-out {
  to {
    opacity: 0;
  }
}

/* "+1": a small red chip that rises and goes. */
.plus-one {
  position: absolute;
  top: -6px;
  left: 100%;
  margin-left: 6px;
  padding: 4px 10px;
  border-radius: var(--r-pill);
  background: var(--brand);
  color: var(--on-brand);
  font: 600 var(--fs-xs) / 1 var(--font-ui);
  white-space: nowrap;
  pointer-events: none;
  animation: plus-up 900ms var(--ease-enter) forwards;
}

@keyframes plus-up {
  0% {
    opacity: 0;
    transform: translateY(6px) scale(0.95);
  }

  25% {
    opacity: 1;
    transform: none;
  }

  70% {
    opacity: 1;
  }

  100% {
    opacity: 0;
    transform: translateY(-14px);
  }
}

@keyframes plus-static {
  0%,
  80% {
    opacity: 1;
  }

  100% {
    opacity: 0;
  }
}

/* -- The bar towards the next reward ------------------------------------------ */

/* The one place red means "progress". Drawn by scaleX on a class (p-0 to
   p-100, rewards/_goal.html), never by width: a transform moves without
   laying the card out again, and a class is what the CSP allows. It moves
   400ms after a short wait, so it follows the point rather than racing it. */
.goal {
  display: grid;
  gap: var(--s-2);
  width: 100%;
  text-align: left;
}

.goal > * {
  margin: 0;
}

.goal__bar {
  height: 6px;
  overflow: hidden;
  border-radius: var(--r-pill);
  background: var(--line);
}

.goal__fill {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--red-300), var(--brand));
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--dur-6) var(--ease-standard) var(--dur-2);
}

.goal__fill.is-still {
  transition: none;
}

.goal__fill.p-5 { transform: scaleX(0.05); }
.goal__fill.p-10 { transform: scaleX(0.1); }
.goal__fill.p-15 { transform: scaleX(0.15); }
.goal__fill.p-20 { transform: scaleX(0.2); }
.goal__fill.p-25 { transform: scaleX(0.25); }
.goal__fill.p-30 { transform: scaleX(0.3); }
.goal__fill.p-35 { transform: scaleX(0.35); }
.goal__fill.p-40 { transform: scaleX(0.4); }
.goal__fill.p-45 { transform: scaleX(0.45); }
.goal__fill.p-50 { transform: scaleX(0.5); }
.goal__fill.p-55 { transform: scaleX(0.55); }
.goal__fill.p-60 { transform: scaleX(0.6); }
.goal__fill.p-65 { transform: scaleX(0.65); }
.goal__fill.p-70 { transform: scaleX(0.7); }
.goal__fill.p-75 { transform: scaleX(0.75); }
.goal__fill.p-80 { transform: scaleX(0.8); }
.goal__fill.p-85 { transform: scaleX(0.85); }
.goal__fill.p-90 { transform: scaleX(0.9); }
.goal__fill.p-95 { transform: scaleX(0.95); }
.goal__fill.p-100 { transform: none; }

.goal__line {
  color: var(--body);
  font-size: var(--fs-sm);
}

/* -- A reward unlocked (gallery block 20) -------------------------------------
   The one celebration in the app, and meant to be seen: a red glow behind the
   card, 24 points bursting out over it, the card arriving with a little
   overshoot and the trophy jumping. 1.1s, once (code-panel.js), and a tap or
   a key skips to the end. Under reduced motion the card fades in, the trophy
   stays still and the glow is still felt -- colour, with nothing travelling. */

.celebrate {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  padding: var(--s-2) 0;
  isolation: isolate;
}

.celebrate__glow {
  position: absolute;
  z-index: -1;
  width: min(22rem, 120%);
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle, color-mix(in srgb, var(--brand) 55%, transparent), transparent 65%);
  opacity: 0;
  pointer-events: none;
}

.celebrate__burst {
  position: absolute;
  z-index: 2;
  top: 46%;
  left: 50%;
  width: 0;
  height: 0;
  pointer-events: none;
}

/* --x and --y are where each point flies, set by code-panel.js through the
   CSSOM; until then every point sits at the centre. */
.celebrate__burst i {
  --x: 0px;
  --y: 0px;
  position: absolute;
  width: 12px;
  height: 12px;
  margin: -6px 0 0 -6px;
  border-radius: 50%;
  background: var(--brand);
  opacity: 0;
}

.celebrate__burst i:nth-child(3n) {
  width: 8px;
  height: 8px;
  background: var(--red-200);
}

.celebrate__burst i:nth-child(4n) {
  border-radius: 3px;
  background: var(--red-700);
}

.prize {
  position: relative;
  display: grid;
  justify-items: center;
  gap: var(--s-2);
  width: min(100%, 20rem);
  padding: var(--s-5) var(--s-4);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  box-shadow: var(--sh-3);
  text-align: center;
}

.prize > *,
.prize h3 {
  margin: 0;
}

.prize__icon {
  display: grid;
  place-items: center;
  width: 60px;
  height: 60px;
  border-radius: var(--r-md);
  background: var(--brand);
  box-shadow: var(--sh-red);
  color: var(--on-brand);
}

/* The reward's name in the display serif, at the section step (24 -> 28px):
   Instrument Serif only ever goes at 24px and up (ETE-208, QA B-09). */
.prize__name {
  color: var(--ink);
  font-family: var(--font-display);
  font-size: var(--fs-section);
  font-weight: 400;
  line-height: 1.1;
}

.prize__line {
  color: var(--body);
  font-size: var(--fs-sm);
}

.prize form {
  margin-top: var(--s-2);
}

.celebrate.is-on .prize {
  animation: prize-in var(--dur-celebrate) var(--ease-spring-pop) both;
}

.celebrate.is-on .prize__icon {
  animation: trophy 900ms var(--ease-spring-pop) 180ms both;
}

.celebrate.is-on .celebrate__glow {
  animation: glow var(--dur-celebrate) var(--ease-enter) both;
}

.celebrate.is-on .celebrate__burst i {
  animation: particle var(--dur-celebrate) cubic-bezier(0, 0, 0, 1) 80ms both;
}

@keyframes prize-in {
  0% {
    opacity: 0;
    transform: scale(0.7) translateY(18px);
  }

  60% {
    opacity: 1;
    transform: scale(1.04);
  }

  100% {
    opacity: 1;
    transform: none;
  }
}

@keyframes trophy {
  0% {
    transform: scale(0.4) rotate(-20deg);
  }

  55% {
    transform: scale(1.2) rotate(8deg);
  }

  100% {
    transform: none;
  }
}

@keyframes glow {
  0% {
    opacity: 0;
    transform: scale(0.6);
  }

  45% {
    opacity: 0.9;
  }

  100% {
    opacity: 0;
    transform: scale(1.5);
  }
}

@keyframes glow-rm {
  0% {
    opacity: 0;
  }

  30% {
    opacity: 0.9;
  }

  100% {
    opacity: 0;
  }
}

@keyframes particle {
  0% {
    opacity: 1;
    transform: translate(0, 0) scale(1.1);
  }

  70% {
    opacity: 1;
  }

  100% {
    opacity: 0;
    transform: translate(var(--x), var(--y)) scale(0.6);
  }
}

/* A surprise double point (ETE-26). Good news, so success and not the brand
   red, with one short entrance. Shared with the counter's scan result. */
.surprise {
  margin: 0 0 var(--s-3);
  padding: 10px var(--s-3);
  border: 1px solid var(--success-line);
  border-radius: var(--r-sm);
  background: var(--success-bg);
  color: var(--success);
  font-weight: 600;
  text-align: center;
  animation: surprise-in var(--dur-5) var(--ease-enter) both;
}

.check-in .surprise {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  margin: 0;
  font-size: var(--fs-sm);
}

@keyframes surprise-in {
  from {
    opacity: 0;
    transform: scale(0.97);
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}

/* -- The customer's cards (gallery block 07) ---------------------------------- */

.shop-card {
  display: grid;
  gap: var(--s-4);
}

.shop-card > *,
.shop-card h2,
.shop-card h3 {
  margin: 0;
}

.shop-card__head {
  display: flex;
  align-items: center;
  gap: var(--s-3);
}

.shop-card__title {
  min-width: 0;
}

.shop-card__title h3 {
  margin: 0;
  font-size: var(--fs-md);
}

.shop-card__title .meta {
  margin: 2px 0 0;
  font-size: var(--fs-xs);
}

/* A shop's mark when it has no logo: its initial, white on the red. */
.avatar {
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--r-tile);
  background: var(--brand);
  color: var(--on-brand);
  font-size: var(--fs-base);
  font-weight: 600;
  line-height: 1;
}

.avatar--lg {
  width: 72px;
  height: 72px;
  border-radius: var(--r-tile-lg);
  font-size: var(--fs-xl);
}

.shop-card__logo {
  flex: none;
  width: 44px;
  height: 44px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--surface);
  object-fit: contain;
}

/* A reward already paid for and waiting at a counter: the featured card, and
   the one place red is a background (§2.2), in --brand-gradient. No red
   glow under it: it is not a button, and --sh-red is the celebration's
   alone (ETE-204, audit B-04/B-16). This is the only featured reward in the
   app -- the owner's menu lists every reward the same (audit D-07). */
.reward-card--feature {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
  padding: 14px 14px 14px 12px;
  border-radius: var(--r-md);
  background: var(--brand-gradient);
  color: var(--on-brand);
}

.reward-card--feature .r-ico {
  display: grid;
  flex: none;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--on-brand) 16%, transparent);
}

.reward-card--feature .ready {
  flex: 1 1 8rem;
  margin: 0;
  color: var(--on-brand);
}

/* White on the red, with the deepest red for the words (7.3:1). */
.btn--on-feature {
  background: var(--on-brand);
  color: var(--red-700);
}

@media (hover: hover) {
  .btn--on-feature:hover {
    background: var(--red-50);
  }
}

/* The menu, one row per reward: a gift in a tinted tile, the name and its
   price, and at the far end Redeem or the distance. A row the card can afford
   carries a faint brand tint, so it is found before it is read. */
.rewards-list {
  display: grid;
  gap: var(--s-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.reward-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2) var(--s-3);
  min-height: 64px;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface);
}

.reward-row.is-ready {
  border-color: var(--brand-halo);
  background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--brand-tint) 55%, var(--surface)));
}

.reward-row__icon {
  display: grid;
  flex: none;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--r-sm);
  background: var(--brand-tint);
  color: var(--brand-text);
}

.reward-row__text {
  display: grid;
  flex: 1 1 7rem;
  gap: 2px;
  min-width: 0;
}

.reward-row__name {
  flex: 1 1 7rem;
  min-width: 0;
  color: var(--ink);
  font-weight: 500;
  line-height: 1.3;
}

.reward-row .meta {
  font-size: var(--fs-xs);
}

.reward-row__short {
  margin-left: auto;
  color: var(--muted-text);
  font-size: var(--fs-xs);
  text-align: right;
}

.reward-row form {
  margin: 0 0 0 auto;
}

/* The public page's price: the points in ink, the visits under them. */
.reward-row__price {
  display: grid;
  justify-items: end;
  margin-left: auto;
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  text-align: right;
  white-space: nowrap;
}

.reward-row__price .meta {
  font-weight: 400;
}

/* "Where these points came from" and "Their page", side by side and each a
   whole thumb's worth tall. */
.shop-card__links {
  display: flex;
  flex-wrap: wrap;
  gap: 0 var(--s-5);
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--fs-sm);
}

.shop-card__links a {
  gap: 6px;
  text-decoration: none;
}

@media (hover: hover) {
  .shop-card__links a:hover {
    text-decoration: underline;
  }
}

/* "Invite a friend" (ETE-27, ETE-47): folded under a hairline, opened by a
   row with a chevron that turns. */
.invite {
  border-top: 1px solid var(--line);
}

.invite summary {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--target);
  /* Ink with its chevron (ETE-208, QA N-04): one red line under every card
     was a second accent per shop. */
  color: var(--ink);
  font-weight: 500;
  list-style: none;
  cursor: pointer;
}

.invite summary .icon {
  color: var(--muted-text);
}

.invite summary::-webkit-details-marker {
  display: none;
}

.invite__chevron {
  margin-left: auto;
  transition: transform var(--dur-3) var(--ease-standard);
}

.invite[open] .invite__chevron {
  transform: rotate(180deg);
}

.invite p {
  margin: var(--s-2) 0;
  font-size: var(--fs-sm);
}

.invite .button-row {
  margin: var(--s-3) 0 var(--s-2);
}

/* Monospaced, like every other code here: the last eight characters of this
   URL are the code. */
.share-link {
  font-family: var(--font-mono);
}

/* The dashboard's empty list: what is going on and what to do now. */
.cards-empty {
  margin: var(--s-6) 0;
}

/* -- The rest of the dashboard ------------------------------------------------ */

/* An invitation to help at a shop, or the poster for a shop nobody has joined:
   a card with an icon beside it, saying one thing and offering one action. */
.notice-card {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
}

.notice-card__icon {
  display: grid;
  flex: none;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--r-sm);
  background: var(--brand-tint);
  color: var(--brand-text);
}

.notice-card__body {
  min-width: 0;
}

.notice-card h2 {
  margin: var(--s-2) 0 var(--s-1);
  font-size: var(--fs-md);
}

.notice-card .button-row {
  margin: var(--s-3) 0 0;
}

.store-card h3 {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--fs-md);
}

.store-card h3 .icon {
  color: var(--muted-text);
}

.store-card .button-row {
  margin: var(--s-3) 0 0;
}

/* The account's own pages: one row each, so a narrow screen wraps between
   rows and never leaves a separator hanging, and "Close my account" apart
   underneath as the rust outline. */
.account {
  display: grid;
  gap: var(--s-4);
  margin: var(--s-7) 0 0;
}

/* Scoped to the dashboard's `nav.account`: the profile and the notification
   preferences use `.account-links` as section 6's inline row, and the two
   unscoped rules were merging into a bordered box of wrapped links on both
   (ETE-188). */
.account .account-links {
  display: block;
  margin: 0;
  padding: var(--s-1) var(--s-4);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  list-style: none;
}

.account .account-links li + li {
  border-top: 1px solid var(--line);
}

.account .account-links a {
  display: flex;
  gap: var(--s-3);
  color: var(--ink);
  text-decoration: none;
}

.account .account-links a > span {
  flex: 1;
}

.account .account-links .icon {
  color: var(--muted-text);
}

/* The chevron is the one sign the row can be tapped, so it is text-grey
   (4.5:1), not the decoration grey (audit B-26; the selector also named
   .account twice and matched nothing). */
.account .account-links .account-links__go {
  color: var(--muted-text);
}

@media (hover: hover) {
  .account .account-links a:hover > span {
    text-decoration: underline;
  }
}

.account-close {
  justify-self: start;
}

/* -- The visits behind a card's number (ETE-41) -------------------------------- */

.balance-card {
  display: grid;
  gap: var(--s-2);
}

.balance-card > * {
  margin: 0;
}

.balance-card .points {
  margin-bottom: var(--s-2);
}

.history {
  margin: var(--s-5) 0 0;
  padding: 0 var(--s-4);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  list-style: none;
}

.history .entry {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--line);
}

.history .entry:last-child {
  border-bottom: 0;
}

.entry__mark {
  display: grid;
  flex: none;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--sunk);
  color: var(--muted-text);
}

.entry-stamp .entry__mark {
  background: var(--brand-tint);
  color: var(--brand-text);
}

.entry-reward .entry__mark,
.entry-redeemed .entry__mark {
  background: var(--success-bg);
  color: var(--success);
}

.entry__body {
  display: grid;
  flex: 1;
  gap: 2px;
  min-width: 0;
}

.history .what {
  color: var(--ink);
  font-weight: 500;
}

/* A taken-back visit is struck through rather than hidden or greyed out. */
.history .entry-voided .what {
  color: var(--muted-text);
}

.history .entry-reward .what,
.history .entry-redeemed .what {
  font-weight: 600;
}

/* The date under the words, tabular, so the eye can run down the column of
   timestamps looking for the day being argued about. */
.history .when {
  color: var(--muted-text);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
}

.entry__body .meta {
  font-size: var(--fs-xs);
}

/* What the row was worth (ETE-136), at the far end in tabular figures, so the
   column can be read down and added up. */
.history .amount {
  flex: none;
  margin-left: auto;
  font-size: var(--fs-sm);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.history__empty {
  padding: var(--s-6) 0;
}

/* -- A reward the customer holds (ETE-57) --------------------------------------- */

.reward-meta {
  margin-top: calc(-1 * var(--s-2));
}

.handed-over,
.ready-to-show {
  display: grid;
  justify-items: center;
  gap: var(--s-2);
}

.handed-over > *,
.ready-to-show > * {
  margin: 0;
}

.handed-over__mark,
.ready-to-show__mark {
  display: grid;
  place-items: center;
  width: 64px;
  height: 64px;
  margin-bottom: var(--s-1);
  border-radius: 50%;
  background: var(--success-bg);
  color: var(--success);
}

.ready-to-show__mark {
  background: var(--brand-tint);
  color: var(--brand-text);
}

.ready-to-show__title {
  color: var(--ink);
  font-size: var(--fs-md);
  font-weight: 600;
}

/* Cancelling a reward that has not been handed over (ETE-58). Quiet and set
   apart from the QR: a Cancel of the same weight beside "show the code" is a
   Cancel somebody taps by accident at a counter. */
.cancel-reward {
  margin: var(--s-2) 0 0;
}

.cancel-reward .btn--link {
  color: var(--body);
  font-size: var(--fs-sm);
}

/* -- The shop's public page --------------------------------------------------- */

/* The head of the funnel: the shop's mark beside its name and address. */
.shop-hero {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  margin: var(--s-2) 0 var(--s-5);
}

.shop-hero__title {
  min-width: 0;
}

.shop-hero h1 {
  margin: 0;
}

.shop-hero .meta {
  margin: var(--s-1) 0 0;
}

.shop-hero .shop-hero__logo {
  flex: none;
  width: 72px;
  height: 72px;
  margin: 0;
  border: 1px solid var(--line);
  border-radius: var(--r-tile-lg);
}

/* "Ana invited you to Café Plaza": the reason the page was opened, in a light
   brand tint with a gift beside it. */
.invited {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  margin: 0 0 var(--s-4);
  padding: var(--s-4);
  border: 1px solid var(--brand-halo);
  border-radius: var(--r-md);
  background: var(--brand-tint);
}

.invited__icon {
  display: grid;
  flex: none;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--surface);
  color: var(--brand-text);
}

.invitation {
  margin: 0;
  color: var(--ink);
  font-weight: 600;
}

.invitation-offer {
  margin: var(--s-1) 0 0;
  color: var(--body);
  font-size: var(--fs-sm);
}

.shop-menu h2 {
  margin-top: 0;
}

.shop-menu__about {
  margin: var(--s-4) 0 0;
  color: var(--body);
}

/* Join, and the way in for somebody who already has an account: full width on
   a phone, one red and one outlined, with the reassurance between them. */
.join {
  display: grid;
  gap: var(--s-3);
  margin: var(--s-5) 0;
}

.join__note {
  margin: 0;
  text-align: center;
}

/* -- Customer audit fixes (ETE-205) ------------------------------------------ */

/* One block for the phase-2b customer fixes, so a merge beside the owner's and
   the account screens' own passes (ETE-206, ETE-207) meets them in one place.
   Each rule names the audit finding it answers. */

/* C-16: the Redeem button on the reward's own line at 360px. The row wrapped
   it onto a second line under the name, so every affordable row was ~115px
   tall and the button sat far from the thing it bought. The text takes what
   is left (`flex: 1 1 0` and `min-width: 0`), and a long name wraps inside
   its own column instead of pushing the button down. */
.reward-row {
  flex-wrap: nowrap;
}

/* ...which only holds if the card's one column can be narrower than a row's
   unwrapped content: a grid track is `auto` by default and grows to its
   widest item's min-content, so a row that no longer wraps pushed the whole
   card past the phone's edge. */
.shop-card {
  grid-template-columns: minmax(0, 1fr);
}

.reward-row__text {
  flex: 1 1 0;
}

.reward-row__action {
  flex: none;
  margin-left: auto;
}

.reward-row__short {
  flex: none;
  max-width: 7.5rem;
}

/* On a phone the gift tile steps down to 32px and the gaps tighten, so the
   name keeps a column it can be read in beside the button. */
@media (max-width: 25rem) {
  .reward-row {
    gap: var(--s-2);
    padding: 10px;
  }

  .reward-row__icon {
    width: 32px;
    height: 32px;
  }

  .reward-row__icon .icon {
    width: 18px;
    height: 18px;
  }

  .reward-row__action {
    padding-inline: 14px;
  }
}

/* C-01: "You have 1 ready" under the price, in the brand's text colour. */
.reward-row__held {
  display: flex;
  align-items: flex-start;
  gap: 4px;
  margin-top: 2px;
  color: var(--brand-text);
  font-size: var(--fs-xs);
  font-weight: 600;
  line-height: 1.35;
}

.reward-row__held .icon {
  flex: none;
  width: 14px;
  height: 14px;
  margin-top: 1px;
}

/* C-02: the confirm step, read as a receipt -- label left, figure right, one
   hairline between rows, the cost in ink as the thing being agreed to. */
.confirm-redeem {
  display: grid;
  gap: var(--s-4);
}

.confirm-redeem > * {
  margin: 0;
}

.confirm-redeem__sum {
  margin: 0;
}

.confirm-redeem__sum > div {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--line);
}

.confirm-redeem__sum > div:first-child {
  padding-top: 0;
}

.confirm-redeem__sum dt {
  color: var(--body);
}

.confirm-redeem__sum dd {
  margin: 0;
  color: var(--ink);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.confirm-redeem__cost {
  font-size: var(--fs-md);
  font-weight: 600;
}

.confirm-redeem__held p {
  margin: 0;
}

.confirm-redeem__held .btn--link {
  min-height: var(--target-sm);
  padding: 0;
}

.confirm-redeem__note {
  text-align: center;
}

/* C-06: what the visit added, kept under the number once the "+1" has gone. */
.counted__added {
  margin-top: calc(-1 * var(--s-2));
}

/* C-23: the rising "+N" beside the number rather than over it -- set on the
   baseline with air before it, so it rises within the number's own height
   instead of into the heading above. */
.counted__points .plus-one {
  top: auto;
  bottom: 0.2em;
  margin-left: 10px;
}

/* C-29: when the receipt gives way to the code by itself. */
.counted__hold {
  margin: var(--s-3) 0 0;
  text-align: center;
}

/* C-20: the reward's reference, the quietest line on its panel. */
.check-in .reward-ref {
  font-size: var(--fs-xs);
}

/* C-18: the history grouped by day -- the date once, small, over its rows. */
.history-day {
  margin-top: var(--s-5);
}

.history-day__title {
  margin: 0 0 var(--s-2);
  padding: 0 var(--s-1);
  color: var(--muted-text);
  font-family: var(--font-ui);
  font-size: var(--fs-sm);
  font-weight: 600;
  line-height: 1.3;
}

.history-day .history {
  margin-top: 0;
}

.entry__status {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 var(--s-3);
  margin-top: 4px;
}

.entry__status form {
  margin: 0;
}

.entry__show {
  min-height: var(--target-sm);
  padding: 0;
  font-size: var(--fs-sm);
}

/* C-10: the public page says how the points get there before asking anyone
   to join. */
.shop-menu__how {
  margin: 0 0 var(--s-4);
}

/* C-11: the shop a signup leads to, above the form. */
.joining {
  margin-bottom: var(--s-4);
}

/* C-19: "Saved", in place, with room kept for it so nothing jumps. */
.prefs-saved {
  display: flex;
  align-items: center;
  gap: 6px;
  min-height: 1.5em;
  margin: 0;
  font-size: var(--fs-sm);
}

/* -- Reduced motion ------------------------------------------------------------- */

/* Reduce, do not remove: fades of 150ms in place of travel, the bar jumping to
   its value, the trophy still and the celebration's glow still felt. The ring
   keeps draining. */
@media (prefers-reduced-motion: reduce) {
  .qr.is-new svg {
    animation: none;
  }

  .qr-frame.is-live.is-renewed .ring__sweep {
    animation: ring-drain var(--ring-period) linear var(--ring-delay) forwards;
  }

  .counted.is-landing .stamp {
    animation: fade-in var(--dur-2) linear both;
  }

  .counted.is-landing .stamp::after {
    display: none;
  }

  .odo .out {
    animation: fade-out var(--dur-2) linear forwards;
  }

  .odo .in {
    animation: fade-in var(--dur-2) linear both;
  }

  .plus-one {
    animation: plus-static 1s linear forwards;
  }

  .goal__fill,
  .invite__chevron {
    transition: none;
  }

  .celebrate.is-on .prize {
    animation: fade-in var(--dur-2) linear both;
  }

  .celebrate.is-on .prize__icon {
    animation: none;
  }

  .celebrate.is-on .celebrate__glow {
    animation: glow-rm 1200ms linear both;
  }

  .celebrate.is-on .celebrate__burst i {
    animation: none;
  }

  .surprise {
    animation-name: fade-in;
    animation-duration: var(--dur-2);
  }
}

/* The email-only screen (ETE-217): the code, and under it the address it is for, in
   one narrow column at any width. */
.qr-screen {
  display: grid;
  gap: var(--s-4);
  width: 100%;
  max-width: 28.75rem;
  margin-inline: auto;
}

.qr-foot {
  display: grid;
  justify-items: center;
  gap: var(--s-1);
  text-align: center;
}

.qr-foot > * {
  margin: 0;
}

/* Neutral, not the link's red: the one thing on this screen to do is show the code. */
.qr-foot button.link {
  color: var(--body);
}

.qr-foot__email {
  max-width: 100%;
  color: var(--ink);
  font-weight: 500;
  overflow-wrap: anywhere;
}

/* An address on a button can be longer than a phone is wide. */
.entry-address {
  height: auto;
  overflow-wrap: anywhere;
  white-space: normal;
}

/* ==========================================================================
   4. COUNTER (ETE-184)
   The scanner, the scan result, the regular's tier and the undo, on design
   v2 (gallery blocks b-scan, b-alerts and b-buttons; the v2 report §2.2,
   §4 items 1-5, §8). Read from arm's length by someone serving a queue, so:
   every line of text is ink (7:1 or more on every panel, in both schemes),
   the actions are 56px, and a result is said three ways at once -- a tint,
   an icon and a word -- never by colour alone and never in the brand red.
   Motion is 150ms or nothing, with no bounce and no shake.
   ========================================================================== */

/* -- Page ------------------------------------------------------------------ */

.counter-links {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0 var(--s-5);
  margin: var(--s-5) 0 0;
}

.counter-links a {
  gap: 6px;
}

/* -- The way to the till, on a helper's account page (ETE-206, D-04) ---------- */

/* Above the code, because during a shift the counter is what the app is
   opened for. A plain card with a small ink label and the button; nothing
   tinted, the red is the button. Scoped to the account page's content, never
   the header (which is sticky and shared). */
.counter-shortcut {
  display: grid;
  gap: var(--s-3);
  margin-bottom: var(--s-5);
}

.counter-shortcut__eyebrow {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin: 0;
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 600;
}

.counter-shortcut__list {
  display: grid;
  gap: var(--s-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.counter-shortcut__list .btn {
  justify-content: flex-start;
}

/* -- The viewfinder (b-scan) ------------------------------------------------ */

.scanner {
  text-align: center;
}

/* A square that is there before the camera is: the stream, when it
   arrives, fills a box that already had its size, so the result panel and
   the typed-code form below never jump. */
.viewfinder {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  max-width: 24rem;
  aspect-ratio: 1;
  margin: 0 auto var(--s-3);
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--sunk);
}

#scanner-video,
#scanner-fallback {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* html5-qrcode (the iPhone path) sizes its own <video> and draws a thick
   white "qr box" with inline styles, set through the CSSOM. The frame here
   is the guide instead, so its shading goes and its video fills the square
   like the native one. Inline styles only yield to !important. */
#scanner-fallback {
  display: grid;
  place-items: center;
  overflow: hidden;
}

#scanner-fallback video {
  min-width: 100%;
  min-height: 100%;
  object-fit: cover;
}

#scanner-fallback #qr-shaded-region {
  display: none !important;
}

/* The approved corners: rounded, 1.6px, nothing else -- no red scanner
   line, no glow. Quiet on the empty frame; white over the live picture,
   with a breath of 1.5% that says the camera is reading. */
.viewfinder__corners {
  position: absolute;
  top: 12%;
  left: 12%;
  width: 76%;
  height: 76%;
  overflow: visible;
  color: var(--control-line);
  pointer-events: none;
}

.viewfinder__corners path {
  fill: none;
  stroke: currentcolor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

#scanner-video:not([hidden]) ~ .viewfinder__corners,
#scanner-fallback:not([hidden]) ~ .viewfinder__corners {
  color: var(--on-brand);
  opacity: 0.85;
  animation: counter-breathe 2.4s var(--ease-standard) infinite alternate;
}

@keyframes counter-breathe {
  to {
    transform: scale(1.015);
  }
}

.viewfinder__start {
  position: relative;
  z-index: 1;
}

.scanner__status {
  min-height: 1.5em;
  margin: 0 0 var(--s-4);
  color: var(--ink);
}

/* -- The status panel ---------------------------------------------------------- */

/* #scan-result. Plain while it asks ("Add this visit?"), tinted once it has
   an answer. It enters in 150ms, 4px up and fading in, decelerating; it
   never bounces, and a refusal never shakes. */
.status-panel {
  margin: 0 0 var(--s-4);
  padding: 20px;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  box-shadow: var(--sh-1);
  color: var(--ink);
  text-align: left;
  animation: counter-enter var(--dur-2) var(--ease-enter);
}

@media (min-width: 30rem) {
  .status-panel {
    padding: var(--s-5);
  }
}

.status-panel--success {
  border-color: var(--success-line);
  background: var(--success-bg);
  box-shadow: none;
}

.status-panel--warning {
  border-color: var(--warning-line);
  background: var(--warning-bg);
  box-shadow: none;
}

.status-panel--danger {
  border-color: var(--danger-line);
  background: var(--danger-bg);
  box-shadow: none;
}

/* A visit taken back (ETE-206, audit D-13): neither a success nor an alarm,
   so the quiet ground and ink, with the undo icon carrying it. */
.status-panel--neutral {
  border-color: var(--line-strong);
  background: var(--sunk);
  box-shadow: none;
}

@keyframes counter-enter {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
}

@keyframes counter-fade {
  from {
    opacity: 0;
  }
}

.status-panel > :last-child {
  margin-bottom: 0;
}

/* The icon sits on the first line of the word, not on the middle of the
   block (ETE-206, audit D-31): a title that wraps at 360px kept its icon
   floating between its two lines. The margin centres 32px on one 31px line
   at --lh-tight. */
.status-panel__head {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  margin: 0 0 var(--s-2);
}

.status-panel__icon {
  flex: none;
  width: 32px;
  height: 32px;
  margin-top: calc((var(--fs-xl) * var(--lh-tight) - 32px) / 2);
}

.status-panel--success .status-panel__icon {
  color: var(--success);
}

.status-panel--warning .status-panel__icon {
  color: var(--warning);
}

.status-panel--danger .status-panel__icon {
  color: var(--danger);
}

/* The word, at 31px: the one thing read from across the counter. */
.status-panel__title,
.status-panel__who {
  margin: 0;
  color: var(--ink);
  font-size: var(--fs-xl);
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: -0.01em;
  overflow-wrap: anywhere;
}

.status-panel__who {
  margin-bottom: var(--s-2);
}

.status-panel__line {
  margin: 0 0 var(--s-3);
  color: var(--ink);
}

.status-panel__note,
.status-panel__meta,
.status-panel .countdown {
  margin: 0 0 var(--s-3);
  color: var(--ink);
  font-size: var(--fs-sm);
}

.status-panel__shop {
  display: flex;
  align-items: center;
  gap: var(--s-1);
  margin: 0 0 var(--s-3);
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 500;
}

.status-panel__reward {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin: 0 0 var(--s-3);
  color: var(--ink);
  font-size: var(--fs-md);
  font-weight: 500;
}

.status-panel__reward .icon {
  color: var(--brand);
}

/* The tick, drawn in one 200ms stroke once the panel has arrived. */
.status-panel__check path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  animation: counter-draw var(--dur-3) var(--ease-enter) var(--dur-1) forwards;
}

@keyframes counter-draw {
  to {
    stroke-dashoffset: 0;
  }
}

/* A refusal inside a panel that is otherwise asking: the code ran out while
   it was on screen. Amber, with its clock, like the panel it stands for. */
.status-panel .failure {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin: 0 0 var(--s-3);
  padding: 10px var(--s-3);
  border: 1px solid var(--warning-line);
  border-radius: var(--r-sm);
  background: var(--warning-bg);
  color: var(--ink);
}

.status-panel .failure .icon {
  color: var(--warning);
}

/* On a tinted panel the double-point line keeps its own white ground, so it
   reads as a line rather than vanishing into the panel. */
.status-panel .surprise {
  background: var(--surface);
  color: var(--ink);
}

.status-panel .points {
  margin: 0 0 var(--s-3);
}

/* What the customer is close to, and what is waiting for them (ETE-206,
   audit D-10): the customer's own goal bar, on its own white ground on a
   tinted panel like the tier line, and the waiting reward as a badge. */
.status-panel__goal {
  margin: 0 0 var(--s-3);
  padding: 12px var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
}

.status-panel__goal .goal__line {
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 500;
}

.status-panel__waiting {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  margin: 0 0 var(--s-3);
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 500;
}

/* The two clocks a panel can carry -- the code's and the undo's -- as one
   shape: a pill with a clock, centred under the button it can disable,
   digits that do not jitter (audit D-09, D-10). */
.status-panel__expiry,
.undo__window {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: fit-content;
  min-height: 32px;
  margin: 0 auto var(--s-3);
  padding: 0 var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--surface);
  font-variant-numeric: tabular-nums;
}

.status-panel__expiry [data-countdown],
.undo__window [data-countdown] {
  font-weight: 600;
}

/* -- Actions -------------------------------------------------------------------- */

.status-panel__action {
  margin: var(--s-4) 0 var(--s-3);
}

/* While the request is out the button is disabled (hx-disabled-elt) and
   carries a spinner. It keeps its colours: grey would read "not allowed",
   and this is "on its way". */
.status-panel__action.htmx-request > .btn--primary {
  border-color: transparent;
  background: var(--brand);
  color: var(--on-brand);
  cursor: progress;
}

.code-entry.htmx-request > .btn--secondary {
  border-color: var(--control-line);
  background: var(--surface);
  color: var(--ink);
  cursor: progress;
}

/* Destructive and outlined, on white even on a green panel, so the rust
   word keeps 7:1. */
.status-panel .btn--danger,
.undo.htmx-request > .btn--danger {
  border-color: var(--danger);
  background: var(--surface);
  color: var(--danger);
}

.status-panel .btn--secondary {
  background: var(--surface);
}

@media (hover: hover) {
  .status-panel .btn--danger:hover:not(:disabled) {
    background: var(--danger-bg);
  }
}

/* -- Undo (b-alerts) ------------------------------------------------------------ */

.undo {
  margin: var(--s-4) 0 var(--s-2);
}

/* "Scan another" is in every result that needs a way on (ETE-206, audit
   D-11), the same button in both places: on the scanner it clears the
   panel, on the full page it goes to the scanner. Under "Visit added" only
   the full page needs it -- on the scanner the camera simply reads the next
   customer. */
.scan-again {
  margin-top: var(--s-3);
}

main:has(#scanner) .scan-again--page {
  display: none;
}

/* -- The regular's tier (ETE-25) --------------------------------------------- */

/* A line someone serving at speed reads in half a second: on its own white
   ground on every panel, in ink, with a dot of the brand red as the only
   colour -- it is the shop's own data, the thing worth marking. */
.status-panel .tier {
  margin: 0 0 var(--s-3);
  padding: 10px var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  text-align: left;
}

.tier-name {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.tier-name::before {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--brand);
  content: "";
}

.tier-count {
  display: block;
  color: var(--ink);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
}

.tier-perk {
  display: block;
  margin-top: var(--s-1);
  color: var(--ink);
  font-weight: 600;
}

/* -- Which shop? ---------------------------------------------------------------- */

.choose-counter__list {
  display: grid;
  gap: var(--s-2);
  margin: var(--s-4) 0 0;
  padding: 0;
  list-style: none;
}

.choose-counter__list .btn {
  justify-content: flex-start;
  text-align: left;
}

.choose-counter__list .btn span {
  flex: 1;
}

/* -- Typed code (ETE-40) ---------------------------------------------------------- */

/* Under the camera and quieter than it: the fallback for when the camera
   will not do, not a second front door. */
.code-entry {
  display: grid;
  justify-items: center;
  gap: var(--s-2);
  margin-top: var(--s-6);
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
  text-align: center;
}

.code-entry label {
  margin: 0;
  color: var(--ink);
}

/* Typed by someone reading it out character by character, so it is shown
   the way it is spoken. */
.code-entry input[type="text"] {
  max-width: 16rem;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-md);
  letter-spacing: 0.08em;
  text-align: center;
  text-transform: uppercase;
}

/* A placeholder that does not look like a code already typed (audit D-32):
   the shape of one, in the lighter grey. */
.code-entry input[type="text"]::placeholder {
  color: var(--muted);
  letter-spacing: 0.12em;
}

/* A typed code the server refused stays, selected, with a rust edge, until
   it is retyped (scan-result.js sets and clears `aria-invalid`). */
.code-entry input[aria-invalid="true"] {
  border-color: var(--danger);
  box-shadow: 0 0 0 1px var(--danger);
} ------------------------------------------------------------- */

/* Reduce, not remove: the panel fades in over 100ms, the tick is simply
   there and the frame stops breathing. Colour, icon, word and vibration
   all stay. */
@media (prefers-reduced-motion: reduce) {
  .status-panel {
    animation: counter-fade var(--dur-1) linear;
  }

  .status-panel__check path {
    stroke-dashoffset: 0;
    animation: none;
  }

  #scanner-video:not([hidden]) ~ .viewfinder__corners,
  #scanner-fallback:not([hidden]) ~ .viewfinder__corners {
    animation: none;
  }
}

/* ==========================================================================
   5. OWNER (ETE-185)
   The shop's page and settings, its reward menu, the customers directory,
   insights and the activity log.

   Every owner screen sets `layout-wide` (60rem), so the header does not jump
   between them, and goes to two columns from 48rem with .grid-2 plus
   .owner-grid. One primary button per view: the scanner on the shop page,
   Save on the settings form, the poster or the scanner in an empty state.
   Destructive actions (Remove, Withdraw, Undo this visit) are .btn--danger:
   rust, outlined, with an icon and a verb.
   ========================================================================== */

/* Public page too (ETE-183 shares these two). */
.logo {
  display: block;
  width: 96px;
  height: 96px;
  /* Shop logos arrive at whatever aspect ratio the owner had to hand. */
  border-radius: var(--r-md);
  background: var(--surface);
  object-fit: contain;
}

/* -- Shared on the owner screens ------------------------------------------ */

/* Two cards side by side from 48rem (.grid-2); the grid owns the spacing. */
.owner-grid {
  align-items: start;
  margin: 0 0 var(--s-4);
}

.owner-grid > .card {
  margin: 0;
}

/* A grid item's minimum is its content's by default, and one long reward
   name would otherwise push the single column past a 360px screen. */
.owner-grid > * {
  min-width: 0;
}

/* The first letter of a name, to run an eye down a list. A tint, never a
   fill: it is a marker, not an action. Scoped to the owner's screens: the
   customer's shop mark (section 3) is the gallery's white-on-red initial, and
   an unscoped rule here was turning every shop card into a tint (ETE-188). */
.layout-wide .avatar {
  display: grid;
  flex: none;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--r-sm);
  background: var(--brand-tint);
  color: var(--brand-text);
  font-size: var(--fs-base);
  font-weight: 600;
  line-height: 1;
}

.layout-wide .avatar--lg {
  width: 56px;
  height: 56px;
  border-radius: var(--r-md);
  font-size: var(--fs-md);
}

/* The text beside an avatar takes the rest of the row and may shrink. */
.row-body {
  flex: 1 1 auto;
  min-width: 0;
}

/* An empty state inside a card that already has its heading: the same ring
   and words, less air. Cards that are only an empty state get more. */
.card.empty {
  padding-block: var(--s-6);
}

.empty h2 {
  font-size: var(--fs-md);
}

.empty .button-row {
  justify-content: center;
}

.empty--inline {
  padding: var(--s-2) 0;
}

.empty--inline .empty__icon {
  width: 56px;
  height: 56px;
  margin-bottom: 0;
}

/* -- The shop page ---------------------------------------------------------- */

/* Today (ETE-206, audit D-06): three numbers in the serif, under small Inter
   labels, the whole card one link to the figures. Ink, not red -- these are
   a glance, not the page's action, which is the Scan button above. */
.today {
  display: grid;
  gap: var(--s-3);
  margin: 0 0 var(--s-4);
  color: var(--ink);
  text-decoration: none;
}

.today__title {
  margin: 0;
  color: var(--muted-text);
  font-family: var(--font-ui);
  font-size: var(--fs-sm);
  font-weight: 600;
}

.today__figures {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-3);
  margin: 0;
}

/* The numbers on one baseline even when a label wraps to two lines at
   360px ("Premios / entregados"). */
.today__figures > div {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.today__figures dd {
  margin-top: auto;
  padding-top: 2px;
}

.today__figures dt {
  color: var(--body);
  font-size: var(--fs-sm);
  line-height: 1.3;
}

.today__figures dd {
  margin-inline: 0;
  margin-bottom: 0;
}

.today__value {
  color: var(--ink);
  font-family: var(--font-display);
  font-size: var(--fs-figure);
  line-height: 1.1;
}

.today__more {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  justify-self: start;
  /* Ink (ETE-208, QA N-04): the figures above it are the key data; the way
     to more of them is a secondary. */
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 600;
}

@media (hover: hover) {
  .today:hover {
    border-color: var(--line-strong);
  }

  .today:hover .today__more {
    text-decoration: underline;
    text-underline-offset: 3px;
  }
}

/* The first steps, for a shop that has not counted a visit yet (D-23):
   a numbered list that ticks itself off. A done step keeps its words, struck
   through nothing -- a tick in the success green says it. */
.first-steps {
  margin: 0 0 var(--s-4);
}

.first-steps > p {
  margin: var(--s-1) 0 var(--s-4);
}

.first-steps__list {
  display: grid;
  gap: var(--s-3);
  margin: 0;
  padding: 0;
  list-style: none;
}

.first-steps__step {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
}

.first-steps__mark {
  display: grid;
  flex: none;
  place-items: center;
  width: 32px;
  height: 32px;
  border: 1px solid var(--line-strong);
  border-radius: 50%;
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 600;
}

.first-steps__step.is-done .first-steps__mark {
  border-color: var(--success-line);
  background: var(--success-bg);
  color: var(--success);
}

.first-steps__text {
  display: grid;
  gap: 2px;
  min-width: 0;
}

/* Ink, not the brand red: three red links under a red Scan button would be
   four "main actions". The underline says they go somewhere. */
.first-steps__text a {
  display: inline-flex;
  align-items: center;
  min-height: var(--target-sm);
  color: var(--ink);
  font-weight: 600;
}

.first-steps__step.is-done .first-steps__text a {
  color: var(--body);
  font-weight: 500;
}

/* The three pages an owner reads, as one row of doors (D-06): a whole-row
   link each, icon, name, one line of what is there, a chevron. One column on
   a phone, three across from 48rem. */
.owner-nav {
  margin: 0 0 var(--s-5);
}

.owner-nav ul {
  display: grid;
  gap: var(--s-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

@media (min-width: 48rem) {
  .owner-nav ul {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

.owner-nav a {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  height: 100%;
  min-height: var(--target);
  padding: 14px var(--s-4);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  color: var(--ink);
  text-decoration: none;
}

@media (hover: hover) {
  .owner-nav a:hover {
    border-color: var(--line-strong);
  }
}

.owner-nav__icon {
  display: grid;
  flex: none;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--r-sm);
  background: var(--sunk);
  color: var(--ink);
}

.owner-nav__text {
  display: grid;
  flex: 1;
  gap: 2px;
  min-width: 0;
}

.owner-nav__name {
  font-weight: 600;
}

.owner-nav__go {
  flex: none;
  color: var(--muted-text);
}

/* The shop as its customers see it: the logo, or a store mark in its place,
   beside the address and the description. Scoped to the owner's screens:
   unscoped, it turned the customer's own cards (section 3) into one long row
   that ran 260px off a 360px phone (ETE-188). */
.layout-wide .shop-card {
  display: flex;
  align-items: flex-start;
  gap: var(--s-4);
}

.shop-mark {
  display: grid;
  flex: none;
  place-items: center;
  width: 64px;
  height: 64px;
  border-radius: var(--r-md);
  background: var(--brand-tint);
  color: var(--brand-text);
}

.shop-card__text {
  min-width: 0;
  overflow-wrap: anywhere;
}

.shop-card__text > :last-child {
  margin-bottom: 0;
}

.layout-wide .shop-card .card__title {
  margin: 0 0 var(--s-2);
}

/* The ghost's own padding would indent its icon from the text above it. */
.layout-wide .shop-card .button-row {
  margin: var(--s-2) 0 0 calc(-1 * var(--s-4));
}

/* What a visit earns (ETE-120): the number and its Save on one line. */
.rate-form {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--s-3);
  margin: 0 0 var(--s-5);
}

.rate-form .field {
  flex: 1 1 8rem;
  max-width: 14rem;
  margin: 0;
}

/* Level with the box rather than with its help text: down by the label's
   line and its gap. */
.rate-form > button {
  margin-top: calc(var(--fs-sm) * 1.5 + 6px);
}

/* The reward menu: each reward a white card (b-cards), the cheapest in the
   red gradient, and its Remove beside it on the neutral ground. */
.owner-menu {
  display: grid;
  gap: var(--s-2);
  margin: 0 0 var(--s-5);
  padding: 0;
  list-style: none;
}

.owner-menu li {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
}

.owner-menu form {
  margin: 0 0 0 auto;
}

.owner-menu .reward-card {
  display: flex;
  flex: 1 1 10rem;
  align-items: center;
  gap: var(--s-3);
  min-width: 0;
  padding: 14px var(--s-4) 14px 14px;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
}

.owner-menu .r-ico {
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--r-sm);
  background: var(--brand-tint);
  color: var(--brand-text);
}

.owner-menu .r-text {
  display: grid;
  min-width: 0;
}

.owner-menu .r-name {
  color: var(--ink);
  font-weight: 500;
  line-height: 1.3;
  overflow-wrap: break-word;
}

.owner-menu .r-pts {
  color: var(--muted-text);
  font-size: var(--fs-sm);
}

/* Adding a reward: its name and its price side by side, Add under them. */
.add-reward {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 9rem);
  gap: 0 var(--s-3);
  align-items: start;
}

.add-reward .field {
  margin-bottom: var(--s-4);
}

.add-reward > button {
  grid-column: 1 / -1;
  justify-self: start;
}

/* The repricing step: each row carries a price box and two sentences, so it
   stacks. */
.reward-menu.reprice li {
  flex-direction: column;
  align-items: stretch;
  gap: var(--s-1);
}

.reward-menu.reprice .field {
  margin: 0;
}

.reward-menu.reprice .field input {
  max-width: 7rem;
}

.reward-menu.reprice .muted {
  font-size: var(--fs-sm);
}

/* The people who help (ETE-206, audit D-33): an initial, the name over the
   email, the badge at the right and the quiet icon action at the end.

   A grid, not a flex row (ETE-208, QA regression D-33). With `nowrap` the
   badge "invited, not answered yet" kept its whole width (~180px) and the
   name column shrank to a few pixels, so "ana@qa.test" broke letter by letter
   on every phone. The grid gives the name the only flexible track; when the
   list itself is narrow (a container query, so it also holds inside a
   two-column dashboard) the badge moves to a second line under the name and
   the bin spans both lines at the end. */
.owner-team {
  container: owner-team / inline-size;
}

.owner-team li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center;
  column-gap: var(--s-3);
  row-gap: var(--s-1);
}

.owner-team__who {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  justify-items: start;
  gap: 2px;
  min-width: 0;
}

.owner-team__who > * {
  max-width: 100%;
}

.owner-team .name {
  font-weight: 600;
  overflow-wrap: anywhere;
}

.owner-team .meta {
  overflow-wrap: anywhere;
}

.owner-team .badge {
  max-width: 13rem;
  white-space: normal;
  text-align: center;
}

@container owner-team (max-width: 30rem) {
  .owner-team li {
    grid-template-columns: auto minmax(0, 1fr) auto;
  }

  .owner-team .avatar {
    grid-row: 1 / span 2;
    align-self: start;
  }

  .owner-team .badge {
    grid-column: 2;
    grid-row: 2;
    justify-self: start;
    text-align: start;
  }

  .owner-team form {
    grid-column: 3;
    grid-row: 1 / span 2;
  }
}

.invite-form {
  margin: var(--s-4) 0 0;
}

.invite-form .field {
  margin-bottom: var(--s-3);
}

/* -- The settings form ------------------------------------------------------ */

/* The basics in one card; editing, the three optional sections stand beside
   it from 48rem. Setting up, there are only the basics, at reading width. */
.shop-form__grid {
  display: grid;
  gap: var(--s-4);
  align-items: start;
}

.shop-form:not(.shop-form--edit) {
  max-width: var(--measure);
}

@media (min-width: 48rem) {
  .shop-form--edit .shop-form__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

.shop-form__grid > .card,
.shop-form__extras > .card {
  margin: 0;
}

.shop-form__extras {
  display: grid;
  gap: var(--s-4);
}

/* A fieldset drawn as a card. The floated legend is the usual way to keep it
   off the border, where browsers otherwise draw it. */
.shop-form fieldset.card {
  min-inline-size: 0;
}

.shop-form fieldset.card > legend {
  float: left;
  width: 100%;
  margin: 0 0 var(--s-2);
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: var(--lh-tight);
}

.shop-form fieldset.card > legend + *,
.shop-form fieldset.card > .switch-field {
  clear: both;
}

/* Save stays in reach on a phone (ETE-206, audit D-22): the edit page is
   four cards long at 360px, and Save was under the last of them. A bar at
   the foot of the screen, on the page's own ground with a hairline over it,
   only while the form is taller than the screen (sticky does nothing
   otherwise). The site header is sticky at the top (ETE-210); this is the
   bottom edge, so the two never meet. */
.shop-form__actions {
  margin-top: var(--s-5);
}

@media (max-width: 47.99rem) {
  .shop-form--edit .shop-form__actions {
    position: sticky;
    bottom: 0;
    z-index: 2;
    margin-inline: calc(-1 * var(--s-4));
    padding: var(--s-3) var(--s-4);
    border-top: 1px solid var(--line);
    background: var(--bg);
  }
}

/* The fields a switch governs appear only while it is on (audit D-22). A
   field with an error is always shown, so nothing refused is hidden. */
.shop-form fieldset:has(.switch-field input:not(:checked)) .switch-depends:not(.has-error) {
  display: none;
}

/* The logo (audit D-21): what is there now, the system's own button for
   choosing another, the file's name once picked, and the limits. The real
   input is hidden but focusable through its <label>. */
.file-field__label {
  display: block;
  margin-bottom: 6px;
  color: var(--ink);
  font-size: var(--fs-sm);
  font-weight: 500;
}

.file-field__box {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-md);
}

.file-field__thumb {
  flex: none;
  width: 56px;
  height: 56px;
  border-radius: var(--r-sm);
  background: var(--surface);
  object-fit: contain;
}

.file-field__thumb--empty {
  display: grid;
  place-items: center;
  border: 1px dashed var(--line-strong);
  background: var(--sunk);
  color: var(--muted-text);
}

.file-field__body {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-1) var(--s-3);
  min-width: 0;
}

.file-field__input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
}

.file-field__input:focus-visible + label {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.file-field__name {
  overflow-wrap: anywhere;
}

.file-field__clear {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--target-sm);
  color: var(--body);
  font-size: var(--fs-sm);
}

.file-field .helptext {
  display: block;
  margin-top: 6px;
}

/* -- The customers page ------------------------------------------------------ */

/* Seven things about a person on a 360px screen: not a <table>, which would
   scroll sideways, but a row that reflows -- who, then the numbers. */

/* Search and the download: stacked on a phone, one line on a laptop. */
.directory-tools {
  margin: 0 0 var(--s-3);
}

@media (min-width: 48rem) {
  .directory-tools {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--s-5);
  }

  .directory-tools .customer-search {
    flex: 0 1 32rem;
    margin: 0;
  }

  .directory-tools .download {
    margin: 0;
  }
}

.customer-search {
  margin-bottom: var(--s-3);
}

/* One line at 360px (ETE-206, audit D-15): the magnifier inside the field,
   the submit an icon button at its end. The input's own padding makes room
   for the icon; min-width:0 lets it shrink rather than push the button off
   the row. */
.search-field {
  position: relative;
  display: flex;
  gap: var(--s-2);
}

.search-field__icon {
  position: absolute;
  top: 50%;
  left: 14px;
  color: var(--muted-text);
  transform: translateY(-50%);
  pointer-events: none;
}

.search-field input[type="search"] {
  flex: 1 1 auto;
  min-width: 0;
  min-height: var(--target);
  margin: 0;
  padding-left: 44px;
}

.search-field .btn {
  flex: none;
  width: var(--target);
  min-height: var(--target);
}

.download {
  margin-bottom: var(--s-3);
  font-size: var(--fs-sm);
}

/* The sort row and the activity log's person filter, as quiet tabs (b-tabs):
   plain words, and a 2px red line under the one you are on. Each item
   carries its own line, so a row that wraps on a phone still reads. */
.sorts {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 var(--s-1);
  margin-bottom: var(--s-3);
  font-size: var(--fs-sm);
}

.sorts > .muted {
  margin-right: var(--s-2);
}

/* The customers' sort row on one line that scrolls sideways when it must,
   rather than breaking into two rows of tabs at 360px (audit D-15). */
.sorts--scroll {
  flex-wrap: nowrap;
  overflow-x: auto;
  scrollbar-width: none;
  white-space: nowrap;
}

.sorts--scroll::-webkit-scrollbar {
  display: none;
}

.sorts--scroll > * {
  flex: none;
}

.sorts a,
.sorts .current {
  position: relative;
  padding: 0 var(--s-3);
  color: var(--body);
  font-weight: 500;
  text-decoration: none;
}

.sorts .current {
  color: var(--ink);
  font-weight: 600;
}

.sorts .current::after {
  content: "";
  position: absolute;
  right: var(--s-3);
  bottom: 6px;
  left: var(--s-3);
  height: 2px;
  border-radius: 2px;
  background: var(--brand);
}

@media (hover: hover) {
  .sorts a:hover {
    color: var(--ink);
  }
}

.count {
  margin-bottom: var(--s-2);
  font-size: var(--fs-sm);
}

/* One definition of a reflowing row, shared by the customers list, the two
   insights lists (.people) and the activity log (.activity). */
.customers,
.people,
.activity,
.customer-visits {
  margin: 0 0 var(--s-4);
  padding: 0;
  list-style: none;
}

/* The directory and the log are a card of rows: the rows bring their own
   padding, the card only its sides. */
.customers.card,
.activity-log.card {
  padding: var(--s-1) 20px;
}

@media (min-width: 30rem) {
  .customers.card,
  .activity-log.card {
    padding-inline: var(--s-5);
  }
}

.customers li,
.people li,
.activity li,
.customer-visits li {
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--line);
}

.customers li:last-child,
.activity li:last-child,
.customer-visits li:last-child {
  border-bottom: 0;
}

/* An initial, then the name and the numbers. */
.customers li,
.people li {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
}

/* The whole row is the customer's link (R2-MOB-2): the link's ::after is
   stretched over the row. */
.customers li {
  position: relative;
}

.customers .name::after {
  content: "";
  position: absolute;
  inset: 0;
  /* Above the numbers line, whose clip-path makes it a stacking context
     painted (and hit-tested) after this. */
  z-index: 1;
}

/* The row's link has no underline of its own -- the whole row is the
   target -- and takes one on hover, like any link. */
.customers .name {
  color: var(--ink);
  text-decoration: none;
}

@media (hover: hover) {
  .customers li:hover .name {
    text-decoration: underline;
    text-decoration-thickness: 1px;
  }
}

.customers .who,
.people .person,
.activity .person {
  margin: 0;
}

.customers .name,
.people .name,
.activity .name {
  display: block;
  font-weight: 600;
  /* People type their own names and an address can be long: both break
     rather than widening the page. */
  overflow-wrap: anywhere;
}

/* One line, cut with an ellipsis (ETE-206, audit D-16): a long address
   wrapped into three lines and doubled the row. The whole address is on
   the customer's own page, a tap away, and in the CSV. */
.customers .email {
  display: block;
  overflow: hidden;
  font-size: var(--fs-sm);
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Each part of the line stays whole, so a wrap falls between two parts --
   where the clip below expects it -- never inside one. */
.customers .numbers > span {
  white-space: nowrap;
}

.customers .numbers,
.people .numbers,
.activity .numbers,
.customer-summary {
  display: flex;
  flex-wrap: wrap;
  /* Row gap only: the space between two numbers is the separator's width. */
  gap: 2px 0;
  /* Pulled one separator's width to the left and clipped there, so a line
     never starts "· joined …" (R2-CUST-4), whatever the width or language. */
  margin: 2px 0 0 -1em;
  clip-path: inset(0 0 0 1em);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
}

/* The separator is drawn rather than written into the strings, so a wrapped
   line does not strand a middle dot and no translator carries punctuation. */
.customers .numbers > span::before,
.people .numbers > span::before,
.activity .numbers > span::before,
.customer-summary > span::before {
  content: "·";
  display: inline-block;
  width: 1em;
  color: var(--muted-text);
  font-weight: 400;
  text-align: center;
}

/* A state badge on the line (ETE-206, audit B-11: "slipping away" is the
   amber badge) carries no drawn separator; its margin is the separator's
   width, so it lines up with the clip when it wraps to a line of its own. */
.people .numbers > .badge {
  margin-left: 1em;
}

.people .numbers > .badge::before {
  content: none;
}

/* The balance inside the line is one number among the others (QA-CUST-1). */
.customers .numbers .points,
.customer-summary .points {
  margin: 0;
  color: inherit;
  font-size: inherit;
  font-weight: inherit;
}

/* The balance at the end of a customer's row, in the serif (ETE-206, audit
   D-16): the one number worth reading from a step back. */
.row-figure {
  display: grid;
  flex: none;
  justify-items: end;
  margin: 0;
  text-align: right;
}

.row-figure__value {
  color: var(--ink);
  font-family: var(--font-display);
  font-size: var(--fs-lg);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.row-figure__unit {
  color: var(--muted-text);
  font-size: var(--fs-xs);
}

.row-badge {
  margin: 4px 0 0;
}

/* The part of the line the page was opened for, in full-strength ink. */
.customers .numbers .visits,
.people .numbers .visits,
.activity .numbers .when,
.customer-summary .visits {
  color: var(--ink);
  font-weight: 600;
}

.pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  /* The empty spans that stand in for a missing Previous or Next keep "Page
     2 of 7" in the middle; the height keeps both links a thumb apart. */
  min-height: var(--target);
  font-size: var(--fs-sm);
}

.pager__group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

/* -- One customer (ETE-122) --------------------------------------------------- */

/* Only the address may break anywhere, on its own line (audit D-17): an
   email has no spaces to break at, and the name above it should not be cut
   mid-word to make room. */
.customer-email {
  margin: 0 0 var(--s-3);
  font-size: var(--fs-sm);
  overflow-wrap: anywhere;
}

.customer-visits__rule {
  margin: 0 0 var(--s-2);
  font-size: var(--fs-sm);
}

.customer-card .customer-summary {
  margin-bottom: 0;
}

.customer-visits .when {
  margin: 0;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.customer-visits .who-served,
.customer-visits .undone,
.customer-visits .too-late {
  margin: 2px 0 0;
  font-size: var(--fs-sm);
}

/* A taken-back visit keeps its row, struck through rather than hidden. */
.customer-visits .voided .when,
.customer-visits .voided .who-served {
  color: var(--muted-text);
}

.customer-visits form {
  margin: var(--s-2) 0 0;
}

/* The last row of a list inside a card: the card's edge is the line under it. */
.people li:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.people .avatar {
  width: 36px;
  height: 36px;
  font-size: var(--fs-sm);
}

/* A regular's tier beside their name (ETE-50): an adjective on the name,
   so inline and quiet. Weight as well as colour. */
.people .tier-mark {
  color: var(--brand-text);
  font-size: var(--fs-sm);
  font-weight: 600;
}

/* The activity log, a day at a time (ETE-206, audit D-20): a small Inter
   heading per day, then its rows, each led by what happened as a badge and
   the customer's name, with the time and who did it underneath. */
.activity-day {
  margin: var(--s-4) 0 0;
  padding-bottom: var(--s-2);
  border-bottom: 1px solid var(--line-strong);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: var(--fs-sm);
  font-weight: 600;
  letter-spacing: 0;
}

.activity-log .activity-day:first-child {
  margin-top: var(--s-3);
}

.activity-log .activity {
  margin-bottom: var(--s-2);
}

.activity .person {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-1) var(--s-2);
}

.activity .person .name {
  display: inline;
}

.regulars-holding {
  margin-top: 2px;
  font-size: var(--fs-sm);
}

/* -- Insights (ETE-29, ETE-192) ----------------------------------------------- */

/* Two by two from the smallest phone, the four in a row on a laptop
   (b-dash). One column at 360px stacked the four into ~600px and pushed the
   charts to the second screen (ETE-206, audit D-24); two columns there take
   a smaller number (32px) and tighter padding to fit. */
.figures {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s-2);
  margin-bottom: var(--s-4);
}

@media (max-width: 29.99rem) {
  .figures .figure {
    padding: var(--s-3);
  }

  .figures .figure-value {
    font-size: var(--fs-xl);
  }
}

@media (min-width: 30rem) {
  .figures {
    gap: var(--s-3);
  }
}

/* Four across only from 64rem: at 768px four tiles are 150px each and every
   label, detail and delta broke over two or three lines (audit B-10). */
@media (min-width: 64rem) {
  .figures {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

/* The grid owns the spacing between tiles. */
.figure {
  display: grid;
  align-content: start;
  justify-items: start;
  gap: 2px;
  margin: 0;
  padding: var(--s-4-5);
}

.figure > * {
  margin: 0;
}

/* A figure that heads a card rather than being one. */
.figure--inline {
  padding: 0;
}

.figure-label {
  margin: 0;
  color: var(--body);
  font-size: var(--fs-sm);
  font-weight: 500;
  letter-spacing: 0;
}

.figure-value {
  /* The one thing on the tile read from across a counter: Instrument Serif,
     as the approved stat tile has it (v2 gallery, block 21) -- the face is
     allowed from 24px and this is 40. Proportional figures, which is what a
     standalone number wants; tabular digits look loose at display size. */
  margin-top: var(--s-1);
  color: var(--ink);
  font-family: var(--font-display);
  font-size: var(--fs-figure);
  font-weight: 400;
  line-height: 1;
  letter-spacing: -0.01em;
}

/* Number, then the delta on its own line, then what it is compared with
   (ETE-202): the same order on every tile. */
.figure-delta {
  margin-top: var(--s-2);
}

.figure-previous {
  margin-top: var(--s-1);
}

.figure-detail,
.figure-previous {
  font-variant-numeric: tabular-nums;
}

/* -- The period selector: three links as the approved segmented pill. The
   current one is the raised segment; it is a link, not a radio, so it works
   with scripting off and every period has its own URL. */
.segmented {
  display: inline-flex;
  gap: 2px;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--sunk);
}

.segmented a {
  display: inline-grid;
  place-items: center;
  min-height: var(--target-sm);
  padding: 0 var(--s-4);
  border-radius: var(--r-pill);
  color: var(--muted-text);
  font-size: var(--fs-sm);
  font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
  transition:
    color var(--dur-2) var(--ease-standard),
    background-color var(--dur-2) var(--ease-standard),
    box-shadow var(--dur-2) var(--ease-standard);
}

.segmented a[aria-current="page"] {
  background: var(--surface);
  box-shadow:
    var(--sh-1),
    inset 0 0 0 1px var(--line-strong);
  color: var(--ink);
}

@media (hover: hover) {
  .segmented a:not([aria-current="page"]):hover {
    color: var(--ink);
  }
}

.segmented a:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: -2px;
}

/* While htmx fetches another period the section keeps its frame and fades a
   little, rather than flashing empty: no skeleton, no jump. */
.period {
  transition: opacity var(--dur-3) var(--ease-standard);
}

.period.htmx-request {
  opacity: 0.55;
}

.insights-trend {
  display: grid;
  gap: var(--s-4);
  margin: 0 0 var(--s-4);
  padding: var(--s-5);
}

/* -- Breakdowns: who, when, what. One column on a phone in that order; two
   on a laptop, with "when" (two charts, the tallest) on the right. */
.breakdowns {
  display: grid;
  gap: var(--s-4);
}

.breakdowns > .card {
  display: grid;
  align-content: start;
  gap: var(--s-3);
  min-width: 0;
  margin: 0;
  padding: var(--s-5);
}

.breakdown h3,
.breakdown h4 {
  margin: 0;
}

.breakdown h3 {
  color: var(--body);
  font-size: var(--fs-sm);
  font-weight: 500;
  letter-spacing: 0;
}

.breakdown h4 {
  margin-top: var(--s-2);
  color: var(--muted-text);
  font-size: var(--fs-xs);
  font-weight: 500;
}

.breakdown-lead {
  margin: 0;
  color: var(--ink);
  font-family: var(--font-display);
  font-size: var(--fs-section);
  line-height: 1.15;
}

@media (min-width: 48rem) {
  .breakdowns {
    grid-template-areas:
      "who when"
      "prizes when";
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-template-rows: auto 1fr;
    align-items: start;
  }

  .breakdown--who {
    grid-area: who;
  }

  .breakdown--when {
    grid-area: when;
  }

  .breakdown--prizes {
    grid-area: prizes;
  }
}

.empty--quiet {
  padding: var(--s-4) 0;
}

.empty--quiet p {
  max-width: 30ch;
  margin: 0;
}

/* -- Who to talk to: the two lists side by side from 48rem. */
.insights-people {
  display: grid;
  gap: var(--s-4);
  margin: 0 0 var(--s-4);
}

.insights-people > .card {
  min-width: 0;
  margin: 0;
}

.insights-people h3 {
  margin-top: 0;
  font-size: var(--fs-md);
}

@media (min-width: 48rem) {
  .insights-people {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    align-items: start;
  }
}

/* -- Charts (ETE-192) ------------------------------------------------------- */

/* Every per-element number on a chart -- a bar's height, a gridline's place,
   a split's width -- arrives as one of these classes, because the CSP drops
   `style=` and typed attr() is not in every browser the counter uses. One
   custom property each; the rules below decide what it is a percentage of. */
.p-0 { --p: 0%; }
.p-1 { --p: 1%; }
.p-2 { --p: 2%; }
.p-3 { --p: 3%; }
.p-4 { --p: 4%; }
.p-5 { --p: 5%; }
.p-6 { --p: 6%; }
.p-7 { --p: 7%; }
.p-8 { --p: 8%; }
.p-9 { --p: 9%; }
.p-10 { --p: 10%; }
.p-11 { --p: 11%; }
.p-12 { --p: 12%; }
.p-13 { --p: 13%; }
.p-14 { --p: 14%; }
.p-15 { --p: 15%; }
.p-16 { --p: 16%; }
.p-17 { --p: 17%; }
.p-18 { --p: 18%; }
.p-19 { --p: 19%; }
.p-20 { --p: 20%; }
.p-21 { --p: 21%; }
.p-22 { --p: 22%; }
.p-23 { --p: 23%; }
.p-24 { --p: 24%; }
.p-25 { --p: 25%; }
.p-26 { --p: 26%; }
.p-27 { --p: 27%; }
.p-28 { --p: 28%; }
.p-29 { --p: 29%; }
.p-30 { --p: 30%; }
.p-31 { --p: 31%; }
.p-32 { --p: 32%; }
.p-33 { --p: 33%; }
.p-34 { --p: 34%; }
.p-35 { --p: 35%; }
.p-36 { --p: 36%; }
.p-37 { --p: 37%; }
.p-38 { --p: 38%; }
.p-39 { --p: 39%; }
.p-40 { --p: 40%; }
.p-41 { --p: 41%; }
.p-42 { --p: 42%; }
.p-43 { --p: 43%; }
.p-44 { --p: 44%; }
.p-45 { --p: 45%; }
.p-46 { --p: 46%; }
.p-47 { --p: 47%; }
.p-48 { --p: 48%; }
.p-49 { --p: 49%; }
.p-50 { --p: 50%; }
.p-51 { --p: 51%; }
.p-52 { --p: 52%; }
.p-53 { --p: 53%; }
.p-54 { --p: 54%; }
.p-55 { --p: 55%; }
.p-56 { --p: 56%; }
.p-57 { --p: 57%; }
.p-58 { --p: 58%; }
.p-59 { --p: 59%; }
.p-60 { --p: 60%; }
.p-61 { --p: 61%; }
.p-62 { --p: 62%; }
.p-63 { --p: 63%; }
.p-64 { --p: 64%; }
.p-65 { --p: 65%; }
.p-66 { --p: 66%; }
.p-67 { --p: 67%; }
.p-68 { --p: 68%; }
.p-69 { --p: 69%; }
.p-70 { --p: 70%; }
.p-71 { --p: 71%; }
.p-72 { --p: 72%; }
.p-73 { --p: 73%; }
.p-74 { --p: 74%; }
.p-75 { --p: 75%; }
.p-76 { --p: 76%; }
.p-77 { --p: 77%; }
.p-78 { --p: 78%; }
.p-79 { --p: 79%; }
.p-80 { --p: 80%; }
.p-81 { --p: 81%; }
.p-82 { --p: 82%; }
.p-83 { --p: 83%; }
.p-84 { --p: 84%; }
.p-85 { --p: 85%; }
.p-86 { --p: 86%; }
.p-87 { --p: 87%; }
.p-88 { --p: 88%; }
.p-89 { --p: 89%; }
.p-90 { --p: 90%; }
.p-91 { --p: 91%; }
.p-92 { --p: 92%; }
.p-93 { --p: 93%; }
.p-94 { --p: 94%; }
.p-95 { --p: 95%; }
.p-96 { --p: 96%; }
.p-97 { --p: 97%; }
.p-98 { --p: 98%; }
.p-99 { --p: 99%; }
.p-100 { --p: 100%; }

.chart {
  position: relative;
  margin: 0;
}

/* The plot: a gutter on the left for the scale's numbers, room underneath
   for the dates, and the bars in between. Fixed height, fluid width; the
   text never scales with it. */
.chart-plot {
  position: relative;
  height: 200px;
  margin: var(--s-5) 0 0;
  padding: 0 12px 26px 34px;
  border-radius: var(--r-xs);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  -webkit-tap-highlight-color: transparent;
}

.chart--small .chart-plot {
  height: 116px;
  margin-top: var(--s-4);
}

@media (min-width: 48rem) {
  .chart-plot {
    height: 240px;
  }
}

.chart-plot:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 6px;
}

/* Gridlines: hairlines in the card's own divider colour, solid, and the
   floor a step stronger because the bars stand on it. */
.chart-ticks {
  position: absolute;
  inset: 0 12px 26px 34px;
  pointer-events: none;
}

.chart-tick {
  position: absolute;
  right: 0;
  bottom: var(--p);
  left: 0;
  border-top: 1px solid var(--line);
}

.chart-tick--floor {
  border-top-color: var(--line-strong);
}

.chart-tick > span {
  position: absolute;
  right: calc(100% + 8px);
  bottom: -0.6em;
  color: var(--muted-text);
  line-height: 1.2;
}

.chart-bars {
  position: absolute;
  inset: 0 12px 26px 34px;
  display: flex;
  gap: 2px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* One column per bar. The whole column is the hit target, not the painted
   bar: a reader aims at a day, not at six pixels of red. */
.chart-col {
  position: relative;
  display: flex;
  flex: 1 1 0;
  align-items: flex-end;
  justify-content: center;
  min-width: 0;
}

/* The column under the pointer or the keyboard gets a faint band, the
   bar-chart version of a crosshair. */
.chart-col::before {
  position: absolute;
  inset: 0 -1px;
  border-radius: 6px;
  background: var(--sunk);
  opacity: 0;
  content: "";
  transition: opacity var(--dur-2) var(--ease-standard);
}

.chart-col.is-active::before {
  opacity: 1;
}

.chart-bar {
  position: relative;
  width: min(24px, 68%);
  height: var(--p);
  min-height: 2px;
  border-radius: 4px 4px 0 0;
  background: var(--chart-soft);
  transform-origin: bottom;
  transition: background-color var(--dur-2) var(--ease-standard);
}

.chart-bar.p-0 {
  min-height: 0;
}

.is-peak > .chart-bar {
  background: var(--brand);
}

.chart-col.is-active > .chart-bar {
  background: var(--chart-soft-hover);
}

.chart-col.is-peak.is-active > .chart-bar {
  background: var(--brand-hover);
}

/* The day or week still running: an outline in the soft red, so a Monday's
   stub reads as "not over yet" rather than as a collapse. */
.is-partial > .chart-bar {
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--chart-soft-hover);
}

.is-peak.is-partial > .chart-bar {
  box-shadow: inset 0 0 0 1.5px var(--brand);
}

/* Still an outline when it is the one being read. */
.chart-col.is-partial.is-active > .chart-bar {
  background: transparent;
}

/* The busiest bar's number, over it. The only number on the plot, and it
   steps back while the tooltip is reading out another bar, so the two never
   sit on top of each other. */
.chart-value {
  position: absolute;
  bottom: calc(var(--p) + 4px);
  left: 50%;
  color: var(--ink);
  font-weight: 600;
  white-space: nowrap;
  transform: translateX(-50%);
}

.has-active .chart-col:not(.is-active) > .chart-value {
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease-standard);
}

.chart-axis {
  position: absolute;
  top: calc(100% + 8px);
  left: 50%;
  color: var(--muted-text);
  line-height: 1;
  white-space: nowrap;
  transform: translateX(-50%);
}

/* Entrance: each bar grows once from the floor, 400ms, decelerating. Pure
   CSS so it runs with scripting off; charts.js only holds a chart that
   starts below the fold until it scrolls into view. Nothing bounces
   («queremos cosas sutiles»). */
@keyframes bar-grow {
  from {
    transform: scaleY(0);
  }
}

@keyframes fade-in-soft {
  from {
    opacity: 0;
  }
}

.chart-bar,
.split-part,
.prize-bar {
  animation: bar-grow var(--dur-6) var(--ease-standard) both;
}

.split-part,
.prize-bar {
  transform-origin: left;
  animation-name: split-grow;
}

@keyframes split-grow {
  from {
    transform: scaleX(0);
  }
}

.chart-value {
  animation: fade-in-soft var(--dur-3) var(--ease-standard) var(--dur-4) both;
}

.chart.is-waiting .chart-bar,
.chart.is-waiting .split-part,
.chart.is-waiting .chart-value,
.prizes.is-waiting .prize-bar {
  animation-play-state: paused;
}

/* Printed (or captured whole), a chart that was still waiting to scroll into
   view would come out with no bars at all (audit B-28): on paper, draw them
   at their final size. */
@media print {
  .chart-bar,
  .split-part,
  .chart-value,
  .prize-bar {
    animation: none;
  }
}

/* The tooltip: value first and strong, the day after it, on the dark fill
   the approved tooltip uses. Placed by charts.js (CSSOM, which the CSP
   allows) over the active bar and kept inside the card. */
.chart-tip {
  /* Where the arrow points, along the tooltip; charts.js moves it onto the
     bar when the tooltip has been pushed sideways to stay in the card. */
  --arrow: 50%;

  position: absolute;
  top: 0;
  left: 0;
  z-index: 5;
  display: grid;
  gap: 1px;
  width: max-content;
  max-width: 220px;
  margin: 0;
  padding: 7px 11px;
  border-radius: 10px;
  background: var(--dark);
  box-shadow: var(--sh-2);
  color: var(--on-dark);
  font-size: var(--fs-xs);
  line-height: 1.35;
  pointer-events: none;
  animation: tip-in var(--dur-2) var(--ease-standard);
}

.chart-tip strong {
  font-size: var(--fs-sm);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.chart-tip::after {
  position: absolute;
  top: 100%;
  left: var(--arrow);
  margin-left: -5px;
  border: 5px solid transparent;
  border-top-color: var(--dark);
  content: "";
}

.chart-tip.is-below::after {
  top: auto;
  bottom: 100%;
  border-top-color: transparent;
  border-bottom-color: var(--dark);
}

@keyframes tip-in {
  from {
    opacity: 0;
    transform: translateY(3px);
  }
}

/* The key under a chart: a sample of each mark beside its word, so colour is
   never the only thing saying what a bar is. */
.chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-4);
  margin: 0;
  padding: 0;
  font-size: var(--fs-xs);
  list-style: none;
}

.chart-legend li {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.legend-key {
  display: inline-block;
  flex: none;
  width: 10px;
  height: 10px;
  border-radius: 3px;
  background: var(--chart-soft);
}

.legend-key--peak,
.legend-key--returning {
  background: var(--brand);
}

.legend-key--partial {
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--chart-soft-hover);
}

.legend-key--new {
  background: var(--chart-neutral);
}

.chart-legend--split {
  font-size: var(--fs-sm);
}

.chart-legend--split .legend-text {
  color: var(--ink);
  font-weight: 500;
}

/* -- The split: one whole in two blocks, returning in the red and new in a
   neutral, a 2px surface gap between them. */
.split {
  display: flex;
  gap: 2px;
  height: 12px;
  margin-top: var(--s-2);
  overflow: hidden;
  border-radius: var(--r-pill);
  -webkit-tap-highlight-color: transparent;
}

.split:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

.split-part {
  flex: 0 1 var(--p);
  min-width: 4px;
  background: var(--chart-neutral);
  transition: opacity var(--dur-2) var(--ease-standard);
}

.split-part--returning {
  background: var(--brand);
}

.split.has-active .split-part:not(.is-active) {
  opacity: 0.45;
}

/* -- What they took home: one row per reward, the number in words and a thin
   bar under it, the most collected in the red. */
.prizes {
  display: grid;
  gap: var(--s-3);
  margin: var(--s-2) 0 0;
  padding: 0;
  list-style: none;
}

.prizes li {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 6px var(--s-3);
  font-size: var(--fs-sm);
}

.prize-name {
  overflow: hidden;
  color: var(--ink);
  font-weight: 500;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.prize-count {
  color: var(--muted-text);
  font-variant-numeric: tabular-nums;
}

.prize-track {
  grid-column: 1 / -1;
  height: 6px;
  border-radius: var(--r-pill);
  background: var(--sunk);
}

.prize-bar {
  display: block;
  width: var(--p);
  min-width: 6px;
  height: 100%;
  border-radius: inherit;
  background: var(--chart-soft);
}

.prize-bar.is-peak {
  background: var(--brand);
}

@media (prefers-reduced-motion: reduce) {
  .chart-bar,
  .split-part,
  .prize-bar,
  .chart-value,
  .chart-tip {
    animation: none;
  }
}


/* ==========================================================================
   6. ACCOUNT AND SYSTEM (ETE-186)
   Sign-in and sign-up, password flows, the profile, notification settings,
   the install prompt, error pages and the offline page.
   ========================================================================== */

/* The birthday's two selects (ETE-28), side by side because they are one
   answer; allowed to wrap on a very narrow phone. */
.field-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
}

.field-row .field {
  flex: 1 1 8rem;
}

.field-row .field:first-child {
  flex: 0 1 6rem;
}

fieldset.birthday .helptext {
  margin-bottom: 10px;
}

/* -- Install prompt ----------------------------------------------------------- */

.install h2 {
  margin-top: 0;
}

.steps {
  margin: 0 0 var(--s-3);
  padding-left: 1.25rem;
}

.steps li {
  margin-bottom: 6px;
}

.glyph {
  /* Sized in em so it tracks the sentence it sits inside. */
  width: 1.2em;
  height: 1.2em;
  color: var(--brand-text);
  vertical-align: -0.25em;
}

/* -- Sign-in, sign-up and the password pages ------------------------------- */

/* The approved signup form (gallery b-signup): one narrow card, a serif title
   above it, labels on top, help text flush with the label, 48px fields with
   a >= 3:1 edge (the components above) and one full-width primary action.
   Every page of the account flow -- log in, sign up, the password pages, the
   "done" pages -- wears the same frame, so moving between them is not a
   change of scenery. */
.auth {
  width: 100%;
  max-width: 28.75rem;
  margin-inline: auto;
}

/* The title is the shared .page-head--center (ETE-204): the global serif
   h1, like every other page, centred over the narrow column. These pages
   used to be the only ones with a serif title, which is what made "Change
   your password" and "Your details" look like two different apps. */
.system h1 {
  text-align: center;
}

.auth .page-head {
  margin-top: var(--s-2);
}

@media (min-width: 30rem) {
  .auth__card {
    padding: var(--s-6);
  }
}

.auth__card > form {
  margin: 0;
}

.auth__card .field {
  margin-bottom: 20px;
}

/* "Forgot your password?" sits under the password it is about, at the end of
   the line, and is still a thumb's worth tall. */
.auth__aside {
  display: flex;
  justify-content: flex-end;
  margin: -12px 0 var(--s-3);
}

.auth__aside a {
  display: inline-flex;
  align-items: center;
  min-height: var(--target);
  font-size: var(--fs-sm);
}

.auth__card .btn--block {
  margin-top: var(--s-2);
}

/* The way to the other half of the flow ("No account yet? Sign up"), under
   the card rather than inside it: it is not part of the form. */
.auth__alt {
  margin: var(--s-5) 0 0;
  color: var(--body);
  font-size: var(--fs-sm);
  text-align: center;
}

/* -- Account links and the danger zone ------------------------------------ */

/* A row of links with the gap doing the separating. The hand-written "·"
   used to be left hanging at the end of a line when the row wrapped (audit,
   "other problems"); with no separator there is nothing to leave behind. */
.account-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0 var(--s-5);
  margin: var(--s-5) 0 0;
  padding: 0;
  list-style: none;
}

/* Closing the account stands apart from the links above it, behind a
   hairline, as the one rust outline on the page: destructive, so never next
   to the primary and never filled until the final confirmation. */
.danger-zone {
  margin: var(--s-6) 0 0;
  padding: var(--s-5) 0 0;
  border-top: 1px solid var(--line);
}

.danger-zone p {
  margin: 0 0 var(--s-3);
  color: var(--muted-text);
  font-size: var(--fs-sm);
}

/* The last step of closing an account: the filled rust button and the way
   back, ending the card without a row's worth of air under them. */
.danger-confirm .button-row {
  margin: 0;
}

/* A sentence that repeats an email address back: a long address is one
   word to the browser, and would push a 360px card sideways. */
.wrap-anywhere {
  overflow-wrap: anywhere;
}

/* A list inside a card on these pages (what closing deletes, the two ways
   out for a shop owner) keeps to the card's own rhythm. */
.card ul.plain-list {
  margin: 0 0 var(--s-4);
  padding-left: 1.25rem;
}

.card ul.plain-list li + li {
  margin-top: var(--s-1);
}

/* -- Switches (notification preferences) ---------------------------------- */

/* The approved switch (gallery b-forms): a 48x28 track in the control edge,
   red only when on, a thumb that slides 20px in 200ms. The label is the
   whole row, so the target is the line, not the 28px pill. It is still a
   checkbox underneath -- role="switch" and the form, not the look, are what
   it says to a screen reader. */
.switch-field {
  margin: 0 0 var(--s-4);
}

.switch-field + .switch-field {
  padding-top: var(--s-4);
  border-top: 1px solid var(--line);
}

.switch-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  min-height: var(--target);
  margin: 0;
  color: var(--ink);
  font-size: var(--fs-base);
  font-weight: 500;
  cursor: pointer;
}

/* Under the row and clear of the switch, so the two never collide. */
.switch-field .helptext {
  margin-top: 0;
  padding-right: 64px;
}

/* The input itself is a 48x44 box -- a full touch target even for a test
   that measures the control and not its label (ETE-204: it was 48x28) --
   and the track and thumb are drawn inside it, so nothing visible moved. */
input[type="checkbox"].switch {
  position: relative;
  flex: none;
  width: 48px;
  height: var(--target-sm);
  margin: 0;
  border-radius: var(--r-pill);
  background: none;
  cursor: pointer;
  appearance: none;
}

input[type="checkbox"].switch::before {
  content: "";
  position: absolute;
  top: calc(50% - 14px);
  left: 0;
  width: 48px;
  height: 28px;
  border-radius: var(--r-pill);
  /* Off is an empty track in the control edge, not a filled grey one
     (ETE-205, audit C-19): grey-violet read as "disabled". The thumb takes
     the edge colour, so "off" is still >= 3:1 against the card. */
  background: var(--sunk);
  box-shadow: inset 0 0 0 1px var(--control-line);
  transition: background-color var(--dur-2) var(--ease-standard);
}

input[type="checkbox"].switch::after {
  content: "";
  position: absolute;
  top: calc(50% - 11px);
  left: 3px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--control-line);
  transition: transform var(--dur-3) var(--ease-standard);
}

input[type="checkbox"].switch:checked::before {
  background: var(--brand);
  box-shadow: none;
}

input[type="checkbox"].switch:checked::after {
  background: var(--on-brand);
  box-shadow: var(--sh-1);
  transform: translateX(20px);
}

.has-error input[type="checkbox"].switch {
  outline: 1px solid var(--danger);
  outline-offset: 2px;
}

input[type="checkbox"].switch:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  input[type="checkbox"].switch::before,
  input[type="checkbox"].switch::after {
    transition: none;
  }
}

/* -- Error pages and the offline page --------------------------------------- */

/* 403, 404, 429, 500, the stale form and offline: the wordmark in the header,
   then the empty state's shape (gallery b-empty) -- a quiet ring with one
   icon, a serif headline, a sentence, one action. The ring is neutral, not
   the brand tint: none of these is the brand's moment, and none of them is
   a form error either, so neither the red nor the rust belongs on it. */
.system {
  padding: var(--s-6) 0 var(--s-5);
}

.system h1 {
  margin: 0;
}

.system .empty__icon {
  border: 1px solid var(--line);
  background: var(--sunk);
  color: var(--body);
}

.system p {
  max-width: 38ch;
  color: var(--body);
}

.system p.meta {
  color: var(--muted-text);
}

.system .btn {
  margin-top: var(--s-3);
}

/* -- ETE-207: account and system pages after the audit ---------------------- */

/* The birthday's errors under the pair, at the row's full width (audit
   A-17): an impossible date is about the day and the month together, and in
   the day's 90px column the sentence broke over two lines. */
fieldset.birthday .birthday__errors {
  margin: var(--s-2) 0 0;
}

/* A link with a line saying what it gives you, under it (audit A-18: what
   "Download my data" downloads). */
.account-links li {
  display: grid;
}

.account-links li > .meta {
  margin-top: calc(-1 * var(--s-2));
  font-size: var(--fs-xs);
}

.account-links a .icon {
  margin-right: 6px;
}

/* A system page's one form (logging out, keeping an account) is its button,
   with the same air as a lone link-button above it; a secondary line under
   the action -- "Back to your account", "change your password" -- sits a
   little apart from it. */
.system form {
  margin: 0;
}

.system form .btn,
.system > .btn {
  margin-top: var(--s-3);
}

.system .btn + p,
.system form + p {
  margin-top: var(--s-3);
}

/* The owner's way out of a refused account closure (audit A-05): an address
   can be long, and it is the label of the button. */
.card .button-row .btn .wrap-anywhere {
  min-width: 0;
}

/* The legal notice and links (ETE-220): present, and no louder than a footnote. */
.legal-acceptance {
  margin: 0 0 var(--s-3);
  text-align: center;
}

.legal-acceptance a,
.site-footer__legal a {
  text-decoration: underline;
  text-underline-offset: 2px;
}

.site-footer__legal {
  display: flex;
  flex-basis: 100%;
  gap: var(--s-4);
  justify-content: center;
}
