/* JetBrains Mono 500, latin subset, self-hosted (task #523).

   ONE FILE, ONE WEIGHT, ONE SUBSET. It is loaded for a single control - the two
   hex value fields on the Card Design page - so anything more would be bytes
   nobody reads. `font-display: swap` paints the platform monospace immediately
   and swaps when the file lands, and `unicode-range` means the browser never
   fetches it at all for a page whose text is outside the subset.

   It is served from /static, from public/jetbrains-mono-latin-500.woff2, which
   is copied verbatim out of @fontsource/jetbrains-mono (exact-pinned in
   package.json) by `npm run font:mono`. That is the same provenance shape the
   Phosphor icon catalogue uses. The font is under the SIL Open Font License 1.1;
   the licence and the attribution are reproduced in THIRD-PARTY-NOTICES.md.

   NOT a Google Fonts request. src/views/fonts.tsx makes exactly one of those,
   carrying Manrope and Caveat, and the two Content-Security-Policies that cover
   HTML pages allow that origin and 'self' for fonts. Adding a third family to
   that request would have made every page in the product wait on a face that one
   page uses. */
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/static/jetbrains-mono-latin-500.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
    U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  /* LoyalPack brand (task #435): purple highlight, dark, white. Greys are
     neutral surface/line/text tokens, not additional brand colours. Status
     colours (error/success and the amber badges below) stay semantic. */
  --brand: #5238bf;
  /* The LoyalPack purple, fixed. --brand above is deliberately overridable per
     page by a merchant's own brand colour (see Layout's brandColor), which is
     right for their card and their Program page but wrong for LoyalPack's own
     chrome. The app header's hover and current-section colours use this token
     so they stay LoyalPack purple whatever the signed-in business has chosen. */
  --brand-fixed: #5238bf;
  /* The same LoyalPack purple, made readable on the dark band (task #583). The
     purple itself measures only 2.37:1 against --ink, so a line drawn in it
     there is barely visible; this is that purple mixed 40% toward white, in the
     same 5% steps the old per-Program lift used, which is the first step to
     clear 6:1 (6.06:1). Twice the 3:1 a non-text graphic is asked for, because
     the dashboard's chart is the thing on that band people actually read. Still
     plainly the brand hue, and quieter than the band's #c8c2d4 furniture. */
  --brand-on-ink: #9788d9;
  /* The two non-purple series colours of the three-line chart (task #787), as
     tokens since task #807. The validated set and the reasoning for it live
     with the chart rules further down; they are tokens because Reports >
     Overview draws a sample of each line beside the tick box that switches it
     on, and #583's rule is that a chart's colours are stated in ONE place. The
     first slot has no token of its own: it is --brand-fixed above. */
  --chart-s2: #0e8f7e;
  --chart-s3: #c2410c;
  --ink: #15111b;
  /* THE DARK BRAND TONE (task #832, Sam: "a new dark type colour that sits
     between the brand colour and the ink colour but closer to the ink"). It is
     --ink carried about a fifth of the way to --brand-fixed, so it is plainly
     the brand hue rather than a neutral, and still reads as a dark: 16.80:1
     against white, against --ink's own 18.62:1. --brand-on-ink measures 5.47:1
     on it and rgba(255, 255, 255, 0.82) 11.55:1, so everything the ink bands
     already draw on themselves carries across with room to spare.

     WHERE IT IS USED, and nowhere else: the front page's grid of blocks and the
     public footer under it. It is deliberately NOT a replacement for --ink. The
     hero, the wallet band and the app's own chrome stay ink, and this reads as
     one deliberate step off it rather than as a second dark everything has to
     choose between. */
  --ink-brand: #22163d;
  --muted: #615c6b;
  --bg: #f7f6fa;
  --card: #ffffff;
  /* Neutral rule/border token. Darkened from #e6e3ec to #d1cbdc (Sam,
     task #445): the six code boxes read as barely-there at the old value.
     Same hue family (a light lavender-grey, ~260deg), one step darker: 1.58:1
     against white instead of 1.27:1, so a 1px border is clearly visible while
     dividers and card edges stay quiet rather than structural. */
  --line: #d1cbdc;
  /* The authenticated page header strip (task #456). A restrained lilac from
     the same family as --line, three steps darker, so it reads clearly as a
     band against the pale grey page rather than as a slightly-off white: 1.3:1
     against --bg over a large area, which is a plain visible step. Everything
     that sits on it clears WCAG AA at normal size: --ink 13.4:1, --strip-muted
     6.6:1, and --brand-fixed (the hover colour) 5.6:1. The active tab takes
     --bg instead, where --brand-fixed clears AA too (task #456 refinement). */
  --strip: #ddd6f0;
  --strip-muted: #4a4457;
  --error: #b3261e;
  --success: #146c43;
  /* The one red that is read ON THE INK, which is the menu bar and the phone
     menu (Sam, task #831 review: "make the logout link red"). --error above is
     the product's red on a white page and measures 6.54:1 there; on --ink
     (#15111b) it is 2.85:1, well under the 4.5:1 AA floor, so a logout item
     painted with it would be a colour a merchant has to hunt for. This is the
     same hue lifted until it reads on the dark: 6.71:1 against --ink, and
     9.36:1 for the hover step, so the resting state and the hover state both
     clear AA comfortably. Deliberately never used on a white surface, where it
     is 2.78:1 and --error is the correct red. */
  --ink-danger: #ff6b6b;
  --ink-danger-hover: #ff9d9d;
  /* THE app message bar (task #543, Sam). Sam's addition to the brief was that
     the current messaging gets lost, so these are deliberately saturated FILLS
     rather than the coloured text --error and --success above give a paragraph.
     Each pairs a bright fill with a very dark ink of its own hue, which is what
     lets a bar be vivid and still sit comfortably above WCAG AA at normal size.
     Measured with the product's own src/contrast.ts, ink on fill:

       success  6.74:1     error  5.41:1     warning  7.33:1

     The fill against the page is the number that answers Sam's complaint
     directly, and it is why a tint would not have done. Against --bg:

       success  2.00:1     error  2.97:1     warning  1.93:1

     For scale, the header strip --strip is 1.31:1 against --bg and the
     stylesheet already calls that "a plain visible step" over a large area. The
     fills these replace were 1.00:1 and 1.01:1: .prog-saved's #f1f8f4 and
     .prog-error's #fdf3f2 were, as blocks, indistinguishable from the page, so
     the only thing carrying the message was 600-weight coloured text. That is
     exactly the "gets lost" Sam described.

     --msg-*-edge is the "slightly darker bottom edge" the brief asks for, the
     same hue a couple of steps down (1.5:1 to 1.75:1 against its own fill). It
     is drawn as a 3px bottom border, so the bar reads as resting on the page
     rather than floating on it.

     These are LoyalPack's message colours, not a second brand: the purple
     --brand-fixed stays the app's own colour and nothing here borrows it, which
     is deliberate. A message is about what just happened, so it has to be
     legible at a glance from across a counter rather than on-palette. */
  --msg-success: #12c98b;
  --msg-success-ink: #04301f;
  --msg-success-edge: #0a9668;
  --msg-error: #ff5347;
  --msg-error-ink: #3d0400;
  --msg-error-edge: #d02718;
  --msg-warning: #ff9d1c;
  --msg-warning-ink: #3a1f00;
  --msg-warning-edge: #d97a00;
  /* The post-it support note in an editable settings section (task #462): a
     pale amber note and a slightly darker amber for its border and folded
     corner. --ink on --note is well above AA (a near-black on pale yellow), and
     so is the purple link colour --brand-fixed. */
  --note: #fdf6d3;
  --note-edge: #e3d488;
  /* Manrope (task #453), then a local fallback for a slow or blocked Google
     Fonts fetch, which is what display=swap paints in the meantime. */
  --font-brand: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
    sans-serif;
  /* Caveat (task #487), the handwritten face for a support note's short title
     and nothing else. It rides the same Google Fonts request as Manrope; the
     cursive keyword is the honest fallback while display=swap waits. */
  --font-note-title: 'Caveat', cursive;
  /* JetBrains Mono (task #523), for a code VALUE the merchant types character by
     character: the Card Design hex fields, and nothing else yet. It is
     SELF-HOSTED from /static rather than fetched from Google Fonts, which is why
     it is a separate token from the two above: a webfont for one field on one
     page is not worth a third family on the shared stylesheet request every page
     makes, and the @font-face below serves one 21 KB latin file only when a page
     actually renders the class. The fallback stack is the platform monospace the
     rest of this file already uses, so the field is a real monospace before,
     during and after the fetch, and remains one if it never arrives. Licence and
     provenance: THIRD-PARTY-NOTICES.md. */
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  /* The content column width every centred interior reads: `.container` and,
     on a signed-in page, the full-width strips' inner elements too, so they all
     stay on one grid. This declaration is the DEFAULT, which is the measure the
     public and customer pages keep: the landing page, enrolment, the browser
     card and the not-found page. Task #497 left it exactly where it was, on
     purpose - those are reading and form surfaces and widening them would
     lengthen their lines for nothing. */
  --content-max: 960px;
  /* The signed-in app's own grid (task #497), opened out from 960px so the
     dashboard, Programs, Reports, Account and the scanner get more room for
     their cards, tables and two-column layouts. This is the single authoritative
     number for the authenticated width: it is written once, here, and reaches
     every shared authenticated interior through the one rule below that hands
     it to --content-max on the app shell's body. Nothing else declares 1120. */
  --app-content-max: 1120px;
  /* THE ONE HORIZONTAL GUTTER (task #833, Sam): the distance from the edge of
     the surface a page is drawn on to the content on it. Every centred interior
     carries exactly this much and nothing else does, so `.container`, the page
     header strip's inner element, the tab row, the footer's inner element and
     the dashboard band's interior all start their content on the same vertical
     line at every width.

     This declaration is the DEFAULT and is unchanged at 16px: it is what the
     public and customer pages keep (the landing page, enrolment, the browser
     card, the card designer and the not-found page). The signed-in app takes a
     wider one on the shell's body, in the one rule below that also hands it its
     wider column, and since task #843 the support section takes it too, because
     it is on that shell. */
  --content-gutter: 16px;
  /* The content column's own top padding, named so a full-bleed section that
     must sit flush under the page header (the dashboard's focused band) can
     cancel exactly this much and never a guessed number. */
  --main-pad-top: 30px;
  /* The padding a full-width editable/content section carries inside its border
     (task #490 settled 30px for .settings-section; task #496 gave the report
     cards the same treatment). Named so a second surface can take the settled
     value rather than a copied number. `.settings-section` keeps the literal
     30px it was pinned with, and a test asserts this token still equals it, so
     the two cannot drift apart. */
  --section-pad: 30px;
  /* One editable control, one height (task #501). Every field a signed-in
     person types into is exactly this tall, and so is the Filter button that
     ends the Reports filter row, so a row of controls has one top edge and one
     bottom edge instead of a 40px field beside a 48.8px button. 48px also keeps
     every control clear of the 44px touch-target floor on a phone.

     The horizontal padding below is the Account fields' own settled value,
     named here so a second surface takes the settled number rather than a copy
     of it. The Reports bar keeps its own tighter 10px inline padding from task
     #496 and overrides only that.

     THE RADIUS IS ZERO, AND THAT IS THE PRODUCT'S ANSWER (task #839, Sam). No
     control in the app has rounded corners: not a button, not a field. The
     number stays a token rather than becoming a literal 0 in the field rule
     because it is still the one place that decides, and because the base .btn
     is held equal to it by a test, so a button and the field beside it can
     never disagree about their corners. Square edges belong to controls only:
     cards, tiles, previews, wallet faces, avatars, status badges and topic tags
     keep the shapes their own rules give them. The two pills that WERE controls
     (the customer range links and the icon-set filters) are square now with
     everything else. */
  --control-height: 48px;
  --control-radius: 0;
  --control-pad-x: 12px;
  /* The signed-in app's left menu bar (task #831, Sam). ONE number for how much
     of the window the ink column takes, read by the body grid that places it and
     by --bleed-w below, so the menu's width and everything measured against the
     content beside it can never disagree. It steps down once on a small laptop
     (see the app-shell block further down), and there is no third value.

     Declared here rather than in that block because --bleed-w's DEFAULT has to
     be expressible without it, and because a reader looking for "how wide is the
     menu" looks in the token list. */
  --sidebar-w: 268px;
  /* WHAT "YOU ARE HERE" LOOKS LIKE IN THE MENU COLUMN (task #852, Sam). ONE
     fill, read by the app's own sidebar and by the support section's, so the
     two columns cannot answer the same question differently.

     It is --brand-fixed at half strength, which on the ink (#15111b) composites
     to #34246d: a deep indigo that reads as the brand rather than as a grey
     lift, and that is plainly a filled row without being a bright block in a
     dark column. Measured, WCAG 2.2 relative luminance:

       white label on the fill        12.99:1  (AA and AAA, comfortably)
       the fill against the bare ink   1.45:1  (the band you see at a glance)

     THE FILL DOES NOT REPLACE THE PURPLE BAR, IT JOINS IT. The 3px leading
     mark is still there and still --brand-fixed, and aria-current="page" still
     says it in the markup, so the state has three signals and never depended on
     this one. Half strength rather than more is what keeps the mark visible
     against its own row: the purple is only 2.37:1 above the ink to begin with,
     so every point of fill is a point off the bar, and this is the split that
     leaves both readable.

     IT IS THE CURRENT ITEM'S ALONE. Hover stays exactly what Sam settled in
     task #449: the label and its icon turn purple, with no background, box or
     underline, so nothing a pointer passes over can be mistaken for the row a
     merchant is standing on. */
  --nav-current-fill: rgba(82, 56, 191, 0.5);
  /* How wide a full-bleed band inside the content column should draw itself,
     and the whole of what task #831 had to teach the four bands that do it.

     A band is an ordinary block inside the centred column that stretches itself
     back out to the edges of the surface it sits on, by negative inline margins
     of exactly the distance from column edge to surface edge. Until this task
     that surface was always the window, so the distance was always half the
     viewport and every band wrote `50vw` directly.

     IT IS NOT THE WINDOW ANY MORE on a signed-in page: the menu bar takes the
     left of it, and a band that still bled to 100vw would start underneath the
     menu and hang off the right of the screen by the menu's width. So the bands
     read this token instead of naming a viewport, and the app shell hands it the
     content area's own width. Everywhere else - the public landing page, the
     card designer, and every one of these bands on a phone, where the menu is a
     top bar again - it is the window, exactly as before. */
  --bleed-w: 100vw;
}

/* The focused-Program band below stretches itself to 100vw from inside the
   centred column, and vw units include a classic (non-overlay) scrollbar, so
   its far edge can poke past the layout viewport by the scrollbar's width.
   `clip` cuts that sliver off at the viewport without creating a scroll
   container - unlike `hidden`, nothing becomes programmatically scrollable
   and vertical scrolling is untouched - so the page can never grow a
   horizontal scrollbar from the full-bleed treatment. */
html {
  overflow-x: clip;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: var(--font-brand);
  color: var(--ink);
  background: var(--bg);
  line-height: 1.55;
}

/* The signed-in app shell (task #497). Layout puts this class on <body> for
   every authenticated page and on no public or customer one, so this is the
   single switch between the two grids.

   It works by REDEFINING the shared token rather than by adding a second
   max-width anywhere: every centred authenticated interior already reads
   --content-max (`.container`, `.pagehead-inner`, `.pagehead-tablist`,
   `.app-footer-inner` and the focused band's `.dash-focus-inner`), and all of
   them inherit from <body>, so one declaration moves the header strip, the
   content, the footer and the full-bleed band's interior together and they
   cannot drift apart. The gutter is the same story one token along (see
   --content-gutter, and the app's own value just below), so the four still
   share exact left and right edges at every width.

   Below 1120px + gutters the max-width simply stops applying and the column is
   fluid, which is why there is no media query here and nothing to overflow. */
/* AND THE PUBLIC CARD DESIGNER (task #572, Sam: "make --content-max wider,
   same as the web app uses"). It is the one public surface built like an app
   screen: a stage bar, a working column and a live preview beside it, and at
   960px the two columns were cramped enough that the icon picker's fifty marks
   would not sit in five rows. It joins the SWITCH rather than declaring a width
   of its own, so there is still exactly one rule reading --app-content-max and
   still exactly one number to change. The landing page, enrolment, the browser
   card and the not-found page are untouched: none of them carries .cc-page. */
/* AND THE SUPPORT SECTION (task #574 review, Sam: a persistent navigation
   column down the left). A sidebar has to come out of the content column, and
   at 960px it would have taken a third of it: the article measure survives that
   (the prose is capped at 66ch either way) but the screenshots do not, and they
   were widened for exactly that reason two changes ago. So support joins the
   same switch, on the same one rule, and the band, the trail and the content
   all widen together because every one of them reads --content-max. */
body.app-shell,
body:has(.cc-page),
body:has(.support-page),
body:has(.lp-page) {
  --content-max: var(--app-content-max);
}

/* AND THE SIGNED-IN APP HOLDS ITS CONTENT 30px IN FROM THE GLASS (task #833,
   Sam), against the 16px it shared with every other surface until now. 16px is
   a reading page's gutter; a working screen wants the wider one, and on a
   shrinking window it is the whole of what stops a card, a table or a header
   title sitting almost against the edge of the screen.

   ONE DECLARATION, because every interior reads the token rather than naming a
   number, so the strip, the tabs, the column, the footer and the dashboard
   band's interior move together and cannot land on different lines. The
   max-widths above are untouched: on a wide screen the column is still exactly
   1120px and still centred, and the wider gutter is simply taken out of it, so
   nothing overflows and no breakpoint is involved.

   `body.app-shell` ALONE, and deliberately not the four-selector list above:
   the card designer and the public front page take the app's WIDTH because they
   are built like app screens, but they are public reading surfaces and Sam's
   request is about the signed-in app.

   THE SUPPORT SECTION TAKES THE WIDER GUTTER SINCE TASK #843, and it takes it
   from this rule rather than by joining the list: the section is on the app's
   shell now, class and all, so it is on the app's measure for the same reason
   every other surface with that class is. Its `body:has(.support-page)` entry
   in the width switch above is what it was, and is simply no longer the thing
   deciding. */
body.app-shell {
  --content-gutter: 30px;
}

/* Native controls take the user agent's font rather than their ancestors'.
   Element selectors, so the deliberate monospace fields further down (the hex
   colour value, the enrolment URL) still win on specificity. */
button,
input,
select,
textarea {
  font-family: inherit;
}

/* The file picker's button is in the input's shadow tree and inherits from
   nothing, not even the input. Two rules, because a browser that knows only one
   of these names would drop a combined selector list. */
input[type='file']::file-selector-button {
  font-family: inherit;
}

input[type='file']::-webkit-file-upload-button {
  font-family: inherit;
}

.container {
  max-width: var(--content-max);
  margin: 0 auto;
  padding: var(--main-pad-top) var(--content-gutter) 64px;
}

/* The container's own padding is the ONE thing that sets the distance from the
   page header strip (or, on a page without one, from the top of the content) to
   the first thing on the page. A page-level block that ALSO carried a top
   margin used to add to it, which is why the two report cards, every 403
   refusal, the shared Message page and the counter poster all sat 46px down
   while Programs, Account and Billing sat at the token's 30px, and why a page
   that happened to open with a flash paragraph took the browser's own 1em on
   top of that (task #501). Dropping the leading margin here, at the boundary, is
   what makes the token the answer on every route; the alternative was a
   compensating rule per page, which is how the 46px got there in the first
   place.

   Only the FIRST block's own top margin goes. Its interior spacing, and every
   later block's rhythm, are untouched: `.card` still separates two stacked cards
   by 16px, `.settings-section` still leaves 24px under itself.

   The dashboard's full-bleed focused band is the one deliberate exception. It
   cancels the padding entirely so it can sit flush under the header, and it
   still does: its rule further down is more specific than this one. */
main.container > :first-child {
  margin-top: 0;
}

a {
  color: var(--brand);
}

h1 {
  font-size: 1.9rem;
  line-height: 1.2;
  margin: 0 0 12px;
}

h2 {
  margin: 0 0 8px;
}

h3 {
  margin: 0 0 8px;
}

code {
  background: #f0eef5;
  padding: 2px 6px;
  border-radius: 6px;
  font-size: 0.92em;
  word-break: break-all;
}

/* --- nav --- */
.topnav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 20px;
  background: var(--card);
  border-bottom: 1px solid var(--line);
}

.brand {
  display: inline-flex;
  align-items: center;
  font-weight: 800;
  font-size: 1.15rem;
  text-decoration: none;
  color: var(--brand);
}

/* The brand wordmark (task #1024): the approved wide artwork's white variant,
   drawn by the one component in src/views/brand-logo.tsx as an <img> and
   sized by HEIGHT. width:auto keeps the artwork's proportions, and the
   width/height attributes the component writes hold the slot while the file
   loads. The sizing class on each bar (.appnav .brand-logo, .auth-logo) owns
   the height; this rule owns only the shape, and a bar that set no height
   would draw the attributes' 46px. */
.brand-logo {
  display: block;
  width: auto;
}

.nav-links {
  display: flex;
  align-items: center;
  gap: 18px;
}

.nav-links a {
  text-decoration: none;
  color: var(--ink);
}

/* THE PUBLIC BAR'S ONE FILLED ITEM (task #955 pass 2, Sam: its label was
   rendering black; pass 3: the bar is ink). The public bar wears the app
   bar's `appnav` treatment, whose `.appnav .nav-links > a` paints every item
   as white text on nothing at (0,2,1), outranking `.btn` at (0,1,0): the one
   item that is a filled button (Get Started for a stranger, Account for a
   merchant) would lose its fill and its label colour to that rule. This rule
   is scoped to that one item on the public bar (`.topnav-action` is set by
   PublicNav and nothing else) at (0,3,1), and restates the button for every
   state a link has, visited and active included, so no user-agent or shared
   rule can take it back. The fill is WHITE with ink text, the same decision
   the hero's button on the ink band takes: the brand purple is a 2.4:1 block
   against ink, while ink on white measures 18.6:1. Hover keeps the hero
   button's brightness dip; focus gets a white ring offset onto the ink bar so
   it reads on either side of the button's edge. The authenticated bar's own
   items are never `.topnav-action` and are not reached. */
.topnav .nav-links > .topnav-action,
.topnav .nav-links > .topnav-action:link,
.topnav .nav-links > .topnav-action:visited,
.topnav .nav-links > .topnav-action:hover,
.topnav .nav-links > .topnav-action:active,
.topnav .nav-links > .topnav-action:focus,
.topnav .nav-links > .topnav-action:focus-visible {
  color: var(--ink);
  background: #fff;
  border: 2px solid #fff;
  padding: 8px 16px;
}

.topnav .nav-links > .topnav-action:hover {
  opacity: 0.92;
}

.topnav .nav-links > .topnav-action:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}

/* The app bar pulls its row 12px past the column so its LAST item's own
   padding ends on the wordmark's edge; a filled button's padding is its box,
   so the public bar with one keeps the row on the column instead. */
.appnav .nav-links:has(> .topnav-action) {
  margin-right: 0;
}

/* An inline glyph beside a nav label (task #572). Only the support section's
   menu uses one now (the public bar's Login lost its glyph in task #955 pass
   3): the app's own icons come from the merchant's Font Awesome kit, which
   sizes them itself. 1.125em keeps the sheet's hand-drawn bar marks one size,
   and em keeps it tracking the label. */
.topnav-glyph {
  width: 1.125em;
  height: 1.125em;
}

/* --- authenticated app header (task #449) ---
   Scoped to `.appnav`, which only MerchantNav sets, so the public landing nav
   (plain `.topnav`, white bar, dark wordmark) keeps its existing look.

   The bar is LoyalPack ink edge to edge with the approved white wordmark at
   46px tall, and every nav item, the Log out button included, shares one
   typography and hit-area rule below. Square corners throughout.

   Interaction (Sam's refinement): no boxes at all. Items are white at rest,
   the label and its icon turn LoyalPack purple on hover, and the current section
   is marked by a 2px square-ended purple bar under the item.

   Measured contrast against the ink bar (#15111b), WCAG 2.2 relative
   luminance:
     white   #ffffff  18.62:1
     purple  #5238bf   2.37:1
   Purple therefore fails AA for text (4.5:1) and 1.4.11 for non-text (3:1).
   Sam asked for purple hover explicitly, so hover is purple: it is transient,
   pointer-only, and carries no information the label does not already state.
   Everything a keyboard or screen-reader user depends on stays high contrast:
     - the current section keeps WHITE text (18.62:1) and uses purple only for
       the bar, which is Sam's own stated fallback for insufficient contrast;
     - the section is also announced by aria-current="page", so the bar is
       never the only signal;
     - :focus-visible keeps white text and swaps the bar to white, so the
       keyboard indicator never depends on the low-contrast purple. */
.appnav {
  background: var(--ink);
  border-bottom: none;
  flex-wrap: wrap;
  gap: 8px 16px;
}

.appnav .brand {
  color: #fff;
}

.appnav .brand-logo {
  height: 46px;
}

.appnav .nav-links {
  flex-wrap: wrap;
  /* Tighter than the public nav's 18px because each item now carries its own
     12px of horizontal padding: the padded hit areas supply the rhythm. */
  gap: 2px 6px;
  /* Cancel the last item's padding at the bar's right edge, so "Log out" ends
     the same 20px in from the edge as the wordmark starts on the left. */
  margin-right: -12px;
}

/* One rule for anchors and the Log out button. The button is reset to nothing
   (no border, no background, no appearance, no underline) and given the exact
   font, colour, line-height, padding and alignment of its sibling anchors, so
   it sits on the same baseline in the same flex row rather than riding high.
   10px + 24px + 10px = a 44px touch target. */
.appnav .nav-links > a,
.appnav-logout {
  position: relative;
  display: inline-flex;
  align-items: center;
  margin: 0;
  padding: 10px 12px;
  border: none;
  border-radius: 0;
  background: none;
  -webkit-appearance: none;
  appearance: none;
  gap: 5px;
  font-family: inherit;
  /* Sam, task #529: 16px semibold. The labels lost a pixel of size and gained
     the weight back, which reads as firmer against the ink bar and matches the
     Scan action beside them (already 1rem/600), so the whole bar is one
     typographic voice. Nothing else about the item moved: the 10px + 24px +
     10px box, and so the 44px touch target, is unchanged. */
  font-size: 1rem;
  font-weight: 600;
  line-height: 24px;
  color: #fff;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: color 120ms ease-in-out;
}

/* Font Awesome icons (Sam's Pro kit, loaded by MerchantNav).
   The kit swaps each <i> for an inline <svg class="svg-inline--fa">. Until it
   does, the placeholder is display:none, which means:
     - nothing reserves space, so there is no blank icon column and no reflow
       flash while the deferred kit is still loading;
     - if the kit is blocked, fails or is simply switched off, the nav is
       exactly the row of labels it was before, with no phantom gap (a
       display:none child is not a flex item, so the 5px gap above is not
       generated for it either).
   The svg is a real flex item, so it picks up that 5px gap and the centre
   alignment. Width comes from Font Awesome's own fa-fw class, so every label
   starts at the same offset. Colour is currentColor throughout, which is what
   makes the icon follow the label to purple on hover and stay white on focus
   and on the current item. */
.appnav .nav-links > a > i,
.appnav-logout > i {
  display: none;
}

.appnav .nav-links > a > svg,
.appnav-logout > svg {
  flex: none;
  color: inherit;
}

/* The Log out form is a flex item like any anchor: no browser default margin,
   no extra box, nothing to knock the button out of line. */
.appnav-form {
  display: flex;
  margin: 0;
}

/* The indicator bar. Every item carries it, transparent by default and
   positioned out of flow, so switching page or tabbing through the nav can
   never change an item's width, height or baseline. Square ends: no radius. */
.appnav .nav-links > a::after,
.appnav-logout::after {
  content: '';
  position: absolute;
  left: 12px;
  right: 12px;
  bottom: 4px;
  height: 2px;
  border-radius: 0;
  background: transparent;
}

/* Hover: the label alone turns LoyalPack purple. No background, border, pill,
   shadow or underline (see the contrast note above). */
.appnav .nav-links > a:hover,
.appnav-logout:hover {
  color: var(--brand-fixed);
}

/* LOGOUT IS THE ONE RED THING IN THE MENU (Sam, task #831 review: "make the
   logout link red with a logout icon"). Two declarations and nothing else: it
   keeps the shared rule's typography, padding, icon gap, reserved indicator
   slot and focus behaviour, so it is the same menu item as its neighbours and
   only its colour says that it is the one that ends something.

   --ink-danger is the product's red lifted until it reads on the ink (6.71:1;
   --error itself is 2.85:1 there). The hover step is a lighter red rather than
   the purple its neighbours take: an item Sam asked to be red should not stop
   being red under the pointer, and the purple is 2.37:1 anyway. Focus is left
   to the shared rule, which is deliberately the highest-contrast state in the
   bar: white label, white indicator bar. Colour is currentColor throughout, so
   the sign-out icon follows the label through all three states. */
.appnav-logout {
  color: var(--ink-danger);
}

.appnav-logout:hover {
  color: var(--ink-danger-hover);
}

/* Current section: white label, purple bar, plus aria-current="page" in the
   markup. Log out is an action rather than a place, so it never gets this. */
.appnav .nav-links > a[aria-current='page']::after {
  background: var(--brand-fixed);
}

/* Keyboard focus, independent of both of the above and deliberately the
   highest-contrast state in the bar: white label, white bar, still no box.
   Placed after the aria-current rule so a focused current item shows the white
   indicator rather than the purple one. */
.appnav .nav-links > a:focus-visible,
.appnav-logout:focus-visible {
  outline: none;
  color: #fff;
}

.appnav .nav-links > a:focus-visible::after,
.appnav-logout:focus-visible::after {
  background: #fff;
}

/* The wordmark link is an image, so it has no label to underline: it keeps a
   plain white ring, inset so it never clips against the bar edge. */
.appnav .brand:focus-visible {
  outline: 2px solid #fff;
  outline-offset: -2px;
}

/* Forced-colours mode paints its own palette and drops background images, so
   the bar above would vanish. Hand focus back to a system outline there. */
@media (forced-colors: active) {
  .appnav .nav-links > a:focus-visible,
  .appnav-logout:focus-visible {
    outline: 2px solid;
    outline-offset: -2px;
  }
}

/* --- mobile header controls (task #458) ---
   The burger is mobile-only: hidden here at desktop widths so the desktop bar
   is byte-for-byte unchanged, and revealed in the phone breakpoint below. It
   used to share this rule with a standing Scan action pinned to the bar's right;
   task #784 took the web scanner out of the app, so the burger is the only
   mobile-only control left. Square corners throughout, matching the app chrome
   (this whole block is asserted to carry no rounded corners). */
.appnav-burger {
  display: none;
}

/* A real button reset to nothing, then given a 44px square touch target. */
.appnav-burger {
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin: 0;
  padding: 0;
  border: none;
  border-radius: 0;
  background: none;
  -webkit-appearance: none;
  appearance: none;
  color: #fff;
  cursor: pointer;
}

.appnav-burger:focus-visible {
  outline: 2px solid #fff;
  outline-offset: -2px;
}

/* Two inline-SVG glyphs, no icon font: three bars when the menu is closed and
   an x-mark when it is open. The swap is driven purely by the button's
   aria-expanded, which the script keeps in step, so the icon can never disagree
   with the menu state. */
.appnav-burger-bars,
.appnav-burger-x {
  display: block;
  width: 24px;
  height: 24px;
}

.appnav-burger-x {
  display: none;
}

.appnav-burger[aria-expanded='true'] .appnav-burger-bars {
  display: none;
}

.appnav-burger[aria-expanded='true'] .appnav-burger-x {
  display: block;
}

/* The dedicated mobile navigation is a separate element from the desktop link
   row (task #458, Sam's restructure); its own styles live in the "mobile
   overlay nav" section further down. Hidden at every width by default. */
.appnav-mobile {
  display: none;
}

/* Phones: the wordmark steps down to 34px (still well above the old 26px). The
   bar keeps burger and wordmark on one stable row; the desktop link row is
   hidden (never repurposed) and the overlay nav takes over the menu. */
@media (max-width: 720px) {
  .appnav {
    padding: 10px 14px;
    /* The intentional gap between the burger and the wordmark (task #811,
       Sam). 12px, which is a clear, deliberate space next to the burger's own
       44px square: the glyph inside that square is 24px, so it leaves 10px of
       its own on the right, and the wordmark starts about 22px clear of the
       drawn bars without either control looking adrift from the other. */
    gap: 0 12px;
  }

  .appnav-burger {
    display: inline-flex;
  }

  /* THE WORDMARK SITS AT THE LEFT, BESIDE THE BURGER (task #811, Sam: align
     the logo left with a suitable gap after the burger).

     The bar inherits `justify-content: space-between` from `.topnav`, and on a
     phone the desktop link row is display:none, so it is not a flex item at
     all: that left the bar with exactly two items and pushed the wordmark hard
     against the RIGHT edge, with the 8px gap above never able to apply. An
     auto margin absorbs free space BEFORE justify-content distributes any, so
     one declaration here puts the whole of that slack on the wordmark's right
     and the two controls read as the pair they are.

     Deliberately an auto margin rather than a `justify-content` override: it
     is also exactly what space-between was doing on a bar that has no burger
     (the public card designer and the support section wear this bar's look
     without its machinery, and keep their link row visible on a phone - see
     the `:not(:has(.appnav-burger))` rule below), so that bar keeps exactly
     the layout it already had. */
  .appnav .brand {
    margin-right: auto;
  }

  .appnav .brand-logo {
    height: 34px;
  }

  /* The desktop link row is not used on a phone: the overlay nav carries the
     same destinations. Kept in the DOM so desktop is byte-for-byte untouched. */
  .appnav .nav-links {
    display: none;
  }

  /* UNLESS THERE IS NO BURGER TO REACH THEM BY (task #572). The rule above is
     safe in the app because .appnav-mobile carries the same destinations behind
     the burger. A PUBLIC bar wearing this treatment has neither: the card
     designer and the support section render the bar's look without any of its
     machinery, so on a phone the rule above was leaving a signed-out visitor a
     wordmark and nothing else, with no way to reach Log in.

     Keyed off the burger's own absence rather than a class on the bar, because
     that is the one fact the question turns on: the row IS the menu whenever
     nothing else is. A signed-out app page gets the same answer for the same
     reason. Browsers without :has() are exactly where they were. */
  .appnav:not(:has(.appnav-burger)) .nav-links {
    display: flex;
  }
}

.inline-form {
  display: inline;
}

/* --- mobile overlay nav (task #458, Sam's restructure) ---
   The phone menu is a dedicated <nav> (.appnav-mobile, id="appnav-menu"), never
   the desktop row repurposed, so opening it cannot move the wordmark or the
   Scan action. Below the phone breakpoint the burger drops it as an opaque,
   full-page, scroll-locked overlay sitting under the still-visible header
   controls; above the breakpoint it does not exist. Square corners and the
   bar's white-on-ink identity throughout. Kept below .inline-form so the app
   header's own region tests (which scan up to .inline-form) stay about the
   bar. */
@media (max-width: 720px) {
  /* Generously touchable rows. Purple label on hover; a square purple bar on
     the left of the current one (its text stays white, because purple on ink is
     below the contrast floor, exactly as the bar's current item does).

     THE LOGOUT BUTTON IS ONE OF THESE ROWS (Sam, task #831 review). The phone
     menu carries the same bottom group the desktop column does, so the one
     control here that is a <button> in a POST form rather than an anchor is
     paired into the row rules: same height, same gap, same icon contract, same
     focus ring. Without that it would have arrived wearing the horizontal bar's
     10px/12px box in the middle of a column of 56px rows. */
  .appnav-mobile a,
  .appnav-mobile .appnav-logout {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 16px 20px;
    border-left: 4px solid transparent;
    color: #fff;
    font-size: 1.125rem;
    text-decoration: none;
  }

  /* The same Font Awesome swap contract as the bar: the <i> placeholder shows
     nothing until the kit replaces it with an <svg>. */
  .appnav-mobile a > i {
    display: none;
  }

  .appnav-mobile a > svg,
  .appnav-mobile .appnav-logout > svg {
    flex: none;
    width: 1.1em;
    color: inherit;
  }

  .appnav-mobile a:hover {
    color: var(--brand-fixed);
  }

  /* Restated after the row rule above, which paints every item in the menu
     white, because this is the one item that is not: Sam's red, in the shade
     that reads on the ink (6.71:1), with the same lighter-red hover the bar's
     own logout takes. */
  .appnav-mobile .appnav-logout {
    /* The form around it is a block and the button inside it is not, so this is
       what makes the whole row touchable rather than only the label. */
    width: 100%;
    color: var(--ink-danger);
  }

  .appnav-mobile .appnav-logout:hover {
    color: var(--ink-danger-hover);
  }

  /* The indicator bar every item in the horizontal bar reserves is not part of
     this menu's vocabulary: here the current item is marked by the left edge,
     and the logout item is never current anyway. Dropped rather than left to
     paint a stray white line across the bottom of a focused row. */
  .appnav-mobile .appnav-logout::after {
    display: none;
  }

  .appnav-mobile a[aria-current='page'] {
    border-left-color: var(--brand-fixed);
  }

  .appnav-mobile a:focus-visible,
  .appnav-mobile .appnav-logout:focus-visible {
    outline: 2px solid #fff;
    outline-offset: -2px;
  }

  /* THE SAME BOTTOM GROUP THE COLUMN HAS (Sam, task #831 review). The open
     overlay is a full-height flex column, so one auto margin puts Account and
     the way out at the foot of it, with the places above. If the list is long
     enough to fill the screen the auto margin resolves to nothing and the menu
     simply scrolls, so a small phone never loses the two items to the fold. */
  .appnav-mobile-foot {
    margin-top: auto;
  }

  /* Open: lock the page behind, pin the header to the top so its controls stay
     visible and reachable, and drop the overlay from just under the header to
     the bottom of the viewport, full width and opaque, covering page header,
     body and footer. */
  html.appnav-open,
  html.appnav-open body {
    overflow: hidden;
  }

  html.appnav-open .appnav {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 30;
  }

  html.appnav-open .appnav-mobile {
    display: flex;
    flex-direction: column;
    position: fixed;
    top: var(--appnav-h, 64px);
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 20;
    padding: 8px 0 24px;
    background: var(--ink);
    overflow-y: auto;
  }
}

/* --- the signed-in app's left menu bar (task #831, Sam) ---------------------

   Sam's brief, in his words: a vertical menu bar down the left of the page in
   the ink, the page's content on the right, the brand purple header strips at
   the top of that right-hand area rather than spanning the whole window, a
   footer in a different colour so it is not read as more of the left bar, and
   in the bar itself the logo, a space, a border, a space, and then the menu
   items, comfortably spaced with a bit more room between each icon and its
   label. This is the first pass of that.

   IT IS THE SAME BAR. MerchantNav still renders one `.topnav.appnav` carrying a
   wordmark and a `.nav-links` row of items built from NAV_ITEMS, and this
   section turns that row on its side above the phone breakpoint. That is
   deliberate and is what keeps the change small:

     - Sam's review of this pass added ONE thing to the markup: a second
       `.nav-links` list, `.nav-foot`, holding Account and a logout control, so
       the two items about the person at the keyboard sit at the bottom of the
       column rather than at the end of the list of places. It is the same list
       element with the same items in it, so every rule below dresses it without
       being told, and the phone menu carries the same group for the same
       reason. See AppNavFoot in views/layout.tsx;

     - the destinations, their order, their icons and their active state are
       still NAV_ITEMS resolved server-side, so nothing here can disagree with
       the footer's Navigate column or with the phone menu;
     - the phone keeps EXACTLY the header it has had since task #458 - ink bar,
       burger, full-page overlay - because every rule here is behind
       `min-width: 721px`, which is the same edge the burger appears at. There
       is one breakpoint in this shell, not two, so there is no width at which
       both treatments or neither applies;
     - the public pages that wear the app bar's LOOK without its machinery are
       untouched. There are two ways a page can be one, and the selector below
       answers both:

         the card designer renders `.appnav` on a body that is NOT `app-shell`,
         so the first half of the selector excludes it;

         the two legal pages render `.appnav appnav-plain` on a body that IS
         `app-shell` when somebody is signed in (legal.tsx passes
         `app={Boolean(session)}`, and public-chrome.tsx draws that bar for
         task #759). `appnav-plain` is the class those pages already carry to
         NAME "the ink bar with no machinery behind it", so excluding it is
         exactly the question being asked: this rule is about the app's real
         menu, and a public page's bar stays a bar across the top.

     - so is the counter poster, which is an app-shell page with a header strip
       and no nav at all. `:has()` is what tells that apart, and it is the
       honest test: this is a rule about pages that HAVE a menu bar.

     - AND THE SUPPORT SECTION IS IN, DELIBERATELY, SINCE TASK #843. It is
       public, and it is on this shell on purpose: Sam asked for it to be laid
       out like the app, so it renders `.topnav.appnav` on an `app-shell` body
       and every rule in this block applies to it unchanged. It carries one
       extra class, `support-shell`, which nothing here reads; that is for the
       one thing this shell cannot answer for it, an extra band under the strip
       (see the support shell block at the end of this file).

   THE SHELL IS A GRID ON <body>, which is where the four surfaces already are:
   Layout renders the nav, the header strip, <main> and the footer as four
   body-level siblings (see its `header` prop, and task #456's note on why the
   strip is not inside the column). So placing them is two columns and three
   rows and no new element:

     column 1, all rows      the menu bar
     column 2, row 1         the purple page header strip
     column 2, row 2 (1fr)   <main class="container">
     column 2, row 3         the footer

   The 1fr on the middle row is what the flex column on `body.has-app-footer`
   was doing before it: a short page still puts the footer at the bottom of the
   window rather than half way up it. This rule outranks that one on
   specificity, and pages with no footer are covered by the same three rows,
   with the third simply empty. */

@media (min-width: 721px) {
  body.app-shell:has(.appnav:not(.appnav-plain)) {
    /* The bands inside the content column bleed to the content AREA now, not to
       the window. See --bleed-w's note in the token list: this is the one place
       that hands it the narrower surface, and the four bands that read it did
       not have to learn anything about a menu bar. */
    --bleed-w: calc(100vw - var(--sidebar-w));
    display: grid;
    grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
    grid-template-rows: auto 1fr auto;
    min-height: 100vh;
  }

  /* THE MENU BAR SPANS EVERY ROW, so the ink is one column of colour from the
     top of the window to the bottom of the page whatever is beside it, and the
     footer's own colour cannot appear to run underneath it.

     Then it is pinned: `align-self: start` stops the grid stretching it to the
     height of the whole page, `height: 100vh` gives it the window instead, and
     `position: sticky` holds that against its full-height grid area as the page
     scrolls. So the menu is always where it was left, on a long report as much
     as on the dashboard, and it never scrolls out of reach. `overflow-y: auto`
     is for the short window (a laptop at 200% zoom) where the items themselves
     do not fit: the bar scrolls inside itself rather than being cut off. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav {
    grid-column: 1;
    grid-row: 1 / -1;
    position: sticky;
    top: 0;
    align-self: start;
    height: 100vh;
    overflow-y: auto;
    /* ABOVE THE CONTENT COLUMN, BY ONE. Nothing in the column should reach the
       menu, and one thing very nearly does: --bleed-w is the window less the
       menu, and `100vw` includes a classic (non-overlay) scrollbar, so on a
       desktop with one a full-bleed band is about 15px wider than the content
       area really is. It is centred, so half of that lands under the menu's
       right edge; the other half runs off the window and is clipped at the root
       exactly as it always was (see the html rule up top). The dashboard's
       focused band is `position: relative`, so without this it would paint over
       the menu for those few pixels, being later in the document.

       One is enough, and one is deliberate: it clears the content column, and
       it stays well under the phone drawer's 20 and its pinned header's 30,
       which must go on covering everything. */
    z-index: 1;
    /* The bar is `.topnav`, which is a horizontal flex row with its items
       pushed apart. On its side it is a plain column that starts at the top:
       every one of these cancels something that only made sense lying down. */
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: stretch;
    justify-content: flex-start;
    gap: 0;
    padding: 0;
  }

  body.app-shell:has(.appnav:not(.appnav-plain)) > .pagehead {
    grid-column: 2;
    grid-row: 1;
  }

  body.app-shell:has(.appnav:not(.appnav-plain)) > main.container {
    grid-column: 2;
    grid-row: 2;
  }

  body.app-shell:has(.appnav:not(.appnav-plain)) > .app-footer {
    grid-column: 2;
    grid-row: 3;
  }

  /* THE LOGO, A SPACE, A BORDER, A SPACE, THEN THE ITEMS (Sam). The four are
     drawn by two rules and no extra element: the wordmark's own generous
     padding is the space above and below it, and the item list's top border and
     top padding are the border and the space under it. The border runs the full
     width of the bar because the list is stretched to it, which is what makes
     it read as the bar's own division rather than as a rule over the links.

     The wordmark keeps the 46px it has on the desktop bar (task #449): at 268px
     wide the column has room for it at its natural proportions, which is what
     "comfortably sized" means for a piece of artwork. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .brand {
    flex: none;
    justify-content: flex-start;
    padding: 30px 24px;
  }

  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-links {
    flex: none;
    flex-direction: column;
    align-items: stretch;
    flex-wrap: nowrap;
    gap: 0;
    /* The row's -12px right margin cancelled the last item's padding against
       the window's right edge. There is no such edge here. */
    margin: 0;
    /* NOTHING ABOVE THE FIRST ITEM (task #833, Sam): the list starts directly
       under the wordmark's own block, so the border below is the division and
       the space above it is the wordmark's padding, once, rather than that
       padding plus a second gap nobody asked for. The 24px under the last item
       stays: that is the end of the menu, not a join. */
    padding: 0 0 24px;
    border-top: 1px solid rgba(255, 255, 255, 0.16);
  }

  /* TWO GROUPS, AND THE SECOND ONE IS HELD AT THE BOTTOM (Sam, task #831
     review: "move both account and logout to fixed position on the bottom of
     the vertical menu"). The bar is a flex column of the height of the window,
     so the whole of that is one declaration: the list of places takes every
     spare pixel, which leaves the group of two sitting on the foot of the
     column whether the page above it is the dashboard or a fifty-row report.

     `1 0 auto` rather than `1`: grow into the slack, but never shrink below the
     items' own height. On a short window (a laptop at 200% zoom) there is no
     slack to take, the two groups meet in the middle, and the bar scrolls
     inside itself as it already did rather than compressing the items into each
     other.

     The group is a `.nav-links` list of its own, so it has taken the rule above
     as it stands: the same 16% division over it that the wordmark has under it,
     and the same 24px under its last item, which is now the bottom of the
     menu. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-links:not(.nav-foot) {
    flex: 1 0 auto;
  }

  /* ONE ITEM: full width, comfortably tall, and with real room between the icon
     and the word. 14px + 24px + 14px is a 52px row, up from the bar's 44px,
     because a column has the height to spend and a menu of eight items reads
     better spaced than packed. The 16px gap is Sam's "a bit more space between
     icon and text", against the 5px the horizontal bar uses, where the items
     sit side by side and every extra pixel is width off the next label. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-links > a,
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .appnav-logout {
    gap: 16px;
    padding: 14px 24px;
  }

  /* THE BOTTOM GROUP IS A STEP SMALLER (Sam: "perhaps smaller"), and it is the
     items that shrink rather than the column: 15px type in a 44px row, against
     the 16px and 52px above. That is a plain step down in the same voice, and
     44px is exactly the touch floor, so the two rows a merchant reaches for
     least are still rows a thumb can land on. 10px + 24px + 10px, the same
     arithmetic the horizontal bar's items use.

     `padding-block` rather than the shorthand, deliberately: the left edge the
     labels start on is the column's, not this group's, and it steps to 18px on
     a small laptop with everything else in the bar. Setting the shorthand here
     would have been more specific than that step and quietly held these two
     items 24px in while the seven above them moved. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-foot > a,
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-foot .appnav-logout {
    padding-block: 10px;
    font-size: 0.9375rem;
  }

  /* The subtle horizontal border between items Sam asked about. White at 7% on
     the ink, which is a division you notice only once you look for it: enough
     to give the column a rhythm, not enough to draw eight boxes. It is between
     the items and never above the first or below the last, so it can never be
     confused with the bar's own border above.

     The second selector is that same division inside the bottom group, where
     the row under Account is a form rather than an anchor and `a + a` cannot
     see it. Same rule and same 7%: that group is two items, and two items are
     divided like any other two. The mark below starts at `-1px`, so a current
     or focused item still draws one unbroken bar over the division. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-links > a + a,
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-foot > .appnav-form {
    border-top: 1px solid rgba(255, 255, 255, 0.07);
  }

  /* THE CURRENT SECTION'S MARK TURNS THROUGH NINETY DEGREES WITH THE BAR, and
     it is the same mark: every item already carries an empty, out-of-flow
     ::after that the aria-current and :focus-visible rules up in the header
     block colour purple and white. Here it is re-laid as a bar down the item's
     leading edge instead of under its label, so switching page still cannot
     move an item by a pixel, and both of those rules keep working untouched.

     Square-ended and 3px, and the item's label stays white: purple on ink is
     2.37:1, so the colour is the mark and never the text, exactly as the
     horizontal bar and the phone overlay (which has drawn a purple left edge on
     its current item since task #458) already have it. `top: -1px` covers the
     divider above, so a current item's mark is one unbroken bar.

     Hover is inherited and unchanged: the label and its icon turn LoyalPack
     purple, with no background, box or underline anywhere. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-links > a::after,
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .appnav-logout::after {
    inset: -1px auto 0 0;
    width: 3px;
    height: auto;
  }

  /* AND THE CURRENT ROW IS FILLED (task #852, Sam: make where you are obvious
     at a glance). The 3px mark above was the whole of it, and down a 268px
     column of eight evenly spaced rows a 3px edge is not enough to find without
     looking for it: it reads at the moment you go hunting, which is exactly the
     moment you should not have to.

     So the row itself takes the brand fill (--nav-current-fill in the token
     list, where the contrast measurements are), and its label goes from 600 to
     700. Three signals now, none of them the only one: a filled band, a purple
     leading bar, and a bolder white word. Nothing else about the row moves - the
     fill paints the border box the row already occupied, so the column's rhythm
     and every item's baseline are untouched.

     ONE RULE FOR BOTH COLUMNS, AND IT HAS TO BE (Sam: the same treatment in the
     app and in Support). The app's items are anchors in a `.nav-links` list and
     Support's are two levels of list, so they cannot share a selector, but they
     can and do share a declaration block: adding a state to one here is adding
     it to the other, which is the drift this task was fixing.

     WHY IT IS INSIDE THIS BREAKPOINT. This is the treatment for the VERTICAL
     menu. Below 721px both sections put their items in a full-screen drawer
     where the current row is already marked by its own left edge and nothing is
     hard to find on a list you opened on purpose, so the phone keeps exactly
     what it has.

     Hover is untouched and stays a colour: an inactive row a pointer is over
     has no background of any kind, so it can never be read as the selected
     one. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-links > a[aria-current='page'],
  body.app-shell:has(.appnav:not(.appnav-plain)) .support-side [aria-current='page'] {
    background: var(--nav-current-fill);
    font-weight: 700;
  }

  /* AND THE FILLED ROW HOVERS TO LILAC, NOT TO PURPLE (Sam, task #852 review:
     "the brand purple is too close to the bg purple on the active state").

     He is right, and it is measurable: the nav's hover colour is --brand-fixed,
     which is 18.62:1 on the bare ink and only 1.71:1 on the fill this task put
     behind the current row. So the one row a merchant is standing on was the
     one row whose label faded when they pointed at it. --strip is the product's
     lilac and reads 9.24:1 there, so hover stays a real response and the label
     gets clearer rather than dimmer.

     THIS IS THE SAME EXCEPTION TASK #760 ALREADY MADE, in the same words: the
     page header tabs hover from white to this lilac because the nav's purple was
     settled for labels on a LIGHT surface and says nothing useful on a purple
     one. Reusing --strip rather than minting a colour is what keeps the two
     purple surfaces in the app answering this the same way.

     Only the current row, and only in this column: an inactive row is on the
     bare ink where the purple is 18.62:1 and Sam's standing hover rule is
     exactly right, so it is untouched, and so are both phone drawers, which
     have no fill for the purple to disappear into.

     Focus is left alone deliberately. `:focus-visible` on its own still gives
     the white label and the white mark that the keyboard indicator depends on;
     only pointing at an already-focused current row lands here, and 9.24:1 is
     nowhere near a contrast question. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-links > a[aria-current='page']:hover,
  body.app-shell:has(.appnav:not(.appnav-plain)) .support-side [aria-current='page']:hover {
    color: var(--strip);
  }

  /* A guide inside an open topic rests at 78% white, a step quieter than the
     topic above it. On the fill it is the current row, so it takes the full
     white the app's items already have: the hierarchy that 78% draws is between
     the RESTING rows, and the one you are reading is not one of them. 12.99:1,
     against 8.49:1 had it stayed quiet. */
  body.app-shell:has(.appnav:not(.appnav-plain)) .support-side-guides > li > a[aria-current='page'] {
    color: #fff;
  }

  /* The one thing on a signed-in page that is pinned to the WINDOW rather than
     laid out in the grid: the Card Design page's pull-up preview (task #612),
     which is a fixed drawer along the bottom edge below 980px. Between this
     breakpoint and that one the two overlap, and a drawer starting at the
     window's left edge would run under the menu bar and centre its phone on the
     window instead of on the page. Starting it at the menu's inside edge puts
     it back over the content it previews. Nothing else about the drawer moves:
     its own width ceiling (--pullup-w) is the frame's 374px at every width this
     rule applies to. */
  body.app-shell:has(.appnav:not(.appnav-plain)) .preview-pullup {
    left: var(--sidebar-w);
  }
}

/* A small laptop pays for the column out of the menu rather than out of the
   page. One step, at the width where a 1120px content column stops fitting
   beside a 268px bar anyway, and nothing else moves: --bleed-w is expressed in
   this token, so the bands beside it follow on their own. */
@media (min-width: 721px) and (max-width: 1023px) {
  body.app-shell:has(.appnav:not(.appnav-plain)) {
    --sidebar-w: 228px;
  }

  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .brand,
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .nav-links > a,
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .appnav-logout {
    padding-inline: 18px;
  }

  /* The wordmark is 196px wide at its 46px, and this column's 192px of
     content width is four short of it: two pixels down keeps the artwork at
     its proportions (187px wide) and inside the padding. */
  body.app-shell:has(.appnav:not(.appnav-plain)) > .appnav .brand-logo {
    height: 44px;
  }
}

.btn-link {
  background: none;
  border: none;
  padding: 0;
  color: var(--ink);
  font: inherit;
  cursor: pointer;
  text-decoration: underline;
}

/* --- buttons ---

   Square, and square HERE (task #839, Sam). Every button in the product wears
   this class, so the shape is settled once at the base and nothing downstream
   needs to restate it: the Program page's copy, download and settings buttons
   all used to carry their own `border-radius: 0` and no longer do. A component
   rule below may say how a button sits in its row (flex, width, height) but not
   how round it is. The number matches --control-radius, asserted as an equality
   in test/form-controls.test.ts, so a button and the field beside it cannot
   drift apart. */
.btn {
  display: inline-block;
  background: var(--brand);
  color: #fff;
  border: 2px solid var(--brand);
  border-radius: 0;
  padding: 10px 18px;
  font: inherit;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  text-align: center;
}

.btn:hover {
  filter: brightness(1.08);
}

.btn-big {
  padding: 14px 26px;
  font-size: 1.05rem;
}

.btn-small {
  padding: 6px 14px;
}

.btn-ghost {
  background: transparent;
  color: var(--brand);
}

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

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

/* Non-interactive placeholder (e.g. wallet "coming soon" buttons). */
.btn-disabled {
  opacity: 0.45;
  cursor: default;
  pointer-events: none;
}

button:disabled {
  opacity: 0.55;
  cursor: wait;
}

/* --- cards & layout --- */
.card {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 24px;
  margin: 16px 0;
}

.narrow {
  max-width: 460px;
  margin-left: auto;
  margin-right: auto;
}

/* .branded-top is GONE (task #567, Sam). It was `border-top: 6px solid
   var(--brand)` on a `.card`, so the enrolment panel was a 14px-rounded white
   box with a fat brand-coloured bar mitred across its top: the treatment Sam
   named, and the enrolment page was its only user in the product. The page's
   own section near the bottom of this sheet replaces it, and deleting the class
   rather than unhooking it is what stops it being reached for again. */

.two-col {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
}

@media (max-width: 720px) {
  .two-col {
    grid-template-columns: 1fr;
  }
}

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

.small {
  font-size: 0.85rem;
}

.center {
  text-align: center;
}

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

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

.success.big {
  font-size: 1.1rem;
}

/* --- the app message bar (task #543) ---------------------------------------

   One appearance for every "did that work?" answer in the signed-in app. The
   stack is rendered by src/views/app-message.tsx as the FIRST child of
   <main class="container">, which is the placement the brief asks for: on the
   grey page background at the top of the content column, never inside the white
   box the form happens to live in. The boundary rule further up
   (main.container > :first-child { margin-top: 0 }) means the column's own 30px
   token is still the whole distance from the header strip, exactly as it is on a
   page with no message. */
.app-messages {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 0 0 20px;
  /* STICKY, 30px FROM THE TOP OF THE SCREEN (task #602, Sam).

     The bar was correct and unseen. A refusal from a field near the BOTTOM of a
     long form (Sam's case: leaving just `#` in Card Design's stamp area colour)
     re-renders the page with the reason at the top of the column, and the
     browser restores the scroll position you submitted from, which is nowhere
     near it. Nothing was broken: the message was announced, addressed and
     escaped. It was simply above the window.

     The STACK sticks, not each message, and that is what makes one declaration
     enough. Its containing block is <main class="container">, which spans the
     whole scrollable page, so the messages are pinned for the entire scroll and
     settle back into their own place at the top when you return to it. Nothing
     below moves: a sticky box keeps its space in flow, so the page is laid out
     exactly as it was before this rule.

     THE OFFSET IS THE COLUMN'S OWN 30px TOKEN, not a second number that happens
     to match. At rest the bar sits --main-pad-top below the header strip; stuck,
     it sits --main-pad-top below the top of the window. One value, one place to
     change it, and the gap above the bar reads the same either way.

     IT CANNOT COVER THE HEADER. Nothing above the bar in its own column is
     fixed or sticky: the page header strip scrolls away like everything else.
     So the bar only reaches its 30px once the header has already passed it, and
     while the header is on screen the bar is still 30px UNDER it, exactly where
     it has always been. There are two pinned things in the shell and neither is
     above it: the phone's nav drawer, only while it is open, which the z-index
     below handles; and, since task #831, the desktop menu bar, which is a
     sticky item in the OTHER column of the body grid and so has nothing of this
     column to cover.

     z-index 5 is the smallest value that works from both sides: it clears every
     layer inside the content column (the highest is the half-phone preview's 3)
     so a message is never scrolled behind a card or a sticky preview rail, and
     it stays well below the mobile nav's drawer at 20 and its pinned header at
     30, both of which must cover the page. */
  position: sticky;
  top: var(--main-pad-top);
  z-index: 5;
  /* NOT clipped here, deliberately. Clipping the stack was the obvious way to
     keep the entrance tidy, and it is wrong twice: it cuts the contact shadow off
     the bottom of the last message, and it cuts the focus ring off a message that
     has taken focus, which is the one ring in this block that must never be
     lost. Nothing needs clipping anyway: `html { overflow-x: clip }` already
     stops a horizontal scrollbar, so a bar travelling in from off-screen costs
     the page nothing. */
}

.app-message {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: 12px;
  /* Square edged, on Sam's follow-up: no radius here and none on anything
     inside, so the bar is a plain block of colour at the top of the column.
     The extra 3px underneath is the countdown runner's own room, because the
     runner is drawn INSIDE the box now rather than as a border outside the
     padding: the text stops above it instead of the box growing an edge. */
  padding: 14px 16px 17px;
  font-weight: 600;
  line-height: 1.45;
  /* A low shadow of the page's own ink, which is the other half of what "a
     slightly darker bottom edge" bought: the bar rests on the page. */
  box-shadow:
    0 1px 0 rgba(21, 17, 27, 0.05),
    0 6px 18px rgba(21, 17, 27, 0.09);
  /* Slides in across to the left (Sam). `backwards`, deliberately NOT `both`:
     the fill mode has to stop applying when the animation ends, or the final
     keyframe's transform: translateX(0) would out-rank the exit's declared
     transform in the cascade and the message could never slide away. */
  animation: app-message-in 340ms cubic-bezier(0.16, 0.84, 0.44, 1) backwards;
}

/* The countdown (Sam's follow-up). The darker bottom edge is now the clock: it
   spans the bar when the message arrives and shortens away to nothing over the
   ten seconds it has left, so "this is about to go" is visible rather than a
   surprise. Three things about the shape are load-bearing:

     - it is a pseudo-element INSIDE the box, not a border. A border sits
       outside the padding and adds to the height, which is what Sam asked us to
       undo; this one takes no space at all and cannot move the page as it goes.
     - the width is a scaleX rather than a `width`, so a frame of the countdown
       is a composited transform and never a layout.
     - the fraction is a custom property written by /static/app-message.js. With
       no script (or before it starts) it resolves to the fallback 1 and the
       runner is simply the darker bottom edge it always was. Freezing it is
       therefore nothing more than the script stopping writing, which is exactly
       what "the border becomes stationary" has to mean. */
.app-message::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: var(--app-message-edge);
  transform: scaleX(var(--app-message-left, 1));
  transform-origin: left center;
}

/* A simple stack, gently staggered, so two messages arrive as a sequence rather
   than as one lurch. Four is more than any route can currently produce (the
   Account security tab's connect/disconnect/confirm notices are the widest
   case); a fifth simply arrives with the fourth. */
.app-message:nth-child(2) {
  animation-delay: 70ms;
}

.app-message:nth-child(3) {
  animation-delay: 140ms;
}

.app-message:nth-child(4) {
  animation-delay: 210ms;
}

/* Measured from the bar's OWN width, never the viewport: the stylesheet keeps
   exactly one 100vw element (the dashboard's full-bleed band, guarded by
   test/content-grid.test.ts) and a transform is not worth being the second. On a
   wide screen that means the sweep begins in the gutter beside the content
   column rather than beyond the window, which reads the same and travels less. */
@keyframes app-message-in {
  from {
    transform: translateX(100%);
    opacity: 0;
  }

  to {
    transform: translateX(0);
    opacity: 1;
  }
}

/* Then leaves UPWARDS (Sam), rising out of its own space as it fades rather
   than sweeping sideways again: the message came in from the side to be noticed,
   and it goes the way everything above the fold goes when it is finished with.
   Measured against its OWN height, so a one-line confirmation and a three-line
   refusal both clear themselves. Added by /static/app-message.js, either on the
   ten-second countdown or on the dismiss button; the script removes the node
   when this finishes. */
.app-message-out {
  transform: translateY(-100%);
  opacity: 0;
  transition:
    transform 260ms ease-in,
    opacity 260ms ease-in;
}

.app-message-text {
  margin: 0;
  flex: 1 1 auto;
  /* A long unbroken reason (a filename, a pasted URL) must not push the dismiss
     button out of the bar. */
  min-width: 0;
  overflow-wrap: anywhere;
}

.app-message-mark {
  flex: 0 0 auto;
  /* Optically centred on the first line of text rather than on its box. */
  margin-top: 1px;
}

.app-message-close {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  margin: -3px -4px 0 0;
  border: 0;
  background: transparent;
  color: inherit;
  cursor: pointer;
  opacity: 0.7;
}

.app-message-close:hover {
  opacity: 1;
  background: rgba(21, 17, 27, 0.09);
}

/* The bar's ink is a near-black, so the app's purple focus ring would be the
   lower-contrast of the two options here. Its own ink is the visible one. */
.app-message-close:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* A fieldless error takes focus (see AppMessageBar's `focus`), so the whole bar
   can be the focused element. Its ring is drawn just INSIDE its own edge: an
   outward ring on a box that already has a 3px bottom border and a contact
   shadow reads as a smudge, and an inset one cannot be clipped by anything. */
.app-message:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: -4px;
}

/* Each tone names its own edge colour and the shared rule above draws it, so a
   variant can no more forget the countdown than it could forget the fill. */
.app-message-success {
  background: var(--msg-success);
  color: var(--msg-success-ink);
  --app-message-edge: var(--msg-success-edge);
}

.app-message-error {
  background: var(--msg-error);
  color: var(--msg-error-ink);
  --app-message-edge: var(--msg-error-edge);
}

.app-message-warning {
  background: var(--msg-warning);
  color: var(--msg-warning-ink);
  --app-message-edge: var(--msg-warning-edge);
}

/* A link inside a message (only Billing has one) takes the message's own ink:
   the brand purple on a saturated fill is neither on-brand nor legible. */
.app-message a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* Reduced motion: the message appears and disappears, it just does not travel.
   The script honours the same preference by removing the node outright rather
   than waiting for an exit that will not run.

   The countdown runner keeps working, because it is information rather than
   decoration: how long you have left to read this is the same fact either way.
   It is the script that softens it, moving the runner in ten visible steps
   instead of sliding it every frame, so there is nothing continuously animating
   on the page. */
@media (prefers-reduced-motion: reduce) {
  .app-message {
    animation: none;
  }

  .app-message-out {
    transition: none;
  }
}

/* On the auth pages the bar is not a page-level flash but the reason a sign-in
   or a reset was refused, so it stays in the working column beside the form it
   is about (src/views/auth-shell.tsx). Only its spacing differs. */
.auth-main .app-messages {
  margin: 0 0 18px;
  /* And it does not stick (task #602). The sticky bar answers a problem these
     pages do not have: an auth page is one short card with nothing to scroll
     past, and its message is the reason the form directly beneath it was
     refused rather than a page-level flash. Pinning it to the window would let
     it drift away from the field it explains, which is the opposite of what
     #543 put it there for. */
  position: static;
}

/* --- forms --- */
.stack-form {
  display: flex;
  flex-direction: column;
  gap: 16px;
  text-align: left;
}

.stack-form label {
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-weight: 600;
}

/* THE source for how an editable control looks and how tall it is (task #501).
   One rule, one set of tokens, and every field a signed-in person types into
   reads it: the Account and Program-create forms (.stack-form), the scanner's
   and the landing page's inline rows (.row-form), and the Reports filter bar
   (.report-field). Those were three separate copies of the same four
   declarations before this task, and the Reports bar had none of them at all,
   which is how it came to be a 40px unstyled native control sitting beside a
   48.8px Filter button on the same row.

   The vertical padding is deliberately zero and the height comes from the token
   instead. A single-line input and a select both centre their own text in the
   content box, so ONE number gives every control the same border-box height
   whatever the browser's own metrics for that control happen to be, which is
   what the padding-plus-line-height arithmetic could never promise.

   Nothing here resets `appearance`, so a select still draws its own indicator
   and a date field its own picker button, both inside a box that is now taller
   than they need rather than tighter (nothing is clipped, on any engine).

   Two deliberate overrides follow elsewhere in this sheet: the Reports bar
   takes 10px of inline padding instead (task #496), and the auth shell squares
   the corners (task #442). The Program settings page keeps its own separate
   rule: that surface is square by design. */
.stack-form input[type='text'],
.stack-form input[type='email'],
.stack-form input[type='password'],
.stack-form input[type='number'],
.stack-form input[type='url'],
/* The telephone type joined the list with task #936 (Sam). Account Details'
   Business phone number (task #850) is type="tel" so a phone offers its number
   pad, and the sign-up page's phone box (task #805) is the same control. Neither
   was named here, so each was the browser's own box, 24px tall with a 2px inset
   grey border and 13px text, standing between a 48px email field and a 48px
   website field: precisely the mismatch this rule exists to end. The support contact form named its own tel field from the
   start (below); this closes the gap on the signed-in form and the sign-up
   form with one selector rather than a copy on each. The ring and the error
   colour already reached it, because those two rules name `input` by element. */
.stack-form input[type='tel'],
/* Date and time joined the list with trading Locations (task #745): the roaming
   schedule is built from a day, a start and an end, and a native 40px date box
   beside a 48px name field is precisely the mismatch this rule exists to end.
   Nothing else in the app asked for either type before, so this adds two
   controls rather than changing any. */
.stack-form input[type='date'],
.stack-form input[type='time'],
/* Selects joined this rule with the Business Type field (task #525). It also
   picks up the invite form's Role select on Account > Users, which was the
   last unstyled 40px native control sitting beside a 48px text input in this
   app: exactly the mismatch this rule was written to end, so it is a deliberate
   inclusion rather than a side effect. The per-member role control is
   .inline-form and is untouched. */
.stack-form select,
.row-form input,
/* The scanner's location switcher (task #745), which is a select on a .row-form
   line beside a button. Every other member of this list is here because a
   40px native control beside a 48px one is the mismatch the rule ends, and this
   is exactly that. */
.row-form select,
.report-field input,
.report-field select,
/* Reports > Overview's two conditional selects (task #807). Named here rather
   than nested for the reason the list keeps learning: the Overview is not a
   `.report-field` filter row, and a select that dressed itself only because of
   where it happened to sit is a control one refactor away from losing its
   height, its border and its focus ring in silence. */
.ovw-field select,
/* The stamp quantity on a customer card's page (task #815). A number somebody
   types into, standing in a row beside the button that acts on it, which is the
   exact shape this rule exists for: without naming itself here it would be the
   40px-against-48px mismatch beside a 48px Award stamps button, on the one
   control in the web admin that changes a customer's balance. */
.cust-step-input,
/* The trading week's controls on Account > Locations (tasks #786 and #816): a
   location select, two time boxes and the notice's message, standing in a row
   together on a pale band. They are not in a `.stack-form`, so without naming
   themselves here they would be the 40px-against-48px mismatch this rule was
   written to end. Named rather than nested, which is the lesson `.locmap-query`
   below cost us. THE TICK BOX IS EXCLUDED, and that exclusion is the whole
   reason this is two selectors rather than `.sched-cell input`: a checkbox given
   a 48px height and a full-width border stops looking like a checkbox. */
.sched-cell select,
.sched-cell input:not([type='checkbox']),
/* The Locations map search (task #778 follow-up, Sam: "the map search input box
   is not styled"). It USED to be in this rule without naming itself: until #778
   the map lived inside `<form class="stack-form">`, so `.stack-form
   input[type='text']` reached it. Moving the map out of the settings grid moved
   it out of that selector too, and the box silently lost its height, its border,
   its radius and its focus ring while looking like nothing had happened. It is
   named here now, so the styling no longer depends on where in the page the
   control happens to sit. */
.locmap-query,
/* The public card designer's typed controls (task #572). That page is not a
   form (it has nothing to submit and no <form> element at all), but a business
   name field and two selects are still fields somebody types into, and an
   unstyled 40px native select beside a 48px text input is precisely the
   mismatch this rule exists to end. So they join the list rather than carrying
   a fourth copy of these six declarations.

   A CLASS AND NOT `input[type='text']`, which is what it was before the page
   took the shared colour well: `.cc-editor input[type='text']` also matches the
   well's hex field, at a higher specificity than `.colour-well-value`, so it
   would have dressed a 200px swatch-you-type-into as an ordinary 48px box. The
   class is opted into by the two fields that want it and reaches nothing
   inside a component. */
/* The public support section's contact form (task #603). It joins the list for
   the reason the list exists: an email field on /support and an email field on
   /account should be the same 48px box with the same border and the same
   radius, and the way to guarantee that is to have one rule rather than two
   that agree today. The textarea takes its own height below; everything else
   about it is this. */
.support-contact-form input[type='email'],
.support-contact-form input[type='tel'],
.support-contact-form input[type='text'],
.support-contact-form textarea,
/* Account Details' postal address (task #850), the first textarea a signed-in
   form has ever carried. It joins the list for the list's own reason: an
   address box standing directly under a website box should be the same border,
   the same radius and the same background, and one rule is how that stays true.
   Its height and its block padding are its own, just below. */
.stack-form textarea,
.cc-editor .cc-text,
.cc-editor select {
  min-height: var(--control-height);
  padding: 0 var(--control-pad-x);
  border: 1px solid var(--line);
  border-radius: var(--control-radius);
  font: inherit;
  background: var(--card);
  color: var(--ink);
}

/* Account Details' postal address (task #850): the one control on a signed-in
   form that is not a line. --control-height is the floor it inherits from the
   shared rule above; these four are what a textarea gets wrong without them,
   and they are `.support-field textarea`'s four for its reasons: it opens one
   row tall, it can be dragged wider than its own column, its text sits tight
   against the top border because the shared rule's zero block padding was
   written for something 48px tall, and it does not inherit the 600 weight of
   the label above it (nor should the typed address look like a heading).

   A CLASS OF ITS OWN RATHER THAN `.stack-form textarea` AGAIN, which is the
   shape `.support-contact-form textarea` and `.support-field textarea` already
   take. The shared rule above owns appearance and the height FLOOR for every
   consumer, and test/form-controls.test.ts enforces that no consumer redeclares
   either: a second `.stack-form textarea` block would be exactly the stale
   copy that guard exists to prevent. Height that is genuinely this control's
   own belongs on a selector that is this control's own. */
.stack-textarea {
  min-height: 7rem;
  padding: 10px var(--control-pad-x);
  line-height: 1.55;
  font-weight: 400;
  resize: vertical;
}

/* The field that is wrong, marked on the control as well as in the message bar
   above it (task #850): colour alone is not a signal everyone receives. The
   sentence itself lives in the page's one message bar, which is where every
   outcome on these pages goes since task #543, and each bad control points at
   its own message with aria-describedby.

   THE ELEMENT NAMES ARE LOAD-BEARING, exactly as they are for `.support-field`
   below: `.stack-form [aria-invalid='true']` ties the shared rule on classes
   and then loses on the element in `input[type='text']`, so only the textarea
   and the select would have turned red. */
.stack-form input[aria-invalid='true'],
.stack-form select[aria-invalid='true'],
.stack-form textarea[aria-invalid='true'] {
  border-color: var(--error);
}

/* ---------------------------------------------------------------------------
   The Locations map picker (task #747, restructured by Sam's third review, and
   moved out of the settings grid by #778).

   A centred search row and a map, both the full width of the panel they sit in.
   It replaced three address fields that nothing read: a wallet is given latitude
   and longitude and nothing else, so the honest control for "where is this" is
   the one that produces those.

   WHAT THIS STYLESHEET OWNS AND WHAT THE SCRIPT OWNS. Everything static is
   here: the frame, the shapes, the colours, where the pin's tip is relative to
   its box. /static/location-map.js sets exactly three kinds of inline style, all
   of them positions that change as the map moves: a tile's transform and size,
   and the pin's left/top. Nothing about how any of it LOOKS is decided in
   JavaScript, so the control can be restyled here without opening that file.

   THE COLUMN, top to bottom: the search row, the search's own status line, the
   results, then the map frame. */

/* THE FULL-BLEED ARITHMETIC IS GONE (task #778), and its absence is the point.

   Until #778 this control sat in the SECOND track of `.settings-section`'s
   2fr/3fr grid and escaped it with a negative margin derived from those
   proportions, which a test had to hold in step with the grid's own numbers so
   the map could not quietly start hanging off one side of the card. Sam's
   restructured add/edit page puts the map in a plain full-width panel ABOVE the
   settings block instead, so there is no column to escape: the map is as wide as
   what contains it, and there is one fewer pair of numbers to keep agreed.

   The top rule went with the margins. It read as a divider between the form and
   the map, and the map is no longer below the form. */
.locmap {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin: 4px 0 0;
  width: 100%;
}

/* THE SEARCH ROW: label inline to the left of the box, and the whole row
   centred over the map (Sam's third review, point 8). Centred rather than
   left-aligned because the map underneath is now the full width of the card,
   and a search row pinned to its left edge reads as belonging to the column it
   came from rather than to the map it drives. */
.locmap-search {
  display: flex;
  flex-direction: column;
  gap: 8px;
  align-items: center;
}

.locmap-search-row {
  display: flex;
  align-items: center;
  gap: 10px;
  /* Wide enough to type an address into, capped so the row stays a row rather
     than becoming a stripe across a 1120px card. */
  width: min(100%, 620px);
}

/* Inline, and NOT the stacked label the rest of the form uses: `.stack-form
   label` is a flex column, which would put "Map search" above the box. This is
   a plain inline label beside its input, so it needs `flex: none` or the flex
   row would shrink the words before it shrank the box. */
.locmap-search-label {
  flex: none;
  font-weight: 600;
}

.locmap-query {
  flex: 1 1 auto;
  min-width: 0;
}

/* `flex: none` so a long placeholder can never squeeze "Search" onto two lines,
   which is the failure this arrangement has on a narrow phone if the button is
   allowed to shrink.

   THE LOOK IS `.btn-ghost` (task #757, Sam), applied in the markup rather than
   here: the filled brand button is the one that saves the location, and having a
   second solid purple button halfway up the form competing with it made the
   search read as the thing to press last. Outlined says "helper" without
   inventing a third button style. */
.locmap-go {
  flex: none;
}

/* The search's own commentary: searching, how many were found, that nothing
   was, or the refusal when somebody tries to save with no pin down. Centred with
   the row it belongs to. */
.locmap-note {
  margin: 0;
  font-size: 14px;
  color: var(--muted);
  text-align: center;
}

.locmap-noscript {
  /* Shown only by the <noscript> rule the view renders. With scripting on this
     paragraph never exists as far as the page is concerned. */
  display: none;
  margin: 0;
}

/* The results. A list of buttons rather than a dropdown: each answer is two
   lines, a name and where in the world it is, and a native select cannot show
   the second. Held to the width of the search row above it so the answers line
   up under the question. */
.locmap-results {
  list-style: none;
  margin: 0;
  padding: 0;
  width: min(100%, 620px);
  border: 1px solid var(--line);
  overflow: hidden;
  background: var(--card);
}

.locmap-results > li + li {
  border-top: 1px solid var(--line);
}

.locmap-result {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 100%;
  padding: 10px 12px;
  border: 0;
  background: transparent;
  font: inherit;
  text-align: left;
  cursor: pointer;
  color: var(--ink);
}

.locmap-result:hover,
.locmap-result:focus-visible {
  background: var(--bg);
}

.locmap-result-name {
  font-weight: 600;
}

.locmap-result-detail {
  font-size: 13px;
  color: var(--muted);
  /* Two lines at most: a Nominatim display name can run to eight commas, and
     the point of the second line is to tell two answers apart, not to be read
     end to end. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* SQUARE CORNERS (task #757, Sam). The frame is a hairline round the map and
   nothing else: rounding it clipped the tiles at four corners for no gain, and
   at the full width of the card it read as a card inside a card. The `overflow`
   stays, because it is what keeps a tile that overhangs the edge mid-pan from
   painting over the page. */
.locmap-frame {
  border: 1px solid var(--line);
  overflow: hidden;
}

/* THE MAP ITSELF.

   `touch-action: none` is the one line here that costs something, and it is
   unavoidable: without it a finger dragged across the map scrolls the page
   instead, and the map cannot be panned on a phone at all. The price is that
   the map is not a place you can scroll the page from, which is why it is a
   fixed height rather than a full-height panel: there is always page above and
   below it to scroll by.

   It is taller than it was, because it is now wider than it was: at the full
   width of the card a 300px map is a letterbox.

   The background is the colour OpenStreetMap's own land tiles are, so a tile
   still in flight reads as map that has not arrived rather than as a hole. */
.locmap-viewport {
  position: relative;
  height: 380px;
  background: #e8e0d8;
  overflow: hidden;
  touch-action: none;
  cursor: grab;
  user-select: none;
}

.locmap-viewport.is-dragging {
  cursor: grabbing;
}

.locmap-tiles {
  position: absolute;
  inset: 0;
}

.locmap-tile {
  position: absolute;
  top: 0;
  left: 0;
  /* The script positions every tile with a transform from this origin, so the
     browser is moving layers rather than reflowing a stack of images. */
  transform-origin: 0 0;
  pointer-events: none;
  -webkit-user-drag: none;
}

/* A tile from the zoom level we have just left, held underneath the new one for
   half a second so a zoom is a picture growing rather than a white flash. */
.locmap-tile-stale {
  z-index: 0;
}

/* THE PIN. `left`/`top` are the point on the map; the transform is what makes
   the tip of the pin, rather than the middle of its box, sit on that point. Get
   this wrong and every location in the product is out by half a pin. */
.locmap-pin {
  position: absolute;
  z-index: 2;
  transform: translate(-50%, -100%);
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--brand-fixed);
  line-height: 0;
  cursor: grab;
  /* Same reason as the viewport: a finger on the pin must move the pin. */
  touch-action: none;
  /* NO shadow filter, and that is a rule about the whole sheet rather than a
     taste about this pin (test/programs-half-frame.test.ts asserts the sheet
     declares no such filter anywhere). A filter here would also be a filter on
     the one element in this control that is repositioned on every frame of a
     drag, which is exactly what that rule exists to prevent. The pin reads
     against the tiles on its own: LoyalPack purple is darker than anything
     OpenStreetMap's standard style paints a street, and the white centre gives
     it its shape. */
}

.locmap-pin.is-dragging {
  cursor: grabbing;
}

.locmap-pin:focus-visible {
  outline: 3px solid var(--brand-fixed);
  outline-offset: 3px;
  border-radius: 4px;
}

.locmap-zoom {
  position: absolute;
  z-index: 3;
  top: 10px;
  right: 10px;
  display: flex;
  flex-direction: column;
  gap: 1px;
  overflow: hidden;
  box-shadow: 0 1px 4px rgb(0 0 0 / 0.3);
}

/* 34px square: comfortably tappable without covering the map it is sitting on.
   The glyphs are set in a heavier weight because a bare + at body weight on a
   34px button reads as a decoration rather than a control. */
.locmap-zoom-btn {
  width: 34px;
  height: 34px;
  border: 0;
  background: var(--card);
  color: var(--ink);
  font-size: 20px;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
}

.locmap-zoom-btn:hover {
  background: var(--bg);
}

/* Attribution. Required by the tile usage policy, so it is rendered by the
   server and cannot be lost with the script. Bottom right, on a translucent
   plate, exactly where every map on the web puts it. */
.locmap-credit {
  position: absolute;
  z-index: 3;
  right: 0;
  bottom: 0;
  margin: 0;
  padding: 2px 6px;
  background: rgb(255 255 255 / 0.8);
  border-top-left-radius: 6px;
  font-size: 11px;
  color: var(--muted);
}

.locmap-credit a {
  color: inherit;
}

/* ---------------------------------------------------------------------------
   The Locations pages (task #778, Sam).

   ONE FULL-WIDTH PANEL PER PAGE, and that is the whole restructure. The list
   used to be the right-hand 60% of a settings block, which put a four-column
   table in a letterbox with white space either side of it on a 1120px page; the
   heading and its introduction sat in the other 40%, describing a table nobody
   could see beside them. `.loc-panel` is the plain square panel the Program
   surfaces already use for their lists, and its children run the full measure.

   It is deliberately NOT `.settings-section`. That block exists for editable
   settings and splits itself into a title column and a fields column; a list is
   neither, and the add/edit page still uses a real settings block below the map
   for the part that genuinely is one (Location Details).

   Square-cornered and shadowless, like every other `prog-`/settings surface. */
.loc-panel {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 30px;
  margin: 0 0 24px;
}

/* Matched to `.settings-title`/`.settings-lede` on purpose: the list page and
   the add/edit page's settings block sit one above the other in the same
   column, so their headings have to be the same size or the page reads as two
   designs. */
.loc-panel-title {
  margin: 0 0 10px;
  font-size: 1.3rem;
  line-height: 1.25;
}

/* NO MEASURE ON IT, which is Sam's ask stated plainly: the heading and the
   introduction SPAN THE FULL WIDTH. A `max-width` here would put the sentence
   back in a column of roughly the width it just escaped, which reads on a
   screenshot as the restructure not having happened. Both lines are short
   enough that this is one or two lines on a desktop rather than a stripe.

   The empty state is the exception, and it has its own measure below: centred
   prose across 1060px is unreadable however good the sentence is. */
.loc-panel-lede {
  margin: 0 0 18px;
  color: var(--muted);
  font-size: 0.9rem;
}

/* --- the three points (task #786) -----------------------------------------

   WHAT REPLACED THE PARAGRAPH. The page used to open with one sentence carrying
   three clauses in it (reports, wallets, and a caveat about pins), which is a
   paragraph doing a feature list's job. These are three short lines with an icon
   each, on the pale page tint, which reads as the public site's own blocks
   rather than as admin small print.

   auto-fit rather than auto-fill, and a 240px floor: three across a desktop, two
   across a tablet and one down a phone, with no breakpoint of their own. */
.loc-points {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 16px;
  list-style: none;
  margin: 0 0 26px;
  padding: 0;
}

/* A PLAINLY VISIBLE BLOCK, NOT AN ALMOST-WHITE ONE (Sam, task #873: "make the
   background slightly more contrast against the white"). These sit on
   `.loc-panel`, which is --card, and --bg over white measures 1.08:1 - a step
   so small the blocks read as three paragraphs rather than as three cards. This
   is the LoyalPack purple at 12% over that same white, so the fill is the brand
   hue the disc beside it is drawn in rather than a second grey, and it lands at
   1.22:1: about halfway to --strip (1.41:1 on white), which this stylesheet
   already calls a plain visible step over a large area. Slightly, as asked, and
   not the header strip's band.

   Everything on it keeps its headroom: --muted 5.31:1 and --brand-fixed 6.45:1,
   both well clear of AA at normal size. */
.loc-point {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  padding: 16px 18px;
  background: color-mix(in srgb, var(--brand-fixed) 12%, var(--card));
}

/* WHITE IN A PURPLE CIRCLE (Sam, task #873), which is exactly what the
   dashboard's quick links already wear - see .dash-link-icon for the long form
   of the reasoning. The wrapper carries the size, the disc and the colour
   because the Font Awesome kit replaces the <i> with an <svg> at runtime and
   would take a hook on that element with it; the kit sizes its SVG in `em`, so
   the one font-size here settles both the icon font's drawing and the kit's.
   `line-height: 1` stops the icon font's taller line box pushing the drawing
   off centre, and the inline-flex centring holds it there for either element.

   A fixed width AND height rather than padding, so all three are the same
   circle whatever their drawing's natural width, and `flex: none` keeps the
   disc out of the shrinking the text track does as the grid narrows. Smaller
   than the dashboard's 52px because these blocks carry one sentence rather than
   a heading and a line, and the disc must not out-measure the text beside it.

   The purple is --brand-fixed, not --brand: this is the product's own chrome on
   the product's own admin page, so a merchant's brand colour does not repaint it
   (the same rule the app header follows). White on it measures 7.85:1. */
.loc-point-badge {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--brand-fixed);
  color: var(--card);
  /* Up from the 1.15rem the bare icon carried (Sam: "a bit larger"). */
  font-size: 1.3rem;
  line-height: 1;
}

.loc-point-text {
  color: var(--muted);
  font-size: 0.92rem;
  line-height: 1.5;
}

/* --- the grid of places (task #786) ---------------------------------------

   ONE SECTION PER TYPE, Static and Roaming, each with its heading on the left of
   a line and its own Add location button on the right of it. That pairing is what
   let the Type column go: a heading says the word once for a group instead of
   repeating it down a column. */
.loc-group {
  margin: 0 0 30px;
}

.loc-group-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px 16px;
  margin: 0 0 14px;
}

.loc-group-title {
  margin: 0;
  font-size: 1.1rem;
  line-height: 1.25;
}

/* A floor of 280px and no ceiling: three cards across the 1060px measure, two on
   a tablet, one on a phone, and a merchant with eleven stalls gets four rows of
   three rather than a column of eleven. */
.loc-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 20px;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Square-cornered and shadowless like every other surface in the admin. The
   card is a column so the map is flush to three of its edges and only the body
   is padded. */
.loc-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line);
  background: var(--card);
}

/* THE MAP. Fixed height and clipped, with the tiles positioned from its CENTRE
   by the inline offsets src/static-map.ts works out, so the place is exactly in
   the middle whatever width the column happens to be. The background is the
   land colour OpenStreetMap's own tiles use, so a tile that has not arrived yet
   is a plain map-coloured rectangle rather than a white hole. */
.loc-card-map {
  position: relative;
  height: 160px;
  overflow: hidden;
  background: #e8e0d8;
}

.loc-card-tile {
  position: absolute;
  width: 256px;
  height: 256px;
  /* The app's images are held to the width of what contains them, which for a
     tile would be the wrong picture at the wrong scale. */
  max-width: none;
}

/* The pin sits at the centre of the box, and the translate puts its TIP on that
   point rather than its middle: a pin whose centre is the place points at
   somewhere half a pin north of it. */
.loc-card-pin {
  position: absolute;
  z-index: 2;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -100%);
  color: var(--brand-fixed);
  line-height: 0;
}

/* A place saved before a pin was compulsory (task #747). It says so rather than
   drawing the middle of the sea. */
.loc-card-map-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg);
}

.loc-card-map-note {
  color: var(--muted);
  font-size: 0.85rem;
}

.loc-card-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px 16px 16px;
}

/* A location name is merchant text and can be long. It wraps rather than
   widening the grid column it is in. */
.loc-card-name {
  margin: 0;
  font-size: 1rem;
  line-height: 1.3;
  overflow-wrap: anywhere;
}

/* One line under the grid, for all of the thumbnails at once: OpenStreetMap's
   tiles are offered on the condition that they are credited where they are
   shown, and twelve copies of the same credit is noise rather than attribution.
   The picker on the add/edit page carries its own, inside its frame. */
.loc-credit {
  margin: 4px 0 0;
  color: var(--muted);
  font-size: 0.8rem;
}

.loc-credit a {
  color: inherit;
}

/* Edit and Delete on one line. The delete is a form, which is a block, so
   without this the two actions stack in the card. */
.loc-row-actions {
  display: flex;
  align-items: center;
  gap: 14px;
}

.loc-row-actions form {
  margin: 0;
}

/* Delete is a link-shaped button, not a second `.btn`: it is one of two actions
   in a table cell, and a filled or outlined button per row would make a list of
   five shops look like a control panel. It opens the row's confirmation rather
   than deleting anything, so the restraint is honest as well as tidy.

   `.btn-link` draws it in `--ink`, which is right where it is the only action on
   a line; here it sits beside an Edit LINK, and two actions that do the same
   kind of thing should not be two colours. */
.loc-delete {
  color: var(--brand);
}

/* --- THE PRODUCT'S CONFIRMATION DIALOG (task #778 follow-up; shared since #857)

   A native <dialog>, opened with showModal(), so the browser owns the backdrop,
   the focus trap, Escape and the top layer. Everything here is the panel's own
   appearance: nothing positions it, because the top layer already has.

   TWO SURFACES WEAR IT, and they share these declarations rather than each
   keeping a near-copy: the Locations list's delete confirmation, one per row
   (src/views/locations.tsx explains why), and the Program Overview's disable
   confirmation (task #857). They are the same object - a short, destructive
   question with Cancel and one dangerous answer - so a change to the shape is
   one change and the two cannot drift. Anything that is genuinely one page's
   own stays in that page's own rules further down. */
.loc-confirm,
.prog-disable-confirm {
  border: none;
  border-radius: 0;
  padding: 26px;
  /* Wide enough for two sentences at a comfortable measure, capped so it never
     becomes a stripe on a desktop, and shrinking honestly on a 320px phone. */
  width: min(100% - 32px, 480px);
  background: var(--card);
  color: var(--ink);
}

.loc-confirm::backdrop,
.prog-disable-confirm::backdrop {
  /* Dark enough that the table behind reads as out of reach, light enough that
     it is obviously still there and one Escape away. */
  background: rgb(17 17 17 / 0.45);
}

.loc-confirm-title {
  margin: 0 0 12px;
  font-size: 1.2rem;
  line-height: 1.25;
  /* A location name is merchant text and can be long. It wraps rather than
     stretching the dialog past the cap above. */
  overflow-wrap: anywhere;
}

.loc-confirm-body,
.prog-disable-body {
  margin: 0 0 12px;
  color: var(--muted);
  font-size: 0.95rem;
}

/* Cancel then the destructive answer, pushed to the right, which is the order
   the markup uses for a reason: Cancel is first in the source so the initial
   focus and Escape both land on the safe answer. */
.loc-confirm-actions,
.prog-disable-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 20px;
}

.loc-confirm-actions form,
.prog-disable-actions form {
  margin: 0;
}

/* --- stopping a running Loyalty Program (task #857, Sam) -------------------

   ONE RED LINK, LAST ON THE OVERVIEW, in place of the white "Program status"
   box that used to be third in the page's left-hand stack. The box is still
   what a DISABLED Program gets (that is where it is enabled again or deleted);
   an active one gets this, and the two paragraphs that were in the box are now
   the confirmation it opens.

   IT SITS IN THE CONTENT COLUMN like everything else on the page: no width of
   its own, no grid, nothing absolute. The margin above is the same 20px rhythm
   .prog-panel gives the boxes higher up, doubled, so the page visibly ends
   before its last line rather than trailing off. */
.prog-disable {
  margin: 40px 0 8px;
}

/* A <button> that reads as a link, because what it does is open a question
   rather than navigate (src/views/programs.tsx says why it is not an <a>).
   --error is the product's one red, 6.5:1 on the page background, and the same
   red every failure message and the sidebar's Logout already wear. */
.prog-disable-link {
  color: var(--error);
  font-size: 0.95rem;
}

.prog-disable-link:hover {
  /* No colour change: the red IS the meaning here, and a hover that lightened
     it would read as the control becoming less serious. The underline thickens
     instead. */
  text-decoration-thickness: 2px;
}

/* THE KEYBOARD GETS MORE THAN THE UNDERLINE, because the underline is already
   there at rest and so says nothing about focus. The offset keeps the ring off
   the glyphs. */
.prog-disable-link:focus-visible {
  outline: 2px solid var(--error);
  outline-offset: 3px;
}

/* A closed dialog is not on the page. Stated rather than left to the UA
   stylesheet: a browser that does not know <dialog> at all treats it as an
   ordinary inline element and would otherwise print the confirmation into the
   foot of the page unasked. It is also the hook both fallbacks below use. */
.prog-disable-confirm:not([open]) {
  display: none;
}

/* The cross in the corner. It is the same control as Cancel and needs no weight
   of its own: a big enough hit target, the page's muted ink, and the same focus
   ring the rest of the product uses. */
.prog-disable-close {
  float: right;
  margin: -12px -10px 0 8px;
  padding: 6px 10px;
  border: none;
  background: none;
  color: var(--muted);
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
}

.prog-disable-close:hover {
  color: var(--ink);
}

/* No modal in this browser (public/program-admin.js says when): the same block
   the <noscript> rule leaves, arrived at from script. */
.prog-disable-confirm.prog-disable-inline {
  position: static;
  width: auto;
  padding: 0;
  background: none;
}

/* --- the trading week (tasks #786 and #816) --------------------------------

   A TIMETABLE, NOT SEVEN LITTLE FORMS. Task #786 drew a day as a band that
   opened into three labelled fields, which was right while a day held one event.
   Task #816 lets a day hold several (a station morning and a high-street
   lunchtime is two events on one Tuesday), and four of those stacked as four
   labelled forms is a wall. So the fields line up in COLUMNS under one header
   row: Location, From, To, and the notice. Reading down a column is how somebody
   checks their week, and it is the whole reason for the geometry below.

   ONE GRID, DECLARED ONCE, USED THREE TIMES. `--sched-cols` is on the section
   and is read by the header row and by every event row of every day, so the
   header cannot drift out of line with what it heads. `--sched-name` is the
   day-name column, and the header's left cell is empty so its columns start
   where a day's do.

   SEVEN BANDS, one per weekday, in the page's own pale grey (Sam: "each day is a
   coloured light grey background row"), with the day's name to the left of its
   events and vertically centred against them. A day with nothing in it is one
   quiet line with a button on it, so an empty week is seven quiet lines rather
   than a wall of empty selects.

   NO PROSE ON THIS SURFACE (Sam, task #816). The lede and the four-sentence
   hint that used to sit under every message field are in the info dialogue
   (`.sched-info`), which is `.loc-confirm` wearing a second class so it inherits
   the panel the delete confirmations already use. */
.loc-sched {
  /* Wide enough for Wednesday at 600 weight without wrapping. */
  --sched-name: 104px;
  /* Location, From, To, the notice, and the cross that ends the row. The two
     time columns are FIXED because a native time input has a width of its own
     and a fr column would let one row's clock sit a few pixels from the next
     one's; the other two are fr so the table uses the measure it is given.
     124px RATHER THAN THE 108 A BRITISH CLOCK NEEDS: a time input is rendered by
     the browser in the READER's format, not the page's, so a phone or laptop set
     to a 12-hour clock draws "07:30 AM" plus a picker icon in the same box. At
     124 the M of the AM was still shaved.

     THE SWITCH COLUMN LEADS (task #915), and it is the narrowest fixed column
     on the row: the track is 46px and needs no room for words, because its
     heading names it once at the top of the table and its own label is only on
     the page when the columns stack. */
  --sched-cols: 46px minmax(0, 1.5fr) 132px 132px minmax(0, 2.1fr) 34px;
  --sched-gap: 10px;
}

/* --- an event's on/off switch (task #915) ---------------------------------

   THE ROW LEADS WITH IT, so reading down one column says which parts of a week
   are running without reading a single time. `.switch` is the product's one
   on/off control (src/views/toggle-switch.tsx): a real checkbox restyled, so
   the keyboard, the focus ring and the submission are the browser's. All this
   cell does is stop the shared component's 10px gap opening a hole where a
   label is not drawn. */
.sched-cell-switch {
  display: flex;
  align-items: center;
  min-width: 0;
}

.sched-switch {
  gap: 0 !important;
}

/* A CONTROL SOMEBODY MAY NOT MOVE LOOKS LIKE ONE (task #915). The trading week
   draws a dead switch and a dead cross on an event that is RUNNING; the row
   says why in words beside them, and these two rules are the part that is seen
   rather than read. Stated on the shared switch rather than on this page's copy
   of it, because a disabled switch should look disabled wherever one appears. */
.switch-control:disabled,
.sched-remove:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* A PARKED EVENT IS GREYED AND ITS CONTROLS ARE DISABLED, and neither of those
   is the only signal: the switch's own knob is at the left, which is what
   somebody who cannot tell the two shades apart reads (WCAG 1.4.1). The switch
   itself keeps full contrast, because it is the way back.

   THE CROSS IS EXCUSED WHEN IT IS DISABLED (task #915, review fix). An event
   that is parked AND running wears both classes, and the two rules have the same
   specificity, so the later one used to win and a locked cross was drawn at the
   parked shade rather than the disabled one. A control somebody may not move has
   one look on this page, whatever else is true of its row. */
.sched-event-off .sched-cell:not(.sched-cell-switch),
.sched-event-off .sched-remove:not(:disabled) {
  opacity: 0.55;
}

/* WHY THE SWITCH AND THE CROSS ARE DEAD ON A RUNNING EVENT, in words, under the
   row they belong to. Both controls name it in aria-describedby and wear it as
   a title, so it is read whether somebody is looking, listening or hovering.
   `1 / -1` spans whatever the row's columns currently are, so it does not have
   to be kept in step with --sched-cols. */
.sched-event-note {
  grid-column: 1 / -1;
  margin: 0;
  color: var(--muted);
  font-size: 0.85rem;
}

.loc-sched form {
  margin: 0;
}

/* The heading and the "i" on one line. The button is the only route to the
   guidance now that none of it is on the page, so it sits against the title
   rather than at the foot of the section. */
.sched-heading {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 16px;
}

.sched-heading .loc-panel-title {
  margin: 0;
}

.sched-info-open {
  padding: 2px 4px;
  border: 0;
  background: none;
  color: var(--muted);
  font-size: 1.05rem;
  line-height: 1;
  cursor: pointer;
}

.sched-info-open:hover {
  color: var(--brand);
}

/* THE "i" BESIDE "Try to tell customers" (task #897, Sam). One on the column
   heading of each timetable on a wide screen, and one beside the label of each
   event on a phone, where the heading row is not drawn. Both are the section
   "i" again in every respect but what they open, so they take its rule above
   and only add the geometry: sized to sit against a heading's small type, and
   inline with the words rather than under them. */
.sched-columns-notice {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
}

.sched-notice-info {
  padding: 0 2px;
  font-size: 0.95rem;
}

/* Hidden on a wide screen, where the column heading carries the "i" once for
   the whole table; the phone rule at the foot of this block shows it. */
.sched-notice-info-row {
  display: none;
}

/* The tick box and, on a phone, its "i" on one line. */
.sched-notify-line {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex: none;
}

/* A browser with no <dialog> has nothing for any "i" to open, and a button
   that silently does nothing is worse than no button. public/locations.js
   puts this class on the root, so rows minted after it ran are covered too. */
.no-dialog .sched-info-open {
  display: none;
}

/* THE COLUMN HEADER, once, above all seven bands. It carries the band's own
   horizontal padding plus its 1px border so its cells start exactly where a
   day's do; getting this wrong is the one thing that would make the table read
   as an accident. */
.sched-columns {
  display: grid;
  grid-template-columns: var(--sched-name) minmax(0, 1fr);
  gap: var(--sched-gap);
  padding: 0 15px 8px;
  color: var(--muted);
  font-size: 0.8rem;
}

.sched-columns-cells {
  display: grid;
  grid-template-columns: var(--sched-cols);
  gap: var(--sched-gap);
}

.sched-days {
  display: flex;
  flex-direction: column;
  gap: 8px;
  list-style: none;
  margin: 0 0 22px;
  padding: 0;
}

.sched-day {
  display: grid;
  grid-template-columns: var(--sched-name) minmax(0, 1fr);
  gap: var(--sched-gap);
  /* CENTRED rather than top-aligned, so the day's name reads as the label of
     the block beside it however many events that block holds, and so a day with
     nothing but its button on it needs no special case. */
  align-items: center;
  padding: 10px 14px;
  background: var(--bg);
  border: 1px solid var(--line);
}

.sched-day-name {
  font-weight: 600;
}

.sched-day-body {
  min-width: 0;
}

.sched-events {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* `:not([hidden])` IS LOAD-BEARING. An author `display` beats the UA
   stylesheet's `[hidden] { display: none }` whatever their specificity, so a
   bare `.sched-event { display: grid }` would paint every spare row on the page.
   The page's own <noscript> rule reveals them again with `!important`, which is
   how a browser with no scripting still gets a usable week. */
.sched-event:not([hidden]) {
  display: grid;
  grid-template-columns: var(--sched-cols);
  gap: var(--sched-gap);
  align-items: center;
}

.sched-cell {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}

/* EVERY FIELD KEEPS ITS LABEL, and on a wide screen that label is hidden rather
   than absent: the column header names it once for the whole table, and a
   `display: none` here would take the name out of the accessibility tree with
   it. The phone rule at the foot of this block puts them back on the page, where
   the columns stack and there is no header to lean on. */
.sched-cell-label {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* THE NOTICE IS ONE COLUMN HOLDING TWO CONTROLS: the tick box that agrees to it
   and the sentence it would say. They sit on one line because they are two
   halves of one decision, and splitting them under two headings would invite
   somebody to fill in the second and think they had done it. */
.sched-cell-notice {
  /* A GRID SINCE TASK #898, where it was a flex row: the box in the first
     column, the message filling the second, and the half-hour hint or the
     row's own refusal spanning both underneath. A flex row could not seat a
     third thing under the other two without wrapping, and wrapping put the
     message under the box on a desktop and the hint outside the card on a
     phone. The two controls read exactly as they did. */
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 8px;
}

/* THE HALF-HOUR RULE, UNDER THE BOX IT IS ABOUT (task #898). Two lines, at most
   one shown: the hint the script reveals while a ticked event is too short, in
   the shared .hint voice, and the refusal the server drew for this row, in the
   error colour and weight every other inline refusal in the product wears. Both
   span the cell so they read as a caption to the pair above rather than as a
   third control beside them. */
.sched-notify-hint,
.sched-row-error {
  grid-column: 1 / -1;
  min-width: 0;
  line-height: 1.5;
}

.sched-row-error {
  color: var(--error);
  font-weight: 600;
  font-size: 0.92rem;
}

.sched-notify-hint[hidden],
.sched-row-error[hidden] {
  display: none;
}

.sched-notify {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: none;
  cursor: pointer;
}

.sched-notify input {
  margin: 0;
  flex: none;
}

.sched-message {
  display: flex;
  flex-direction: column;
  gap: 4px;
  flex: 1 1 auto;
  min-width: 0;
}

.sched-message input {
  width: 100%;
}

/* The one thing in this section drawn as an icon: it removes a line from a form
   nobody has saved yet, so a worded button would read as more consequential than
   it is. */
.sched-remove {
  justify-self: end;
  padding: 4px 8px;
  border: 0;
  background: none;
  color: var(--muted);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
}

.sched-remove:hover {
  color: var(--ink);
}

.sched-day-actions {
  display: flex;
  align-items: center;
  min-height: 30px;
  margin: 0;
}

/* Only spaced away from the events when there is a VISIBLE event above it, which
   `:empty` cannot answer: every day carries a spare hidden row for Add event to
   open, so `.sched-events` is never empty of children. An empty day is the
   button alone, centred against its own day name. */
.sched-events:has(.sched-event:not([hidden])) + .sched-day-actions {
  margin-top: 8px;
}

.sched-actions {
  margin: 0;
}

/* The info dialogue's own copy. `.loc-confirm-body` is a single paragraph's
   worth of rules; this is four, and they need to be spaced from each other
   rather than from the buttons below. */
.sched-info-body {
  color: var(--muted);
  font-size: 0.95rem;
}

.sched-info-body p {
  margin: 0 0 12px;
}

.sched-info-body p:last-child {
  margin-bottom: 0;
}

/* ON A PHONE THE TABLE STOPS BEING A TABLE, because five columns in 360px is
   five things nobody can read. The header row goes, every row stacks, and each
   field's own label comes back on the page: that is the pair the visually
   hidden rule above exists for, and neither half works without the other.

   860px rather than the house 720px, and the number is argued: the narrowest
   this table reads at is a 108px clock beside a location select wide enough to
   hold "Diss Town Market", and below roughly 860 the select is squeezed under
   its own longest option. */
@media (max-width: 860px) {
  .sched-columns {
    display: none;
  }

  .sched-day {
    grid-template-columns: minmax(0, 1fr);
    gap: 8px;
    align-items: stretch;
    padding: 12px 14px;
  }

  .sched-events {
    gap: 0;
  }

  .sched-event:not([hidden]) {
    grid-template-columns: minmax(0, 1fr);
    gap: 10px;
    align-items: stretch;
    padding: 12px 0;
    border-top: 1px solid var(--line);
  }

  /* The first event sits straight under the day's name with no rule above it:
     the day name IS the heading of that group. */
  .sched-event:not([hidden]):first-child {
    border-top: 0;
    padding-top: 4px;
  }

  .sched-cell-label {
    position: static;
    width: auto;
    height: auto;
    padding: 0;
    margin: 0;
    overflow: visible;
    clip: auto;
    white-space: normal;
    color: var(--muted);
    font-size: 0.85rem;
  }

  .sched-cell-notice {
    grid-template-columns: minmax(0, 1fr);
    align-items: stretch;
    gap: 10px;
  }

  /* Worded rather than pushed to a corner: on a stacked row there is no
     right-hand end for a cross to mean anything at. */
  .sched-remove {
    justify-self: start;
    padding: 4px 0;
  }

  /* The switch gets its label back with every other field, and sits beside it
     rather than under it: it is one short word and a control, and stacking
     those would be a line of nothing (task #915). */
  .sched-cell-switch {
    justify-content: flex-start;
  }

  .sched-switch {
    gap: 10px !important;
  }

  /* The heading row is gone, so the "i" moves to sit beside each event's own
     "Try to tell customers" label, which is now on the page. */
  .sched-notice-info-row {
    display: inline-flex;
  }

}

/* --- the empty state ------------------------------------------------------

   CENTRED: a title, one paragraph and the button (Sam). An account with no
   locations is not in an error state, so this says what locations are for and
   offers the one thing to do next, rather than drawing an empty table and
   apologising under it. */
.loc-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: 30px 0 10px;
}

.loc-empty-title {
  margin: 0 0 12px;
  font-size: 1.15rem;
}

.loc-empty-lede {
  margin: 0 0 22px;
  color: var(--muted);
  /* The one place a measure is unavoidable: centred prose across 1060px is
     unreadable however good the sentence is. */
  max-width: 62ch;
}

.loc-empty-action {
  margin: 0;
}

/* --- the add/edit page ----------------------------------------------------

   ONE CARD, NOT TWO (Sam's review of #778). The map had a card and Location
   Details had another, so a page that is one continuous task (find the place,
   then say what it is called) was drawn as two unrelated blocks with a gap
   between them. The title and the intro moved OUT of the card at the same time:
   they introduce the page, not the box.

   The rules below are the whole of it. `.loc-form-panel` is an ordinary
   `.loc-panel`; what is new is stripping the SettingsSection inside it of its
   own card chrome, which is what would otherwise draw a white box inside a white
   box. The section stays a real SettingsSection so the 2fr/3fr column system is
   the shared one and cannot drift from the rest of the app; only its background,
   border, padding and trailing margin are given up, because the card around it
   is already providing all four.

   THE RULE THAT DID ALL OF THAT WAS NEVER APPLIED, until task #786. The comment
   above it had lost its opening `/*`, so a CSS parser read the prose as a
   selector, swallowed the block that followed it as that selector's body, and
   dropped both. What Sam saw is exactly what the stylesheet then said: an
   ordinary `.settings-section` inside the card, with its own 1px border all the
   way round and its own 30px of padding on both sides, drawn as a box inside a
   box. Two lessons, and the second is the one worth keeping: a rule can be
   present, correct and unreachable, and the test suite now checks that every
   comment terminator in this file closes a comment somebody actually opened. */
.loc-form-head {
  margin: 0 0 20px;
}

.loc-form-title {
  margin: 0 0 10px;
  font-size: 1.3rem;
  line-height: 1.25;
}

.loc-form-lede {
  margin: 0;
  color: var(--muted);
  font-size: 0.9rem;
}

/* NO BORDER, AND NO PADDING EITHER SIDE (Sam, task #786). The block is a
   settings section for its columns and for nothing else while it is in here: the
   card around it already draws the edge and already provides the 30px on both
   sides, so a border of its own is a line inside a box and padding of its own is
   the same air twice. The top and bottom values stay, because they are the gap
   between the map and the fields, which is this section's own spacing rather
   than the card's.

   `border: 0` on its own line rather than a `border-top` that used to run across
   the card: an earlier pass kept one edge as a rule between the map and the
   fields, and Sam's answer is that the section has no border at all. */
.loc-form-panel .settings-section {
  background: none;
  border: 0;
  padding: 26px 0 0;
  margin: 4px 0 0;
}

/* Back to your locations, under the settings block. A plain link rather than a
   Cancel button: nothing here is a wizard, and the browser's own back button
   does the same thing. */
.loc-back {
  margin: 0 0 24px;
}

/* At phone width the panel's padding is the same 30px the settings block keeps
   unconditionally (task #490), so nothing is overridden here; only the empty
   state's generous vertical air is worth trimming. */
@media (max-width: 540px) {
  .loc-empty {
    padding: 12px 0 4px;
  }
}

/* At phone width a 380px map is most of a screen, so it loses height. There is
   no full-bleed to give back any more (task #778): the map is a child of a
   full-width panel at every size. */
@media (max-width: 540px) {
  .locmap-viewport {
    height: 280px;
  }

  /* The label goes back above the box on a phone, where an inline label and a
     button leave the input about forty pixels wide. */
  .locmap-search-row {
    flex-wrap: wrap;
  }

  .locmap-search-label {
    width: 100%;
  }
}

/* One focus treatment for everything on a control line, the fields and the
   button that ends the row alike. The app's own controls used to fall back to
   whatever ring the browser draws, which matched nothing else on the page:
   every other focusable thing in the app (the nav, the tabs, the cards, the
   auth shell's own fields and buttons) already draws exactly this. The auth
   shell declares an identical outline, so nothing there changes.

   The 2px offset is what keeps the ring readable on the solid brand button:
   it is drawn clear of the fill, on the surface behind it, where LoyalPack
   purple is well past AA against both white and the pale page. */
.stack-form input:focus-visible,
.stack-form select:focus-visible,
.row-form input:focus-visible,
.row-form select:focus-visible,
.row-form .btn:focus-visible,
.report-field input:focus-visible,
.report-field select:focus-visible,
/* Reports > Overview (task #807): its selects, its tick boxes and the button
   that ends the row all take the app's one ring. */
.ovw-field select:focus-visible,
.ovw-metric input:focus-visible,
.ovw-actions .btn:focus-visible,
/* The trading week's controls (tasks #786 and #816): one ring, drawn once, on
   every field of the timetable, its tick boxes and its two icon buttons
   included. */
.sched-cell select:focus-visible,
.sched-cell input:focus-visible,
.sched-remove:focus-visible,
.sched-info-open:focus-visible,
.locmap-query:focus-visible,
.report-actions .btn:focus-visible,
.support-contact-form input:focus-visible,
.support-contact-form textarea:focus-visible,
.cc-editor .cc-text:focus-visible,
.cc-editor select:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* Buttons on a control line (task #501). A button that ends a row of fields
   rather than standing on its own belongs on the same line as they do: the
   Reports bar's Filter, and the scanner's (and the landing page's) Look up.
   They take the shared height and nothing else, so they are still buttons in
   every way that reads as one: the solid brand fill, the 2px border, the 600
   weight, the radius and the focus behaviour are all still .btn's own, and no
   other button in the app is touched by this.

   Zero block padding, so --control-height alone decides the height, exactly as
   it does for the fields beside them; inline-flex is what keeps the label
   centred in the taller box. */
.report-actions .btn,
/* Reports > Overview's "Update graph" (task #807), which ends its control
   column exactly as Filter ends the reports' filter row. */
.ovw-actions .btn,
.row-form .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--control-height);
  padding-block: 0;
}

/* The brand-colour swatch sits on a control line beside its own hex field, so
   it takes the shared height and roundness (task #501): it used to be 40px
   against a taller text input on the same row. Its width and its 2px inset stay
   its own, because a colour well is not a text field. */
.stack-form input[type='color'] {
  width: 64px;
  height: var(--control-height);
  padding: 2px;
  border: 1px solid var(--line);
  border-radius: var(--control-radius);
}

/* NO .color-row RULES HERE ANY MORE (task #555). That class was the shape every
   colour control in the admin had before task #530: a swatch beside a hex field,
   laid out per page. The last two markups carrying it, the first-run setup form
   above and the Card Design page's Colour Override, render the shared
   .colour-well now, which brings its own layout, its own 50px of colour and its
   own monospace value. A class with no markup is a class somebody re-finds and
   re-wires, so it went with them.

   .stack-form input[type='color'] just above stays, and is deliberately not
   deleted with them: it is the app-wide control rule the well has to OUTRANK on
   this very form, and test/card-design-colour-wells.test.ts asserts that it is a
   real rule in this sheet before asserting the well beats it. */

.stack-form input[type='file'] {
  font: inherit;
}

/* Geometry and frame only. What is painted BEHIND the picture is the shared
   transparency checkerboard further down (task #600), which every upload
   preview takes together, so this rule names no background of its own. */
.logo-preview {
  max-height: 64px;
  max-width: 200px;
  align-self: flex-start;
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 4px;
}

/* --- the shared image-upload control (task #470) --- */

/* A non-label field wrapper inside a stack form, for controls too rich to
   live inside one <label> (the enhanced upload has buttons of its own). */
.stack-form .stack-field {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.stack-form .field-label {
  font-weight: 600;
}

/* The script toggles [hidden] on blocks this sheet gives a display to, so
   hidden must win inside the control whatever else applies. */
.image-upload [hidden] {
  display: none !important;
}

.image-upload-plain {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.image-upload-ui {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.image-upload-drop {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 24px 16px;
  border: 2px dashed var(--line);
  border-radius: 12px;
  background: var(--bg);
  text-align: center;
}

.image-upload-drop[data-drag] {
  border-color: var(--brand);
  background: #eeeafa;
}

.image-upload-glyph {
  color: var(--muted);
}

.image-upload-drop[data-drag] .image-upload-glyph {
  color: var(--brand);
}

.image-upload-drop-copy {
  margin: 0;
  font-weight: 400;
  color: var(--muted);
}

.image-upload-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  justify-content: center;
}

.image-upload-preview {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}

/* Room for the x-mark that overhangs the thumbnail's top-right corner. */
.image-upload-thumb {
  position: relative;
  display: inline-flex;
  margin-top: 10px;
  /* 12, not 10, and the 2 is the point: the remove badge is offset -10px and
     carries a 2px white border, so it overhangs this box by exactly 12. The
     margin is what reserves that room INSIDE the parent. At 10 the badge's
     border sat two pixels outside, which nothing noticed until the control was
     put somewhere narrow enough for two pixels to open a scrollbar. */
  margin-right: 12px;
  /* THE 220px CAP LIVES ON THE WRAPPER, NOT ON THE PICTURE (task #530), and
     that one move is the whole fix for a badge that hung in mid-air.

     The caps have always been a pair: 220px is the design's, and the 100% is
     what stops a picture whose width comes from max-height and its own aspect
     ratio from being wider than the column it sits in. Both were on the img.
     The trouble is that this box is shrink-to-fit, so while the browser is
     working out how wide it should be, the `100%` inside `min(220px, 100%)` has
     nothing to resolve against; unable to evaluate the min() at all, Chrome
     drops the whole max-width from the picture's max-content contribution and
     sizes this box to the picture's NATURAL width instead. The img is then
     capped at 220 in a second pass, against a box already far too wide. The
     x-mark is positioned against THIS box, so it ends up stranded to the right
     of the picture by the difference.

     Measured in headless Chrome across every shape this control is given: a
     1200x300 wordmark logo in a 600px column drew a 342px box around a 220px
     picture, and a 2000x100 banner at desktop width drew 1356px around 220px,
     leaving the x-mark over a thousand pixels from the corner it belongs to and
     pushing Replace onto a line of its own. It only bites above about a 2.5:1
     aspect ratio (below that the 96px height cap binds first and the box hugged
     the picture correctly), which is why a square logo and a 1032x812 card
     background both looked perfect and a wide logo did not.

     Moving the cap here puts it on the box that is actually being sized, and
     leaves the picture with a plain `max-width: 100%` that resolves against a
     parent whose width is by then definite. Same two limits, same 220px, same
     shrink-below-220 behaviour in a narrow column; the box now hugs the picture
     exactly at every size, so the badge lands on its corner.

     min-width: 0 stays: a flex item will not shrink below its content without
     it, and this one is a flex item of .image-upload-preview. */
  max-width: min(220px, 100%);
  min-width: 0;
}

.image-upload-img {
  display: block;
  max-height: 96px;
  /* Never wider than the wrapper, which carries the real caps above. */
  max-width: 100%;
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 6px;
  /* The surface behind the picture is the shared checkerboard below. */
}

/* THE TRANSPARENCY CHECKERBOARD (task #600, Sam).

   A picture with transparent areas is drawn on whatever happens to be behind
   it, and behind a preview that was plain white. So a logo that is white
   artwork on a transparent background, which is a large share of the files
   merchants are given by their designer, arrived as an empty white box: the
   merchant cannot tell it apart from an upload that did not work. The checks
   are what every image editor draws to mean "nothing is here", and they answer
   that without touching one byte of what is stored, sent to R2, or rendered on
   a card or a wallet pass. This is a layer behind the preview, nothing more.

   ONE RULE FOR ALL THREE PREVIEW CLASSES. The enhanced block's picture is
   always .image-upload-img, but the plain no-JavaScript block's takes a
   per-caller class through `previewClass` (.logo-preview by default, and
   .design-asset-preview from the Card Design and create-card picture slots), so
   naming only the shared one would have left the fallback path unanswered. The
   three rules those classes own paint no background at all now; this is the
   only place an upload preview's surface is coloured.

   THE CHECKS STOP AT THE PICTURE. background-origin and background-clip of
   content-box keep them off the 4px to 6px pad, so the white matte frame these
   previews have always had is unchanged and the pattern never runs under the
   border. That is why the white is a LAYER of its own rather than only
   background-color: a background colour is clipped by the LAST layer's
   background-clip, which would otherwise be the picture's own box and would
   leave the pad transparent over whatever the page is.

   #e3e0ec is the neutral lavender-grey of --line lightened until it measures
   1.30:1 against white, where --line itself is 1.58:1. So white artwork reads
   plainly against it and the checks still sit quieter than the border drawn
   around them, which is the line between clear and noisy here. 8px squares mean
   even the shortest preview (the 64px account logo) shows eight rows of them
   and the pattern never flattens into one grey wash. */
.image-upload-img,
.logo-preview,
.design-asset-preview {
  --checker-square: 8px;
  --checker-tint: #e3e0ec;
  background-color: #fff;
  background-image: linear-gradient(
      45deg,
      var(--checker-tint) 25%,
      transparent 25%,
      transparent 75%,
      var(--checker-tint) 75%
    ),
    linear-gradient(
      45deg,
      var(--checker-tint) 25%,
      transparent 25%,
      transparent 75%,
      var(--checker-tint) 75%
    ),
    linear-gradient(#fff, #fff);
  background-position: 0 0, var(--checker-square) var(--checker-square), 0 0;
  background-size: calc(var(--checker-square) * 2) calc(var(--checker-square) * 2),
    calc(var(--checker-square) * 2) calc(var(--checker-square) * 2), auto;
  background-repeat: repeat, repeat, no-repeat;
  background-origin: content-box, content-box, padding-box;
  background-clip: content-box, content-box, padding-box;
}

/* Windows high contrast throws backgrounds and colours away and repaints from
   the system palette, but it keeps background IMAGES: the checks would survive
   as a pattern the system never chose, under artwork it has already repainted.
   Drop them and leave the plain surface, the same repair the switch above and
   task #583's charts needed. */
@media (forced-colors: active) {
  .image-upload-img,
  .logo-preview,
  .design-asset-preview {
    background-image: none;
  }
}

.image-upload-clear {
  position: absolute;
  top: -10px;
  right: -10px;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  border: 2px solid #fff;
  background: var(--ink);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  cursor: pointer;
}

.image-upload-clear:hover {
  background: var(--error);
}

/* A button will not shrink below the width of its own label, and inside the
   Card Design page these sit in a column that gets very narrow indeed: at a
   160px layout viewport (a 320px screen at 200% zoom) "Replace" is wider than
   the space its flex line has left, so it hung two pixels past the page and
   opened a scrollbar. Letting it shrink and letting its label wrap keeps it in
   the column; at every ordinary width there is room and nothing changes. */
.image-upload-preview .btn,
.image-upload-drop .btn {
  min-width: 0;
  max-width: 100%;
}

.image-upload-clear:focus-visible,
.image-upload .btn:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 2px;
}

.image-upload-status {
  margin: 0;
  font-weight: 500;
  font-size: 0.9rem;
  color: var(--strip-muted);
}

.image-upload-status:empty {
  display: none;
}

.image-upload-error {
  margin: 0;
  font-size: 0.9rem;
}

/* --- fitting a photograph to the slot (task #825 follow-up) -----------------

   The crop dialog the camera opens. It is a native <dialog>, so it is not on
   the page at all until showModal(), and this block styles nothing that exists
   before then.

   IT IS SIZED FOR A PHONE HELD IN ONE HAND, because that is the only device it
   opens on. The width is the Locations dialogs' rule (a gutter on a small
   screen, capped so it never becomes a stripe on a desktop that somehow reached
   it), and the stage below is capped by VIEWPORT HEIGHT rather than by a fixed
   pixel box: a 16:5 wide logo and a 1:1 brand icon are wildly different shapes
   in the same dialog, and only the shorter axis can be allowed to drive. */
.image-crop {
  border: none;
  border-radius: 0;
  padding: 20px;
  width: min(100% - 24px, 460px);
  background: var(--card);
  color: var(--ink);
}

.image-crop::backdrop {
  background: rgb(17 17 17 / 0.55);
}

.image-crop-title {
  margin: 0 0 8px;
  font-size: 1.15rem;
  font-weight: 600;
}

.image-crop-copy {
  margin: 0 0 14px;
  color: var(--muted);
  font-size: 0.92rem;
}

/* THE WINDOW. Its aspect ratio is written by the script from the slot's own
   target, so this rule must not name one: a square here would silently crop
   every wide logo to a square whatever the dialog said. */
.image-crop-stage {
  position: relative;
  overflow: hidden;
  width: 100%;
  max-height: 52svh;
  margin: 0 auto;
  background: #1a1a1a;
  /* The picture is dragged with a finger, so the browser must not also read
     that finger as a scroll or a page zoom. touch-action is the only reliable
     way to say so; preventDefault alone is ignored for scrolling on iOS. */
  touch-action: none;
  cursor: grab;
  /* Until the script measures and writes the real one, a square keeps the box
     from collapsing to nothing in the frame before paint. */
  aspect-ratio: 1 / 1;
}

.image-crop-stage:active {
  cursor: grabbing;
}

/* Positioned by transform from the script. `max-width: none` because the
   sheet's global image rule would otherwise cap a zoomed photo at the stage
   width and quietly undo every zoom past 1. */
.image-crop-img {
  position: absolute;
  top: 0;
  left: 0;
  max-width: none;
  transform-origin: 0 0;
  user-select: none;
  -webkit-user-drag: none;
  will-change: transform;
}

.image-crop-zoom {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 14px;
  font-size: 0.9rem;
  color: var(--muted);
}

.image-crop-zoom input[type='range'] {
  flex: 1;
  /* Comfortably bigger than the 24px minimum target, because it is dragged
     with a thumb while somebody else is watching. */
  height: 32px;
}

.image-crop-status {
  margin: 10px 0 0;
  min-height: 1.2em;
  font-size: 0.9rem;
  color: var(--muted);
}

.image-crop-status:empty {
  display: none;
}

/* Cancel, Retake, Use photo. Cancel is FIRST IN THE SOURCE so Escape and the
   initial focus both land on the answer that changes nothing, and it is pushed
   left so the destructive-looking gap sits between it and the commit. */
.image-crop-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 16px;
}

.image-crop-actions .btn {
  max-width: 100%;
}

/* --- the two logo slots, stacked (tasks #674, #903) ---------------------------

   Settings > Brand asks for a Brand Icon and a Wide Logo. Task #674 set them
   side by side in an auto-fit grid, each above a small drawing of the wallet
   surface it fills. Task #903 took the drawings out and stacked the slots:
   two full-width fields in the form's own column, Brand Icon first, so each
   drop zone gets the whole width on a laptop and nothing is left hanging under
   a shorter neighbour. There is no grid to declare any more; a slot is a plain
   `.stack-field` and inherits the stack form's rhythm. The class stays for the
   two rules below, which are about the slot's own label and notice. */
.logo-slot {
  min-width: 0;
}

/* THE BRAND ICON'S ONE WARNING (task #902): white artwork on a transparent
   background vanishes inside Google Wallet's white circle. A post-it in the
   support articles' aside colours, square-cornered like every other block on
   this page, sitting between the field's name and its drop zone so it is read
   before a file is chosen. Deliberately NOT the error treatment: the upload is
   valid, the artwork is a poor choice, and red would say the form is broken. */
.logo-slot-notice {
  margin: 0;
  padding: 10px 14px;
  background: var(--note);
  border-left: 3px solid var(--note-edge);
  border-radius: 0;
  font-size: 0.9rem;
  line-height: 1.55;
}

/* THE LABEL AND ITS "(optional)" ON ONE LINE. `.stack-form label` is a flex
   COLUMN, which would put the Wide Logo's "(optional)" on a row of its own
   under the field's name. Baseline alignment keeps the smaller hint sitting on
   the caption's own baseline. */
.logo-slot .field-label {
  flex-direction: row;
  align-items: baseline;
  gap: 6px;
}

.check {
  flex-direction: row !important;
  align-items: flex-start;
  gap: 10px !important;
  font-weight: 400 !important;
}

.check input {
  margin-top: 4px;
}

/* --- the on/off switch (task #582) -------------------------------------------

   THE CONTROL IS THE CHECKBOX ITSELF, restyled with `appearance: none`, not a
   span dressed up beside a hidden input. See src/views/toggle-switch.tsx for
   why that matters: the browser keeps the keyboard, the focus ring, the
   submission and the back-button restore, and none of it is ours to maintain.
   The consequence for this sheet is that the track is the input's own box and
   the knob is its ::before - there is no third element to position.

   IT IS NOT A FIELD CONSUMER. The one shared rule that declares
   min-height/border/border-radius for typed controls lists its consumers by
   selector (see test/form-controls.test.ts), and a switch is not one of them
   any more than a checkbox is: it has a fixed size of its own, in both axes,
   and inheriting a 48px control height would make it a lozenge.

   --brand-fixed AND NOT --brand, on the overlay slider's thumb's precedent
   (and task #583's): --brand is overridden per page with the merchant's own
   brand colour, and this control sits on the page where they CHOOSE that
   colour. A merchant who picks pale yellow would otherwise get a pale yellow
   track under a white knob, which is a switch nobody can read the state of.
   LoyalPack purple against white is 8.3:1 whatever they choose. */

.switch {
  display: flex;
  flex-direction: row !important;
  /* THE TRACK SITS ON THE FIRST LINE OF THE LABEL, not in the middle of it
     (task #854). Every switch this product drew until now carried a label that
     fitted on one line, where centring and top-aligning are the same picture:
     the track is 26px and a line of this text is 24.8px, so the difference is
     0.6px and nothing visibly moved on the card designers. The Offer tab's Date
     of birth label is a sentence, and it wraps to two or three lines on a phone,
     where centring floats the switch halfway down a paragraph with nothing
     beside it. Aligned to the start, the control is level with the words that
     name it however many lines follow. */
  align-items: flex-start;
  gap: 10px !important;
  font-weight: 400 !important;
  /* The words are part of the control (the label wraps the input), so the
     pointer should say so over all of it. */
  cursor: pointer;
  min-width: 0;
}

/* A wrapped label must wrap inside the row rather than push the track out of
   it: `min-width: 0` lets this flex item shrink past its longest word, and the
   line-height is stated so the arithmetic above is not at the mercy of whatever
   the surrounding form sets. */
.switch-label {
  min-width: 0;
  line-height: 1.55;
}

/* THE TRACK. Both states are a colour AND a knob position, never colour alone:
   off is a white track with a grey knob at the left, on is a purple track with
   a white knob at the right. Someone who cannot tell the two colours apart can
   still see which end the knob is at, which is WCAG 1.4.1 for a control whose
   whole meaning is its state. The 1px border is what gives the OFF track its
   own edge against the panel (--muted on --card is 6.4:1; the track's own fill
   would be 1.05:1 and effectively invisible). */
.switch-control {
  appearance: none;
  -webkit-appearance: none;
  flex: 0 0 auto;
  width: 44px;
  height: 26px;
  margin: 0;
  padding: 0;
  border: 1px solid var(--muted);
  border-radius: 999px;
  background: var(--card);
  position: relative;
  cursor: pointer;
  transition: background-color 160ms ease, border-color 160ms ease;
}

/* THE KNOB. Sized and placed from the track's own numbers: the track is 26px
   including its 1px border, so its content box is 24px, and a 20px knob with
   2px of air each side sits centred in it. Its travel is the same arithmetic
   from the other end: 44 - 2 - 20 - 2 - 2 = 18px. */
.switch-control::before {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--muted);
  transition: transform 160ms ease, background-color 160ms ease;
}

.switch-control:checked {
  background: var(--brand-fixed);
  border-color: var(--brand-fixed);
}

.switch-control:checked::before {
  background: var(--card);
  transform: translateX(18px);
}

/* The motion is decoration: the state is legible without it, and it is the knob
   sliding that some people cannot look at. Taken away rather than shortened. */
@media (prefers-reduced-motion: reduce) {
  .switch-control,
  .switch-control::before {
    transition: none;
  }
}

/* WINDOWS HIGH CONTRAST throws every background and colour away and paints from
   the system palette, which would leave the track and the knob the same colour
   and the switch unreadable in exactly the mode that exists to make things
   readable. The knob is repainted in the palette's own text colour and the
   checked track in its highlight, which is the same repair task #583's charts
   needed. */
@media (forced-colors: active) {
  .switch-control {
    border-color: ButtonText;
  }

  .switch-control::before {
    background: ButtonText;
  }

  .switch-control:checked {
    background: Highlight;
    border-color: Highlight;
  }

  .switch-control:checked::before {
    background: HighlightText;
  }
}

.hint {
  font-weight: 400;
  color: var(--muted);
  font-size: 0.85rem;
}

.row-form {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  justify-content: center;
}

/* Only how the field sits in the row. How it LOOKS and how tall it is come from
   the shared control rule above (task #501), which this used to repeat. */
.row-form input {
  flex: 1 1 200px;
}

/* --- the reading measure a lede holds ---------------------------------------

   Shared: the enrolment page's one paragraph and the public designer's title
   band both read it, and test/content-grid.test.ts pins the 620px. The landing
   page has its own, wider hero lede (.lp-lede below), because it sits in a
   two-column band rather than in the centred column. */
.lede {
  font-size: 1.15rem;
  color: var(--muted);
  max-width: 620px;
  margin: 0 auto 24px;
}

/* --- the public landing page (task #673) -----------------------------------

   THE FRONT PAGE, rebuilt out of what the app learned. Everything here is
   prefixed `lp-` and reaches nothing else, with two deliberate exceptions in
   the same direction the public designer takes: the card face is the product's
   own `.wallet-pass` tree inside the product's own phone frame, so the hero
   shows the real card rather than a drawing of one, and the buttons are the
   shared `.btn`. What this section adds is the bands they sit in.

   THE BANDS. Read down the page the sequence is ink, lilac, page, white, page,
   ink, page, white, page, white, purple, ink: a stack of full-width strips that
   meet with no gap, exactly the idiom /create-card uses (task #572). The bleed
   is the same calculated pattern and for the same reason: 50% is half the
   column and 50vw half the viewport, so the negative inline margin is the
   distance from column edge to viewport edge at every width and zoom, and no
   box is ever 100vw (which would overflow by a classic scrollbar's width;
   test/content-grid.test.ts holds .dash-focus as the only one in the sheet).
   The padding then puts the contents back on the column, so nothing inside a
   band has to know it is in one.

   COLOUR AND CONTRAST. Only the brand tokens: --ink, --brand-fixed, --strip,
   --bg, --card, white and alpha tints of white on the dark bands. --brand-fixed
   rather than --brand anywhere LoyalPack is speaking on its own behalf, because
   --brand is overridable per merchant elsewhere in the sheet. Measured against
   --ink (#15111b): white 18.9:1, rgba(255,255,255,0.82) about 13:1, and
   --brand-on-ink (the purple lifted for dark grounds) 6.06:1. On the purple ask
   band, white measures 7.8:1. Nothing on this page relies on a colour to carry
   meaning on its own.

   NO SHADOWS ANYWHERE, on purpose: the product's flat borders are the house
   style, and test/programs-half-frame.test.ts keeps a census of every non-inset
   box-shadow in this sheet. This section adds none to it.

   Mobile first: one column everywhere until the two breakpoints named below
   (720px for the small grids, 900px for the two-column bands), and nothing has
   a fixed pixel width, so there is no horizontal overflow at any size. */

/* NO VERTICAL PADDING ON THE COLUMN, so the hero starts under the nav rather
   than 30px below it and the bands stack edge to edge. Scoped by :has to this
   page's own root, exactly as the designer's equivalent is: `.container` is
   shared with every public surface and only these two are built out of bands. */
.container:has(> .lp-page) {
  padding-block: 0;
}

/* The scrollbar's own width, clipped on body rather than at the root: `50vw` is
   half the viewport INCLUDING a classic scrollbar, so the bands reach a few
   pixels past each visible edge on a desktop browser that draws one. That
   overhang is invisible but real, and it is exactly the horizontal scroll this
   page must not have. Body is the right element: it is the visible viewport
   wide, so the cut lands on the overhang and never on the bands themselves. */
body:has(.lp-page) {
  overflow-x: clip;
}

.lp-band {
  margin-inline: calc(50% - 50vw);
  padding-inline: calc(50vw - 50%);
}

/* --- the hero -------------------------------------------------------------- */

/* THE BAND IS A PHOTOGRAPH (task #955 pass 3, Sam). The picture is an <img>
   under the copy (src/landing-hero.ts), not a background-image: it carries its
   own alt, its own width and height so the band reserves nothing wrong while
   it loads, a srcset so a phone takes the small file, and fetchpriority so the
   first thing on the front page is the first thing fetched. The band itself
   stays ink, which is what a visitor sees for the instant before the picture
   lands and what they see if it never does.

   DESKTOP (from 900px): the picture fills the band behind the words, the
   calm of the scene on the left and the phone on the right, where the copy's
   34rem column never reaches. The band's height is a clamp on the viewport
   width so the crop keeps the phone whole at 1280 and 1920 alike;
   test/landing-hero.test.ts replays the `cover` arithmetic over the shipped
   pixels to hold that. NARROWER: the copy sits on plain ink and the picture
   follows it as a full-bleed square panel cropped onto the phone, so no word
   is ever drawn over the phone and the picture is still the real one.

   Nothing here animates in any state. */
.lp-hero {
  position: relative;
  isolation: isolate;
  background: var(--ink);
  color: #fff;
}

/* THE SCRIM, and the contrast promise it carries (task #955 pass 4, Sam: the
   first pass faded all the way to ink and the band merged into the ink
   header). NEVER MORE THAN HALF INK, at any stop: the photograph stays
   visible under every word, and the band reads as a picture under the header
   rather than as more of it. A gradient of the band's own ink is laid over
   the picture from the left at that ceiling, fading out towards the phone so
   the scene is intact where it matters. test/landing-hero.test.ts reads the
   stops out of this rule, holds every alpha at or under 0.5, and composites
   them over the shipped pixels under the copy column at 1280 and 1920: white
   LARGE text (the 2.6rem bold headline, the 19.2px bold lede, both past the
   14pt-bold line) must clear 3:1 at every sampled point. The gradient is
   paint, not information, so it is left in place under forced colours, where
   the text gets the platform's own backplate anyway. */
.lp-hero::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: linear-gradient(
    90deg,
    rgba(21, 17, 27, 0.5) 0%,
    rgba(21, 17, 27, 0.5) 48%,
    rgba(21, 17, 27, 0.3) 64%,
    rgba(21, 17, 27, 0.1) 80%,
    rgba(21, 17, 27, 0.02) 100%
  );
}

.lp-hero-photo {
  display: block;
  height: auto;
  object-fit: cover;
  /* Cropped onto the phone when the panel is narrower than the picture: the
     phone stands a little right of the scene's centre. */
  object-position: 70% 55%;
  /* The picture is a square on a phone, never a tower; a wider panel on a
     small tablet (below). NO max-height here: with an aspect ratio set, a
     clamped height transfers back into the width and the panel would stop
     bleeding, which is exactly what happened at 768px before this note. */
  aspect-ratio: 1 / 1;
  /* Full bleed inside the band: the band's own padding, taken back. */
  margin-inline: calc(50% - 50vw);
  width: auto;
}

@media (min-width: 600px) and (max-width: 899px) {
  .lp-hero-photo {
    aspect-ratio: 16 / 10;
  }
}

.lp-hero-copy {
  position: relative;
  z-index: 2;
  max-width: 34rem;
  padding-block: 44px 40px;
}

/* Both lines are LARGE text in WCAG's sense (bold, past 18.66px), which is
   what lets them sit on a half-ink scrim: see the scrim note. The shadow is a
   lift for the eye on the picture's lighter patches, never the contrast
   argument, which the test makes from the scrim alone. */
.lp-hero h1 {
  font-size: 2.6rem;
  line-height: 1.1;
  margin: 0 0 16px;
  text-shadow: 0 1px 3px rgba(21, 17, 27, 0.45);
}

/* The hero's own lede: wider than the shared .lede above, because it sits in
   its own column rather than in the centred one, and never centred. Bold at
   19.2px, so it is large text on the scrim (above). */
.lp-lede {
  margin: 0 0 24px;
  max-width: 34rem;
  font-size: 1.2rem;
  font-weight: 700;
  color: #fff;
  text-shadow: 0 1px 3px rgba(21, 17, 27, 0.45);
}

.lp-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.lp-actions-centre {
  justify-content: center;
}

/* The hero's one button (task #955 pass 2 took away the outlined second one
   beside it, and the ask's). On ink, the LoyalPack purple would be a 2.37:1
   block against the band, so the action is WHITE (18.9:1 against the ink it
   stands on, ink text at the same ratio on it). This is the same decision
   .cc-cta makes on the purple band. */
.lp-hero .lp-cta,
.lp-claim-cta {
  border-color: #fff;
  background: #fff;
  color: var(--ink);
}

.lp-hero .lp-cta:hover,
.lp-hero .lp-cta:focus-visible,
.lp-claim-cta:hover,
.lp-claim-cta:focus-visible {
  border-color: #fff;
  background: #fff;
  opacity: 0.92;
}

.lp-claim-cta {
  color: var(--brand-fixed);
}

/* The focus ring has to read against both the button and the dark band, so it
   is white with an offset that puts it on the band. */
.lp-hero .btn:focus-visible,
.lp-claim .btn:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}

/* The one action on a pale band that is not the primary one: the shared
   .btn-ghost treatment, named here so the plan card does not have to reach for
   a class from another surface. */
.lp-cta-outline {
  background: transparent;
  color: var(--brand-fixed);
  border-color: var(--brand-fixed);
}

.lp-fine {
  margin: 14px 0 0;
  font-size: 0.85rem;
  color: var(--muted);
}

/* --- the explainer video ------------------------------------------------------ */

/* THE BAND SHADES VERY SLIGHTLY TOWARDS ITS FOOT (task #955 pass 5, Sam: make
   it clear where the dividing line is). The section is the page tint and the
   six-callout band under it is white, so their meeting read as nothing; a
   vertical gradient across the whole band, the page tint at the top and the
   same tint carried a fifth of the way towards the hairline token at the
   bottom, puts a perceptible edge on the white below without a border and
   without a colour that is not already the page's own. Static paint, nothing
   animates. The plain tint stands first for a browser without color-mix, which
   simply keeps the band it had. test/landing-sections.test.ts holds the
   gradient to this range: darker than the tint, lighter than the hairline,
   and never the loud difference a border would be. The compound selector is
   load-bearing: the shared `.lp-section` tint is declared later at the same
   weight and would otherwise take the gradient back. */
.lp-section.lp-video {
  background: var(--bg);
  background: linear-gradient(
    180deg,
    var(--bg) 0%,
    var(--bg) 35%,
    color-mix(in srgb, var(--bg) 78%, var(--line)) 100%
  );
}

/* Copy on the left, a 16:9 surface on the right from 900px; stacked under
   it. The surface is the same box whether it holds the YouTube embed or, until
   Sam publishes the video, the labelled placeholder (landing.tsx), so the swap
   moves nothing. */
.lp-video-grid {
  display: grid;
  gap: 28px;
  align-items: center;
}

.lp-video-copy h2 {
  max-width: 22ch;
}

.lp-video-surface {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: 16px;
  overflow: hidden;
  background: var(--ink);
}

.lp-video-embed {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

/* The placeholder: the dark brand tone, a thin edge, a decorative play glyph
   that is inside no control and reacts to nothing, and the line that says the
   video is being prepared. Nothing about it is a button, so nothing about it
   can be pressed and do nothing. */
.lp-video-placeholder {
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 14px;
  padding: 24px;
  background: var(--ink-brand);
  border: 1px solid rgba(255, 255, 255, 0.16);
  color: #fff;
  text-align: center;
}

.lp-video-glyph {
  width: 64px;
  height: 64px;
  color: rgba(255, 255, 255, 0.9);
}

.lp-video-note {
  display: grid;
  gap: 4px;
  margin: 0;
}

.lp-video-note strong {
  font-size: 1.05rem;
}

.lp-video-note span {
  font-size: 0.92rem;
  color: rgba(255, 255, 255, 0.78);
}

/* --- six reasons around the real card ------------------------------------------ */

/* ONE COMPOSITION. The phone is the product's own framed preview (the
   renderer, not a picture), and the six callouts are boxed icon-and-text
   rows on the page's own tint (task #955 pass 4, Sam): every box the same
   quiet colour from the palette, everything ranged left on both sides of the
   phone, and clear air between the boxes. Under 900px the phone comes first
   and the six read down, in their source order, so the order on a phone is
   the order a screen reader gets. */
/* Centred, which the shared head rule's `margin: 0 0 32px` (declared later,
   same weight) would otherwise undo: hence the compound selector. */
.lp-section-head.lp-why-head {
  margin: 0 auto 32px;
  text-align: center;
}

/* THE BAND OPENS OUT PAST THE MEASURE (task #1037, Sam). Every other band
   holds its content to the page's column; this one, three boxes a side of a
   phone, wants more room than the column gives it, so its inline padding is
   the band's own calculation (calc(50vw - 50%) brings a full-bleed band back
   to the column's edge) less --lp-why-reach on each side, and never less
   than the page gutter. On a window narrower than the column plus twice the
   reach that max() is the gutter, so the composition is simply as wide as
   the window allows and nothing can overflow; on a wide desktop it is the
   column plus 300px, which is the sensible ceiling, and it never exceeds the
   window itself. The compound selector outranks .lp-band's own padding by
   specificity, not by order. */
.lp-band.lp-why {
  --lp-why-reach: 150px;
  padding-inline: max(var(--content-gutter), calc(50vw - 50% - var(--lp-why-reach)));
}

.lp-why-grid {
  display: grid;
  gap: 32px;
  align-items: center;
}

.lp-why-list {
  display: grid;
  gap: 28px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The box: the page tint on the white band, one hairline, and SQUARE
   corners (task #1037, Sam: the rounded ones are gone). Light on purpose: a
   marketing callout, not a dashboard card. The title is a size up from the
   body (1.3rem, not 1.25: test/dashboard.test.ts orders the sheet's first
   1.25rem after its first 1.8rem, and this rule comes earlier). */
.lp-why-item {
  display: flex;
  gap: 16px;
  align-items: flex-start;
  text-align: left;
  padding: 22px 24px;
  border-radius: 0;
  background: var(--bg);
  border: 1px solid var(--line);
}

/* The glyph is one Phosphor path from the build's own stamp icon catalogue,
   on a lilac tile so it reads as a mark rather than a stray shape. */
.lp-why-icon {
  flex: none;
  box-sizing: border-box;
  width: 48px;
  height: 48px;
  padding: 11px;
  border-radius: 12px;
  background: var(--strip);
  color: var(--brand-fixed);
}

.lp-why-item h3 {
  margin: 0 0 6px;
  font-size: 1.3rem;
}

.lp-why-item p {
  margin: 0;
  font-size: 1rem;
  color: var(--muted);
}

.lp-why-device {
  display: flex;
  justify-content: center;
  order: -1;
}

/* The phone keeps its own 374px ceiling (test/content-grid.test.ts pins it)
   at desktop widths and is capped under it on a phone, where a full-width
   handset was a picture with the six reasons a scroll away. */
.lp-why-device .phone-frame {
  margin: 0 auto;
  max-width: 300px;
}

/* --- a content band --------------------------------------------------------- */

.lp-section {
  background: var(--bg);
  padding-block: 64px;
}

.lp-section-alt {
  background: var(--card);
}

.lp-section-ink {
  background: var(--ink);
  color: #fff;
}

.lp-section-head {
  max-width: 42rem;
  margin: 0 0 32px;
}

.lp-section h2,
.lp-section h3 {
  line-height: 1.2;
}

.lp-section h2 {
  font-size: 1.85rem;
  margin: 0 0 10px;
}

.lp-section-lede {
  margin: 0 0 8px;
  max-width: 42rem;
  font-size: 1.05rem;
  color: var(--muted);
}

.lp-section-ink .lp-section-lede {
  color: rgba(255, 255, 255, 0.82);
}

/* --- how it works: the customer's three steps, pictured (task #1037) --------- */

/* ONE SEQUENCE, READ ACROSS. Three stages in an ordered list, each a
   picture with its number, title and sentence beneath: one column on a phone,
   where each stage is a picture over its words and the three read down; the
   picture beside its words from 721px, so a tablet reads three short rows
   rather than three tall columns; three columns from 900px, where the row is
   the sequence. The pictures are 4:3 and carry their own width and height,
   so every stage reserves its height before a byte of picture arrives and
   nothing below the band moves as they land. The merchant's set-up journey
   that used to stand beside this is gone and nothing stands in for it. */
.lp-section-head.lp-how-head {
  margin: 0 auto 36px;
  text-align: center;
}

.lp-stages {
  display: grid;
  gap: 36px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.lp-stage {
  display: grid;
  gap: 18px;
  align-content: start;
}

/* Drawn whole: the file is the picture, at the file's own proportions, and
   cover only ever trims a sub-pixel of rounding. Square corners, as the six
   boxes above now are. */
.lp-stage-picture {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

.lp-stage-copy {
  display: grid;
  grid-template-columns: 34px 1fr;
  gap: 14px;
  align-items: start;
}

.lp-stage h3 {
  margin: 0 0 4px;
  font-size: 1.15rem;
}

.lp-stage p {
  margin: 0;
  color: var(--muted);
}

.lp-stage-num {
  display: inline-flex;
  width: 34px;
  height: 34px;
  align-items: center;
  justify-content: center;
  background: var(--brand-fixed);
  color: #fff;
  border-radius: 50%;
  font-weight: 700;
}

/* --- the card gallery (task #1037) ------------------------------------------- */

/* A STRIP OF HALF-HEIGHT PHONES THAT DRIFTS. The band is the lilac strip
   token, so the dark bezels and the twelve coloured cards stand off it and
   the band reads as its own thing between the tinted How it works and the
   white designer band. The strip runs edge to edge by the band's own
   calculation (the same calc(50% - 50vw) every band bleeds by; never 100vw),
   overflow hidden, with a short fade at each edge so phones arrive and leave
   softly rather than being cut by the window.

   THE TRACK is two copies of the twelve, laid in one flex row at their
   natural width; one keyframe moves the track by half of itself, which is
   exactly one copy because each phone carries the same inline-end margin
   (a gap property would leave half a gap unaccounted for at the join and
   the loop would jog). Linear, so the drift is even; 84 seconds for twelve,
   which is a slow walk past a window, readable and restrained. A transform
   is the only thing that changes from frame to frame: no layout, no paint,
   nothing below the band can be pushed about, and there is no script.

   PAUSED UNDER A POINTER, on a device that has one (the same query the
   half-frame reveal uses: on a phone :hover latches after a tap and would
   stop the strip for good). Focus pauses nothing because there is nothing
   in the strip to focus: the phones are pictures, not links.

   REDUCED MOTION IS NO MOTION. The animation goes, the echo copy is not
   drawn at all, and the twelve stand still as a wrapping, centred grid at
   the column's width: the same cards, the same phones, nothing moving.

   WHAT A PHONE SHOWS (task #1045): no longer a header strip but the card's
   header with its logo, its whole stamp field and its reward line, through
   an aperture that runs to the foot of the frame; the card is cut there,
   under the reward and before the barcode plate, and the frame's foot is the
   band's foot (see .lp-section-design).

   THE BAND IS THE DESIGN SECTION'S (task #1047, Sam): the gallery stands at
   the foot of "Full control over the design of your card", straight after
   its four pictured points (see the design rules below), not in a band of
   its own. */
.lp-section.lp-section-design {
  background: var(--strip);
  /* THE CROP IS THE BAND'S OWN EDGE (task #1045, Sam). Each phone's aperture
     runs to the foot of its frame and the frame is the last thing in the
     band, so with no padding under it the card is cut by the next band
     exactly where the phone stops: no strip of lilac under a row of cut
     cards, and nothing that looks like a gap. The band keeps its top
     padding from .lp-section. */
  padding-bottom: 0;
}

.lp-section-head.lp-design-head {
  margin: 0 auto 44px;
  text-align: center;
}

.lp-gallery {
  --lp-gallery-gap: 22px;
  /* A phone is 748:608 since task #1045 (header, stamp field and reward
     line, not a header strip), so it stands 211 to 276px tall; 260px is the
     narrowest at which the reward line under the marks still reads at a
     glance (the face's type is 3.75% of the phone: 9.75px at 260), and 340
     is as large as a strip of them should get on a wide desk. 224px, the
     old floor, left the reward at 8.4px. */
  --lp-gallery-phone: clamp(260px, 26vw, 340px);
  margin-inline: calc(50% - 50vw);
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}

.lp-gallery-track {
  display: flex;
  width: max-content;
  animation: lp-gallery-drift 84s linear infinite;
}

.lp-gallery-set {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
}

.lp-gallery-item {
  flex: none;
  width: var(--lp-gallery-phone);
  margin-inline-end: var(--lp-gallery-gap);
}

@keyframes lp-gallery-drift {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-50%);
  }
}

@media (hover: hover) and (pointer: fine) {
  .lp-gallery:hover .lp-gallery-track {
    animation-play-state: paused;
  }
}

@media (prefers-reduced-motion: reduce) {
  .lp-gallery {
    margin-inline: 0;
    overflow: visible;
    -webkit-mask-image: none;
    mask-image: none;
  }

  /* A block, so the strip inside it has a definite width for its items'
     percentages to resolve against (as a flex item of a row it would not). */
  .lp-gallery-track {
    display: block;
    width: auto;
    animation: none;
  }

  .lp-gallery-set {
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--lp-gallery-gap);
  }

  /* Two to a row on a narrow screen, so twelve still phones are six rows
     and not twelve; the usual width where two fit. */
  .lp-gallery-item {
    width: min(var(--lp-gallery-phone), calc(50% - var(--lp-gallery-gap) / 2));
    margin-inline-end: 0;
  }

  .lp-gallery-set[data-gallery-set='echo'] {
    display: none;
  }
}

/* ONE TO A ROW ON A PHONE, under reduced motion (task #1045). Two phones in
   a 390px column are 170px each, and a card's reward line at that size is
   under 7px: a still grid that cannot be read is not the accessible answer.
   Below 640px each phone takes the column (capped at its usual width and
   centred by the set), so the twelve stand in a single readable column. */
@media (prefers-reduced-motion: reduce) and (max-width: 639px) {
  .lp-gallery-item {
    width: min(var(--lp-gallery-phone), 100%);
  }
}

/* --- a two-column band (copy beside a picture) ------------------------------- */

.lp-split {
  display: grid;
  gap: 36px;
  align-items: center;
}

.lp-split-copy h2 {
  font-size: 1.85rem;
  margin: 0 0 10px;
}

/* --- the design points: four blocks, a circle each (task #1047, Sam) --------

   FOUR POINTS, PICTURE BESIDE WORDS, ALTERNATING. Each point is a two-cell
   grid: the picture, then the words, in DOM order. Under 721px that is a
   column, the circle centred over its words, every point the same way round
   (the How it works stages read the same). From 721px the two stand side by
   side in one centred row no wider than the words' own measure, the circle's
   cell as wide as the circle and the words close beside it (Sam's review:
   no chasm between a block and its picture); from 900px every EVEN point
   moves its picture to the right with `order` and swaps its columns, so the
   composition zigzags down the band while the reading order, and the order a
   screen reader announces, never changes.

   THE CIRCLE IS A WINDOW ONTO THE RENDERER. .lp-design-circle is a square
   clipped to a circle, 160px across on a desktop and never under 136px (Sam's
   review halved it: a close-up, not a feature picture); the layers inside it
   are absolutely positioned cards (or the gallery's phone), placed by inline
   percentages of the circle from src/landing-design.ts, so the composition
   is the same at every size, and every one is drawn larger than the circle
   on every side, so the circle shows no ground of its own (its background is
   the band's, so a slip would be invisible rather than a white hole, and
   test/landing-design-section.test.ts holds the geometry). The card's own
   wrappers (.wallet-card-standalone, .wallet-card-face) cap their width at
   460 and 340px for the surfaces they were made for; inside a layer the
   width is the layer's, so the caps are lifted here and here only. The
   card's type is still its own: it scales from the card's width through
   .wallet-card-face's container query, exactly as on every other surface.

   NO BARCODE: the layers' cards are given no QR, and the plate that would
   hold one is not laid out (the half frame's own rule does the same for the
   preview phone). NOTHING ELSE ON A CARD IS HIDDEN: the circle is the only
   crop. The customer's card rule (task #777: no rule anywhere hides the
   reward row) holds on the front page too.

   THE BRIDGE under the four points (Sam's review): the band's one call to
   action, centred under its own heading with the proof line beneath, with
   enough air to read as the step between the controls and the examples and
   not so much that it reads as a band of its own. */
.lp-design-points {
  display: grid;
  gap: 40px;
  margin: 0 0 48px;
  padding: 0;
  list-style: none;
}

.lp-design-point {
  display: grid;
  gap: 18px;
  align-items: center;
}

.lp-design-figure {
  display: block;
}

.lp-design-circle {
  position: relative;
  width: clamp(136px, 36vw, 160px);
  aspect-ratio: 1;
  margin: 0 auto;
  border-radius: 50%;
  overflow: hidden;
  background: var(--strip);
}

.lp-design-view {
  position: absolute;
  inset: 0;
}

.lp-design-layer {
  position: absolute;
}

.lp-design-layer .wallet-card-standalone,
.lp-design-layer .wallet-card-face {
  max-width: none;
}

.lp-design-circle .wallet-pass-qr {
  display: none;
}

.lp-design-copy {
  max-width: 34rem;
}

.lp-design-copy h3 {
  margin: 0 0 8px;
  font-size: 1.3rem;
}

.lp-design-copy p {
  margin: 0;
  font-size: 1.05rem;
  color: var(--muted);
}

.lp-design-bridge {
  max-width: 34rem;
  margin: 0 auto 40px;
  text-align: center;
}

.lp-design-bridge h3 {
  margin: 0 0 16px;
  font-size: 1.3rem;
}

.lp-design-bridge-action {
  margin: 0;
}

.lp-design-bridge .lp-fine {
  margin: 12px 0 0;
}

/* --- a ticked feature list ---------------------------------------------------- */

.lp-features {
  display: grid;
  gap: 14px;
  margin: 22px 0 0;
  padding: 0;
  list-style: none;
}

.lp-feature {
  display: grid;
  grid-template-columns: 18px 1fr;
  gap: 10px;
  align-items: start;
  color: var(--muted);
}

.lp-feature strong {
  color: var(--ink);
}

.lp-features-ink .lp-feature {
  color: rgba(255, 255, 255, 0.82);
}

.lp-features-ink .lp-feature strong {
  color: #fff;
}

.lp-tick {
  width: 18px;
  height: 18px;
  margin-top: 0.25em;
  color: var(--brand-fixed);
}

.lp-features-ink .lp-tick {
  color: var(--brand-on-ink);
}

.lp-inline-cta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px 16px;
  margin: 26px 0 0;
}

.lp-inline-cta .lp-fine {
  margin: 0;
}

/* --- the feature inventory (task #1049) ------------------------------------

   ONE LIST, THREE COLUMNS, NO BOXES. The band that stood here was nine dark
   panels about the counter (task #832's tone); Sam replaced it with the
   fuller feature set, for the prospect who has read the bands above and now
   wants to check the detail. Each entry is one plain statement, not a title
   over a paragraph, so the whole set scans in a glance: a white tick on a
   purple disc, then the words, in rows.

   THE LIST READS DOWN. It is a multi-column flow (columns, not a grid): the
   entries run down the first column and then the next, so the three columns
   from 1100px are three runs of the one source order, the two from 721px are
   two runs of it, and a phone reads the list straight down. Source order is
   reading order at every width, and a screen reader hears one list of N
   items. A flow rather than a grid because a grid's rows are as tall as
   their tallest cell, and a one-line statement beside a two-line one was
   left sitting over a hole; in a flow every row is exactly its own height
   and the 14px between rows is the same everywhere. break-inside keeps a
   statement in one column.

   THE BAND OPENS OUT PAST THE MEASURE the way the six boxes do (.lp-band.lp-why,
   task #1037): three columns in the page's own column are 300px each at
   1280, which forces most statements onto a second line; with the same
   150px reach a side each column is nearer 380px and a statement of
   ordinary length is one line. The gutter never drops under 3vw (38px at
   1280), so the list does not run to the window's edge on a desktop where
   the reach would otherwise take it there; on a phone the page gutter wins.
   The disc is the brand purple with a white tick (7.85:1, the pair the
   primary button draws); it is aria-hidden, so the list reads as its words
   alone. */
.lp-band.lp-section-features {
  --lp-inventory-reach: 150px;
  padding-inline: max(var(--content-gutter), 3vw, calc(50vw - 50% - var(--lp-inventory-reach)));
}

.lp-section-head.lp-features-head {
  margin: 0 auto 36px;
  text-align: center;
}

.lp-inventory {
  columns: 1;
  column-gap: 32px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.lp-inventory-item {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  break-inside: avoid;
  margin: 0 0 14px;
  font-size: 1rem;
  line-height: 1.45;
  color: var(--ink);
}

.lp-inventory-tick {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  margin-top: 0.05em;
  border-radius: 50%;
  background: var(--brand-fixed);
  color: #fff;
}

.lp-inventory-tick svg {
  width: 12px;
  height: 12px;
}

/* --- the wallet band ------------------------------------------------------------ */

.lp-wallet {
  max-width: 46rem;
}

/* The honest status note. A quiet box rather than a coloured message: it is
   context, not something that just happened. */
.lp-note {
  margin: 28px 0 0;
  padding: 16px 18px;
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: 12px;
  font-size: 0.92rem;
  color: rgba(255, 255, 255, 0.82);
}

/* --- the example counts panel ---------------------------------------------------- */

.lp-split-panel {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 26px;
}

.lp-panel-flag {
  margin: 0 0 14px;
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--brand-fixed);
}

.lp-stat-list {
  display: grid;
  gap: 14px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.lp-stat-list li {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--line);
}

.lp-stat-label {
  color: var(--muted);
}

.lp-stat-value {
  font-size: 1.6rem;
  font-weight: 800;
}

/* --- the plans -------------------------------------------------------------------- */

.lp-plans {
  display: grid;
  gap: 20px;
  align-items: start;
}

.lp-plan {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 28px;
}

.lp-plan-featured {
  border: 2px solid var(--brand-fixed);
}

.lp-plan h3 {
  margin: 0 0 4px;
  font-size: 1.3rem;
}

.lp-plan-price {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px;
  margin: 0 0 18px;
}

.lp-plan-figure {
  font-size: 2.4rem;
  font-weight: 800;
  line-height: 1;
}

.lp-plan-per {
  color: var(--muted);
  font-size: 0.9rem;
}

.lp-plan-list {
  display: grid;
  gap: 8px;
  margin: 0 0 22px;
  padding: 0 0 0 20px;
  color: var(--muted);
}

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

.lp-plans-note {
  max-width: 46rem;
  margin-top: 20px;
}

/* --- the questions ------------------------------------------------------------------ */

.lp-faq {
  display: grid;
  gap: 24px 36px;
}

.lp-faq-item h3 {
  margin: 0 0 6px;
  font-size: 1.05rem;
}

.lp-faq-item p {
  margin: 0;
  color: var(--muted);
}

.lp-faq-note {
  margin-top: 28px;
}

/* --- the ask ----------------------------------------------------------------------------

   The one purple band on the page, between the last of the content and the ink
   footer, in the colour nothing else here is: --brand-fixed, never --brand,
   because this is LoyalPack asking on its own behalf on a public page. White on
   it measures 7.8:1. */
.lp-claim {
  background: var(--brand-fixed);
  color: #fff;
  padding-block: 56px;
  text-align: center;
}

.lp-claim-title {
  max-width: 34rem;
  margin: 0 auto 10px;
  font-size: 1.9rem;
  line-height: 1.2;
}

.lp-claim-line {
  max-width: 38rem;
  margin: 0 auto 24px;
  color: rgba(255, 255, 255, 0.86);
}

/* --- the footer ---------------------------------------------------------------------------

   A STRIP IN THE DARK BRAND TONE since task #832 (Sam: "make the footer the
   same colour as that too"), where it was ink. It is reached by
   `.lp-page .footer` rather than by `.footer` itself, exactly as the designer's
   is: PublicFooter is shared with the whole support section, and that keeps the
   footer it has. So this recolour lands on the front page and on nothing else,
   and the authenticated app footer (views/footer.tsx, `.app-footer`) is a
   different element entirely and is not touched.

   THE LINKS HAD TO BE NAMED HERE. `.footer-links > a` is --muted, written for
   the pale footer the support pages still wear; on a dark strip that measures
   about 2.5:1 and Terms, Privacy and Support were the least legible words on
   the page. They take white, and the hover takes the same white at full
   strength against a quieter rest state, which is the treatment the designer
   page's footer already uses. */
.lp-page .footer {
  margin: 0;
  padding-block: 30px;
  margin-inline: calc(50% - 50vw);
  padding-inline: calc(50vw - 50%);
  background: var(--ink-brand);
  color: rgba(255, 255, 255, 0.72);
}

.lp-page .footer .footer-tagline {
  color: rgba(255, 255, 255, 0.72);
}

.lp-page .footer .footer-links > a {
  color: rgba(255, 255, 255, 0.82);
}

.lp-page .footer .footer-links > a:hover {
  color: #fff;
}

/* --- the two breakpoints ------------------------------------------------------------------- */

@media (min-width: 721px) {
  /* The phone above, the two trios side by side beneath it. */
  .lp-why-grid {
    grid-template-columns: 1fr 1fr;
  }

  .lp-why-device {
    grid-column: 1 / -1;
  }

  .lp-plans,
  .lp-faq {
    grid-template-columns: 1fr 1fr;
  }

  /* A design point is one short centred row from here (task #1047): the
     circle's cell as wide as the circle, the words close beside it, the row
     no wider than the words' measure plus the circle. */
  .lp-design-points {
    gap: 44px;
  }

  .lp-design-point {
    grid-template-columns: auto minmax(0, 1fr);
    column-gap: 32px;
    max-width: 46rem;
    margin-inline: auto;
  }

  /* A stage is a short row on a tablet: the picture beside its words. */
  .lp-stage {
    grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
    gap: 28px;
    align-items: center;
  }

  /* Two runs of the one list on a tablet: down the first column, then the
     second. */
  .lp-inventory {
    columns: 2;
  }
}

@media (min-width: 900px) {
  /* The photograph behind the words. See the hero note above for the crop. */
  .lp-hero {
    display: grid;
    align-items: center;
    min-height: clamp(540px, 42vw, 760px);
  }

  .lp-hero-copy {
    padding-block: 72px;
  }

  .lp-hero-photo {
    position: absolute;
    inset: 0;
    z-index: 0;
    width: 100%;
    height: 100%;
    max-height: none;
    aspect-ratio: auto;
    margin: 0;
    object-position: 62% 50%;
  }

  .lp-video-grid {
    grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
    gap: 48px;
  }

  /* Three, the phone, three. The handset column is sized to the artwork,
     and the air either side of it grows with the window (task #1037): a
     clamp between 56px and 120px so the phone stands clear of the boxes at
     1280 and clearer still at 1920, without the gaps ever eating the boxes'
     own width on a narrow desktop. */
  .lp-why-grid {
    grid-template-columns: minmax(0, 1fr) minmax(280px, 374px) minmax(0, 1fr);
    column-gap: clamp(56px, 6vw, 120px);
    row-gap: 32px;
  }

  .lp-why-device {
    order: 0;
    grid-column: auto;
  }

  .lp-why-device .phone-frame {
    max-width: 374px;
  }

  /* The sequence: three stages across, each a picture over its words again. */
  .lp-stages {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 40px;
  }

  .lp-stage {
    grid-template-columns: none;
    gap: 18px;
    align-items: start;
  }

  .lp-split {
    grid-template-columns: 1fr 1fr;
  }

  /* The design points zigzag from 900px (task #1047): every even point's
     picture to the right, its columns swapped so the circle's cell is still
     the circle's own width and the words still sit close beside it. */
  .lp-design-point:nth-child(even) {
    grid-template-columns: minmax(0, 1fr) auto;
  }

  .lp-design-point:nth-child(even) .lp-design-figure {
    order: 2;
  }

  /* The reports band puts its panel on the left, so two picture-on-the-right
     bands do not run one after the other down the page. Source order keeps the
     copy first, which is the order it should be read and announced in. */
  .lp-split-reverse .lp-split-copy {
    order: 2;
  }

}

/* Three equal columns for the inventory from 1100px, still read down then
   across. Not 900: three columns of a 900px window are 268px each, and a
   statement of ordinary length needs three lines in that; at 1100px a
   column is about 335px, two lines at most, and at 1280 most statements
   are one. */
@media (min-width: 1100px) {
  .lp-inventory {
    columns: 3;
  }
}

@media (max-width: 720px) {
  .lp-hero h1 {
    font-size: 1.95rem;
  }

  .lp-lede {
    font-size: 1.1rem;
  }

  /* Under 900px the copy sits on a plain band above the picture; the dark
     brand tone rather than ink, so the band is distinct from the ink header
     it hangs under (task #955 pass 4), exactly as the video surface is. */
  .lp-hero {
    background: var(--ink-brand);
  }

  .lp-section,
  .lp-claim {
    padding-block: 40px;
  }

  .lp-section h2,
  .lp-split-copy h2 {
    font-size: 1.5rem;
  }

  .lp-claim-title {
    font-size: 1.5rem;
  }

  /* A thumb wants the whole width for the one action in a band it has landed
     on at the end of the page. */
  .lp-actions .btn {
    display: block;
    width: 100%;
  }

  .lp-plan {
    padding: 22px;
  }
}

.footer {
  text-align: center;
  color: var(--muted);
  padding: 24px 0;
  font-size: 0.9rem;
}

/* --- customer card --- */
.qr svg {
  width: 100%;
  height: auto;
}

.wallet-buttons {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 18px 0 8px;
  align-items: center;
}

/* ONE HEIGHT FOR EVERY WALLET BADGE (task #691), never one width. Apple's badge
   is about 3.16:1 and Google's about 5.66:1, so matching their widths would draw
   Apple's nearly twice as tall as Google's and make the desktop pair look like a
   mistake. Matched heights are how vendor badges are set everywhere, and it is
   what both sets of guidelines are written around.

   --badge-w is set per badge by the renderer: the width at which THAT artwork
   stands at the shared height (WALLET_BADGE_HEIGHT in src/wallet-buttons.ts,
   48px, Google's documented minimum and Apple's comfortably-cleared one). A
   width with the height left to follow means the shape always comes from the
   artwork, so neither badge can be stretched; min(…, 100%) shrinks it
   proportionally on a viewport too narrow to hold it rather than overflowing.

   The 10px gap and the card's own side padding are the badges' clear space, so
   nothing crowds the artwork. */
.wallet-badge-link {
  display: block;
  line-height: 0;
  border-radius: 8px;
  width: min(var(--badge-w), 100%);
}

.wallet-badge {
  display: block;
  width: 100%;
  height: auto;
}

/* The text button state (a provider that is configured but has no licensed
   artwork, or an unavailable one) keeps a width of its own so the column reads
   as a column rather than as one stretched control. */
.wallet-buttons > .btn {
  width: min(272px, 100%);
}

.logo {
  max-height: 64px;
  margin: 0 auto 10px;
}

.logo-placeholder {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--brand);
  color: #fff;
  font-size: 1.6rem;
  font-weight: 800;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto 10px;
}

/* --- the customer enrolment page (task #567, Sam) --------------------------

   THE PAGE IS THE CARD. /p/<code> is the last thing a customer sees before the
   loyalty card lands in their wallet, and it used to be the app's generic pale
   grey carrying a white 14px-rounded box finished with a 6px bar of the brand
   colour across the top. Two things were wrong with that at once, and Sam named
   both: the business's colour appeared as a stripe rather than as the page, and
   the rounded-box-with-a-coloured-top-edge is a shape that says nothing about
   anything.

   So the page takes the card's own two colours, and it takes them FROM the card
   rather than restating them. cardSurface() in src/card-definition.ts makes the
   same rgbColor() call cardDefinition() does, so --card-bg is not a colour like
   the pass background, it IS the pass background; and since task #624's second
   pass --card-ink is not a relative of the pass ink either, it IS the pass ink
   wherever that ink can be read at all (src/contrast.ts carries the bar and what
   it costs). Layout validates the pair, writes both onto :root and puts
   .card-surface on <body>. Everything below reads those two tokens and declares
   no colour of its own, which is what makes this whole section correct for a
   brand colour nobody has chosen yet.

   WHAT REPLACED THE BOX IS NOTHING: no panel, no border, no radius, no shadow,
   no second surface to explain. The name, the promise and the fields sit
   directly on the colour the way they sit directly on the card. The only white
   left on the page is the fields a customer types into, the offers tick row
   (task #921, below) and the one error paragraph, which is the honest
   division: a surface here means "this is yours to fill in", and nothing else
   earns one. */
.card-surface {
  background: var(--card-bg);
  color: var(--card-ink);
}

/* EVERY SOFTENED COLOUR ON THIS PAGE FOLLOWS THE CARD (task #624, Sam: the
   text has to switch with the background). The body already inherits --card-ink
   from .card-surface, so the name, the fields' labels and the tick's wording
   move with the colour on their own. --muted does not: it is a mid grey chosen
   to sit quietly on the app's pale page, and against a colour a merchant picked
   it is a smudge on a dark card and barely there on a light one.

   REDEFINING THE TOKEN rather than repainting one element is what makes this
   hold for text that is not on the page yet. `.lede` reads --muted, and so do
   `.muted`, `.small` and `.hint`, which is what any line added to this column
   later will be; one declaration answers for all of them, including the
   paragraph nobody has written. Everything keeps its size, its weight and its
   measure and loses only the colour.

   NOTHING SOFTER THAN THE INK, deliberately. --card-ink is already the readable
   answer for this background, and there is no headroom below it worth spending
   on a page whose entire content is what the customer is about to join. A
   quieter grey would have to be recomputed per colour to stay legible, which is
   a contrast calculation for a hierarchy this page does not need.

   --ink AND --card ARE NOT TOUCHED, which is the boundary that matters: the
   fields are white boxes with dark text and they stay that way whatever the
   card colour is, because a field is a surface of its own rather than part of
   the card. Same for the one error panel below. */
.enrol {
  text-align: center;
  --muted: var(--card-ink);
}

/* `a` is `color: var(--brand)`, and on this page --brand IS the background: a
   link would be drawn in the colour it sits on. There is no link in this column
   today, which is exactly why the rule is written now rather than the next time
   somebody adds one and cannot see it. The underline is the browser's, and it
   is what keeps the link findable now that its colour is the body ink. */
.enrol a {
  color: var(--card-ink);
}

/* The fallback initial for an account with no logo. It is `background: var(
   --brand); color: #fff` by default, which became a brand disc on a brand page
   the moment this treatment landed: invisible, and invisible in the exact spot
   the business's identity should be. Inverting it draws the same disc in the
   one pair this page already guarantees reads. */
.enrol .logo-placeholder {
  background: var(--card-ink);
  color: var(--card-bg);
}

/* The error panel, and it earns its surface. --error is a red chosen to read
   on white, so it is the single message on this page that cannot be left to
   take its chances against a colour the merchant picked. A flat block of the
   same white the fields below use, square and with no coloured edge, so it
   reads as the top of the form rather than as another card. */
.enrol .error {
  background: var(--card);
  padding: 12px 14px;
  margin: 0 0 20px;
  text-align: left;
}

/* The shared control focus ring is 2px of --brand, which on this page is the
   page: the ring would be drawn in the background colour it is meant to stand
   out against. Same ring, same offset, drawn in the ink instead, so it is
   visible on every colour a merchant can choose. The offers tick restates it
   in --ink below, because that box sits on white rather than on the card. */
.enrol .stack-form input:focus-visible,
.enrol .btn:focus-visible {
  outline: 2px solid var(--card-ink);
  outline-offset: 2px;
}

/* THE OFFERS TICK (task #921, Sam). The one tick box on the page, the optional
   "Send me occasional offers" line under the email field, was the browser's own
   13px control, its checked face painted by accent-color from whatever the
   engine chose to put on the card's ink: a pale grey tick on white on some
   phones, and a box too small to find with a thumb on all of them. The label
   row was 24.8px tall on one line, so the tap target was the words and a
   13px square.

   THE ROW IS A SURFACE OF ITS OWN, like the fields above it and the error
   panel: a flat block of the same white, square, with no coloured edge, and
   its words, its ring and its fill in the app's own --ink. That is the
   boundary task #624 drew for this page (--ink and --card are never rebound
   here, which is what keeps a field a white box with dark text on any card
   colour), and it is what makes every ratio on this row a constant rather
   than a function of the merchant's colour: --ink on --card is 18.6:1 for
   the words, the ring and the checked fill, and --card on --ink is the same
   18.6:1 for the tick. The page's other text follows --card-ink, which cardSurface()
   keeps at 3:1 or better on mid-tones by design (task #624's second pass); a
   choice the customer is asked to make, in 16px body text, is held to the
   4.5:1 text minimum instead, and no card-aware token can promise that on
   every colour, so the row does not depend on one. The whole panel is the
   label, so every point of it toggles the box, and nothing here paints in the
   merchant's raw colour where it could vanish. The two states differ by fill
   AND mark rather than by colour alone (WCAG 1.4.1).

   THE INPUT STAYS THE CONTROL. `appearance: none` on the real checkbox, on the
   same reasoning as the on/off switch (src/views/toggle-switch.tsx): the
   browser keeps the keyboard, the focus ring, the label association, the form
   submission and the back-button restore, and none of it is ours to maintain.
   Nothing here hides the input, and no span stands in for it.

   THE ROW IS THE TARGET. The label wraps the input and the words, so the whole
   panel toggles the box; 10px above and below and 12px each side (the fields'
   own inline padding) and a 44px floor make it a comfortable thumb-sized
   target on a phone (WCAG 2.5.8 wants 24px, Apple and Google both ask for
   44px) while the box itself stays 24px. The padding is the hit area, and it
   sits in the form's 16px rhythm like any other block.

   THE TICK IS TWO BORDERS. A short rectangle with a right and a bottom edge,
   turned 45 degrees, is a tick on every engine without an image, a font glyph
   or a data: URL, so the page's content security posture is untouched and
   nothing has to load. No transition on any of it: the state is the message
   and there is nothing for reduced-motion to take away.

   THE FOCUS RING IS RESTATED IN --ink. The page's own ring above is drawn in
   --card-ink because it sits on the card colour; this box sits on white, where
   a card ink that happens to be white would be no ring at all. Same 2px, same
   2px offset, in the ink that is guaranteed on this surface.

   SCOPED TO THIS PAGE. Every selector here sits under `.enrol`, which only the
   sign-up page carries; the shared `.check` rule and every other tick box in
   the product (Program settings, uploads, the demo confirmations, the
   operator's forms) are untouched. */
.enrol .check {
  min-height: 44px;
  padding: 10px 12px;
  background: var(--card);
  color: var(--ink);
  cursor: pointer;
}

.enrol .check > span {
  min-width: 0;
  flex: 1 1 auto;
}

.enrol .check input[type='checkbox'] {
  appearance: none;
  -webkit-appearance: none;
  flex: 0 0 auto;
  box-sizing: border-box;
  width: 24px;
  height: 24px;
  margin: 0;
  padding: 0;
  border: 2px solid var(--ink);
  border-radius: var(--control-radius);
  background: var(--card);
  position: relative;
  cursor: pointer;
}

.enrol .check input[type='checkbox']:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.enrol .check input[type='checkbox']:checked {
  background: var(--ink);
}

/* The tick, drawn only when there is one. The box's padding area is 20px
   square inside the 2px border; a 6px by 12px rectangle with 3px right and
   bottom edges, rotated 45 degrees about its centre, sits at (7, 2) so its
   visual weight lands in the middle of that square. */
.enrol .check input[type='checkbox']:checked::after {
  content: '';
  position: absolute;
  left: 7px;
  top: 2px;
  width: 6px;
  height: 12px;
  border: solid var(--card);
  border-width: 0 3px 3px 0;
  transform: rotate(45deg);
}

/* WINDOWS HIGH CONTRAST throws the page's colours away, and a box whose two
   states are a fill would lose one of them. Painted from the system palette
   instead, on the on/off switch's precedent: the ring in the text colour, the
   checked fill in the highlight and the tick in the highlight's own text
   colour. forced-color-adjust: none, which the switch does not need, is what
   keeps the tick's two borders and the checked fill from being repainted out
   from under those declarations; every colour named here is a system colour,
   so the result is still the user's palette and never ours. The focus ring
   has to be restated too: with the adjustment off, the --ink ring above would
   be drawn as near-black on a dark palette's canvas, which is no ring at all
   in exactly the mode built for these users. */
@media (forced-colors: active) {
  .enrol .check input[type='checkbox'] {
    forced-color-adjust: none;
    border-color: CanvasText;
    background: Canvas;
  }

  .enrol .check input[type='checkbox']:focus-visible {
    outline-color: CanvasText;
  }

  .enrol .check input[type='checkbox']:checked {
    background: Highlight;
    border-color: Highlight;
  }

  .enrol .check input[type='checkbox']:checked::after {
    border-color: HighlightText;
  }
}

/* Get my card. The card's pair, inverted: the ink as the fill, which makes the
   one action on the page the highest-contrast thing on it. Contrast is not a
   judgement here, it is the same guarantee that put the ink on the page. Full
   width because it is the only button and the form is a single column.

   The LABEL is --card-action-ink rather than --card-bg (task #568, Sam). On a
   dark fill the card colour was the merchant's own light tint, legible but
   muddy on the one thing the customer has to press; white is what a button that
   dark should say. surfaceVars() in views/layout.tsx decides, because the
   choice is a contrast question and that is where the pair is measured: white
   on every dark fill, and the card colour still when the fill is white, where
   white would be no button at all. One declaration covers every state: hover
   and :disabled move opacity, and the focus ring is drawn separately above.

   The shared .btn hover is `filter: brightness(1.08)`, which is nothing at all
   on a near-black fill, so this lifts slightly instead. */
.btn-card {
  background: var(--card-ink);
  border-color: var(--card-ink);
  color: var(--card-action-ink);
  width: 100%;
}

.btn-card:hover {
  filter: none;
  opacity: 0.86;
}

/* --- dashboard --- */
.dash-title {
  display: flex;
  align-items: center;
  gap: 12px;
}

.dash-logo {
  height: 44px;
  width: auto;
  border-radius: 8px;
}

.dash-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
  flex-wrap: wrap;
  margin-top: 8px;
}

.dash-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.stat-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin: 16px 0;
}

.stat {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 20px;
  text-align: center;
}

.stat-num {
  display: block;
  font-size: 2rem;
  font-weight: 800;
  color: var(--brand);
}

.stat-label {
  color: var(--muted);
}

table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.92rem;
}

th,
td {
  text-align: left;
  padding: 8px 10px 8px 0;
  border-bottom: 1px solid var(--line);
}

th {
  color: var(--muted);
  font-weight: 600;
}

.activity {
  list-style: none;
  margin: 0;
  padding: 0;
}

.activity li {
  padding: 8px 0;
  border-bottom: 1px solid var(--line);
}

.enrol-url {
  font-size: 1rem;
}

/* --- billing --- */
.badge {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--bg);
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 600;
  vertical-align: middle;
}

.badge-good {
  border-color: var(--success);
  color: var(--success);
  background: #eaf5ef;
}

.badge-warn {
  border-color: #b98a00;
  color: #8a6700;
  background: #fdf6e3;
}

.badge-bad {
  border-color: var(--error);
  color: var(--error);
  background: #fbeeed;
}

.banner {
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 12px 16px;
  margin: 0 0 16px;
}

.banner-warn {
  border-color: #b98a00;
  background: #fdf6e3;
}

/* `.banner-bad` was used by the free_expired banner (src/views/billing.tsx) and
   had never been defined, so the one state where scanning has actually stopped
   drew as a plain grey-bordered box: SOFTER than the amber warnings that lead up
   to it. Given as the message tokens (task #543) rather than a fourth palette,
   so the worst plan state and an error bar agree about what red means. These
   banners are not app messages and deliberately stay where they are: they carry
   an upgrade action and describe a standing state, so they must not slide away.*/
.banner-bad {
  border-color: var(--msg-error-edge);
  border-bottom-width: 3px;
  background: #fff1ef;
}

.billing-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin: 16px 0 8px;
}

/* --- poster --- */
.poster {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  text-align: center;
  padding: 48px 32px;
  max-width: 560px;
  margin: 16px auto;
}

.poster h1 {
  font-size: 2.2rem;
  color: var(--brand);
}

.poster-sub {
  font-size: 1.1rem;
  max-width: 420px;
  margin: 0 auto 20px;
}

.qr-big {
  max-width: 380px;
  margin: 0 auto;
}

.poster-url {
  font-weight: 600;
  word-break: break-all;
}

.poster-foot {
  color: var(--muted);
  margin-top: 20px;
}

@media print {
  .no-print,
  .topnav {
    display: none !important;
  }

  body {
    background: #fff;
  }

  .poster {
    border: none;
  }
}

/* --- auth shell (task #442) ---
   Coherent layout for the unauthenticated auth pages (log in, sign up,
   forgot/reset password). The full-page background is three independently
   tunable layers, each driven by its own custom property so it can be adjusted
   later without regenerating artwork:
     Layer 1  solid white base           -> --auth-bg-base
     Layer 2  generated doodle artwork   -> --auth-artwork-opacity (+ -size)
     Layer 3  translucent ink overlay    -> --auth-overlay-opacity
   The white base is controllable separately from the artwork and the overlay.
   The auth panel itself is opaque white with dark text, so foreground contrast
   stays WCAG-sensible regardless of how the layers are tuned.

   Layer 2 artwork provenance (task #442 v2): public/auth-pattern.webp is a
   web-optimised derivative of the approved FAL source sheet
   (20260720-153354-39.png, 2048x2048, sha256 9d8529c4...). Conversion:
   sharp(src).resize(2048,2048).webp({quality:80,effort:6}) -> 288 KiB. Only the
   production derivative is committed; the source lives outside the repo. */
.auth-body {
  --auth-bg-base: #ffffff;
  --auth-artwork-opacity: 0.5;
  --auth-artwork-size: 600px;
  --auth-overlay-opacity: 0.9;
  margin: 0;
  min-height: 100vh;
  background-color: var(--auth-bg-base); /* Layer 1 */
}

.auth-body::before,
.auth-body::after {
  content: '';
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 0;
}

/* Layer 2: the generated doodle sheet, repeated on a large tile so the organic
   composition reads rather than turning into a conspicuous small grid. */
.auth-body::before {
  background-image: url('auth-pattern.webp');
  background-repeat: repeat;
  background-position: center;
  background-size: var(--auth-artwork-size) var(--auth-artwork-size);
  opacity: var(--auth-artwork-opacity);
}

/* Layer 3: translucent ink overlay. */
.auth-body::after {
  background: var(--ink);
  opacity: var(--auth-overlay-opacity);
}

.auth-shell {
  position: relative;
  z-index: 1;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 22px;
  padding: 32px 24px; /* page gutter: keeps the 1000px panel off the edges */
}

.auth-panel {
  /* Target/maximum desktop width 1000px, never wider than the padded shell so
     it can never cause horizontal overflow (task #442 v3). */
  width: min(1000px, 100%);
  max-width: 1000px;
  background: var(--card);
  /* Square corners (task #442 v2); no border at all (v3). */
  border-radius: 0;
  border: 0;
  overflow: hidden;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}

/* Ink header bar carrying the approved white logo. Tall, with a generously
   sized logo (task #442 v3). */
.auth-header {
  background: var(--ink);
  padding: 30px 50px;
  display: flex;
  align-items: center;
}

.auth-brand {
  display: inline-flex;
  align-items: center;
}

.auth-logo {
  /* The wordmark's height on ONE selector: the phone rule below restates it
     on the same selector, so it wins by order rather than losing to a more
     specific one (task #1024 follow-up). */
  display: block;
  height: 60px;
  width: auto;
}

/* Square every interactive control inside the auth shell (task #442 v2),
   scoped to .auth-body so the merchant dashboard/product UI keeps its rounded
   cards and controls. */
.auth-body .btn,
.auth-body .btn-social,
.auth-body .stack-form input[type='text'],
.auth-body .stack-form input[type='email'],
.auth-body .stack-form input[type='password'] {
  border-radius: 0;
}

/* Keep keyboard focus obvious on the squared controls. */
.auth-body .stack-form input:focus-visible,
.auth-body .btn:focus-visible,
.auth-body .auth-alt summary:focus-visible,
.auth-body a:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* Slightly darker muted text on auth pages only (task #442 v4), for legibility.
   #565162 gives ~7.6:1 on white vs the base --muted #615c6b (~6.5:1); scoped to
   .auth-body so the dashboard/product UI and holding site are unchanged. */
.auth-body .muted {
  color: #565162;
}

/* Social sign-in placeholders (login page only). Inert and disabled, but shown
   in AUTHENTIC brand colours (Sam, task #442 v4): Google is a white button with
   a neutral border, dark label and the full-colour G; Apple is a solid black
   button with a white mark and label. The disabled buttons themselves signal
   availability, so no redundant "coming soon" label (task #442 v6).
   Square-cornered like the rest of the auth controls. */
.auth-social {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 0;
}

.btn-social {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 12px 18px;
  font: inherit;
  font-weight: 600;
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--ink);
}

.btn-google {
  background: #ffffff;
  color: #3c4043;
  border: 1px solid #dadce0;
}

.btn-apple {
  background: #000000;
  color: #ffffff;
  border: 1px solid #000000;
}

/* Keep authentic colours despite disabled semantics, and never hint at
   interactivity (non-interactive cursor, no hover cue). */
.btn-social:disabled {
  opacity: 1;
  cursor: not-allowed;
}

.btn-social:hover {
  filter: none;
}

.social-glyph {
  display: inline-flex;
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
}

.social-glyph svg {
  display: block;
  width: 100%;
  height: 100%;
}

.auth-grid {
  display: grid;
  grid-template-columns: 1.05fr 0.95fr;
}

.auth-grid-single {
  grid-template-columns: 1fr;
}

.auth-main {
  padding: 50px; /* task #442 v3: 50px per column on desktop */
}

.auth-main h1 {
  font-size: 1.7rem;
}

.auth-intro {
  margin-bottom: 4px;
}

.auth-aside {
  padding: 50px; /* task #442 v3: 50px per column on desktop */
  background: var(--bg);
  border-left: 1px solid var(--line);
  /* Flex column so the registration prompt can anchor to the bottom (v4). */
  display: flex;
  flex-direction: column;
}

/* Registration prompt moved to the bottom of the right column (task #442 v4).
   margin-top:auto pins it to the bottom when the column is stretched (desktop);
   on mobile the column is content-height, so it simply sits after the copy. */
.auth-register {
  margin-top: auto;
  padding-top: 20px;
  /* Whole prompt bold, link included (Sam, task #442). Set on the container so
     the text and the /signup link share one weight; nothing in the cascade sets
     a font-weight on `a` or `.muted`, so the link simply inherits 700 (Manrope
     700 is one of the loaded webfont weights). */
  font-weight: 700;
}

/* The whole support prompt bold on the code stage (Sam, task #445), link and
   full stop included. One weight on the paragraph, like .auth-register above:
   nothing in the cascade sets a font-weight on `a` or `.muted`, so the link
   inherits 700 (an already-loaded Manrope weight) and keeps its colour, hover
   and focus treatment. */
.auth-body .auth-support-strong {
  font-weight: 700;
}

.auth-aside-inner h2 {
  font-size: 1.15rem;
}

.auth-points {
  margin: 10px 0 0;
  padding-left: 18px;
  color: var(--muted);
}

.auth-points li {
  margin-bottom: 8px;
}

.auth-alt {
  margin: 4px 0;
}

.auth-alt summary {
  cursor: pointer;
  color: var(--brand);
  font-weight: 600;
}

.auth-alt .stack-form {
  margin-top: 12px;
}

/* Auth dividers (task #442 v4). Both share a 30px 0 margin; the visual
   distinction is weight/colour: soft is a light hairline, hard a stronger rule.
   Scoped to .auth-body so no dashboard/product page is affected. */
.auth-body hr.soft {
  border: none;
  border-top: 1px solid var(--line);
  margin: 30px 0;
}

.auth-body hr.hard {
  border: none;
  border-top: 1px solid var(--muted);
  margin: 30px 0;
}

/* Legal footer (task #442): sits below the panel, over the dark ink overlay,
   constrained to the same ~1000px width. Legal block left, link nav right on
   desktop; stacks (legal first) on mobile. Understated but legible white. */
.auth-footer {
  position: relative;
  z-index: 1;
  width: min(1000px, 100%);
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 24px;
  flex-wrap: wrap;
}

.auth-legal {
  color: rgba(255, 255, 255, 0.72);
  font-size: 0.82rem;
  line-height: 1.5;
}

.auth-legal p {
  margin: 0;
}

.auth-legal-nav {
  display: flex;
  gap: 20px;
  flex-wrap: wrap;
}

.auth-legal-nav a {
  color: rgba(255, 255, 255, 0.82);
  font-size: 0.82rem;
  text-decoration: none;
}

.auth-legal-nav a:hover {
  text-decoration: underline;
}

.auth-legal-nav a:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 2px;
}

@media (max-width: 720px) {
  /* Mobile keeps the clean single column; horizontal padding is trimmed so the
     content is not cramped and never overflows (task #442 v3). Exact mobile
     overrides: shell gutter 20px 14px, columns 28px 22px, header 24px 24px,
     logo 46px (still visually substantial). */
  .auth-shell {
    padding: 20px 14px;
  }

  .auth-grid {
    grid-template-columns: 1fr; /* one column, form first (DOM order) */
  }

  .auth-aside {
    border-left: none;
    border-top: 1px solid var(--line);
  }

  .auth-main,
  .auth-aside {
    padding: 28px 22px;
  }

  .auth-header {
    padding: 24px 24px;
  }

  .auth-logo {
    height: 46px;
  }

  /* Footer stacks: legal first (DOM order), links after, with roomier taps. */
  .auth-footer {
    flex-direction: column;
    align-items: flex-start;
    gap: 16px;
  }

  .auth-legal-nav {
    gap: 12px 22px;
  }

  .auth-legal-nav a {
    padding: 4px 0;
  }
}

/* --- password checklist (reset step, task #442) ---
   Live requirements list mirroring the backend policy exactly (a minimum
   length plus a confirmation match). State is not colour-only: the indicator
   swaps from an empty ring to a filled purple tick, and a visually-hidden
   status word updates for assistive tech (auth-checklist.js). */
.pw-check {
  list-style: none;
  margin: 0;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: 0;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.pw-check-item {
  display: flex;
  align-items: center;
  gap: 10px;
  font-weight: 400;
  color: var(--muted);
}

.pw-check-icon {
  flex: 0 0 auto;
  position: relative;
  width: 20px;
  height: 20px;
  border-radius: 0; /* square marker to match the square auth aesthetic */
  border: 2px solid var(--line);
  background: #ffffff;
}

.pw-check-item[data-ok='true'] {
  color: var(--ink);
}

.pw-check-item[data-ok='true'] .pw-check-icon {
  border-color: var(--brand);
  background: var(--brand);
}

.pw-check-item[data-ok='true'] .pw-check-icon::after {
  content: '';
  position: absolute;
  left: 6px;
  top: 2px;
  width: 5px;
  height: 10px;
  border: solid #ffffff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* --- six-digit code entry (task #445) ---
   One real <input name="code"> plus six decorative squares. Before
   static/otp-code.js runs (or with JavaScript off) the squares are not shown
   and the input is an ordinary, visible, submittable text field. Once the
   script marks the control ready, the squares appear and the SAME input is
   stretched transparently across them: it keeps its place in the layout, the
   focus order and the accessibility tree (never display:none, never
   visibility:hidden), so clicking any square focuses it and every keystroke,
   caret move and paste is the browser's own behaviour. */
.otp {
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* Room above the boxes so they read as their own step, separate from the
     sentence that explains them (Sam, task #445). The control is the first
     rendered child of the form (the email field is hidden), so this is the
     whole gap from the intro copy, on top of .auth-intro's 4px. */
  margin-top: 30px;
}

.otp-control {
  position: relative;
}

/* Hidden until enhanced: with no script there is nothing to fill them. */
.otp-boxes {
  display: none;
}

.otp[data-otp-ready] .otp-boxes {
  display: grid;
  /* Fractional columns, so six boxes always fit the column width and can never
     overflow, however narrow the phone. */
  grid-template-columns: repeat(6, 1fr);
  gap: 8px;
}

.otp-box {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 58px;
  border: 1px solid var(--line);
  /* The ONE deliberate exception to the square auth aesthetic (Sam, task #445):
     a restrained 4px on the code boxes only. Nothing else here is rounded: not
     the real input, the button, the panel or any other control. */
  border-radius: 4px;
  background: var(--card);
  font-size: 1.5rem;
  font-weight: 700;
  line-height: 1;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.otp-box[data-filled='true'] {
  border-color: var(--ink);
}

/* The current box, i.e. where the caret is. Only ever set while the input has
   focus, so this doubles as the control's focus indicator. */
.otp-box[data-active='true'] {
  border-color: var(--brand);
  box-shadow: inset 0 0 0 2px var(--brand);
}

.otp[data-otp-ready] .otp-input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  /* Transparent rather than removed: the value stays in the real field, which
     is what autofill, password managers and form submission use. */
  color: transparent;
  caret-color: transparent;
  text-align: center;
  font: inherit;
  z-index: 1;
}

/* No doubled focus ring: the active box carries it (see above). */
.otp[data-otp-ready] .otp-input:focus,
.otp[data-otp-ready] .otp-input:focus-visible {
  outline: none;
}

/* Selecting or replacing digits must not paint a highlight over the squares. */
.otp[data-otp-ready] .otp-input::selection {
  background: transparent;
}

/* Rejected code: the row itself carries the state, matching the shell's
   message above it (which the field points at with aria-describedby). */
.otp .otp-input[aria-invalid='true'] ~ .otp-boxes .otp-box {
  border-color: var(--error);
}

/* Breathing room under the primary action, before the "start again" line
   (Sam, task #445). Scoped to the code stage via .otp-form, so every other auth
   form keeps its existing rhythm. The following paragraph's 16px top margin
   collapses into this, so 36px is the gap you actually get. */
.otp-form {
  margin-bottom: 36px;
}

@media (max-width: 720px) {
  .otp-box {
    height: 52px;
    font-size: 1.3rem;
  }

  .otp[data-otp-ready] .otp-boxes {
    gap: 6px;
  }

  /* Proportionally tighter on a phone, so Continue stays above the fold. */
  .otp {
    margin-top: 22px;
  }

  .otp-form {
    margin-bottom: 28px;
  }
}

/* --- browser card auto-refresh (task #447) ---
   The cue for a stamp that landed while the customer was looking at the page.
   One short settle, on the pass and its stamp row only (task #467): the QR, the
   layout and the scroll position are untouched, and nothing moves for anyone
   who has asked for reduced motion. */
.card-bump {
  animation: lk-card-bump 700ms ease-out;
}

@keyframes lk-card-bump {
  0% {
    transform: scale(1);
  }
  35% {
    transform: scale(1.045);
  }
  100% {
    transform: scale(1);
  }
}

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

/* ==========================================================================
   Loyalty Programs (task #452)
   --------------------------------------------------------------------------
   The dashboard's Program list and one Program's four management tabs:
   Overview, Offer, Card Design (task #508) and Resources (task #585).

   Everything in the `prog-` family is SQUARE. The older merchant surfaces keep
   the rounded `.card` they already had, so this block never touches them: no
   selector here is shared with the dashboard chrome, the auth shell, the public
   card page or the poster.
   ========================================================================== */

.prog-panel,
.prog-section,
.prog-notice,
.prog-empty,
.prog-settings-form,
.analytics-metric,
.analytics-panels {
  border-radius: 0;
}

/* --- shared panel --- */
.prog-panel {
  background: var(--card);
  border: 1px solid var(--line);
  padding: 22px;
  margin: 0 0 20px;
}

.prog-panel h2,
.prog-panel h3 {
  margin-top: 0;
}

.prog-narrow {
  max-width: 560px;
}

/* Add a Loyalty Program: submit and Cancel, side by side or stacked when they
   wrap, but never touching. Same row shape as .dash-actions/.billing-actions. */
.prog-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.prog-section {
  margin: 0 0 32px;
}

/* .prog-section-head is gone (task #500). The Programs listing was the only
   thing that ever used it: an h2 repeating the page title beside an Add a
   Program button. The title is now said once, by the page header, and the
   action sits in that header's own .pagehead-actions row with every other
   page's primary action. */

.prog-back {
  margin: 0 0 12px;
  font-size: 0.95rem;
}

/* .prog-error and .prog-saved are GONE (task #543). They were the Program
   family's own save confirmation and refusal, and both are now the shared app
   message bar at the top of the content column: one appearance for every "did
   that work?" in the app, and a visible one. Their fills were the measurement
   that settled it, #f1f8f4 and #fdf3f2 being 1.00:1 and 1.01:1 against --bg, so
   as blocks they simply were the page. */
.prog-notice,
.prog-empty {
  border: 1px solid var(--line);
  padding: 12px 14px;
  margin: 0 0 16px;
}

/* The disabled-Program notice STAYS here, and is deliberately not a message: it
   is a standing state rather than the outcome of an action, so it belongs on the
   page it describes and must not be able to slide away. */
.prog-notice {
  background: #f4f2f9;
}

.prog-empty {
  background: var(--card);
  color: var(--muted);
}

/* --- one Program: head --- */
.prog-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin: 0 0 20px;
}

.prog-head-text {
  min-width: 0;
}

.prog-title {
  margin: 0 0 4px;
  overflow-wrap: anywhere;
}

.prog-head-actions {
  flex: 0 0 auto;
}

/* --- THE shared Program row (task #504 final pass) --------------------------

   Overview, Offer and Card Design are three pages of ONE management area, so
   they share one two-column shape rather than three that merely agreed: two
   thirds of content in a white .prog-panel on the left, one third of bare page
   background carrying the iPhone on the right, and an exact 60px between them.
   Before this there were three shapes - 1fr 1fr, 1fr 1fr and 1fr 380px - at
   three gaps, on three different outer widths.

   2fr / 1fr over minmax(0, ...) tracks, so the split is exactly two thirds and
   one third of what is left after the gap, and no track can be pushed past its
   share by its own content. In the 1120px app column (task #497) that is
   706.67px and 353.33px; the frame's own 374px ceiling means the iPhone is
   never upscaled at any width.

   column-gap and row-gap are stated separately on purpose: the column gap is
   the 60px, and the row gap is what is actually seen once the columns stack. */
.prog-layout {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  column-gap: 60px;
  row-gap: 32px;
  align-items: start;
}

/* Grid items default to min-width: auto, which lets a wide child (a long
   unbroken URL, a table) push its whole track past the viewport. Pinning them
   to 0 is what makes these three pages shrink honestly on a phone. */
.prog-layout > * {
  min-width: 0;
}

/* The right-hand third: the iPhone, straight on the page background. No panel,
   no border, no heading and no caption - the frame is self-explanatory (task
   #473), and a second white box around it would have read as a control. */
.prog-preview-rail {
  min-width: 0;
}

/* Centred in its own column. A no-op at desktop, where the column is narrower
   than the frame's 374px ceiling so the frame simply fills it, and what puts
   the phone in the middle of the page once the columns stack. */
.prog-preview-rail .phone-frame {
  margin: 0 auto;
}

/* Sticky while a long form scrolls past it: Offer and Card Design opt in,
   the Overview does not (its left column is short, so nothing there would ever
   travel far enough for sticking to mean anything).

   align-self: start is what lets it stick at all inside a grid: a stretched
   item is already as tall as its track and has nowhere to go. Containment
   needs no rule of its own - a sticky element cannot leave its containing
   block, and that block is this grid, which ends well above the page footer.
   So the preview can never ride over the footer however far the form runs. */
@media (min-width: 981px) {
  .prog-preview-rail-sticky {
    position: sticky;
    top: 24px;
    align-self: start;
  }
}

/* A preview taller than the space it would be pinned into cannot be read while
   it sticks: its lower half would sit permanently below the fold. Below this
   height it scrolls with the page instead, which is exactly what these pages
   did before. The threshold is the frame's tallest rendered height in this
   column (353.33 x 1539/748 = 727px) plus the offset above it and a little
   below, and it is also why 200% zoom - where the viewport is half as tall in
   CSS pixels - never sticks. Declared after the rule above so it wins when
   both match. */
@media (max-height: 800px) {
  .prog-preview-rail-sticky {
    position: static;
  }
}

.prog-copy-row {
  display: flex;
  gap: 8px;
  align-items: stretch;
}

.prog-url {
  flex: 1 1 auto;
  min-width: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.9rem;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  background: #f7f6fa;
  color: var(--ink);
}

.prog-copy-btn {
  flex: 0 0 auto;
}

.prog-copy-status {
  min-height: 1.4em;
  margin: 8px 0 0;
  font-size: 0.9rem;
  color: var(--muted);
}

.prog-copy-status[data-tone='ok'] {
  color: var(--success);
}

.prog-copy-status[data-tone='warn'] {
  color: var(--error);
}

/* --- the enrolment panel, relaid (task #585) -------------------------------

   Heading across the top, then two columns under it: the code on the left, and
   what the code is plus the link to copy on the right. It used to be one column
   of five stacked things, of which the last two were downloads that have their
   own tab now (Resources).

   The QR column is a fixed size rather than a fraction, because the thing in it
   has one right size: a QR that grows with the panel is a QR nobody needs bigger,
   and the sentence beside it wants every pixel it can have to stay near a
   readable measure. The 60px-gap grid outside this leaves the panel about 620px
   wide on a 1120px desktop, so the text column lands around 390px.

   The size is a custom property because the column and the picture's own cap
   have to agree: the grid track sizes the column at a desktop, and the cap is
   what stops the code blowing up to fill a phone once the two stack. */
.prog-enrol-body {
  --enrol-qr-size: 200px;
  display: grid;
  grid-template-columns: var(--enrol-qr-size) minmax(0, 1fr);
  gap: 24px;
  align-items: start;
}

/* The picture and the download that covers it are ONE box, sized to the picture
   so the overlay below can be `inset: 0` and land exactly on it. Without this
   the anchor would stretch to the column and a merchant would get the dark veil
   while pointing at white space beside the code. */
.prog-qr-frame {
  position: relative;
  width: 100%;
  max-width: var(--enrol-qr-size);
}

.prog-qr {
  margin: 0;
  /* The QR is an inline <svg>, so its line box would otherwise leave a few
     pixels of descender space under it that the absolutely positioned overlay
     does not cover, showing as a pale strip along the bottom edge on hover. */
  line-height: 0;
}

.prog-qr svg {
  display: block;
  width: 100%;
  height: auto;
  background: #fff;
}

/* THE DOWNLOAD, WHICH IS THE PICTURE.

   A real link to the real PNG route, covering the whole code. It is always in
   the document and always in the tab order; what hover changes is only whether
   it is painted. So the keyboard gets the identical control in the identical
   place with the identical accessible name, revealed by :focus-visible exactly
   as the pointer reveals it by :hover - which is the whole reason this is a
   link with an .sr-only label rather than a background image with a handler. */
.prog-qr-download {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #ffffff;
  /* The app's own ink, at the weight that leaves the code legible underneath:
     dark enough that the white glyph reads instantly against the code's own
     black modules, light enough that what is being downloaded is still visible
     through it. */
  background: rgba(23, 18, 43, 0.78);
  text-decoration: none;
  opacity: 0;
  transition: opacity 140ms ease;
}

.prog-qr-download:hover,
.prog-qr-download:focus-visible {
  opacity: 1;
}

/* Inside the code rather than around it: an outline offset outwards would sit
   on the panel's white and be the one part of the control visible before it is
   focused. `outline-offset` is negative for the same reason the veil is inset. */
.prog-qr-download:focus-visible {
  outline: 3px solid #ffffff;
  outline-offset: -6px;
}

@media (prefers-reduced-motion: reduce) {
  .prog-qr-download {
    transition: none;
  }
}

/* Forced-colours mode drops the veil's background and repaints the glyph, so
   the affordance would be an invisible link over the code. Give it the system
   canvas and a permanent border instead: the picture is covered while it is
   pointed at or focused, which is what the veil does anyway. */
@media (forced-colors: active) {
  .prog-qr-download {
    background: Canvas;
    border: 2px solid LinkText;
  }
}

/* One column below the panel's own comfortable width, code first. The frame
   keeps its --enrol-qr-size cap, so the code is the same size stacked as it is
   beside the text and never blows up to fill a phone. */
@media (max-width: 720px) {
  .prog-enrol-body {
    grid-template-columns: minmax(0, 1fr);
  }
}

.prog-downloads {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

/* --- one resource, with its own preview (task #707 follow-up) --------------

   THE SHAPE EVERY RESOURCE IS HEADING FOR, and today the Small Sticker QR's
   alone: a heading with a rule across the WHOLE content box, then the controls
   on the left and a live picture of the file on the right. The Counter Poster
   and the QR PNG keep the plain button row above until they become sections of
   this shape themselves.

   THE RULE BELONGS TO THE BOX, NOT THE COLUMN. It separates one resource from
   the one above it, so it runs the panel's full width; stopping it where the
   form stops would read as part of the form. That is why the heading sits
   OUTSIDE the two-column body rather than in its first cell.

   The panel's own rule zeroes every h3's top margin, so the room and the line
   are put back deliberately and at a specificity that beats it. */
.prog-panel .resource-tool-title {
  margin-top: 26px;
  padding-top: 20px;
  border-top: 1px solid var(--line);
}

/* Controls left, preview right. The preview track is sized in ch of nothing:
   it is a fixed, modest column, because the picture is a REFERENCE and the
   controls are the work. minmax(0, ...) on both tracks stops a long word or a
   wide picture from pushing the grid past its container. */
.resource-tool-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
  gap: 32px;
  align-items: start;
}

.resource-tool-intro {
  margin-top: 0;
}

/* The picture of what will be downloaded. The border is the paper's edge, and
   it still earns its keep now that the artefacts are printed in the merchant's
   own colour (task #851): it is what says where a full-bleed coloured sign
   STOPS, on a panel that is not that colour. The white field behind it is the
   ground the picture arrives on before the first response does. */
.resource-preview-image {
  display: block;
  width: 100%;
  height: auto;
  background: #ffffff;
  border: 1px solid var(--line);
}

.resource-preview-note {
  margin: 10px 0 0;
}

/* One column below the panel's own comfortable width, controls first: the
   preview is a reference, so on a phone it belongs under the thing it is a
   preview OF rather than above it. It also stops being full-bleed there - a
   64 x 40 sticker blown up to the width of a phone is bigger than the sticker
   will ever be in the merchant's hand. */
@media (max-width: 860px) {
  .resource-tool-body {
    grid-template-columns: minmax(0, 1fr);
    gap: 24px;
  }

  .resource-preview-image {
    max-width: 320px;
  }
}

/* --- a setup guide under a resource's downloads (task #851 review, Sam) ----

   THE LINK IS THE LINE AFTER THE BUTTONS. It wears the same quiet button
   treatment as the two downloads above it, at the same 10px gap the button row
   keeps between its own children, so the three read as one group: print this,
   print that, and here is how to put it together. */
.resource-guide {
  margin-top: 10px;
}

/* THE GUIDE IS A PICTURE, SO THE DIALOG WRAPS ONE. The picture decides its own
   width (below) and the box shrinks to fit it; the box is capped at the
   viewport less a margin on every side, and a picture that will not fit
   scrolls INSIDE the box, sideways on a phone, rather than under it. Square
   corners and no border, like every dialog here (task #839: nothing on this
   site is rounded). The two height lines are one rule said twice: svh is the
   phone's honest height and the older unit is the fallback for a browser that
   has no such thing. */
.resource-guide-dialog {
  border: none;
  border-radius: 0;
  padding: 16px 20px 20px;
  width: fit-content;
  max-width: calc(100% - 32px);
  max-height: calc(100vh - 32px);
  max-height: calc(100svh - 32px);
  overflow: auto;
  background: var(--card);
  color: var(--ink);
}

.resource-guide-dialog::backdrop {
  background: rgb(17 17 17 / 0.45);
}

/* A closed dialog is not on the page. Stated rather than left to the UA
   stylesheet for the same reason the confirmations state it: a browser that
   does not know <dialog> would otherwise print the guide into the tab under
   the buttons, unasked. */
.resource-guide-dialog:not([open]) {
  display: none;
}

/* Title on the left, the cross on the right, on one line above the picture,
   and PINNED THERE: the row is sticky against both edges of the scrolling box,
   so on a phone scrolling sideways through the four steps the cross never
   leaves the screen. The negative top margin takes the row up to the box's
   own padding edge so nothing scrolls past in the strip above it; the sides
   are left alone, so the row is exactly as wide as the picture's column and
   the cross can never be pushed past the box. */
.resource-guide-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  position: sticky;
  top: 0;
  left: 0;
  z-index: 1;
  margin: -16px 0 12px;
  padding: 16px 0 8px;
  background: var(--card);
}

.resource-guide-title {
  margin: 0;
  font-size: 1.2rem;
  line-height: 1.25;
}

/* The cross: a big enough hit target, the page's muted ink, and the same
   focus ring the rest of the product uses. */
.resource-guide-close {
  flex: none;
  margin: 0 -10px 0 0;
  padding: 6px 10px;
  border: none;
  background: none;
  color: var(--muted);
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
}

.resource-guide-close:hover {
  color: var(--ink);
}

/* THE PICTURE IS SIZED TO BE READ, NOT TO FIT. Its width is whatever fills
   the viewport's height with the title row above it (the 128px is the box's
   margin and padding plus that row), so a desktop sees the whole sheet at
   once with nothing to scroll; capped at 1200px, above which a 1800px sheet
   gains nothing; and NEVER narrower than 900px, at which the smallest type on
   the sheet is still about 13px. On a phone that floor is wider than the
   screen, so the box scrolls sideways through the four steps, which is what a
   landscape guide on a portrait phone should do: shrunk to fit it would be a
   picture of a guide rather than a guide. --resource-guide-aspect is the
   picture's own width over height, set on the element by the view, so the
   height arithmetic holds for any sheet. */
.resource-guide-image {
  display: block;
  width: clamp(900px, (100vh - 128px) * var(--resource-guide-aspect, 1.44), 1200px);
  width: clamp(900px, (100svh - 128px) * var(--resource-guide-aspect, 1.44), 1200px);
  height: auto;
}

/* Two fields side by side: they are one sentence in two halves (what goes above
   the code, what goes below it), and stacking them made a short form look like
   a long one. They fall to one column at the sheet's own established phone
   breakpoint, below. */
.prog-sticker-lines {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px;
  margin-bottom: 16px;
}

/* The signs' two fields stack instead of sitting side by side (task #826). They
   are whole sentences rather than a sticker's two half-lines, and a headline
   the width of half a column wraps in the field while reading as one line on
   the sign, which is the one thing this form must not do. */
.prog-sign-lines {
  display: grid;
  gap: 16px;
  margin-bottom: 16px;
  max-width: 46ch;
}

.prog-sign-hint {
  margin: 0 0 14px;
  max-width: 52ch;
}

.prog-sticker-lines label,
.prog-sign-lines label {
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-weight: 600;
}

/* The square field treatment this surface uses: the Program pages are square by
   design, so they keep their own control rule rather than reading the rounded
   --control-height token the Account forms take. */
.prog-sticker-lines input[type='text'],
.prog-sign-lines input[type='text'] {
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  width: 100%;
}

.prog-sticker .check {
  display: flex;
  margin-bottom: 12px;
}

.prog-sticker .hint {
  margin: 0 0 14px;
  max-width: 52ch;
}

@media (max-width: 720px) {
  .prog-sticker-lines {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* --- the lifecycle (task #554) ---------------------------------------------

   Two small pieces of layout and nothing else. The panel itself is the shared
   square .prog-panel and the buttons are the product's existing quiet treatment
   of a destructive action (.btn-ghost, as the Users table's Remove already is),
   so switching a Program off and deleting one need no colour of their own: the
   copy beside them carries the weight. */

/* The confirmation the delete form requires. The box and its sentence sit on one
   row with the text able to wrap under itself rather than under the box, so a
   long sentence stays readable at 320px; `align-items: flex-start` keeps the box
   level with the first line rather than centred against the block. */
.prog-confirm {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  margin: 0 0 14px;
  font-size: 0.95rem;
}

/* The shared field rule (task #501) equalises control heights across the app,
   which is right for text inputs and wrong for a checkbox: it would stretch this
   one into a rectangle. Sized here and pinned so it cannot shrink in a flex row. */
.prog-confirm input[type='checkbox'] {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  min-height: 0;
  margin: 2px 0 0;
}

.prog-delete {
  margin: 0;
}

/* .prog-disabled-list and .prog-disabled-name are gone (task #554 follow-up).
   They dressed the separate "off" section this listing briefly grew underneath
   its cards; a disabled Program is a marked tile in the one grid now, so the
   rules went with the markup rather than being left to dress nothing. Its
   treatment lives with the rest of the tile's, at .dash-prog-card-disabled. */

/* --- the customers' card in the iPhone frame (task #465) -------------------

   /static/device-phone-frame.png is 748x1539 with a transparent 627x883
   display window at (60, 145). The markup (src/views/card.tsx) derives
   every position from those exact ratios as inline percentages, so the pass
   stays registered to the window at any width; the only fixed pixel value here
   is the maximum rendered width. The frame PNG is layered OVER the pass and
   the window's transparency lets the pass show through.

   THE PASS IS DRAWN THREE SOURCE PIXELS PAST THE APERTURE ON EVERY EDGE (task
   #560), which is why the inline percentages on .phone-frame-screen are a hair
   wider than the ratios above and not equal to them. It is a bleed and nothing
   else: the aperture has not moved, the pass runs under the bezel, and no
   device pixel on any edge can be left with the pass already finished and the
   frame not yet opaque. See PHONE_FRAME.windowOverdraw in src/views/card.tsx
   for the seam that closes and how the number is sized; there is deliberately
   no length here, so the geometry cannot be edited in two places. */
.phone-frame {
  position: relative;
  width: 100%;
  max-width: 374px; /* half the source PNG: crisp on 2x screens, never upscaled */
  /* Unboxed on the Program page (task #473): flush with the top of its column
     and pushed to the right edge; the stacked layout recentres it below. */
  margin: 0 0 0 auto;
  container-type: inline-size;
}

.phone-frame-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
  /* The frame is glass: taps and clicks pass through to whatever is under it. */
  pointer-events: none;
  user-select: none;
}

.phone-frame-screen {
  position: absolute;
  z-index: 1;
  display: flex;
}

/* The pass fills the window edge to edge, the way Wallet lays a pass out. The
   pixel font-size is only the fallback for browsers without container query
   units; with them the type scales with the frame, and everything inside is
   sized in em / %, so the whole face is proportional at any rendered width. */
.wallet-pass {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  background: var(--pass-bg, var(--brand));
  color: var(--pass-ink, #fff);
  font-size: 14px;
  font-size: 3.75cqw;
  border-radius: 0.9em;
  /* THE HEADER IS APPLE'S SLOT, corner to corner: 160 x 50pt of a 375pt pass,
     in this face's em, WITH the pass's own margins inside it (task #709). Every
     length here is derived in PASS_HEADER in src/views/card.tsx, which is where
     they are decided and where the reasoning lives; test/canonical-card.test.ts
     asserts these declarations still agree with it.

     The side padding is a NAMED value because two things on the face have to
     cancel it exactly: the artwork band below, and the logo's own box, which is
     the slot LESS this margin because that is where Wallet's logo stops. The
     calc is the cancellation, so the margin and the logo cannot be edited
     apart. .cc-monogram on the public designer reads the logo height. */
  --pass-logo-slot-w: 11.38em;
  --pass-logo-slot-h: 3.56em;
  --pass-pad-x: 0.71em;
  --pass-pad-top: 0.36em;
  --pass-header-gap: 0.36em;
  --pass-logo-max-w: calc(var(--pass-logo-slot-w) - var(--pass-pad-x));
  --pass-logo-max-h: calc(var(--pass-logo-slot-h) - var(--pass-pad-top));
  padding: var(--pass-pad-top) var(--pass-pad-x) 1em;
  overflow: hidden;
  /* THE CARD STATES ITS OWN ALIGNMENT, so it cannot be leaned on by the box it
     is put in (task #621, Sam). Every field on a pass runs from the left unless
     a rule below says otherwise: the header count and the second secondary
     field say `right`, and nothing else does.

     WHAT WENT WRONG WITHOUT THIS. .wallet-card-standalone centres its text, for
     the page copy and the wallet buttons that sit UNDER the card, and that
     centring reached into the card as well. So on the customer's own browser
     card the "Card holder" value was centred inside a field box only as wide as
     its own label: a short name like "Test" hung in the middle of the column
     while the label above it sat against the left edge, and it read as though
     the name were floating rather than filling the block. The reward beside it
     never showed the fault because it is explicitly right-aligned, which is
     exactly the rule that had assumed the first field was left-aligned.

     Fixed HERE and not on the wrapper, because the wrapper's centring is
     correct for the wrapper's own children. Every framed surface already
     computed `start`, so this changes nothing on any of them; it states what
     they were relying on. */
  text-align: left;
}

/* Inside the device frame the pass is square-edged: the frame's transparent
   window already rounds what you see, and a second radius underneath it would
   double-mask and leave dark slivers in the corners. Scoped to the framed
   composition ONLY - a pass rendered anywhere without the frame keeps the
   rounded corners above. */
.phone-frame .wallet-pass {
  border-radius: 0;
}

.wallet-pass-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75em;
}

.wallet-pass-brand {
  display: flex;
  align-items: center;
  gap: 0.5em;
  min-width: 0;
}

/* THE HEADER LOGO IS WHAT APPLE'S SLOT HAS LEFT once the pass's own margins are
   inside it (tasks #691 and #709, Sam). Both lengths are declared above, as the
   slot less the margin on that axis, and are DECIDED IN src/views/card.tsx
   (PASS_HEADER) with a test asserting these declarations still agree with it.
   Edit them there, not here.

   A MAXIMUM AND NOT A SIZE, which is where a third of the header's height went
   (task #709). This used to state `height: var(--pass-logo-h)`, so the box was
   always the full slot however tall the picture in it came out: a wordmark
   wider than the box's own shape was letterboxed, reserving empty card above
   and below itself and pushing the strip down. With both maxima and auto on
   both axes the browser fits the picture inside the box and the box IS the
   picture, so the header is as tall as what is actually drawn in it and never
   taller.

   Nothing is ever stretched: a square Brand Icon meets the height limit, a
   wordmark meets the width limit, and the aspect ratio is the browser's own for
   a replaced element with two auto lengths. object-fit: contain says the same
   thing a second time, for the case where flex shrinks the box. min-width: 0
   lets it give ground when the business name beside it needs the room, so a
   long name wraps instead of being pushed out of the header. */
.wallet-pass-logo {
  height: auto;
  width: auto;
  max-height: var(--pass-logo-max-h);
  max-width: var(--pass-logo-max-w);
  min-width: 0;
  object-fit: contain;
}

.wallet-pass-logotext {
  font-weight: 700;
  font-size: 1.15em;
  overflow-wrap: anywhere;
}

.wallet-pass-field {
  display: block;
  min-width: 0;
}

.wallet-pass-label {
  display: block;
  font-size: 0.72em;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  opacity: 0.85;
}

.wallet-pass-value {
  display: block;
  overflow-wrap: anywhere;
}

.wallet-pass-headerfield {
  text-align: right;
  flex: 0 0 auto;
}

/* THE HEADER ROWS GROUP AT THE RIGHT (task #837), which they have to do the
   moment there can be two of them. The strip is space-between, so with a brand
   and one field the field sits hard right; with a brand and TWO the middle one
   would have floated off into the space between them, and the card would read
   as three separate things rather than a name on the left and the card's own
   two facts on the right. The auto margin on the FIRST field takes that whole
   space at once, and everything after it is spaced by the strip's own gap. It
   changes nothing about a one-field card: hard right is hard right. */
.wallet-pass-brand + .wallet-pass-headerfield {
  margin-left: auto;
}

/* AND THE NAME IS THE ROW THAT GIVES WAY. A Program Name may be up to 24
   characters against a business name of up to 80, in a strip the width of a
   phone: something has to shrink, and it is the row that was added rather than
   the identity that was always there. `flex: 0 1 auto` lets it, and the shared
   .wallet-pass-value already wraps anywhere, so a long pair wraps instead of
   pushing the logo out of the header. */
.wallet-pass-namefield {
  flex: 0 1 auto;
}

.wallet-pass-primaryfield {
  margin-top: 1.1em;
}

.wallet-pass-primaryfield .wallet-pass-value {
  font-size: 2.1em;
  font-weight: 700;
  line-height: 1.15;
}

/* The visual stamp row (task #474): the same StampDisplay the .pkpass strip
   artwork is drawn from, as circles sized in em so they scale with the pass.
   Earned stamps are solid discs, remaining ones outline rings - a shape
   difference on top of the weight difference, readable without colour. Both
   are drawn in the pass ink, which is already contrast-picked against the
   brand background. */
.wallet-pass-stamps {
  /* The header row to the strip. Apple's own gap is next to nothing: the strip
     begins where the 50pt slot ends, and the named value is what task #709 took
     the last of the preview's extra header height out of. */
  margin-top: var(--pass-header-gap);
  display: flex;
  flex-direction: column;
  gap: 0.5em;
  align-items: center;
}

.wallet-pass-stamp-row {
  display: flex;
  justify-content: center;
  gap: 0.42em;
}

.wallet-pass-stamp {
  box-sizing: border-box;
  /* The fallback is what one and two rows have always been; three and four rows
     step down, so the stamp block stays about the height two rows occupy and
     the card does not grow past the phone frame. Set by the renderer from
     stampMarkEm() in src/wallet/stamp-display.ts. */
  width: var(--pass-stamp-size, 1.5em);
  height: var(--pass-stamp-size, 1.5em);
  border-radius: 50%;
  border: 0.14em solid var(--pass-ink, #fff);
  /* UNEARNED_MARK_OPACITY in src/card-scene.ts, restated because a stylesheet
     cannot import a TypeScript constant, and pinned to it by
     test/card-scene.test.ts. This row is the fourth surface a card's marks are
     drawn on (the other three go through the scene), and it is the browser
     preview of the very Programs whose Apple strip the scene rasterises, so
     the two must agree about how faint a mark still to collect is. They did
     not: this was 0.55 and the scene was 0.6. */
  opacity: 0.35;
}

.wallet-pass-stamp.earned {
  background: var(--pass-ink, #fff);
  opacity: 1;
}

/* Rewards saved for later (task #736): "Available rewards: 2", under the marks
   and centred, on the cards that carry the shipped stamp discs. A designed card
   draws the same words in the same place from the scene instead
   (pushAvailableRewards in src/card-scene.ts), which is why this rule sits with
   the discs rather than with the band.

   SMALL, and deliberately so: it is a footnote under the marks, not a second
   headline. The em size mirrors REWARD_LINE_HEIGHT in the scene, which sets it
   at 6% of the stamp area's height. The row above is not moved - the flexbox
   gap already leaves this line the space the scene centres it in. */
.wallet-pass-rewards {
  margin: 0;
  font-size: 0.62em;
  font-weight: 600;
  letter-spacing: 0.01em;
  text-align: center;
  color: var(--pass-ink, #fff);
}

/* --- the designed card's artwork band, edge to edge ------------------------

   THE ONE PLACE THE ARTWORK BAND'S GEOMETRY IS DECIDED, and it is shared by
   every Apple-style surface this product draws: the Card Design page's iPhone,
   a Program's Overview and Offer previews, the account dashboard, the Programs
   listing's half-iPhone and the customer's own browser card. They all render
   the same .wallet-pass tree (src/views/card.tsx), so this rule reaches all of
   them at once and none of them needs a negative margin of its own.

   WHY IT BLEEDS. In Apple Wallet a storeCard's strip image spans the full
   width of the pass: it meets both edges and is cut off by the pass's own
   corner radius, with no margin anywhere. LoyalPack's preview drew the same
   artwork as an ordinary child of the padded face, so it stopped 1.15em short
   on each side and sat on two strips of flat card colour that a real pass does
   not have. The rasterised strip Apple actually signs was always full bleed,
   which means the preview was disagreeing with the pass it was previewing.

   THE MISTAKE THIS RULE WAS WRITTEN TO AVOID, because the first attempt made
   it. Bleeding the band with a negative inline margin and leaving the SVG at
   `height: auto` widens the box and lets the height follow the viewBox ratio,
   so the artwork got about 11px TALLER on the standalone card and ate half the
   clearance above the barcode plate. Sam asked for the left and right gaps to
   go. Nothing was said about the card's vertical rhythm, and changing it was
   not ours to do.

   HOW, therefore, IN TWO PARTS.

   The BAND keeps the height it had before it bled. It is a stretched item of
   the column flex face, so its inline size is the face's CONTENT width, and
   aspect-ratio turns that width into exactly the height the in-flow SVG used
   to produce. --pass-scene-ratio is written onto the element by the renderer
   from the scene's own box (src/views/card.tsx, from SCENE_BOXES.web in
   src/card-scene.ts), so this stylesheet is not restating a number that lives
   in TypeScript and cannot drift from it. The literal here is only a fallback.

   The ARTWORK bleeds, inside that fixed band. The SVG is taken out of flow and
   pinned to the band's block edges, then widened on each side by exactly the
   face's own side padding, read from the custom property that DECLARES that
   padding so the cancellation cannot drift from the thing it cancels. Because
   it is out of flow it cannot push the band's height around, which is the
   whole point.

   NOTHING IS STRETCHED. The scene SVG carries preserveAspectRatio="xMidYMid
   slice" (src/scene-svg.ts), so a viewport wider than its own ratio is COVERED,
   not distorted: the drawing scales up uniformly and the surplus is trimmed
   evenly off the top and bottom, inside the SVG's own viewport. About 5.3% of
   the artwork's height goes (twice --pass-pad-x over the card's width, in the
   face's own em), which is what going edge to edge costs when the height is
   held; the alternative is a taller card, which is what the customer's own
   browser card takes below for the reason given there.

   WHAT DOES NOT MOVE. The top margin, the band's height, the vertical rhythm
   below it and the barcode plate pinned to the foot. The face already clips
   (overflow: hidden), so the widened artwork is cut by the pass's rounded
   corners where it has them, by the square corners inside the full iPhone
   frame, by the top-only radius in the half frame, and by the half frame's
   aperture at the foot. The default flexbox stamp row is NOT given this
   treatment: it is discs on the card, not a picture, and Wallet draws no strip
   for it.

   Only the SVG scene band gets it, never .wallet-pass-stamps generally, so a
   Program on the shipped default design renders exactly as it did. */
.wallet-pass-scene {
  position: relative;
  align-self: stretch;
  aspect-ratio: var(--pass-scene-ratio, 375 / 144);
}

/* The artwork, bled out of the band on both sides and clipped by the face.
   Absolute so it cannot feed its height back into the band; both offsets are
   the face's own side padding, negated. The SVG's own slice fit does the
   cropping, so there is no vertical overflow here to hide. */
.wallet-pass-scene > svg {
  position: absolute;
  display: block;
  inset-block-start: 0;
  inset-inline-start: calc(var(--pass-pad-x) * -1);
  width: calc(100% + var(--pass-pad-x) * 2);
  height: 100%;
}

/* THE CUSTOMER'S OWN CARD SHOWS THE WHOLE STRIP, WITH NOTHING TRIMMED
   (task #777, Sam): "the Available rewards line is touching the bottom of the
   stamp area, it doesn't display like that in the other previews".

   WHY IT WAS TOUCHING, and it is the slice above rather than anything about the
   line. pushAvailableRewards in src/card-scene.ts centres the line in the space
   under the lowest row of marks, which on a twelve-stamp 375x144 strip leaves
   about 4 units of clear card below the letters: the same 4 units the Apple
   strip and the Google hero rasterise, because all three surfaces draw the one
   scene. But the band above holds the height its UNBLED width gives it, so the
   bled SVG covers a viewport wider than its own ratio and `slice` trims 2.7% of
   144 units, 3.8 of them, off the bottom. Measured on the live card at a 390px
   viewport, that left the letters finishing 0.2px above the band's bottom edge.

   NOBODY MET IT UNTIL NOW because this is the only surface that draws the line
   at all. An admin preview's example balance banks no reward (demoStamps and
   programPassRequest in src/views/card.tsx), and the two wallets rasterise the
   strip whole, at its own ratio, with no bleed and so no slice.

   THE FIX IS THE BAND'S GEOMETRY, NOT A NUDGE TO THE LINE. Where the line sits
   is right, and it is shared with two rasterisers whose output must not move.
   So here the BAND itself bleeds, exactly as .cc-marks does on the public card
   designer, and its aspect-ratio therefore applies to the width the artwork
   actually occupies. The SVG's viewport comes out at 375/144 exactly, `slice`
   has nothing left to trim, and the whole strip shows across the card's full
   width, which is where Apple Wallet puts a storeCard's strip in the first
   place. The SVG's own bleed is cancelled because the band now carries it.

   THE COST IS 0.55em OF CARD HEIGHT (twice --pass-pad-x, at the strip's ratio),
   about 7px on a 390px phone, and the unframed card is the one surface with
   room for it: its height is whatever its contents come to, no frame lies over
   it and no aperture crops its foot. That is precisely why the framed previews
   are left alone, where task #523 held the band's height on purpose so a card
   with four rows of marks cannot grow past the phone's window.

   .customer-card, not .wallet-card-standalone: the landing page's hero card
   shares that wrapper and is a picture of a card in a page's layout, not the
   card a customer is reading their own balance off. */
.customer-card .wallet-pass-scene {
  margin-inline: calc(var(--pass-pad-x) * -1);
  width: calc(100% + var(--pass-pad-x) * 2);
}

.customer-card .wallet-pass-scene > svg {
  inset-inline-start: 0;
  width: 100%;
}

/* The face's field row under the stamp artwork. Since task #691 it holds one
   field, the reward, so it runs from the left edge like everything else on the
   card: the cardholder block that used to sit here and push the reward to the
   right edge is gone from the canonical model (src/card-definition.ts), which
   means it is gone from every surface that draws a card.

   flex-start rather than space-between, and the right-align rule that used to
   follow this one is deleted rather than left in place. Both described a
   two-field row; kept, they would quietly right-align whatever a future second
   field turned out to be, which is a layout decision nobody would have made on
   purpose. The row stays a flex row because it is still a row of fields, and a
   second one would be added deliberately, with its alignment chosen then. */
.wallet-pass-secondary {
  display: flex;
  justify-content: flex-start;
  gap: 1em;
  margin-top: 1em;
}

/* The QR sits at the foot of the pass on a white plate (scanner contrast),
   where Wallet puts a storeCard barcode. margin-top: auto pins it to the
   bottom of the window whatever the text above it does. */
.wallet-pass-qr {
  margin-top: auto;
  align-self: center;
  width: 56%;
  background: #fff;
  border-radius: 0.5em;
  padding: 0.35em;
}

/* THE UNFRAMED CARD HAS NO SLACK FOR THAT AUTO MARGIN TO TAKE, so it has to be
   told the gap (task #621, Sam).

   Inside the phone frame the pass is stretched to the display aperture, so
   there is spare height between the holder and reward lines and the foot of the
   window, and `margin-top: auto` above turns all of it into clearance: about
   30px on the Card Design page's iPhone. The customer's own browser card at
   /card/:code is not stretched by anything. Its height is whatever its contents
   come to, the spare height is zero, and an auto margin of zero put the white
   barcode plate hard against the underside of the holder's name and the reward
   text, with not one pixel between them. The two blocks read as one crowded
   lump, and the plate looked like it was covering them.

   So on that surface alone the pin becomes a stated distance. It REPLACES the
   auto margin rather than adding to it, which is exactly right: auto resolves
   to nothing here, so this is the same rule with the nothing named. In em, so
   it tracks the card's own container-query type scale like every other length
   on the face, and the gap stays proportional from a 320px phone to the desk.

   Deliberately NOT put on .wallet-pass-qr itself. A framed card whose artwork
   has grown (four rows of stamps rather than two) can have very little spare
   height left, and a fixed margin there would push the barcode plate down past
   the bottom of a card that clips. */
.wallet-card-standalone .wallet-pass-qr {
  margin-top: 1.4em;
}

.wallet-pass-qr svg {
  display: block;
  width: 100%;
  height: auto;
}

/* The card code beneath the QR, as Apple Wallet prints the barcode's alt text:
   small and centred, in the pass ink and the pass's own font. Present only on
   an issued card (an admin preview's barcode names no card, so this never
   renders there). */
.wallet-pass-code {
  align-self: center;
  margin-top: 0.5em;
  font-size: 0.72em;
  letter-spacing: 0.08em;
  opacity: 0.85;
}

/* --- the compact half-phone preview (task #493, rebuilt task #500) ---------

   /static/device-phone-half-frame.png is 748x490: the top of the same device.
   Its alpha is transparent from y=145 to the foot of the file at y=489, across
   x=60..686, which is a 627 x 345 aperture. 627 is exactly the native width of
   Sam's nine decorative card PNGs, so each one is drawn at its natural size.
   (The window itself is drawn three source pixels past that on the left, the
   right and the top, to cover a sub-pixel seam at the bezel; the reasoning is
   with windowOverdraw in src/views/card.tsx, and every one of those pixels is
   under opaque bezel at every rendered width. The foot is not overdrawn: it is
   the foot of the frame, where the stats bar already tucks under.)

   Inside that aperture stand three layers, back to front: two decorative wallet
   cards and then the merchant's own canonical card, which is the FRONT of the
   stack and so sits lowest and paints over both. At rest the decoration is a
   pair of 50px lips and the merchant's card has the remaining 245; hover or
   focus collapses each lip to 16, which is exactly the corner radius, so the
   whole rounded top of each card survives and the merchant's card takes 313.
   The total is 345 in both states, so nothing below the frame can be pushed
   about. Each artwork is drawn its full native 130 tall against its lip, so its
   foot is always tucked under the rounded top corner of the card in front: that
   overlap, and the contact shadow drawn at it, are what make a stack read as a
   stack.

   The frame's foot is where .dash-prog-stats begins, so the stack is anchored
   to the bar rather than floating above it.

   Every position comes from those ratios as inline percentages (frameWindowStyle
   and halfFrameStackStyles in src/views/card.tsx), measured against a box whose
   height the frame's own aspect-ratio makes definite. The frame PNG lies OVER
   everything; the aperture lets the composition show through and the bezel
   rounds its corners. */
.phone-half-frame {
  position: relative;
  width: 100%;
  /* aspect-ratio is inline, from PHONE_HALF_FRAME, so the vertical percentages
     below have a definite height to resolve against. */
  container-type: inline-size;
}

.phone-half-frame-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  /* Above the card: the bezel and the stack artwork must cover it, not the
     other way round. */
  z-index: 2;
  /* The frame is glass: taps and clicks pass through to the tile link. */
  pointer-events: none;
  user-select: none;
}

/* The crop. left/top/width/height are inline percentages of the frame box. */
.phone-half-frame-window {
  position: absolute;
  z-index: 1;
  overflow: hidden;
}

/* One layer of the stack. top, height and --half-stack-shift are inline, from
   PHONE_HALF_FRAME.stack via halfFrameStackStyles. They are ratios of the
   WINDOW BOX, which is the aperture's 345 rows plus the 3 of top overdraw: tops
   of 0, 3+50 and 3+100 of that 348; heights of 130, 130 and 245 of it; and
   shifts of 0, -34/130 and -68/245 of the layer's OWN height, which is what a
   percentage translateY resolves against. The overdraw cancels out of the
   shifts, and out of what each layer shows inside the aperture: 50/50/245 at
   rest and 16/16/313 revealed.

   The reveal is a TRANSFORM and nothing else. No height animates, no layout is
   recalculated, nothing below the frame can be pushed about: the compositor
   moves two images and a card inside a box that has already been laid out.
   will-change promotes them so the whole stack moves on one composited frame
   and cannot tear away from the bezel drawn over it. */
.phone-half-frame-layer {
  position: absolute;
  left: 0;
  right: 0;
  width: 100%;
  transform: translateY(0);
  transition: transform 260ms cubic-bezier(0.22, 0.61, 0.36, 1);
  will-change: transform;
}

/* The middle artwork sits MID-aperture, so it takes the stack's rounded top
   corners. The back artwork does not, and must not - it is flush with the top
   of the aperture, where the frame's own transparency already rounds what you
   see, and a second radius underneath would open dark slivers in those two
   corners. (The real card takes the same radius further down, on the pass face
   itself rather than on its layer: the layer deliberately does not clip.) */
.phone-half-frame-art[data-half-frame-art='middle'] {
  border-radius: var(--half-stack-radius) var(--half-stack-radius) 0 0;
}

/* THE CONTACT SHADOW, which is what makes three overlapping pictures read as
   three physical cards rather than as three stripes of colour.

   It goes on the two layers that actually cover something: the middle artwork,
   which laps over the back one, and the pass face, which laps over the middle
   one. A card in a wallet casts its shadow UPWARDS onto the card behind it,
   because everything below its own top edge is hidden by the card itself, so
   the offset is negative and there is no blur worth the name: a 1px lift and a
   2px blur is a contact line, not a lift off the page. The back artwork gets
   none - it is flush with the top of the aperture and has nothing behind it to
   sit on.

   Declared on the two RADIUSED elements rather than on their layers, which buys
   two things. The shadow follows the rounded top corners exactly, so it hugs
   each lip instead of drawing a squared-off bar across it; and the layers that
   animate are left with a plain composited transform, with no filter for the
   compositor to re-rasterise on every frame of the reveal. The window's own
   overflow: hidden clips all of it, so nothing reaches past the aperture, and
   the side spread lands under the bezel with the overdraw.

   Ink at 0.30, measured rather than picked: on Sam's palest artwork it darkens
   the contact row by about a tenth and is gone within three pixels, and on the
   dark ones (sparks on ink, starling on a dark Program colour) it is invisible,
   which is the correct answer there - a shadow that showed on those would be a
   stripe. */
.phone-half-frame-art[data-half-frame-art='middle'],
.phone-half-frame .wallet-pass {
  box-shadow: 0 -1px 2px rgba(21, 17, 27, 0.3);
}

/* The decorative wallet behind the merchant's card: nine supplied 627x130 PNGs,
   allocated fresh on every page render (src/program-artwork.ts). The box is the
   window's full width by 130/345 of a window that is itself 345/490 of the frame
   tall, so it is within a percent of the artwork's own 627:130 and object-fit:
   cover holds the rest: the six pixels of overdraw make the box a whisker wide
   for its height, and cover answers that by cropping about half a source pixel
   off the top and bottom rather than by stretching anything. What each layer
   SHOWS is set by its lip, above, and never by the height the image happens to
   render at. `fill` would distort and `contain` would letterbox.

   Glass, like the bezel: the tile link underneath stays clickable, and these
   are decoration, not targets. */
.phone-half-frame-art {
  display: block;
  object-fit: cover;
  pointer-events: none;
  user-select: none;
}

/* The card runs its NATURAL height from the top of its layer, and the layer
   does NOT clip it - the window does. The layer's height is the card's 115px
   resting allocation, which exists to give the percentage shift something
   definite to resolve against. Squashing the card into 115px instead would let
   the barcode plate's margin-top:auto pull the plate up into the visible
   strip. */
.phone-half-frame-card {
  overflow: visible;
}

/* Hover opens the stack, from ANYWHERE on the listing tile (task #500) rather
   than only over the miniature phone: the whole cell is one link and one hit
   area, so the preview should answer wherever the pointer actually is. The
   preview's own :hover stays for any surface that shows it outside a tile.

   ONLY on a device that really has a pointer. On a phone or a tablet :hover
   latches after a tap and would leave a tile stuck half open with no way to
   close it, so the query keeps the whole behaviour off those devices and the
   resting composition is what they always get. */
@media (hover: hover) and (pointer: fine) {
  .dash-prog-card:hover .phone-half-frame-layer,
  .phone-half-frame:hover .phone-half-frame-layer {
    transform: translateY(var(--half-stack-shift));
  }
}

/* And so does keyboard focus, on every device, on the same tile. The listing's
   tile IS the focus target - one anchor around the whole cell - so :focus-within
   on the tile is what a Tab press matches; there is nothing focusable inside the
   preview and nothing here adds one. The wrapper's own :focus-within covers any
   future surface that puts the preview somewhere focusable. */
.dash-prog-card:focus-within .phone-half-frame-layer,
.phone-half-frame:focus-within .phone-half-frame-layer {
  transform: translateY(var(--half-stack-shift));
}

/* THE TAP LATCH, closed. A tap on a touch device focuses the tile as well as
   activating it, and there is no pointer to move away afterwards, so the rule
   above would leave the tile stuck open exactly the way the hover query above
   refuses to. :focus-visible is the browser's own "this focus came from the
   keyboard" signal, so undoing the reveal when the tile has focus but not
   focus-visible closes the tap case and nothing else: a Tab still opens it, on
   a tablet with a keyboard as much as on a desktop.

   Measured, not assumed: without this, tapping a tile in a touch-emulated
   Chrome left the stack held open with no way back. */
@media (hover: none), (pointer: coarse) {
  .dash-prog-card:focus-within:not(:focus-visible) .phone-half-frame-layer {
    transform: translateY(0);
  }
}

/* Reduced motion takes the movement away, not the content: hover and focus
   still open the stack, they simply arrive there with no travel. */
@media (prefers-reduced-motion: reduce) {
  .phone-half-frame-layer {
    transition: none;
  }
}

/* The real card is the front of the stack and stands MID-aperture (task #500),
   100 source pixels down at rest and 32 once the stack has folded away, so it
   needs the same rounded top corners as the artwork behind it or it would read
   as a slab rather than a card. The radius goes here, on the pass face, and not
   on .phone-half-frame-card: that layer lets the card overflow it on purpose,
   so a radius there would clip nothing. Its contact shadow is declared with the
   middle artwork's, further up, for the same reason.

   Only the TOP corners are rounded. The foot of the card runs on past the
   aperture and is cut off by the window, exactly as in the full frame. */
.phone-half-frame .wallet-pass {
  border-radius: var(--half-stack-radius) var(--half-stack-radius) 0 0;
}

/* ALONE IN THE FRAME (task #1037, the front page's gallery): the card is
   flush with the top of the aperture, where the frame's own transparency
   rounds what you see, so it takes NO radius of its own there. The reason is
   the back artwork's, above: a second radius under the frame's would open
   dark slivers in the two top corners. Same specificity as the rule above,
   later in the sheet, so it wins by order. */
.phone-half-frame-solo .wallet-pass {
  border-radius: 0;
}

/* THE SOLO CARD FILLS ITS APERTURE (task #1045). The gallery's aperture now
   runs to the foot of the frame, past the reward line, and the card's own
   height stops at its bottom padding; between the two the band's lilac would
   show through the open aperture as a strip under every card. So the solo
   card's layer lays its one child out as a column, and the face's own
   `flex: 1 1 auto` (declared on .wallet-pass for the full frame's window)
   stretches the card to the layer, which is the aperture: the card's colour
   runs on under the reward to the crop. Nothing inside the card moves: a
   column flex container with no growing child packs its content at the top,
   and the barcode plate's auto margin has nothing to pin because the plate is
   not drawn in a half frame. The listing's own layer (.phone-half-frame-card
   without -solo) is untouched: its card sits over a stack and its layer
   height is a resting allocation, not an aperture to fill. */
.phone-half-frame-solo .phone-half-frame-card {
  display: flex;
  flex-direction: column;
}

/* Belt and braces on top of the crop: nothing barcode-shaped is even laid out
   inside this wrapper. The listing passes no QR to the renderer in the first
   place, and its card definition carries no card code, so this rule is the
   third independent guard rather than the only one. */
.phone-half-frame .wallet-pass-qr,
.phone-half-frame .wallet-pass-code {
  display: none;
}

/* A long business name must not wrap in the preview. The card runs its natural
   height and the window crops it, so a second line here would not simply be a
   second line: it would push the stamp row and everything under it down, and
   what the crop shows would depend on how long a merchant's name happens to be.
   It shortens with an ellipsis here and here only; every full-card surface still
   wraps it whole. */
.phone-half-frame .wallet-pass-logotext {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* THE LOGO IS THE NAME in the front page's gallery (task #1041, Sam). Each
   example card there carries one of Ada's fictional wordmarks as its logo
   picture, and the wordmark already says the business's name, so the text
   wordmark the renderer draws beside it would say it twice. Hidden here and
   here only: the selector needs the gallery item AND a logo picture in front
   of the text, so a gallery card without a picture still shows its name, and
   every other surface (the listing's half frames, the designer, the browser
   card, the Program page) keeps the canonical strip untouched. Nothing is lost
   to a screen reader: the phone is one role="img" named with the business,
   and the strip under it is already aria-hidden. The same rule reaches the
   design section's example-card circles (task #1047, .lp-design-cards),
   which wear the same wordmark logos and are one named picture the same way;
   the preview phone beside them is not a gallery card and keeps its name. */
.lp-gallery-item .wallet-pass-logo + .wallet-pass-logotext,
.lp-design-cards .wallet-pass-logo + .wallet-pass-logotext {
  display: none;
}

/* The standalone (unframed) presentation of the shared card (task #467): the
   customer's browser card and the settings live preview. The card keeps the
   base .wallet-pass rounded corners (only .phone-frame flattens them); the
   container-query wrapper gives it the same width-driven type scaling it has
   inside the phone frame, so one .wallet-pass stylesheet serves both wrappers. */
.wallet-card-standalone {
  max-width: 460px;
  margin: 0 auto;
  text-align: center;
}

.wallet-card-face {
  container-type: inline-size;
  width: 100%;
  max-width: 340px;
  margin: 0 auto;
}

/* The customer's own browser card is the hero of its page, so its face runs a
   little larger and sits clear of the top of the column. */
.customer-card .wallet-card-face {
  max-width: 360px;
  margin-top: 8px;
}

/* --- The Overview's left column (task #508) --------------------------------

   The Overview's content track holds TWO white boxes now: the Program's name
   above the enrolment block. .prog-layout is a two-column grid, so they are
   wrapped in one grid item rather than becoming a third one that would land in
   the phone's column.

   NO GAP OF ITS OWN (task #557, Sam). It carried 24px, and every box inside it
   is a .prog-panel whose own margin is `0 0 20px`. A flex container does not
   collapse its items' margins, so the two were ADDED: 44px between the panels
   on a page whose rhythm everywhere else is 20px, and the doubling grew with
   task #554's third panel. Dropping the gap leaves the panels' own margin as
   the one thing that spaces them, which is what spaces two stacked panels on
   every other page in the admin.

   The flex column stays, and not only for `min-width: 0`: it is what keeps the
   panels' margins from collapsing INTO each other, so the 20px is 20px rather
   than a max() of whatever each neighbour declares. */
.prog-main {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* The rename form: the field and its Save button side by side, because the
   whole form is one short field and stacking a button under it would waste a
   line and read as a bigger commitment than it is. It carries no visible label
   of its own - the panel's heading is the field's name, associated by
   aria-labelledby, so the name is said once.

   It wraps rather than shrinking: at a narrow width the button drops under the
   field at full width, which is what keeps it usable at 320px and at 200% zoom
   without a breakpoint of its own. */
.prog-name-form {
  margin-top: 12px;
}

.prog-name-row {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: 10px;
}

.prog-name-row input[type='text'] {
  flex: 1 1 14em;
  min-width: 0;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  background: var(--card);
  color: var(--ink);
}

/* flex-shrink 1, not 0. The field takes the free space, so at any comfortable
   width the button still sits at exactly its own text width; at a 160px layout
   viewport (a 320px screen at 200% zoom) it is allowed to give ground and wrap
   its label onto two lines instead of pushing 2px of the panel off the screen.
   min-width: 0 is what actually permits that: a flex item's floor is its
   min-content width otherwise. */
.prog-name-row .btn {
  flex: 0 1 auto;
  min-width: 0;
}

/* --- The Offer page --- */
/* The page's fields get the same stacked shape as every other LoyalPack form
   (label above, full-width control below, hint under it), but square. They are
   not inside .stack-form: that class also owns the auth shell's rhythm, and
   this page needs its own tighter spacing.

   The class name is unchanged (.prog-settings-fields, .prog-settings-form,
   .prog-settings-foot): it is a private structural hook shared with the Card
   Design page's own footer, and renaming it would have touched two pages and
   the stylesheet to change nothing a merchant can see. What a merchant reads is
   in the markup, and none of that says Settings any more. */

/* The block's own heading (task #586), which is the shared .prog-panel h2 and
   declares nothing about how it looks. The one declaration is the room under
   it: the global h2 leaves 8px, which is the gap between a label and its own
   control, and a title sitting that close to the first field reads as part of
   it. 16px is the gap the fields already keep between themselves. */
.prog-settings-form > h2 {
  margin-bottom: 16px;
}

.prog-settings-fields label {
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-weight: 600;
  margin-bottom: 16px;
}

/* THERE IS NO TICK BOX ON THIS FORM ANY MORE (Sam, task #854), and so no rule
   for one. The free stamp on joining wore `.check` from task #686 to task #854;
   it and the four Data collection rows are the shared on/off switch now, which
   carries its own row treatment, and the 16px every control on this form keeps
   below it still comes from `.prog-settings-fields label` above, which a
   `label.switch` matches exactly as a `label.check` did.

   The birthday's own pointer paragraph went at the same time (it was
   `.prog-birthday-pointer`, task #797): the two facts it carried are in the
   Date of birth switch's label now. */

/* The enrolment stamp's own block (task #854). One switch under a heading, in
   the same h3 treatment Data collection below wears, so the tab reads as three
   named subjects rather than as a field list with a loose control in it.

   NO HAIRLINE ABOVE IT, which is the one way it differs from Data collection.
   The rule down there separates what the customer EARNS from what joining ASKS
   THEM FOR, which are two subjects inside one save; the free stamp is part of
   the first of those, so a second rule here would cut the offer in half. */
.prog-enrolment-stamp {
  margin-bottom: 16px;
}

.prog-enrolment-stamp h3,
.prog-data-collection h3 {
  margin: 0 0 4px;
  font-size: 1rem;
}

/* The switch is the block's last child, so the 16px above it would double the
   gap the block itself keeps. */
.prog-enrolment-stamp label.switch:last-child {
  margin-bottom: 0;
}

.prog-settings-fields input[type='text'],
.prog-settings-fields input[type='number'],
.prog-settings-fields textarea {
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  width: 100%;
}

/* The terms and conditions box (task #508). `rows` sets the height in the
   markup; these three are the parts a textarea gets wrong by default:

     resize: vertical   a textarea is horizontally resizable by default, and
                        dragging it wider inside a 2fr grid track pushes the
                        whole column past the layout;
     box-sizing         so width: 100% above is the border box and the padding
                        does not add 24px of overflow;
     font-weight        the label above is 600 and a textarea does not inherit
                        it, but nor should the typed text look like a heading.

   No monospace: the admin has exactly ONE family (task #453) and this is
   ordinary prose, not code. */
.prog-settings-fields textarea {
  box-sizing: border-box;
  resize: vertical;
  font-weight: 400;
  min-height: 7em;
}

.prog-settings-fields input[type='file'] {
  font: inherit;
}

/* Data collection (task #789): the Offer form's second subject, and the only
   block on it that is a group of ticks rather than a labelled field. A hairline
   above it and its own heading are what separate "what the customer earns" from
   "what joining asks them for" inside one form and one save; a second panel
   would have meant a second form, and the two are saved together on purpose
   (they are one decision about how this Program works).

   The h3 is the level below the panel's own h2, which is the block's heading,
   so the outline reads Offer > Data collection with nothing skipped. Its
   margins are stated here because the global h3 rule is written for prose.

   The rows are the shared on/off switch the enrolment stamp above uses
   (task #854), so the form has one kind of control in it, not two. The last one
   drops its bottom margin: the panel's foot supplies the gap to the Save button.

   Task #804 briefly gave Card sharing a second selector on each of these four
   rules, wearing the same treatment. Task #810 removed that block along with the
   setting behind it, so these are back to describing one subject. */
.prog-data-collection {
  border-top: 1px solid var(--line);
  padding-top: 20px;
  margin-top: 4px;
}

.prog-data-collection .hint {
  display: block;
  margin: 0 0 12px;
}

.prog-data-collection label.switch:last-child {
  margin-bottom: 0;
}

/* The Offer form's own colour swatch and its row went the same way (task #555).
   Nothing on that page has been a colour control since the card colour moved to
   Card Design (task #508); these two rules outlived the markup they were written
   for and were left targeting nothing. */

.prog-settings-fields .hint {
  font-weight: 400;
  font-size: 0.86rem;
  color: var(--muted);
}

/* The two-choice card colour block (task #469): radio "Primary Account
   Colour" (with a small swatch of it) or radio "Colour Override", whose
   picker row is revealed by :has() when it is the checked choice. Browsers
   without :has() simply always show the row, which is harmless: the server
   only honours brand_color when colour_mode is 'override'. No script is
   involved in the reveal.

   IT LIVES ON THE CARD DESIGN PAGE NOW (task #508), not on the old settings
   form, so every rule it needs is scoped to .colour-mode itself rather than to
   whichever page it sits on.

   WHAT IS LEFT HERE IS THE GROUP, AND ONLY THE GROUP (task #555). The picker
   inside it used to be styled here too, first by .prog-settings-fields and then
   by three rules of its own; it is the shared .colour-well now, styled once with
   the two wells below it, so this block draws the fieldset and the reveal and
   stops. Nothing it declares reaches inside the control. */
/* No border and no inset since task #514: the group sits inside the Colours
   accordion section, whose white box is the only box. The fieldset stays for
   what it means (one named radio group); only its drawing changed. */
.colour-mode {
  border: 0;
  border-radius: 0;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* THE GROUP'S OWN LEGEND RULE IS GONE (task #558), because the legend is. This
   fieldset sits directly under the section heading that says "Card colour", so
   it takes its accessible name from that heading by aria-labelledby rather than
   repeating the words in a <legend> nobody would want to read twice. A rule
   dressing an element this page no longer renders is a rule the next reader has
   to work out the fate of, so it went with it. */

/* THE ROW RULES THIS GROUP USED TO CARRY ARE GONE (task #514 final pass, Sam).
   Its two options render through the shared ChoiceCard now, so they are
   .check.design-choice-card exactly like every other radio group on the page,
   and .design-acc-body .check plus .design-choice-card already say everything
   these three rules said: the row direction, the flex-start alignment, the
   4px lift on the radio and the column span with its 2px gap. Keeping local
   copies of them was the drift risk this change exists to remove, so they went
   with the markup they were written for. The title is a <strong> now rather
   than a span weighted by CSS, which is what lets the shared rules apply
   unaltered. */

/* 50 BY 25 SINCE TASK #557 (Sam), up from 30 by 14. This is the one place the
   account's own colour is shown on the Card Design page, and at 30x14 it was a
   hyphen-sized chip that a merchant checking "is this the right purple?" had to
   hunt for. It is the same proportion at nearly three times the area, and it is
   the same 50px width as the shared colour well's swatch four rules down the
   panel, so the two readings of a colour on this page are the same size.

   The flex row it sits in (.design-choice-title) is what puts it in front of the
   words; `vertical-align` no longer decides anything and is gone with the inline
   layout it was written for. */
.colour-mode-swatch {
  display: block;
  flex: none;
  width: 50px;
  height: 25px;
  border: 1px solid var(--line);
}

/* THE WELL LIVES INSIDE ITS OWN OPTION NOW (Sam, task #558), so this row is the
   second child of the Colour Override shell rather than a third block under the
   group. The 26px indent lines it up with the option's TITLE, not with the
   radio: inside the box, the radio column is the option's own gutter, and a
   control starting under the radio would read as a second, unlabelled choice.
   That is the opposite of the answer task #514 gave when this row sat OUTSIDE
   the cards, and for the same reason: line a thing up with what it belongs to.

   26px is the radio's own width plus .check's 10px gap, which is what the title
   beside it is offset by. It is dropped below 380px, where the box is narrow
   enough that the indent costs the hex field more than the alignment is worth. */
.colour-mode .colour-override-row {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-left: 26px;
  min-width: 0;
  /* The shell is pressable and says so; the control inside it is not the same
     gesture, so it does not inherit the pointer. */
  cursor: auto;
}

@media (max-width: 380px) {
  .colour-mode .colour-override-row {
    margin-left: 0;
  }
}

.colour-mode:has(input[value='account']:checked) .colour-override-row {
  display: none;
}

/* THE THREE RULES THAT DREW THIS PICKER ARE GONE (task #555, Sam).

   They were a local layout for a swatch beside a hex field: a wrapping flex row,
   a 64-by-44 bordered colour input and a 9em text field carrying its own border,
   padding, background and ink. Every one of them was a near-copy of something
   .colour-well already says, kept in step by hand, and each was a place the
   override could drift away from the two wells six rules down the same panel.
   The row renders the shared component now, so the component's rules are the
   only rules involved and there is nothing left here to keep in step.

   Note what that also deletes: `.colour-mode input[type='color']`, which was
   scoped to this group rather than to the row, would have gone on matching the
   well's own 50px swatch inside this fieldset. It loses to
   `.colour-well input[type='color'].colour-well-swatch` on specificity, but only
   just, and every declaration it does not overlap (flex, cursor) would have
   applied. A rule that only fails to matter by a class is not a rule to leave
   lying in a group the control now lives inside.

   No .colour-mode .hint rule either, and there never needs to be one: the shared
   .hint is the same weight and the same muted colour to within a hundredth of a
   rem, and one supporting-line treatment across the page is worth more than
   that. */

/* The Offer page keeps its bespoke form (task #462, Sam's review; task
   #468 made it a page) rather than the shared settings-section layout, and the
   form itself is a .prog-panel, so it shares the square white surface of the
   rest of the tree.

   What it no longer keeps is a width of its own. It carried max-width: 980px,
   and its own inner two-column body, from the days when the form and the
   preview were both inside it; on the 1120px app grid (task #497) that made the
   middle tab visibly narrower than Overview and Card Design, so the white box
   moved as a merchant changed tab. The shared .prog-layout decides the width of
   all three now, once (task #504 final pass). */

.prog-settings-foot {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 8px;
  padding-top: 16px;
  border-top: 1px solid var(--line);
}

/* --- narrow screens --- */
/* The shared Program row stacks at the dashboard's existing 980px breakpoint,
   content first and the preview under it, on all three pages at once (task
   #504 final pass). Below that width a third of the column is too narrow for
   the iPhone to read and two thirds is too narrow for the Card Design
   controls, so keeping two columns would be worse than stacking. It is the
   same width the focused-Program band stacks at, deliberately: those are the
   two places in the app where a phone sits beside a column of content.

   Both gaps come down with the layout. 60px between two stacked blocks is a
   hole rather than a rhythm, and at 320px it would be a fifth of the screen. */
@media (max-width: 980px) {
  .prog-layout {
    grid-template-columns: minmax(0, 1fr);
    column-gap: 24px;
    row-gap: 24px;
  }
}

@media (max-width: 820px) {
  .prog-head {
    flex-direction: column;
    align-items: stretch;
  }

  .prog-head-actions .btn {
    display: block;
    text-align: center;
  }
}

@media (max-width: 520px) {
  .prog-copy-row {
    flex-direction: column;
  }
}

/* The customers table keeps its columns and scrolls inside its panel rather
   than widening the page. */
.prog-table-wrap {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.prog-table-wrap table {
  min-width: 520px;
}



/* --- the shared analytics module (task #504) --------------------------------

   ONE block, rendered identically by the dashboard (account-wide) and by a
   Loyalty Program's Overview (Program-scoped): three metric blocks that are
   also the tabs over one shared chart surface, with the active metric's report
   button under the graph. It replaced the dashboard-only .dash-metric /
   .dash-panel family and the Program page's three stacked bar charts.

   Square corners throughout, matching the prog- family; the totals use the
   brand colour. */
.analytics-module {
  margin-bottom: 28px;
}

.analytics-module h2 {
  margin: 0 0 2px;
}

.analytics-module-sub {
  margin: 0 0 14px;
  font-size: 0.95rem;
}

/* The metric blocks overlap the chart surface's top border by one pixel, so
   the selected block can merge into it: the selected tab takes the panel's own
   card background and drops its bottom border, reading as one attached
   surface, while the unselected tabs sit back on the page background behind
   their own bottom edge. No brand bottom bar anywhere.

   THREE ACROSS AT EVERY WIDTH (task #620, Sam). They are tabs, so they stay a
   tab row on a phone rather than becoming three stacked blocks: the phone
   breakpoints below only shrink the type and the padding to fit. minmax(0, 1fr)
   rather than a bare 1fr so the tracks may go narrower than their own content
   and the row can never push the Program page sideways at 320px. */
.analytics-metrics {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 12px;
  margin-bottom: -1px;
}

.analytics-metric {
  display: flex;
  flex-direction: column;
  position: relative;
  z-index: 1;
  background: var(--bg);
  border: 1px solid var(--line);
}

/* The block's tab: a real button filling the block, UA chrome reset. */
.analytics-metric-tab {
  appearance: none;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  display: block;
  width: 100%;
  padding: 16px;
  text-align: center;
  cursor: pointer;
}

.analytics-metric-tab:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: -3px;
}

/* THE TAB TEXT IS BLACK, SELECTED AND UNSELECTED ALIKE (Sam, task #811
   review). The totals used to take --brand-fixed as the graph's key, naming the
   series the chart below draws in the colour it is drawn in (task #583), and
   the labels were --muted with the selected one lifted to the same purple. Sam
   asked for both states black, so all three now take --ink, which is the same
   black the focus counts above them wear (task #771) and the current pagehead
   tab wears (task #782). The purple is left to the chart itself.

   Selection therefore rests entirely on the things that were never colour: the
   selected block takes the panel's white surface and merges into it, its label
   goes bold, and aria-selected says so outright. Focus rings in this module are
   left on --brand, which is the app-wide convention for them.

   This is the SHARED module (task #504), so a Loyalty Program's Overview reads
   the same way: one component, one look, which is the drift #504 exists to
   prevent. */
.analytics-metric-num {
  display: block;
  font-size: 2rem;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.1;
}

.analytics-metric-label {
  display: block;
  font-size: 0.82rem;
  color: var(--ink);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  /* The tracks are allowed to shrink below their content (above), so the one
     long word here breaks rather than escaping its tab on a narrow phone. */
  overflow-wrap: anywhere;
}

/* The selected metric attaches to the chart panel: card surface continuing
   over the panel's top border (via :has, as the colour-mode fieldset already
   relies on). Selection is never carried by colour at all now (see the black
   tab text above) - the selected block is the one whose label is BOLD and whose
   surface joins the panel, and aria-selected says so outright. */
.analytics-metric:has(.analytics-metric-tab[aria-selected='true']) {
  background: var(--card);
  border-bottom-color: var(--card);
}

.analytics-metric:has(.analytics-metric-tab[aria-selected='false']) .analytics-metric-tab:hover {
  background: var(--card);
}

.analytics-metric-tab[aria-selected='true'] .analytics-metric-label {
  color: var(--ink);
  font-weight: 700;
}

/* The shared chart area: a bordered card the selected tab sits attached to,
   one panel visible at a time (the [hidden] attribute is toggled by
   /static/analytics-module.js; with scripting off the module's own <noscript>
   style shows all three stacked). The panel is focusable per the tabs pattern,
   so keyboard users land on the chart after the tablist. */
.analytics-panels {
  background: var(--card);
  border: 1px solid var(--line);
  padding: 16px 18px 6px;
}

.analytics-panel:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 2px;
}

/* What the visible metric counts, in the reports' own words, above its graph. */
.analytics-panel-def {
  margin: 0 0 12px;
  font-size: 0.9rem;
}

/* The dataset's own action, under the graph and inside its metric's panel, so
   the visible button always matches the visible chart. */
.analytics-panel-report {
  margin: 14px 0 8px;
  text-align: right;
}

/* --- the dashboard's Loyalty Program cards --- */
.dash-prog-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* Exactly two columns on a desktop (task #486, revised from the original
     three): each canonical card gets more room to breathe while the pass
     itself stays capped at its usual width. One column on a phone, below. */
  grid-template-columns: repeat(2, 1fr);
  /* 50px between tiles on a desktop, the same generous measure the settings
     surfaces take (task #490). With the preview capped below, each tile is a
     device standing clear of its neighbour rather than two frames nearly
     touching. The phone breakpoint puts it back to 20: stacked in one column
     that gap is vertical, and 50 there would just push the second Program off
     the fold. */
  gap: 50px;
  align-items: stretch;
}

.dash-prog-cell {
  display: flex;
  min-width: 0;
}

/* The whole cell is one link. It wears the list-link treatment (card surface,
   brand edge on hover/focus) around the canonical pass face. */
/* No flex gap: the half-phone preview and the stats bar underneath it have to
   meet with nothing between them (task #493), so the spacing that used to come
   from a column gap now sits on the caption elements themselves.

   The padding is spelled out on all four sides because the stats bar's exact
   negative margins (-16 right, -16 bottom, -20 left) are its mirror: that is
   what lets the band reach the tile's inner edges precisely instead of leaving
   a strip of card under it or hanging past its border. Change one and the
   other has to change with it. */
.dash-prog-card {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-width: 0;
  background: var(--card);
  border: 1px solid var(--line);
  padding: 16px 16px 16px 20px;
  color: inherit;
  text-decoration: none;
  /* The containing block for the arrow below, and nothing else: the preview
     inside carries its own position: relative, so no existing absolute layer
     changes what it resolves against. */
  position: relative;

  /* THE LIFT (task #500 follow-up). The tile used to answer a pointer with
     colour: --brand on the border plus a second ring drawn inside it with an
     outline. Sam's decision replaces the colour with depth. The border now
     stays the same quiet --line in every state, and the tile instead rises off
     the page and settles back.

     BOTH VALUES ARE DECLARED, as two layers each, and that is the whole reason
     the movement is smooth in AND out. box-shadow interpolates layer for
     layer, so a transition from `none` has nothing to pair with and the browser
     snaps; a transparent shadow of the same shape and count gives every layer a
     partner at both ends, so the raise and the fall are the same 220ms curve
     run in opposite directions. The two values live in custom properties so the
     raised state is written once and the coarse-pointer guard below can put the
     tile back to the resting one by name.

     THE GEOMETRY IS DELIBERATELY CONTAINED, because two things around this tile
     clip. A shadow reaches blur/2 + spread beyond the box, so the negative
     spreads hold this one to 6px at the sides and 14px below, with nothing at
     all above the tile (offset 8 exceeds the 12px the first layer's blur could
     reach upwards). Sideways that is well inside .container's 16px page
     padding, so the overflow-x: clip on html never crops it even at 320px;
     downwards it is inside the 20px the grid leaves between stacked tiles on a
     phone. Nothing here loosens the aperture's own overflow: hidden, which is a
     different box entirely.

     Ink rather than a grey, at 0.30 and 0.22: the same rgb(21, 17, 27) the
     stack's contact shadow takes, so the tile and the cards inside it are lit
     the same way. And a shadow changes no layout: no border width moves, no
     outline claims space, nothing reflows and no neighbour is nudged. */
  --tile-lift-rest: 0 0 0 0 rgba(21, 17, 27, 0), 0 0 0 0 rgba(21, 17, 27, 0);
  --tile-lift-raised: 0 8px 24px -6px rgba(21, 17, 27, 0.3), 0 2px 8px -4px rgba(21, 17, 27, 0.22);
  box-shadow: var(--tile-lift-rest);
  transition: box-shadow 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* ONLY on a device that really has a pointer, exactly as the preview's own
   reveal is (see .phone-half-frame-layer): on a phone :hover latches after a
   tap and would leave a tile stuck raised with no way to put it down. */
@media (hover: hover) and (pointer: fine) {
  .dash-prog-card:hover {
    box-shadow: var(--tile-lift-raised);
  }
}

/* And keyboard focus gets the identical lift, on every device. The tile IS the
   focus target - one anchor around the whole cell - so :focus-within is what a
   Tab press matches, and it is deliberately outside the pointer query. */
.dash-prog-card:focus-within {
  box-shadow: var(--tile-lift-raised);
}

/* The visible focus indicator, untouched by any of the above: this is the
   accessible affordance, not decoration, and the lift and the arrow are added
   alongside it rather than in place of it. It is the one purple that stays. */
.dash-prog-card:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 2px;
}

/* THE TAP LATCH, closed, on the same terms as the preview's (task #500): a tap
   focuses the tile as well as activating it, and with no pointer to move away
   :focus-within would hold the lift and the arrow on. :focus-visible is the
   browser's own "this focus came from the keyboard" signal, so undoing it for
   focus that is NOT focus-visible closes the tap case and only that case - a
   Tab still lifts the tile, on a tablet with a keyboard as much as a desktop. */
@media (hover: none), (pointer: coarse) {
  .dash-prog-card:focus-within:not(:focus-visible) {
    box-shadow: var(--tile-lift-rest);
  }
}

/* THE GO ARROW (task #500 follow-up): a purple arrow-right in the tile's
   top-right corner, faded in on the same hover and focus the lift answers, to
   say that the whole tile is a way in to the Program.

   DECORATIVE, AND ONLY DECORATIVE. aria-hidden on the wrapper takes both the
   <i> and the <svg> the Font Awesome kit replaces it with out of the
   accessibility tree, so nothing new is announced; the link's own aria-label
   already ends "open this Loyalty Program", which is what the arrow draws, so
   an exposed icon would only repeat it. pointer-events: none is not a
   nicety: the arrow sits over the anchor's own padding, and without it the
   top-right corner of a full-card link would stop being part of that link.

   --brand-fixed, not --brand. --brand is overridden per account from the
   merchant's own branding (src/views/layout.tsx), and this listing has already
   decided that no merchant colour tints its chrome - the same rule the stats
   band below is built on. #5238bf on the white card is 8.6:1.

   The wrapper is what carries the class and the geometry, because the kit
   swaps the <i> element itself out for an SVG at runtime and any hook on it
   would go with it (task #501). font-size on the wrapper is what sizes that
   SVG, which is em-width. */
.dash-prog-go {
  position: absolute;
  top: 18px;
  right: 16px;
  z-index: 1;
  font-size: 1rem;
  line-height: 1;
  color: var(--brand-fixed);
  pointer-events: none;
  opacity: 0;
  transform: translateX(-4px);
  transition:
    opacity 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
    transform 200ms cubic-bezier(0.22, 0.61, 0.36, 1);
}

@media (hover: hover) and (pointer: fine) {
  .dash-prog-card:hover .dash-prog-go {
    opacity: 1;
    transform: translateX(0);
  }
}

.dash-prog-card:focus-within .dash-prog-go {
  opacity: 1;
  transform: translateX(0);
}

@media (hover: none), (pointer: coarse) {
  .dash-prog-card:focus-within:not(:focus-visible) .dash-prog-go {
    opacity: 0;
    transform: translateX(-4px);
  }
}

/* Reduced motion takes the movement away, not the affordance: hover and focus
   still raise the tile and still show the arrow, they simply arrive with no
   travel and no fade. The arrow's offset goes too, so there is nothing left to
   slide even for the instant a zero-duration change would take. */
@media (prefers-reduced-motion: reduce) {
  .dash-prog-card,
  .dash-prog-go {
    transition: none;
  }

  .dash-prog-go {
    transform: none;
  }

  .dash-prog-card:hover .dash-prog-go,
  .dash-prog-card:focus-within .dash-prog-go {
    transform: none;
  }
}

/* Long Program names wrap rather than truncate: the name is the whole reason
   to look at the cell, and an unbroken word still breaks rather than
   overflowing the cell. */
.dash-prog-title {
  display: block;
  font-weight: 700;
  /* 1.4rem (task #500 follow-up, up from 1.05): the Program's name is the one
     thing the tile is scanned for, and at the old size it read as a caption to
     the picture rather than as the tile's heading. */
  font-size: 1.4rem;
  color: var(--ink);
  overflow-wrap: anywhere;
  margin-bottom: 4px;
  /* Room kept for the arrow, permanently rather than on hover: reserving it
     only when the arrow appears would reflow a wrapped title under the pointer.
     26 is the icon's 16px plus a 10px gap, and it is a padding rather than a
     margin so the reserved strip belongs to the heading's own box and a long
     name wraps before it reaches the corner instead of running under it. */
  padding-right: 26px;
}

.dash-prog-reward {
  display: block;
  color: var(--muted);
  font-size: 0.9rem;
  /* 30px (task #500 follow-up, up from 14): the larger title above needs the
     offer line to sit as its own paragraph rather than crowd it, and the gap
     down to the device is where that room comes from. The device's margin-top:
     auto still holds every frame in a row level, so on a tile with a short
     title this simply moves into the slack instead of making the tile taller. */
  margin-bottom: 30px;
  overflow-wrap: anywhere;
}

/* The half-phone preview (task #493), pushed to the foot of the cell so the
   devices sit level across a row whatever their captions do. margin-bottom: 0
   is the point of the whole block: the stats bar begins where the frame
   ends. */
.dash-prog-device {
  display: block;
  margin-top: auto;
  margin-bottom: 0;
  min-width: 0;
}

/* THE LISTING CAP (task #500 follow-up). 374 is half the frame PNG's 748: the
   same "never upscaled" ceiling .phone-frame takes for the full device, and the
   width at which the aperture's 627 source pixels and the nine 627-wide
   artworks inside it land on a 2x screen at their natural size. Two desktop
   columns of a 1120 grid are far wider than that, and without a cap the artwork
   grew to fill them and dominated the Program's name and figures.

   Nothing about the composition changes: the frame's aspect-ratio, the
   aperture's inline percentages, the stack's 50/50/245 rest and 16/16/313
   reveal and its cqw corner radius are all ratios of the rendered width, so a
   capped preview is the same picture drawn smaller. Below the cap (a narrow
   column, a phone, 200% zoom) width: 100% still wins and the preview shrinks
   with its tile.

   The cap and the centring go on the FRAME, not on .dash-prog-device. That
   wrapper is a flex item of the tile: an auto inline margin or a centred
   align-self on it would stop it stretching, and its only content is
   percentage-width and absolutely positioned, so it would shrink-fit to nothing
   and the preview would vanish (task #480, caught live on staging; the trap is
   pinned by test/dashboard.test.ts). The frame is an ordinary block inside that
   stretched wrapper, where max-width plus auto inline margins is nothing more
   exotic than block centring. The stats band underneath keeps its own bleed to
   the tile's inner edges, so the device now stands centred on it. */
.dash-prog-device .phone-half-frame {
  max-width: 374px;
  margin-inline: auto;
}

/* The Programs listing's counts (task #480, recoloured task #493 and again in
   its revision): a row of three figures directly under the phone, so the frame
   and the figures read as one object rather than a picture with a caption. The
   link's aria-label already speaks the same figures, so this row is the visual
   presentation.

   ONE FIXED COLOUR. The band used to take each Program's own card colour
   through per-tile custom properties, with an ink measured against it. Sam's
   decision is that it must not: every tile now paints the same soft cool grey
   #e8e5ec under the product's own --ink, so a wall of Programs reads as one
   calm listing and no merchant colour can influence LoyalPack's chrome. --ink
   #15111b on #e8e5ec is 14.94:1, and both rows below take it at full strength,
   so nothing dims it back towards the 4.5:1 line.

   THE FOUR MARGINS. -1px at the top is a tuck under the frame's own opaque
   bottom edge, so no rounding can open a hairline of tile background between
   the two. The other three are the exact negatives of .dash-prog-card's
   padding on those sides (16 right, 16 bottom, 20 left), which is why that
   padding is written out asymmetrically above: the band bleeds precisely to
   the tile's inner edges, with no gap under it and no overhang past its
   border. */
.dash-prog-stats {
  display: flex;
  gap: 24px;
  margin: -1px -16px -16px -20px;
  padding: 14px 16px;
  background: #e8e5ec;
  color: var(--ink);
  min-width: 0;
  flex-wrap: wrap;
}

.dash-prog-stat {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* Figures and labels both take the measured ink at full strength. Dimming
   either (the --muted label this row used to carry, or an opacity) would undo
   the contrast calculation the colour was chosen by. */
.dash-prog-stat-num {
  font-size: 1.3rem;
  font-weight: 700;
  color: inherit;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

.dash-prog-stat-label {
  font-size: 0.78rem;
  color: inherit;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* --- a DISABLED Program's tile (task #554 follow-up, Sam) ------------------

   A Program the merchant has disabled is a tile in this same grid, not a row in
   a section underneath it, so the whole distinction has to be carried by the
   tile: a white veil over the card preview, and a red DISABLED badge at the end
   of the counts bar. Two signals, one of them not colour alone, and the
   accessible name says "disabled" too (see ProgramCardItem).

   THE VEIL IS REGISTERED TO THE FRAME, not to the tile. It is drawn as an
   ::after on .phone-half-frame itself, so it covers the miniature phone exactly
   - bezel, stack and card face together - and stops at its edges. Everything the
   merchant needs in order to ACT stays at full strength: the Program's name, its
   offer line, the go arrow, the counts, and above all the badge, which is the
   one thing on a disabled tile that must not be faded.

   0.58 white is measured rather than picked: it lifts the palest supplied
   artwork to near-white and still leaves the darkest card faces (sparks on ink)
   clearly readable as themselves, which is the point - the card must stay
   recognisable as this Program's card, only obviously not in service.

   z-index 3 clears .phone-half-frame-img at 2 and the window at 1. It has to:
   the frame PNG is painted OVER the composition, so a veil below it would leave
   the bezel crisp over a faded card. container-type on .phone-half-frame makes
   that element a stacking context, so all three values are compared inside it
   and nothing here can reach past the preview. Glass, so the tile-wide link
   underneath keeps every pixel of its hit area. */
.dash-prog-card-disabled .phone-half-frame::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 3;
  background: rgba(255, 255, 255, 0.58);
  pointer-events: none;
}

/* The badge, at the end of the counts bar. margin-left: auto is what puts it
   there and keeps it there: the three counts take their natural widths at the
   start of the row and the badge is pushed to the far end, so a wall of tiles
   can be read down one edge for what is not running. On a narrow tile the bar
   already wraps (flex-wrap on .dash-prog-stats), so the badge drops onto its own
   line rather than squeezing the figures.

   --error #b3261e under white is 6.5:1, which is AA at this size and well past
   it for the weight. It is LoyalPack's own red from :root - no merchant colour
   can reach this bar, exactly as the neutral behind it cannot be tinted.
   align-self keeps it the height of its own text rather than stretching down the
   two-line counts beside it. */
.dash-prog-badge {
  margin-left: auto;
  align-self: center;
  background: var(--error);
  color: #ffffff;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  line-height: 1;
  padding: 6px 10px;
  white-space: nowrap;
}

/* The create-Program callout (task #486) and its styles were removed in task
   #535 along with the component: a one-Program account is usually on the Free
   plan, which includes one Program, so the strip invited a form that would
   refuse. */

@media (max-width: 720px) {
  /* THE METRICS STAY TABS ON A PHONE (task #620, Sam). Until now this
     breakpoint turned the row into a single stacked column, which also cost
     the module its attachment: three full-width blocks cannot merge into the
     chart surface, so they took a closed bottom border and an ordinary gap
     instead, and the tab set stopped reading as a tab set.

     Now only the type and the spacing give way. The row, the -1px overlap and
     the selected block's open bottom edge are all inherited from the desktop
     rules untouched, so a phone shows the same three tabs attached to the same
     chart. Nothing below re-states the grid: it is already three columns. */
  .analytics-metric-tab {
    padding: 14px 8px;
  }

  .analytics-metrics {
    gap: 8px;
  }

  .analytics-metric-num {
    font-size: 1.6rem;
  }

  .analytics-metric-label {
    font-size: 0.72rem;
    letter-spacing: 0.03em;
  }

  .dash-prog-grid {
    grid-template-columns: 1fr;
    gap: 20px;
  }
}

/* The narrow phone, at the breakpoint the report filters already use. Three
   tabs across 288px of content (320px less the container's own 16px sides)
   leaves each about 93px, so the longest label, CUSTOMERS, needs both the
   smaller size and the tighter tracking to sit on one line inside its tab.
   Checked at 390px and 320px. */
@media (max-width: 420px) {
  .analytics-metric-tab {
    padding: 12px 4px;
  }

  .analytics-metrics {
    gap: 4px;
  }

  .analytics-metric-num {
    font-size: 1.35rem;
  }

  .analytics-metric-label {
    font-size: 0.62rem;
    letter-spacing: 0.02em;
  }
}

/* --- the focused Program band (task #479 revision, relaid task #489) --------

   One Loyalty Program at a time, first on the page: a heading
   row carrying the Program's title and configured offer on the left with
   Manage Program on the right (task #495), then TWO columns - the
   iPhone-framed canonical card in a third of the width on the left, hard
   against that column's leading edge, and the Program's own content in the
   other two thirds: enrolment link, the three lifetime counts and its 30-day
   Stamps chart. The band runs the full viewport
   width; its inner element re-centres onto the normal
   content grid. With several Programs, one bottom control row (previous, the
   position status, next) and a swipe page between whole slides, each switch a
   fast directional slide.

   WHITE, AND NO DOT PATTERN (task #762, Sam: "the web app dashboard needs to be
   white background, remove the dot pattern, and make the text colour and boxes
   appropriate colours to display on white"). The band was --ink #15111b under a
   drawn dot texture, with a fixed light-on-ink palette of its own: white, the
   pale lavender #c8c2d4 and the control purple #9b8ae8. All of that is gone.
   The band is now the card white the rest of the signed-in app is built from,
   and every colour in this family is one of the app's own named tokens rather
   than a hex written for a dark surface. Measured against #ffffff with the
   product's own src/contrast.ts:

     --ink         #15111b  18.62:1   titles, the enrolment URL
     --muted       #615c6b   6.46:1   labels, the offer, the chart's furniture
     --brand-fixed #5238bf   7.85:1   links, the counts, the paging controls
     --success     #146c43   6.45:1   the copy control's "copied" line
     --error       #b3261e   6.54:1   and its failure line

   All five clear AA at normal size, and the two large icon controls clear it by
   more than double the 3:1 a large control is asked for. Nothing here is a
   one-off hex any more, so a later change to a token moves the band with it.

   The one hairline bottom border is what still says "section": on a white page
   a white band has no edge of its own, and the account-wide module below it
   needs to read as a separate thing rather than as more of the same. */

/* THE DASHBOARD PAGE IS WHITE (task #762, Sam). The rest of the signed-in app
   still sits on the pale grey --bg, so this is scoped to the one page rather
   than declared on `body`: .dash-focus is the dashboard's own section and
   appears on no other surface in the product, including the empty state, so
   selecting the body that contains it names exactly /dashboard and nothing
   else. The stylesheet already relies on body-level :has() for the content
   grid (see body:has(.cc-page) above), so this is the established tool here
   and not a new one.
   It is deliberately the BODY and not <main>: main is only as tall as its
   content, so on a short dashboard the grey would have returned below the
   footer. A browser without :has() simply keeps the pale grey page under a
   white band, which is legible in every respect - nothing on this page reads
   against --bg. */
body:has(.dash-focus) {
  background: var(--card);
}

/* THE ACCOUNT-WIDE SECTION KEEPS ITS LIGHT GREY (task #772, Sam). Before task
   #762 this section had no surface of its own: the whole page was the pale grey
   --bg, and "Loyalty performance at a glance" simply sat on it, with the metric
   tabs' own --bg fill reading as part of that ground and only their hairline
   edges drawn. Whitening the page took that away as a side effect, which is
   what Sam is asking back for, so the grey returns as the SECTION's own fill
   rather than as the page's.

   It is the same --bg token the page used to carry, so nothing is a new colour
   and the module's interior still resolves exactly as it always did: the
   unselected tabs recede into this ground, the selected one lifts to --card and
   merges into the white chart surface below it. Nothing inside the module is
   restyled here and no layer is added - one background declaration, no image,
   no gradient, no ::before. The dot pattern task #762 removed stays removed.

   Scoped through `body:has(.dash-focus)` for the same reason the white page is
   (see above): the module is SHARED with a Loyalty Program's Overview, which
   still sits on the app's ordinary grey page and must keep reading as a plain
   run of content there. This is the one instance that is an island on white.

   IT IS A BAND, NOT A PANEL (task #772 review, Sam: the grey "needs to span
   full window width, meet the border above, and meet the footer below"). The
   first pass gave the section a box inside the content column, which left the
   grey floating with white down both sides; a page surface has to reach the
   window edges or it reads as a card. So this bleeds by exactly the calculation
   the band above it uses (.dash-focus, task #479): margin 50% - 50vw pulls the
   element out of the centred column to the viewport's edges, where 50% is half
   the column and 50vw half the window, whatever either measures.

   With no inner wrapper to re-centre through, the SAME distance comes straight
   back as horizontal padding, so the content box lands exactly on the shared
   grid: heading, tabs and chart line up with the band's interior above and with
   `.container` below, and the grey behind them runs edge to edge. That is why
   the two horizontal values are one expression negated, and why neither is a
   pixel number that could drift from the other.

   THE AIR AROUND IT MOVED INSIDE IT. The band above used to own 50px of margin
   under itself and the content column 64px of padding under everything (both
   still their #507 and #497 numbers, unchanged as distances); with a surface
   here, both were white stripes separating two things Sam wants touching. So
   `.dash-focus` now ends at its own hairline, this section opens with that 50px
   as its own top padding, and the column's closing 64px becomes this band's
   bottom padding so the grey runs into the footer. Nothing moved by a negative
   offset and no gap changed size: the same distances are simply drawn in grey
   rather than in white. */
body:has(.dash-focus) .analytics-module {
  background: var(--bg);
  width: var(--bleed-w);
  /* Bottom margin cleared with the same shorthand that bleeds it: the footer
     follows this band immediately, so the module's own 28px (which the Program
     Overview still uses) must not reopen a white stripe under the grey. */
  margin: 0 calc(50% - var(--bleed-w) / 2);
  padding: 50px calc(var(--bleed-w) / 2 - 50%) 64px;
}

/* The column's closing air, on this page only, is drawn by the band above (see
   its bottom padding), so the column must not add it a second time under the
   grey. Scoped through the body rather than through `main` for the same reason
   the page colour is: it is a decision about this page, and the one section
   that names it. */
body:has(.dash-focus) main.container {
  padding-bottom: 0;
}

.dash-focus {
  /* Still the containing block for the slides' absolutely-positioned
     mid-flight overlay (see .dash-slide-anim), and still its own stacking
     context, so a slide in motion can never paint over the page header strip
     or the footer. The dot pattern this used to isolate is gone (task #762);
     since task #824 it is the photographic layer below that is held here. */
  position: relative;
  isolation: isolate;
  /* THE PHOTOGRAPH'S TWO DIALS (task #824), both stated here so the layer
     below reads as geometry and these are the only two numbers to turn.

     THE BLUR is a CSS one on a layer of its own, deliberately: the shipped
     WebP is sharp, so this can be tuned to taste without re-exporting an
     asset, and nothing in front of it is touched by it.

     THE WASH is a flat veil drawn ON the photograph, and since Sam's #824
     review it is a BLACK one: it settles the picture down rather than fading
     it out, which is what the darker, sharper treatment asked for.

     WHAT THAT COSTS, and why the panel below moved with it. A white wash used
     to lift the darkest pixel that could sit behind the content column, so the
     pair of numbers bounded the worst case by construction. A black wash
     cannot do that: black over a black photograph is still black, so the ONLY
     thing standing between the panel's type and pure black is the panel's own
     opacity. At the 80% panel this band shipped with, the app's muted ink
     measured 4.02:1 there and missed AA, so the panel is now 85% (4.58:1).
     test/dashboard-hero.test.ts does that arithmetic from these values rather
     than trusting this note, mixing whichever colour the wash names, so moving
     any of the three past what the type can take is a red build. */
  --dash-hero-blur: 3px;
  --dash-hero-wash: color-mix(in srgb, #000 30%, transparent);
  /* THE PANEL: white at 85%, the surface the content column and the empty
     state are drawn on, so the picture is still there behind the words rather
     than being hidden by them. It is the third value in the same arithmetic as
     the wash above, which is why it is stated beside it, and with a black wash
     it is the whole of the guarantee: see the note above for the measurement
     that moved it up from Sam's original 80%. */
  --dash-hero-panel: color-mix(in srgb, var(--card) 85%, transparent);
  /* Which of the two shipped sizes the layer draws. The URLs themselves arrive
     per account in the band's style attribute (src/dashboard-hero.ts); this is
     the stylesheet's half of that split, and the 720px block below is the only
     place it changes. */
  --dash-hero-src: var(--dash-hero-image);
  /* The blurred layer is drawn PAST all four edges (see the inset below) and
     cut off square here, at the BAND'S OWN EDGES.

     It is a clip path rather than the `overflow: clip` this carried until task
     #833, and the difference is the box each of them cuts at: overflow clips at
     the PADDING box, and the band now has horizontal padding (see below), so
     `clip` would have taken the photograph off both sides of the band and left
     white down the edges of the screen. `inset(0)` is the BORDER box, which is
     the band, whatever padding it carries.

     Everything else about the choice is unchanged: like `clip`, and unlike
     `hidden`, this makes nothing scrollable, so there is still no scroll
     container for a focused control inside a slide to be dragged into. */
  clip-path: inset(0);
  /* Under the photograph, and what a browser that never fetches it keeps: the
     band is still a white band, with white's palette on it. */
  background: var(--card);
  color: var(--ink);
  /* The section's own bottom edge, in the app's neutral rule token. */
  border-bottom: 1px solid var(--line);
  /* Full bleed (task #479 revision): stretch the band to the width of the
     surface it sits on from inside the centred column with the calculated
     pattern - 50% is half the column and half --bleed-w is half that surface,
     so the negative inline margins are exactly the distance from column edge to
     surface edge whatever the screen or zoom. The surface is the window on a
     public page and on a phone, and the content area beside the menu bar on a
     signed-in desktop page (task #831): the arithmetic below did not have to
     change for either, because it never named a viewport. The overhang a
     classic scrollbar adds is clipped at the root (see the html rule up top),
     so this can never scroll the page sideways. The inner element below
     re-centres the content itself. */
  width: var(--bleed-w);
  /* The 50px under the band (task #507) is UNCHANGED AS A DISTANCE and has
     moved to the other side of the join (task #772 review): the section below
     is now a grey band of its own, so a margin here would have drawn those 50px
     as a white stripe between two surfaces Sam wants meeting. It is the same
     one number, still setting the whole gap and still unable to compound, but
     it is stated as that band's top padding so the gap is drawn in its colour.
     The band therefore ends at its own hairline. */
  margin: 0 calc(50% - var(--bleed-w) / 2);
  /* AND THE BLEED IS TAKEN BACK OFF THE INTERIOR (task #833, Sam). The band is
     as wide as the surface; its contents must still be exactly as wide as the
     content column, and until now they were not.

     `.dash-focus-inner` re-centres by max-width and auto margins, which centres
     it on the BAND. That is right only while the max-width binds. On any window
     narrower than the column's maximum the interior simply filled the band, so
     its gutter was measured from an element WIDER THAN THE WINDOW - --bleed-w
     is `100vw` less the menu, and 100vw includes a classic scrollbar - and the
     content ended up about half a scrollbar closer to the glass than the header
     strip and the footer beside it. The band's picture is meant to run off the
     edges; its words are not.

     So the band pulls its own interior back to exactly where `.container`'s
     border box is, and then the interior carries the shared gutter itself, in
     the same declaration and from the same token as the other three. Half the
     bleed less half the column, which is what `50%` (the column's content box)
     plus one gutter comes to: whatever the surface, whatever the scrollbar, the
     content box that lands inside is the column's own, so a merchant reading
     the dashboard sees the same 30px down both sides of the page as on every
     other screen in the app.

     The photograph behind it is untouched: it is drawn by the layer below,
     which is positioned out to the band's border box, and cut off there by the
     clip path above rather than by overflow. */
  padding-inline: calc(var(--bleed-w) / 2 - 50% - var(--content-gutter));
}

/* THE DOT PATTERN IS STILL GONE (task #762, Sam), and this is not it coming
   back. That layer was a repeating radial-gradient of white dots drawn for a
   dark ink band (task #489); Sam had it removed outright when the band went
   white, and nothing in this sheet draws a dot on the dashboard any more.

   THE BAND'S PHOTOGRAPH (task #824). A picture of a real small business behind
   the whole band, on its own layer, so:

     - THE BLUR REACHES ONLY THE PICTURE. It is declared here and nowhere else,
       so no text, control, chart or phone above it is ever filtered. That is
       the reason this is a layer at all rather than a background on the band:
       a filter on .dash-focus would blur its contents.
     - THE AMOUNT IS A NUMBER, NOT AN EXPORT. --dash-hero-blur above is the one
       dial, and the shipped WebP is sharp, so refining the effect never means
       regenerating an asset.
     - THE PICTURE IS PER ACCOUNT AND THE DRAWING IS NOT. The two URLs arrive in
       the band's style attribute, chosen from the account's Business Type by
       src/dashboard-hero.ts; everything about how they are cropped, washed,
       blurred and clipped is here, once, for every account.

   THE EDGES. A blur samples nothing beyond its own box, so a layer at inset 0
   fades to transparent along all four edges and the page shows through as a
   halo. The layer is therefore pulled OUT by three times the blur radius on
   every side, which puts the whole falloff outside the band, and the band's
   own `overflow: clip` cuts it off square. Three radii is the conventional
   allowance for a Gaussian: a CSS blur of radius r is a standard deviation of
   r, and beyond 3 sigma there is nothing left to see.

   THE CROP is `cover`, centred: one file serves a band that is 1920 wide and
   short on a desktop and 390 wide and tall on a phone, and neither one
   stretches the picture. What a phone loses off the sides it never sees, and
   what it draws is blurred anyway.

   The public card designer's working column (.cc-split::before, task #580)
   still draws dots of its own: a separate surface with its own tinted ground,
   never asked about here, stating its own numbers. (The public front page's
   hero drew the same dots until task #955 pass 3 put a photograph under it.) */
.dash-focus::before {
  content: '';
  position: absolute;
  /* Vertically, three blur radii outside the band. Horizontally, the same three
     radii outside the band's BORDER box, which is three radii plus the band's
     own horizontal padding outside this layer's containing block (task #833:
     an absolutely positioned box is laid out against its ancestor's PADDING
     box, and the band gained padding when it took its interior in hand).
     `50%` here is half that padding box, which is the content column, so
     `50% - --bleed-w / 2` is exactly the padding negated, whatever the surface
     and whatever the column measures. The clip path on the band cuts the whole
     falloff off square at its edges. */
  inset-block: calc(-3 * var(--dash-hero-blur));
  inset-inline: calc(50% - var(--bleed-w) / 2 - 3 * var(--dash-hero-blur));
  z-index: 0;
  /* Decoration, and inert: it cannot take a click, and .dash-focus-inner's
     z-index puts every pixel of content above it. */
  pointer-events: none;
  /* The wash sits ON the photograph, as a flat two-stop gradient because that
     is how a colour is drawn as a background layer. */
  background-image: linear-gradient(var(--dash-hero-wash), var(--dash-hero-wash)),
    var(--dash-hero-src);
  background-position: center;
  background-repeat: no-repeat;
  background-size: cover;
  filter: blur(var(--dash-hero-blur));
}

/* Flush under the page header strip: when the band opens the page (no billing
   banner above it), cancel the content column's own top padding - the named
   token, never a guessed offset - so the band's white meets the purple strip
   with no gap. */
main.container > .dash-focus:first-child {
  margin-top: calc(-1 * var(--main-pad-top));
}

/* Re-centres the band's content onto the body's content grid: the same
   max-width and the same shared gutter as `.container` and `.pagehead-inner`,
   so the three focused columns line up with, and use all of, the normal
   constrained content width while the picture behind them runs edge to edge.
   Since task #833 the band has already brought this element back to the
   column's own border box (see its padding-inline), so what is left here is
   exactly what `.container` does with the width it is given. */
.dash-focus-inner {
  /* Above whatever the slides' mid-flight overlay is doing: one intentional
     layer, so nothing in motion can sit over text, a control or its focus
     ring. (It also held the removed dot pattern down; see task #762.) */
  position: relative;
  z-index: 1;
  max-width: var(--content-max);
  margin: 0 auto;
  /* 50px of air above and below the band's content (task #507), stated as two
     separate values because they are two separate decisions; the token between
     them is the shared content gutter and is what keeps this interior on the
     same grid as `.container`, which is why it is a token and not a number. */
  padding: 50px var(--content-gutter) 50px;
}

/* The slides viewport: the box the directional animation happens inside.
   Relative, so the outgoing slide can overlay the incoming one while both are
   on screen; clipped horizontally (clip, not hidden: no scroll container for
   a focused offscreen slide to drag sideways), so a slide mid-travel never
   widens the page; pan-y, so a finger on it still scrolls the page vertically
   and only a deliberate horizontal drag is a swipe. */
.dash-focus-slides {
  position: relative;
  overflow-x: hidden;
  overflow-x: clip;
  touch-action: pan-y;
}

/* The directional slide (task #479 revision): next sends the current slide
   out to the LEFT and brings the incoming one in from the right; previous is
   the mirror. The outgoing slide is lifted out of flow so the two overlap for
   the fraction of a second both exist; the incoming slide stays in flow and
   owns the viewport's height. Fast and crisp on purpose. */
.dash-slide-anim {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.dash-slide-out-left {
  animation: dash-slide-out-left 190ms ease both;
}

.dash-slide-out-right {
  animation: dash-slide-out-right 190ms ease both;
}

.dash-slide-in-right {
  animation: dash-slide-in-right 190ms ease both;
}

.dash-slide-in-left {
  animation: dash-slide-in-left 190ms ease both;
}

@keyframes dash-slide-out-left {
  to {
    transform: translateX(-100%);
  }
}

@keyframes dash-slide-out-right {
  to {
    transform: translateX(100%);
  }
}

@keyframes dash-slide-in-right {
  from {
    transform: translateX(100%);
  }
}

@keyframes dash-slide-in-left {
  from {
    transform: translateX(-100%);
  }
}

/* Reduced motion: no slide, no fade, no flash - the switch is instant. The
   script checks the same media query and skips the animation classes
   entirely; this rule is the belt to that brace, so even a stray class
   cannot move content for someone who asked it not to. */
@media (prefers-reduced-motion: reduce) {
  .dash-slide-out-left,
  .dash-slide-out-right,
  .dash-slide-in-right,
  .dash-slide-in-left {
    animation: none;
  }
}

/* Links in the band are the app's own purple, as they are everywhere else on a
   white surface (task #762). `:not(.btn)` is load-bearing rather than tidy: the
   heading row's Manage Program is an ANCHOR carrying .btn, a filled purple
   button whose label is white, and a catch-all `.dash-focus a` outranks .btn on
   specificity - it would paint purple text on a purple fill. */
.dash-focus a:not(.btn) {
  color: var(--brand-fixed);
}

/* The empty state (a Team Member of an account with no running Program) reads
   on the same white panel the content column wears, for the same reason: it is
   a sentence over a photograph, and a sentence over a photograph needs a
   surface (task #824). Same wash, same padding as the column at this width, so
   the two states of the band are the same band. */
.dash-focus-empty {
  margin: 0;
  background: var(--dash-hero-panel);
  padding: 26px;
  color: var(--muted);
}

/* The slide's heading row (task #495): the title/offer group on the left, the
   Manage Program action on the right, vertically centred against the text
   block. The text group is the flexible one - `flex: 1 1 <basis>` with
   `min-width: 0` - so a long Program name or a long offer takes the whole
   remaining width and wraps inside it, and the action (flex: 0 0 auto, so it
   is only ever as wide as its own label) is never squeezed and never squeezes
   the copy. The basis is a readable measure, not a sample title's width.

   Narrow: `flex-wrap: wrap` drops the action onto its own line below the
   offer, still sized to its text and aligned with it, so nothing truncates and
   nothing overflows. See the 720px block for the touch sizing.

   IT CARRIES NO MARGIN OF ITS OWN (Sam, task #811 review). The row used to
   stand above both columns with 40px under it (task #507); it is now the first
   block INSIDE the content column, so the column's own 18px flex gap is the
   distance to the counts beneath it and a margin here would double-space it.
   The 40px did not move anywhere: the row it separated things from is gone. */
.dash-focus-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 14px 24px;
  margin: 0;
}

.dash-focus-headtext {
  flex: 1 1 260px;
  min-width: 0;
}

.dash-focus-title {
  margin: 0 0 4px;
  color: var(--ink);
  font-size: 1.8rem;
  overflow-wrap: anywhere;
}

/* The Program's configured offer, as the subtitle directly under its name
   (task #489). Real Program data, so it can be any length: it wraps rather
   than truncating, and takes the app's muted ink, which clears AA on the
   band's white (task #762). The
   spacing below the pair now belongs to the heading row (task #495), so the
   offer itself carries no bottom margin. */
.dash-focus-offer {
  margin: 0;
  color: var(--muted);
  font-size: 1rem;
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* Title and offer above, then two columns (task #489): the device in one
   third, the Program's content in two. Since task #507 the phone fills its
   whole third, which makes it the taller column by a long way, so the two are
   centred against each other rather than hung from a shared top line: the
   Program's content now reads against the middle of its own card instead of
   trailing off beside a much longer device.
   The 60px between them is a desktop measure and belongs to the wide layout
   only; the stacked layout below keeps its own smaller, established gap. */
.dash-focus-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
  gap: 60px;
  align-items: center;
}

/* The device column stands DIRECTLY ON THE PHOTOGRAPH (task #824): no fill, no
   panel, no card behind it, so the phone is a phone on a counter rather than a
   phone on a white tile on a counter. The frame's own artwork is opaque where
   the phone is and transparent everywhere else, which is exactly what makes
   that work, and the only opaque thing inside it is the card itself, which is
   the product. Nothing to remove here: the column has never had a surface, and
   this note is what keeps one from being added. */
.dash-focus-device {
  min-width: 0;
}

.dash-focus .phone-frame {
  /* Both tracks are definite (minmax(0, Nfr)), so a percentage width resolves
     here and the frame - whose children are all absolutely positioned, giving
     it no intrinsic width of its own - still gets a real box.

     THE PHONE TAKES ITS WHOLE COLUMN (task #507). It used to be held back to
     300px inside a column wider than that, which left a strip of dead ink
     beside it; now it is exactly as wide as the track the grid gives it, so
     it can never exceed its column and the column can never exceed the
     content grid. The height follows from the frame's inline aspect-ratio
     (PHONE_FRAME, 748 x 1539), so the artwork's proportions are unchanged at
     any width. The shared `.phone-frame` max-width of 374px - half the source
     PNG, the point past which the artwork would be upscaled - still stands
     above this and is the one thing that could cap it: at the widest content
     column the device track is (1120 - 60 - 60) / 3 = 333.33px, so it does
     not bind today and is left in place purely as that guarantee.
     `margin-right: auto` with NO left margin is what pins it to the far left
     edge of its column rather than centring it, which only has anything to do
     if that cap ever does bind. */
  width: 100%;
  margin: 0 auto 0 0;
}

/* The content column: one vertical rhythm from the heading row - which is the
   first block in it since Sam's #811 review, where the enrolment link used to
   be - down to the Program's chart and, with more than one Program, the
   position line that closes the column (Sam, task #811 follow-up). Everything
   in it stretches to the column's full width, which is what lets that line be
   centred against the column rather than against its own text. */
.dash-focus-main {
  display: flex;
  flex-direction: column;
  gap: 18px;
  min-width: 0;
  /* THE WHITE PANEL (task #824): the column is now over a photograph, so it
     carries its own surface at 80% white. Everything readable on the band is
     inside this box - the title, the offer, Manage Program, the three counts,
     the chart and the paging row - so one declaration covers the lot, and the
     picture still shows through it rather than being blocked out.
     The padding is the panel's own air; the column's 18px rhythm inside it is
     untouched, and so is every measure the two columns are laid out on. */
  background: var(--dash-hero-panel);
  padding: 26px;
}

/* The counts' boxes on the white band (task #762): the page's own off-white
   fill inside the app's neutral rule, which is a quiet recessed tile rather
   than a second white square lost against the band. Both are named tokens, so
   the boxes here and the analytics module's tabs below cannot drift apart. */
.dash-focus-block {
  background: var(--bg);
  border: 1px solid var(--line);
  padding: 14px 16px;
  min-width: 0;
}

.dash-focus-block-title {
  margin: 0 0 8px;
  font-size: 0.78rem;
  font-weight: 700;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* THE ENROLMENT LINK IS NOT ON THE DASHBOARD (Sam, task #811 review). Task
   #489 put it on the band as a restrained row of plain text with a copy control
   beside it, and the whole .dash-copy-* family existed for that one block. Sam
   asked for the block removed and the Program's heading row moved into its
   place, so the family went with it rather than being left dead in the sheet.
   The link itself did not go anywhere: it lives on the Program's own page,
   which is where Manage Program leads, drawn by the .prog-copy-* family the
   customer detail rail shares. */

.dash-focus-more {
  display: inline-block;
  font-size: 0.8rem;
  text-decoration: underline;
}

/* The three lifetime counts, side by side across the content column. */
.dash-focus-counts {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 12px;
}

/* The three lifetime figures, in the app's black ink (Sam, task #771). They
   were the brand purple the analytics module's totals below them use
   (task #762); Sam asked for the numbers themselves to be black, so they now
   take --ink, the same ink as the band's title above them, and the purple is
   left to the analytics totals. */
.dash-focus-count-num {
  margin: 0 0 6px;
  font-size: 1.9rem;
  font-weight: 700;
  line-height: 1.1;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* --- the one chart (task #504, from tasks #452 and #489) --------------------

   ONE line chart component (src/views/charts.tsx) on every surface: the shared
   analytics module's three panels and the focused Program band's Stamps trend.
   The marks all carry .lk-chart-*, so the geometry, the responsive scaling and
   the forced-colours behaviour are stated once.

   EVERY CHART IS LOYALPACK PURPLE (task #583). The band's charts used to be
   painted in each Program's own card colour, written by the server as SVG
   presentation attributes; the graph therefore moved whenever a merchant
   changed a stamp colour. A card colour belongs on the card, so the chart now
   carries no colour of its own at all. Both palettes live here, one per
   surface, and the component adds exactly one of the two classes:

     .lk-chart-panel   LoyalPack's white cards (the shared analytics module,
                       and since task #762 the focused Program band too)
     .lk-chart-ink     a dark ink surface

   Neither palette can reach the other, and no unscoped mark rule sits under
   them for a chart to fall back to by accident.

   THE INK PALETTE IS KEPT (task #762). The focused band was the one caller of
   it and is now white, so it asks for 'panel' like everything else; the ink
   palette stays declared because it is the answer to "how is this chart drawn
   on a dark surface", and #583's rule that both surfaces are stated in one
   place, from --brand-fixed and its band-legible tint --brand-on-ink, is what
   stops a future dark surface deriving a colour for itself. It is deliberately
   NOT applied to the band: --brand-on-ink #9788d9 measures 3.07:1 against
   white, barely the 3:1 a non-text graphic is asked for, where the flat brand
   purple is 7.85:1 there. The graph is the thing on this band people read. */
.lk-chart-svg {
  display: block;
  width: 100%;
  height: auto;
  max-width: 100%;
}

/* On white: the brand purple line and dots over a low-alpha wash of the same
   hue. #5238bf measures 8.6:1 against the panel's white, well past the 3:1
   that non-text graphics are asked for, and the shape is never the only
   carrier of meaning: the selected metric's label and total are beside it and
   the exact daily table is under it. --brand-fixed rather than --brand, so a
   page that ever hands Layout a merchant colour cannot repaint the graph. */
.lk-chart-panel .lk-chart-line {
  stroke: var(--brand-fixed);
}

.lk-chart-panel .lk-chart-dot {
  fill: var(--brand-fixed);
}

.lk-chart-panel .lk-chart-area {
  fill: rgba(82, 56, 191, 0.12);
}

.lk-chart-panel .lk-chart-grid {
  stroke: var(--line);
}

.lk-chart-panel .lk-chart-axis {
  stroke: var(--muted);
}

.lk-chart-panel .lk-chart-tick,
.lk-chart-panel .lk-chart-date {
  fill: var(--muted);
}

/* On ink: the same purple, lifted to read on the band (--brand-on-ink,
   6.06:1), over a wash of itself. The wash is 0.22 rather than the panel's
   0.12 because a tint has to work harder to be seen on a dark surface. */
.lk-chart-ink .lk-chart-line {
  stroke: var(--brand-on-ink);
}

.lk-chart-ink .lk-chart-dot {
  fill: var(--brand-on-ink);
}

.lk-chart-ink .lk-chart-area {
  fill: rgba(151, 136, 217, 0.22);
}

/* The band's neutral furniture, in its established pale lavender. */
.lk-chart-ink .lk-chart-grid {
  stroke: rgba(255, 255, 255, 0.14);
}

.lk-chart-ink .lk-chart-axis {
  stroke: rgba(255, 255, 255, 0.32);
}

/* Diminutive but readable: 12 units in a 720-wide viewBox, in the pale
   lavender that measures 11.3:1 on the band. The exact figures never depend
   on reading them - the daily table beside the graph is the data. */
.lk-chart-ink .lk-chart-tick,
.lk-chart-ink .lk-chart-date {
  fill: #c8c2d4;
}

.lk-chart-grid,
.lk-chart-axis {
  stroke-width: 1;
}

.lk-chart-tick,
.lk-chart-date {
  font-size: 12px;
  font-family: inherit;
}

/* --- three series on one pair of axes (task #787) ---------------------------

   The customer's own page draws stamps, rewards earned and rewards spent
   together, so it needs three marks a person can tell apart. #583's rule is
   unchanged and is what this obeys: the colours are stated HERE, once, and no
   call site ever passes one in.

   THE SET WAS VALIDATED, NOT PICKED BY EYE. All three sit in the readable
   lightness band, clear the chroma floor (none of them reads grey), clear 3:1
   against the white panel, and separate under protanopia, deuteranopia and
   tritanopia as well as for normal vision (worst adjacent pair OKLab dE 12.7
   simulated, 26.5 normal, against a target of 8 and a floor of 15).

     s1  --brand-fixed  #5238bf  stamps, in the product's own purple, because it
                                 is the line a merchant looks at most and every
                                 other chart in the app is already that colour
     s2  teal           #0e8f7e  rewards earned
     s3  rust           #c2410c  rewards spent

   AND COLOUR IS NEVER THE ONLY DIFFERENCE. Each slot also carries its own dash
   pattern, so the three read apart in greyscale, on a bad projector, printed,
   and in forced-colours mode where all three become CanvasText. The legend
   beside the chart names them in words, and the exact daily figures are in a
   table for assistive technology. */
/* s1 is solid, so it states a stroke and no dash pattern at all. */
.lk-chart-multi .lk-chart-line.lk-chart-s1 {
  stroke: var(--brand-fixed);
}

.lk-chart-multi .lk-chart-dot.lk-chart-s1 {
  fill: var(--brand-fixed);
}

.lk-chart-multi .lk-chart-line.lk-chart-s2 {
  stroke: var(--chart-s2);
  stroke-dasharray: 7 4;
}

.lk-chart-multi .lk-chart-dot.lk-chart-s2 {
  fill: var(--chart-s2);
}

.lk-chart-multi .lk-chart-line.lk-chart-s3 {
  stroke: var(--chart-s3);
  stroke-dasharray: 1.5 4;
}

.lk-chart-multi .lk-chart-dot.lk-chart-s3 {
  fill: var(--chart-s3);
}

/* The legend: real words in the page's own ink, never in the series colour, with
   a sample of the actual line beside each. Wraps at narrow widths rather than
   scrolling. */
.lk-chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 20px;
  margin: 10px 0 0;
  padding: 0;
  list-style: none;
  font-size: 0.85rem;
  color: var(--muted);
}

.lk-chart-legend-item {
  display: flex;
  align-items: center;
  gap: 8px;
}

.lk-chart-legend-mark {
  width: 24px;
  height: 8px;
  flex: 0 0 auto;
}

/* --- reading the picture: crosshair, rings and readout (task #807 follow-up)

   Sam asked to be able to hover the graph and have it say what is under the
   pointer. Everything in this sub-section is drawn ONLY by the charts that opt
   in (MultiLineChart's `interactive`), which today is the Reports Overview and
   nothing else, and every one of these marks starts [hidden] until the script
   has a day to point at. So a chart without the script, or with it broken, is
   exactly the chart it was before: lines, dots, a legend and the real figures
   table underneath.

   NONE OF IT IS THE ACCESSIBLE CONTRACT. The exact numbers are in that table,
   as they are on every chart in the app. This is a second way to read the same
   figures for a sighted pointer user, plus arrow-key access to the same
   readout for a keyboard, announced through a live region the component also
   renders. */

/* The positioning context the readout hangs in, and the one thing on the
   picture a keyboard can land on. */
.lk-chart-frame {
  position: relative;
}

.lk-chart-frame:focus-visible {
  outline: 3px solid var(--brand-fixed);
  outline-offset: 3px;
  border-radius: 6px;
}

/* THE PICTURE HAS A FLOOR. A viewBox is an aspect, so the wide box the Overview
   draws at would be an 86px strip on a phone: a graph nobody can read. Below the
   width where the aspect stops working the drawing keeps a minimum and this
   scrolls to it sideways instead, which contains the overflow here rather than
   handing it to the page. Vertical scrolling is untouched.

   THE TWO NUMBERS MEET, so there is no jump at the breakpoint. The Overview's
   card is the window less its 24px gutters, so the drawing is already 772px
   wide at a 820px window and the floor does not bind; below about 808px it
   starts to, and the picture scrolls by the few pixels it is short. The same
   floor is what any other chart that opts in would get. */
.lk-chart-scroll {
  overflow-x: auto;
  overscroll-behavior-x: contain;
}

@media (max-width: 820px) {
  .lk-chart-scroll .lk-chart-svg {
    min-width: 760px;
    max-width: none;
  }
}

/* The hidden attribute is what the script toggles on these, and an SVG child is
   not reliably hidden by the user agent's own [hidden] rule. Stated here so a
   chart outside a report card behaves the same way. */
.lk-chart [hidden] {
  display: none;
}

/* The crosshair: a quiet dashed rule at the day being asked about, never a
   second thing to read. */
.lk-chart-crosshair {
  stroke: var(--muted);
  stroke-width: 1;
  stroke-dasharray: 4 4;
}

/* Each showing line's point on that day, ringed in its own colour. A ring
   rather than a bigger dot, so the mark the merchant is reading is still the
   mark the server drew. */
.lk-chart-ring {
  fill: none;
  stroke-width: 2.5;
}

.lk-chart-multi .lk-chart-ring.lk-chart-s1 {
  stroke: var(--brand-fixed);
}

.lk-chart-multi .lk-chart-ring.lk-chart-s2 {
  stroke: var(--chart-s2);
}

.lk-chart-multi .lk-chart-ring.lk-chart-s3 {
  stroke: var(--chart-s3);
}

/* The plot itself, as a target: invisible, and the reason a hover anywhere over
   the drawing answers about the nearest day rather than only when the cursor
   lands on a 3px dot. */
.lk-chart-surface {
  fill: transparent;
  cursor: crosshair;
}

/* The readout. The script sets its LEFT and nothing else, so the top is settled
   here and a tooltip can never wander up and down as the pointer moves across
   the days. It takes no pointer events of its own, so it can never sit between
   the cursor and the plot it is describing. */
.lk-chart-tip {
  position: absolute;
  top: 10px;
  left: 0;
  z-index: 2;
  min-width: 156px;
  max-width: 280px;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--card);
  font-size: 0.85rem;
  line-height: 1.4;
  pointer-events: none;
}

.lk-chart-tip-day {
  margin: 0 0 6px;
  font-weight: 700;
  color: var(--ink);
}

.lk-chart-tip-list {
  display: grid;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.lk-chart-tip-row {
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--muted);
}

/* The same sample the legend carries, in the same three slot classes, so a row
   in the readout and a line on the picture are matched by shape as well as by
   colour. */
.lk-chart-tip-mark {
  width: 18px;
  height: 0;
  flex: 0 0 auto;
  border-top: 3px solid var(--brand-fixed);
}

.lk-chart-tip-mark.lk-chart-s2 {
  border-top-color: var(--chart-s2);
  border-top-style: dashed;
}

.lk-chart-tip-mark.lk-chart-s3 {
  border-top-color: var(--chart-s3);
  border-top-style: dotted;
}

.lk-chart-tip-value {
  margin-left: auto;
  font-weight: 700;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* Forced colours: the brand-coloured marks give way to system colours, and
   the tint is dropped rather than left as an unreadable wash. Written at the
   same weight as the two surface palettes above, and after them, so it wins
   on both. The three-series chart survives this because its dash patterns are
   not colours and are left alone. */
@media (forced-colors: active) {
  .lk-chart .lk-chart-line {
    stroke: CanvasText;
  }

  .lk-chart .lk-chart-dot {
    fill: CanvasText;
  }

  .lk-chart .lk-chart-area {
    fill: transparent;
  }

  /* The reading scaffolding takes system colours too, and the readout keeps a
     real border so it is still a box rather than floating text. */
  .lk-chart-crosshair {
    stroke: CanvasText;
  }

  .lk-chart-ring {
    stroke: CanvasText;
  }

  .lk-chart-tip {
    border-color: CanvasText;
    background: Canvas;
    color: CanvasText;
  }
}

/* Nothing here animates, at any setting: the line, the dots and the fill are
   painted once by the server and never move, so there is no motion for
   prefers-reduced-motion to have to switch off. The crosshair and the readout
   added in task #807's follow-up are placed, never tweened, so that still
   holds with them on the picture. */

/* --- the focused Program's chart wrapper (task #489) --- */
.prog-linechart {
  min-width: 0;
}

.prog-linechart-title {
  margin: 0 0 4px;
  font-size: 0.78rem;
  font-weight: 700;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.prog-linechart-def,
.prog-linechart-empty {
  margin: 0 0 10px;
  font-size: 0.82rem;
  color: var(--muted);
}

/* Manage Program: the heading row's action (task #495), the standard button
   from the shared family, sized to its text rather than to the row. `flex: 0 0
   auto` keeps it at its label's width at every screen size - it neither
   stretches across the band nor gives ground to a long Program name - and the
   min-height guarantees a 44px target however the label wraps.

   Since task #762 it sits on white, so its focus ring is the app's purple, the
   same ring the band's other controls take. */
.dash-focus-manage {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: 44px;
}

.dash-focus-manage:focus-visible {
  outline: 3px solid var(--brand-fixed);
  outline-offset: 2px;
}

/* The paging row (task #479 revision, put back together by Sam's correction to
   the #811 review): previous, the position, next, on one centred row at the
   foot of the content column.

   It sat under BOTH columns until Sam's #811 follow-up moved the position line
   into the content column and left the two arrows behind in it. The three read
   as one control, so the row itself moved down to join the line rather than the
   line being dragged back up: it is now the last flex child of
   .dash-focus-main, which stretches its children, so `justify-content: center`
   centres the trio against the COLUMN - not against the band, whose middle
   falls in the gap beside the phone.

   It closes the column, so it keeps the column's own rhythm (the 18px flex gap)
   and states no margin of its own: the 40px that used to hold it clear of the
   two columns above belonged to a row that sat under them, and there is nothing
   above this one but the chart it follows. */
.dash-focus-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
}

/* The previous/next controls: large purple Font Awesome angles, real buttons
   with visible focus. The app's own purple now that the band is white (task
   #762) - it was a lifted #9b8ae8 for legibility on ink, which would be barely
   there here - darkening to the page's ink on hover. */
.dash-focus-nav {
  appearance: none;
  border: 0;
  background: none;
  cursor: pointer;
  padding: 4px 12px;
  min-width: 44px;
  min-height: 44px;
  color: var(--brand-fixed);
  font-size: 2rem;
  line-height: 1;
}

.dash-focus-nav:hover {
  color: var(--ink);
}

.dash-focus-nav:focus-visible {
  outline: 3px solid var(--brand-fixed);
  outline-offset: 2px;
}

/* Which Program of how many, between the two arrows at the foot of the content
   column (Sam, task #811 follow-up and his correction to the review; it was
   .dash-focus-status when the row sat under both columns). The row above
   centres it now that the arrows are back either side of it; text-align stays
   because the line wraps on a narrow phone and both of its lines should sit
   centred under each other. Quiet muted type: it is a position marker, not
   something to read before the Program's own figures. */
.dash-focus-position {
  margin: 0;
  text-align: center;
  font-size: 0.85rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

@media (max-width: 980px) {
  /* One column: the phone, then the Program's content, which since Sam's #811
     review opens with the title and the offer - the same order the markup reads
     in. The phone keeps a legible width and centres over the content below
     it. */
  .dash-focus-grid {
    grid-template-columns: minmax(0, 1fr);
    gap: 22px;
  }

  .dash-focus .phone-frame {
    /* Centred once it is no longer beside anything, and free to shrink with a
       very narrow screen. Stacked, its column IS the whole content width, so
       the full-column width above is scoped to the two-column layout and this
       keeps the established cap: a device drawn the width of a phone screen
       would push everything about the Program below the fold. At 980px the
       desktop track is (980 - 60 - 60) / 3 = 286.67px against the 300px cap
       here, so the two sizes all but meet at the breakpoint and the phone
       cannot jump wider than the width of its own artwork across it. */
    width: min(100%, 300px);
    margin: 0 auto;
  }
}

@media (max-width: 720px) {
  /* Phone widths: the three counts stay on one row (they are short numbers
     and one-word labels) but give up their generous padding so nothing has to
     overflow to fit. Their report links are already at their one size (see
     .dash-focus-more), so nothing restates it here. */
  .dash-focus-title {
    font-size: 1.25rem;
  }

  .dash-focus-counts {
    gap: 8px;
  }

  .dash-focus-counts .dash-focus-block {
    padding: 10px;
  }

  .dash-focus-count-num {
    font-size: 1.5rem;
  }

  /* THE PHONE TAKES THE SMALL PICTURE (task #824). The band here is narrow and
     tall and the crop is severe whatever the file, so the 1200px WebP shows a
     merchant the same thing the 1920px one would at rather less than half the
     bytes, on the connection least able to spare them. The fallback is the wide
     file, so a band that was somehow given only one URL still draws. */
  .dash-focus {
    --dash-hero-src: var(--dash-hero-image-narrow, var(--dash-hero-image));
  }

  /* The panel keeps its surface and gives up the desktop's air: at 390px, 26px
     each side is a sixth of the screen. The empty state matches it. */
  .dash-focus-main,
  .dash-focus-empty {
    padding: 18px;
  }

  /* Every chart's SVG scales down with its column, and its labels scale with
     it, so they are stated larger in viewBox units here to land at roughly
     the same rendered size a phone reads them at. */
  .lk-chart-tick,
  .lk-chart-date {
    font-size: 18px;
  }

}

/* FORCED COLOURS: the photograph goes rather than being repainted, and the two
   translucent panels become the system's own surface. A picture behind text is
   exactly what somebody in this mode has asked not to have, and a wash of a
   colour the browser has overridden is meaningless anyway. This is the same
   answer the public card designer's decorative layer gives (.cc-split::before,
   task #580): display: none, not a recolour. */
@media (forced-colors: active) {
  .dash-focus::before {
    display: none;
  }

  .dash-focus-main,
  .dash-focus-empty {
    background: Canvas;
  }
}

/* REDUCED TRANSPARENCY: the same two panels go opaque, so the words sit on flat
   white with the photograph still visible around them rather than through them.
   The picture itself stays: it is behind the content, not under it. */
@media (prefers-reduced-transparency: reduce) {
  .dash-focus-main,
  .dash-focus-empty {
    background: var(--card);
  }
}

/* --- the dashboard's setup tasklist (task #811 follow-up, Sam) -------------

   The six things a business sets up once, directly under the focused Program
   band and directly above the ink quick-links band. White, in the ordinary
   content column, because it is the page's own content rather than a surface:
   the two bands either side of it are the surfaces, and a third one here would
   leave nothing plain to read.

   THE #507 50px ON BOTH SIDES. Above, it is the distance from the band's
   closing hairline; below, it is this section's OWN closing air, drawn in white
   before the ink band starts, and it has to be here rather than left to that
   band. The join below is a hard colour edge and not a hairline: without it the
   bordered list's bottom rule would sit flush against the ink and read as
   though the ink had cut the last row off. That leaves 100px between the list
   and "Quick links" (50 white, 50 ink), which is exactly the distance the quick
   links and the graph already had between them.

   THE ROWS ARE ONE BORDERED LIST rather than six separate cards, because a
   checklist is read down its left edge: one column of marks, one column of
   text, and the hairline between rows keeps the eye on the line it is on. Every
   colour is a named token against the white page (task #762): --ink 18.62:1 for
   a live title, --muted 6.46:1 for a finished one and for every supporting
   line, --success 5.87:1 for a tick, --brand-fixed 7.85:1 for the links.
   --brand-fixed and never --brand, which is overridden per account from the
   merchant's own branding: no merchant colour tints LoyalPack's own chrome. */
.dash-tasks {
  padding: 50px 0;
}

/* The title, the count and the one dismissal on one line, wrapping when they
   will not all fit. Baseline alignment so the small count sits on the heading's
   baseline rather than floating in the middle of its box. */
.dash-tasks-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  margin: 0 0 16px;
}

.dash-tasks-heading {
  margin: 0;
  color: var(--ink);
  font-size: 1.2rem;
}

.dash-tasks-count {
  margin: 0;
  color: var(--muted);
  font-size: 0.9rem;
}

/* "HIDE AND DO NOT SHOW AGAIN", ALIGNED RIGHT BESIDE THE HEADING (Sam). The
   `auto` margin is what puts it at the trailing edge, and it goes on doing that
   after a wrap: on a narrow phone the link drops to its own line and is still
   pushed to the right of it, so the control is in the same place at every width
   and the heading never has to compete with it for the middle of the row.
   Nothing here is nowrap: the words may wrap inside the link rather than
   pushing the page sideways. */
.dash-tasks-hide {
  margin: 0 0 0 auto;
}

/* What the last press did, and - after a hide, which takes the row off the page
   - the one press that would bring it back. Rendered only on the render that
   follows the write, so this is not a permanent strip on the page. */
.dash-tasks-note {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  margin: 0 0 16px;
}

.dash-tasks-note-text {
  margin: 0;
  color: var(--success);
  font-size: 0.95rem;
  font-weight: 600;
}

.dash-tasks-undo {
  margin: 0;
}

/* THE SECTION'S TWO TEXT LINKS: the dismissal beside the heading (Sam: "make
   Hide and do not show again a text link") and the way out of it, offered once
   beside the sentence that announced the hide.

   BOTH ARE REAL SUBMIT BUTTONS IN REAL FORMS, because both WRITE, and
   everything that would make a <button> look like one is stripped here: no
   background, no border, no padding, and the page's own face and size. What is
   left reads as the plain link each of them should be rather than as a third
   button competing with the way in on every row. One recipe for the two, so
   they cannot drift into looking like different kinds of thing. */
.dash-tasks-hide-btn,
.dash-tasks-undo-btn {
  appearance: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: var(--brand-fixed);
  text-decoration: underline;
  cursor: pointer;
}

/* Each takes the size of the line it sits on: the dismissal beside the count,
   the undo beside the sentence that announced the hide. */
.dash-tasks-hide-btn {
  font-size: 0.9rem;
}

.dash-tasks-undo-btn {
  font-size: 0.95rem;
}

.dash-tasks-hide-btn:focus-visible,
.dash-tasks-undo-btn:focus-visible {
  outline: 3px solid var(--brand-fixed);
  outline-offset: 2px;
}

.dash-tasks-list {
  margin: 0;
  padding: 0;
  list-style: none;
  border: 1px solid var(--line);
}

/* Two tracks at every width since Sam's review: the mark, then the block. The
   buttons moved INSIDE the block, at its bottom, so there is no third column to
   squeeze and nothing left to drop under anything on a phone. The text track
   has a 0 minimum, so a long title wraps inside the row instead of growing it
   past the content column and scrolling the page sideways. */
.dash-task {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: 4px 16px;
  padding: 16px 18px;
}

.dash-task + .dash-task {
  border-top: 1px solid var(--line);
}

/* The hand-drawn ring, and the tick inside it once the step is done. The
   colour is on the wrapper and the drawing is `stroke: currentColor`, so the
   two states are one colour change and the artwork itself never varies. The
   same class carries both shapes the row can take: a plain <span> on a step the
   account's own data answered, and a <form> on one that is still a merchant's
   to tick. */
.dash-task-mark {
  display: flex;
  line-height: 0;
  padding-top: 2px;
  margin: 0;
  color: var(--muted);
}

.dash-task-done .dash-task-mark {
  color: var(--success);
}

.dash-task-mark-art {
  flex: none;
  width: 28px;
  height: 28px;
}

/* THE CIRCLE IS THE CONTROL (Sam's review revision): clicking the empty ring
   ticks the step. It is a real submit button, so it is keyboard-reachable and
   Space/Enter-pressable for nothing, and everything that would make it LOOK
   like a button is stripped: no background, no border, no padding of its own.
   What is left is the drawing, which is exactly what Sam asked to see. The
   accessible name is the visually-hidden sentence inside it saying what
   pressing it will do.

   The hit area is the ring plus a little, from the padding below rather than
   from a larger drawing: 28px of artwork inside 4px of padding clears the 32px
   a finger expects without the ring growing away from the title beside it. */
.dash-task-tick {
  appearance: none;
  display: flex;
  margin: -4px;
  padding: 4px;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  line-height: 0;
  cursor: pointer;
}

/* Pressable, and it says so on the way past: the ring the merchant is about to
   tick darkens to the ink the title is written in. */
@media (hover: hover) and (pointer: fine) {
  .dash-task-tick:hover {
    color: var(--ink);
  }
}

.dash-task-tick:focus-visible {
  outline: 3px solid var(--brand-fixed);
  outline-offset: 0;
  border-radius: 50%;
}

.dash-task-body {
  min-width: 0;
}

.dash-task-title {
  margin: 0;
  color: var(--ink);
  font-size: 1.05rem;
  font-weight: 700;
  overflow-wrap: anywhere;
}

/* THE STEP'S NUMBER (Sam's review revision: "1. Create a Loyalty Program"). It
   is the row's POSITION in the list it is drawn in rather than a stored
   property of the step, so a list somebody has dismissed a row from still
   reads 1, 2, 3 with no gap where a step used to be. Outside the name, so a
   finished step's line-through crosses the words and not the numeral. */
.dash-task-number {
  font-variant-numeric: tabular-nums;
}

/* THE CROSSING OUT (Sam: cross out the task with a line-through). It is on the
   NAME rather than on the whole paragraph, so the visually-hidden "Done:" that
   opens the line and the number that follows it are not struck through as well,
   and the whole row is quietened to --muted so a finished list recedes without
   becoming unreadable. Nothing about the state depends on either: the words say
   it too. */
.dash-task-done .dash-task-title {
  color: var(--muted);
}

.dash-task-done .dash-task-name {
  text-decoration: line-through;
  text-decoration-thickness: 2px;
}

.dash-task-hint {
  margin: 2px 0 0;
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.45;
}

/* THE WAY IN, AT THE BOTTOM OF THE BLOCK (Sam's review revision): the primary
   way in to doing the step, inside the text block and under the line it belongs
   to rather than out at the row's trailing edge, and offered only while the step
   is outstanding. It is the row's only button since the dismissal moved up
   beside the heading.

   `flex-wrap` and no `white-space: nowrap` anywhere in here: the label wraps
   inside its own button on the narrowest phone rather than pushing the row past
   the content column. */
.dash-task-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-top: 12px;
}

/* It rides the app's own .btn shape, so the tasklist has no private idea of what
   a button is. Only the COLOUR is restated, and it is restated for a reason:
   .btn paints itself in --brand, which pages override per account from the
   merchant's own branding, and no merchant colour tints LoyalPack's own chrome
   (task #583's rule, and this section's since #811). */
.dash-task-go {
  background: var(--brand-fixed);
  border-color: var(--brand-fixed);
  font-size: 0.9rem;
}

.dash-task-go:focus-visible {
  outline: 3px solid var(--brand-fixed);
  outline-offset: 2px;
}

@media (max-width: 720px) {
  /* The same air the rest of this page gives up at this breakpoint. */
  .dash-tasks {
    padding: 34px 0;
  }
}

/* --- the dashboard's quick links (task #811, Sam) --------------------------

   Six signposts into the rest of the app, between the tasklist above and the
   account-wide graph below: Programs, Reports, Customers, Notifications,
   branding and Locations.

   TWO ROWS OF THREE ON A DESKTOP, which is Sam's ask and is stated as exactly
   that: three equal `minmax(0, 1fr)` tracks over a six-item list. The minimum
   is 0 rather than the grid default of min-content, so a long label can never
   grow a track past the content column - the label wraps instead, and the page
   cannot scroll sideways because of this section. Below 980px it is two
   columns (three rows), and below 560px one, so a phone gets a readable stack
   rather than three squeezed cards. 980px is the band's OWN breakpoint, the
   width its two columns become one, so the page reflows at one place rather
   than at two a few pixels apart.

   IT IS A BAND IN THE DARK BRAND TONE (Sam's #811 follow-up: make the section
   ink, the blocks white, the title white, and put a large picture on the left
   of each block; then task #832's revision: "apply that colour (#22163d) to the
   web app dashboard footer, and web app dashboard 'quick links' strip"). The
   band was --ink and is now --ink-brand, the same token the front page's blocks
   and footer wear. THE BLOCKS THEMSELVES STAY WHITE: the ask is about the
   strip, and the white blocks on a dark band are the whole shape of the
   section. It therefore bleeds to the window edges, with the SAME calculated pattern the
   grey graph band below it uses: 100vw wide, pulled out of the centred column
   by negative inline margins, and its horizontal padding put back at exactly
   the distance from viewport edge to column edge, so the blocks stay on the
   page's own grid whatever the screen. The overhang a classic scrollbar adds to
   100vw is clipped at the root (see the html rule up top), so this cannot
   scroll the page sideways. The band opens with the #507 50px, which is also
   the whole gap between it and the tasklist above (that section closes with
   nothing), and closes with 50px against the grey band's own opening 50px.

   Every colour is a named token, measured against the surface it is actually
   drawn on. On the tone: --card (white) 16.80:1 for the section heading, and
   --brand-on-ink 5.47:1 for a focus ring, which is still the one place
   --brand-fixed could not go - the purple measures 2.14:1 against the tone
   (2.37:1 against the ink it replaced), so a focus ring drawn in it on this
   band would be the one indicator on the page nobody could see. Both figures
   come down slightly with the lighter ground and both keep a wide margin: the
   heading is asked for 4.5:1 and the ring, a non-text indicator, for 3:1.
   Inside a white block nothing changed: --ink 18.62:1 for the
   label, --muted 6.46:1 for the supporting line, --brand-fixed 7.85:1 for the
   picture, the angle and the hover edge. --brand-fixed rather than --brand,
   because --brand is overridden per account from the merchant's own branding
   and no merchant colour tints this chrome (the same rule the Program tiles are
   built on). */
.dash-links {
  background: var(--ink-brand);
  width: var(--bleed-w);
  margin: 0 calc(50% - var(--bleed-w) / 2);
  padding: 50px calc(var(--bleed-w) / 2 - 50%);
}

.dash-links-heading {
  margin: 0 0 20px;
  color: var(--card);
  font-size: 1.2rem;
}

.dash-links-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The whole block is the link: one tab stop, one accessible name, nothing
   interactive inside it. TWO COLUMNS since Sam's follow-up - the large picture,
   then everything else - stated as `auto minmax(0, 1fr)` so the text track can
   shrink and wrap rather than pushing the block wider than its grid cell.
   `align-items: start` keeps the picture level with the label's first line
   however many lines the block runs to, and `height: 100%` makes every block in
   a row as tall as the tallest, so the grid reads as a grid however long a
   supporting line runs. */
.dash-link {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: 16px;
  height: 100%;
  padding: 18px;
  border: 1px solid var(--line);
  border-radius: 0;
  background: var(--card);
  color: var(--ink);
  text-decoration: none;
  transition:
    border-color 160ms ease-in-out,
    transform 160ms ease-in-out;
}

/* THE LARGE PICTURE (Sam: a large icon to the left of each block, relevant to
   the block's content), WHITE IN A PURPLE CIRCLE since Sam's #811 review. The
   wrapper carries the size, the colour and now the disc because the Font
   Awesome kit swaps the <i> out for an <svg> at runtime and a hook on the
   element itself would go with it (task #501); the kit's SVG is sized in `em`,
   so one font-size here settles both what is drawn now and what is drawn after
   the kit lands. `line-height: 1` stops the taller line box the icon's own font
   would otherwise reserve from pushing the drawing off the disc's centre, and
   the inline-flex centring is what holds it there for both the <i> and the
   <svg>.

   The disc is the one round thing in this square-cornered chrome, which is what
   Sam asked for; the block around it keeps its `border-radius: 0`. A fixed
   width AND height rather than padding, so every one of the six is the same
   circle whatever its drawing's natural width, and `flex: none` keeps it out of
   the shrinking the text track does.

   White on --brand-fixed measures 7.85:1, and both are named tokens: --card is
   the app's white and --brand-fixed is the purple no merchant colour replaces
   (--brand is overridden per account). */
.dash-link-icon {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: var(--brand-fixed);
  font-size: 1.4rem;
  line-height: 1;
  color: var(--card);
}

/* The label and its line, stacked in the block's second track. */
.dash-link-text {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}

/* Hover and keyboard focus raise the same affordance: the edge takes the brand
   purple and the block lifts a pixel. No colour change on the text, so nothing
   about what the block says depends on a state. */
@media (hover: hover) and (pointer: fine) {
  .dash-link:hover {
    border-color: var(--brand-fixed);
    transform: translateY(-1px);
  }
}

/* THE ONE COLOUR THE INK BAND CHANGED. The ring is drawn on the band itself
   (2px clear of the white block), so what it has to be legible against is
   --ink, and --brand-fixed measures 2.37:1 there - below the 3:1 a non-text
   indicator is asked for, and in practice invisible. --brand-on-ink is that
   same purple lifted toward white for exactly this problem (task #583): 6.06:1
   on the band, and plainly still the brand hue. */
.dash-link:focus-visible {
  outline: 3px solid var(--brand-on-ink);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .dash-link {
    transition: none;
  }

  .dash-link:hover {
    transform: none;
  }
}

/* The label, with the angle pushed to the block's trailing edge by the gap
   taking the slack. A long label wraps rather than pushing the angle out of
   the block. */
.dash-link-title {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 1.05rem;
  font-weight: 700;
  overflow-wrap: anywhere;
}

/* The wrapper carries the colour and the size because the kit swaps the <i>
   itself out for an <svg> at runtime and a hook on the element would go with
   it (task #501). `margin-left: auto` is what puts it at the trailing edge. */
.dash-link-go {
  margin-left: auto;
  flex: none;
  font-size: 0.9rem;
  line-height: 1;
  color: var(--brand-fixed);
}

.dash-link-hint {
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.45;
}

@media (max-width: 980px) {
  .dash-links-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (max-width: 560px) {
  .dash-links-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (max-width: 720px) {
  /* Phone widths: the same air the rest of this page gives up at this
     breakpoint, so the surfaces stay evenly spaced when they are all narrower.
     The horizontal half of the shorthand is UNCHANGED and must stay: it is the
     band's bleed, not spacing, and a bare `34px 0` here would pull every block
     out to the window edges with nothing between them and the glass. */
  .dash-links {
    padding: 34px calc(var(--bleed-w) / 2 - 50%);
  }

  /* The disc and its drawing come down a step with the rest of the type, so a
     block on a phone is not mostly picture. Both measures move together, or the
     circle stops being one. */
  .dash-link-icon {
    width: 44px;
    height: 44px;
    font-size: 1.2rem;
  }
}

/* THE GREY BAND RUNS INTO THE FOOTER (task #811, Sam: there is a white strip
   between the graph section and the footer).

   Task #772 moved the content column's closing 64px into the grey band's own
   bottom padding so the two surfaces would meet, and stopped the column adding
   it a second time. What it did not reach is the FOOTER's own 48px top margin
   (task #481), which is body-level: on every other page that 48px is drawn in
   the page's pale grey and reads as air above the ink band, but this page is
   white, so it was drawn as a white stripe between the grey and the ink.

   Cleared on this page only, by the same `body:has(.dash-focus)` selector the
   white page and the grey band are already scoped through, so the footer keeps
   its 48px everywhere else. The distance is not moved anywhere: the band's own
   64px of bottom padding is the closing air, drawn in grey, which is exactly
   what task #772 asked for.

   IT IS MARGIN ONLY AGAIN (task #832's second revision, Sam: the tone goes on
   every signed-in footer). Between the two revisions this rule also carried
   `--footer-bg`, because the dashboard was the one page carved out of #831's
   white band. Now that every app-shell footer wears the tone, the fill is
   declared once for all of them beside the palette itself and naming it here as
   well would be a second answer to a settled question. The join this rule is
   really about is unchanged: the grey graph band above meets a dark footer at a
   colour edge, which is what task #772 asked for in the first place. */
body:has(.dash-focus) .app-footer {
  margin-top: 0;
}

/* --- authenticated page header strip (task #456) ---------------------------

   One band under the ink app bar on every signed-in page, carrying the page
   title, an optional back link, an optional action and an optional row of tabs
   anchored to its bottom edge. See src/views/page-header.tsx.

   It is a body-level sibling of <main>, not a child of it (see Layout's
   `header` prop), so the coloured band fills the full viewport width like the
   app bar above it. Because it is a normal block whose containing block is the
   viewport-width body, it is exactly as wide as the page and can never add a
   horizontal scrollbar (a full-bleed from inside the centred column would be
   100vw, which overflows by the scrollbar's width). Its inner element then
   re-centres to the same content grid as `.container`. */
/* TWO SURFACES, ONE DECLARATION (task #572). The public card designer's title
   band wears exactly this treatment, so .cc-hero joins the rule rather than
   restating it: the band colour is declared once and the two cannot drift.

   .pagehead is written last, on its own line, because test/pagehead-artwork.ts
   finds this rule by that selector.

   BRAND PURPLE, AND NO DOODLE (task #758, Sam). The band was the pale lilac
   --strip carried behind a faint line-doodle sheet from task #528; Sam asked
   for the flat LoyalPack purple and for the doodle overlay to go. --brand-fixed
   rather than --brand, because a Program page overrides --brand with the
   merchant's own colour and this band is LoyalPack's own chrome.

   THE BAND IS A DARK FILL, so the ink drawn on it is declared here rather than
   left to the page's own dark tokens (task #760, Sam, the contrast follow-up to
   #758). Two inks, and only two, for both surfaces:

     --band-ink        #fff        7.85:1 on the band. Titles, tab labels.
     --band-ink-muted  var(--strip) 5.59:1. Sub-text, back links, tab hover.

   THE MUTED INK IS THE OLD BAND ITSELF. --strip is the pale lilac this band was
   before #758, which is the lilac Sam asked for by name: nothing new joins the
   palette, the band and its quiet ink are the same two colours swapped over,
   and both clear WCAG AA at normal size. They are custom properties on the band
   rather than literals in each rule, so .cc-hero and .pagehead share one ink
   pair exactly as they already share one fill.

   `color` is set here too, so anything added to the band later is legible by
   default rather than inheriting the page's near-black. */
.cc-hero,
.pagehead {
  position: relative;
  --band-ink: #fff;
  --band-ink-muted: var(--strip);
  background: var(--brand-fixed);
  color: var(--band-ink);
}

/* Re-centres the header content onto the body's content grid: the same
   max-width and horizontal gutter as `.container`, so the title, back link and
   action line up with the cards below. Vertical padding is Sam's exact
   request: 50px above (task #758, down from #456's 60px), 30px below. */
.pagehead-inner {
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 50px var(--content-gutter) 30px;
}

/* The back link takes the band's quiet ink, and hover brightens it to the full
   one (task #760). Both used to be page tokens: --strip-muted read at 1.19:1 on
   the purple and the purple hover at 1.00:1, so the link was invisible and
   hovering it erased it. Sam's list named the title, the sub-line, the action
   and the tabs; the back link is the same ink on the same band, and leaving it
   would have shipped an invisible way out of a Program page. */
.pagehead-back {
  display: inline-block;
  margin-bottom: 6px;
  color: var(--band-ink-muted);
  font-size: 0.95rem;
  text-decoration: none;
}

.pagehead-back:hover {
  color: var(--band-ink);
  text-decoration: underline;
}

/* The back link on the pages under Loyalty Programs (task #477): raised into
   the strip's top padding and a step smaller, so the page's own title leads the
   strip. The individual Program page and the Add page both opt in and so sit
   identically (task #761); the settings page has no back link at all (task #468). */
.pagehead-back-program {
  margin: -30px 0 30px 0;
  font-size: 0.9rem;
}

.pagehead-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 8px 16px;
}

/* White, stated rather than inherited (task #760, Sam): the page's one h1 is
   the thing the band exists to carry, so its colour is written where a reader
   looks for it. */
.pagehead-title {
  margin: 0;
  font-size: 1.7rem;
  line-height: 1.2;
}

.pagehead-sub {
  margin: 4px 0 0;
  color: var(--band-ink-muted);
}

.pagehead-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
}

/* THE ONE ACTION ON THE BAND (task #760, Sam). A default .btn is brand purple
   on a brand-purple band: a same-colour rectangle with a same-colour border,
   which is exactly the Programs list's missing "Add a Program". So the action
   inverts here, wearing the band's lilac with the page's dark ink on it: the
   fill is 5.59:1 against the band, so the control's edge is plainly there, and
   the label is 13.3:1 on the fill. The Support band's Search button is the only
   other .btn in this row and takes the same treatment, so the two cannot drift.
   Scoped to .pagehead-actions, so no other button in the app is touched. */
.pagehead-actions .btn {
  background: var(--band-ink-muted);
  border-color: var(--band-ink-muted);
  color: var(--ink);
}

/* Tabs. The <nav> is the scroller and the gutter lives on the list, so at a
   narrow width the row slides under the strip's own padding instead of being
   clipped by it. min-width: max-content keeps it one row that scrolls rather
   than a ragged wrapped block, and no label is ever truncated. */
.pagehead-tabs {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
}

/* The tab row re-centres onto the same content grid as the title above and the
   cards below, so the tabs start at the content's left edge on a wide screen.
   min-width: max-content keeps it one row that the scroller pans when the tabs
   are wider than the grid (at 390px). */
.pagehead-tablist {
  display: flex;
  min-width: max-content;
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 0 var(--content-gutter);
  list-style: none;
}

.pagehead-tabitem {
  display: flex;
}

/* `.pagehead-tab-form` and `.pagehead-tab-button` MATCH NO MARKUP SINCE SAM'S
   TASK #831 REVIEW, which broke Logout out of the Account section and made it a
   menu item at the foot of the left column (`.appnav-logout`, in the app header
   block above). They are kept, unchanged, for the reason `.appnav-logout` itself
   was kept when task #456 moved that button the other way: a tab row that has to
   carry a POST action again is a shape this product has had twice, and the
   values below are the ones it had. Nothing renders them today. */
.pagehead-tab-form {
  display: flex;
  margin: 0;
}

/* One rule for the tab anchors and a submit button in the row, exactly as the
   app bar does for its own anchors and button: the button is reset to nothing
   and given the anchors' font, padding and alignment, so it sits on the same
   baseline while staying a real submit control in a POST form.
   Sam's desktop tab: exactly 20px 25px (task #499), so 20px + 20px + 20px = a
   60px row. The phone keeps the tighter 12px 14px below, which is still a 44px
   touch target and still lets five Account tabs pan in one row at 390px. */
.pagehead-tab,
.pagehead-tab-button {
  position: relative;
  display: inline-flex;
  align-items: center;
  margin: 0;
  padding: 20px 25px;
  border: none;
  border-radius: 0;
  background: none;
  -webkit-appearance: none;
  appearance: none;
  font-family: inherit;
  font-size: 1rem;
  font-weight: 500;
  line-height: 20px;
  color: var(--band-ink);
  white-space: nowrap;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: color 120ms ease-in-out;
}

/* The indicator. Every tab carries it, transparent by default and positioned
   out of flow, so changing tab can never change a tab's width or baseline.
   Square ends, sitting on the strip's bottom border: no pill, no radius. The
   insets mirror the tab's own horizontal padding, so the bar spans the label
   and nothing else; they move with it at the phone breakpoint below. */
.pagehead-tab::after,
.pagehead-tab-button::after {
  content: '';
  position: absolute;
  left: 25px;
  right: 25px;
  bottom: 0;
  height: 2px;
  border-radius: 0;
  background: transparent;
}

/* Hover: the label alone steps from white to the band's lilac (task #760, Sam).
   No background, border, pill or underline: the app bar's rule, kept.

   THIS IS THE ONE PLACE THE PRIMARY-NAV PURPLE DOES NOT APPLY, and it is a
   deliberate exception rather than a drift. The nav rule ("hover is LoyalPack
   purple") was settled for labels on a light surface; on a purple band that
   same purple is 1.00:1 against its own background, so hovering a tab used to
   delete it. Sam asked for white to lilac here by name. Everywhere the nav rule
   still describes a light surface, it is untouched. */
.pagehead-tab:hover,
.pagehead-tab-button:hover {
  color: var(--band-ink-muted);
}

/* Current tab: it takes the page-body colour so it reads as a document tab
   joined to the content below, which is the same --bg (task #456 refinement).
   No bottom bar, because the filled tab IS the indicator. Log out is an action,
   not a place, so it never gets this.

   The label is black on Sam's ask (task #782). "Black" here is var(--ink), the
   #15111b every other black text in the app takes; there is no pure-black token
   and a raw #000 is not this sheet's language. It was --brand-fixed, and this is
   the one tab NOT on the purple band, so the page ink is a colour written for
   the fill it actually sits on: 17.3:1 against --bg, where the purple was 5.9:1.
   That is the same reasoning, and now the same value, as the focus state below,
   which already had to reach for --ink for this selector (task #760). */
.pagehead-tab[aria-current='page'] {
  color: var(--ink);
  background: var(--bg);
}

/* Keyboard focus, still deliberately the highest-contrast state and still no
   box: the full band ink for the label and for the bar under it (task #760).
   It was --ink, which on the purple band is 2.37:1, i.e. the state meant to be
   the loudest had become the quietest one on the strip. */
.pagehead-tab:focus-visible,
.pagehead-tab-button:focus-visible {
  outline: none;
  color: var(--band-ink);
}

.pagehead-tab:focus-visible::after,
.pagehead-tab-button:focus-visible::after {
  background: var(--band-ink);
}

/* THE CURRENT TAB IS THE ONE TAB NOT ON THE BAND: it takes --bg above, so the
   band ink would be white on near-white. Focus there goes back to --ink, which
   is what reads on that fill (17.3:1). Written after, and more specific, so the
   two focus states cannot be reordered into each other. */
.pagehead-tab[aria-current='page']:focus-visible {
  color: var(--ink);
}

.pagehead-tab[aria-current='page']:focus-visible::after {
  background: var(--ink);
}

/* Forced-colours mode paints its own palette and drops backgrounds, so the bar
   above would vanish. Hand focus back to a system outline there. */
@media (forced-colors: active) {
  .pagehead-tab:focus-visible,
  .pagehead-tab-button:focus-visible {
    outline: 2px solid;
    outline-offset: -2px;
  }
}

@media (max-width: 720px) {
  .pagehead-title {
    font-size: 1.45rem;
  }

  /* The action drops under the title rather than squeezing it. */
  .pagehead-row {
    flex-direction: column;
    align-items: stretch;
  }

  /* The tabs keep the tighter pre-#499 padding on a phone: 12px + 20px + 12px
     is still a 44px touch target, and the narrower gaps keep the row pannable
     rather than pushing the last Account tab far off the right edge. */
  .pagehead-tab,
  .pagehead-tab-button {
    padding: 12px 14px;
  }

  .pagehead-tab::after,
  .pagehead-tab-button::after {
    left: 14px;
    right: 14px;
  }
}

/* --- editable settings sections (task #462) --------------------------------

   One block for each place a signed-in owner edits something: their details and
   their password (Account), their plan (Billing) and a new Loyalty Program's
   essentials. All are rendered from the one component in
   src/views/settings-section.tsx, so this layout is written once and cannot
   drift between pages.

   Grid alignment: a block is a plain child of <main class="container">, which is
   already the shared --content-max column with the same shared gutter as
   .pagehead-inner. So the blocks sit on exactly the same centred grid as the
   lilac header strip above them and never span the full viewport; there is no
   width or max-width here that would break that contract.

   The block is white and square-cornered, in line with the no-rounded-corners,
   no-shadow direction. Two columns on desktop, roughly 40/60: the section title,
   a short introduction and one support note on the left; the section's own
   fields, help and actions on the right. Non-editable content (logs, diagnostics,
   analytics, the card preview) is never wrapped in this and keeps its own card or
   panel treatment.

   Task #490 opened the block out: 50px between the columns and 30px of padding,
   both unconditional, so a phone gets the same generous rhythm as a desktop. The
   grid items are pinned to a floor of zero below, so the wider padding still
   cannot push the block past a 390px viewport. */
.settings-section {
  display: grid;
  grid-template-columns: 2fr 3fr;
  gap: 50px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 30px;
  margin: 0 0 24px;
}

/* Grid items default to min-width:auto, which lets a wide control or a long
   unbroken word push a column past its track. Pin both to 0 so the block shrinks
   honestly on a phone (no horizontal overflow at 320px). */
.settings-section > * {
  min-width: 0;
}

.settings-title {
  margin: 0 0 10px;
  font-size: 1.3rem;
  line-height: 1.25;
}

.settings-lede {
  margin: 0 0 18px;
  color: var(--muted);
  /* A step below page copy (task #490), so the section title leads and the
     introduction reads as supporting text. Scoped to this one class, so ordinary
     paragraphs, form help and the note's own copy are unaffected. */
  font-size: 0.9rem;
}

/* The right column holds the section's existing fields and actions. Its direct
   children (flash messages, the form, a trailing note) sit in a single rhythm:
   their own margins are dropped and the gap owns the spacing, so the column
   reads evenly whatever mix of pieces a section happens to render. */
.settings-body {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.settings-body > * {
  margin-top: 0;
  margin-bottom: 0;
}

/* A settings table (the team-members list is the one there is) must fit the
   column it is in. A long unbroken email address gives a cell a min-content
   width wider than the phone column, and because table layout respects that
   floor the table used to spill past the block, clipped rather than scrollable.
   `anywhere` is the wrap mode that also lowers the min-content width, so the
   address wraps and the table shrinks honestly, exactly as long enrolment links
   already do in the chart tables. Found while widening the block padding in task
   #490; the overflow predates it. */
.settings-body th,
.settings-body td {
  overflow-wrap: anywhere;
}

/* A pill is a short label, not prose: it keeps the ordinary wrap rules so
   "Owner" cannot be broken across two lines inside its own border. Scoped to
   the settings tables, which are the only place the rule above applies. */
.settings-body td .badge {
  overflow-wrap: normal;
}

/* The post-it support note. One restrained, pale note with a folded corner:
   square outer geometry, plain guidance and a short row of links into help. The
   same renderer (SupportNote) is used by every settings section and by the
   Program settings dialog, so the note never diverges.

   No border since task #490: the pale amber and the folded corner carry the note
   on their own. --note-edge still draws the fold's underside below. */
.support-note {
  position: relative;
  background: var(--note);
  border-radius: 0;
  padding: 16px 18px;
}

/* The folded corner: a small triangle tucked into the top-right. Its upper-right
   half takes the card colour behind the note, as if the corner had peeled away
   to show the page, and its lower-left half takes the darker amber as the fold's
   underside. Purely decorative, so it is aria-hidden in the markup and drawn
   entirely here. */
.support-note::before {
  content: '';
  position: absolute;
  top: 0;
  right: 0;
  width: 0;
  height: 0;
  border-top: 16px solid var(--card);
  border-left: 16px solid var(--note-edge);
}

/* The note's short handwritten title (task #487): Caveat, one weight, sized so
   the annotation reads clearly above the body without turning into branding.
   Caveat's glyphs run small on the em square, so even at 1.5rem (task #490) the
   title reads as a handwritten annotation rather than branding, while leading
   its body unmistakably. Colour stays --ink, which clears AA on the amber at any
   size. */
.support-note-title {
  /* A clear step down to the body it introduces (task #490). */
  margin: 0 0 10px;
  color: var(--ink);
  font-family: var(--font-note-title);
  font-size: 1.5rem;
  font-weight: 600;
  line-height: 1.2;
  /* Keep clear of the folded corner, which the title sits alongside. */
  padding-right: 18px;
}

.support-note-body {
  margin: 0 0 12px;
  color: var(--ink);
  /* Another step below (0.9rem in task #487, 0.85rem after it, 0.8rem now in
     task #490), so the handwritten title clearly leads and the note reads as an
     aside. The taller line-height keeps the smaller size comfortable. Scoped to
     the note body alone: ordinary page copy and the note's own links keep their
     sizes. */
  font-size: 0.8rem;
  line-height: 1.5;
  /* Keep clear of the folded corner on a short one-line note. */
  padding-right: 8px;
}

.support-note-links {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
}

.support-note-links a {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--brand-fixed);
  font-size: 0.9rem;
  font-weight: 600;
  text-decoration: none;
}

.support-note-links a:hover {
  text-decoration: underline;
}

.support-note-icon {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
}

/* Below the shared breakpoint the two columns stack to one. The aside is first
   in the source, so title, introduction and support note sit above the fields,
   which is the reading order the reference layout asks for. The stack is the
   only thing that changes: the tightened gap and padding this rule used to carry
   were dropped in task #490 so the 50px/30px above hold at every width. */
@media (max-width: 720px) {
  .settings-section {
    grid-template-columns: 1fr;
  }
}

/* Admin settings form actions hug their text (task #462, Sam's review). A
   settings-block form is a flex column (.stack-form), whose default
   align-items:stretch was stretching a normal action button to the full column
   width. Its direct-child submit now sizes to its content and aligns left. This
   is scoped to .settings-body, so the auth pages' deliberately full-width CTAs
   (scoped to .auth-body) are untouched, and truly full-width controls (the plan
   card CTA) opt in on their own selector below. At 320px an intrinsic-width
   button cannot overflow. */
.settings-body .stack-form > button,
.settings-body .stack-form > .btn {
  align-self: flex-start;
}

/* THE .settings-subform BLOCK RETIRED IN TASK #546. Task #525 needed it because
   the account's Business Type sat inside Your details as a second, separately
   saved form, and a rule plus a small heading were what stopped one box with two
   Save buttons reading as a mistake. Sam merged the two into one form with one
   button, so there is no second submission left to signal and the three rules
   that signalled it have gone with it rather than lingering as dead selectors.
   If a settings section ever needs a genuinely separate save inside it again,
   this is the treatment to bring back: a rule, a small heading, no card, no
   panel. */

/* --- billing plan picker (task #462) ---------------------------------------
   Two plan cards (Free, Pro) side by side on desktop, stacked on a phone, on the
   same content grid as everything else. The merchant's current plan is marked
   with a non-colour cue (a "Current plan" tick label) plus aria-current, and a
   purple 2px border; the other card keeps a 2px line border so neither shifts
   size. On Free the Free card carries the live usage bar and the Pro card the one
   Upgrade CTA; on Pro the Pro card is simply current. */
.plan-section {
  margin: 0 0 24px;
}

.plan-section-title {
  margin: 0 0 6px;
  font-size: 1.3rem;
  line-height: 1.25;
}

.plan-section-intro {
  margin: 0 0 16px;
}

.plan-picker {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  align-items: stretch;
}

/* min-width:0 so a long feature line can never push a card past its track. */
.plan-picker > * {
  min-width: 0;
}

.plan-card {
  display: flex;
  flex-direction: column;
  gap: 14px;
  background: var(--card);
  border: 2px solid var(--line);
  border-radius: 0;
  padding: 22px;
}

.plan-card-current {
  border-color: var(--brand);
}

.plan-card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}

.plan-name {
  margin: 0;
  font-size: 1.15rem;
}

.plan-current-badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--brand);
  white-space: nowrap;
}

.plan-tick {
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
}

.plan-price {
  margin: 0;
  font-size: 1.6rem;
  font-weight: 700;
  line-height: 1.1;
}

.plan-per {
  font-size: 0.9rem;
  font-weight: 400;
  color: var(--muted);
}

.plan-features {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.plan-features li {
  position: relative;
  padding-left: 22px;
  color: var(--ink);
}

/* A square-cornered purple tick before each feature, drawn in CSS so the list
   needs no icon font. */
.plan-features li::before {
  content: '';
  position: absolute;
  left: 2px;
  top: 3px;
  width: 6px;
  height: 11px;
  border: solid var(--brand);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

/* The Free usage bar and the Pro CTA/renewal note both sink to the card foot so
   the two cards line up whatever their height. */
.usage,
.plan-cta,
.plan-cta-note {
  margin-top: auto;
}

.usage-count {
  margin: 0 0 6px;
  font-weight: 400;
}

/* The bar is decorative; the count above and aria-valuetext are the real value.
   Square, in the LoyalPack palette, and clamped 0-100% by the inline width. */
.usage-bar {
  height: 8px;
  background: var(--line);
  border-radius: 0;
  overflow: hidden;
}

.usage-fill {
  display: block;
  height: 100%;
  background: var(--brand);
}

.usage-note {
  margin: 8px 0 0;
  font-size: 0.85rem;
  color: var(--error);
}

/* The Upgrade CTA is a deliberate full-width plan-card action (the exception the
   admin intrinsic-width rule above allows). */
.plan-cta {
  margin-bottom: 0;
}

.plan-cta .btn {
  width: 100%;
}

/* --- the monthly/yearly choice (task #535) ---------------------------------

   One fieldset, two radios, one submit: paying yearly is a property of the
   upgrade, not a second upgrade. Square-cornered like the rest of the plan
   family, and the disabled row (an installation with no annual price
   configured) stays legible rather than vanishing, so the page never quietly
   loses an option it advertises. */
.plan-interval {
  border: 1px solid var(--line);
  border-radius: 0;
  margin: 0 0 14px;
  padding: 12px 14px;
}

.plan-interval legend {
  padding: 0 6px;
  font-size: 0.875rem;
  color: var(--muted);
}

.plan-interval-choice {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 6px 0;
  cursor: pointer;
}

.plan-interval-choice:has(input:disabled) {
  cursor: default;
  color: var(--muted);
}

.plan-interval-choice input {
  margin-top: 0.2em;
  flex: 0 0 auto;
}

.plan-interval-note {
  display: block;
  font-size: 0.875rem;
  font-weight: 400;
  color: var(--muted);
}

.plan-interval-vat {
  margin: 10px 0 0;
}


@media (max-width: 720px) {
  .plan-picker {
    grid-template-columns: 1fr;
  }
}

/* --- Reports (task #478, laid out in two rows in task #496) ----------------

   The Customers and Stamps reports share one filter bar, one table treatment
   and one pager. The filter bar is a wrapping flex row of labelled fields; the
   table reuses .prog-table-wrap for horizontal scroll on narrow screens.

   The bar is deliberately two rows at desktop width:

     row 1   Search, on its own, the full width of the card, on the card's own
             white.
     row 2   every other filter, each flexing to share the row, and the Filter
             button at the end, all on an Ink panel with white labels (Sam's
             follow-up on task #501). Customers: Program, From, To, Filter.
             Stamps: Program, Event, User, From, To, Filter.

   Narrower widths are mostly not a separate design: each field declares a flex
   basis and a floor it will not shrink below, so the row wraps a field at a time
   (on a tablet the button drops below the Stamps filters, then the dates follow)
   with nothing squeezed and nothing overflowing. One media query finishes the
   job at phone widths, where sharing a row stops being sensible at all: see the
   420px rule below.

   The card itself matches .settings-section (task #496): square corners and the
   same --section-pad. Scoped to .report-card, so every other .card in the app
   (Program cards, the dashboard, the auth pages) is untouched. */
.report-card {
  display: flex;
  flex-direction: column;
  gap: 18px;
  border-radius: 0;
  padding: var(--section-pad);
}

/* The client script toggles [hidden] on controls this sheet gives a display to
   (the search clear, the reset link), so hidden must win inside the card. */
.report-card [hidden] {
  display: none !important;
}

/* The row gap is 30px on Sam's ask (task #769). Every child of this form is a
   full-width flex line of its own (the search field, the ink filter panel, the
   foot note), so the row half is only ever the vertical rhythm BETWEEN those
   three, and raising it cannot reflow a control or reach inside the panel. The
   column half stays 16px: the panel keeps its own 12px 16px, so the spacing
   between the controls on the ink is exactly as task #496 sized it. */
.report-filters {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 30px 16px;
  margin: 0;
}

/* A field grows to share its row and will not shrink under a width where its
   control stops being usable; below that the row wraps instead.

   The bases are sized so the LONGER of the two rows fits the content column:
   Stamps puts five things on its second row (three selects at 130, the date pair
   at 270, the button at about 79, and four 16px gaps), which comes to 803. The
   slack is then shared out by the grow factors, so the row fills the card.

   The card's own inner width follows the grid rather than a number written here:
   the app column, less its shared gutters, the card's 1px borders and its
   --section-pad on each side. That was 866px on the old 960px grid, 1026px on
   the 1120px one (task #497) and is 998px now the app's gutter is 30px rather
   than 16px (task #833), so the row is still far roomier than it was and
   nothing about these bases has had to be compensated for either change. */
.report-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  flex: 1 1 130px;
  min-width: 116px;
}

.report-field label {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--muted);
}

/* 10px of breathing room inside every filter control (task #496). Horizontal
   only, and the ONE thing this bar changes about the shared control treatment:
   the height, the border, the radius, the typography, the background and the
   focus ring all come from the authoritative rule near the top of this sheet
   (task #501). The vertical padding it sets is zero and stays zero here, so
   --control-height alone decides how tall these are, and a native select's
   arrow and a date field's picker button are drawn exactly as the browser
   intends with nothing clipped. */
.report-filters input,
.report-filters select {
  padding-left: 10px;
  padding-right: 10px;
}

/* A select's value is the customer's own data (a Program title, a colleague's
   name), so it can be any length, and a shared row can be narrower than the
   longest one. Ellipsis is what keeps that honest: the value shortens with a
   visible cue and the browser's own arrow stays clear, instead of the text
   running under the arrow. */
.report-filters select {
  text-overflow: ellipsis;
}

/* The control fills its field rather than overhanging it. Its height is the
   shared token's, declared once with the rest of the treatment (task #501). */
.report-field input,
.report-field select {
  width: 100%;
}

/* Row 1: search, full width, on its own line. */
.report-search {
  flex: 1 0 100%;
}

/* Two things sit inside the field's trailing edge and exactly one shows at a
   time (task #501): a decorative magnifying glass while nothing is searched,
   and the accessible clear button once a search has applied. Both are the same
   width, so the input's trailing room is one number and text can never run
   underneath whichever is showing. The browser's own search-cancel button is
   dropped so there is exactly one clear affordance, the accessible one with a
   real focus ring and an announced result. */
.report-search-box {
  position: relative;
  display: flex;
}

.report-search-box input[type='search'] {
  padding-right: 38px;
}

.report-search-box input[type='search']::-webkit-search-cancel-button {
  display: none;
}

.report-search-clear {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 38px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  background: none;
  color: var(--muted);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
}

.report-search-clear:hover {
  color: var(--ink);
}

.report-search-clear:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: -2px;
}

/* The magnifier: a signifier, not a control. It occupies the same 38px the
   clear button does, so the swap between them moves nothing, and it is inert in
   every sense that matters. pointer-events: none means a click on it lands on
   the input behind and puts the caret there, which is what a customer aiming at
   the right-hand end of a search box expects; it is a span, so it takes no tab
   stop; and it is aria-hidden in the markup, so no screen reader meets it. Grey
   is the sheet's --muted, 6:1 on the card's white, comfortably past the 3:1
   WCAG 1.4.11 asks of a meaningful graphic even though this one only echoes the
   placeholder beside it. */
.report-search-icon {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 38px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  font-size: 1rem;
  line-height: 1;
  pointer-events: none;
}

/* Row 2, on its own Ink panel (Sam's follow-up on task #501). Search keeps the
   card's white above it and the note row keeps the white below it, so the panel
   is exactly the row that ends in the Filter button: "narrow this down" reads as
   one block rather than as six controls loose on a white card.

   It is a plain wrapper inside the same form and it repeats the bar's own flex
   behaviour verbatim (the same wrap, the same bottom alignment, the same
   12px/16px gaps), so every field keeps the basis and the floor task #496 sized
   for it, and the row wraps on a tablet and stacks at 420px exactly as it did.
   Full width, so it spans the card's inner column cleanly, and square, like the
   card it sits on and the settings sections beside it.

   Sam's second follow-up on #501 took it edge to edge: the ink now meets the
   card's left and right sides instead of floating in a white margin, so the row
   reads as a band across the card rather than as a tile on it. That is done
   with the card's OWN padding and nothing else:

     margin-inline   minus --section-pad, the exact value .report-card pads by,
                     so the panel's edges land on the card's padding box: no
                     white strip, and never a pixel past the border either.
     flex-basis      100% PLUS that padding twice, which is exactly what the two
                     negative margins take back off again: the item's OUTER size
                     is still one full row, so the extra width can never become
                     overflow, at any viewport or in any wrapping state. There
                     is no `width` here to fight the flex line with, and nothing
                     is bled vertically: the top and bottom edges stay where the
                     card's own padding puts them.
     padding-inline  --section-pad again, so what the bleed took outwards the
                     padding gives back, and the controls on the ink line up
                     with the search field on the white directly above them.

   The 16px block padding is the row's own column gap, so the space around the
   controls is the space between them. Because the horizontal bleed and the
   horizontal padding are the same number, the stacked phone column inside the
   panel is exactly as wide as the card's own content column (228px at 320px),
   which is wider than it was before this change, not narrower. */
.report-filter-panel {
  flex: 1 0 calc(100% + var(--section-pad) * 2);
  margin-inline: calc(var(--section-pad) * -1);
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 12px 16px;
  padding: 16px var(--section-pad);
  background: var(--ink);
}

/* White labels on the ink, which is what Sam asked for and what the contrast
   needs: --muted is 2.3:1 there, and --card is 18.6:1. Scoped to the panel, so
   the Search label above it is untouched and stays --muted on the white card.
   --card is this sheet's one white, and it is the same white these fields are
   filled with, so a label and the control under it read as one piece. */
.report-filter-panel .report-field label {
  color: var(--card);
}

/* The fields themselves are deliberately unchanged. The shared control
   treatment already fills them white with ink text inside a --line border, all
   of which is high contrast on the ink (the fill alone is 18.6:1 against it), so
   a filter on the panel is the same readable control it is on the Account page,
   at the same 48px, with the same 10px of inline padding.

   The Filter button is the one thing that needed a boundary: its brand-purple
   fill is 2.4:1 against the ink, under the 3:1 WCAG 1.4.11 asks of a control's
   own edges, so on the panel it is ringed in the white its neighbours use. Only
   the border colour changes: the fill, the white 600-weight label (7.9:1 on that
   purple), the radius and the height are all still the button's own. */
.report-filter-panel .report-actions .btn {
  border-color: var(--card);
}

/* And the focus ring goes white inside the panel, for the same reason: the
   shared 2px brand ring is 2.4:1 on the ink, and it is the one indicator a
   keyboard user cannot do without. Only the colour changes, so the width, the
   style and the 2px offset are still the ones every other control line draws. */
.report-filter-panel .report-field input:focus-visible,
.report-filter-panel .report-field select:focus-visible,
.report-filter-panel .report-actions .btn:focus-visible {
  outline-color: var(--card);
}

/* The date range groups its two inputs and names, in its legend, exactly which
   date it filters (registration vs event), so the range is never ambiguous to
   assistive technology. The legend is visually hidden because the two dates now
   sit on the shared filter row at the same label level as Program; the same
   sentence is printed in the visible note below the row, which both inputs
   reference with aria-describedby. */
.report-dates {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 8px 12px;
  margin: 0;
  padding: 0;
  border: 0;
  flex: 2 1 270px;
  min-width: 116px;
}

/* The pair takes twice a single select's growth and splits it between two
   dates, so each keeps about 140px: enough for the platform's own dd/mm/yyyy
   field AND its picker button, neither of which may be clipped. */
.report-dates .report-field {
  flex: 1 1 125px;
  min-width: 110px;
}

/* The button ends the second row, sized to its text and bottom-aligned with the
   fields beside it. */
.report-actions {
  display: flex;
  align-items: center;
  gap: 14px;
  flex: 0 0 auto;
}

/* See "buttons on a control line" near the top of this sheet: Filter takes the
   shared control height there, alongside the scanner's Look up. */

/* Under the row: the sentence naming which date the range filters, and the
   reset link at the far end. */
.report-filters-foot {
  flex: 1 0 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 16px;
}

.report-dates-note {
  margin: 0;
  font-size: 0.78rem;
  color: var(--muted);
}

.report-clear {
  font-size: 0.9rem;
}

/* Export CSV (task #836). The note row is a space-between pair, so the export
   link claims the free space and the two actions travel together on the
   trailing edge, in reading order: the sentence about the filters, then the two
   things you can do with them. When nothing is filtered the reset link is
   hidden and the export sits alone on that edge, which is where a reader has
   already learnt to look for it. */
.report-export {
  margin-left: auto;
  font-size: 0.9rem;
}

/* The cap, printed under the count when the current filters select more rows
   than one file may carry. Muted and small: it is a footnote about a download,
   not a warning about the report. */
.report-export-note {
  margin: 4px 0 0;
  font-size: 0.78rem;
  color: var(--muted);
}

/* On a phone (390px and 320px are the widths this is checked at) the bar stops
   sharing rows and becomes one clean column of full-width controls: at 320px a
   shared date pair would leave each field too narrow for its own picker. The
   button is last in that column, so it takes the full width too rather than
   sitting alone and small. Nothing here is a fixed width, so there is no
   overflow at either size. */
@media (max-width: 420px) {
  .report-field,
  .report-dates,
  .report-dates .report-field,
  .report-actions {
    flex: 1 0 100%;
  }

  .report-actions .btn {
    width: 100%;
  }
}

/* The one region a live filter replaces, whole (task #496): the count, the
   table or empty state, and the pager. It keeps the card's own rhythm, so a
   replaced region looks exactly like a server-rendered one.

   While a request is in flight the region is aria-busy and slightly faded, and
   nothing moves: the rows already on screen stay readable and in place until
   the new ones arrive (or until a failure leaves them alone for good). */
.report-results {
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.report-results[aria-busy='true'] {
  opacity: 0.55;
  transition: opacity 120ms linear;
}

@media (prefers-reduced-motion: reduce) {
  .report-results[aria-busy='true'] {
    transition: none;
  }
}

.report-table-wrap table {
  min-width: 640px;
}

/* Numeric columns (Stamps, Rewards) read right-aligned like a total. */
.report-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* The card code on the Customers report and on a customer's own page (task
   #789). Monospace, because a code is read in groups and typed back character
   by character, and the admin's one proportional family turns 5 and S into a
   guess at a counter. Slightly smaller so a fixed-width run of thirteen
   characters does not out-shout the name beside it, and nowrap so a code never
   breaks across two lines mid-group.

   THE PLATFORM STACK, NOT --font-mono, which is the deliberate choice. That
   token puts the self-hosted JetBrains Mono first, and it was bought for the
   colour wells' hex fields: pointing a column of an everyday report at it would
   pull a webfont down on a page that has no other use for one, to draw text
   that any monospace draws correctly. The enrolment URL box (the only other
   plain code this app prints) already reads exactly this stack, so the two
   agree. */
.report-card-code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.86rem;
  white-space: nowrap;
}

/* Event tag: a plain square-cornered chip, purple for a redeemed reward so the
   two event kinds are distinguishable without relying on the word alone. */
.report-tag {
  display: inline-block;
  padding: 2px 8px;
  border: 1px solid var(--line);
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--strip-muted);
  background: var(--bg);
}

.report-tag-reward {
  border-color: var(--brand-fixed);
  color: var(--brand-fixed);
}

/* Task #808: the rows that say something did not work (a wallet update refused,
   a retry that failed, retries given up, a message no wallet would take).

   THE WORD IS STILL THE SIGNAL AND THE COLOUR IS THE SECOND ONE, which is why
   this is a border and a text colour rather than a filled red badge: the chip
   has to stay readable in a column of chips, and a merchant scanning their own
   history should feel the difference before they read it without the table
   looking like an incident board. It borrows --error, the one red in this
   product, which is 6.5:1 under white and so AA at this size (see the contrast
   note further up this file) exactly as .report-tag-reward's purple is. */
.report-tag-issue {
  border-color: var(--error);
  color: var(--error);
}

/* The line under an event chip: which wallet, which attempt, and what the
   platform said, or what one card actually got from a message. Muted and a step
   down in size because it is the second thing you read on the row. It wraps
   (the chip above it does not), because a provider's sentence is a sentence and
   truncating it would hide the only part support actually needs. */
.report-detail {
  margin-top: 3px;
  font-size: 0.78rem;
  line-height: 1.35;
  color: var(--strip-muted);
  max-width: 42ch;
  white-space: normal;
}

/* --- sortable column headings (task #785) ----------------------------------

   Every column a report can be ordered by carries a real link inside its own
   <th>, so the whole feature is ordinary navigation: it works with scripting
   off, it can be opened in a new tab or copied, and the address it points at IS
   the sorted report. Three steps per column (unsorted, ascending, descending,
   back to unsorted), and the heading says which one it is on in three
   independent ways: the arrow's DIRECTION, which is a shape and never a colour;
   the heading going from the muted heading grey to the page's own black; and
   aria-sort on the cell, for anything reading the table out.

   The link takes the heading's own type and colour rather than a link's, so a
   sortable heading still reads as a heading. Nothing here changes the row
   height: the arrow is smaller than the label and sits on the same line. */
.report-sort {
  /* POSITIONED, and this is load-bearing rather than tidy. The link carries a
     .sr-only span naming the order and what the next activation does, and
     .sr-only is position:absolute: with no positioned ancestor its containing
     block is the INITIAL one, so the hidden text lays out against the document
     out where the horizontally scrolled table is, .prog-table-wrap cannot clip
     it, and the PAGE grows a sideways scrollbar onto empty background. That is
     exactly the trap task #505 walked into with the row's card link, and the
     same one declaration is the fix. */
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: inherit;
  font: inherit;
  text-decoration: none;
}

/* The column in force is BLACK, the way the current tab and a focused count
   are (tasks #771 and #782): --ink, never #000. */
.report-sort-on {
  color: var(--ink);
}

/* The underline is on the label alone, so hovering does not drag a line under
   the arrow beside it. */
.report-sort:hover .report-sort-label {
  text-decoration: underline;
}

.report-sort:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* Faint two-way arrows on a column that is not sorted, so the heading reads as
   sortable before anybody hovers it, and a full-strength single arrow once it
   is the one the rows are in. */
.report-sort-mark {
  font-size: 0.85em;
  opacity: 0.4;
}

.report-sort:hover .report-sort-mark,
.report-sort-on .report-sort-mark {
  opacity: 1;
}

/* The arrow is ALWAYS after the label, including on the two right-aligned
   numeric headings. Putting it on the outside there (which would keep those
   labels flush with the figures below them) reads worse than it sounds: the
   header row becomes "Program (arrow) (arrow) Stamps", two marks side by side
   with nothing saying which heading either belongs to. One rule for every
   column means a mark is always to the right of the words it is about. */

/* --- Wallet and Card columns on the Customers report (task #505) ------------

   Two columns appended to the Customers table, holding what used to sit on the
   Program page: whether a customer's card reached Apple Wallet or Google
   Wallet, and a way to open the customer's own browser card.

   The Wallet cell is deliberately NOT two columns. Apple and Google each need a
   provider word and a state phrase, and two of those side by side would have
   pushed the table far past the width even a desktop card can hold. One cell
   stacking two chips keeps the pair together, reads as one answer, and grows
   downwards (where a table row has room) rather than sideways (where it does
   not). Rows with nothing to report collapse to a single muted "Not added", so
   the column is quiet on the majority of rows.

   Everything the chips communicate is in their text. The colours and the two
   icons are a second, redundant channel for people who scan rather than read;
   remove them and every row still says which provider is in which state. */
/* Wide enough that the longest pair ("Google: Updates active", with its glyph)
   sits on one line inside the chip. The table is already the app's scrolling
   treatment on a narrow screen, so a wider column costs a little scroll inside
   .prog-table-wrap and never a millimetre of horizontal PAGE overflow, which is
   the trade a wrapped two-line chip would lose. */
.report-wallet {
  min-width: 195px;
}

.wallet-chips {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}

/* Square-cornered like .report-tag beside it, so the two chip families on this
   page read as one system. inline-flex, not inline-block: the icon and the two
   text spans need a gap that does not depend on the whitespace between tags. */
.wallet-chip {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  padding: 2px 8px;
  border: 1px solid var(--line);
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--strip-muted);
  background: var(--bg);
  white-space: nowrap;
}

/* The provider word carries as much meaning as the state, so it is not
   de-emphasised into decoration: same size, same weight, just enough colour
   separation to read as a label followed by its value. */
.wallet-chip-provider::after {
  content: ':';
}

.wallet-chip-state {
  font-weight: 500;
}

/* An icon inside a chip is decorative and aria-hidden; it never carries a state
   the words do not already give. */
.wallet-chip i {
  font-size: 0.78rem;
}

/* The three colour treatments reuse the values the app's existing badges were
   already vetted at (.badge-good and .badge-warn near the top of this sheet),
   so nothing new had to be checked for contrast: #146c43 on #eaf5ef and
   #8a6700 on #fdf6e3 both clear WCAG AA comfortably at this size and weight,
   as does --strip-muted on --bg for the two neutral states. */
.wallet-chip-active {
  border-color: var(--success);
  color: var(--success);
  background: #eaf5ef;
}

.wallet-chip-issue {
  border-color: #b98a00;
  color: #8a6700;
  background: #fdf6e3;
}

/* "Pass issued" and "Not added" are the neutral states and keep the default
   chip treatment: no colour claim, because there is nothing to claim. */

/* The one line of guidance under a row that has a sync problem. Visible text,
   never a title attribute: a tooltip is unreachable by touch and by keyboard,
   and this is the only sentence telling a merchant the counter is unaffected. */
.wallet-note {
  display: block;
  margin-top: 5px;
  max-width: 30ch;
  font-size: 0.76rem;
  line-height: 1.35;
  color: var(--muted);
}

/* The Card action. A plain link at the end of the row, sized so it cannot be
   mistaken for the customer's data beside it, and never showing the card code:
   the opaque code is a bearer capability and belongs in the href alone.

   It sits on the customer's own page since task #787 rather than in a column of
   its own, so this is now the ONE place it is styled, and it keeps its
   position: relative for the reason written out below. .sr-only is
   position:absolute (see the utility above). With no positioned ancestor its
   containing block is the INITIAL one, so hidden text placed inside a
   horizontally scrolled table is laid out against the PAGE at its static
   position, hundreds of pixels to the right, and Chrome extends the page's
   scrollable area to reach it: /reports/customers once gained 810px of sideways
   scroll onto empty background that way. Making the link its own containing
   block puts the hidden text back inside the wrapper that clips it.

   The rule generalises: any visually hidden text placed inside one of these
   scrolling tables needs a positioned parent, or it drags the page sideways.
   That is why .report-row-link below carries the same declaration. */
.report-card-link {
  position: relative;
  font-size: 0.86rem;
  font-weight: 600;
}

/* The Name cell's link into the customer's own page (task #787). It is the
   row's real, keyboard-reachable navigation; the whole-row click that
   /public/reports.js adds is an enhancement on top of it and reads its href.

   It is deliberately quiet: the underline arrives on hover and focus, so fifty
   rows do not read as fifty buttons, and the ink stays the table's own. */
.report-row-link {
  position: relative;
  color: inherit;
  font-weight: 600;
  text-decoration: none;
}

.report-row-link:hover,
.report-row-link:focus-visible {
  color: var(--brand);
  text-decoration: underline;
}

/* With the script running, the pointer says the whole row is clickable. Written
   on the attribute the script itself keys on, so a row can never LOOK clickable
   on a page where nothing is listening. */
tr[data-report-row-href] {
  cursor: pointer;
}

/* And the row lifts under the pointer, so the click target is visible rather
   than merely present. Colour only, no movement: the table is dense and a
   shifting row would be worse than no hint at all. */
tr[data-report-row-href]:hover {
  background: var(--bg);
}

.report-empty {
  margin: 0;
}

/* Pager: the count line always shows (even on one page), and the arrows become
   inert text at the ends rather than dead links. */
.report-pager {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
}

.report-count {
  margin: 0;
  font-size: 0.9rem;
  color: var(--muted);
}

/* The summary is programmatically focusable (tabindex="-1") so a live page
   change can land focus somewhere useful. It is not in the tab order, and a
   mouse click never rings it: only the keyboard/scripted case shows the ring. */
.report-count:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 3px;
}

.report-pager-links {
  display: flex;
  align-items: center;
  gap: 16px;
}

.report-pager-at {
  font-size: 0.9rem;
  color: var(--muted);
}

.report-pager-off {
  color: var(--line);
}

/* --- one customer's own page (task #787) ------------------------------------

   Three stacked cards under the header strip: the graph and its range control,
   the customer's facts, and the card's own ledger. They take the Reports card's
   square corners and section padding so the page reads as part of the section
   it was opened from, rather than as a different product. */
.cust-card {
  display: flex;
  flex-direction: column;
  gap: 14px;
  border-radius: 0;
  padding: var(--section-pad);
}

.cust-card h2 {
  margin: 0;
  font-size: 1.15rem;
}

.cust-section-sub {
  margin: -6px 0 0;
  font-size: 0.9rem;
}

/* The heading and the range control share a line, and wrap onto two at narrow
   widths rather than the control shrinking into its neighbour. */
.cust-section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
}

/* THE LABEL AND THE THREE PILLS WRAP (task #815's correction). Neither could
   shrink, so on a 320px phone the row was about 306px of unshrinkable content
   inside a card with 226px to give it, and the two pixels it stuck out by were
   the only horizontal scroll left in the app. Wrapping costs nothing at any
   width that has room for one line, which is every width above a small phone. */
.cust-range {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
}

.cust-range-label {
  font-size: 0.85rem;
  color: var(--muted);
}

.cust-range-list {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Each range is a real link to a real address. The current one is filled: a
   background AND a border change, not a colour alone, and it also carries
   aria-current so it is announced rather than merely seen. Square since task
   #839, like every other control a person clicks. */
.cust-range-link {
  display: inline-block;
  padding: 5px 12px;
  border: 1px solid var(--line);
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--ink);
  text-decoration: none;
}

.cust-range-link:hover,
.cust-range-link:focus-visible {
  border-color: var(--brand);
  color: var(--brand);
}

.cust-range-on,
.cust-range-on:hover,
.cust-range-on:focus-visible {
  border-color: var(--brand-fixed);
  background: var(--brand-fixed);
  color: var(--card);
}

/* A quiet line under a flat chart. It never replaces the picture: the range
   control has to keep something to control. */
.cust-quiet {
  margin: 0;
}

/* The facts. Two columns of term-and-value on anything wider than a phone, one
   on a phone, in the source order the page states them. */
.cust-facts {
  display: grid;
  /* 224px, not 220: the image upload's thumbnail cap owns 220px in this sheet
     and a test counts it to keep it declared exactly once (task #585). */
  grid-template-columns: repeat(auto-fit, minmax(224px, 1fr));
  gap: 14px 24px;
  margin: 0;
}

.cust-fact dt {
  margin: 0 0 2px;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--muted);
}

.cust-fact dd {
  margin: 0;
  font-size: 1rem;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.cust-wallet-note {
  margin: 0;
  max-width: 60ch;
}

/* --- One card's page: the split, the stamp control and the card itself ------
   (task #815, Sam)

   The Details tab is the graph across the top, then THE SHARED PROGRAM ROW
   (.prog-layout, task #504) under it: two thirds of content on the left, one
   third carrying the customer's own card on the right, at the same 60px gap and
   stacking at the same 980px breakpoint the three Loyalty Program pages use.
   The class is reused rather than copied, so this page and those cannot drift
   about what "two thirds and a card" looks like, and the phone-width behaviour
   arrived for free. */
.cust-layout {
  margin: 16px 0;
}

/* The left-hand two thirds: the facts, then the stamp control. A flex column
   rather than plain flow, so the two boxes cannot have their .card margins
   collapse into a different rhythm from the rest of the page; the gap is the
   16px .card carries, stated once, with the cards' own margins zeroed. */
.cust-main {
  display: flex;
  flex-direction: column;
  gap: 16px;
  min-width: 0;
}

.cust-main > .cust-card {
  margin: 0;
}

/* The right-hand third: the card, its code, the two actions and the link box,
   straight on the page background. No panel around it, for the reason the
   Program pages' preview rail has none (task #473): the card is
   self-explanatory, and a white box around it would read as a control. */
.cust-rail {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}

/* The card sits at the top of the column, a little narrower than the customer's
   own page draws it: this is a reference copy beside a page of figures, not the
   hero of its own screen.

   `width: 100%` IS LOAD-BEARING AND IS NOT THE SAME AS LEAVING IT OUT. The card
   face inside this wrapper is a container-query container and its type scales
   with its own width (font-size: 3.75cqw, see .wallet-pass), so the wrapper must
   have a width that comes from the COLUMN rather than from its contents. As a
   flex item with auto inline margins it would otherwise shrink to fit, its
   contents would be asked how wide they are, and the answer would depend on the
   width being resolved: the cycle resolves to zero and the whole card collapses
   to nothing. Stating the width breaks it, and the auto margins then centre what
   is left over inside the 340px cap. */
.cust-rail .cust-card-face {
  width: 100%;
  max-width: 340px;
  margin: 0 auto;
}

.cust-rail-code {
  margin: 0;
  text-align: center;
}

/* The two actions share a line and wrap to two when the column is narrow. Both
   are equal-weight ghost buttons: one opens the customer's page and one hands
   it on, and neither is the more important thing to do. */
.cust-rail-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.cust-rail-btn {
  flex: 1 1 140px;
  text-align: center;
}

/* The share glyph is decorative and the words beside it are the label, so it
   only ever needs a little air. */
.cust-share i {
  margin-right: 6px;
}

.cust-rail-url {
  min-width: 0;
}

/* --- Award stamps ---------------------------------------------------------

   The quantity and its button on one line, with the sentence saying what the
   number would do underneath. It wraps to two lines rather than shrinking the
   field, because a stamp count squeezed to two characters is the one control on
   this page nobody should have to squint at. */
.cust-award {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 0;
}

.cust-award-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 16px;
}

.cust-step {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.cust-step-label {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--muted);
}

/* Three controls on one line: minus, the number, plus. Each is an ordinary
   control of the app's own kind - the two buttons are real .btn ghosts and the
   field reads the shared control treatment (task #501) - so the group is the
   product's existing language rather than a bespoke widget, and it is 48px tall
   like every other row of controls in the admin. */
.cust-step-control {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Square, because the whole label is one character. No height and no border of
   its own: both are .btn's, which is what keeps it exactly as tall as the field
   between them and the Award stamps button beside them. */
.cust-step-btn {
  flex: 0 0 auto;
  width: 48px;
  padding-inline: 0;
  font-size: 1.25rem;
  line-height: 1.35;
}

.cust-step-btn[disabled] {
  border-color: var(--line);
  color: var(--line);
  cursor: default;
}

.cust-step-btn[disabled]:hover {
  filter: none;
}

/* Layout and legibility only: the height, the border, the radius, the face and
   the background all come from the shared control rule this field is named in. */
.cust-step-input {
  width: 84px;
  text-align: center;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* The native spinner goes ONLY once the script has taken over, which is what
   [data-award-ready] says: without it the browser's own up and down arrows are
   the only way to step the number with a pointer, and taking them away would
   leave a control that could only be typed into. */
.cust-award[data-award-ready] .cust-step-input {
  appearance: textfield;
  -moz-appearance: textfield;
}

.cust-award[data-award-ready] .cust-step-input::-webkit-outer-spin-button,
.cust-award[data-award-ready] .cust-step-input::-webkit-inner-spin-button {
  appearance: none;
  margin: 0;
}

.cust-award-submit {
  flex: 0 0 auto;
}

/* The sentence that says what the chosen number would do. It is the reason the
   control exists, so it is body-sized and full-strength ink, not a footnote. */
.cust-award-note {
  margin: 0;
  font-size: 0.95rem;
  color: var(--ink);
}

.cust-award-full {
  margin: 0;
}

@media (max-width: 420px) {
  /* The button takes its own line under the stepper rather than being squeezed
     beside it, and fills the width so it is an easy target at the counter. */
  .cust-award-submit {
    flex: 1 0 100%;
  }
}

/* --- Reports > Overview (task #807, and Sam's follow-up) --------------------

   The section's landing tab: one row of controls and one big graph. It borrows
   the Reports card (.report-card) and the customer page's range pills and
   section head, so all three Reports surfaces read as one page with different
   contents rather than as three designs.

   THE ONE AUTHENTICATED CARD THAT IS WIDER THAN THE COLUMN (Sam, task #807:
   "I want the data to span full screen width", corrected in task #815: not
   edge to edge, and still a card). It used to bleed to the window, which cost
   it its two side borders and, on a wide monitor, drew a fortnight of stamps as
   a nearly flat line four feet across. It is an ordinary card again - four
   borders, the section's own padding - that is simply allowed to be WIDER than
   the 1120px app column, up to 1500px, centred on the same axis as everything
   else on the page.

   HOW THE WIDTH IS ARRIVED AT, and why it is three terms rather than one:

     calc((100% - 1500px) / 2)      the cap. 100% is the column, so this is the
                                    negative margin that makes the box exactly
                                    1500px wide, centred (both margins equal).
     calc(50% - var(--bleed-w)/2
          + --content-gutter
          + 9px)                    the window. The bleed expression plus the
                                    app's shared gutter and a little more, so
                                    the box can never be wider than the surface
                                    it sits on less two of them: this card is
                                    held off the glass by at least the 30px the
                                    header strip above it and the footer below
                                    it are (task #833, up from the 24px it was
                                    pinned with).

                                    THE 9px IS HALF A SCROLLBAR, and the one
                                    thing here that is an allowance rather than
                                    a measurement. --bleed-w is built from
                                    100vw, which INCLUDES a classic scrollbar,
                                    while the layout the card is placed in does
                                    not; the band is centred, so half of that
                                    difference lands on each side and the card
                                    ends up that much closer to the glass than
                                    the number says. No CSS can read a
                                    scrollbar's width, so the constant carries
                                    half of the widest common one (18px). It
                                    errs the safe way at both ends: on a desktop
                                    with scrollbars the card sits about 31px
                                    from the edge, and on a platform that
                                    overlays them it sits at 39px - never inside
                                    the gutter, and never over the edge.

                                    Since task #831 that surface is the content
                                    area beside the menu bar on a signed-in
                                    desktop page, so the card stops at the menu
                                    rather than reaching under it.
     min(0px, max(...))             and it never goes POSITIVE, so at a width
                                    where the column is already the narrower of
                                    the two the card lines up exactly with the
                                    cards above it rather than being inset by a
                                    few pixels of arithmetic. The allowance
                                    above is what makes that clamp reliable: at
                                    every width where the column is fluid the
                                    window term comes out at or above zero, so
                                    the card IS the column and carries exactly
                                    the same 30px as everything else on the page.

   max() takes the LESS negative of the first two, which is the narrower box:
   1500px on a wide screen, the viewport less about 78px between roughly 1150
   and 1580, and the plain column on anything narrower. Nothing here is a fixed
   width and nothing here is 100vw, so no width can overflow a phone.

   AND NOTHING ELSE MOVES. Every rule here is scoped to .ovw-card or to a class
   only this page renders, and the width is on the card rather than on
   `.container`, so the page header, the tab row, Customer Cards and the Audit
   Log are all untouched.

   THE GRAPH IS THE PAGE. Its SVG is width:100% like every other chart in the
   app, and the component draws it at a wider, taller viewBox here (1220x300
   against 720x210 on a customer's page) so the extra pixels become days rather
   than height. The floor under it on a phone is .lk-chart-scroll's, up in the
   chart section with the rest of the drawing's rules. */
.ovw-card {
  gap: 16px;
  margin-inline: min(
    0px,
    max(calc((100% - 1500px) / 2), calc(50% - var(--bleed-w) / 2 + var(--content-gutter) + 9px))
  );
}

/* THE CONTROL ROW (Sam's follow-up): everything that decides what is drawn on
   the left, the window on the right. One flex line that wraps to two only when
   the two ends would collide, so a phone stacks and a desktop does not; with
   the window on its own line, space-between puts it at the start of that line
   rather than stranding it out on the right. */
.ovw-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px 24px;
}

/* The left-hand end: the tick boxes, then the two conditional selects, then the
   no-JavaScript button and the reset link. A row rather than the column it was
   before the follow-up, because the line it shares now has a right-hand end to
   keep clear of. */
.ovw-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 14px 24px;
  margin: 0;
  flex: 1 1 420px;
  min-width: 0;
}

/* The window sits at the right-hand end of the row, and drops to a line of its
   own only when the controls beside it have taken the first one.

   IT MAY SHRINK (task #815's correction). At `0 0 auto` its width was its
   one-line max-content whatever the card had to give, so on a 320px phone the
   label and three pills stood 306px wide in a 226px card and stuck two pixels
   out of the window: the last horizontal scroll in the app. Shrinking lets the
   wrap the two boxes inside it now allow actually happen. Nothing moves at any
   width with room for the line. */
.ovw-range {
  flex: 0 1 auto;
  min-width: 0;
}

.ovw-metrics {
  margin: 0;
  padding: 0;
  border: 0;
  flex: 0 1 auto;
  min-width: 0;
}

.ovw-metrics legend {
  padding: 0;
  margin: 0 0 8px;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--muted);
}

.ovw-metric-list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 22px;
}

/* Each box is a real label around a real checkbox, sized as a comfortable
   target at the counter: the whole pill is clickable, not just the 16px box. */
.ovw-metric {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  font-size: 0.95rem;
  color: var(--ink);
  cursor: pointer;
}

.ovw-metric input[type='checkbox'] {
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: var(--brand-fixed);
  flex: 0 0 auto;
}

/* A sample of the line this box switches on, so the connection between a tick
   and a line on the picture is visible rather than remembered. It is a hint and
   never the label: the words beside it name the metric, and the chart's own
   legend and figures table say it again. */
.ovw-metric-mark {
  width: 22px;
  height: 0;
  flex: 0 0 auto;
  border-top: 3px solid var(--brand-fixed);
}

.ovw-metric-stamps {
  border-top-color: var(--brand-fixed);
}

.ovw-metric-earned {
  border-top-color: var(--chart-s2);
  border-top-style: dashed;
}

.ovw-metric-spent {
  border-top-color: var(--chart-s3);
  border-top-style: dotted;
}

/* THE TWO CONDITIONAL SELECTS ARE ONE THING: they shrink together and they wrap
   together, which is what `nowrap` here buys. Program and Location are a pair a
   merchant reads across, and the two ways of getting this wrong were both seen
   on the real page: as a WRAPPING flex item the box sized itself to a width
   neither its content nor the row asked for and stacked the pair where there was
   room for both, and as two independent members of the outer row they split
   across two lines the moment the row ran short. Held together, the outer row
   has one honest decision to make: both selects beside the tick boxes, or both
   on the line under them.

   The phone rule at the bottom of this block hands the wrapping back, because
   there the answer really is one field per line. */
.ovw-filters {
  display: flex;
  flex-wrap: nowrap;
  align-items: flex-end;
  gap: 16px;
  flex: 0 1 auto;
  min-width: 0;
}

/* A cap rather than a width: with two selects they share the row, and with one
   it stays a control instead of stretching the whole card. It is a maximum, so
   a phone still gets a full-width box. Sized so both fit beside the three tick
   boxes on a laptop, which is the account this page was built for. */
.ovw-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  flex: 0 1 230px;
  min-width: 150px;
  max-width: 260px;
}

.ovw-field label {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--muted);
}

.ovw-field select {
  width: 100%;
  padding-left: 10px;
  padding-right: 10px;
  text-overflow: ellipsis;
}

/* The Update graph button and the reset link. The button is what a browser with
   no JavaScript applies the row with, and the script hides it on arrival, so on
   an ordinary visit this box is empty and takes no width: the row it ends is
   the tick boxes and the selects, exactly as Sam asked. */
.ovw-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 14px;
  flex: 0 1 auto;
}

/* THE 1500px CAP IS ON THE CARD, NOT HERE (Sam's correction on task #815).
   It was briefly on this block, with auto side margins to centre it, and that
   was wrong twice over. The card is a flex column, so auto margins in the cross
   axis stop a flex item stretching: the graph shrank to its own content, which
   for a width:100% SVG is the browser's default 300px, and the picture came out
   about 360px wide inside a card the width of the window. Capping the CARD caps
   the drawing with no rule here at all, because the SVG already fills whatever
   it is given.

   The lesson, and it is the second time this sheet has paid for it (see
   .cust-rail .cust-card-face): auto inline margins on a flex item are not a
   centring device, they are an opt-out of stretching. */

/* The graph fills the card, and its height follows its own aspect: the wider,
   taller viewBox is what makes it large, at every width, with no fixed height to
   letterbox the drawing or overflow a phone. */
.lk-chart-full {
  width: 100%;
  margin-top: 4px;
}

/* The region a control change replaces, whole. While a request is in flight it
   is aria-busy and slightly faded, and nothing moves: the picture already on
   screen stays readable and in place until the new one arrives, or until a
   failure leaves it alone for good. The same contract .report-results keeps for
   the two lists (task #496). */
.ovw-graph[aria-busy='true'] {
  opacity: 0.55;
  transition: opacity 120ms linear;
}

@media (prefers-reduced-motion: reduce) {
  .ovw-graph[aria-busy='true'] {
    transition: none;
  }
}

@media (max-width: 420px) {
  /* The pair stops being a pair: there is no width here to read them across, so
     each select gets its own full-width line. */
  .ovw-filters {
    flex-wrap: wrap;
  }

  .ovw-field,
  .ovw-actions {
    flex: 1 0 100%;
  }

  .ovw-actions .btn {
    width: 100%;
  }
}

/* --- The authenticated app footer (task #481) ----------------------------

   One ink band at the foot of every signed-in page, matching the app bar
   (both are var(--ink), #15111b). Three columns on desktop: the primary nav
   (Account excepted, task #491), the role-aware account tabs, and Support;
   below them the legal lines and the Terms/Privacy/Support links the auth
   footer prints. The auth shell keeps its own .auth-footer and never gets
   this one.

   Measured contrast against the ink band (WCAG 2.2 relative luminance):
   links at 92% white 15.8:1, body copy at 84% white 13.2:1, the legal lines
   at 72% white 9.9:1, and the column titles in --strip 13.3:1 - all
   comfortably past AA (4.5:1). */

/* TWO PALETTES, ONE SET OF DECLARATIONS (task #831, Sam: "the footer will need
   to change colour to create a distinction from the left bar").

   With the primary nav standing down the left of the page in the ink, an ink
   footer stopped reading as the end of the page and started reading as the
   bottom of the menu bar turning a corner: two ink surfaces meeting at a right
   angle, with the content boxed into the remaining corner. So on the app shell
   the band goes light, which puts a plain horizontal edge under the page and
   leaves exactly one ink surface on screen.

   The colours are therefore tokens on the band rather than literals in the nine
   rules below, in the way .pagehead already carries --band-ink for its own two
   inks (task #760). The rules do not know which palette they are drawing, so
   the two cannot drift apart and a third could be added in one place.

   THE DARK PALETTE IS STILL THE DEFAULT, and it is still exactly the values
   task #481 measured. It is what the footer wears on the public pages that
   render it for a signed-in visitor (task #759): the landing page and the two
   legal pages, neither of which is the app shell and both of which still wear
   the ink bar ACROSS THE TOP. A light footer under a top bar would be answering
   a question those pages do not ask.

   The light palette, measured against its own white fill:

     --footer-ink        --ink     16.9:1   links
     --footer-ink-strong --brand   8.6:1    a link under the pointer
     --footer-ink-quiet  --muted   6.4:1    the support paragraph
     --footer-ink-faint  --muted   6.4:1    the legal lines, at 0.7rem
     --footer-title      --muted   6.4:1    the column titles

   Nothing new joins the palette: every one of those is a token the rest of the
   app is already drawn in, which is the point. The two rules are the app's own
   --line, the hairline it draws every other divider in the product with. */

/* Only pages that carry the footer become a min-height column, so a short
   page (the scanner, an empty report) still puts the footer at the bottom
   edge without ever overlaying or squashing the content above it. The class
   is set server-side by Layout exactly when a footer is passed; every other
   body (auth shell, customer card, public pages) keeps the default flow. */
body.has-app-footer {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

body.has-app-footer > main.container {
  flex: 1 0 auto;
  width: 100%;
}

.app-footer {
  --footer-bg: var(--ink);
  --footer-ink: rgba(255, 255, 255, 0.92);
  --footer-ink-strong: #ffffff;
  --footer-ink-quiet: rgba(255, 255, 255, 0.84);
  --footer-ink-faint: rgba(255, 255, 255, 0.72);
  --footer-ink-legal: rgba(255, 255, 255, 0.82);
  --footer-title: var(--strip);
  --footer-title-rule: rgba(221, 214, 240, 0.42);
  --footer-edge: rgba(255, 255, 255, 0.16);
  --footer-focus: #ffffff;
  background: var(--footer-bg);
  color: var(--footer-ink);
  margin-top: 48px;
}

/* THE BAND IN THE DARK BRAND TONE, ON EVERY SIGNED-IN PAGE (task #832's second
   revision, Sam: "The footer colour should be applied to all footers across the
   authenticated web app"). `:has(.appnav)` is the same test the menu bar itself
   is placed by, so the footer changes colour exactly on the pages that grew a
   left-hand column and on no other.

   THIS RULE USED TO BE A WHOLE SECOND PALETTE, and the shape of this change is
   that it is not one any more. Task #831 had turned the app-shell footer WHITE
   so it would not read as the left ink column turning a corner, which meant
   restating all ten tokens as dark-on-white; #832's first revision then carved
   the dashboard back out of it with a `:not(:has(.dash-focus))`. Sam has now
   asked for the tone everywhere in the signed-in app, so both of those go: the
   exception is gone from the selector, and the nine text tokens are gone from
   the body. What is left is the FILL, over the dark palette on `.app-footer`
   that was always there. One recipe for words on a dark ground, declared once.

   The light band's `border-top` went with it. That hairline existed because
   white on the page's pale grey has no edge of its own; a dark footer is its
   own edge, which is how this band read before #831 and how it reads again.

   WHAT IS DELIBERATELY NOT IN SCOPE. Public pages are not the app shell: they
   carry `appnav-plain` (see PublicChrome), which this selector excludes, so the
   legal pages, the card designer and the auth shell keep the footers they had.
   The front page is the one public surface that took the tone on its own, and
   it says so itself a few thousand lines up.

   THE SUPPORT SECTION IS IN SCOPE SINCE TASK #843, and by this rule rather than
   by a rule of its own: Sam asked for its footer to match the app's in layout
   and in colour, the section is on the app's shell with the app's bar, so this
   selector answers the colour half with nothing added. The layout half is the
   markup (see SupportFooter in views/support.tsx), which is the app footer's own
   classes with contents a signed-out reader can actually use. */
body.app-shell:has(.appnav:not(.appnav-plain)) .app-footer {
  --footer-bg: var(--ink-brand);
}

.app-footer-inner {
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 40px var(--content-gutter) 28px;
}

.app-footer-cols {
  display: grid;
  /* minmax(0, ...) rather than a bare 1fr: a grid track's default minimum is
     min-content, so a column holding an unbreakable string (the support address
     is 22 characters) grows the track past the page instead of wrapping. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.3fr);
  gap: 32px;
}

/* The footer is on every signed-in page, so this is what stops ANY of them
   opening a horizontal scrollbar at 200% zoom on a small phone (a 320px screen
   at 200% is a 160px layout viewport, narrower than the support address). The
   grid minimum above lets the track shrink; this lets the text inside it give
   way, which is the same treatment the team-members table took in task #490. */
.app-footer-col {
  min-width: 0;
}

/* Only prose and the support address may give way inside a word (task #518).
   `anywhere` used to sit on every link in the band as well, and that was half
   of a wrapping bug. The other half is under `.app-footer ul` below: the list
   items are grid items, and a start-aligned grid item is shrink-to-fit, so
   each label's box was EXACTLY as wide as its own text with nothing spare. A
   box with no slack that is also allowed to break mid-word pays any shortfall,
   however small, by dropping its last character onto a second line. A
   shortfall of a few pixels is ordinary: Manrope is 2px to 5px wider per label
   at this size than the system face that renders first under `display=swap`,
   so a footer measured against the fallback and painted in Manrope reads
   "Dashboar/d", "Report/s", "Setting/s". Both halves are fixed here. */
.app-footer-col p {
  overflow-wrap: anywhere;
}

/* A navigation label is a word, and a word is never broken across two lines.
   Stated rather than left to the initial value, so nothing added to the sheet
   later can hand these links intra-word breaking again by accident.
   Multi-word labels ("Create New Program") still wrap at their spaces. */
.app-footer-col a {
  overflow-wrap: normal;
  word-break: normal;
}

/* The one genuinely unbreakable string in the band: 22 characters with no
   space among them, wider than the ~128px of text column a 320px phone at
   200% zoom has left. It keeps the old treatment, because there is nowhere
   else for it to go. Written with the element and the column in the selector
   so it outweighs the `normal` above it rather than tying with it: a bare
   `.app-footer-address` would lose to `.app-footer-col a` and the address
   would run off the side of a very narrow screen. */
.app-footer-col a.app-footer-address {
  overflow-wrap: anywhere;
}

/* The column titles (task #491). They were the same white as everything else
   on the band, one notch dimmer, so the three groups ran together. They now
   take --strip, the established lilac from the page-header strip: a real hue
   step away from the white body copy and links rather than another shade of
   grey, and 13.3:1 against the ink, comfortably past AA. The hairline beneath
   is the same lilac at 42%, which is 3.2:1 against the ink, so it clears the
   1.4.11 non-text bar for a separator while a 1px line stays quiet. Neither
   carries meaning on its own: these are <h2>s inside labelled <nav>s, and the
   structure is in the markup whether or not the colours are seen. */
.app-footer h2 {
  color: var(--footer-title);
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  margin: 0 0 14px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--footer-title-rule);
}

/* One row per link. The rows deliberately take the whole column track rather
   than shrink-wrapping their text (task #518): a `justify-items: start` here
   made every <li> a shrink-to-fit box exactly as wide as the label inside it,
   which is a box with no room for the ordinary sub-pixel disagreements between
   the width a box is measured at and the width its text finally needs. It
   bought nothing to look at, because the <a> inside is inline and so hugs its
   own text anyway: the hover underline and the focus ring were always drawn
   around the text, never around the row. Full-width rows leave a phone's
   340-odd pixels of slack behind every label instead of nought. */
.app-footer ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 8px;
}

.app-footer a {
  color: var(--footer-ink);
  text-decoration: none;
  font-size: 0.95rem;
}

.app-footer a:hover {
  color: var(--footer-ink-strong);
  text-decoration: underline;
}

.app-footer a:focus-visible {
  outline: 2px solid var(--footer-focus);
  outline-offset: 3px;
}

.app-footer-support p {
  margin: 0 0 12px;
  font-size: 0.95rem;
  line-height: 1.55;
  color: var(--footer-ink-quiet);
  max-width: 36ch;
}

.app-footer-legal {
  border-top: 1px solid var(--footer-edge);
  margin-top: 36px;
  padding-top: 20px;
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px 24px;
  flex-wrap: wrap;
}

/* The registered-company lines sit a step below the rest of the band (Sam,
   task #491): 0.7rem, 11.2px at a 16px root. They are reference detail, not
   reading matter, and dropping them stops the three lines competing with the
   links above. Scoped to this element, so the auth footer's legal block and
   the public site's keep their own sizes; the line-height and the 9.9:1
   colour are unchanged, and the block still wraps freely at any zoom. */
.app-footer-legal-lines {
  color: var(--footer-ink-faint);
  font-size: 0.7rem;
  line-height: 1.5;
}

.app-footer-legal-lines p {
  margin: 0;
}

.app-footer-legal-nav {
  display: flex;
  gap: 20px;
  flex-wrap: wrap;
}

.app-footer-legal-nav a {
  color: var(--footer-ink-legal);
  font-size: 0.82rem;
}

@media (max-width: 720px) {
  /* One column, in DOM order (Navigate, the account links, Support, legal).
     Headed short lists rather than a re-flowed grid: the whole footer stays
     a few compact groups, never a wall of links or a second burger menu. */
  .app-footer-inner {
    padding: 32px var(--content-gutter) 24px;
  }

  .app-footer-cols {
    grid-template-columns: 1fr;
    gap: 26px;
  }

  .app-footer-legal {
    flex-direction: column;
  }
}

/* --- Sign-in methods (task #434) ------------------------------------------ */

/* The configured social buttons on the login page live in their own tiny
   forms; the form is layout-invisible so the button column reads exactly like
   the placeholder state. */
.auth-social-form {
  display: contents;
}

/* The "Ways you sign in" list on Account > Security: one method per row, the
   status muted beside its name, any action button on its own line beneath. */
.signin-methods {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 14px;
}

.signin-methods li {
  margin: 0;
}

.signin-methods .hint {
  display: block;
  margin-top: 4px;
}

.signin-method-action {
  margin-top: 8px;
}

/* The "Your login email" block on Account > Security (task #937): the current
   address on its own line above the change form, and the relay warning as a
   plain hint beneath it. */
.login-email-current {
  display: grid;
  gap: 4px;
  margin: 0 0 14px;
  overflow-wrap: anywhere;
}

.login-email-relay {
  margin: 0 0 14px;
}

/* --- Card Design (task #502) ---------------------------------------------- */

/* The designer at /programs/:code/design, flattened to ONE static panel (task
   #523) and split into THREE BOXES by task #586: the controls down the left in
   three white boxes, the one preview down the right, sticking with the merchant
   as they scroll.

   THE ACCORDION IS GONE, and with it .design-acc, .design-acc-section,
   .design-acc-summary, .design-acc-state, .design-acc-body, the two angle
   rules and the <details> display trap they were all written around. Task #523
   left .design-panel in its place, one white box with no summary row, no [open]
   state and nothing to keep shut; task #586 retired that too, and every rule
   that was scoped to it is scoped to .design-section instead, unchanged
   otherwise, so the fields inside look exactly as they did.

   The page's two-column shape is NOT here (task #504 final pass): it is
   .prog-layout, the row Overview and Offer share. The form itself sits on the
   page background and the panel is the white surface inside it.

   The whole section is written to survive 320px and 200% zoom without a
   horizontal scrollbar, which needs three things and they are easy to lose:
   `minmax(0, ...)` or a min() floor on every grid track, `min-width: 0` on
   every grid child, and `min-inline-size: 0` on the fieldsets, whose default
   min-inline-size is min-content. */

.design-form {
  display: flex;
  flex-direction: column;
  gap: 18px;
  min-width: 0;
}

/* --- the three boxed sections (task #558, boxed by #586) -------------------- */

/* ONE SECTION IS ONE WHITE BOX (Sam, task #586): Card Colour, Stamp Icon,
   Background, three surfaces down the form's own column rather than three bands
   inside one panel.

   IT IS .prog-panel'S BOX, DECLARED HERE RATHER THAN BORROWED. The fill, the
   1px neutral rule, the square corner and the 22px of padding are exactly what
   every other Program tab's panel carries (see the `prog-` block above), so a
   merchant changing tab sees the same surface in the same place. The class is
   its own because this box is ALSO a layout: a flex column on the 16px gap that
   .design-panel used to set, which is what keeps every control inside it spaced
   as it was. .prog-panel is not a flex container and spaces its children by
   their own margins, so joining the two selectors would have changed one of
   them; the four visual declarations are the whole of what is shared, and they
   are short enough to say twice and read straight.

   The 18px between the boxes is .design-form's own gap, one level up.

   min-inline-size is the fieldset guard the panel carried: a fieldset's default
   min-inline-size is min-content, and it is the reason a wide icon grid could
   push this column sideways at 320px. */
.design-section {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 22px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 0;
  min-width: 0;
  min-inline-size: 0;
}

/* THE SECTION TITLE IS AN ORDINARY h2, AND THAT IS THE POINT (Sam, task #586).

   Task #558 made it a full-width band and its follow-up made that band white on
   --ink, the loudest treatment on the page. Sam's answer is that the box is the
   grouping now, so the title only has to name the box: this is the Overview's
   "Program name" treatment exactly, which is the global h2 with its top margin
   already at 0. Nothing is declared here about size, weight or colour, so the
   three titles cannot drift from the headings on the other three tabs, and the
   subtitles inside a section (.design-group-label, .design-asset-label) stay
   the ordinary 1rem they have always been.

   The one declaration is the bottom margin, and it is a REMOVAL: the section is
   a flex column with a 16px gap, and the global h2's `margin: 0 0 8px` would be
   added to that gap rather than collapsed into it, leaving 24px under the title
   and 16px between everything else. The child combinator matters, because the
   background library's <dialog> renders an h2 of its own inside this section
   and it is not this page's heading. */
.design-section > h2 {
  margin-bottom: 0;
}

/* --- groups and fields ------------------------------------------------------ */

/* A named group of radios: the fieldset survives for what it MEANS, the box it
   used to draw is gone (task #514). The legend reads as a plain label. */
.design-group {
  border: 0;
  border-radius: 0;
  padding: 0;
  margin: 0;
  min-inline-size: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* The 10px below the legend is not decoration and is not a duplicate of the
   group's gap (Sam, task #514 final pass). A <legend> is not a flex item: the
   user agent lifts it out of the fieldset's flow to sit on the border, so
   .design-group's `gap: 10px` reaches every OTHER child and never the legend.
   The result was a group label sitting flush against the first bordered choice
   card, touching it. A margin is the only spacing a legend can be given, and
   because the legend is out of flow it neither collapses with the first child's
   margin nor adds to the flex gap: every group gets exactly 10px, matching the
   gap between the cards below it.

   IT IS A SUBTITLE NOW (task #558), and the only groups still carrying one are
   Unstamped icon and the overlay: Card colour and Stamp icon take their names
   from the section headings above them, which is why .colour-mode legend is no
   longer declared anywhere. The size is unchanged, deliberately - only the three
   main titles grew. */
.design-group-label {
  font-weight: 600;
  padding: 0;
  margin-bottom: 10px;
  max-inline-size: 100%;
  overflow-wrap: break-word;
}

.design-section .hint {
  margin: 0;
}

/* A label above its control, the shape every other LoyalPack form uses. */
.design-field {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}

.design-field > label {
  font-weight: 600;
}

/* NO GENERIC FIELD RULE HERE ANY MORE (task #523). .design-field used to carry
   the square 12px treatment for any text input or select inside it, copied from
   .prog-settings-fields. The page's only .design-field elements are the two
   colour wells, whose value field is styled by .colour-well-value below, and a
   generic `.design-field input[type='text']` would have OUTRANKED that class on
   specificity and quietly put the neutral border and the opaque card background
   back over the merchant's colour. Nothing on this page needs the generic rule,
   so it is gone rather than fought with. */

/* --- the colour wells (task #523) -------------------------------------------- */

/* THE PAIR GRID IS GONE (task #558). It laid Stamp colour and Stamp area colour
   side by side wherever the column would take two and stacked them wherever it
   would not, by auto-fit over a 13rem floor rather than by a media query. The
   two are in different sections now - the marks' colour under Stamp icon, the
   field behind them under Background - so there is no pair to lay out, and a
   single well in a two-track grid is a two-track grid pretending.

   NOTHING ABOUT EITHER WELL CHANGED WITH IT, which is the reason the wrapper
   could simply go rather than be replaced: .colour-well caps itself at 200px and
   shrinks below that wherever the column is narrower, so each one draws at
   exactly the width it drew at inside the grid, at 320px and at 200% zoom
   alike. The grid was deciding how many wells sat on a line, and there is only
   ever one now. */

/* THE WELL: a bordered box filled with the colour itself, holding both real
   controls 5px in from its edges. The fill is a custom property the server
   renders and public/colour-well.js repaints as the merchant types, so the box
   IS the swatch and there is no separate chip to keep in step with it.

   200px IS A CEILING, NOT A WIDTH (Sam's #523 follow-up). A hex is seven
   characters, so a well that ran the column's full width read as a text field
   that happened to be coloured rather than as a swatch. max-width caps it and
   nothing else: the box still shrinks below 200px wherever the column is
   narrower, which is what keeps a 320px screen and a 200% zoom free of any
   overflow. min-width: 0 is the other half of that, and stays.

   EVERY RULE IN THIS BLOCK IS SCOPED TO .colour-well AND TO NO PAGE (task #530).
   The well was written for the Card Design page and its parts were styled by
   bare single-class selectors, which was fine while the design panel was the
   only place it appeared. Account > Settings renders it inside .stack-form, and
   that form carries the app's shared control rules: `.stack-form
   input[type='text']` and `.stack-form input[type='color']` are a class, an
   attribute and an element apiece, so they OUTRANK a bare `.colour-well-value`
   and would quietly hand the well a 48px box, a 10px radius, an opaque card
   background over the merchant's colour and a 64px bordered swatch.

   Hence the shape these selectors are written in, which looks redundant and is
   not: ancestor class, then the element AND its type attribute, then the part's
   own class. That is two classes and an attribute over one element, which beats
   a class and an attribute over one element outright, so the well renders
   identically on both pages by specificity rather than by source order, and
   without a single page-specific override existing anywhere. Scoping to
   `.design-panel`, which is what these rules used to do, is the thing that
   cannot work once the control is on two pages. */
.colour-well {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 5px;
  min-width: 0;
  max-width: 200px;
  border: 1px solid var(--line);
  border-radius: 0;
  background: var(--colour-well-fill, var(--card));
}

/* THE UNSET STATE (task #530): white, with a red slash across the swatch.

   It is drawn whenever the field does not hold a colour, which is BOTH an empty
   field and a half-typed one. Before this, an empty well was filled with the
   colour the card would fall back to and a half-typed one simply kept the last
   colour that parsed, so in the two states where the merchant most needs to know
   that nothing is set, the control showed them a colour and said nothing. An
   unset Stamp colour was pixel-identical to a Stamp colour deliberately set to
   the same value, and deleting a hex left the deleted colour on screen.

   The white is declared here rather than left to the custom property on purpose:
   the state, not the inline fill, is what decides how this looks, so a fill that
   somehow went stale cannot show through the slash as though it were current.

   NOT A COLOUR-ONLY SIGNAL. The value is the text of the field beside it, which
   is empty when the well is empty and holds exactly the typed characters when it
   is mid-word, and the hint under the control says what empty means. The slash
   restates that visually; it is never the only place it is said. */
.colour-well[data-colour-unset] {
  background: #fff;
}

/* The left 50px: the colour, unobstructed. The native input is stripped of its
   own border and padding so its swatch meets the fill around it seamlessly, and
   the vendor pseudo-elements are stripped too because that is where the chrome
   actually lives in each engine. It is NOT hidden to achieve that: an
   `opacity: 0` control paints no focus ring (task #514 learned that on the icon
   tiles), and this one is a real focus stop with the platform picker behind it.

   IT SITS IN A WRAPPER, and the wrapper is the flex item (task #530). A native
   colour input is a replaced element, so it has no ::after of its own to draw
   the unset slash with; the wrapper exists for that one reason and for nothing
   else. It takes no focus, carries no accessible name and lets every pointer
   event through, so the control a merchant meets is the same control it was. */
.colour-well .colour-well-picker {
  position: relative;
  flex: 0 0 50px;
  display: block;
  width: 50px;
  height: 40px;
  line-height: 0;
}

.colour-well input[type='color'].colour-well-swatch {
  display: block;
  width: 50px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  cursor: pointer;
}

/* The slash, corner to corner, at ANY size the swatch is ever given.

   `to top right` is doing the non-obvious work. CSS puts the two corners either
   side of the named one, here the top-left and the bottom-right, exactly on the
   gradient's 50% line, so the 2px band lands precisely on that diagonal whatever
   the box's aspect ratio is. Writing an angle instead would have hard-coded the
   50-by-40 above into a second place and quietly gone crooked the day it moved.

   pointer-events: none, so the slash never eats a click meant for the picker
   underneath it, and aria-hidden by construction: a pseudo-element is not in the
   accessibility tree at all. */
.colour-well[data-colour-unset] .colour-well-picker::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    to top right,
    transparent calc(50% - 1px),
    var(--error) calc(50% - 1px),
    var(--error) calc(50% + 1px),
    transparent calc(50% + 1px)
  );
}

.colour-well input[type='color'].colour-well-swatch::-webkit-color-swatch-wrapper {
  padding: 0;
}

.colour-well input[type='color'].colour-well-swatch::-webkit-color-swatch {
  border: 0;
  border-radius: 0;
}

.colour-well input[type='color'].colour-well-swatch::-moz-color-swatch {
  border: 0;
  border-radius: 0;
}

/* THE CLICK MARK: a droplet over the colour, saying the colour is a button
   (task #622, Sam).

   The swatch has always BEEN the picker, and a flat rectangle of colour said
   nothing about that: it reads as a preview of the hex beside it, which is
   exactly what it also is. The droplet is the ordinary mark for "this opens a
   colour picker", and it is drawn over the left end of the control because that
   is the part that opens one.

   IT ADDS NO TARGET AND MOVES NOTHING. pointer-events: none is the load-bearing
   declaration here: without it the affordance would swallow the very click it
   invites, and the merchant would press the picture of a picker instead of the
   picker. The glyph is positioned over the swatch inside the wrapper that
   already exists for the unset slash, so it is out of flow: the 50px of colour,
   the 5px pads, the 200px cap and the hex field's share of the box are all
   exactly what they were before it. Nothing about the well's layout is asked to
   make room.

   WHITE, OUTLINED IN BLACK, for the reason the focus ring below is two-tone: the
   surface under it is a colour the MERCHANT chose, and one ink cannot be seen on
   sixteen million of them. A white body with a dark edge always has one half in
   contrast with the fill, and on a mid-tone it has both. That is also why it
   survives the unset state, where the box goes white and the body of the droplet
   is then held by its outline alone.

   IT STAYS WHEN THE WELL IS UNSET, and the slash simply crosses it. An empty
   well is the state where a merchant most needs to know they can press this, so
   hiding the affordance exactly there would have been the wrong half to drop.
   The slash is a 2px line and it passes OVER the mark rather than under it,
   which is a fact about the box tree rather than about these rules: it is the
   wrapper's own ::after, so it is that wrapper's last child and paints above the
   elements inside it. The "no colour" reading is unchanged, and the two read
   together.

   16px, WHICH IS SMALL AGAINST 50 BY 40. The mark is a hint, not a picture: it
   covers about a sixteenth of the swatch, so what a merchant reads there is
   still overwhelmingly the colour itself. */
.colour-well .colour-well-picker .colour-well-mark {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 16px;
  height: 16px;
  transform: translate(-50%, -50%);
  pointer-events: none;
  fill: #fff;
  stroke: rgba(0, 0, 0, 0.55);
  stroke-width: 1.2;
}

/* Forced-colours mode replaces the merchant's colour with the user's own
   palette, so the two-tone trick above has nothing left to be legible against.
   The system pair is the answer there: the page's surface as the body, the
   page's ink as the edge. */
@media (forced-colors: active) {
  .colour-well .colour-well-picker .colour-well-mark {
    fill: Canvas;
    stroke: CanvasText;
  }
}

/* The value, in the rest of the box, on a translucent white veil over the fill.

   THE VEIL'S ALPHA IS THE CONTRAST GUARANTEE and is not a taste decision. White
   at 0.58 over the darkest possible fill, pure black, composites to about 58%
   grey, which clears 4.5:1 against --ink; every lighter fill composites lighter
   still and clears it by more. So the value stays readable on any colour a
   merchant can choose, with ONE ink and no measurement, rather than by shipping
   a second copy of src/contrast.ts into a browser script. Thinning it breaks
   that floor: test/card-design-colour-wells.test.ts recomputes the worst case
   through the real contrast helpers and says so.

   The border is a translucent black, which shows against the pale field on a
   dark fill and against the field's own near-white on a light one.

   CENTRED, because the well is now capped at 200px and the seven characters no
   longer fill the space they are given: left-aligned, the hex sat hard against
   the 50px of colour beside it with a run of empty field to its right, which
   read as a field waiting for more. Centring is presentation only, so the value
   a screen reader announces and the caret a merchant types at are unchanged.

   min-height is declared here as its own floor rather than inherited from the
   app's --control-height, because this field's height is the well's height and
   the well is 50 wide by 40 tall of colour plus its padding. On Account >
   Settings the shared 48px control rule would otherwise have made this one field
   taller than the swatch beside it inside its own box. */
.colour-well input[type='text'].colour-well-value {
  flex: 1 1 auto;
  width: auto;
  min-width: 0;
  min-height: 0;
  height: 40px;
  padding: 9px 10px;
  border: 1px solid rgba(0, 0, 0, 0.22);
  border-radius: 0;
  font-family: var(--font-mono);
  font-size: 0.95rem;
  text-align: center;
  background: rgba(255, 255, 255, 0.58);
  color: var(--ink);
}

.colour-well input[type='text'].colour-well-value::placeholder {
  color: var(--muted);
}

/* THE FOCUS RINGS, and they moved here from the Card Design panel's own block
   with the rest of the control (task #530). They were scoped `.design-panel
   .colour-well ...`, which on Account > Settings would have left the well with
   whatever ring .stack-form draws and put a LoyalPack-purple outline on a swatch
   a merchant may well have set to LoyalPack purple. Both rules are three classes,
   so they still outrank every shared ring in this sheet: the design panel's
   `input[type='color']`/`input[type='text']` pair and the account form's
   `.stack-form input:focus-visible` alike, all of which are two classes and an
   element.

   The swatch sits on, and is filled with, a colour the MERCHANT chose. A single
   purple ring around a purple swatch is a ring nobody can see, and a merchant
   whose brand colour is close to LoyalPack's own is exactly the merchant most
   likely to have picked it. So the ring is two-tone and drawn on both sides of
   the swatch's edge: white just inside it, the app's near-black just outside it.
   Whatever the fill, at least one of those two contrasts with it, and on a
   mid-tone both do.

   THE INNER RING IS A BORDER, NOT AN INSET SHADOW, and that is not arbitrary.
   LoyalPack's visual system is flat: two tests count the non-inset shadows in
   this whole sheet, because a shadow appearing where nobody intended one is how
   a flat product stops looking flat. (Those tests read the sheet as text, so
   this note deliberately does not spell the property either.) A border costs no
   layout here because
   `* { box-sizing: border-box }` is set at the top of this file, so the swatch
   keeps its 50 by 40 exactly and the ring eats two pixels of the colour rather
   than pushing the field along.

   It is a SHAPE appearing rather than a hue changing, so it satisfies the same
   not-by-colour-alone rule the rest of the control follows. */
.colour-well input[type='color'].colour-well-swatch:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 0;
  border: 2px solid var(--card);
}

/* THE VALUE FIELD NEEDS THE SAME TREATMENT AND FOR THE SAME REASON, which is
   easy to miss because the field looks pale: the shared ring is drawn at a 2px
   OFFSET, which lands it in the well's own padding, on the undiluted colour. So
   a merchant whose brand colour is close to LoyalPack purple gets a purple ring
   on a purple surround, on the control they are most likely to be typing into.

   Same dual ring, arranged so nothing moves: the border it already has turns
   dark and stays 1px, and a white outline is drawn immediately outside it. The
   two are adjacent and contrast with EACH OTHER, which is what makes a dual ring
   work: its visibility never depends on the colour it happens to be sitting on.
   Because only the border's COLOUR changes and the outline is drawn outside the
   layout box, the field does not shift by a pixel when it takes focus. */
.colour-well input[type='text'].colour-well-value:focus-visible {
  outline: 2px solid var(--card);
  outline-offset: 0;
  border-color: var(--ink);
}

/* A checkbox or radio row inside a section. `.check` sets the direction and
   the gap with !important; all this adds is the display it needs to lay out. */
.design-section .check {
  display: flex;
  margin: 0;
  min-width: 0;
}

.design-section .check > span {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  /* flex lets the span claim the room the radio does not want, and breaking
     inside a word is what makes long labels fit a column barely wider than
     the word itself at 320px and 200% zoom. */
  flex: 1 1 auto;
  overflow-wrap: anywhere;
}

/* --- the bordered selection blocks ------------------------------------------ */

/* THE one choice-card treatment (task #514 follow-up): every radio group on
   this page that offers a short menu of named options draws each option as a
   bordered card, stacked vertically by its group's own gap. One class for
   every group (Card colour, What the card shows, Unstamped icon, Bar style,
   Picture style, Background), so they cannot drift apart by an edit to one.
   Card colour joined them in the final pass and brought no rules of its own. */
.design-choice-card {
  min-width: 0;
  border: 1px solid var(--line);
  padding: 12px;
  background: var(--card);
  cursor: pointer;
}

/* AN OPTION THAT CONTAINS ITS OWN CONTROL (task #558), which today is the card
   colour override and nothing else.

   The bordered treatment above is on the SHELL for such an option rather than on
   the label, so the control is inside the option's box instead of floating under
   the group as a half-drawn third option. Every rule in this block still lands:
   the radio is a descendant of the shell, so :has(input:checked) and
   :focus-within both match it, and the border, padding, background and hover are
   the ones already declared. This rule adds only the column and the honesty
   about what is pressable.

   `cursor: auto` on the shell and `pointer` back on the label: the shell's
   padding is not a click target for the radio (a click there does nothing, so a
   pointer would be a lie), while the label is exactly as pressable as every
   other option on the page. */
.design-choice-shell {
  display: flex;
  flex-direction: column;
  gap: 10px;
  cursor: auto;
}

.design-choice-shell > .design-choice-lead {
  cursor: pointer;
}

/* A hover that says "this is one thing and it is pressable" without inventing
   a new surface language: the neutral line darkens to the muted ink. The
   checked echo below outranks it by order. */
.design-choice-card:hover {
  border-color: var(--muted);
}

/* The chosen option is stated by the radio itself; this is the visual echo of
   it. :has() is progressive: without it the radio still says which is which.
   A second border drawn INSIDE the first rather than a shadow: the admin's
   settings surfaces are flat and square by decision (task #462).

   ONE RADIO PER BOX AGAIN (task #575). Task #563 put a second radio group INSIDE
   an option - the row of empty marks, inside the Unstamped icon option that
   consulted it - and an unscoped `:has(input:checked)` cannot tell one group's
   radio from the other's, so a checked TILE lit the whole box. That option is
   gone: the unstamped mark is one flat row of nine tiles in a group of its own,
   in no box at all, and no choice card on this page now contains a radio that is
   not its own. The scoping this rule needed went with it. */
.design-choice-card:has(input:checked) {
  border-color: var(--brand);
  outline: 1px solid var(--brand);
  outline-offset: -2px;
}

/* An option's title WITH a thumbnail in front of it, on one line (task #557).
   Rendered only for an option that has one, which today is Primary Account
   Colour and nothing else, so every other choice card's markup is unchanged.

   A flex row rather than an inline swatch: `align-items: center` centres a
   50-by-25 block against the words whatever the line height turns out to be, and
   the row cannot be broken by a wrap, so the colour and the name of the colour
   stay together in the narrowest column this page has. `min-width: 0` keeps the
   inherited `overflow-wrap: anywhere` able to do its job on the title itself. */
.design-choice-title {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

/* --- the row of unstamped marks (task #563, recut as nine by #575) ----------- */

/* NINE TILES, ONE ROW, and that is why this is not .design-icon-grid.

   The earned picker's grid packs as many tracks as the column will take, which is
   right for fifty marks and wrong for nine: auto-fill would break the row into
   five and four at whatever width the panel happens to be, and Sam asked for one
   row. Nine explicit fractional tracks cannot do that. They also cannot overflow:
   `minmax(0, 1fr)` lets every tile shrink below its content, and
   .design-icon-tile's `aspect-ratio: 1` keeps each one square as it does, so the
   row narrows with the column instead of widening it. That is what holds a 320px
   screen at 200% zoom free of a sideways scroll.

   NINE RATHER THAN EIGHT IS ALSO A SIZE DECISION (task #575, Sam): the live
   Match tile took a ninth place in this row, and the shrink that comes with it
   is wanted. At eight tracks these tiles drew visibly LARGER than the stamp
   picker's own tiles directly above them, which made the smaller grid read as
   the lesser control; at nine the two rows sit at about the same size and the
   section reads as one picker with a row of alternatives under it.

   FIVE AND FOUR ON A NARROW SCREEN, below. */
.design-empty-row {
  display: grid;
  grid-template-columns: repeat(9, minmax(0, 1fr));
  gap: 8px;
  min-width: 0;
}

/* At the width where nine tracks would put a touch target under about 40px, the
   row becomes five and four (Sam accepted a wrap on small screens). The
   breakpoint is the panel's own, not the viewport's ideal: 560px is where the
   design column stops being able to give every tile a fingertip.

   Five rather than three or four because nine splits into five and four and
   nothing else without leaving a line of one: three would be three even rows but
   at a tile size this narrow column does not need, and four would strand the
   ninth tile alone on a line of its own. */
@media (max-width: 560px) {
  .design-empty-row {
    grid-template-columns: repeat(5, minmax(0, 1fr));
  }
}

/* THE MATCH TILE IS THE STAMP MARK, FADED (task #575), which is the whole of
   what "match" means: the same icon, shown lighter until that stamp is
   collected. The number is the card's own UNEARNED_MARK_OPACITY and the public
   page's .cc-pick-faded, so the tile previews the treatment rather than
   approximating it.

   ON THE GLYPH, NOT THE TILE. Fading the label would fade the chosen border and
   the focus ring with it, and this tile has to be as selectable and as clearly
   selected as the eight beside it. */
.design-empty-match-art {
  opacity: 0.35;
}

/* TEN MORE PIXELS ABOVE THE NUMBERS SWITCH (task #587, Sam), which is 20px
   between the row of nine and the switch where the fieldset's own `gap: 10px`
   used to be the whole of it.

   The gap alone was enough while a paragraph of help sat under the switch and
   ended that block visibly. With the paragraph gone (see EmptyMarkPicker), the
   switch sits directly under the tiles at the same distance the tiles sit from
   their own hint, so the second question reads as part of the first. A margin
   on a flex item is ADDED to the gap rather than collapsed into it, which is
   task #557's doubled-gap trap being used deliberately: 10 + 10 = 20. */
.design-empty-numbers {
  margin-top: 10px;
}

/* --- the stamp icon picker --------------------------------------------------- */

/* The redesigned picker (task #514 follow-up): square icon-only tiles in a
   dense grid, one for the featured set and one for search answers. The
   tracks have a small minimum and may shrink below it (the min() floor), so
   the grid packs a desktop column and still never widens a 160px layout
   viewport at 200% zoom on a 320px screen. */
.design-icon-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 52px), 1fr));
  gap: 8px;
  min-width: 0;
}

/* The display above would outrank the user-agent's own [hidden] rule (the
   task #506 lesson, learned again here on a screenshot): while a search is
   showing its answers the featured grid is hidden, and hidden must win. */
.design-icon-grid[hidden] {
  display: none !important;
}

/* One tile: a square label whose whole face is the control. The radio inside
   is REAL and focusable but visually hidden (opacity, never display:none,
   which would take it out of the group's arrow-key order); the tile shows the
   state instead. The accessible name is the sr-only span beside it, repeated
   as the label's title for a sighted hover. */
.design-icon-tile {
  position: relative;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  border: 1px solid var(--line);
  background: var(--card);
  cursor: pointer;
}

.design-icon-tile input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.design-icon-tile:hover {
  border-color: var(--muted);
}

/* The chosen tile wears a clear LoyalPack purple border: the brand colour on
   the border itself plus a second run drawn inside it, the same flat-square
   echo the choice cards use, only heavier because there is no text to help. */
.design-icon-tile:has(input:checked) {
  border-color: var(--brand);
  outline: 2px solid var(--brand);
  outline-offset: -3px;
}

/* The focus ring stays distinct from the checked border: outside the tile,
   the same 2px offset every control on this page uses. :focus-within rather
   than :has(:focus-visible) so it also serves engines without :has(); the
   radio is only ever focused by intent (click focuses the tile's own input,
   which is the tile itself). */
.design-icon-tile:focus-within {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* THE RING THE CHECKED TILE WOULD OTHERWISE NOT HAVE.
   Tab into a radio group lands on the CHECKED radio, so this is the tile
   keyboard focus normally sits on - and the two rules above cannot mark it:
   :has(input:checked) outranks :focus-within for the tile's ONE outline, and
   the radio that would draw its own shared ring is opacity: 0, which paints
   nothing at all, its outline included. So the checked tile had no visible
   focus indicator whatever (WCAG 2.4.7), which a screenshot proves and an
   attribute check cannot: the computed outline is there, the pixels are not.
   This rule matches the checked rule's specificity and comes after it, so
   focus wins the outline while the brand BORDER still says which is chosen. */
.design-icon-tile:has(input:focus-visible) {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

.design-icon-glyph {
  width: 58%;
  height: 58%;
  flex: 0 0 auto;
  color: var(--ink);
}

/* The search above the grid: the same field treatment as every text control
   in these sections. Hidden until the script that answers it is running. */
.design-icon-search {
  width: 100%;
  min-width: 0;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  background: var(--card);
  color: var(--ink);
}

.design-icon-search-row {
  min-width: 0;
}

/* THE TOOLS ROW under the search box (task #559, Sam): the count on the left,
   the type filters on the right, on one line.

   IT IS THE WHOLE ROW THAT COMES AND GOES, not the filters inside it, and that
   is deliberate: both of its children belong to an active search and neither has
   anything to say over a screen of recommendations, so one `hidden` on the
   wrapper is one thing for the script to toggle and one thing that can be wrong.
   Same [hidden] guard every toggled block on this page needs, because the author
   `display: flex` here outranks the user-agent rule (the task #506 lesson).

   `align-items: center` rather than baseline: the filters are pills with their
   own padding and the count is one line of hint text, so centring is what makes
   the two read as one row rather than as text with buttons after it. */
.design-icon-tools {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px 12px;
  min-width: 0;
}

.design-icon-tools[hidden] {
  display: none !important;
}

/* The live answer line ("No icons match"), now the LEFT half of that row.
   Present but empty until a search answers; margin comes from the row's gap. */
.design-icon-status {
  min-height: 0;
  margin: 0;
  min-width: 0;
}

.design-icon-status:empty {
  display: none;
}

/* THE FEATURED GRID'S CAPTION AND ITS ONE RULE ARE GONE (task #587, Sam).
   Task #525 captioned the recommendations "Suggested for your business" for an
   account whose Business Type had chosen them, and this rule was the
   `display: none !important` every toggled block on this page needs, because
   the script hid the caption with the grid it described. The caption has been
   retired from src/views/program-design.tsx: the picker is a grid of icons to
   choose from, and it does not need a line of help explaining its order. The
   ORDER is untouched. Recorded rather than deleted silently, on the same
   footing as the open library's retired caption further down. */

/* THE THREE FILTER BUTTONS beside the search box (task #541, Conrad's
   follow-up): Solid, Outline, Colourful. Toggles, so the pressed state is the
   information and `aria-pressed` is what carries it; the styling follows that
   attribute rather than a class, so the two cannot disagree.

   PUSHED RIGHT BY `margin-left: auto` rather than by the row's
   `justify-content` (task #559), because the count beside them collapses to
   nothing when it is empty (.design-icon-status:empty): with space-between, a
   search still on its way would leave the filters sitting on the LEFT and then
   jump them right when the count arrived. An auto margin on the filters
   themselves holds them against the right edge whether or not anything is
   beside them. */
.design-icon-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-left: auto;
}

/* Hidden with the row around it rather than on its own since task #559, but the
   guard stays: the author `display: flex` above outranks the user-agent
   `[hidden]`, and anything that hides this must win. Same lesson as
   .design-icon-grid[hidden] and .design-library-row[hidden]. */
.design-icon-filters[hidden] {
  display: none !important;
}

.design-icon-filter {
  appearance: none;
  font: inherit;
  font-size: 0.8125rem;
  font-weight: 600;
  padding: 4px 12px;
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--muted);
  cursor: pointer;
}

.design-icon-filter:hover {
  border-color: var(--muted);
}

/* NONE are pressed to start, so this is the exception rather than the rule: a
   button a merchant has actually chosen, in the brand colour. */
.design-icon-filter[aria-pressed='true'] {
  border-color: var(--brand);
  color: var(--brand);
  background: color-mix(in srgb, var(--brand) 8%, var(--card));
}

.design-icon-filter:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* Load more, under the library's grid, CENTRED ON A DIVIDER (task #557, Sam).
   It was left-aligned and quiet; it is still quiet, and it now also says
   "that is the end of the list" by sitting on the line that ends it.

   THE LINE IS THIS ROW'S BACKGROUND, and that choice is the whole reason no
   script changed for this. The button is server-rendered `hidden` and shown only
   when the library really has another page, which is not most of the time; a
   divider drawn as a pseudo-element would therefore need its own hiding, in this
   sheet or in public/program-design.js. As a background it needs none: with the
   button gone this row is a zero-pixel-tall box, and a background image one pixel
   tall inside a zero-height box paints nothing at all. A background also cannot
   swallow a click, which a full-width pseudo-element laid across the row could.

   The 10px lift lives here rather than on the button, so the 50% that puts the
   line halfway down measures the button and not the button plus its margin. */
.design-load-more-divider {
  display: flex;
  justify-content: center;
  margin-top: 10px;
  background: linear-gradient(var(--line), var(--line)) 0 50% / 100% 1px no-repeat;
}

/* And the air the empty row would otherwise leave: 10px of margin above plus the
   group's own 10px gap, at the bottom of the Stamp icon group, whenever there is
   no further page to offer. It HIDES, so a browser without :has() keeps that
   whitespace and still never draws a line across nothing. */
.design-load-more-divider:has(> [hidden]) {
  display: none !important;
}

/* Opaque, so the divider is interrupted by the button rather than drawn through
   its label. --card is the panel this row sits on, so the button looks exactly
   as it did: .btn-ghost's `transparent` over white and this white are the same
   pixels. It is what makes the line read as running BEHIND the button. */
.design-icon-load-more {
  background: var(--card);
}

.design-icon-load-more[hidden] {
  display: none !important;
}

/* "Stamp colour does not apply to coloured icons." IS INSIDE THE WELL'S HINT
   NOW (task #587), as its second line, so the paragraph this rule pulled up
   under the control no longer exists and neither does the rule. Two lines of
   one hint need no spacing of their own: they are one flex item, broken by a
   <br />. See ColourWell's `hint` in src/views/colour-well.tsx. */

/* THE OPEN LIBRARY'S CAPTION AND ITS SPACING ARE GONE (task #559 follow-up,
   Sam: "Why is there now two sections visible in a search?"). Task #541 gave
   the second source a captioned block under the bundled catalogue's answers,
   and these two rules were what made it read as a section of its own: one to
   hide it when it had nothing to say, one for the air above it that told a
   merchant a new list was starting.

   Both are retired rather than merely emptied, because there is no second grid
   left to caption: one search box now answers in one grid, and where a mark
   came from lives on its tooltip. Recorded here rather than deleted silently
   because the rules were load bearing while they existed, and a reader coming
   from the task #541 notes should find out what happened to them. */

/* --- controls that only some choices use (task #506 idiom) ------------------- */

/* THERE ARE NONE LEFT ON THIS PAGE (task #523), and that is worth recording
   rather than deleting silently, because the idiom itself is still the house
   rule wherever it IS needed.

   The rule: a control shown by only some choices is hidden by a :has() naming
   the choices that do NOT use it, never shown by one naming the choice that
   does. A browser without :has() then matches nothing and renders the control,
   which is a page that still works, rather than hiding a control it cannot
   reveal.

   The last one gated the background uploader on the 'Background Image' choice.
   Task #523 retired the choice, so the uploader is unconditional and there is
   nothing to gate. Task #519 retired the two before it, the progress bar's
   per-stamp markers and the jigsaw's picture slot. The card-colour override
   picker at .colour-mode is not one of these: it is gated on a radio in its own
   group, not on a retired selector, and it is unchanged.

   What is NOT here any more, and must not come back: .design-background-art
   (the wrapper that rule hid) and .design-asset-remove (the bespoke destructive
   checkbox inside it). The background image's removal is the shared uploader's
   own control now, styled by .image-upload like the account logo's. */

/* --- artwork slots ----------------------------------------------------------- */

/* THE one slot: a label, one line of help and the shared uploader, last in the
   panel. The per-slot <details>, fit radios, focal grid and bespoke removal tick
   are all gone with the controls they held. */
.design-asset {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}

.design-asset-label {
  font-weight: 600;
}

/* THE BACKGROUND BLOCK (task #534): the chooser, and BELOW it how much is laid
   over the picture it chooses.

   It was a wrapping two-column row while the overlay was three one-word radios,
   which fitted the space a bounded drop zone leaves to its right on a desktop.
   Sam's follow-up made it a slider, and a slider wants LENGTH rather than a
   leftover column: every point of precision the merchant has is how many pixels
   one percentage point is worth, so it takes the whole width the panel has at
   every viewport. That also puts the two in the order they are answered, which
   matters more now that the second one only appears once the first is answered.

   So this is a plain column and there is no wrap rule left to reason about, no
   basis to get right and no width at which it changes shape: it is one column at
   1440px and one column at 160px, which is the layout viewport a 320px phone has
   at 200% zoom. 20px rather than the panel's own 16px, because the pair is one
   subject and the gap inside it should not read as the gap between it and the
   Stamp colour block above.

   WHAT MUST NOT COME BACK: `flex-wrap` with a per-item `flex-basis`. An `auto`
   basis is an item's MAX-CONTENT width, and the overlay's hint sentence wants
   about 400px on one line, so the pair wrapped off a desktop column that had
   room for it three times over until both items declared a basis. That whole
   hazard leaves with the row. */
.design-background-row {
  display: flex;
  flex-direction: column;
  gap: 20px;
  min-width: 0;
}

/* --- the overlay slider (task #534, Sam's follow-up) ------------------------- */

/* The field: label, hint, track, and a legend line under the track. Full width
   of the panel, and `hidden` whenever the card has no picture to lay anything
   over - which is a real `hidden` attribute, so the control leaves the tab order
   and the accessibility tree together while its value goes on being submitted.
   `!important` because .design-group sets `display: flex` and a bare `[hidden]`
   would lose to it. */
.design-overlay {
  min-width: 0;
  max-width: 100%;
  gap: 6px;
}

.design-overlay[hidden] {
  display: none !important;
}

/* The label, and the current value on the same line as it. The readout belongs
   HERE rather than under the track for two reasons: the eye is already at the
   label when it wants to know what the control says, and a number that changes
   under a track that is also changing reads as part of the scale. It wraps to
   its own line, under the label, wherever the two will not share one.
   .design-group-label's own bottom margin is dropped: .design-group already
   spaces its children and the two together would double the gap. */
.design-overlay-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 2px 12px;
  min-width: 0;
}

.design-overlay-head > .design-group-label {
  margin-bottom: 0;
}

/* THE TRACK IS THE CONTROL'S ONE PIECE OF ARTWORK, and it says which way is
   which before the merchant has moved anything: dark at the left end, light at
   the right, and a notch down the middle marking Off so the centre is visible as
   well as magnetic.

   THE MIDPOINT IS A MID TONE, AND IT HAS TO BE. The obvious ramp is black to the
   card's own white through the middle to white at the right end, which is what
   this rule said first: it reads correctly in a mockup and it is invisible on the
   page, because --card IS white, so the entire light half of the track is white
   on white inside a hairline border. The merchant is shown a control whose right
   half looks empty. Ending the ramp at a tone the panel can actually contrast
   against is the whole of the fix, so the stops are --ink, a lavender-grey
   midway between the two, and white.

   IT IS NOT A COLOUR PICKER and must not read as one. What stops it is
   everything around it: the anchors under the track say Dark, Off and Light in
   words, the readout beside the label says which and how much, and the card an
   arm's length to the right redraws as the thumb moves. The ramp is the AXIS -
   darker this way, lighter that way - not a swatch strip. A mid grey at the
   centre is the honest midpoint of that axis and does not claim the merchant is
   choosing grey; the centre draws no treatment at all, which is what "Off" says.

   `appearance: none` on both the input and the thumb is what makes any of this
   paintable; the track and thumb rules are then repeated for the WebKit and
   Gecko pseudo-elements, which cannot be combined into one selector list because
   a browser drops the whole rule at the first pseudo-element it does not know. */
.design-overlay-range {
  appearance: none;
  -webkit-appearance: none;
  width: 100%;
  /* TALLER THAN ITS TRACK, ON PURPOSE, AND THIS FIXES THREE THINGS AT ONCE.
     Left to itself the input's border box is exactly the 10px track, and that
     10px was: the whole tap target away from the thumb, so a tap aimed at the
     track to jump to a value simply missed on a phone; and the box the focus
     ring is drawn around, so the ring cut straight through a 20px thumb instead
     of enclosing the control. 24px is a target worth aiming at, the ring now
     clears the thumb at both ends, and the browser centres the 10px track inside
     it, so nothing about the drawing moves. Measured in Chrome. */
  height: 24px;
  margin: 6px 0;
  background: transparent;
  /* A horizontal drag is the control's, a vertical one is the page's. `none`
     would take both, so a merchant scrolling a phone with a finger that happens
     to land on the track would find the page stuck; `pan-y` leaves scrolling
     alone and still stops the browser turning a sideways drag into a swipe. */
  touch-action: pan-y;
  cursor: pointer;
}

.design-overlay-range::-webkit-slider-runnable-track {
  height: 10px;
  border: 1px solid var(--line);
  border-radius: 0;
  background:
    linear-gradient(var(--card), var(--card)) 50% 0 / 2px 100% no-repeat,
    linear-gradient(to right, var(--ink) 0%, #9a94a6 50%, #ffffff 100%);
}

.design-overlay-range::-moz-range-track {
  height: 10px;
  border: 1px solid var(--line);
  border-radius: 0;
  background:
    linear-gradient(var(--card), var(--card)) 50% 0 / 2px 100% no-repeat,
    linear-gradient(to right, var(--ink) 0%, #9a94a6 50%, #ffffff 100%);
}

/* The thumb is a brand-coloured DISC with a white ring around it, so it stays
   visible over both ends of the track: purple on white at the light end, purple
   inside white on black at the dark end.

   ROUND SINCE TASK #557 (Sam). It was an 18-by-20 square block, which was the
   one control on this page that had to be square: everything else on the admin's
   settings surfaces is square because it is a BOX (a panel, a field, a tile),
   and a slider handle is not a box, it is a grip. 20 by 20 with a 50% radius is
   the same handle at the same size, so nothing about the geometry below moves.

   THE CENTRING MARGIN IS MEASURED FROM THE TRACK'S CONTENT BOX, which is the
   one thing to get right here and the one thing that is easy to state wrongly.
   WebKit lays an `appearance: none` thumb out from the top of the track's
   CONTENT box, and `* { box-sizing: border-box }` (top of this sheet) makes the
   10px track 10px INCLUDING its 1px border, so the content box is 8px. The
   formula is therefore (8 - 20) / 2 = -6px, not (10 - 20) / 2. At -5px the thumb
   sat a pixel low: 4px of overhang above the track and 6px below, which is
   visibly lopsided. Verified by decoding a Chrome screenshot pixel by pixel.
   Gecko centres its own thumb and must not be given the margin at all. */
.design-overlay-range::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 20px;
  height: 20px;
  margin-top: -6px;
  border: 2px solid var(--card);
  border-radius: 50%;
  background: var(--brand-fixed);
  box-shadow: 0 0 0 1px var(--ink);
}

.design-overlay-range::-moz-range-thumb {
  width: 20px;
  height: 20px;
  border: 2px solid var(--card);
  border-radius: 50%;
  background: var(--brand-fixed);
  box-shadow: 0 0 0 1px var(--ink);
}

/* The same ring every other control on this page draws (task #501's rule), on
   the input itself rather than on the thumb: the thumb's own outline would be
   clipped by the track in Gecko. */
.design-overlay-range:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* THE THREE ANCHORS UNDER THE TRACK, in EQUAL THIRDS rather than by
   space-between, which is the one thing this row has to get right: "Dark 80%"
   and "Light 80%" are different lengths, so space-between would push "Off" off
   the centre of the track by half the difference and the middle of the control
   would stop being where the middle label says it is. Three equal columns put
   the centred one's centre on the row's centre whatever the words are.

   `min-width: 0` on each so a very narrow column wraps the words rather than
   widening the row: measured with no horizontal overflow down to a 160px layout
   viewport, where each third is about 40px and each label takes two lines. */
.design-overlay-scale {
  display: flex;
  min-width: 0;
  gap: 4px;
  font-size: 0.8125rem;
  color: var(--muted);
}

.design-overlay-scale > span {
  flex: 1 1 0;
  min-width: 0;
  overflow-wrap: break-word;
}

.design-overlay-scale > span:nth-child(2) {
  text-align: center;
}

.design-overlay-scale > span:nth-child(3) {
  text-align: right;
}

/* The one item in this block that would otherwise refuse to shrink. `flex: 0 0
   auto` keeps it beside the label at every ordinary size, and the two lines
   under it are for the size that is not ordinary: a raised minimum font size, or
   Firefox's text-only zoom, in the 90px column a 160px layout viewport leaves.
   Without them "Light 80%" measured 136px in a 90px box, and `overflow-x: clip`
   on <html> means that excess is cut off rather than scrolled to. */
.design-overlay-value {
  flex: 0 0 auto;
  min-width: 0;
  overflow-wrap: break-word;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* The preview of what is already uploaded. Bounded on both axes: a background
   picture is stored at 1032x812 and would otherwise fill the column alone. It
   used to paint itself var(--card); the surface is now the shared transparency
   checkerboard in the image-upload section (task #600), which this rule must
   not declare a background over. */
.design-asset-preview {
  max-width: 100%;
  max-height: 120px;
  align-self: flex-start;
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 4px;
}

/* --- the ready-made background library (task #524) ---------------------------- */

/* The line that says what is chosen. THE BUTTON THAT OPENS THE MODAL LEFT THIS
   ROW IN TASK #557 (Sam): it is inside the uploader now, on the same line as that
   control's own buttons, which is where a merchant looking for another way to
   fill this slot already is. The row that used to hold both of them keeps the
   caption, so what remains is one hint under the picture it describes.

   Still server-rendered `hidden` and revealed by public/program-design.js, so it
   never talks about a library a browser cannot open. The flex row stays: it costs
   one line, it is what centres the caption against anything a later task puts
   beside it, and it is the box the [hidden] rule below is written for. */
.design-library-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 12px;
}

/* AND THE RULE THAT MAKES THAT `hidden` MEAN ANYTHING. `[hidden]` is a
   user-agent `display: none`, so the author `display: flex` above beats it and
   the row would be visible with no script at all: the merchant would meet a
   "Choose from library" button that does nothing, forever. Same lesson as
   .image-upload [hidden] and .design-icon-grid[hidden], learned here a third
   time. Any rule that gives this row a display MUST be paired with this one. */
.design-library-row[hidden] {
  display: none !important;
}

/* Empty until something is chosen, so it must take no space when it is.
   `:empty` + no padding means the box collapses on its own; it is deliberately
   NOT `display: none`, because this is a role="status" live region and a region
   that is not rendered at the instant its text is set is not reliably
   announced. Kept in the tree, taking no room, saying nothing. */
.design-library-chosen:empty {
  margin: 0;
}

/* THE MODAL. The dialog element itself is only a positioner: it carries no
   surface of its own, so the panel inside it is the visible box and the
   platform's centring, top layer and backdrop do the rest.

   The width is the smaller of a comfortable maximum and the viewport, so a
   phone gets a near-full-width sheet and a laptop gets a dialog; the height is
   capped in vh and the panel's middle row scrolls inside it, so at 200% zoom the
   tiles scroll while the heading and the buttons stay put rather than the whole
   page overflowing. How many tiles sit on a row is the grid's own business,
   below. */
.design-library-dialog {
  border: 0;
  padding: 0;
  background: transparent;
  color: var(--ink);
  width: min(940px, 94vw);
  max-height: min(86vh, 900px);
  overflow: visible;
}

.design-library-dialog::backdrop {
  background: rgba(21, 17, 27, 0.62);
}

/* THE SCROLL LOCK, in CSS rather than in script. showModal() makes the page
   behind inert but does not stop it scrolling, and a page that scrolls away
   under a modal is disorienting on a phone. Written with :has so nothing has to
   add and remove a class, and so the lock cannot outlive the dialog: it is true
   exactly while the element carries `open`. A browser without :has simply keeps
   scrolling, which is the old behaviour and not a broken one.

   `overflow-y`, NOT the `overflow` shorthand, because only the vertical axis
   needs locking and declaring the horizontal one is not this rule's business.
   Be clear about what that does and does not buy, since the obvious claim is
   wrong: the root carries `overflow-x: clip` (task #479's full-bleed carousel),
   and CSS says that when one axis is `clip` and the other is neither `visible`
   nor `clip`, the `clip` COMPUTES TO `hidden`. So overflow-x is `hidden` while
   the modal is open whichever form is written here. Measured: `clip`/`visible`
   closed, `hidden`/`hidden` open, back to `clip`/`visible` on close, and no
   horizontal scrollbar appears at any viewport. That is harmless while the page
   behind is inert, and it reverts, which is what matters. */
html:has(.design-library-dialog[open]) {
  overflow-y: hidden;
}

/* THREE ROWS, AND ONLY THE MIDDLE ONE SCROLLS. The panel is a flex column that
   never scrolls itself: head and foot are `flex: 0 0 auto`, the body between
   them takes the leftover height and the scrollbar. That is what puts the
   buttons AT the bottom of the modal rather than over the tiles.

   TWO EARLIER CUTS OF THIS WERE BOTH WRONG, and the pair of failures is the
   reason for the shape:

     * the GRID as the scroller inside a panel of fixed rows read well and broke
       outright at 200% zoom on a 390px phone, because the head then carried a
       heading AND a paragraph of instructions: the two together were taller than
       the panel, the grid collapsed to a sliver, and `overflow: hidden` clipped
       Use this background off the bottom of the dialog;
     * the PANEL as the scroller with a `position: sticky` foot kept the button
       reachable, and paid for it by floating that bar over the artwork the whole
       way down the scroll.

   This shape needs neither trade. The instructions and the note are gone (the
   two named buttons say the same thing), so the head is one short line and the
   body always has room; the foot is in normal flow, so it cannot overlap
   anything. The 20px inset lives on the three children rather than on the panel,
   so the scrolling body runs edge to edge and the foot's rule spans the panel. */
.design-library-panel {
  display: flex;
  flex-direction: column;
  max-height: min(86vh, 900px);
  overflow: hidden;
  background: var(--card);
  border: 1px solid var(--line);
}

.design-library-head {
  flex: 0 0 auto;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  padding: 20px 20px 14px;
}

.design-library-head h2 {
  margin: 0;
  font-size: 1.15rem;
}

.design-library-close {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--ink);
  cursor: pointer;
}

.design-library-close:hover {
  border-color: var(--ink);
}

/* THE SEARCH ROW: the fourth part of the panel, and like the head and the foot
   it is `flex: 0 0 auto` and never a scroller. It costs the body height, which
   is the whole reason task #43bd572 deleted the intro paragraph from here, so
   it is kept to one field and one line of status: no border, no panel, no
   heading. The status line collapses to nothing at all until there is something
   to say, so the ordinary state of this row is exactly one control tall. */
.design-library-search {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 0 20px 12px;
}

/* PAIRED WITH THE display ABOVE, and not optional. The row is server-rendered
   `hidden` and revealed by the script; a bare `display: flex` outranks the user
   agent's `[hidden]` rule, so without this the search box would be on the page
   of every merchant whose script never ran. Same trap, same fix, as
   .design-library-row and .design-icon-grid. */
.design-library-search[hidden] {
  display: none !important;
}

/* The field and the two things that share its trailing edge, on the Reports
   search box's geometry (task #501): both are 38px, so the swap from the
   decorative magnifier to the real clear button moves nothing, and the input's
   trailing padding is one number that text can never run under. The browser's
   own search-cancel button is dropped so there is exactly one clear affordance,
   the accessible one with a focus ring and an announced result. */
.design-library-search-box {
  position: relative;
  display: flex;
}

.design-library-search-box input[type='search'] {
  width: 100%;
  min-width: 0;
  padding: 10px 38px 10px 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  color: var(--ink);
  background: var(--card);
}

.design-library-search-box input[type='search']::-webkit-search-cancel-button {
  display: none;
}

/* A signifier, not a control: aria-hidden in the markup, a span so it takes no
   tab stop, and pointer-events off so a click at the right-hand end of the box
   lands on the input behind and puts the caret there. */
.design-library-search-icon {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 38px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  font-size: 1rem;
  line-height: 1;
  pointer-events: none;
}

.design-library-search-clear {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 38px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  background: none;
  color: var(--muted);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
}

.design-library-search-clear:hover {
  color: var(--ink);
}

.design-library-search-clear:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: -2px;
}

/* Paired with the display above: the clear button is rendered `hidden` and is
   swapped with the magnifier by the script. */
.design-library-search-clear[hidden],
.design-library-search-icon[hidden] {
  display: none !important;
}

/* One line, and no line at all when there is nothing to say. `:empty` cannot be
   used here because the paragraph always holds its two spans, so the collapse
   is driven by the spans' own emptiness instead. */
.design-library-search-status {
  margin: 0;
  min-height: 0;
}

.design-library-search-status > span:empty {
  display: none;
}

/* THE ONE SCROLLER. `min-height: 0` is what lets a flex child shrink below its
   content and so actually take the scrollbar; without it this box would grow to
   fit every tile and push the foot out of the panel. The 4px of vertical inset is
   room for a focused tile's ring (2px wide at 2px offset), which the scroll
   container would otherwise clip along the top and bottom edges. */
.design-library-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 4px 20px;
}

/* FOUR ACROSS ON A DESKTOP, said outright rather than left to auto-fill to
   arrive at from the dialog's width, which is the kind of arithmetic that
   quietly becomes three the day the dialog or the gap changes. The steps down
   are by viewport, which tracks the dialog because it is 94vw until it hits its
   940px cap.

   EVERY TRACK IS `minmax(0, 1fr)`, never a px minimum: a track with a floor
   wider than the space available is exactly how an auto-fill grid overflows its
   container at 200% zoom, and a floor of 0 cannot. At 390px and 200% zoom (a
   195px layout viewport) this is one column and there is no horizontal
   scrollbar. */
.design-library-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px;
}

@media (max-width: 860px) {
  .design-library-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

@media (max-width: 620px) {
  .design-library-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* One across only when two would be too narrow to judge a picture by. 360px is
   below every common phone (390, 393, 412 all keep two), so this is the small
   handset and, more to the point, the 200% zoom case: a 390px phone at 200% is
   a 195px layout viewport, and lands here. */
@media (max-width: 360px) {
  .design-library-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* ONE TILE IS THE PICTURE AND NOTHING ELSE: no padding, no border, no caption,
   so the gallery reads as artwork rather than as a table of cards. The radio is
   real and visually hidden but never display:none, so the group keeps its native
   keyboard behaviour and its checked state is what assistive technology reads.
   Exactly the .design-icon-tile arrangement. */
.design-library-tile {
  display: block;
  position: relative;
  margin: 0;
  padding: 0;
  border: 0;
  background: var(--card);
  cursor: pointer;
  min-width: 0;
}

.design-library-tile input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  padding: 0;
  border: 0;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

/* Hover darkens the picture's OWN hairline rather than giving the tile a border
   of its own. A border that appears under the pointer would both shift the
   artwork and impersonate the selected state, which is the one thing a border
   means here. */
.design-library-tile:hover .design-library-thumb {
  border-color: var(--ink);
}

.design-library-thumb {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 258 / 203;
  object-fit: cover;
  border: 1px solid var(--line);
}

/* THE SELECTED STATE IS THREE THINGS AND ONLY ONE OF THEM IS A COLOUR: the
   chosen tile, and only the chosen tile, gains a purple border, a tick badge
   appears, and the badge carries the word "Chosen". A merchant who cannot tell
   the purple from the grey has the badge's shape and the word; a screen reader
   has the radio's own checked state, which is the real one. The badge is hidden
   rather than merely transparent when unchecked, so it is not in the
   accessibility tree for five tiles at once.

   The border is drawn INSIDE the tile's box (`* { box-sizing: border-box }` at
   the top of this file), so choosing a tile cannot move the grid or its
   neighbours: the frame is taken from the picture, not added around it. */
.design-library-tick {
  display: none;
}

.design-library-tile:has(input:checked) {
  border: 2px solid var(--brand-fixed);
}

/* Pinned to the picture's top corner, inside the border the chosen tile has
   just gained, so it reads as one mark with it. */
.design-library-tile:has(input:checked) .design-library-tick {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  position: absolute;
  top: 6px;
  right: 6px;
  padding: 3px 8px;
  background: var(--brand-fixed);
  color: #ffffff;
  font-size: 0.8rem;
  font-weight: 600;
}

/* FOCUS IS NOT THE SELECTED MARK, and telling them apart is the point of every
   choice here. Selected is a purple border flush against the artwork plus a
   badge; focus is an INK ring drawn clear of the tile at a 2px offset. Different
   colour, different position, and the two are legible together on the tile that
   is both.

   `:has(input:focus-visible)`, not `:focus-within`: the radio inside the label
   takes focus on a mouse click too, so :focus-within ringed a tile that had
   merely been clicked, which is precisely the "focus adds a border" the tiles
   should never show. focus-visible leaves it to the browser to say when a ring
   was earned, which for a keyboard is always. */
.design-library-tile:has(input:focus-visible) {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

/* THE LAST ROW OF THE PANEL, in normal flow: not sticky, not floating, nothing
   passing under it. The body above it is the only thing that scrolls, so this
   bar is always at the bottom of the modal and always has the panel's full
   width for its rule. */
.design-library-foot {
  flex: 0 0 auto;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 12px;
  padding: 14px 20px 20px;
  background: var(--card);
  border-top: 1px solid var(--line);
}

/* COLLAPSED, NOT REMOVED. This is a role="status" live region, and a region
   that is not rendered at the instant its text is set is not reliably
   announced: the change reads as an insertion rather than an update. So it
   keeps its box and gives up its size instead of taking `display: none`.
   `width: 0` with `flex-basis: auto` also stops the empty line claiming a whole
   flex row of its own, which is what the 100% basis below would otherwise cost
   in a 10px row gap under the buttons. */
.design-library-status:empty {
  flex-basis: auto;
  width: 0;
  margin: 0;
  padding: 0;
}

.design-library-status {
  flex-basis: 100%;
  margin: 0;
  color: var(--error);
}

/* --- focus ------------------------------------------------------------------- */

/* Every control this page adds gets the same visible ring the rest of the app
   uses, at the same 2px offset so it is drawn clear of the control's own
   border. Radios and checkboxes are included explicitly: several of them sit
   inside bordered cards, where a browser's own faint default ring is easy to
   lose against the card edge. */
.design-section input[type='radio']:focus-visible,
.design-section input[type='checkbox']:focus-visible,
.design-section input[type='text']:focus-visible,
.design-section input[type='search']:focus-visible,
.design-section input[type='color']:focus-visible,
.design-section input[type='file']:focus-visible,
.design-section select:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* The colour well's own two-tone rings are NOT here (task #530). They moved to
   the shared well block above with the rest of the control, unscoped from this
   panel, so Account > Settings gets them too. They are still three classes and
   still outrank the shared ring above.

   The `input[type='color']` in that shared list is the Colour Override row's
   picker, which is not a well and keeps the ordinary brand ring. */

/* The ring belongs on the card as well as on the small radio inside it, so a
   keyboard user can see which option they are on from across the box. On the
   CHECKED card the checked echo outranks this rule for the card's one
   outline, and there the radio's own ring above is the visible mark: unlike
   the icon tiles, these radios are ordinary visible controls, so their ring
   is really drawn. That is the whole difference, and it is why the tiles need
   a rule of their own (see .design-icon-tile:has(input:focus-visible)). */
.design-choice-card:focus-within {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* --- the preview column ------------------------------------------------------ */

/* There is no preview CSS here (task #504 final pass), and that is the point:
   the designer's preview is THE shared .prog-preview-rail holding THE shared
   .phone-frame, exactly as the Program's Overview and its Offer page hold it,
   so the three previews cannot drift apart by a stylesheet edit. .prog-layout
   stacks all three pages at 980px and stops the rail sticking there.

   WHAT FOLLOWS IS NOT AN EXCEPTION TO THAT (task #612, Sam, replacing #609).
   Nothing below restyles the rail, the frame or the card: it moves WHERE the
   one rail is read on a narrow screen, and paints the room it is read in. */

/* --- THE PULL-UP PREVIEW (task #612, Sam) ------------------------------------

   THE ONE BLOCK IN THIS STYLESHEET SHARED BY THE ADMIN AND A PUBLIC PAGE. Both
   card designers, /programs/:code/design and /create-card, stack their controls
   above their preview on a phone and so leave the card at the foot of a form
   several screens long. Both now read it through this drawer instead, so it is
   declared once and each page only says which element goes in it.

   IT REPLACES TASK #609's corner Preview button and full-screen lilac sheet,
   which are gone. Reverting to them is reverting this commit.

   EVERY LENGTH IS IN SOURCE PIXELS OF THE PHONE FRAME PNG, which is 748 wide,
   through --pullup-u. That is what makes the drawer proportional rather than
   merely small: the closed sliver is 170 of those units, which is exactly the
   85px Sam measured at the frame's own 374px ceiling and 85 * width / 374 at
   every width below it. The black cover is 145 units, the frame's status area
   to the pixel, because 145 is where the display aperture starts (PHONE_FRAME
   in src/views/card.tsx). So the 25 units left over below it are the card's own
   colour: 12.5px at 374, which is the ten Sam asked to see.

   THE CLASS IS THE GATE, AND THERE IS NO MEDIA QUERY HERE. public/preview-pullup.js
   adds `preview-pullup-on` to <body> only while its own matchMedia says the
   drawer applies, so the script and the stylesheet cannot end up true about
   different widths: there is one answer to that question and the script holds
   it. A browser that never runs the file never gets the class and keeps the
   plain stacked page, with the preview where the server put it. */
body.preview-pullup-on {
  /* The width the frame actually renders at inside the drawer: its own ceiling,
     or the window less a margin either side, whichever is smaller. */
  --pullup-w: min(374px, 100vw - 32px);
  /* One source pixel of the frame, and the unit of everything below. */
  --pullup-u: calc(var(--pullup-w) / 748);

  /* SAM'S THREE KNOBS for the cover, and the whole of what tunes it. Retuning
     is an edit here and nowhere else; both designers follow.

     THEY ARE TUNED BY EYE AND ARE NOT THE SAME KIND OF NUMBER. The inset is a
     percentage, which in `left`/`right` resolves against the containing block's
     WIDTH: the panel, which is exactly the preview's width, so 6% is honestly
     "6% of the phone". The drop is a LENGTH, and must stay one, because a
     percentage in `top` resolves against the containing block's HEIGHT and the
     panel is the WHOLE phone (1539 units against 748): the same number written
     as a percentage there is a completely different distance.

     Neither scales with --pullup-u, unlike everything else in this block, so
     the drop is a fixed 15px at every width. That is deliberate while the shape
     is being tuned; it does mean the cover is shorter, proportionally, on a
     small screen. */
  --pullup-cover-inset: 6%;
  --pullup-cover-drop: 15px;

  /* Black, which is the bezel's own colour, so the cover reads as part of the
     phone rather than a box laid on it. It was briefly #c00 while Sam tuned the
     shape, because a black cover on a black bezel has invisible edges; that is
     what this token is for, and it is back where it belongs. */
  --pullup-cover-bg: #000;
}

/* Room to scroll past the closed drawer, taken on the FOOTER rather than on the
   body: the extra height then carries the footer's own background instead of
   opening a strip of page colour underneath it. Both designers render one. */
body.preview-pullup-on .footer {
  padding-bottom: calc(var(--pullup-u) * 170 + 24px);
}

/* THE DRAWER, and its HEIGHT IS THE WHOLE ANIMATION. The panel inside is
   anchored to the drawer's top, so growing the box downward from a bottom edge
   pinned to the foot of the window slides the phone up and reveals more of it.
   Nothing is transformed and nothing is translated, so there is no layer to fall
   out of step with the finger during a drag, and no stacking context beyond the
   one z-index below.

   Sized without a viewport unit for the height, for the reason task #605
   documents: on a phone the viewport height is precisely what scrolling
   changes. This box is measured from the bottom edge, which does not move.

   z-index 10 is the app's layer map read off the two places that already state
   it: everything inside the content column is at most 3 and the sticky message
   bar is 5, so 10 clears the page; the mobile nav's drawer is 20 and must go on
   covering this, as it covered task #609's sheet.

   `pointer-events: none` so the page goes on working everywhere the phone is
   not, and only the panel takes the pointer back. */
.preview-pullup {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--pullup-h, calc(var(--pullup-u) * 170));
  z-index: 10;
  pointer-events: none;
  transition: height 220ms ease;
}

/* A drag IS the height and the fade, so neither must be chased by a transition
   as well: the script writes both on every pointer move. */
.preview-pullup.is-dragging {
  transition: none;
}

.preview-pullup.is-dragging .preview-pullup-grip {
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  .preview-pullup {
    transition: none;
  }
}

/* Exactly the frame's width, centred, so the handle above the phone and the
   phone below it are the same box and no rule has to line them up. */
/* THE WHOLE DRAWER IS THE DRAG SURFACE (Sam), not just the cover: a drag
   anywhere on the phone opens and closes it, so the cover only has to be big
   enough to READ, not big enough to hit. That is what retires the 44px worry
   about a cover that is 40.8px tall at a 320px phone: the target is this box,
   which is the whole visible sliver and, once open, the whole card.

   `touch-action: none` therefore belongs HERE rather than on the cover.
   Without it the browser claims the vertical gesture for scrolling and the
   pointermove events never reach the drawer at all. It is set on the panel and
   not on the container, so it only covers the phone itself: the container is
   `pointer-events: none` and the page beside the phone still scrolls. */
.preview-pullup-panel {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  width: var(--pullup-w);
  margin-inline: auto;
  pointer-events: auto;
  touch-action: none;
}

/* THE ISOLATION, and it is load-bearing. .phone-frame-img is z-index 2 inside a
   .phone-frame that sets no z-index of its own, so .phone-frame is NOT a
   stacking context and that 2 would compete with the handle's 1 directly and
   win: the bezel would paint over the words. That is exactly the task #609
   review bug, and this is its cure. Outbidding it from the handle instead would
   tie the handle's number to the shared card renderer, and any future layer
   inside the card would climb back over it. */
.preview-pullup-slot {
  position: relative;
  isolation: isolate;
}

/* The moved element brings its own page's spacing with it: the admin rail, or
   /create-card's full-bleed white preview block. The panel is already exactly
   the frame's width, so both are flattened and the frame fills it. Scoped to
   the slot, so everything comes back as it was when the drawer gives it up.

   TWO CLASSES DEEP, AND THAT IS THE POINT, not tidiness. `.cc-stage` carries
   `padding: 24px ...` and a white background in its own phone media query, and
   a media query grants NO extra specificity: a single-class reset here would
   tie with it and lose on source order alone, because that block is written
   1400 lines further down. It measured as a real defect, not a hypothetical:
   the frame sat 24px below the drawer's top, so the black cover met the bezel
   a quarter of the way down and no card colour showed at all. Anything added
   here must clear the same bar. */
.preview-pullup .preview-pullup-slot > * {
  margin: 0;
  padding: 0;
  background: none;
  max-width: none;
  width: auto;
}

.preview-pullup .preview-pullup-slot .cc-stage-inner {
  position: static;
}

/* The line under /create-card's preview explains the card to someone reading
   the page. The drawer stops ten pixels below the card's artwork, so it could
   never be read there anyway. */
.preview-pullup .preview-pullup-slot .cc-stage-note {
  display: none;
}

/* The panel is already the frame's width, so the frame simply fills it. Three
   classes, for the same reason as above: `.cc-stage .phone-frame` sets a margin
   of its own further down the sheet. */
.preview-pullup .preview-pullup-slot .phone-frame {
  margin: 0 auto;
  max-width: none;
  width: 100%;
}

/* THE HANDLE: the black cover over the phone's status area, carrying Sam's
   words. Its box is the frame's own geometry, so it sits on the status area
   rather than near it: in five source pixels from each side, which is where the
   PNG's silver rim ends and its black body begins, 145 units tall, and rounded
   at the top by 101 units, the corner radius measured off the PNG's own alpha.
   The bezel underneath is pure black, so the cover reads as part of the phone
   rather than as a box laid over it.

   A real <button>, so it is focusable, operable from a keyboard and announced
   with its state; the script keeps aria-expanded on it. */
.preview-pullup-grip {
  position: absolute;
  /* NESTED INSIDE THE PHONE, not laid over the whole of it (task #612 review).
     The cover used to be the status area exactly: full device width, from the
     very top edge down to the aperture. Against a bezel that is itself pure
     black that made one large black slab of the tab and the phone together,
     which is what Sam saw as "too large".

     Both knobs pull it IN from where the phone's own edges are, so the bezel
     reads as the outline and the cover as a tab sitting on it. THE BOTTOM EDGE
     DOES NOT MOVE: the drop is added to `top` and taken straight back off
     `height`, so top + height is still 145 units, still exactly the top of the
     display aperture, and the card colour below it is untouched. That identity
     is why `height` subtracts the drop rather than naming its own number: the
     two can never be set inconsistently, whatever the drop becomes. */
  top: var(--pullup-cover-drop);
  left: var(--pullup-cover-inset);
  right: var(--pullup-cover-inset);
  z-index: 1;
  height: calc(var(--pullup-u) * 145 - var(--pullup-cover-drop));
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  border-radius: calc(var(--pullup-u) * 101) calc(var(--pullup-u) * 101) 0 0;
  background: var(--pullup-cover-bg);
  color: #fff;
  cursor: pointer;
  /* IT FADES OUT AS THE DRAWER OPENS (Sam), so the phone's own status area shows
     through exactly as the frame artwork draws it, and fades back in as the
     drawer slides down.

     --pullup-open is the drawer's progress, 0 shut to 1 open, written by
     public/preview-pullup.js on every frame of a drag as well as on open and
     close: the black therefore tracks the FINGER, not just the end state. The
     fallback 0 keeps the cover solid before the script has ever written it.

     OPACITY, NOT visibility OR display: the button has to stay exactly where it
     is and stay pressable, because a tap on the top bar closes the drawer and
     that must go on working when the bar cannot be seen. */
  opacity: calc(1 - var(--pullup-open, 0));
  transition: opacity 220ms ease;
  /* Lifts the drawer off the page it covers. Since the review this is no longer
     the drawer's own top edge (the phone's bezel is, above it), so the shadow
     reads as the tab sitting proud of the phone as well. It is soft enough at
     30% that the silver rim it now falls across still reads as silver: measured
     at 149 against the 204 it starts from, which is nowhere near the black
     around it. */
  box-shadow: 0 -4px 16px rgba(21, 17, 27, 0.3);
}

/* Inside the handle, because an outline drawn outside it would sit over the
   page rather than over the drawer, and on the smallest screen it would be
   clipped by the window. */
.preview-pullup-grip:focus-visible {
  outline: 2px solid #fff;
  outline-offset: -6px;
  /* BACK TO FULL STRENGTH FOR A KEYBOARD USER, whatever the drawer's progress.
     The cover fades to nothing once open, and an outline drawn on a transparent
     button would be an invisible focus ring, which is no focus ring at all.
     Only :focus-visible, so a pointer user never sees the black come back. */
  opacity: 1;
}

/* The post-it title's handwritten face (task #487), the one other place in the
   product it is used, at the one weight the font request loads. Sized in the
   same frame units as the box around it, so the words hold their place on the
   status area at every width. */
.preview-pullup-grip-label {
  font-family: var(--font-note-title);
  font-weight: 600;
  font-size: calc(var(--pullup-u) * 52);
  line-height: 1;
}

/* THE HAND-DRAWN ARROW beside the words (Sam). Sized and spaced in the same
   frame units as the label it sits with, so the pair holds its proportions at
   every width, and drawn in currentColor so it fades with the cover rather than
   surviving it. `stroke` and not `fill`: it is a line drawing, and round caps
   and joins are what stop it reading as a geometric UI glyph. */
.preview-pullup-grip-arrow {
  width: calc(var(--pullup-u) * 46);
  height: calc(var(--pullup-u) * 46);
  margin-inline-start: calc(var(--pullup-u) * 14);
  /* Nudged up to sit against the handwritten face's optical centre rather than
     its box, which Caveat leaves low. */
  margin-block-start: calc(var(--pullup-u) * -8);
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* FLIPPED, THEN TURNED A QUARTER CLOCKWISE (Sam, in that order of asking).
     Done here rather than by rewriting the path: one declaration on the shared
     rule turns it on every surface at once, and the drawn path stays the drawn
     path, so a later tweak to the sketch does not have to be redrawn twice.

     THE ORDER IS THE WHOLE OF THE CORRECTNESS, and it reads backwards. CSS
     applies the RIGHTMOST function to the element first, so this mirrors the
     sketch and THEN turns the mirrored result. Written the other way round,
     `scaleX(-1) rotate(90deg)`, the turn would happen inside the mirrored
     coordinate space and come out anticlockwise on screen: the same two
     functions, the opposite picture. */
  transform: rotate(90deg) scaleX(-1);
}

/* =========================================================================
   Support mode and the internal admin panel (task #536)
   ========================================================================= */

/* The support-mode banner. It is injected as the FIRST child of <body>, above
   the app bar, by src/support-mode.ts, and it is an ordinary block in the
   normal flow: not fixed, not sticky, not an overlay. That choice is the whole
   of why it cannot break anything. It pushes the page down instead of covering
   it, so the app bar keeps its own stacking and its burger overlay, the page
   header strip keeps its full-bleed band, the tab rows keep their scroller,
   and no form control is ever hidden behind it at any width.

   The colour is a warning amber rather than LoyalPack purple, on purpose: the
   whole point of the bar is that this page does NOT look like the product
   behaving normally. It is never shown to a merchant. */
.support-banner {
  background: #7a3d00;
  color: #ffffff;
  font-size: 0.875rem;
  line-height: 1.4;
}

.support-banner-inner {
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 0.5rem 1rem;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 0.75rem;
}

.support-banner-tag {
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 0.75rem;
  background: #ffffff;
  color: #7a3d00;
  padding: 0.125rem 0.5rem;
}

/* The message takes whatever room is left and wraps; the actions never do. On
   a phone the text simply runs onto a second line above the buttons. */
.support-banner-text {
  flex: 1 1 16rem;
}

.support-banner-actions {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  flex-wrap: wrap;
}

/* The banner's own links and button, scoped so nothing here reaches the app. */
.support-banner a {
  color: #ffffff;
  text-decoration: underline;
}

.support-banner button {
  font: inherit;
  background: #ffffff;
  color: #7a3d00;
  border: 0;
  padding: 0.25rem 0.75rem;
  cursor: pointer;
}

.support-banner button:hover {
  background: var(--bg);
}

.support-banner :focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 2px;
}

/* --- the internal panel ---------------------------------------------------

   Deliberately minimal. It reuses .card, .btn and the shared page header, and
   adds only what a dense internal table genuinely needs: a horizontal scroller
   so a wide table never widens the page itself, and right-aligned numbers so a
   column of counts can be compared down its edge. */

.admin-table-scroll {
  overflow-x: auto;
}

.admin-table {
  border-collapse: collapse;
  width: 100%;
  font-size: 0.875rem;
}

.admin-table th,
.admin-table td {
  text-align: left;
  padding: 0.375rem 0.75rem 0.375rem 0;
  border-bottom: 1px solid var(--line);
  vertical-align: top;
  white-space: nowrap;
}

.admin-table th {
  font-weight: 600;
}

.admin-table .num {
  text-align: right;
}

.admin-search {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.admin-search label {
  flex-basis: 100%;
}

.admin-search input[type='search'] {
  flex: 1 1 16rem;
}

.admin-pager {
  display: flex;
  align-items: center;
  gap: 1rem;
}

/* A two-column definition list of facts about one account. Falls to a single
   stacked column on a phone, where two columns would leave neither readable. */
.admin-facts {
  display: grid;
  grid-template-columns: minmax(0, 12rem) minmax(0, 1fr);
  gap: 0.25rem 1rem;
  margin: 0;
}

.admin-facts dt {
  font-weight: 600;
}

.admin-facts dd {
  margin: 0;
}

@media (max-width: 720px) {
  .admin-facts {
    grid-template-columns: minmax(0, 1fr);
  }

  .admin-facts dd {
    margin-bottom: 0.5rem;
  }
}

/* The internal panel's one warning note (task #536): what an account drops back
   to when a complimentary grant ends. Amber rather than red, because it is a
   consequence to read before choosing an expiry, not an error that has
   happened. Scoped to the panel; nothing merchant-facing uses it. */
.admin-warning {
  border-left: 3px solid #7a3d00;
  background: #fdf6ef;
  padding: 0.5rem 0.75rem;
}

/* The panel at the top of Account > Security when a superadmin is setting
   somebody else's password (task #780 follow-up).

   It is a `.card`, so it inherits the surface every other block on that page
   uses, plus one heavy amber edge. That edge is the whole of the styling and it
   is doing a real job: this page has meant "your own credentials" to every
   operator who has ever used it on their own account, so the one thing it must
   never look like is an ordinary Security tab. Amber rather than red, because
   nothing has gone wrong; this is a warning to read, not an error. */
.support-security-notice {
  border-left: 4px solid #7a3d00;
}

.support-security-notice h2 {
  margin-top: 0;
}

/* --- protective controls (task #780) --------------------------------------

   Three rules for the three things the levers that take capability AWAY need
   that the rest of the panel did not: a marker in the account list, a
   confirmation tick that reads as one line rather than as a stray box, and a
   separator between the people on an account so two adjacent forms are visibly
   two forms. Nothing merchant-facing uses any of them.

   The suspension marker is red because it is the one state in this panel that
   means an account has been stopped, and it is a `strong` so the word carries
   without the colour (a colour alone is not a status). */
.admin-flag {
  color: #a3161b;
  text-transform: uppercase;
  font-size: 0.75rem;
  letter-spacing: 0.04em;
  white-space: nowrap;
}

/* The label wraps the checkbox, so the whole sentence is the hit target and
   `align-items: baseline` keeps the box on the first line's text rather than
   floating in the middle of a two-line sentence. */
.admin-confirm {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  font-weight: 400;
}

.admin-confirm input[type='checkbox'] {
  flex: none;
}

/* One person, one block. The rule above each is what stops "Require a password
   reset" under one address reading as though it belonged to the next. */
.admin-person + .admin-person {
  border-top: 1px solid var(--line, #e6e3ee);
  margin-top: 1.25rem;
  padding-top: 1.25rem;
}

/* --- the public card designer (task #572) ---------------------------------

   /create-card: a standalone conversion page where somebody builds their own
   loyalty card before they have an account. Everything here is prefixed `cc-`
   and reaches nothing else, with ONE deliberate exception in both directions:
   the card face is the product's own `.wallet-pass` tree, so the preview is the
   real card rather than a drawing of one, and the two rules further down that
   name `.wallet-pass` only give it a container to size itself in and swap the
   flexbox discs for the chosen mark.

   THE PAGE'S LIVE VALUES ARE THREE CUSTOM PROPERTIES ON `.cc-page`:
   --pass-bg and --pass-ink (which the card's own stylesheet rules already read)
   and --cc-pack-ink, which the launch pack reads. One write repaints the card
   and all three pack pieces together, which is what makes a colour change feel
   instant rather than like six separate updates. See public/create-card.js.

   Mobile first: everything is one column until 900px, where the editor and the
   preview split and the preview becomes sticky. Nothing has a fixed pixel
   width, so there is no horizontal overflow at any width. */

/* NO MEASURE OF ITS OWN. The designer keeps the 960px public column every other
   public surface has (--content-max on :root): 960 less the container's gutters
   splits into a 536px editor and a 360px preview, which is room enough, and the
   wider 1120px token belongs to the signed-in shell and is read in exactly one
   place (test/content-grid.test.ts holds that). A public page reaching for it
   would have made the app's measure two measures. */

/* --- the page's full-bleed bands -------------------------------------------

   Four stacked strips: the lilac title band, the ink stage bar, the working
   area and the ink footer. Each spans the viewport while its contents stay on
   the page's own column, and they meet with no gap at all, which is the whole
   look Sam asked for.

   HOW THE BLEED WORKS, AND WHY NOT 100vw. The band is an ordinary block inside
   the centred column, so its width is auto: negative inline margins of exactly
   the distance from column edge to viewport edge make it grow to the viewport
   and no further. 50% is half the column and 50vw half the viewport, so the
   pair is correct at every width and zoom without naming a viewport width at
   all. That matters: `.dash-focus` is the ONE 100vw element in this stylesheet
   and test/content-grid.test.ts holds it to that, because a 100vw box overflows
   by the width of a classic scrollbar. The padding then puts the contents back
   on the column, so nothing inside a band has to know it is in one. */
.cc-band {
  margin-inline: calc(50% - 50vw);
  padding-inline: calc(50vw - 50%);
}

/* THE LEFT FIELD'S TWO TONES, diagonal from top left to a darker bottom right
   (Sam). Both are lilac rather than grey, so the working column reads as part
   of the same page as the title band above it; the darker end is a step deeper
   than --bg and still light enough that the ink text and the white controls
   standing on it keep the contrast they have everywhere else. */
.cc-page {
  --cc-field-top: #f6f4fb;
  --cc-field-bottom: #ddd8ee;
  /* THE DOT THAT IS SCATTERED OVER THAT FIELD (task #580): --brand-fixed at
     14%, which is the one value the dashboard's pattern cannot be reused for.
     Its dot is white, because that band is dark ink; this field is a pale
     lilac, and white on it would be no pattern at all. Every other number in
     the pattern IS the dashboard's, and test/create-card.test.ts reads them
     off .dash-focus::before rather than trusting this comment.

     Faint enough to be texture and no more: over the darkest corner of the
     field a fully inked dot lands on #cac2e7, where --strip-muted (this page's
     secondary ink on this field) still measures 5.49:1 and --ink 10.98:1. That
     is the worst case a glyph can land in, it clears WCAG AA at normal size,
     and the same test pins it against src/contrast.ts. */
  --cc-dot-ink: rgba(82, 56, 191, 0.14);
}

/* NO VERTICAL PADDING ON THE COLUMN, so the bands stack edge to edge and the
   first one starts under the nav rather than 30px below it. Scoped by :has to
   this page's own root: `.container` is shared with every public surface and
   none of the others is built out of bands. The horizontal padding stays,
   because it is the gutter the bands re-centre their contents onto. */
.container:has(> .cc-page) {
  padding-block: 0;
}

/* AND THE SCROLLBAR'S OWN WIDTH IS CLIPPED HERE, not at the root.
   `50vw` is half the VIEWPORT INCLUDING a classic scrollbar, so on a desktop
   browser that draws one the bands reach about 8px past each visible edge. That
   overhang is invisible either way, but it is real: measured live, the page
   could be dragged 8px sideways, which is exactly the horizontal scroll this
   page must not have. `html { overflow-x: clip }` further up does not stop it,
   because the box that ends up scrolling is below the root.
   Clipping on BODY does, and body is the right element: it is exactly the
   visible viewport wide, so the cut lands on the overhang and nowhere near the
   bands themselves, which stay edge to edge. Scoped by :has, because no other
   page in this stylesheet bleeds from inside the column. */
body:has(.cc-page) {
  overflow-x: clip;
}

/* --- the title band ------------------------------------------------------- */

/* The band colour is .pagehead's, shared with it further up this sheet (brand
   purple since task #758), and so are its two inks since task #760. This adds
   only what a full-width band on a public page needs. */
.cc-hero {
  text-align: center;
  padding-block: 44px 40px;
  color: var(--band-ink);
}

.cc-hero h1 {
  margin: 0;
  font-size: 2.3rem;
}

.cc-hero .lede {
  margin: 12px auto 0;
  max-width: 34rem;
  color: var(--band-ink-muted);
}

@media (max-width: 720px) {
  .cc-hero {
    padding-block: 30px 26px;
  }

  .cc-hero h1 {
    font-size: 1.8rem;
  }
}

/* --- the progress line ----------------------------------------------------

   Four outlined circles on one rule, and the state of the build at a glance.

   HOW THE RULE STOPS AT THE CIRCLES, which was the whole ask and has one
   obvious wrong answer. The obvious answer draws a single line across the row
   and hides it behind opaque discs, which only works while the page behind the
   discs is exactly the flat colour they were filled with, and stops working the
   moment anything sits behind it. So no line is ever drawn THROUGH a circle:
   each step after the first carries its own segment, and the two ends of that
   segment are computed from the circle's own diameter, so the geometry follows
   the size instead of being maintained beside it.

   The maths, with W the column width and D the disc diameter. Every step is one
   equal grid column, so a step's own box is [0, W] and the step before it is
   [-W, 0]. Both discs are centred, so the previous one's right edge is at
   -W/2 + D/2 and this one's left edge is at W/2 - D/2. Those are exactly the
   two `left`/`right` offsets below (percentages on an absolutely positioned box
   resolve against its containing block, which is the step), plus a small gap so
   the rule stops just short of the ink rather than touching it.

   The discs are filled as well. That costs nothing, and it means a rounding
   error at some zoom level cannot show a hairline through a number. */

/* --- the stage bar -------------------------------------------------------- */

/* AN INK STRIP directly under the lilac band and directly over the working
   area, with no gap on either side (Sam). Every one of the three step states
   is re-stated for ink below, because a line drawn for a pale page is not
   legible on a dark one: the tokens are inverted rather than reused. */
/* margin-BLOCK, never `margin: 0`: the shorthand would reset the inline
   margins .cc-band sets and quietly un-bleed the strip, which is exactly what
   it did the first time. Only the vertical gap is this rule's business. */
.cc-progress {
  margin-block: 0;
  padding-block: 22px 20px;
  background: var(--ink);
  color: #fff;
  /* Two lighter inks, so the states have somewhere to be told apart on a dark
     band: one for what has happened, one for what has not. */
  --cc-step-on: #fff;
  --cc-step-off: rgba(255, 255, 255, 0.42);
}

@media (max-width: 720px) {
  .cc-progress {
    padding-block: 16px 14px;
  }
}

.cc-steps {
  --cc-step-disc: 34px;
  --cc-step-gap: 7px;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  margin: 0;
  padding: 0;
  list-style: none;
}

.cc-step {
  position: relative;
  min-width: 0;
  text-align: center;
}

.cc-step + .cc-step::before {
  content: '';
  position: absolute;
  left: calc(-50% + (var(--cc-step-disc) / 2) + var(--cc-step-gap));
  right: calc(50% + (var(--cc-step-disc) / 2) + var(--cc-step-gap));
  top: calc(var(--cc-step-disc) / 2);
  height: 2px;
  margin-top: -1px;
  border-radius: 2px;
  background: var(--cc-step-off);
}

/* A segment leading into a step the visitor has reached is filled, in the same
   purple the discs at either end of it wear. Read left to right that draws the
   road behind them and leaves the road ahead pale. */
.cc-step[data-cc-state='current']::before,
.cc-step[data-cc-state='complete']::before {
  background: var(--brand-fixed);
}

/* And a segment into a LOCKED step is dashed, so the road ahead is not merely
   paler than the road behind: it is drawn differently. */
.cc-step[data-cc-state='locked']::before {
  background: none;
  border-top: 2px dashed var(--cc-step-off);
  height: 0;
}

.cc-step-node {
  display: grid;
  justify-items: center;
  gap: 7px;
  padding: 0 2px;
  color: var(--cc-step-off);
  text-decoration: none;
  font-weight: 600;
  font-size: 0.85rem;
}

.cc-step-node:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 3px;
  border-radius: 8px;
}

/* THREE STATES, AND EVERY ONE OF THEM IS A SHAPE AS WELL AS A COLOUR.

     locked    a DASHED outline. Not startable, and drawn like it.
     current   a SOLID brand ring with a halo around it: the active identifier,
               and the one thing on this line a visitor needs at a glance.
     complete  a FILLED disc whose number has given way to a tick.

   Colour alone would leave all three the same object in three shades, which is
   no use to anybody who cannot separate the shades. The words in the sr-only
   span beside each node say the same three facts. */
.cc-step-disc {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--cc-step-disc);
  height: var(--cc-step-disc);
  border-radius: 50%;
  border: 2px dashed var(--cc-step-off);
  /* The fill that keeps the rule out of the circle even if the geometry above
     is ever a pixel out. On ink, that fill IS the ink. */
  background: var(--ink);
  color: var(--cc-step-off);
  font-variant-numeric: tabular-nums;
}

.cc-step-tick {
  display: none;
  width: 60%;
  height: 60%;
}

.cc-step-label {
  line-height: 1.2;
  overflow-wrap: anywhere;
}

/* WHITE LABELS (Sam). Every step's label is white; the three states are told
   apart by the disc beside it and by weight, never by fading the words to the
   point where a locked step's name cannot be read. */
.cc-step-label {
  color: #fff;
}

.cc-step[data-cc-state='current'] .cc-step-node,
.cc-step[data-cc-state='complete'] .cc-step-node {
  color: #fff;
}

/* THE ACTIVE IDENTIFIER, IN LOYALPACK PURPLE (Sam: "in the previous version the
   active identifier was purple; now it is not. Use the LoyalPack purple again").
   It went white when the bar went to ink, which made it one more white thing on
   a line of white things.

   THE NUMBER STAYS WHITE, and that is the part worth being deliberate about:
   --brand-fixed on --ink is about 2.6:1, which is fine for a ring somebody is
   locating and not fine for a digit somebody is reading. So the brand colour
   draws the RING and the halo, which is what the eye finds, and the numeral
   inside it stays white at 16:1. The halo is heavier than the white one it
   replaces for the same reason: purple needs more of itself on ink to register.
   The label beside it is white and bold, as every label on this bar is. */
.cc-step[data-cc-state='current'] .cc-step-disc {
  border-style: solid;
  border-color: var(--brand-fixed);
  color: #fff;
  box-shadow: 0 0 0 5px color-mix(in srgb, var(--brand-fixed) 45%, transparent);
}

.cc-step[data-cc-state='current'] .cc-step-label {
  font-weight: 700;
}

/* The completion mark: the disc fills and the number gives way to a tick, so a
   finished step is told by a shape and not by colour alone. Filled in the BRAND
   PURPLE now that the current step wears it too, with a white tick at 8.6:1 on
   it; the two states are then one family, told apart by fill against ring
   rather than by two unrelated colours. */
.cc-step[data-cc-state='complete'] .cc-step-disc {
  border-style: solid;
  border-color: var(--brand-fixed);
  background: var(--brand-fixed);
  color: #fff;
}

.cc-step[data-cc-state='complete'] .cc-step-number {
  display: none;
}

.cc-step[data-cc-state='complete'] .cc-step-tick {
  display: block;
}

/* A LOCKED STAGE IS NOT A PLACE YOU CAN GO, so it does not offer itself as one:
   no pointer, no hover, and the whole node set back. The click is stopped in
   the script as well, so this is the appearance of a rule rather than the rule
   itself. */
.cc-step-node[aria-disabled='true'] {
  cursor: default;
  opacity: 0.72;
}

/* NOTHING UNDER A NODE (task #581, Sam). There was an Edit link below every
   finished step and a save line below the row, and between them they made this
   bar two rows of small type that had to be read to be dismissed. Both are
   gone: the node is already the way back to a finished stage, and the save line
   now sits at the foot of the working area. What is left is a marker, a label
   and a state, which is all this strip was ever for. */

@media (max-width: 460px) {
  .cc-steps {
    --cc-step-disc: 28px;
  }

  .cc-step-node {
    font-size: 0.72rem;
  }
}

/* --- the working area ------------------------------------------------------

   ONE BAND FROM THE STAGE BAR TO THE FOOTER, with nothing between any of them,
   carrying both the editor/preview split and the Generate showcase.

   TWO SECTIONS DEFINED BY A GRADIENT (Sam's own second option, and the right
   one). The obvious alternative is a hard seam at the column boundary, but that
   boundary is a fixed distance from the CENTRE of a centred column, so pinning
   a background stop to it means arithmetic over the viewport width that has to
   be redone at every breakpoint and is wrong the moment a track changes. A
   blend does the same job honestly: the working side is the page's own pale
   grey, the preview side lifts to white, and the change happens across the gap
   between them. The seam sits between 56% and 59% of the viewport at every
   width from 900px up, which is what the stops below are placed around.

   Below 900px the two sections are stacked rather than side by side, so the
   gradient turns with them. */
/* THE RIGHT-HAND FIELD is the band's own background, and it is what the left
   field's hard edge is cut against. The preview column sits on it.

   NO VERTICAL PADDING OF ITS OWN (Sam). Padding here is INSIDE the painted
   band, so it read as a gap above and below the content while the colour ran
   on past it; the breathing room belongs to the two COLUMNS, which take 50px
   each, top and bottom. The band itself now touches the stage bar and the
   footer with nothing in between, which is what it was always meant to do. */
.cc-content {
  background: var(--card);
}

/* THE BREATHING ROOM, on the columns rather than on the band. Both take the
   same 50px so the two fields start and end level with each other, and neither
   can leave a strip of unpainted page against the bar above or below. */
.cc-editor,
.cc-stage,
.cc-generate {
  padding-block: 50px;
}

/* WHETHER THE DRAFT REACHED THE SERVER (task #578), and since task #581 it is
   here rather than on the stage bar: a background fact about the card, at the
   foot of the area the card is built in, instead of a second row of small type
   inside the strip that says which step you are on.

   IT IS NOT DRAWN WHILE THE NEWS IS GOOD (Sam's review of #581). Saving and
   saved are both the expected answer, and a lone word sitting in the gap
   between the two columns and the call to action read as an orphan: no heading
   near it, nothing beside it, and nothing for the reader to do about it. So the
   good states are announced and not shown. The element stays in the page and
   stays the `aria-live` region it always was, so a screen reader still hears
   "Draft saved"; the sighted reader is told by the ask below instead, which is
   where the draft actually matters.

   THE .sr-only TECHNIQUE, WRITTEN OUT HERE rather than by putting that class on
   the element, because this is a state and not a permanent property: the
   failure rule below has to undo it, and a rule can only undo declarations it
   can see. */
.cc-savestate {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.cc-savestate[hidden] {
  display: none;
}

/* THE FAILURE IS THE ONE STATE THAT IS SHOWN, because it is the only one with
   something to do about it: a Retry nobody can see is not an offer. It takes
   back the space the quiet states give up, and is louder only by going to the
   page's full ink beside a button. No colour and no icon: the card is unharmed
   either way and the local copy is already written, so the honest weight of
   "we could not save that just now" is a sentence, not an alarm.

   NO MARGIN OF ITS OWN. The column above it already ends on its 50px, and the
   band ends on this line's own padding, so the spacing is two numbers rather
   than a margin that has to be reasoned about against a collapsing neighbour. */
.cc-savestate:has([data-cc-save-retry]:not([hidden])) {
  color: var(--ink);
  position: static;
  width: auto;
  height: auto;
  margin: 0;
  padding: 0 0 26px;
  overflow: visible;
  clip: auto;
  white-space: normal;
  display: flex;
  gap: 10px;
  align-items: baseline;
  justify-content: center;
  font-size: 0.85rem;
}

.cc-savestate-retry {
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

.cc-savestate-retry[hidden] {
  display: none;
}

.cc-split {
  /* HOW FAR THE LEFT FIELD STOPS SHORT OF THE COLUMN'S RIGHT EDGE: the preview
     rail's own width plus half the gutter between the two, so the edge lands in
     the middle of the gap rather than against either column. Both numbers are
     the grid's own, named here so the seam cannot drift from the track it
     marks: the same two lay the grid out in the wide block below. */
  --cc-rail: 360px;
  /* 100px (Sam, task #580), up from 32px: the working column and the phone are
     two separate things to look at, and the air between them is what says so.
     It is the desktop GUTTER, and the seam below follows it, so the hard edge
     still lands in the middle of the gap rather than against either column.
     The stacked gap, 20px below, is deliberately not this number: at that width
     the two are one above the other, each already carrying 50px of its own, and
     100px between them would read as a hole in the page. */
  --cc-rail-gap: 100px;
  --cc-seam: calc(var(--cc-rail) + var(--cc-rail-gap) / 2);
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 20px;
  align-items: start;
  /* The pattern's containing block and its own stacking context, so the layer
     below paints over this field's gradient and under everything in it. Both
     came from the shared strip rule until task #580 took the doodle off this
     element; they are the pattern's, not the doodle's, so they stayed. */
  position: relative;
  isolation: isolate;
  /* THE LEFT FIELD, BLED TO THE WINDOW AND CUT DEAD AT THE RAIL.

     The element is stretched leftwards to the viewport edge and padded back by
     the same amount, so the grid's contents stay exactly on the page column
     while its BACKGROUND box runs from the window's left edge to the column's
     right. Everything measured from the RIGHT of that box is then a fixed
     number of pixels: the seam is --cc-seam from it, whatever the viewport is
     doing. No viewport arithmetic, no breakpoint to redo, and no 100vw.

     Painted as one sized layer rather than as colour stops, because a stop
     would have to be a percentage of a box whose width changes with the window
     while the seam does not. `background-size` takes the calc directly, so the
     gradient is drawn edge to seam and the band's own white shows through
     after it. That IS the hard edge Sam asked for: not a blend, a boundary. */
  margin-inline-start: calc(50% - 50vw);
  padding-inline-start: calc(50vw - 50%);
  /* AND BLED TO THE RIGHT-HAND WINDOW EDGE TOO, until there is a rail to cut
     against (task #610, Sam: on a phone "the background doesn't span full width,
     it stops at the end of the form elements").

     That was exactly what it did. The element was stretched leftwards only, so
     its background box ran from the window's left edge to the COLUMN's right,
     and `background-size: 100%` below is 100% of that box and not of the
     window. On a wide screen the difference is invisible, because the seam cuts
     the colour off well before the box ends; stacked on a phone there is no
     seam, so the field ended at the right-hand gutter and the band's white
     showed in the strip beyond it, level with the right edge of every control.

     The second edge is written as its own pair rather than folded into
     `margin-inline`/`padding-inline`, because the two edges are not the same
     kind of thing: the left one is structural and holds at every width, and
     this one is given back at 900px below, where the rail arrives and the box's
     right edge becomes the ruler --cc-seam is measured from. Two declarations
     that one media query turns off is easier to hold in the head than one
     shorthand that a longhand later contradicts. */
  margin-inline-end: calc(50% - 50vw);
  padding-inline-end: calc(50vw - 50%);
  background-image: linear-gradient(to bottom right, var(--cc-field-top), var(--cc-field-bottom));
  background-repeat: no-repeat;
  background-size: 100% 100%;
  /* Said out loud, because this field is a SIZED gradient painted on nothing:
     past the seam it is the band's own white that must show through, and a
     colour here of any kind would fill the part the gradient deliberately does
     not reach. */
  background-color: transparent;
}

/* THE DOT PATTERN (task #580, Sam: swap the doodle on the left column for the
   dashboard's dotted background). It is the dashboard band's pattern, down to
   the numbers: a drawn repeating radial-gradient, one 1px dot fading out by
   1.6px on a 24px tile, so there is no image asset to load, nothing to decode
   and nothing that can 404. Only the ink differs, and it has to: see
   --cc-dot-ink at .cc-page.

   NO CENTRE FADE. The band's mask exists to keep dots off the phone and the
   chart in the middle of it; here the layer is already cut at the seam, and
   everything left of that is a column of controls the texture runs behind.

   Stacked deliberately: z-index -1 inside this element's own stacking context,
   so it paints over the field's gradient and under every control and focus
   ring, and pointer-events: none means it can never take a click. Nothing
   animates, in any state. */
.cc-split::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image: radial-gradient(circle at center, var(--cc-dot-ink) 1px, transparent 1.6px);
  background-size: 24px 24px;
}

/* Forced colours (Windows high contrast) paints its own palette: the decoration
   is dropped entirely rather than repainted in a system colour, which is the
   same answer .dash-focus::before gives. */
@media (forced-colors: active) {
  .cc-split::before {
    display: none;
  }
}

/* THE PATTERN STOPS WHERE THE COLOUR DOES. The layer is inset from the right by
   the same --cc-seam the gradient is cut at, so the dots belong to the left
   column and the preview column stays clean white with nothing behind the
   phone. Absolutely positioned, so it is not a grid item and adds no track to
   the split. Below 900px the two columns are stacked and the field fills the
   band, so the layer does too and this needs no override. */
@media (min-width: 900px) {
  .cc-split::before {
    right: var(--cc-seam);
  }
}

/* THE RAIL ARRIVES, and with it the ruler. Taking the right-hand bleed back
   here is what makes the seam arithmetic true: the box's right edge returns to
   the column's right edge, so --cc-seam is a fixed number of pixels measured
   from a known line rather than from the window. Everything above this width is
   a phone or a narrow window with the two halves stacked, where the field is
   the whole band and there is nothing to measure. */
@media (min-width: 900px) {
  .cc-split {
    grid-template-columns: minmax(0, 1fr) minmax(0, var(--cc-rail));
    gap: var(--cc-rail-gap);
    margin-inline-end: 0;
    padding-inline-end: 0;
    background-size: calc(100% - var(--cc-seam)) 100%;
  }

  /* ...except on GENERATE, where the editor has gone and the rail with it, so
     there is nothing to measure here either and the field takes the window
     back. Without this the finished card stood on a field that stopped at the
     right-hand gutter, which is the same white strip Sam saw on the phone. */
  .cc-page[data-cc-stage='generate'] .cc-split {
    margin-inline-end: calc(50% - 50vw);
    padding-inline-end: calc(50vw - 50%);
  }
}

/* On GENERATE the editor gives way and there is no rail to cut against, so the
   field simply fills the band and the card stands on it. */
.cc-page[data-cc-stage='generate'] .cc-split {
  background-size: 100% 100%;
}

/* ...and the texture with it: there is no rail to cut against on Generate. */
.cc-page[data-cc-stage='generate'] .cc-split::before {
  right: 0;
}

/* --- the editor column ---------------------------------------------------- */

/* NO CARD SHELL (task #572, Sam: "the content area should have no defined box
   background colour / border"). The working column is content laid directly on
   the field behind it, not a panel floating on top of one: no border, no
   radius, no background of its own. The split is the BAND's job now, which is
   the whole correction here, and a card would have cut a second edge across it.

   Its vertical breathing room is the 50px the two columns share, set above; a
   padding of its own here would win on order and quietly undo it, which it did
   until this line went. */
/* NO PADDING ON THE RIGHT EITHER (task #580). It carried 36px, to hold the
   controls off the gutter between the two columns; the gutter is 100px now, so
   the air is there without borrowing any from the controls, and the column runs
   the full width of its track. Deliberately no `padding-right: 0` line: nothing
   in this sheet gives this element a horizontal padding, so zero is what it
   already computes to and a declaration would only be a second place to look.
   test/create-card.test.ts pins the absence. */
.cc-editor {
  display: grid;
  gap: 22px;
  min-width: 0;
}

/* One stage's panel. It is a grid for the same reason .cc-editor is: the gap
   between its fields is one number rather than a margin on each of them. */
.cc-panel {
  display: grid;
  gap: 22px;
  min-width: 0;
}

/* The panels carry the `hidden` attribute, and a declared display beats the
   browser's own [hidden] rule, so the display has to be taken back. */
.cc-panel[hidden] {
  display: none;
}

/* Focused only by script, to move the reading position to the top of a stage.
   Nothing should be drawn around it: the heading below it is the signal. */
.cc-panel:focus {
  outline: none;
}

/* Why Continue would not go through. It appears in answer to a button press,
   sits directly above the button that was pressed, and goes the moment the edit
   that answers it is made. */
.cc-stage-error {
  margin: 0;
  padding: 10px 12px;
  border-radius: var(--control-radius);
  border: 1px solid var(--error);
  color: var(--error);
  background: color-mix(in srgb, var(--error) 7%, transparent);
  font-size: 0.9rem;
}

.cc-stage-error[hidden] {
  display: none;
}

.cc-panel-title {
  margin: 0;
  font-size: 1.4rem;
}

.cc-panel-lede {
  margin: -14px 0 0;
  color: var(--strip-muted);
}

/* THE SAME ANSWER FOR THE SMALL PRINT (task #580). Secondary text on this field
   is --strip-muted, for the reason the page header strip gives: --muted is
   tuned against white, and on a lilac it is already the thinnest ink here
   (4.65:1 on the field's darkest tone, before any texture). Two hints inside
   the working column had been left on the app default since task #572, and the
   dot pattern is drawn over them; this puts them on the page's own ink, which
   clears AA over a dot with room to spare. Nothing outside this column moves.

   AND IT CARRIES NO MARGIN OF ITS OWN (task #588), which is the account page's
   own `.design-section .hint { margin: 0 }` arriving here. Most hints in this
   column are the shared controls' <span>s, but a few are paragraphs, and a <p>
   keeps the browser's 1em top and bottom until something says otherwise. Inside
   a group that is a flex column those margins are ADDED to the gap rather than
   collapsed into it, so a paragraph ending a group opened a visibly wider space
   under it than the group's own spacing, and the next .cc-field sat that much
   further away than every other one. Every gap in this column is now the one
   the container declares. */
.cc-editor .hint {
  color: var(--strip-muted);
  margin: 0;
}

/* Back on the left, forward on the right, and forward is the wide one: it is
   what almost everybody is about to press. */
.cc-panel-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}

.cc-panel-nav .cc-next {
  margin-left: auto;
}

.cc-panel-nav .cc-back[hidden] {
  display: none;
}

.cc-field {
  display: grid;
  gap: 8px;
  min-width: 0;
}

/* Two controls side by side where there is room, stacked where there is not.
   The first column is as wide as the stepper and its label and no wider (task
   #1033: the control is a fixed three cells, so the column that used to be
   sized for a select has nothing to leave room for), and the second is
   minmax(0, 1fr) rather than 1fr so a long reward cannot push the pair wider
   than its container. */
.cc-field-pair {
  grid-template-columns: minmax(0, 1fr);
}

/* align-content: start, because the two columns share one grid row and the
   taller one (What they get, with its hint) sets its height. Without it each
   column's own auto rows share the slack equally, so the Stamps needed label
   grew a second, empty line and pushed its control 25px below the text box it
   is meant to line up with (task #1033). Packed at the top, label sits by
   label and box by box. */
.cc-field-pair > div {
  display: grid;
  align-content: start;
  gap: 8px;
  min-width: 0;
}

@media (min-width: 560px) {
  .cc-field-pair {
    grid-template-columns: max-content minmax(0, 1fr);
    gap: 12px;
  }
}

.cc-label {
  font-weight: 600;
}

.cc-hint {
  margin: 0;
  color: var(--muted);
  font-size: 0.85rem;
}

/* LAYOUT ONLY, deliberately. How these two look, how tall they are and how they
   focus all come from the shared control rule they joined further up this
   sheet; declaring any of it again here is exactly the drift that rule exists to
   prevent, and test/form-controls.test.ts fails on it. */
.cc-editor .cc-text,
.cc-editor select {
  width: 100%;
}

/* THE ONE THING A SELECT WILL NOT TAKE FROM THAT SHARED RULE (task #610, Sam:
   on a phone "the select elements appear unstyled compared to the text input
   elements").

   They were not unstyled. Both selects on this page have carried the shared
   height, border, radius, background, colour and font since task #572, and on
   Blink they look exactly like the text field above them. WebKit is the
   difference, and it is not a bug in this sheet: a select still drawing its
   native `menulist` appearance is a platform control, and on iOS and on Safari
   the engine paints its own pill (its own grey fill, its own corner radius, its
   own light hairline) over the author's border, radius and background. Only the
   width and the box the control sits in were ever ours. So on the phone Sam
   builds cards on, the business name field was a white 48px box with a 10px
   radius and the sector select beside it was a grey rounded platform button:
   the exact mismatch the shared rule exists to end, on the one engine that
   never honoured it.

   `appearance: none` is what makes the shared declarations bind. It also takes
   the platform indicator away with them, so this rule owes the control a new
   one, and draws it rather than loading it: two 5px linear-gradient halves
   meeting as a 10px triangle, which is nothing to fetch, nothing to decode and
   nothing that can 404. It is `--ink`, the select's own text colour, so the
   glyph and the mark it opens are one colour. The end padding is the caret's
   own width and 8px clear of it, so a long option label stops before the mark
   instead of running under it.

   SCOPED TO THIS PAGE, deliberately. The same is true of every select in the
   product on WebKit, but the signed-in surfaces are not what this task is
   about and repainting them all is its own change with its own review. This is
   the narrow fix for the page Sam reported, and the shared rule above is where
   the wider one belongs when it comes. */
.cc-editor select {
  -webkit-appearance: none;
  appearance: none;
  padding-inline-end: 32px;
  background-image: linear-gradient(45deg, transparent 50%, var(--ink) 50%),
    linear-gradient(135deg, var(--ink) 50%, transparent 50%);
  background-position: calc(100% - 19px) center, calc(100% - 14px) center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}

/* Forced colours (Windows high contrast) paints the page in the reader's own
   palette and drops background images with it, which would leave a select with
   no indicator at all. So the platform control is handed straight back: it
   draws its own arrow in a system colour, which is the whole point of that
   mode, and the padding goes back to the shared one so nothing is held off an
   edge that no longer has a caret against it. */
@media (forced-colors: active) {
  .cc-editor select {
    -webkit-appearance: auto;
    appearance: auto;
    padding-inline-end: var(--control-pad-x);
    background-image: none;
  }
}

/* The Stamps needed stepper (task #1033), still inside the designer's own
   section: the test that bounds that section stops at the next "---" banner,
   so this is a paragraph and not a banner.

   Minus, the number, plus: one box, three cells. The box takes the shared
   control's own border, face and ink, and `height` (not min-height: the token
   owns the height and nothing in here may add to it) is the one control height
   every field on this row stands at, so it is exactly as tall as the What they
   get box beside it and lines up with it to the pixel. It is inline-grid and
   starts at its column's left, so the three cells are the whole control at
   every width: on a phone, where the pair stacks, it does not stretch into a
   full-width bar with two buttons at the far ends.

   Each button cell is 44px wide, the touch target the rest of the app is held
   to, with no border of its own: the box draws the outline once, and the value
   cell carries the two hairlines that divide it. The number is bold, tabular
   and centred in a cell that fits two digits with room, so 9 and 12 sit in the
   same place and the buttons do not move under a thumb as the count changes.
   The glyphs are the SVG paths the view ships, in the button's own ink, so
   disabled is a colour change and nothing else. The value track is
   minmax(0, 2.75rem), which an inline grid resolves to its maximum, for the
   same reason every track on this page can be pushed narrow: nothing may
   force the column wider than its container. */
.cc-stepper {
  display: inline-grid;
  grid-template-columns: 44px minmax(0, 2.75rem) 44px;
  align-items: stretch;
  justify-self: start;
  box-sizing: border-box;
  height: var(--control-height);
  border: 1px solid var(--line);
  border-radius: var(--control-radius);
  background: var(--card);
  color: var(--ink);
}

.cc-stepper-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--brand);
  font: inherit;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

.cc-stepper-btn:hover {
  background: var(--bg);
}

/* The global button:disabled rule says `cursor: wait` and dims the whole
   button, which is the wrong story here: nothing is being waited for, the
   number has simply reached its end. Muted ink, full opacity, no hover. */
.cc-stepper-btn[disabled] {
  color: var(--muted);
  opacity: 1;
  cursor: default;
}

.cc-stepper-btn[disabled]:hover {
  background: transparent;
}

/* Inside the cell, so the ring is never cut by the neighbouring cell or by the
   box's own edge, and the same two pixels of brand the shared rule draws. */
.cc-stepper-btn:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: -2px;
}

.cc-stepper-value {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 6px;
  border-inline: 1px solid var(--line);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  text-align: center;
}

/* Forced colours drops the author's inks; the box border and the hairlines
   come back in the system's own line colour, which is the whole point. */
@media (forced-colors: active) {
  .cc-stepper,
  .cc-stepper-value {
    border-color: CanvasText;
  }
}

/* The shared uploader's plain file input, which the browser draws itself and
   which /static/image-upload.js hides the moment it boots. The only rule it
   needs is one that stops a long filename widening the column while it shows. */
.cc-editor input[type='file'] {
  max-width: 100%;
}

/* --- colour swatches ------------------------------------------------------ */

.cc-swatches {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.cc-swatch {
  display: inline-flex;
  position: relative;
  cursor: pointer;
}

/* The radio is the control and it keeps its size and its place: an element with
   no box paints no focus ring, and this picker is reachable by keyboard. It is
   made invisible and laid over its own tile, so the ring is drawn on the tile
   by the sibling rule below (the trap task #514 hit on the designer's radios). */
.cc-swatch input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.cc-swatch-dot {
  display: block;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--cc-swatch, var(--brand-fixed));
  border: 2px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(21, 17, 27, 0.14);
}

.cc-swatch input:checked + .cc-swatch-dot {
  border-color: var(--ink);
  box-shadow: 0 0 0 2px #fff inset;
}

.cc-swatch input:focus-visible + .cc-swatch-dot {
  outline: 2px solid var(--brand-fixed);
  outline-offset: 2px;
}

/* --- the stamp mark picker ------------------------------------------------

   THE ACCOUNT'S OWN PICKER, CLASS FOR CLASS (task #572, Sam: "use the real one
   from the admin ... Same exact layout and concept"). Everything the box is
   made of - `.design-icon-grid`, `.design-icon-tile`, `.design-icon-glyph`,
   `.design-icon-search`, `.design-icon-tools`, `.design-icon-status`,
   `.design-icon-filters`, `.design-icon-load-more` - is declared once, far up
   this sheet, for the signed-in Card Design page. This page reuses those rules
   rather than restating them, exactly as it reuses ColourWell's and the
   uploader's, so a change to how a mark is drawn over there reaches this page
   instead of leaving it behind.

   WHAT IS LEFT HERE IS THE TWO THINGS THE PUBLIC COLUMN NEEDS AND THE ADMIN
   DOES NOT. */

/* ONE: TEN COLUMNS, SO FIFTY IS FIVE ROWS.

   The shared grid is `repeat(auto-fill, minmax(min(100%, 52px), 1fr))`, and at
   the ADMIN's column width that packs ten tracks, which is why fifty marks is
   five clean rows there and why RECOMMENDED_STAMP_ICON_COUNT is fifty at all.
   This page is the 960px PUBLIC measure (test/content-grid.test.ts holds that a
   public page may not reach for the wider signed-in one), and its editor column
   is narrower, so auto-fill lands on eight and fifty becomes seven ragged rows.

   So the count is honoured by naming the tracks instead of hoping for them:
   exactly ten, once there is room for two columns on the page at all. Below
   that the shared auto-fill rule is left alone and the grid reflows, which is
   what it is for. The tiles are smaller here than in an account; five rows of
   ten is the shape Sam asked for and this is the width it has to happen in. */
@media (min-width: 900px) {
  .cc-icon-picker .design-icon-grid {
    grid-template-columns: repeat(10, minmax(0, 1fr));
    gap: 6px;
  }
}

/* TWO: the picker and the unstamped row are the account's `.design-group` flex
   column on a page whose fields are a grid, so they take the column's own
   spacing rather than a form's. Both were fieldsets until task #588 made every
   group on this page a <div role="group"> named by a .cc-label; the class list,
   and so everything below, is unchanged by that. */
.cc-icon-picker,
.cc-empty-picker {
  min-width: 0;
}

/* The "same mark, faded" tile previews the earned mark at the weight an
   uncollected cell draws it: UNEARNED_MARK_OPACITY, the same 0.35 .cc-mark and
   .wallet-pass-stamp both restate, because a stylesheet cannot import a
   TypeScript constant. */
.cc-pick-faded {
  opacity: 0.35;
}

/* --- the stamp area's background ------------------------------------------ */

/* THE OVERLAY SLIDER IS THE ACCOUNT'S OWN (Sam: "style the overlay slider like
   the web app with gradient"). `.design-overlay`, its head, its value, its
   `.design-overlay-range` and its scale are declared once, far up this sheet,
   for the signed-in Card Design page, and this page wears them rather than
   restating them: the dark-to-light track gradient, the 2px centre notch at
   Off, the brand thumb and its ring all come from there. Nothing is added here,
   which is the point. */

/* --- the background gallery ------------------------------------------------

   A native <dialog>, so the focus trap, the backdrop and the Escape key all
   arrive with it. What a pick MEANS here is the difference from the account's
   own library: inside an account a confirmed choice is materialised into the
   Program's own asset at save time, and this page has no save, so a pick sets a
   custom property to the picture's public address and stops. */

.cc-library {
  width: min(880px, 94vw);
  max-height: 88vh;
  padding: 0;
  border: 0;
  border-radius: 16px;
  background: var(--card);
  color: var(--ink);
  overflow: hidden;
}

.cc-library::backdrop {
  background: rgba(20, 16, 30, 0.55);
}

.cc-library-panel {
  display: grid;
  grid-template-rows: auto auto minmax(0, 1fr) auto;
  max-height: 88vh;
}

.cc-library-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 18px 20px 10px;
}

.cc-library-head h2 {
  margin: 0;
  font-size: 1.2rem;
}

.cc-library-close {
  border: 0;
  background: none;
  cursor: pointer;
  font-size: 1.5rem;
  line-height: 1;
  color: var(--muted);
}

/* The search box itself is the account's `.design-library-search` block, shared
   whole; this only gives the dialog's own copy the padding it sits in. */
.cc-library .design-library-search {
  padding: 0 20px 12px;
}

.cc-library-body {
  overflow-y: auto;
  padding: 0 20px;
}

.cc-library-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 12px;
  padding-bottom: 4px;
}

.cc-library-tile {
  position: relative;
  display: block;
  cursor: pointer;
  border-radius: 10px;
  overflow: hidden;
  border: 2px solid var(--line);
}

.cc-library-tile[hidden] {
  display: none;
}

/* The radio keeps its box for the same reason every other picker on this page
   keeps one: an element with no box paints no focus ring. */
.cc-library-tile input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.cc-library-thumb {
  display: block;
  width: 100%;
  height: auto;
}

/* A shape AND a word, never colour alone. */
.cc-library-tick {
  display: none;
  position: absolute;
  left: 8px;
  bottom: 8px;
  align-items: center;
  gap: 5px;
  padding: 3px 8px;
  border-radius: 999px;
  background: var(--brand-fixed);
  color: #fff;
  font-size: 0.72rem;
  font-weight: 700;
}

/* ITS OWN CLASS, not the progress line's. The tick is one drawn glyph shared by
   two places, and .cc-step-tick is `display: none` until a step is complete:
   borrowing that class here left the gallery's chosen badge showing the word
   and no tick at all. */
.cc-library-tick-mark {
  width: 12px;
  height: 12px;
}

.cc-library-tile:has(input:checked) {
  border-color: var(--brand-fixed);
}

.cc-library-tile:has(input:checked) .cc-library-tick {
  display: inline-flex;
}

.cc-library-tile:has(input:focus-visible) {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.cc-library-foot {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  padding: 14px 20px 18px;
}

/* --- the preview column ---------------------------------------------------

   ON DESKTOP it needs no background of its own: it stands on the band's white,
   which is what the left field's hard edge is cut against.

   BELOW 900px the two are stacked, and a hard VERTICAL seam across a phone
   would be the "weird split" Sam asked to avoid. So the rail takes the white as
   a block of its own, bled to both edges (its own 50% is the grid area, which
   at that width IS the column), and the boundary becomes a horizontal one
   between two stacked sections, which is what stacking should look like. */
.cc-stage {
  min-width: 0;
}

@media (max-width: 899px) {
  .cc-stage {
    margin-inline: calc(50% - 50vw);
    padding: 24px calc(50vw - 50%) 8px;
    background: var(--card);
  }
}

@media (min-width: 900px) {
  .cc-stage-inner {
    position: sticky;
    top: 20px;
  }
}

/* The canonical iPhone preview, which arrives complete: its own container
   query, its own aspect ratio, its own aperture and its own 374px ceiling. ONE
   thing only is this page's business.

   CENTRED. .phone-frame is `margin: 0 0 0 auto` because the Program page hangs
   it off the right of its column; here it is the one object in a column of its
   own and belongs in the middle of it.

   AND NOTHING ELSE (task #580). This page held the phone to 300px, a second
   ceiling under the shared 374px one, so the card people are building was shown
   smaller here than anywhere else in the product. Sam took it off: the rail is
   360px wide, so the frame now fills its column and stops at the shared ceiling
   like every other phone in the sheet. It is a taller object than it was, which
   is the point; the column is sticky, and the card is what a visitor is here to
   look at. Generate keeps a ceiling of its own further down, because there the
   phone is alone in the full measure and would otherwise grow past it. */
.cc-stage .phone-frame {
  margin-inline: auto;
}

/* The monogram a business shows until it has a logo: the enrolment page's
   placeholder idea, at the card's own scale and in the card's own ink. Empty
   until somebody types, so nothing flashes on an untouched page. */
/* The stand-in shown until a logo is chosen. It occupies the SAME box the real
   logo will (task #691), so choosing one does not move the header about: the
   slot is square here because a monogram is.

   THE DIVISION IS NOT DECORATION. Setting font-size on this element rescales its
   own em, and --pass-logo-max-h is in em, so a circle sized straight from it
   would come out --scale times too big. Dividing by the same factor cancels that
   exactly, which keeps ONE number (the slot) deciding the size while the letter
   inside it stays proportionate. */
.cc-monogram {
  --scale: 1.5;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  font-size: calc(1em * var(--scale));
  width: calc(var(--pass-logo-max-h) / var(--scale));
  height: calc(var(--pass-logo-max-h) / var(--scale));
  border-radius: 50%;
  border: 0.12em solid var(--pass-ink, #fff);
  font-weight: 700;
  line-height: 1;
}

.cc-monogram:empty,
.cc-monogram[hidden] {
  display: none;
}

/* The chosen mark, in place of the default design's flexbox discs. Everything
   about the LAYOUT is the card's own: .wallet-pass-stamps stacks the rows,
   .wallet-pass-stamp-row spaces the marks in one, and --pass-stamp-size sizes
   them. All this adds is the picture inside a cell, and the two `hidden` rules
   the toggling needs, because both of those containers declare a `display` and
   a declared display beats the browser's own [hidden] rule. */
.cc-marks .wallet-pass-stamp-row[hidden] {
  display: none;
}

/* --- the stamp band's own background --------------------------------------

   THREE LAYERS, IN src/card-scene.ts's ORDER, because this band is the one part
   of the preview that has to reproduce a draw order rather than a value:

     1. the band colour, --cc-band-bg. Unset by default, so the card colour
        shows through: cardScene() takes the brand colour whenever
        design.backgroundColor is null, and "unset" is how that reads in CSS.
     2. the picture, centre-cropped to cover the band. `cover` with a centred
        position is the same crop coverImage() computes.
     3. the wash, AND THIS IS THE LAYER WITH THE TRAP IN IT. The scene does not
        lay a flat rectangle over the band: it draws the picture a SECOND time
        with every covered pixel in one flat colour, so a cut-out picture is
        shaded where it has ink and left alone where it is transparent. A flat
        rectangle here would spread the wash across gaps the real card leaves
        clear, and a merchant's transparent PNG would preview wrong. So the
        overlay layer is masked by the picture's own alpha, which is the CSS
        spelling of the same idea. -webkit- first for Safari, which still wants
        the prefix.

   The rows sit above all three, on their own stacking level. */

/* THE CARD'S REAL PROPORTION AND ITS REAL WIDTH, ALWAYS (task #579).

   These declarations used to sit on a second rule, `[data-cc-band='scene']`,
   applied only once the stamp area had a colour or a picture of its own; the
   bare band kept the default card's shrink-to-fit height and its em-sized
   marks. That was the wrong half of the product's own condition. A card is
   drawn as a scene unless its whole design is the shipped default, and the
   design a visitor builds on this page always names a stamp mark, so it never
   is. Choosing a background colour was making the band taller and the marks
   larger, and clearing it was shrinking both, when the only thing that had
   changed was a colour. One rule, one shape.

   FULL WIDTH, WHICH IS THE PART A PICTURE MAKES OBVIOUS (Sam). A scene band is
   drawn edge to edge on the card: .wallet-pass-scene > svg is bled out by
   --pass-pad-x on both sides and clipped by the face, because that is how the
   strip arrives in Apple Wallet. A band that stopped at the face's side padding
   showed a merchant's chosen photograph in a letterbox with two gutters the real
   card does not have. So this cancels the same named padding, the same way, and
   the face's own `overflow: hidden` does the clipping. A band that reaches the
   edge has no corners of its own, which is why there is no border-radius here.

   min-height AND NOT height, deliberately, and for the reason task #572's own
   table card learned the hard way: `aspect-ratio` against the `overflow: hidden`
   the picture needs will CLIP whatever does not fit, and four rows of marks on a
   narrow card do not fit. A floor gives the same shape and lets the box grow.

   A CONTAINER, so the marks inside it can be sized against the BAND rather than
   against the card's type scale. That is what lets --pass-stamp-size be
   stampLayout()'s own answer in cqw (see createCardBandMarks() in
   src/views/create-card.tsx, and its port in public/create-card.js). Declaring
   it here is safe and declaring it on an ancestor would not be: a cqw unit
   resolves against the nearest ANCESTOR container, so only the marks read this,
   never the band itself. */
.cc-marks {
  position: relative;
  isolation: isolate;
  align-self: stretch;
  padding: 0.6em 0;
  background-color: var(--cc-band-bg, transparent);
  overflow: hidden;
  aspect-ratio: var(--cc-band-ratio, 375 / 144);
  min-height: fit-content;
  justify-content: center;
  margin-inline: calc(var(--pass-pad-x) * -1);
  width: calc(100% + var(--pass-pad-x) * 2);
  container-type: inline-size;
  gap: var(--cc-mark-gap, 0.5em);
}

.cc-band-image,
.cc-band-overlay {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.cc-band-image[hidden],
.cc-band-overlay[hidden] {
  display: none;
}

.cc-band-image {
  background-image: var(--cc-band-src, none);
  background-size: cover;
  background-position: center;
}

.cc-band-overlay {
  background-color: var(--cc-band-overlay-colour, #000);
  opacity: var(--cc-band-overlay-alpha, 0);
  -webkit-mask-image: var(--cc-band-src, none);
  mask-image: var(--cc-band-src, none);
  -webkit-mask-size: cover;
  mask-size: cover;
  -webkit-mask-position: center;
  mask-position: center;
}

/* Above all three layers, and at the scene's own pitch: a mark sits in a cell,
   and what is left of the cell is the gap. One number, set beside the mark
   size. */
.cc-marks .wallet-pass-stamp-row {
  position: relative;
  z-index: 1;
  gap: var(--cc-mark-gap, 0.42em);
}

.cc-mark {
  display: block;
  width: var(--pass-stamp-size, 1.5em);
  height: var(--pass-stamp-size, 1.5em);
  /* The cell is the box a number is placed against (task #582). */
  position: relative;
}

/* THE SETTING-BACK IS ON THE ARTWORK, NOT ON THE CELL (moved there by task
   #582), and the distinction is the whole of how a numbered mark reads.

   UNEARNED_MARK_OPACITY in src/card-scene.ts, the same value
   .wallet-pass-stamp restates and for the same reason: a stylesheet cannot
   import a TypeScript constant, and a mark still to collect has to be exactly
   as faint here as it is on the card this is previewing.

   It used to sit on .cc-mark, which was identical while a cell held one thing.
   A cell now holds two, and opacity on a parent is a CEILING its children
   cannot climb back out of: the number would have been capped at the
   placeholder's 0.35 and could never have been the stronger of the two, which
   is exactly the relationship the scene draws (see NUMBER_MARK_OPACITY). Each
   layer carries its own alpha instead, which is also how the two nodes
   composite on the real card. */
.cc-mark [data-cc-mark-art] {
  opacity: 0.35;
}

.cc-mark[data-cc-mark='earned'] [data-cc-mark-art] {
  opacity: 1;
}

.cc-mark[hidden] {
  display: none;
}

/* THE POSITION NUMBER (task #582): laid over the mark, filling the same cell,
   at NUMBER_MARK_OPACITY. Hidden unless the switch is on AND this cell is still
   to collect - one attribute on the container and one on the cell, so a stamp
   being collected takes its number away with no second write.

   SCOPED TO .cc-mark, WHICH IS NOT DECORATION: the shared `.cc-mark svg` rule
   below is (0,1,1) and a bare `.cc-mark-number` is (0,1,0), so it LOSES the
   display and the figure paints whether the switch is on or not. Caught in a
   real browser, invisible to the markup: `display: none` was in the sheet, in
   the cascade, and simply outranked. It is the same trap task #530 wrote up on
   the colour well, and the fix is the same one - answer a rule at its own
   weight. The sizing is left to that rule too, rather than restated here;
   `position` and `inset` are all this layer adds. */
.cc-mark .cc-mark-number {
  display: none;
  position: absolute;
  inset: 0;
  opacity: 0.75;
}

.cc-marks[data-cc-numbers='on'] .cc-mark[data-cc-mark='empty'] .cc-mark-number {
  display: block;
}

/* THE STAMP COLOUR, and why it is a fallback chain rather than a second rule.
   stampMarkColor() in src/card-scene.ts says a mark is drawn in the design's
   stampColor when there is one and in the card's ink when there is not, which
   is exactly what var(--cc-stamp-ink, var(--pass-ink, #fff)) spells: the script
   sets the first property when the well holds a colour and removes it when the
   well is empty, so there is no state where both are trying to answer.

   It is declared on the <svg> and INHERITED by the paths under it, which is
   what makes a layered colour mark keep its own colours: a `fill` attribute on
   a path is a presentation attribute and beats an inherited value, so the
   layers that carry one are untouched and only the layers that do not follow
   the stamp colour. That is the same rule the account's Card Design applies,
   and the reason the copy beside both controls says a stamp colour does not
   reach a mark that brought its own. */
.cc-mark svg {
  display: block;
  width: 100%;
  height: 100%;
  fill: var(--cc-stamp-ink, var(--pass-ink, #fff));
}

.cc-stage-note {
  margin: 18px 0 14px;
  text-align: center;
  color: var(--muted);
}

.cc-fine {
  margin: 12px 0 0;
  text-align: center;
  color: var(--muted);
  font-size: 0.85rem;
}

/* --- stage four: Generate --------------------------------------------------

   The one stage that is not beside the phone. On the first three the controls
   have the column and the card sits next to them; here the controls give way
   entirely, the card becomes the hero in the middle of the measure, and the
   sign-up page and the launch pack spread out underneath.

   IT IS A LAYOUT SWITCH AND NOT A SECOND CARD. One `data-cc-stage` attribute on
   .cc-page does the whole thing, so the card a visitor has been building is the
   card they are shown: two of them would be two things to keep in step, and one
   of the two would eventually be the wrong one. */

.cc-page[data-cc-stage='generate'] .cc-editor {
  display: none;
}

@media (min-width: 900px) {
  .cc-page[data-cc-stage='generate'] .cc-split {
    grid-template-columns: minmax(0, 1fr);
  }
}

.cc-page[data-cc-stage='generate'] .cc-stage-inner {
  position: static;
}

.cc-page[data-cc-stage='generate'] .cc-stage .phone-frame {
  max-width: 330px;
}

.cc-generate {
  display: grid;
  gap: 22px;
}

.cc-generate[hidden] {
  display: none;
}

.cc-generate:focus {
  outline: none;
}

.cc-generate-head {
  display: grid;
  gap: 8px;
  text-align: center;
}

.cc-generate-head .cc-panel-title,
.cc-generate-head .lede {
  margin: 0;
}

.cc-generate-head .cc-panel-title {
  font-size: 1.7rem;
}

/* The sign-up page and the three launch-pack pieces, on one grid. auto-fit with
   a floor rather than a fixed column count, because there are four pieces of
   three different shapes and a hard `repeat(4, ...)` would make each of them a
   quarter of a phone. */
.cc-showcase {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 20px;
  margin: 8px 0 0;
}

@media (min-width: 640px) {
  .cc-showcase {
    /* Not 220px: test/image-upload.test.ts holds that number to the uploader's
       thumbnail cap and asserts it is written once in the whole sheet. */
    grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr));
    align-items: end;
  }
}

/* THE CONTAINER IS THE FIGURE, NOT THE ARTEFACT, and that distinction is the
   whole reason the three pieces below can be sized in cqw at all: a container
   query unit resolves against the nearest ANCESTOR container, so an element
   that declares `container-type` on itself cannot query its own width and its
   cqw silently falls back to the VIEWPORT. That is what happened first time
   round: every line of poster copy came out at 4.4vw. The card face solves this
   the same way (.wallet-card-face wraps .wallet-pass), and so does this. */
.cc-pack-item {
  container-type: inline-size;
  margin: 0;
  min-width: 0;
}

.cc-pack-item figcaption {
  margin-top: 10px;
  text-align: center;
  color: var(--muted);
  font-size: 0.85rem;
  font-weight: 600;
}

/* The four pieces share one skin: the brand colour, the pack's measured ink,
   a quiet edge and a container-query type scale so each reads correctly at
   whatever width its column happens to be. The sign-up page joins them because
   the real one wears the card's own colour too (task #567): it is the same
   surface, not a lookalike of one. */
.cc-enrol,
.cc-poster,
.cc-tent,
.cc-social {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 0.6em;
  background: var(--pass-bg, var(--brand-fixed));
  color: var(--cc-pack-ink, #fff);
  border-radius: 10px;
  padding: 1.4em 1.2em;
  font-size: 15px;
  font-size: 5cqw;
  overflow: hidden;
}

/* A FLOOR, NOT A FIXED SHAPE, and the difference is the whole reason these read
   at every width. Each piece is the proportion of the real thing (A-portrait,
   a landscape tent card, a square post) expressed as a multiple of its own
   column width, so the shape is right whenever the content fits. But the copy
   inside is a business's own words, and a long reward on a narrow phone simply
   needs more room than the proportion allows. `aspect-ratio` would hold the
   shape and clip the difference against the `overflow: hidden` above, which is
   exactly what it did: the table card lost its kicker off the top and the
   business name off the bottom at 390px. A minimum lets the box grow instead,
   so nothing a visitor types can ever be cut in half. */
.cc-poster {
  min-height: 141cqw;
  justify-content: space-between;
}

.cc-tent {
  min-height: 72cqw;
  justify-content: center;
}

.cc-social {
  min-height: 100cqw;
  justify-content: center;
}

.cc-artefact-brand {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  min-width: 0;
}

.cc-artefact-logo {
  height: 2.2em;
  width: auto;
  max-width: 6em;
  object-fit: contain;
}

.cc-artefact-monogram {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 2em;
  height: 2em;
  border-radius: 50%;
  border: 0.11em solid var(--cc-pack-ink, #fff);
  font-weight: 700;
  line-height: 1;
}

.cc-artefact-monogram:empty,
.cc-artefact-monogram[hidden] {
  display: none;
}

.cc-artefact-name {
  font-weight: 700;
  font-size: 1.15em;
  overflow-wrap: anywhere;
}

/* The QR sits on its own white plate on every piece, which is what a scanner
   needs whatever colour the business chose. */
.cc-artefact-qr {
  width: 42%;
  background: #fff;
  border-radius: 0.4em;
  padding: 0.3em;
}

.cc-artefact-qr svg {
  display: block;
  width: 100%;
  height: auto;
}

.cc-poster-kicker,
.cc-tent-kicker {
  margin: 0;
  font-size: 0.78em;
  font-weight: 700;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  opacity: 0.85;
}

.cc-poster-offer {
  margin: 0;
  font-size: 1.45em;
  font-weight: 700;
  line-height: 1.2;
  overflow-wrap: anywhere;
}

.cc-poster-foot,
.cc-social-foot {
  margin: 0;
  font-size: 0.82em;
  opacity: 0.9;
}

.cc-tent .cc-artefact-qr {
  width: 34%;
}

.cc-tent-offer,
.cc-social-offer {
  margin: 0;
  font-weight: 700;
  overflow-wrap: anywhere;
}

.cc-tent-name {
  margin: 0;
  font-size: 0.85em;
  opacity: 0.9;
  overflow-wrap: anywhere;
}

.cc-social-line {
  margin: 0;
  font-size: 1.35em;
  font-weight: 700;
  line-height: 1.25;
}

/* --- the Pro Pack on a counter (task #715) ---------------------------------
   The pack, photographed. One picture with four holes cut in it, the same
   artwork as the flat pieces above sitting under those holes, and the picture
   back on top so the counter's own light falls over it. Everything about WHERE
   is in the style attribute each layer carries, measured from the picture's
   alpha channel by scripts/gen-pro-pack-mockup.ts and turned into a size, a
   clip and a projective transform by src/pro-pack-layout.ts. Nothing in this
   sheet knows a coordinate, which is the point: re-export the artwork with a
   window somewhere else and the CSS does not change. */
.cc-mock {
  margin: 22px 0 0;
}

/* THE PICTURE'S BOX IS THE COORDINATE SYSTEM, and since task #715's review it
   is a query container as well as a positioning one. Every layer is sized in
   percentages of this element and MOVED in cqw of it, so the whole mockup is
   one drawing that holds together at any width, from a 390px phone to the 960px
   public measure, with no script and no media query. The cqw is what lets the
   perspective be a real one: see src/pro-pack-layout.ts.

   Naming the container here rather than leaning on the figure's own matters,
   because a container query unit resolves against the nearest ANCESTOR
   container and .cc-mock-art declares `container-type` itself, so its own
   transform is measured against whatever is above it. That should be the
   picture, explicitly, and not whichever wrapper happens to be a container.

   `aspect-ratio` is what makes the percentage HEIGHTS resolve, and it is the
   picture's own. */
.cc-mock-scene {
  position: relative;
  container-type: inline-size;
  aspect-ratio: 1600 / 893;
  overflow: hidden;
  border-radius: 12px;
}

.cc-mock-photo {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* One artwork layer, under one hole. It sits at the scene's top left at its
   own untransformed size, and everything about where it ENDS UP is the
   transform in its style attribute: a perspective, a translate3d and a
   matrix3d, in that order, all of them either container-relative lengths or
   numbers that do not depend on the width. `transform-origin: 0 0` is what the
   whole derivation is written about, so it is not a nicety. */
.cc-mock-art {
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 0 0;
  container-type: inline-size;
}

/* The artwork fills its layer, and since the review the layer lands ON the
   window rather than on the best parallelogram through it, so the clip now
   falls within half a percent of the artwork's own edge. These can go back to
   about the padding the flat pieces use; the earlier `11cqw` existed only to
   feed the affine fit's worst corner.

   `border-radius: 0` matters more than it looks: a rounded corner inside a
   clip cut to the true window is a crescent of page showing through the
   photograph. The hole has whatever corners the real object has, and they are
   already in the clip. */
.cc-mock-art > * {
  width: 100%;
  height: 100%;
  min-height: 0;
  border-radius: 0;
  padding: 7cqw 6cqw;
}

/* The cup label and the two tags come out between about 75px and 115px wide,
   which is life-size for a sticker a couple of centimetres across. At the
   pack's 5cqw the business name would be five pixels tall, so these carry the
   brand and the QR only, and carry the brand larger. */
.cc-mock-label {
  position: relative;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4em;
  background: var(--pass-bg, var(--brand-fixed));
  color: var(--cc-pack-ink, #fff);
  text-align: center;
  font-size: 9cqw;
  line-height: 1.1;
  overflow: hidden;
}

.cc-mock-label-brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3em;
  min-width: 0;
  max-width: 100%;
}

.cc-mock-label .cc-artefact-logo {
  height: 2.2em;
  max-width: 100%;
}

/* Bigger than the pack's 42%, because on a piece this small the QR is the
   piece: it is the only part of a cup sticker anybody is meant to do anything
   with. The white plate and the radius come from .cc-artefact-qr, which is
   what a scanner wants whatever colour the business chose. */
.cc-mock-qr {
  width: 54%;
  padding: 0.2em;
  flex: 0 0 auto;
}

.cc-mock-tagLeft .cc-mock-qr,
.cc-mock-tagRight .cc-mock-qr {
  width: 62%;
}

/* THE TABLE TALKER'S FACE (task #1001), the second piece in the table-talker
   pictures where the counter picture has its cup. It is a flat printed panel
   leaning back on its fold, so the projective map in the style attribute is
   the whole of its placement: no wrap, no canvas, no patch and no shading
   (.cc-mock-wrap belongs to the cup alone). The face is nearly square and about
   half again the width of a tag, so it carries the printed talker's opening
   line over the brand and the QR. */
.cc-mock-tent > .cc-mock-label {
  gap: 3cqw;
  padding: 8cqw 7cqw;
  font-size: 8cqw;
}

.cc-mock-tent-kicker {
  font-size: 0.62em;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.85;
}

.cc-mock-tent .cc-mock-qr {
  width: 48%;
}

/* Only the chosen table-talker photograph shows (task #1001). The rule above
   says `display: block`, which beats the browser's own [hidden] rule, so hiding
   is said here explicitly; `display: none` keeps a lazy picture that is not
   shown from being fetched at all. */
.cc-mock-photo[hidden] {
  display: none;
}

/* The cup's flat label is the 60 by 40 mm label itself since task #994's
   rework: a landscape layer, so the name runs across the top in one row and
   the QR takes the middle, as on the drawn one. Sizes are in cqw of the layer,
   which is 1.5 times as wide as it is tall. */
.cc-mock-cup > .cc-mock-label {
  gap: 2cqw;
  padding: 4cqw 6cqw;
  font-size: 5.2cqw;
}

.cc-mock-cup .cc-mock-label-brand {
  flex-direction: row;
  gap: 0.4em;
}

.cc-mock-cup .cc-mock-qr {
  /* The same share as the drawn label's plate: 0.7 of its height. */
  width: 46.7%;
  padding: 0.15em;
}

/* THE ONE ELEMENT IN THE MOCKUP THAT IS PURE SHADING (Conrad's review: the cup
   sticker "needs to be slightly curved so it looks like it wraps around the
   cup").

   The label's OUTLINE already wraps: its clip is the label's own outline on
   the cup (task #994), whose top and bottom bow as a circle round a cup does. What was missing was the surface. A cylinder does two
   things to a flat label: it turns the vertical edges away from the viewer, so
   they fall into shade, and it puts a highlight where the surface faces the
   window. Both are a horizontal gradient, and a horizontal gradient over the
   artwork is all this is.

   THE STOPS ARE READ OFF THE PHOTOGRAPH, NOT INVENTED, and this is the part
   worth knowing. The cut-out is not empty: it is grey at alpha 127, and that
   grey is the light that fell on the cup. Sampled across the middle of the
   window it climbs from 21 to 108, left to right, so the picture ALREADY lays
   most of a cylinder's shading over whatever is underneath, and repeating it
   here would only make the sticker muddy. What the cut-out has no way of
   carrying is the two TURNING edges, where the surface bends away from the
   viewer and out of the light: on the left that lands where the picture is
   already darkest, so it needs a touch; on the right it lands on the brightest
   part, so it needs a lot. That asymmetry is the whole rule, and it is why
   this gradient is nothing like symmetrical.

   Everything here is halved on its way to the eye, because the photograph sits
   over it at half opacity. An amount that looks right in isolation is twice
   what should be written.

   Since task #994 this flat, shaded label is the FALLBACK. public/cup-warp.js
   lays a 3:2 copy of it out on a canvas and wraps it round the cup strip by
   strip, and only once that has been drawn does this label step aside (see
   .cc-mock-cupwarp below). Without the script, a canvas or its fonts, this is
   still what shows. */
.cc-mock-wrap {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: linear-gradient(
    to right,
    rgba(0, 0, 0, 0.38) 0%,
    rgba(0, 0, 0, 0.12) 6%,
    rgba(0, 0, 0, 0) 22%,
    rgba(255, 255, 255, 0.07) 60%,
    rgba(0, 0, 0, 0.12) 80%,
    rgba(0, 0, 0, 0.42) 93%,
    rgba(0, 0, 0, 0.64) 100%
  );
}

/* The flat fallback's shading was sampled across the old, much taller
   cut-out, whose edges turned well away; the label's edges are only about 40
   degrees round, so it is carried at half strength here. */
.cc-mock-cup .cc-mock-wrap {
  opacity: 0.5;
}

/* BARE CUP ROUND THE LABEL (task #994's rework). The photograph's cut-out is
   the shape of the old, taller label; this small image, derived from the
   photograph's own cup, covers all of it except the new label, and sits on top
   of everything else in the scene. Its box is in the style attribute. */
.cc-mock-cup-patch {
  position: absolute;
  display: block;
  pointer-events: none;
}

/* public/cup-warp.js sets `hidden` on the patch until the photograph has
   loaded, and for good if the patch fails. The rule above says `display:
   block`, which beats the browser's own [hidden] rule, so hiding is said here
   explicitly; and as `visibility`, not `display: none`, so a lazy patch still
   loads while it waits instead of starting only once it is shown. */
.cc-mock-cup-patch[hidden] {
  visibility: hidden;
}

/* THE CUP, WRAPPED (task #994). public/cup-warp.js draws the 60 by 40 mm
   label onto this canvas already wrapped round the cup's front and already cut
   to the label's own outline, so all this rule does is put the canvas over the
   label (its box is in the style attribute, as percentages of the picture) and
   keep it out of the way of the pointer. It is `hidden` until a draw has finished, and only then does the
   scene say so and the flat label step aside. */
.cc-mock-cupwarp {
  position: absolute;
  display: block;
  pointer-events: none;
}

.cc-mock-cupwarp[hidden] {
  display: none;
}

/* `visibility` rather than `display`, so taking the flat label away changes
   nothing about the layout and bringing it back is instant. */
.cc-mock-scene[data-cc-cupwarp-state='drawn'] > .cc-mock-cup {
  visibility: hidden;
}

.cc-pack-fine {
  max-width: 44rem;
  margin-left: auto;
  margin-right: auto;
}

/* --- the ask ---------------------------------------------------------------

   A BRAND-PURPLE STRIP BETWEEN THE WORKING AREA AND THE FOOTER (task #581,
   Sam), and the only place on this page that asks for anything.

   IT IS A BAND, like the three above it, so it runs to both edges of the window
   and meets the white working area and the ink footer with no gap on either
   side. Read down the page the sequence is lilac, ink, white, purple, ink: the
   ask is the one thing between the card and the end of the page, in the one
   colour nothing else on the page is.

   --brand-fixed, NEVER --brand. That variable is overridable per merchant, and
   this is LoyalPack asking on its own behalf on a public page, so it takes the
   fixed purple the step markers take. White on it measures 7.8:1.

   `hidden` UNTIL THE CARD IS FINISHED, which is the script's business and not
   this rule's; all that is here is the display it takes when it is shown. */
.cc-claim {
  display: grid;
  justify-items: center;
  gap: 6px;
  padding-block: 44px;
  background: var(--brand-fixed);
  color: #fff;
  text-align: center;
}

.cc-claim[hidden] {
  display: none;
}

/* Wide enough for the sentence to hold one line on a desktop, narrow enough
   that it breaks into two rather than running the width of the window. */
.cc-claim-title {
  max-width: 34rem;
  margin: 0 0 14px;
  font-size: 1.7rem;
  line-height: 1.2;
}

/* THE BUTTON, and it is white on the purple rather than purple on it: this is
   the one action in the strip, so it is the lightest thing in it. Auto width
   around its own words, because a full-bleed band would otherwise give it the
   whole window. */
.cc-cta {
  display: inline-block;
  padding-inline: 32px;
  border-color: #fff;
  background: #fff;
  color: var(--brand-fixed);
}

.cc-cta:hover,
.cc-cta:focus-visible {
  border-color: #fff;
  background: #fff;
  color: var(--brand-fixed);
  opacity: 0.92;
}

/* The focus ring has to be visible against BOTH the white button and the purple
   band, so it is drawn in white with an offset that puts it on the purple. */
.cc-cta:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}

/* THE HONEST LINES, on the band rather than on the page, so they take a white
   at the alpha that still reads: 0.86 measures 6.25:1 on --brand-fixed, and it
   is plainly quieter than the button and the headline above it. The number is
   pinned in test/create-card.test.ts against src/contrast.ts. */
.cc-claim .cc-fine {
  max-width: 40rem;
  margin-top: 14px;
  --cc-claim-fine-ink: rgba(255, 255, 255, 0.86);
  color: var(--cc-claim-fine-ink);
}

@media (max-width: 720px) {
  .cc-claim {
    padding-block: 34px;
  }

  .cc-claim-title {
    font-size: 1.4rem;
  }

  /* Full width on a phone, where there is no room for a button to sit in the
     middle of a band and still be the size a thumb wants. */
  .cc-cta {
    display: block;
    width: 100%;
    box-sizing: border-box;
  }
}

/* --- the same ask, as a strip under the stage bar (task #597, Sam) ----------

   THE PROMISE ON THE LEFT AND THE BUTTON ON THE RIGHT, and nothing else in it.
   It is a band on this page like the three it sits among: rendered between the
   four-step nav and the working area, so it meets the ink stage bar above it
   and the columns below it with no gap on either side. It began first inside
   .cc-page, hard under the header, where it read as part of the chrome rather
   than as part of the page; the review moved it here and changed nothing else.

   SHOWN AND HIDDEN BY THE SAME RULE AS THE STRIP AT THE FOOT, which is not this
   stylesheet's business either: it carries `data-cc-cta`, the script reveals
   every element with that hook on complete(lastStage), and all that is here is
   the display it takes when it is shown. The pair is `display: flex` and
   `[hidden] { display: none }` for the same reason the pair below it exists:
   the `hidden` attribute's own rule is weaker than a display declaration.

   --brand-fixed and white, exactly the strip below: LoyalPack asking on its own
   behalf on a public page, at 7.8:1, in the one colour nothing else here is. */
.cc-topclaim {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 20px;
  /* padding-BLOCK, never the shorthand: the inline padding is what .cc-band
     uses to put the contents back on the page's column, and a shorthand here
     would wipe it and run the sentence to the window's own edge. */
  padding-block: 14px;
  background: var(--brand-fixed);
  color: #fff;
}

.cc-topclaim[hidden] {
  display: none;
}

/* Not a heading and not sized like one: this is a bar, and the strip at the
   foot of the page is where the ask is made at size. */
.cc-topclaim-line {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 600;
  line-height: 1.3;
}

@media (max-width: 720px) {
  .cc-topclaim {
    justify-content: center;
    padding-block: 12px;
    text-align: center;
  }

  /* AND THE BUTTON STAYS ITS OWN WIDTH HERE. The rule above sends .cc-cta full
     width on a phone, which is right in a band somebody has landed on at the
     end of the page and wrong in a bar whose whole job is to be short. Two
     classes beat one, so this wins without an !important. */
  .cc-topclaim .cc-cta {
    display: inline-block;
    width: auto;
  }
}

/* --- the footer ------------------------------------------------------------

   AN INK STRIP (Sam), and reached by `.cc-page .footer` rather than by `.footer`
   itself: PublicFooter is shared with the landing page and the whole support
   section (task #574), and those two keep the footer they have. This rule
   touches the one instance inside this page's own root and no other.

   It is a band like the three above it, so it meets the working area with no
   gap and runs to both edges of the window. */
.cc-page .footer {
  margin: 0;
  padding-block: 30px;
  margin-inline: calc(50% - 50vw);
  padding-inline: calc(50vw - 50%);
  background: var(--ink);
  color: rgba(255, 255, 255, 0.72);
  border-top: 0;
}

.cc-page .footer .footer-tagline {
  color: rgba(255, 255, 255, 0.72);
}

.cc-page .footer a {
  color: #fff;
}

/* --- the sign-up page, previewed -------------------------------------------

   The piece a visitor has not seen yet and the one that makes the card real:
   where a customer actually lands from the counter QR. Flat, like the rest of
   the showcase, and honest about it in the copy below. The two things under the
   offer are a SHAPE of a form and not a form: no inputs, no names, nothing that
   could be typed into or submitted, on a page whose whole argument is that it
   has neither. */
.cc-enrol {
  min-height: 128cqw;
  justify-content: center;
  gap: 0.9em;
}

.cc-enrol-offer {
  margin: 0;
  font-size: 1.25em;
  font-weight: 700;
  line-height: 1.25;
}

.cc-enrol-line {
  margin: 0;
  opacity: 0.85;
}

.cc-enrol-mock {
  display: grid;
  gap: 0.5em;
  width: 100%;
  margin-top: 0.4em;
}

.cc-enrol-field {
  border: 1px solid currentColor;
  border-radius: 0.45em;
  padding: 0.55em 0.7em;
  text-align: left;
  opacity: 0.6;
}

.cc-enrol-btn {
  border-radius: 0.45em;
  padding: 0.6em 0.7em;
  font-weight: 700;
  background: var(--cc-pack-ink, #fff);
  color: var(--pass-bg, var(--brand-fixed));
}

/* --- public footer links (task #574) ---

   The public pages ended on one muted sentence in `.footer`. They still do; the
   sentence has gained a row of links under it (Terms, Privacy, Support), which
   is what makes the new support section reachable from a page somebody landed
   on without an account. Everything here is additive: `.footer` itself is
   untouched, so the two pages that carried the sentence alone look the same
   above the new row. */
.footer-tagline {
  margin: 0;
}

.footer-links {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px 18px;
  margin-top: 10px;
}

.footer-links > a {
  color: var(--muted);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.footer-links > a:hover {
  color: var(--ink);
}

/* --- the support section's own title band (task #574 review, Sam) ---

   TWO THINGS THE APP'S STRIP DOES NOT DO, and both are asked for by name: the
   search runs the full width of the page content, and the band is the same
   height on every page in the section.

   THE WIDTH COMES FOR FREE, once the search is on its own line. `.pagehead-inner`
   and `.container` are the same measure with the same shared gutter (both held
   to that by test/content-grid.test.ts), so a form at `width: 100%` inside the
   band lines up with the content below it exactly, at every breakpoint, without
   this file naming a single pixel.

   THE HEIGHT COMES FROM THE BAND CARRYING ONLY WHAT DOES NOT VARY. The interior
   stacks at every width rather than only on a phone, so there is one layout to
   reason about instead of two: a title, then a full-width search. The line that
   used to sit between them was a page summary, one sentence on one page and
   three on another, and it is now the first paragraph of the content column
   (`.support-lede`). Moving it was the alternative to clamping it, which would
   have bought the same height by hiding the end of somebody's sentence. */
.support-pagehead .pagehead-row {
  flex-direction: column;
  align-items: stretch;
  gap: 18px;
}

/* A TITLE'S WORTH OF RESERVED HEIGHT. At the full measure every title in the
   library is one line, so this changes nothing there. On a phone the longer
   article titles take two, and without a floor the band would shrink by a line
   on "Support" and grow again on the next page. Two lines are reserved and a
   third is still allowed to push: the ask was a stable height, not a truncated
   heading. */
.support-pagehead .pagehead-title {
  min-height: 1.2em;
}

.support-search {
  width: 100%;
  margin: 0;
}

/* The page's opening line, evicted from the band so the band could stop
   changing height. It keeps the reading measure the article body uses, because
   it is the same kind of text. */
.support-lede {
  max-width: 66ch;
  /* Room under it, because it introduces what follows rather than being the
     first item of it: at 4px the opening line of a category page sat against
     the first card as if it were a caption on one. */
  margin: 0 0 20px;
  color: var(--muted);
  font-size: 1.05rem;
  line-height: 1.5;
}

/* It answers the box in the band above it, and it is now the first thing in
   the content column rather than a caption under a centred form, so it starts
   where the content starts. */
.support-count {
  margin: 0 0 4px;
  color: var(--muted);
  font-size: 0.95rem;
}

.support-section-title {
  font-size: 1.25rem;
  margin: 36px 0 14px;
}

/* --- the trail under the title band (task #574 review, Sam) ---

   A body-level strip like the band above it, for the same reason: it is a
   sibling of the content column, not a child, so it spans the surface it is
   drawn on and its interior re-centres to the same grid as `.container` and
   `.pagehead-inner`.

   IT IS A BAND OF ITS OWN NOW, IN THE LILAC (task #843, Sam: a dedicated
   breadcrumb strip directly below the purple header, in either the lilac or a
   light ink, whichever fits accessibly). It was the page's own pale grey, which
   made it the first thing on the page rather than the last row of the header;
   with the header strip now sitting at the top of a content area beside a menu
   column, the stack wanted a closing step rather than an opening one.

   THE LILAC IS --strip, AND IT IS THE ONE THE PURPLE BAND ABOVE IT ALREADY
   USES AS ITS QUIET INK (task #760). So nothing joins the palette: the two
   bands are the section's two colours in the other order, which is what makes
   them read as one header. Measured on --strip (#ddd6f0), with the product's
   own src/contrast.ts:

     --ink          13.26:1   the page the reader is on
     --strip-muted   6.63:1   the crumbs above it, the separators, the way up
     --brand-fixed   5.59:1   any of them under the pointer

   THE WHOLE ROW CHANGES INK WITH THE BAND. The page's --muted, which the crumbs
   were drawn in when they sat on the pale grey, clears AA here too, but at
   4.60:1 it is a tenth of a point of margin at 0.9rem; --strip-muted has half
   again as much and is the colour the band above is already declared with.

   A light ink would have put white text on a dark band directly under a dark
   band, which is two dark bands with a seam in them; and the ink it would have
   had to be (--ink-brand) is the footer's, at the other end of the page.

   The separator is a character in the markup, marked aria-hidden, so nothing
   reads punctuation aloud between the links and nothing depends on this
   stylesheet having arrived for the trail to read as a trail. */
.support-crumbs {
  background: var(--strip);
}

.support-crumbs-inner {
  /* Flex only to get the wrapping and the gap. The trail is already horizontal
     without it: it is inline links and inline separators, which is the whole
     point of building it that way (see SupportCrumbs in views/support.tsx). */
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  max-width: var(--content-max);
  margin: 0 auto;
  /* THE SHARED GUTTER, NOT A LITERAL 16px (task #843). The strip used to name
     the number, which was the same number every other interior was on. It is
     not any more: the section is on the app shell, whose gutter is 30px, so a
     trail naming 16 would start two centimetres inside the title above it and
     the content below it. The band is symmetrical now as well, because it is a
     band rather than the top of the page: it had no bottom padding at all when
     what was under it was the page's own colour. */
  padding: 13px var(--content-gutter);
  font-size: 0.9rem;
  line-height: 1.4;
}

/* The same muted tone as the links it sits between, which is what makes the
   trail read as one path: the only thing that stands out along it is the page
   the reader is on. It carries its own spaces, so it is still punctuation with
   air around it if this rule never arrives. */
.support-crumbs-sep {
  color: var(--strip-muted);
  padding: 0 2px;
}

.support-crumbs a {
  color: var(--strip-muted);
  text-decoration: none;
}

.support-crumbs a:hover {
  color: var(--brand-fixed);
  text-decoration: underline;
}

/* The page itself: the end of the trail, and never a link. */
.support-crumbs [aria-current='page'] {
  color: var(--ink);
  font-weight: 600;
}

/* THE FIRST HEADING IN THE COLUMN KEEPS NO TOP MARGIN.
   `main.container > :first-child { margin-top: 0 }` cannot reach these two: on
   the landing page the column opens with the count line, which carries `hidden`
   until somebody searches, so the block the reader actually sees first is the
   SECOND child and its heading's 36px lands on top of the column's own 30px.
   Scoped to the two blocks that can open the column rather than loosened at the
   boundary rule, which is shared with every page in the product. */
[data-support-browse] > .support-section-title:first-child,
.support-results > .support-section-title:first-child {
  margin-top: 0;
}

/* --- the support section's shell (task #843, Sam) ---------------------------

   Sam's ask: rebuild the section with the authenticated web app's layout. The
   menu stands down the left in the ink instead of running across the top, the
   support navigation moves into it, the purple strip becomes the top of the
   right-hand content area, the trail gets a band of its own directly under it,
   the way into the product is one pinned link at the foot of the column, and
   the section ends on the app's own footer.

   ALMOST NONE OF THAT IS WRITTEN HERE, AND THAT IS THE POINT. The section now
   renders `.topnav.appnav` inside `<body class="app-shell support-shell">`,
   which is exactly the pair task #831's shell is selected by, so the grid, the
   pinned full-height ink column, the strip starting at the menu's inside edge,
   the 268px width and its 228px step on a small laptop, the phone treatment
   below 721px and the footer's dark brand tone (task #832) all arrive with no
   new rule. What is below is the two things the app cannot answer for this
   section: it has ONE MORE BAND than an app page (the trail), and its menu is a
   two-level library rather than a flat row of destinations.

   THE SECTION IS STILL PUBLIC. `app-shell` is a layout switch and nothing else
   reads it; routes/support.tsx is untouched, and a signed-out reader gets the
   same page a signed-in one does apart from the footer's contents.

   ONE BREAKPOINT, AT 721px, WHICH IS THE SHELL'S OWN. The section used to have
   its own at 768; that is gone, so there is no width at which the reader gets
   the app's phone treatment and this section's tablet one, or the other way
   round. A tablet now gets the ink column, exactly as the app does. */

/* ONE MORE BAND, SO ONE MORE ROW. The app's shell is three rows (the strip, the
   content, the footer); the trail is a fourth, between the first and the
   second, so the three below it each move down one.

   THE SELECTOR CARRIES `support-shell` AS WELL, AND HAS TO: these rules correct
   placements the shell has already made, and a rule that only tied on
   specificity would be settled by which one happens to be written later in this
   file. The class is on <body> for this section only (Layout's `bodyClass`). */
@media (min-width: 721px) {
  body.app-shell.support-shell:has(.appnav:not(.appnav-plain)) {
    grid-template-rows: auto auto 1fr auto;
  }

  body.app-shell.support-shell:has(.appnav:not(.appnav-plain)) > .support-crumbs {
    grid-column: 2;
    grid-row: 2;
  }

  body.app-shell.support-shell:has(.appnav:not(.appnav-plain)) > main.container {
    grid-row: 3;
  }

  body.app-shell.support-shell:has(.appnav:not(.appnav-plain)) > .app-footer {
    grid-row: 4;
  }

  /* THE BAR ITSELF DOES NOT SCROLL; THE LIST INSIDE IT DOES. The app's column
     scrolls as a whole on a short window, which is right when everything in it
     is one list. Here the last item is pinned to the bottom edge, and a pinned
     item that scrolls away is not pinned: the wordmark and the way back hold
     still, and the guides between them take the overflow. */
  body.app-shell.support-shell:has(.appnav:not(.appnav-plain)) > .appnav {
    overflow: hidden;
  }

  /* The division under the wordmark, which on an app page is the item list's
     own top border (task #831): the logo, a space, a border, a space, then the
     items. Same colour, same job, one element further out because this menu has
     a foot as well as a body. */
  .support-menu {
    border-top: 1px solid rgba(255, 255, 255, 0.16);
  }
}

/* THE COLUMN'S BODY: the guides, then the way back at the bottom of it. Both
   widths use this, because the drawer a phone slides in IS this element; only
   its position changes. `min-height: 0` on both is what lets the list actually
   scroll rather than stretching its parent, and the `auto` margin is what pins
   the foot without taking it out of flow, so it can never overlay a row. */
.support-menu {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
}

.support-side {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* THE ROWS ARE THE APP'S ROWS, and deliberately to the pixel: 14px + 24px + 14px
   is the 52px item the menu column has carried since task #831, the 16px is
   Sam's gap between a glyph and its word, and white at 1rem/600 is the bar's own
   typography. They are restated rather than shared with a selector list because
   the app's items are anchors inside `.nav-links` and these are two levels of
   list; test/support-shell.test.ts reads both blocks out of this sheet and holds
   the numbers equal, which is the part that would otherwise drift. */
.support-side-home,
.support-side-topic > a,
.support-menu-back {
  position: relative;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 14px 24px;
  color: #fff;
  font-size: 1rem;
  font-weight: 600;
  line-height: 24px;
  text-decoration: none;
}

/* THE WAY BACK IS THIS MENU'S FOOT GROUP, and it is the app's (task #831
   review, Sam: "move both account and logout to fixed position on the bottom of
   the vertical menu... perhaps smaller"). One item rather than two, because this
   section has one thing to say down there, but the same treatment: held at the
   foot, divided from the list above by the bar's own 16% border, and a step
   smaller than the rows it follows, which is 15px type in a 44px row against
   16px in a 52px one. Exactly the touch floor, and the same arithmetic the
   horizontal bar's items use.

   `padding-block` alone, for the app's reason: the left edge these labels start
   on is the COLUMN's, and it steps to 18px on a small laptop with everything
   else in the bar. The shorthand here would outrank that step and hold this one
   row 24px in while the guides above it moved.

   ONE DIVERGENCE FROM `.nav-foot`, DELIBERATE. The app holds its group down with
   `flex: 1 0 auto` on the list above and lets the whole bar scroll when there is
   no slack; that is right for eight items, which almost always fit. This menu is
   a library: an open topic can be seventeen rows, which does not fit a 900px
   window, and a foot that scrolls away is not a foot. So the LIST scrolls inside
   itself (see .support-side) and the bar does not. */
.support-menu-back {
  flex: none;
  margin-top: auto;
  padding-block: 10px;
  font-size: 0.9375rem;
  border-top: 1px solid rgba(255, 255, 255, 0.16);
}

.support-side-list,
.support-side-guides {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* The subtle division between items, white at 7% on the ink, exactly as the
   app's column draws it. It is on the topics only: All support is above the
   first of them, so no rule doubles the bar's own border under the wordmark,
   and the guides inside an open topic are one block rather than more rows. */
.support-side-topic > a {
  border-top: 1px solid rgba(255, 255, 255, 0.07);
}

/* The open topic is the one being read, and it keeps its emphasis whether or
   not it is itself the current row: on an article page the highlighted row is
   one of its children, and this is what says whose child. */
.support-side-open > a {
  font-weight: 700;
}

/* THE RULE THAT SAYS WHERE YOU ARE, and it is one rule for all three depths:
   the guide on an article page, the topic on a category page, All support on
   the landing page. A 3px square-ended purple bar down the leading edge, which
   is the mark the app's own column uses, for the reason it uses it: purple on
   ink is 2.37:1, so the colour is the mark and never the text. The label stays
   white and aria-current says it in the markup, so the bar is never the only
   signal.

   THE THIRD SIGNAL, the brand fill behind the row, is not here: it belongs to
   the VERTICAL column and is written once for both menus, in the app shell
   block near the top of this sheet (search --nav-current-fill). This rule is
   what the phone drawer keeps, and it is what the fill is drawn on top of. */
.support-side [aria-current='page'] {
  font-weight: 700;
}

.support-side [aria-current='page']::after {
  content: '';
  position: absolute;
  /* top / right / bottom / left. -1px covers the divider above, so the mark is
     one unbroken bar rather than one with a notch in it. */
  inset: -1px auto 0 0;
  width: 3px;
  background: var(--brand-fixed);
}

/* A guide inside its topic: indented past the label above it, a step quieter
   and a step smaller, so the two levels are told apart by more than position.
   White at 78% is 14.5:1 on the ink, so "quieter" is a hierarchy and never a
   legibility trade. */
.support-side-guides > li > a {
  position: relative;
  display: block;
  padding: 10px 24px 10px 44px;
  color: rgba(255, 255, 255, 0.78);
  font-size: 0.95rem;
  line-height: 22px;
  text-decoration: none;
}

/* A SMALL LAPTOP NARROWS THESE ROWS WITH EVERY OTHER ROW IN THE COLUMN. The
   shell's own step takes the column from 268px to 228px and pulls the wordmark
   and the app's items in to 18px; these rows are not those items, so they need
   saying, at the same width and by the same number. The guides keep their
   indent under the topic above them, one gutter further in. */
@media (min-width: 721px) and (max-width: 1023px) {
  .support-side-home,
  .support-side-topic > a,
  .support-menu-back {
    padding-inline: 18px;
  }

  .support-side-guides > li > a {
    padding-inline: 38px 18px;
  }
}

/* Hover turns the label LoyalPack purple, with no box, no background and no
   underline: the app bar's own interaction, which Sam settled in task #449 and
   which is pointer-only, transient, and carries nothing a keyboard or screen
   reader user depends on. Behind a pointer query because a :hover that sticks
   after a tap leaves a lit row on a page the reader has already left. */
@media (hover: hover) {
  .support-side a:hover,
  .support-menu-back:hover {
    color: var(--brand-fixed);
  }
}

/* White, like every other focus ring on this ink (the phone overlay's, task
   #458). The page's --brand outline the column used to draw is 2.37:1 here and
   would have been a keyboard indicator nobody could see. */
.support-side a:focus-visible,
.support-menu-back:focus-visible {
  outline: 2px solid #fff;
  outline-offset: -2px;
}

/* The content column is one column now: the guides are the shell's menu, so
   what is left in <main> is the page. This is all that survives of the grid
   that used to hold the two side by side, and it survives for the reason it was
   written - a wide screenshot inside the column shrinks to it rather than
   pushing the page sideways. */
.support-main {
  min-width: 0;
}

/* --- the way back, beside the trail ---

   The trail says where you are; this says the one place worth going from here,
   which on a guide is its topic and on a topic is the section. It sits at the
   far end of the same line rather than under the title, so the row reads as one
   navigation strip instead of two. */
.support-crumbs-trail {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}

.support-return {
  margin-left: auto;
  padding-left: 16px;
  /* The trail's own quiet ink, since this now sits on the lilac band with it
     (task #843), rather than the page's --muted it shared with the crumbs
     before: see the note on the band above for why the whole row changed ink. */
  color: var(--strip-muted);
  white-space: nowrap;
  text-decoration: none;
}

.support-return:hover {
  color: var(--brand-fixed);
  text-decoration: underline;
}

/* --- below 721: the menu becomes a drawer ----------------------------------

   ONE BREAKPOINT, AND IT IS THE SHELL'S (task #843). This block was at 767,
   which was this section's own edge and a width the rest of the product knows
   nothing about; the column is now the app's menu bar, so it becomes a drawer
   at exactly the width the app's becomes a burger. Between 721 and 767 a tablet
   now gets the ink column at its 228px step rather than a drawer, which is what
   the app does at that width.

   IT SLIDES IN FROM THE LEFT, and the switch is one attribute that only
   public/support.js ever writes, plus `:target` for a page where that file
   never arrives. The drawer is simply what the menu IS below this width; the
   script only opens and closes it, so nothing here can strand a reader behind a
   control that cannot open. */
@media (max-width: 720px) {
  .support-return {
    /* Wraps under the trail rather than squeezing it onto two lines. */
    margin-left: 0;
    padding-left: 0;
    width: 100%;
  }

  /* OFF CANVAS BY DEFAULT, WITH NO SCRIPT INVOLVED. What slides is the menu's
     whole body, guides AND the way back, so a phone reader gets the same menu a
     desktop one does rather than most of it. It keeps the ink it wears in the
     column, because it is the same column: a white drawer sliding out of a dark
     bar would read as a second, unrelated thing. */
  .support-menu {
    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 40;
    width: min(82vw, 300px);
    padding: 12px 0;
    border-right: 1px solid rgba(255, 255, 255, 0.16);
    background: var(--ink);
    transform: translateX(-100%);
    transition: transform 200ms ease;
  }

  /* ROOM FOR THE HANDLE TO SIT ON. It is fixed at the middle of the viewport,
     so whatever is scrolling past is what it covers, and without this it was
     the first character or two of every line it passed. Both the content and
     the trail move over to clear its 26px; they live in different parts of the
     document, which is why nothing here is scoped to the page block.

     THE TRAIL CARRIES THE GUTTER AND THE CONTENT SITS INSIDE ONE, which is why
     they are not the same number. 42px from the edge of the glass, both, with
     the column's own gutter subtracted from the one that is already inside it:
     before this the two were 42 and 58 and the trail was visibly out of line
     with the text under it. */
  .support-crumbs-inner {
    padding-left: 42px;
  }

  .support-main {
    padding-left: calc(42px - var(--content-gutter));
  }

  /* OPEN, TWO WAYS. `:target` is the one that needs no script at all: the handle
     is a link to the menu's own id, so pressing it with JavaScript off still
     brings the drawer in. With the script the click is intercepted, the hash
     never changes, and the root attribute drives it instead, which is what
     carries aria-expanded, Escape and the press-outside-to-close. */
  .support-menu:target,
  [data-support-drawer='open'] .support-menu {
    transform: translateX(0);
  }

  /* The dimmer. A pseudo-element on the page root rather than a div, because
     the click that closes the drawer is already handled on the document and
     this only has to darken what is behind it. */
  [data-support-drawer='open']::after {
    content: '';
    position: fixed;
    inset: 0;
    z-index: 39;
    background: rgba(21, 17, 27, 0.4);
  }

  /* THE HANDLE, ATTACHED TO THE VERY LEFT EDGE (Sam). Fixed, so it is on the
     edge of the VIEWPORT rather than of the column, and vertically centred so a
     thumb finds it without looking. It travels with the drawer, which is what
     makes it read as the drawer's own edge rather than a button that happens to
     sit near it. */
  .support-drawer-toggle {
    position: fixed;
    /* CENTRED AGAINST THE SMALL VIEWPORT, WHICH IS THE ONE THAT HOLDS STILL
       (task #605, Sam: the button moves when you scroll up and down). Being
       fixed was never the problem: the handle does not drift with the content.
       But `top: 50%` is half of the viewport HEIGHT, and on a phone that height
       is what scrolling changes, because the browser retracts its address bar
       on the way down and brings it back on the way up. Half of that swing is
       how far the handle slid. `svh` is the height with the browser's own bars
       shown, and it does not change when they retract, so the offset is now a
       constant. The percentage stays underneath it for anything too old to know
       the unit: there the behaviour is exactly what it was. */
    top: 50%;
    top: 50svh;
    left: 0;
    z-index: 41;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 60px;
    margin: 0;
    padding: 0;
    border: 1px solid var(--line);
    border-left: none;
    background: var(--card);
    color: var(--brand-fixed);
    /* THE HANDLE IS A LINK, so the browser underlines it, and that underline
       propagates to the chevron span. The span is what rotates when the drawer
       opens, and a propagated decoration is drawn with the box it lands on: the
       line sat under the chevron closed and ABOVE it open, which is exactly what
       Sam reported. The handle reads as a button, so it takes a button's
       decoration. The border here is the handle's own shape and stays. */
    text-decoration: none;
    font-size: 1.3rem;
    line-height: 1;
    cursor: pointer;
    transform: translateY(-50%);
    transition: left 200ms ease;
  }

  .support-drawer-toggle[aria-expanded='true'] {
    left: min(82vw, 300px);
  }

  .support-drawer-chevron {
    display: block;
    transition: transform 200ms ease;
  }

  .support-drawer-toggle[aria-expanded='true'] .support-drawer-chevron {
    transform: rotate(180deg);
  }

  .support-drawer-toggle:focus-visible {
    outline: 2px solid var(--brand);
    outline-offset: 2px;
  }
}

/* The handle belongs to the drawer, and above the breakpoint there is no
   drawer: the column is simply there. */
@media (min-width: 721px) {
  .support-drawer-toggle {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .support-menu,
  .support-drawer-toggle,
  .support-drawer-chevron {
    transition: none;
  }
}

/* --- the topic tags (task #574 review, Sam) ---

   A row of pills that wraps, replacing a three-column grid of tiles that each
   carried a summary and a guide count. Seven of those filled most of the first
   screen and pushed the guides themselves under the fold, to say two things
   nobody was acting on: the summary is repeated on the page the tile led to,
   and "3 guides" is not a number anybody chooses by.

   No breakpoints, because there is nothing to rearrange: the row simply wraps
   at whatever width it is given, which is the one layout behaviour a tag cloud
   needs and the reason it replaces three grid declarations.

   The resting pill is the card surface with the section's own line colour,
   like every other bordered thing on these pages; hover fills it with the
   lilac the title band is painted in, so a topic lights up in the colour of the
   header it belongs to. Behind a pointer query, because a :hover that sticks
   after a tap leaves a lit pill on a page the reader has already left. */

.support-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.support-tag {
  display: inline-flex;
  align-items: center;
  padding: 9px 16px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--card);
  color: var(--brand-fixed);
  font-size: 0.95rem;
  font-weight: 600;
  line-height: 1.3;
  text-decoration: none;
  transition: border-color 160ms ease, background 160ms ease;
}

@media (hover: hover) {
  .support-tag:hover {
    border-color: var(--brand-fixed);
    background: var(--strip);
  }
}

.support-tag:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* --- the article rows --- */

.support-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.support-item > a {
  display: flex;
  flex-direction: column;
  gap: 4px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 14px 16px;
  color: inherit;
  text-decoration: none;
  /* Only the edge moves on this one. A row is not a tile and takes no lift:
     fifteen of them lifting in turn as a cursor runs down the list is noise. */
  transition: border-color 160ms ease;
}

.support-item-title {
  font-weight: 700;
  color: var(--brand-fixed);
}

.support-item-summary {
  color: var(--muted);
  font-size: 0.93rem;
  line-height: 1.45;
}

@media (hover: hover) {
  .support-item > a:hover {
    border-color: var(--brand);
  }
}

.support-item > a:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

.support-group {
  margin-top: 24px;
}

.support-group-title {
  font-size: 1rem;
  margin: 0 0 10px;
  color: var(--muted);
  font-weight: 700;
}

/* A heading that happens to be a link, not a link in a heading: the underline
   is kept for the hover, where it says the word is clickable, and taken off the
   resting state, where seven underlined phrases down the page read as a list of
   links rather than as the structure of one. */
.support-group-title > a {
  color: inherit;
  text-decoration: none;
}

.support-group-title > a:hover {
  text-decoration: underline;
}

/* The landing page's full index: titles alone, tighter rows. See the note on
   SupportRow in views/support.tsx for why this one list is compact. */
.support-list-compact {
  gap: 6px;
}

.support-list-compact .support-item > a {
  padding: 10px 14px;
}

.support-empty {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 20px;
  color: var(--muted);
  margin-top: 16px;
}

/* A READING MEASURE, AND IT IS ON THE TEXT RATHER THAN ON THE COLUMN. The
   public column is 960px, which is right for a hero and a grid of tiles and
   much too wide for prose: a line of body text that long is hard to track back
   from at the end of it. So every text block in an article is capped at 66ch.

   THE CAP IS ON THE BLOCKS AND NOT ON THEIR CONTAINER because a screenshot is
   not text (task #574 follow-up). A picture earns the whole column: at the
   width of the prose the labels inside these crops are too small to read, which
   defeats the point of showing them. Capping the container instead would have
   left a figure only able to reach the column by overflowing its own parent,
   which is the kind of trick that stops working the first time anything above
   it grows an `overflow` of its own. */
.support-body > p,
.support-body > h2,
.support-body > .support-steps,
.support-body > .support-points,
.support-body > .support-aside,
.support-figure figcaption {
  max-width: 66ch;
}

.support-body h2 {
  font-size: 1.2rem;
  margin: 28px 0 10px;
}

.support-body p {
  line-height: 1.6;
  margin: 0 0 14px;
}

.support-steps,
.support-points {
  margin: 0 0 16px;
  padding-left: 22px;
  line-height: 1.6;
}

.support-steps > li,
.support-points > li {
  margin-bottom: 8px;
}

/* The one aside treatment, on the shared note tokens the settings pages use, so
   a pulled-out line in an article looks like a pulled-out line everywhere.
   NOT `.support-note`: that name has belonged to the signed-in settings pages'
   help block since task #462, and this is a paragraph in a public article. */
.support-aside {
  background: var(--note);
  border-left: 3px solid var(--note-edge);
  border-radius: 0;
  padding: 12px 16px;
  margin: 0 0 16px;
  line-height: 1.6;
}

.support-others {
  margin-top: 36px;
}

.support-others ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 8px 16px;
}

.support-related {
  margin-top: 36px;
}

.support-contact {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 24px;
  margin: 40px 0 8px;
}

.support-contact h2 {
  margin: 0 0 8px;
  font-size: 1.2rem;
}

.support-contact p {
  max-width: 66ch;
  line-height: 1.6;
}

@media (max-width: 720px) {
  .support-pagehead .pagehead-title {
    min-height: 2.4em;
  }
}

@media (prefers-reduced-motion: reduce) {
  .support-tag,
  .support-item > a {
    transition: none;
  }
}

/* --- support article screenshots (task #574 follow-up) ---

   Every picture in an article is a real screenshot of this product, captured by
   scripts/capture-support-shots.ts and served from public/support. They run the
   full content column rather than the narrower measure the prose is capped to,
   which is what the rule above exists to allow: these are element crops carrying
   real interface labels, and at the width of a line of text those labels stop
   being readable, which is the whole reason for showing a picture at all.

   `max-width` and not `width`, so a shot narrower than the column is never
   blown up past its own pixels. `height: auto` with the width and height
   attributes the view prints is what lets the browser hold the right space
   before the bytes land; the two together are the whole reason the text does
   not jump. */
.support-figure {
  margin: 0 0 20px;
}

.support-figure img {
  display: block;
  max-width: 100%;
  height: auto;
  border: 1px solid var(--line);
  border-radius: 0;
  /* The crops carry the app's own pale page colour to their edges, so a white
     plate behind a transparent edge is not what this is for: it is what a
     failed load shows instead of nothing. */
  background: var(--card);
}

.support-figure figcaption {
  margin-top: 8px;
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.5;
}

/* --- the support contact form (task #603, Sam) ---

   The "Still stuck?" box stopped being a mailto: link and became four fields,
   so this is the box's inside. The box itself is unchanged: same surface, same
   border, same square edge, same place at the foot of every page in the
   section.

   ONE COLUMN, AND NARROWER THAN THE PROSE. The fields are capped well short of
   the content column because a 60-character-wide email box is a box whose
   contents you cannot see the start of, and because a form that runs the full
   measure reads as the page's subject rather than as the thing you do when the
   page did not help.

   NOTHING HERE DRAWS A SHADOW, deliberately: the section is flat throughout,
   and test/programs-half-frame.test.ts holds the whole sheet to it. */
.support-contact-form {
  margin-top: 20px;
  max-width: 32rem;
}

.support-field {
  margin-bottom: 16px;
}

.support-field label {
  display: block;
  font-weight: 600;
  margin-bottom: 4px;
}

/* "(required)" is a word beside the label rather than a red star, so it needs
   no legend elsewhere on the page explaining what the star meant. Lighter than
   the label it qualifies, because it is not part of the field's name. */
.support-field-req {
  font-weight: 400;
  color: var(--muted);
}

.support-field-hint {
  margin: 0 0 6px;
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.5;
}

/* ABOVE the control, not below it. The error is named in the control's
   aria-describedby either way, so a screen reader reaches it regardless; a
   sighted reader on a phone with the keyboard up can have the space below the
   field covered, and a message they have to scroll to is a message they think
   is missing. */
.support-field-error {
  margin: 0 0 6px;
  color: var(--error);
  font-weight: 600;
  font-size: 0.92rem;
  line-height: 1.5;
}

.support-field input,
.support-field textarea {
  display: block;
  width: 100%;
}

/* The one control that is not a line. --control-height is the floor it
   inherits from the shared rule; these three are what a textarea gets wrong
   without them: it opens one row tall, it can be dragged wider than its own
   column, and its text sits tight against the top border because the shared
   rule's zero block padding was written for something 48px tall. */
.support-field textarea {
  min-height: 9rem;
  padding: 10px var(--control-pad-x);
  line-height: 1.55;
  resize: vertical;
}

/* The field that is wrong, marked on the control as well as above it: colour
   alone is not a signal everyone receives, and this pairs the red border with
   a sentence in words. aria-invalid rather than a class, so the attribute a
   screen reader reads and the border a sighted reader sees can never disagree.

   THE ELEMENT NAMES ARE LOAD-BEARING. `.support-field [aria-invalid='true']`
   is the obvious way to write this and it loses: an attribute selector counts
   in the class column, so it ties the shared field rule on classes and then
   loses on the element in `input[type='email']`. The result was a form where
   only the textarea turned red. Naming the elements here makes the two rules
   equal and lets source order, which this block is on the right side of,
   decide. */
.support-field input[aria-invalid='true'],
.support-field textarea[aria-invalid='true'] {
  border-color: var(--error);
}

/* The three things that can happen to a submission, in one treatment: it was
   refused, it was throttled, or the provider failed. All are the reader's
   problem to see, so all are red-bordered and none of them is a toast that
   disappears before it is read. */
/* SCOPED, and for the same reason the invalid rule above names its elements:
   `.support-contact p` sets the measure for the prose in this box and beats a
   bare class on the element in its selector, so an unscoped rule here would
   have had its width and its line height quietly overruled. */
.support-contact .support-contact-alert {
  margin: 0 0 16px;
  /* The same measure as the form it is about, rather than the wider one the
     paragraph above it takes: a message about four fields that runs past them
     reads as a message about the page. */
  max-width: 32rem;
  padding: 12px 14px;
  border: 1px solid var(--error);
  color: var(--error);
  font-weight: 600;
  line-height: 1.55;
}

.support-contact .support-contact-alert a {
  color: inherit;
}

.support-contact .support-contact-sent {
  margin: 0 0 16px;
  max-width: 32rem;
  padding: 12px 14px;
  border: 1px solid var(--success);
  color: var(--success);
  font-weight: 600;
  line-height: 1.55;
}

/* --- square edges across the support section (task #574 corrective patch) ---

   Sam reported rounded guide rows on a page whose stylesheet already said
   otherwise, and the cause was a stale cached copy of THIS FILE rather than the
   cascade. The fingerprinted asset URL is what actually fixes that. This block
   is the belt to that braces: every surface the section draws, named once, at
   the end of the sheet, so no rule shipped later in a future edit can quietly
   put a radius back on a guide row without also having to argue with this.

   TWO THINGS ARE DELIBERATELY NOT HERE. The search control keeps the shared
   --control-radius every field and button in the product uses, and the topic
   tags keep the pill they were asked for. Sam named both. */
body:has(.support-page) .support-item > a,
body:has(.support-page) .support-list,
body:has(.support-page) .support-results,
body:has(.support-page) .support-empty,
body:has(.support-page) .support-contact,
body:has(.support-page) .support-contact-alert,
body:has(.support-page) .support-contact-sent,
body:has(.support-page) .support-aside,
body:has(.support-page) .support-article,
body:has(.support-page) .support-figure,
body:has(.support-page) .support-figure img,
body:has(.support-page) .support-side,
body:has(.support-page) .support-side a,
body:has(.support-page) .support-menu-back,
body:has(.support-page) .support-main .card {
  border-radius: 0;
}

/* --- the public legal pages (task #488) ---

   /terms and /privacy, in the app's own chrome. They are the longest prose in
   the product by a wide margin, so the whole of this block is about making a
   long document navigable: a reading measure on the text, a contents list that
   gets a reader to a clause in one jump, and enough vertical rhythm between
   numbered sections that the eye can find the boundaries while scrolling.

   THE MEASURE IS ON THE TEXT, NOT ON THE COLUMN, for the same reason it is in
   the support section: the public column is 960px, which is right for a hero
   and much too wide to track a line of body copy back from. The summary and
   contents boxes are deliberately outside that cap, because both are scanned
   rather than read and both want the full width to keep their items short. */
.legal {
  margin: 0 0 8px;
}

.legal-meta {
  color: var(--muted);
  font-size: 0.92rem;
  margin: 0 0 6px;
}

.legal-lede {
  font-size: 1.08rem;
  line-height: 1.6;
  max-width: 66ch;
  margin: 0 0 24px;
}

.legal-summary {
  background: var(--note);
  border-left: 3px solid var(--note-edge);
  border-radius: 0;
  padding: 16px 20px;
  margin: 0 0 28px;
}

.legal-summary h2 {
  font-size: 1.05rem;
  margin: 0 0 10px;
}

.legal-summary ul {
  margin: 0;
  padding-left: 20px;
  line-height: 1.6;
}

.legal-summary li {
  margin-bottom: 8px;
}

.legal-summary li:last-child {
  margin-bottom: 0;
}

.legal-contents {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 16px 20px;
  margin: 0 0 32px;
}

.legal-contents h2 {
  font-size: 1.05rem;
  margin: 0 0 10px;
}

/* TWO COLUMNS WHERE THERE IS ROOM, one where there is not. A list of twenty-one
   clause headings is a tall wall on a phone and a thin ribbon on a desktop, and
   `columns` handles both without a media query or a grid whose item count has
   to be guessed. */
.legal-contents ul {
  list-style: none;
  margin: 0;
  padding: 0;
  columns: 2;
  column-gap: 32px;
  line-height: 1.5;
}

.legal-contents li {
  break-inside: avoid;
  margin-bottom: 6px;
}

@media (max-width: 640px) {
  .legal-contents ul {
    columns: 1;
  }
}

.legal-body > section {
  /* The clause a fragment link lands on should not sit under the sticky bar
     the app puts at the top of a page (task #602). */
  scroll-margin-top: 24px;
  margin-bottom: 32px;
}

.legal-body h2 {
  font-size: 1.2rem;
  margin: 0 0 12px;
  max-width: 66ch;
}

.legal-body h3 {
  font-size: 1rem;
  margin: 22px 0 8px;
  max-width: 66ch;
}

.legal-body p {
  line-height: 1.65;
  margin: 0 0 14px;
  max-width: 66ch;
}

.legal-body ul {
  margin: 0 0 16px;
  padding-left: 22px;
  line-height: 1.65;
  max-width: 66ch;
}

.legal-body li {
  margin-bottom: 8px;
}

/* The lead word of a defined item sits on its own line where the item is long,
   which is most of them in the list of providers, so the emphasis is doing
   scanning work rather than decoration. */
.legal-defs > li > strong {
  color: var(--ink);
}

.legal-foot {
  border-top: 1px solid var(--line);
  padding-top: 18px;
  margin-top: 8px;
  color: var(--muted);
  font-size: 0.92rem;
  line-height: 1.6;
  max-width: 66ch;
}

.legal-foot p {
  margin: 0 0 6px;
}

/* --- Notifications (task #796) ----------------------------------------------

   The /notifications section: the explainer, the compose form, the confirmation
   step and one send's card-by-card record.

   IT BORROWS RATHER THAN INVENTS. The white square-cornered block is .loc-panel's
   measurements exactly (30px padding, 24px below, one --line border, no radius),
   the tables reuse .prog-table-wrap, and the form fields inside the compose block
   are an ordinary .stack-form inside a real SettingsSection. So this surface has
   almost no geometry of its own: what is here is the handful of things that are
   genuinely new, which is a quotation of the merchant's own message, a definition
   list of counts, and a radio pair that has to hold two lines each.

   Square corners throughout, matching the whole app admin. */
.notif-panel {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 30px;
  margin: 0 0 24px;
}

/* Matched to .loc-panel-title and .settings-title, for the same reason those two
   match each other: three of these blocks sit one above another in the same
   column, so a heading a step out would read as three designs. */
.notif-panel-title {
  margin: 0 0 10px;
  font-size: 1.3rem;
  line-height: 1.25;
}

.notif-lede {
  margin: 0 0 18px;
  color: var(--muted);
  font-size: 0.95rem;
  line-height: 1.6;
  max-width: 68ch;
}

/* THE EXPLAINER IS THE FIRST THING ON THE PAGE AND IT LOOKS LIKE IT. A tinted
   block rather than another white one: what wallets actually do is the single
   most misunderstood thing about this feature, and a merchant about to message
   four hundred people should not be able to scroll past it by accident. */
.notif-explainer {
  background: var(--bg);
}

.notif-points {
  list-style: none;
  margin: 0 0 16px;
  padding: 0;
  display: grid;
  gap: 12px;
  max-width: 74ch;
}

.notif-points li {
  line-height: 1.6;
  font-size: 0.95rem;
  color: var(--muted);
  padding-left: 16px;
  border-left: 2px solid var(--line);
}

/* The lead clause of each point is the scannable half: somebody reading this in
   ten seconds should get the four rules from the bold words alone. */
.notif-points strong {
  color: var(--ink);
}

.notif-help {
  margin: 0;
  font-size: 0.92rem;
}

/* --- the message the merchant typed, quoted back ---------------------------

   On the confirmation step and on a send's own page. It is deliberately NOT a
   form field and deliberately not styled like one: on the confirmation step
   nothing is editable any more (going back is a button), and on the send page it
   is a historical record. A quotation says both of those without a label. */
/* The heading a quoted message went out under (task #889), on the check page,
   the safety dialog and a sent message's own page. Empty until the script or
   the route fills it, and an empty one takes no room. */
.notif-quote-title {
  margin: 0 0 6px;
  font-weight: 700;
  color: var(--ink);
  max-width: 60ch;
  overflow-wrap: anywhere;
}

.notif-quote-title:empty {
  display: none;
}

.notif-quote {
  margin: 0 0 20px;
  padding: 16px 18px;
  border-left: 3px solid var(--brand-fixed);
  background: var(--bg);
  font-size: 1.05rem;
  line-height: 1.55;
  color: var(--ink);
  max-width: 60ch;
}

/* --- the counts ------------------------------------------------------------

   A definition list, because that is what it is: a term and the number that
   answers it. auto-fit with a 190px floor gives five across a desktop, two or
   three on a tablet and one down a phone, with no breakpoint of its own. */
.notif-facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 18px;
  margin: 0 0 20px;
}

.notif-fact dt {
  font-size: 0.85rem;
  color: var(--muted);
  line-height: 1.4;
  margin-bottom: 4px;
}

/* The figures are the thing on this page that gets read, so they are large and
   they are --ink rather than --brand: a count of real customers is a fact, not a
   highlight, and every one of these numbers is equally important. */
.notif-fact dd {
  margin: 0;
  font-size: 1.6rem;
  line-height: 1.1;
  color: var(--ink);
}

/* Counts line up under one another when several sit side by side. */
.notif-fact dd {
  font-variant-numeric: tabular-nums;
}

/* Two of these facts are SENTENCES rather than figures ("Who" and "How"), and a
   sentence set at 1.6rem wraps across three lines and shouts. They are marked in
   the markup rather than guessed at with a positional selector, so adding a
   fourth count later cannot silently re-style one. */
.notif-fact-text dd {
  font-size: 1rem;
  line-height: 1.45;
  font-variant-numeric: normal;
}

.notif-notice {
  max-width: 74ch;
}

/* Every page in this section ends with the same reminder that a wallet decides.
   Quiet, because it is the third time a careful reader has seen it, and present,
   because the person who scrolled straight to the button has seen it none. */
.notif-caveat {
  margin: 18px 0 0;
  padding-top: 16px;
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.6;
  max-width: 72ch;
}

/* The delivery-mode radios lived here until task #803 removed the choice: a
   one-off message always asks the wallets to notify, so there is no fieldset on
   the compose form any more and nothing left to style. */

.notif-confirm-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  margin: 0;
}

.notif-empty {
  max-width: 66ch;
}

/* THE STACKING RULES ARE GONE (task #814 review). Birthdays (task #797) and
   Automatic loyalty updates (task #798) each drew one form PER Loyalty Program
   inside one settings block, so they needed a rule between them to read as
   separate things without each becoming a separate panel. On a Program's own
   Notifications tab there is exactly one of each, so `.notif-birthday +
   .notif-birthday` and `.notif-loyalty + .notif-loyalty` could never match
   again. The classes themselves stay: they are what the forms are named. */

/* The trail's tables carry more columns than the reports' do, so they need a
   wider floor before the horizontal scroller earns its keep. */
.notif-table-wrap table {
  min-width: 720px;
}

/* A message can be the full 120 characters, which would otherwise set the width
   of the whole table on a desktop and squeeze the counts into nothing. */
.notif-message-cell {
  max-width: 34ch;
}

/* A card code is compared character by character against something on a
   customer's screen, so it must not wrap mid-code. Deliberately NOT set in
   --font-mono: that token belongs to the one field a merchant TYPES a code into
   (the colour well's hex input), and the font tests pin it to that single rule so
   the admin's type stays one family by inheritance. A code being read rather than
   typed does not need a second family. */
.notif-code-cell {
  white-space: nowrap;
}

.notif-error-cell {
  max-width: 32ch;
  color: var(--muted);
  font-size: 0.85rem;
  line-height: 1.45;
  overflow-wrap: anywhere;
}

/* ---------------------------------------------------------------------------
   SEND MESSAGE, TASK #888: Sam's picture of the page.

   TOP TO BOTTOM: the section's purple strip (the shared .pagehead, untouched);
   a full-bleed photograph with the supplied handset standing on it, the
   message drawn onto its screen; a white composer floating over the
   photograph's bottom edge, holding the Program, the message, the "Send to"
   radios and one round purple arrow; three dark totals; one note. On a phone
   the composer comes first, the handset is compact and centred, and the totals
   stack.

   WHAT IT BORROWS. The photograph is the dashboard hero (task #824): the same
   two custom properties, set per account in the band's style attribute, and
   the same full-bleed arithmetic .dash-focus uses. The handset is Sam's PNG,
   not a phone drawn in borders. The number under a numeric option is the
   shared .field-unit treatment (task #859). The dialog below is #814's.

   THE ARROW IS ROUND, AND THAT IS THE ONE EXCEPTION TO TASK #839. Sam's own
   picture draws it as a circle, and it is the only control on the page that
   is: the select, the number and the radios are square. It is not a .btn, so
   the base button rule stays square and silent about it.
   --------------------------------------------------------------------------- */
.notif-send-page {
  display: flex;
  flex-direction: column;
  /* The handset's rendered width, which every measurement on its screen is a
     fraction of, so the notice stays registered to the picture at any size. */
  --notif-device-w: 272px;
  /* How far the composer climbs onto the photograph. Sized so the notice on
     the handset, at the longest message and title the form accepts, ends above
     the sheet's top edge: the composer must never cover the words it previews
     (Sam's review of #888; measured in headless Chrome at 120 + 80 characters). */
  --notif-overlap: 150px;
  /* The banner's height on a desktop: taller than the dashboard band, so the
     larger handset has room to stand on it and reach its bottom edge. */
  --notif-hero-h: 480px;
}

/* The photograph ------------------------------------------------------------

   Full bleed, exactly as .dash-focus is (task #479 revision): from inside the
   centred column, 50% is half the column and half --bleed-w is half the
   surface, so the negative inline margins are the distance from column edge to
   surface edge whatever the screen. The inner element brings the content back
   onto the shared grid.

   THE PICTURE IS THE SEND MESSAGE BANNER (src/send-message-banner.ts), one
   high street for every account, written onto the section as two custom
   properties; it is no longer the account's dashboard hero. */
.notif-hero {
  position: relative;
  width: var(--bleed-w);
  margin: 0 calc(50% - var(--bleed-w) / 2);
  height: var(--notif-hero-h);
  overflow: hidden;
  /* Under the photograph, and what a browser that never fetches it keeps: the
     app's ink, on which the white handset frame still reads. */
  background-color: var(--ink);
  background-image: var(--notif-banner-image);
  background-position: center;
  background-repeat: no-repeat;
  background-size: cover;
}

/* Flush under the purple strip when the hero opens the page (no message bar
   above it): cancel the column's own top padding by its named token, never a
   guessed offset, so the photograph meets the strip with no white gap. */
main.container > .notif-send-page:first-child > .notif-hero {
  margin-top: calc(-1 * var(--main-pad-top));
}

.notif-hero-inner {
  position: relative;
  height: 100%;
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 0 var(--content-gutter);
}

/* THE HANDSET. Sam's PNG is the top of a phone, opaque to its last row (no
   transparent padding, measured off the file), and at this width it is taller
   than the banner less its top offset, so it visibly meets the banner's bottom
   edge and the band's overflow cuts it square there. It stands left of centre
   so the composer, which is centred, reads as sitting in front of it rather
   than on top of it, and never left of the column's own edge: on a desktop
   too narrow for the full offset it stops at the gutter instead of sliding
   behind the menu column. */
.notif-device {
  position: absolute;
  top: 26px;
  left: max(0px, calc(50% - var(--notif-device-w) / 2 - 200px));
  width: var(--notif-device-w);
  line-height: 0;
}

.notif-device-frame {
  display: block;
  width: 100%;
  height: auto;
}

/* The notification, drawn ONTO the screen at proportions measured off the
   PNG: the screen's side bezels are 2% of the picture's width, the status bar
   ends 7% of the way down, and a lock-screen notice sits a little under that.
   A translucent light tile, as both wallets draw one, over the black. */
.notif-phone-notice {
  position: absolute;
  left: 7.5%;
  right: 7.5%;
  top: 25.5%;
  display: flex;
  gap: calc(var(--notif-device-w) * 0.045);
  align-items: flex-start;
  background: rgba(255, 255, 255, 0.22);
  border-radius: calc(var(--notif-device-w) * 0.07);
  padding: calc(var(--notif-device-w) * 0.045) calc(var(--notif-device-w) * 0.05);
  line-height: 1.3;
  color: #fff;
}

/* The mark beside the message: the account's own Brand Icon, or the same
   initial-in-a-circle stand-in the enrolment page falls back to (Sam's review
   of #814). The slot itself carries NO background or radius, because whichever
   of the two is inside brings its own: a purple tile behind a merchant's logo
   would tint every transparent one. */
.notif-phone-icon {
  flex: 0 0 auto;
  width: calc(var(--notif-device-w) * 0.13);
  height: calc(var(--notif-device-w) * 0.13);
  display: grid;
  place-items: center;
}

/* ON A WHITE TILE, and contained rather than cropped. The notice sits on a dark
   screen, so a logo drawn in dark ink would otherwise disappear into it, and a
   merchant's mark is not ours to crop: `contain` shows all of it whatever shape
   it was uploaded in. This is also what a real notification does with an app
   icon, which is the whole point of the drawing. */
.notif-phone-logo {
  width: 100%;
  height: 100%;
  object-fit: contain;
  border-radius: 22%;
  background: #ffffff;
  padding: 2px;
}

/* The shared stand-in, at this slot's size. Only the measurements are
   overridden: the circle, the brand fill, the white ink and the weight are the
   enrolment page's own, so the two cannot drift into different stand-ins. */
.notif-phone-icon .logo-placeholder {
  width: 100%;
  height: 100%;
  margin: 0;
  font-size: calc(var(--notif-device-w) * 0.065);
}

.notif-phone-body {
  min-width: 0;
}

.notif-phone-app {
  margin: 0 0 2px;
  color: rgba(255, 255, 255, 0.72);
  font-size: calc(var(--notif-device-w) * 0.036);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  overflow-wrap: anywhere;
}

.notif-phone-text {
  margin: 0;
  color: #ffffff;
  font-size: calc(var(--notif-device-w) * 0.046);
  line-height: 1.35;
  overflow-wrap: anywhere;
}

/* Before anything is typed: the empty state, in a lighter ink, so the drawing
   shows where the words go without pretending some are already there. */
.notif-phone-text-empty {
  color: rgba(255, 255, 255, 0.62);
}

/* The heading above the sentence (task #889): the title, or the business name
   standing in for a blank one. One line, cut with an ellipsis, so a long
   heading never pushes the message off the notice. */
.notif-phone-title {
  margin: 0 0 1px;
  color: #ffffff;
  font-size: calc(var(--notif-device-w) * 0.046);
  font-weight: 700;
  line-height: 1.3;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The composer ----------------------------------------------------------------

   Centred, narrower than the column, and lifted onto the photograph by its own
   height less a strip: the bottom third of the card sits on white so the
   arrow, which pokes out of its corner, lands on the page rather than on the
   picture. Above the hero in the stack, so the photograph never paints over a
   focus ring.

   600px WIDE (Sam's review additions), up from 442: wide enough for the Send
   to list and the number beside it to share one line with the label, with the
   arrow still clear of them. How far it climbs (--notif-overlap) is unchanged,
   so the notice on the handset still ends above the sheet's top edge. */
.notif-composer {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: 600px;
  margin: calc(-1 * var(--notif-overlap)) auto 0;
}

/* THE ONE ROUNDED PANEL IN THE ADMIN, at Sam's request: a floating card over
   a photograph reads as a sheet, and a sheet has soft corners. Nothing in it is
   rounded except the arrow. */
.notif-composer-card {
  position: relative;
  background: var(--card);
  color: var(--ink);
  border-radius: 12px;
  box-shadow: 0 14px 36px rgba(21, 17, 27, 0.22);
  padding: 24px 24px 22px;
}

/* One ring around the sheet while the message is being typed into, since the
   message box deliberately draws no border of its own. The same 2px brand ring
   every other field gets, drawn as an outline so the sheet's one shadow stays
   the one shadow (test/programs-half-frame.test.ts counts them). */
.notif-composer-card:has(.notif-composer-message textarea:focus-visible),
.notif-composer-card:has(.notif-composer-title input:focus-visible) {
  outline: 2px solid var(--brand-fixed);
  outline-offset: 2px;
}

.notif-composer-row {
  display: flex;
  align-items: center;
  gap: 16px;
}

.notif-composer-label {
  font-weight: 600;
  font-size: 1.05rem;
  color: var(--ink);
}

/* A select, square like every other control (task #839), one step shorter
   than the shared 48px because the sheet is set tighter than a settings form
   and the row beside it is a single word. */
.notif-composer-program select {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 40px;
  padding: 0 var(--control-pad-x);
  border: 1px solid var(--line);
  border-radius: var(--control-radius);
  background: var(--card);
  color: var(--ink);
  font: inherit;
}

.notif-composer-program select:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* THE TWO FIELDS, EACH UNDER A TAB (Sam's review of #888). Title and Message
   are the same kind of thing on the sheet: a rule across the top, a small
   uppercase label a few pixels under that rule in a tinted block, and a
   borderless field under it. The rule is the field's top border; the tab row
   is in the flow under it, so a hint beside the tab that has to wrap on a
   narrow screen pushes the field down instead of running into it.

   THE CLEARANCE (Sam's review additions): the row sits 6px under the rule
   rather than touching it, which is the field's top padding. */
.notif-composer-field {
  position: relative;
  margin: 0;
  padding: 6px 0 0;
  border-top: 1px solid var(--line);
}

/* The tab and, on the Title field, the hint beside it: one line, the hint
   centred on the tab's height, wrapping under it only when the sheet is too
   narrow for both. */
.notif-composer-tabrow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 10px;
  margin: 0 0 5px;
}

/* Ruled off from a Program row above it; with no row (one Program, and
   nothing to choose) the first tab sits at the top of the sheet. */
.notif-composer-program + .notif-composer-field {
  margin-top: 18px;
}

/* A shade darker than the page's own background (Sam's review additions): the
   tint sits between --bg and --line, so it reads as a tab on the white sheet
   without introducing a colour the palette does not have. */
.notif-composer-tab {
  flex: 0 0 auto;
  margin: 0;
  padding: 4px 10px;
  background: color-mix(in srgb, var(--line) 35%, var(--bg));
  color: var(--muted);
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1.2;
}

/* The title is the last thing in its field now that the hint sits beside the
   tab, so the field itself keeps the room above the Message rule. */
.notif-composer-title {
  padding-bottom: 14px;
}

.notif-composer-title input {
  display: block;
  width: 100%;
  border: 0;
  padding: 0;
  margin: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 1rem;
  font-weight: 600;
  line-height: 1.3;
  outline: none;
}

.notif-composer-title input::placeholder {
  color: var(--muted);
  opacity: 1;
}

/* Beside the tab, in the tab's own size and ink, and able to break anywhere
   so a narrow sheet never lets it overflow. */
.notif-composer-hint {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: 0.68rem;
  line-height: 1.3;
  color: var(--muted);
  overflow-wrap: anywhere;
}

/* The message: large, borderless, and ruled off above (the field's own rule,
   under its tab) and below, so the sentence itself is the biggest thing on
   the sheet. */
.notif-composer-message {
  margin-bottom: 16px;
  padding-bottom: 6px;
  border-bottom: 1px solid var(--line);
}

.notif-composer-message textarea {
  display: block;
  width: 100%;
  border: 0;
  padding: 0;
  margin: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 1.3rem;
  font-weight: 500;
  line-height: 1.3;
  resize: none;
  outline: none;
}

.notif-composer-message textarea::placeholder {
  color: var(--muted);
  opacity: 1;
}

.notif-composer-count {
  margin: 8px 0 0;
  text-align: right;
  font-size: 0.68rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* The foot: the radios on the left, the arrow on the right, the arrow sitting
   on the sheet's bottom-right corner. */
.notif-composer-foot {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
}

/* The Send to list ------------------------------------------------------------

   One label, one select and, for the two options that take a number, that
   number with its unit box appended, all on one line while the sheet is wide
   enough and wrapping when it is not. */

.notif-sendto {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 12px;
}

.notif-sendto-label {
  flex: 0 0 auto;
  font-size: 0.95rem;
}

/* The same square select the Program row draws, one step shorter than the
   shared 48px because the sheet is set tighter than a settings form. It does
   not grow (Sam's review additions): at its own width the number for a numeric
   option sits right beside it on the 600px sheet, rather than at the far end
   of a list stretched across the sheet. */
.notif-sendto select {
  flex: 0 1 auto;
  /* Wide enough to show its longest option in full. On a sheet too narrow for
     the number as well, the number wraps under it rather than the list
     truncating its own words. */
  min-width: 15rem;
  min-height: 40px;
  padding: 0 var(--control-pad-x);
  border: 1px solid var(--line);
  border-radius: var(--control-radius);
  background: var(--card);
  color: var(--ink);
  font: inherit;
}

.notif-sendto select:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* THE NUMBER EXISTS ONLY WHILE ITS OPTION IS CHOSEN, and it is the stylesheet
   that hides it rather than a script or an attribute the server would have to
   keep in step with a list the merchant can move. Each number wrapper names
   its option, and ONE :has() over the select's checked option decides it, one
   rule per numeric option, never nested (a :has() inside a :has() is dropped
   silently by every browser). `display: none` takes it out of the focus order
   and the accessibility tree in one move, and it comes back the instant the
   list moves, with no round trip. The server reads only the chosen option's
   number whatever the page sends (sendToFilters), so this is a courtesy and
   never the rule. The script mirrors it with `hidden`, which the shared
   .field-unit display would otherwise override, hence the second rule. */
.notif-sendto:not(:has(option[value='stamps']:checked)) .notif-sendto-value[data-notif-sendto-option='stamps'],
.notif-sendto:not(:has(option[value='inactive']:checked)) .notif-sendto-value[data-notif-sendto-option='inactive'],
.notif-sendto-value[hidden] {
  display: none;
}

/* The shared unit treatment: one border around the number and its box, the
   box on the right. */
.notif-sendto-value {
  flex: 0 0 auto;
  margin: 0;
}

.notif-sendto-value .field-unit-input {
  flex: 1 1 5ch;
  min-width: 4ch;
  width: 6ch;
  min-height: 38px;
  padding: 0 10px;
  border: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  outline: none;
}

/* The arrow ---------------------------------------------------------------- */

.notif-send-button {
  flex: 0 0 auto;
  width: 48px;
  height: 48px;
  border: 0;
  border-radius: 50%;
  background: var(--brand-fixed);
  color: #fff;
  display: grid;
  place-items: center;
  cursor: pointer;
  padding: 0;
  /* Onto the corner of the sheet: eight pixels past its right edge and its
     bottom edge, as in Sam's picture. */
  margin: 0 -32px -30px 0;
}

.notif-send-button svg {
  display: block;
  /* Nudged so the arrow's visual weight, not its box, is centred. */
  transform: translateX(1px);
}

.notif-send-button:hover {
  filter: brightness(1.08);
}

.notif-send-button:focus-visible {
  outline: 2px solid var(--brand-fixed);
  outline-offset: 3px;
}

.notif-send-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.notif-composer-empty {
  text-align: left;
}

/* The three totals -------------------------------------------------------------

   Three dark tiles in the brand's ink tone (task #832), centred under the
   sheet, each a label and one large figure. Square corners: they are not
   controls, and they are not the sheet. */
.notif-totals {
  margin: 44px 0 0;
}

.notif-totals-row {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 232px));
  justify-content: center;
  gap: 24px;
}

.notif-total {
  position: relative;
  background: var(--ink-brand);
  color: #fff;
  padding: 26px 24px 28px;
  min-width: 0;
}

.notif-total-label {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 500;
  line-height: 1.35;
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

/* White, large and tabular: the figures are the thing on this page that gets
   read, so they are the biggest type on it after the message. */
.notif-total-figure {
  margin: 8px 0 0;
  font-size: 2.75rem;
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

/* The info affordance: a small ring with an i in it, and a panel that opens
   under the tile's label. A native disclosure element, so it needs no script
   to work. Its summary is `inline-grid` rather than `list-item`, which is what
   keeps the browser's own triangle marker off it in every engine without a
   vendor pseudo-element (test/card-design-form.test.ts forbids one). */
.notif-info {
  display: inline-block;
  position: static;
}

.notif-info-button {
  list-style: none;
  display: inline-grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border: 1.5px solid currentColor;
  border-radius: 50%;
  font-size: 0.72rem;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
  color: inherit;
}

.notif-info-button:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

/* Positioned against the TILE (the details itself is static), so it can be
   wider than the tile on a desktop and exactly the tile on a phone, and it
   never opens off the left edge of the screen. */
.notif-info-panel {
  position: absolute;
  left: 0;
  top: calc(100% - 20px);
  z-index: 3;
  width: max(100%, 320px);
  max-width: calc(var(--bleed-w) - 2 * var(--content-gutter));
  background: var(--card);
  color: var(--ink);
  border: 1px solid var(--line);
  padding: 14px 16px;
  text-align: left;
  font-size: 0.85rem;
  line-height: 1.5;
  font-weight: 400;
}

.notif-info-title {
  margin: 0 0 8px;
  font-weight: 700;
  color: var(--ink);
}

.notif-info-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 6px;
  color: var(--muted);
}

.notif-info-list strong {
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.notif-stats-note {
  margin: 14px auto 0;
  font-size: 0.9rem;
  line-height: 1.5;
  color: var(--ink);
  max-width: 62ch;
  text-align: center;
}

.notif-stats-note[hidden] {
  display: none;
}

/* The note ---------------------------------------------------------------------- */

.notif-note {
  margin: 44px auto 0;
  max-width: 72ch;
  text-align: center;
  font-size: 0.95rem;
  line-height: 1.6;
  color: var(--ink);
}

.notif-note-help {
  margin-top: 10px;
  font-size: 0.9rem;
  color: var(--muted);
}

/* On a phone (task #888, Sam's delegated call): the composer first, under the
   strip, because it is the work; then a compact photograph with the handset
   centred on it and nothing overlapping; then the totals one under another. */
@media (max-width: 720px) {
  .notif-send-page {
    --notif-device-w: 190px;
    --notif-overlap: 0px;
    --notif-hero-h: 330px;
  }

  .notif-composer {
    order: -1;
    max-width: none;
    margin: 0 0 24px;
  }

  .notif-composer-card {
    padding: 20px 18px 18px;
  }

  .notif-composer-foot {
    flex-direction: column;
    align-items: stretch;
  }

  .notif-send-button {
    align-self: flex-end;
    margin: 4px -26px -26px 0;
  }

  main.container > .notif-send-page:first-child > .notif-hero {
    margin-top: 0;
  }

  .notif-hero {
    background-image: var(--notif-banner-image-narrow, var(--notif-banner-image));
  }

  .notif-device {
    top: 22px;
    left: calc(50% - var(--notif-device-w) / 2);
  }

  /* The list takes the whole line and the number sits under it. */
  .notif-sendto select {
    flex-basis: 100%;
    min-width: 0;
  }

  .notif-totals {
    margin-top: 24px;
  }

  .notif-totals-row {
    grid-template-columns: minmax(0, 1fr);
    gap: 12px;
  }

  .notif-total {
    padding: 20px 20px 22px;
  }

  .notif-total-figure {
    font-size: 2.25rem;
  }

  .notif-note {
    margin-top: 28px;
  }
}

/* The safety dialog --------------------------------------------------------- */

.notif-dialog {
  border: 1px solid var(--line);
  border-radius: 0;
  background: var(--card);
  color: var(--ink);
  padding: 26px 28px 22px;
  max-width: 460px;
  width: calc(100% - 32px);
}

.notif-dialog::backdrop {
  background: rgba(21, 17, 27, 0.55);
}

.notif-dialog-title {
  margin: 0 0 12px;
  font-size: 1.3rem;
  line-height: 1.25;
}

.notif-dialog-count {
  margin: 0 0 14px;
  font-size: 1.05rem;
  line-height: 1.5;
}

.notif-dialog-count strong {
  font-size: 1.5rem;
  font-variant-numeric: tabular-nums;
}

.notif-dialog-warning {
  margin: 0 0 20px;
  color: var(--error);
  font-size: 0.95rem;
  line-height: 1.5;
}

/* Both buttons full size and plainly separated: a Cancel that reads as a link
   beside a Send that reads as a button is not a choice between two things. */
.notif-dialog-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  justify-content: flex-end;
}

.notif-dialog-actions .btn {
  flex: 0 1 auto;
  min-width: 11ch;
}

/* ---------------------------------------------------------------------------
   SCAN UPDATES AND REMINDERS, TASK #859.

   NO OUTER PANEL AROUND EITHER (Sam). Both blocks used to be a .notif-panel
   wrapping a SettingsSection, and both of those are a white square-cornered
   block with a --line border and 30px of padding: two borders around one thing,
   with nothing between them but the outer one's padding. The wrapper is gone
   from the markup, so no rule was needed here to unwind it. .notif-panel itself
   stays, because the Send Message tab still uses it. THE BIRTHDAY BLOCK LOST ITS
   WRAPPER TOO in task #860, for the same reason and in the same way.

   THE THRESHOLD ONLY EXISTS WHILE REMINDERS ARE ON. A field asking how many
   quiet days count as "not been in" is meaningless while nothing is being sent,
   so it is not shown, and `display: none` is what "not shown" means here: it is
   out of the tab order and out of the accessibility tree, not merely invisible.

   CSS RATHER THAN A SCRIPT, and specifically :has() on the form, which is the
   same technique the app bar uses to tell a burger nav from a plain one. It
   costs no asset, it cannot arrive after the first paint, and it follows the
   switch with no round trip. THE SERVER IS STILL AUTHORITATIVE: the route
   clamps the number to INACTIVE_MIN_DAYS..INACTIVE_MAX_DAYS however it arrives,
   and the sweep reads the saved column, so nothing about eligibility depends on
   whether a browser understood this rule. A browser without :has() shows the
   field all the time, which is exactly where it was before this task.

   THE HIDDEN INPUT IS STILL SUBMITTED, which is the point of hiding it this way
   rather than disabling it: a `display: none` field posts its value, so turning
   reminders off and saving keeps the number the merchant chose, and turning
   them back on finds it still there. */
.notif-nudge-form:not(:has(.switch-control:checked)) .notif-nudge-days {
  display: none;
}

/* ---------------------------------------------------------------------------
   BIRTHDAYS, TASK #860.

   ONE RED BLOCK WHEN THE PROGRAM ASKS FOR NO DATE OF BIRTH, and it is the whole
   section: the introduction, the switch, both fields and the post-it are not
   rendered at all in that state (see BirthdayPanel), so there is nothing here
   for this to sit beside.

   IT IS THE APP'S OWN ERROR RED, not a second one. --msg-error with
   --msg-error-ink is the pair the message bar already carries, measured at
   5.4:1, which clears AA for body text; the link takes the same ink rather than
   the purple --brand-fixed, which would not clear anything on this fill. The
   3px underside in --msg-error-edge is the message bar's shape too, so a block
   that is standing state rather than the outcome of an action still reads as
   part of one family.

   IT IS NOT A MESSAGE, though, and must not look like one that can be dismissed
   or scrolled away: it is the .prog-notice reasoning (a standing state belongs
   on the page it describes), wearing the colour the state deserves. */
/* THE BLOCK KEEPS THE TWO COLUMNS (Sam, task #860 review), and there is
   deliberately no rule here changing them. This state used to collapse the
   section to one column because its left column held only a title; now the
   introduction stays, so the left column reads exactly as it does in the other
   two states and in the two sections above, and the warning simply occupies the
   content column where the form otherwise sits. Nothing about the grid, the
   50px gutter or the 720px stack is special-cased for it. */
.notif-dob-warning {
  margin: 0;
  padding: 14px 16px;
  border: 0;
  border-bottom: 3px solid var(--msg-error-edge);
  border-radius: 0;
  background: var(--msg-error);
  color: var(--msg-error-ink);
  font-size: 0.95rem;
  line-height: 1.6;
  max-width: 74ch;
}

.notif-dob-warning a {
  color: inherit;
  text-decoration: underline;
  font-weight: 700;
}

/* THE TWO FIELDS ONLY EXIST WHILE BIRTHDAY MESSAGES ARE ON, on the technique
   and for the reasons written out under Reminders above: :has() on the form,
   `display: none` so they leave the tab order and the accessibility tree, and
   the inputs still POST so a merchant's wording and chosen treat survive being
   switched off and saved.

   display: contents WHILE SHOWN, so the wrapper groups the pair for hiding
   without becoming a box in the middle of a .stack-form column: the two labels
   keep the form's own 16px rhythm exactly as they would with no wrapper at all.
   A browser without :has() shows both fields all the time, which is where they
   were before this task. */
.notif-birthday-fields {
  display: contents;
}

.notif-birthday:not(:has(.switch-control:checked)) .notif-birthday-fields {
  display: none;
}

/* AND SO DOES THE POST-IT, which is why this reaches out of the form and across
   the block: the note says when birthdays go out, and with the switch off none
   go out. It is scoped by :has(.notif-birthday) so it can only ever reach the
   Birthday section's own note, and it is the one rule in the app that hides a
   support note, so nothing else can be caught by it.

   THE TWO PSEUDO-CLASSES SIT SIDE BY SIDE ON THE SECTION, and that is not a
   style choice: :has() MAY NOT BE NESTED INSIDE :has(), so the obvious
   `:has(.notif-birthday:not(:has(.switch-control:checked)))` is an invalid
   selector that a browser drops silently, leaving the note showing with the
   switch off. Written this way the section must contain the birthday form AND
   must not contain a checked switch inside it, which is the same question asked
   legally. Caught on staging in task #860, where the markup was right and the
   note was still there. */
.settings-section:has(.notif-birthday):not(:has(.notif-birthday .switch-control:checked))
  .support-note {
  display: none;
}

/* THE UNIT IS PART OF THE FIELD, NOT A WORD BESIDE IT (Sam, task #859). The
   border, the height and the radius move OFF the number and onto this wrapper,
   so the grey block sits INSIDE the field's own treatment against its right
   edge rather than looking like a second control that happens to be adjacent.

   IT DELIBERATELY OUTRANKS THE SHARED FIELD RULE, which is the shape
   .colour-well already takes on these same forms: the app-wide treatment still
   decides what a field looks like, and a control that is a field PLUS something
   else re-homes the treatment rather than keeping a second copy of it. The
   numbers below are read from that rule and from the tokens, never restated.

   IT DECLARES NO HEIGHT OF ITS OWN, DELIBERATELY. `align-items: stretch` over a
   number that still carries the shared rule's min-height makes the wrapper
   exactly as tall as the field it frames, which is the whole point: there is
   still ONE declaration of how tall a control is in this sheet, and
   test/form-controls.test.ts counts it.

   WIDTH FROM THE CONTENT. `max-content` keeps this the size of a three-digit
   number and its unit instead of a full-width box holding "30", and the
   `max-width: 100%` under it is what makes that safe on a 320px phone. */
.field-unit {
  display: inline-flex;
  align-items: stretch;
  width: max-content;
  max-width: 100%;
  border: 1px solid var(--line);
  border-radius: var(--control-radius);
  background: var(--card);
  overflow: hidden;
}

/* The focus ring belongs to the whole control, because the whole control is
   what the border draws. Same 2px brand ring at the same offset every other
   field on these forms gets; the inner one is suppressed because it would draw
   a second rectangle inside the first. */
.field-unit:focus-within {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* The number keeps its spinners, its min, its max, its numeric keypad, its
   height and its side padding: only the frame moved outwards onto the wrapper.
   `flex` with a shrinkable basis is what lets it give way to the unit block on a
   narrow screen instead of overflowing it.

   THE SELECTOR IS THREE CLASSES ON PURPOSE. The shared field rule reaches this
   input as `.stack-form input[type='number']`, which outranks a lone class, so
   giving up the border here needs to outrank THAT. Stating the whole path is
   also the honest description of when this applies: a number inside a unit
   wrapper inside a settings form, and nowhere else. */
.stack-form .field-unit .field-unit-input {
  flex: 1 1 6ch;
  min-width: 4ch;
  border: 0;
  background: transparent;
  outline: none;
}

/* NON-EDITABLE, AND IT LOOKS IT: the page's own pale grey, muted ink, and a
   single line dividing it from the number. `user-select: none` because there is
   nothing here to copy, and nowrap because "DAYS" breaking across two lines
   inside a 48px box is the one thing this block must never do. */
.field-unit-suffix {
  display: inline-flex;
  align-items: center;
  flex: 0 0 auto;
  padding: 0 var(--control-pad-x);
  border-left: 1px solid var(--line);
  background: var(--bg);
  color: var(--muted);
  font-size: 0.85rem;
  letter-spacing: 0.06em;
  white-space: nowrap;
  user-select: none;
}

/* --- the personalised demo sales tool (task #838) --------------------------

   TWO SURFACES, AND ONLY ONE OF THEM IS DESIGNED.

   The ADMIN screens (/admin/demos) are part of the internal panel and take its
   posture exactly: they reuse .card, .btn, .admin-table and the shared page
   header, and add only the handful of rules a dense form genuinely needs. An
   internal tool earns its keep by being correct, not by being pretty.

   The PUBLIC page (/d/<code>) is the opposite: it is the first thing a prospect
   ever sees of LoyalPack, and it is a fixed sales page. It reuses the public
   chrome, .card and the canonical card renderer, and what is added here is the
   small amount of typographic hierarchy a one-screen pitch needs. Nothing here
   is per-demo: the only thing that varies between two prospects' pages is the
   card itself and one sentence, both of which are content rather than style. */

/* The one form pattern the sales screens add to the panel: a stacked
   label-then-control column, which is what a form of thirty short fields wants
   and what .admin-search (a horizontal row) deliberately is not. */
.admin-form {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.375rem;
  max-width: 34rem;
}

.admin-form label:not(.check) {
  font-weight: 600;
  font-size: 0.875rem;
  margin-top: 0.5rem;
}

.admin-form input[type='text'],
.admin-form input[type='email'],
.admin-form input[type='search'],
.admin-form input[type='number'],
.admin-form select,
.admin-form textarea {
  width: 100%;
}

.admin-form .btn {
  margin-top: 0.75rem;
}

/* The card preview on a demo's admin page. Bounded so a full-bleed card face
   does not dominate a screen that is mostly a form. */
.demo-admin-preview {
  max-width: 20rem;
  margin: 0.75rem 0;
}

.demo-admin-link input {
  width: 100%;
  /* NO FAMILY OF ITS OWN. A readonly field holding a URL is a candidate for
     monospace, and it is deliberately not given one: the app resolves to a
     single family by inheritance everywhere (test/app-font.test.ts pins it),
     and one internal field is not worth being the exception. */
  font-size: 0.8125rem;
}

.demo-admin-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 1rem;
}

/* The per-row revoke and replace forms, side by side inside one table cell. */
.demo-alias-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.demo-alias-actions form {
  display: flex;
  gap: 0.25rem;
}

.demo-alias-actions input {
  width: 9rem;
}

/* --- the public sales page ------------------------------------------------ */

/* The privacy line, above everything. Quiet rather than alarming: it is
   reassurance, not a warning, and a page that shouts about being private reads
   as a page that has something to hide. */
.demo-privacy {
  margin: 0 0 1rem;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--muted);
}

.demo-hero {
  text-align: left;
}

.demo-eyebrow {
  margin: 0 0 0.5rem;
  font-size: 0.8125rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand);
}

.demo-heading {
  margin: 0 0 0.75rem;
  font-size: clamp(1.5rem, 4vw, 2rem);
  line-height: 1.15;
}

.demo-lede {
  margin: 0 0 1.25rem;
  font-size: 1.0625rem;
  line-height: 1.55;
  max-width: 42rem;
}

/* THE ONE EDITABLE THING ON THE PAGE, and it is given a treatment that makes it
   obviously somebody's words rather than ours: an indented block with a brand
   rule down its left edge, in the place the fixed layout reserves for it. */
.demo-message {
  margin: 0 0 1.5rem;
  padding: 0.75rem 0 0.75rem 1rem;
  border-left: 3px solid var(--brand);
  max-width: 42rem;
}

.demo-message p {
  margin: 0;
  font-size: 1.0625rem;
  line-height: 1.55;
}

.demo-card-stage {
  max-width: 22rem;
  margin: 0 auto;
}

.demo-card-caption {
  margin: 0.75rem 0 0;
  text-align: center;
  font-size: 0.9375rem;
}

/* The three points. A list that reads as three short sections rather than as
   bullets, and one column on a phone. */
.demo-points ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 1.5rem;
}

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

.demo-points h2 {
  margin: 0 0 0.375rem;
  font-size: 1.0625rem;
}

.demo-points p {
  margin: 0;
  font-size: 0.9375rem;
  line-height: 1.5;
  color: var(--muted);
}

/* THE ONE ASK, at the end, exactly as the rest of the public site puts it. */
.demo-claim h2 {
  margin: 0 0 0.5rem;
}

.demo-claim > p {
  margin: 0 0 1rem;
  max-width: 42rem;
}

.demo-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin: 1rem 0 0;
}

.demo-contact {
  margin: 1.25rem 0 0;
  font-size: 0.9375rem;
}

.demo-confirm-list {
  margin: 0 0 1rem;
  padding-left: 1.25rem;
}

.demo-confirm-form {
  margin-top: 1.25rem;
}

.demo-confirm-form .check {
  margin-bottom: 0.5rem;
}

.demo-unavailable h1 {
  margin: 0 0 0.75rem;
  font-size: clamp(1.375rem, 4vw, 1.75rem);
}

.demo-unavailable p {
  margin: 0 0 0.75rem;
  max-width: 40rem;
  line-height: 1.55;
}

/* --- Spread the word: sharing the joining link (task #894, redesigned at the
   review) -------------------------------------------------------------------

   The Resources tab's second panel. The controls on the left are the work
   (the invitation, the joining link, the channel row, the save), the picture
   on the right is the reference, which is the resource tools' own split and
   for their reason. Below the panel's comfortable width the picture drops
   under the controls, as the previews above it do. */
.prog-share-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 360px);
  gap: 32px;
  align-items: start;
}

.prog-share-closed {
  margin: 0 0 18px;
  padding: 12px 14px;
  border: 1px solid var(--line);
  max-width: 62ch;
}

.prog-share-field {
  margin-bottom: 18px;
}

/* A label row: the field's name on the left, its small actions on the right,
   on one line, so an action reads as belonging to the thing under it. */
.prog-share-field-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 6px;
  min-width: 0;
}

.prog-share-field-head label {
  display: block;
  font-weight: 600;
}

.prog-share-mini-actions {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
}

/* The small graphical action: icon and a short name, one line, square like
   every other control (task #839), and a size down from the buttons so it
   does not compete with Save. The icon is the kit's inline svg once it has
   loaded and an empty <i> until then; either way the name carries it. */
.prog-share-mini {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  border: 1px solid var(--brand);
  border-radius: 0;
  background: transparent;
  color: var(--brand);
  font: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  line-height: 1.2;
  cursor: pointer;
  white-space: nowrap;
}

.prog-share-mini:hover,
.prog-share-mini:focus-visible {
  background: var(--brand);
  color: #fff;
}

.prog-share-mini:disabled {
  opacity: 0.6;
  cursor: default;
}

.prog-share-mini svg,
.prog-share-mini i {
  width: 0.9em;
  height: 0.9em;
}

/* An inline-flex display would beat the UA sheet's [hidden]; the attribute is
   honoured here by name (the device share button until the script shows it). */
.prog-share-mini[hidden] {
  display: none;
}

/* The same square field the sticker and sign lines take, taller: an
   invitation is a couple of lines. It resizes down only, so it cannot be
   dragged out of its column. */
.prog-share-textarea {
  display: block;
  width: 100%;
  min-height: 6.5rem;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  line-height: 1.5;
  background: var(--card);
  color: var(--ink);
  resize: vertical;
}

/* n/x, bottom right, under the box. */
.prog-share-count {
  margin: 4px 0 0;
  text-align: right;
  font-size: 0.8rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.prog-share-count[data-share-count-full] {
  color: var(--ink);
  font-weight: 600;
}

.prog-share-link .prog-url {
  display: block;
  width: 100%;
}

/* THE CHANNEL ROW: one line of buttons, each the platform's logo and name.
   Wrapping is the no-script rendering (every button is there, on as many
   lines as it takes); the script keeps it to one line by moving what does
   not fit into More, so with the script it never wraps. */
.prog-share-channels {
  margin: 18px 0 8px;
}

.prog-share-channels-label {
  margin: 0 0 8px;
  font-weight: 600;
}

.prog-share-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.prog-share-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  background: var(--card);
  color: var(--ink);
  font: inherit;
  font-size: 0.9rem;
  font-weight: 600;
  line-height: 1.2;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
}

.prog-share-chip:hover,
.prog-share-chip:focus-visible {
  border-color: var(--brand);
  color: var(--brand);
}

.prog-share-chip svg,
.prog-share-chip i {
  width: 1.1em;
  height: 1.1em;
  flex: 0 0 auto;
}

.prog-share-more-wrap {
  position: relative;
  flex: 0 0 auto;
}

.prog-share-more[hidden] {
  display: none;
}

/* The popover under More: the buttons that did not fit, stacked, in order,
   over whatever is below the row. An ink border rather than a shadow: the
   sheet allows itself exactly two drop shadows (programs-half-frame.test.ts)
   and this is not one of them. */
.prog-share-overflow {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 5;
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 12rem;
  padding: 6px;
  border: 1px solid var(--ink);
  background: var(--card);
}

.prog-share-overflow[hidden] {
  display: none;
}

.prog-share-overflow .prog-share-chip {
  justify-content: flex-start;
}

.prog-share-note {
  margin: 0 0 14px;
  max-width: 62ch;
}

/* The X username: one closed line under the row, the field inside. */
.prog-share-x {
  margin: 0 0 18px;
  max-width: 34ch;
}

.prog-share-x summary {
  cursor: pointer;
  font-weight: 600;
  font-size: 0.9rem;
}

.prog-share-handle {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 10px;
}

.prog-share-handle-label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}

.prog-share-handle input[type='text'] {
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  width: 100%;
}

.prog-share-handle .hint {
  margin: 0;
}

.prog-share-save {
  margin-top: 4px;
}

/* The reset form under the settings form: a rule to say it is a different
   action, the tick and its sentence, then the button. */
.prog-share-reset {
  margin-top: 20px;
  padding-top: 16px;
  border-top: 1px solid var(--line);
}

.prog-share-reset-check {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  max-width: 62ch;
  margin-bottom: 12px;
}

.prog-share-picture-title {
  margin-top: 0;
}

/* The panel's title rule (`.prog-panel .resource-tool-title`) puts a rule and
   room above every h3 of that class; the picture's heading is the top of its
   own column, so it takes neither. */
.prog-panel .prog-share-picture-title {
  margin: 0;
  padding-top: 0;
  border-top: 0;
}

.prog-share-picture .prog-downloads {
  margin-top: 12px;
}

@media (max-width: 860px) {
  .prog-share-body {
    grid-template-columns: minmax(0, 1fr);
    gap: 24px;
  }

  .prog-share-preview {
    max-width: 480px;
  }
}

/* The invisible trap field on public forms (task #953, src/views/honeypot.tsx).
   Off-screen and clipped rather than display:none, so a form-filling bot still
   finds it; aria-hidden and tabindex -1 on the markup keep people out of it. */
.lk-hp {
  position: absolute !important;
  left: -10000px !important;
  top: auto !important;
  width: 1px !important;
  height: 1px !important;
  overflow: hidden !important;
  clip-path: inset(50%) !important;
  white-space: nowrap !important;
  opacity: 0 !important;
  pointer-events: none !important;
}
