/* =========================================================================
 * The promotion DEAL BUILDER - one stylesheet, two surfaces.
 * =========================================================================
 * Loaded by order entry (order.php, sales-order.php), by the customer portal
 * (mini_shop) and by the promotion builder's preview pane. It lives under
 * mini_shop/shared/ - not where it conceptually belongs, but the only place BOTH
 * surfaces can reach it: .htaccess rewrites every URI a store subdomain asks for
 * into /mini_shop/, and the app is under no such restriction. The reasoning is
 * written out in full at the top of promotion-deal.js beside it.
 *
 * It used to be two files with the same class names and different palettes, and
 * they drifted: the storefront said "Spend $50.00 more" where order entry said
 * "Add $50.00 more" about the same promotion. A shopper and the rep on the phone
 * to them were reading two descriptions of one offer, which is the exact failure
 * App\Promotions\PromotionStorefront exists to prevent on the server side.
 *
 * -------------------------------------------------------------------------
 * THE PALETTE IS THE ONLY THING A SURFACE MAY CHANGE
 * -------------------------------------------------------------------------
 * Structure is shared; the accent is not. The storefront is the customer-facing
 * shop and keeps its green identity, the app keeps its blue - so a surface
 * overrides these four variables and nothing else. Anything beyond a colour is a
 * change to the FEATURE, and belongs here where both surfaces get it.
 *
 *   --pd-header        the header gradient
 *   --pd-accent        the surface's primary
 *   --pd-accent-dark   its hover / text-on-light shade
 *   --pd-accent-soft   its subtle border shade
 *
 * The GREEN and AMBER below are deliberately NOT variables. Green is money
 * already saved and amber is money still on the table, and a buyer scanning a
 * long list should be able to tell those apart before reading either. They mean
 * the same thing on both surfaces, so they are not a surface's business.
 * ========================================================================= */

/*
 * Defaults are the app's, so a page that loads this and forgets to theme it looks
 * like the app rather than like nothing.
 *
 * Wrapped in :where() to give them a specificity of ZERO, so a surface's own
 * `.promo-deal { --pd-accent: … }` always wins no matter which stylesheet the page
 * happens to load first. Written as a plain `.promo-deal` these would tie with the
 * storefront's override on specificity and be decided by load order — which is a
 * rule nobody adding a third surface would think to look up, and whose failure
 * mode is the customer-facing shop silently turning blue.
 */
:where(.promo-deal) {
    --pd-header:      linear-gradient(135deg, #0a4fa8 0%, #0d6efd 60%, #3d8bfd 100%);
    --pd-accent:      #0d6efd;
    --pd-accent-dark: #0a4fa8;
    --pd-accent-soft: #9ec5fe;
}

/* ── Modal frame ───────────────────────────────────────────────────────── */
.promo-deal .modal-content {
    overflow: hidden;
    border: 0;
    border-radius: .75rem;
    box-shadow: 0 1.5rem 3rem rgba(16, 24, 32, .22);
}

.promo-deal-header {
    align-items: flex-start;
    padding: 1.25rem 1.25rem 1rem;
    border-bottom: 0;
    background: var(--pd-header);
    color: #fff;
}

.promo-deal-heading {
    min-width: 0;   /* let a long promotion name wrap instead of pushing the close button off */
}

.promo-deal-eyebrow {
    display: inline-flex;
    align-items: center;
    gap: .375rem;
    margin-bottom: .25rem;
    padding: .125rem .5rem;
    border-radius: 999px;
    background: rgba(255, 255, 255, .18);
    font-size: .6875rem;
    font-weight: 600;
    letter-spacing: .04em;
    text-transform: uppercase;
}

.promo-deal .modal-title {
    font-size: 1.125rem;
    font-weight: 700;
    line-height: 1.3;
}

/* The offer in the promotion's own words, under its name. Held at 0.9 opacity
   rather than a lighter colour so it stays legible on the gradient. */
.promo-deal-headline {
    margin: .25rem 0 0;
    font-size: .875rem;
    line-height: 1.35;
    opacity: .9;
}

/*
 * `margin-left: auto` is what puts it on the right, and it has to be written
 * here. Bootstrap's own `.modal-header .btn-close` rule carries it — this app's
 * `.modal-header` has no `justify-content: space-between` to fall back on (the
 * portal's older copy of Bootstrap still does) — so the flat `margin: 0` that
 * was cancelling the negative offsets was also cancelling the auto, and the
 * close button sat wherever the promotion's name happened to stop.
 */
.promo-deal-header .btn-close {
    flex: none;
    margin: 0 0 0 auto;
    filter: invert(1) grayscale(100%) brightness(200%);
    opacity: .8;
}

/* ── Step rail ─────────────────────────────────────────────────────────── */
/*
 * Horizontally scrollable rather than wrapped: a required mix can run to four
 * or five steps, and a rail that wraps to a second line pushes the products —
 * the thing the modal was opened for — below the fold.
 */
.promo-deal-rail {
    display: flex;
    gap: .25rem;
    overflow-x: auto;
    padding: .625rem 1.25rem;
    border-bottom: 1px solid #e7eaee;
    background: #f7f9fa;
    scrollbar-width: none;
}

.promo-deal-rail::-webkit-scrollbar {
    display: none;
}

.promo-deal-step {
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: .4rem;
    padding: .3rem .625rem .3rem .3rem;
    border: 1px solid transparent;
    border-radius: 999px;
    background: none;
    color: #6b7480;
    font-size: .8125rem;
    font-weight: 500;
    white-space: nowrap;
    transition: background-color .15s ease-in-out, color .15s ease-in-out;
}

.promo-deal-step:hover {
    background: #eef1f4;
    color: #37414d;
}

.promo-deal-step-dot {
    display: inline-flex;
    flex: none;
    align-items: center;
    justify-content: center;
    width: 1.375rem;
    height: 1.375rem;
    border-radius: 50%;
    background: #dfe4e9;
    color: #6b7480;
    font-size: .75rem;
    font-weight: 700;
}

.promo-deal-step.is-done {
    color: #146c43;
}

.promo-deal-step.is-done .promo-deal-step-dot {
    background: #d1e7dd;
    color: #146c43;
}

.promo-deal-step.is-current {
    border-color: #cfd6dd;
    background: #fff;
    color: var(--pd-accent-dark);
    font-weight: 600;
}

.promo-deal-step.is-current .promo-deal-step-dot {
    background: var(--pd-accent);
    color: #fff;
}

/* Not yet earned. The padlock carries the meaning; the muting only stops it
   competing with the step the rep is actually on. */
.promo-deal-step.is-locked {
    color: #aeb5bd;
    cursor: not-allowed;
}

.promo-deal-step.is-locked:hover {
    background: none;
    color: #aeb5bd;
}

.promo-deal-step.is-locked .promo-deal-step-dot {
    background: #eef1f4;
    color: #aeb5bd;
    font-size: .625rem;
}

/*
 * Every step is named, because naming only the current one leaves the rail
 * saying "1 Qualifying products · 2 · 3" — which hides the fact that a FREE
 * ITEM step is coming next, and that is the most persuasive thing the modal has
 * to say. The names collapse to bare numbers on a narrow screen.
 */
@media (max-width: 575.98px) {
    .promo-deal-step:not(.is-current) .promo-deal-step-name {
        display: none;
    }
}

/* A hairline between the dots so the rail reads as a sequence rather than as
   three unrelated buttons. Drawn on the gap, never after the last step. */
.promo-deal-step + .promo-deal-step::before {
    content: "";
    flex: none;
    width: .75rem;
    height: 1px;
    margin-right: .4rem;
    background: #d3d9df;
}

.promo-deal-step.is-done + .promo-deal-step::before,
.promo-deal-step.is-current + .promo-deal-step::before {
    background: var(--pd-accent-soft);
}

/* ── Progress ──────────────────────────────────────────────────────────── */
.promo-deal-progress {
    padding: .875rem 1.25rem .75rem;
    border-bottom: 1px solid #eef1f4;
    background: #fff;
}

.promo-deal-progress-top {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    margin-bottom: .5rem;
}

.promo-deal-instruction {
    font-size: .9375rem;
    font-weight: 600;
    color: #1d252d;
}

.promo-deal-count {
    display: flex;
    flex: none;
    flex-direction: column;
    align-items: flex-end;
    font-size: .8125rem;
    font-weight: 600;
    color: #6b7480;
    font-variant-numeric: tabular-nums;   /* the count must not jitter as it climbs */
}

/* "10 to buy + 1 free = 11 on the order" — the bridge between the number the
   offer names and the number the bar counts to. Quiet, because it is an
   explanation rather than an instruction. */
.promo-deal-count-note {
    font-size: .6875rem;
    font-weight: 500;
    color: #a4abb3;
    white-space: nowrap;
}

.promo-deal-track {
    position: relative;
    height: .5rem;
    border-radius: 999px;
    background: #e7eaee;
}

.promo-deal-fill {
    width: 0;
    height: 100%;
    border-radius: 999px;
    background: linear-gradient(90deg, #20c997, #198754);
    transition: width .25s ease-out;
}

.promo-deal-fill.is-done {
    background: linear-gradient(90deg, #198754, #146c43);
}

/* A tier ladder drawn on the track. The rungs are milestones on ONE bar because
   the customer gets the single best tier they reach — never all of them — so
   separate bars would read as separate deals. */
.promo-deal-mark {
    position: absolute;
    top: 50%;
    width: .1875rem;
    height: .875rem;
    margin-left: -.09375rem;
    border-radius: 999px;
    background: #b6bec7;
    transform: translateY(-50%);
}

.promo-deal-mark.is-hit {
    background: #146c43;
}

/* ── The tier ladder, named ────────────────────────────────────────────── */
/*
 * The marks above say WHERE the order is on the ladder and cannot say what any
 * rung is worth — which is the only thing anybody opened a tiered deal to find
 * out. So every rung is also a chip: the quantity, what it pays, and which one
 * of them the order is on.
 *
 * Three states, and the colours are the ones this file already uses: green for
 * money already earned, the app's blue for the rung ahead, grey for the rest.
 */
.promo-deal-tiers {
    display: flex;
    flex-wrap: wrap;
    gap: .375rem;
    margin-top: .5rem;
}

.promo-deal-tier {
    display: inline-flex;
    align-items: baseline;
    gap: .3rem;
    padding: .2rem .5rem;
    border: 1px solid #e0e5ea;
    border-radius: 999px;
    background: #f7f9fa;
    color: #8a9199;
    font-size: .75rem;
    line-height: 1.25;
    white-space: nowrap;
}

.promo-deal-tier b {
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

.promo-deal-tier i {
    font-size: .625rem;
}

/* A rung the order has already passed. */
.promo-deal-tier.is-hit {
    border-color: #a8ddbd;
    background: #eefaf2;
    color: #146c43;
}

/* The rung being PAID. Solid rather than tinted, because "which tier am I
   getting" is the one question the old modal could not answer, and a shade of
   the same green as the rungs below it would not answer it either. */
.promo-deal-tier.is-now {
    border-color: #198754;
    background: #198754;
    color: #fff;
    font-weight: 600;
}

/* The band BELOW the ladder — the ordinary price the rest are cheaper than.
   Never green, whatever state it is in: it is the thing the deal beats, and a
   buyer standing in it has earned nothing yet. Dashed so it reads as the
   starting line rather than as a rung. */
.promo-deal-tier-base {
    border-style: dashed;
}

.promo-deal-tier-base.is-now {
    border-color: #b6bdc4;
    background: #fff;
    color: #5c636a;
    font-weight: 600;
}

/* The next rung up: outlined, not filled — it is an offer, not a fact. */
.promo-deal-tier.is-next {
    border-color: var(--pd-accent-soft);
    background: #fff;
    color: var(--pd-accent-dark);
}

.promo-deal-status {
    margin-top: .5rem;
    font-size: .8125rem;
    color: #6b7480;
}

.promo-deal-status.is-done {
    color: #146c43;
    font-weight: 600;
}

/* ── Product list ──────────────────────────────────────────────────────── */
.promo-deal-body {
    padding: 0;
}

.promo-deal-loading {
    padding: 3rem 0;
    text-align: center;
}

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

.promo-deal-row {
    display: flex;
    align-items: center;
    gap: .75rem;
    padding: .625rem 1.25rem;
    border-bottom: 1px solid #f0f2f5;
    /* The green edge is the "this is on the order" signal, held at zero width so
       a row changing state does not shift the text beside it. */
    box-shadow: inset .1875rem 0 0 transparent;
    transition: background-color .15s ease-in-out, box-shadow .15s ease-in-out;
}

.promo-deal-row:last-child {
    border-bottom: 0;
}

.promo-deal-row.is-in-cart {
    background: #f6fcf8;
    box-shadow: inset .1875rem 0 0 #198754;
}

.promo-deal-thumb {
    display: flex;
    flex: none;
    align-items: center;
    justify-content: center;
    width: 3rem;
    height: 3rem;
    overflow: hidden;
    border: 1px solid #eef1f4;
    border-radius: .5rem;
    background: #fff;
}

.promo-deal-thumb img {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
}

.promo-deal-noimg {
    color: #c6ccd3;
}

.promo-deal-info {
    flex: 1 1 auto;
    min-width: 0;
}

.promo-deal-name {
    display: block;
    font-size: .875rem;
    font-weight: 600;
    line-height: 1.3;
    color: #1d252d;
}

.promo-deal-meta {
    display: block;
    margin-top: .125rem;
    font-size: .75rem;
    color: #8a9199;
}

.promo-deal-oos {
    color: #b02a37;
}

/* This product's OWN deal, on a per-product promotion. Green because it is the
   offer rather than description — the same colour the tile badge it is copied
   from uses. */
.promo-deal-own-offer {
    display: block;
    margin-top: .125rem;
    font-size: .75rem;
    font-weight: 600;
    line-height: 1.25;
    color: #146c43;
}

/*
 * On a ladder that same line carries two things: the rung this row is ON, and
 * the way to the next one. The rung is bold green — money the order already has
 * — and the way up is amber, which is this file's colour for money still on the
 * table.
 */
.promo-deal-own-offer b {
    font-weight: 700;
}

.promo-deal-tier-next,
.promo-deal-tierup {
    margin-left: .375rem;
    font-size: .6875rem;
    font-weight: 600;
    white-space: nowrap;
}

/* Stated, not offered — a spend threshold's target cannot be typed into a
   quantity box, and a voided order cannot be written to at all. */
.promo-deal-tier-next {
    color: #a4abb3;
}

.promo-deal-tierup {
    display: inline-flex;
    align-items: center;
    gap: .25rem;
    padding: .0625rem .5rem;
    border: 1px solid #f3d68b;
    border-radius: 999px;
    background: #fffaef;
    color: #7a5910;
    transition: background-color .15s ease-in-out;
}

.promo-deal-tierup:hover {
    background: #fdf1d6;
}

.promo-deal-tierup:focus-visible {
    outline: 2px solid #c8901a;
    outline-offset: 1px;
}

.promo-deal-tierup i {
    font-size: .5625rem;
}

/* ── Per-row progress ──────────────────────────────────────────────────── */
/*
 * Only per-product promotions draw this, and for them it is not decoration:
 * each row is a separate race to its own threshold, so a single bar at the top
 * can only ever describe one of them. Deliberately smaller and quieter than the
 * summary bar — it answers "and how is THIS one doing", not "how am I doing".
 */
.promo-deal-rowprog {
    display: flex;
    align-items: center;
    gap: .4rem;
    margin-top: .25rem;
}

.promo-deal-rowtrack {
    flex: 1 1 auto;
    max-width: 8rem;
    height: .1875rem;
    border-radius: 999px;
    background: #e7eaee;
}

.promo-deal-rowfill {
    display: block;
    height: 100%;
    border-radius: 999px;
    background: #a3cfbb;
    transition: width .25s ease-out;
}

.promo-deal-rowprog.is-done .promo-deal-rowfill {
    background: #198754;
}

.promo-deal-rowcount {
    flex: none;
    font-size: .6875rem;
    font-weight: 600;
    color: #8a9199;
    font-variant-numeric: tabular-nums;
}

.promo-deal-rowprog.is-done .promo-deal-rowcount {
    color: #146c43;
}

.promo-deal-rowcount i {
    margin-right: .25rem;
}

.promo-deal-money {
    display: flex;
    flex: none;
    flex-direction: column;
    align-items: flex-end;
    line-height: 1.2;
}

.promo-deal-price {
    font-size: .875rem;
    font-weight: 700;
    color: #1d252d;
    font-variant-numeric: tabular-nums;
}

.promo-deal-price.is-deal {
    color: #146c43;
}

.promo-deal-was {
    font-size: .6875rem;
    color: #a4abb3;
}

/* ── Stepper ───────────────────────────────────────────────────────────── */
.promo-deal-stepper {
    display: inline-flex;
    flex: none;
    align-items: center;
    overflow: hidden;
    border: 1px solid #dfe4e9;
    border-radius: .5rem;
    background: #fff;
}

/* A quantity change is a server round trip that moves inventory. The row says
   so rather than accepting clicks it is going to drop. */
.promo-deal-stepper.is-busy {
    opacity: .55;
}

.promo-deal-stepper button {
    width: 2rem;
    height: 2rem;
    border: 0;
    background: #f4f6f8;
    color: #4a5560;
    font-size: .75rem;
    transition: background-color .15s ease-in-out;
}

.promo-deal-stepper button:hover:not(:disabled) {
    background: #e3e8ec;
}

.promo-deal-stepper button:focus-visible {
    outline: 2px solid var(--pd-accent);
    outline-offset: -2px;
}

.promo-deal-qty {
    width: 2.75rem;
    height: 2rem;
    border: 0;
    border-right: 1px solid #eef1f4;
    border-left: 1px solid #eef1f4;
    text-align: center;
    font-size: .875rem;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
}

.promo-deal-qty:focus {
    outline: 0;
    background: #f1f7ff;
}

/* A document nobody may edit still shows what it holds. */
.promo-deal-readonly {
    flex: none;
    min-width: 2.75rem;
    text-align: center;
    font-size: .875rem;
    font-weight: 600;
    color: #6b7480;
    font-variant-numeric: tabular-nums;
}

/* ── How many of each are free ───────────────────────────────────────── */
/*
 * A stepper over the PICKS, not the basket: a deal worth twelve free units
 * can take all twelve from one product. Built on .promo-deal-stepper for the
 * shape, with its own button and input classes so the quantity handlers never
 * see it. Green, because it is money the buyer keeps.
 */
.promo-deal-freepick {
    display: inline-flex;
    flex: none;
    flex-direction: column;
    align-items: center;
    gap: .125rem;
}

.promo-deal-freestep {
    border-color: #198754;
}

.promo-deal-freestep.is-picked {
    background: #d1e7dd;
}

.promo-deal-freestep button {
    background: #e8f3ee;
    color: #146c43;
}

.promo-deal-freestep button:hover:not(:disabled) {
    background: #d1e7dd;
}

.promo-deal-freestep button:disabled {
    color: #a4abb3;
    cursor: default;
}

.promo-deal-free-qty {
    width: 2.75rem;
    height: 2rem;
    border: 0;
    border-right: 1px solid #d1e7dd;
    border-left: 1px solid #d1e7dd;
    background: #fff;
    color: #146c43;
    text-align: center;
    font-size: .875rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

.promo-deal-free-qty:focus {
    outline: 0;
    background: #f1faf5;
}

.promo-deal-free-cap {
    font-size: .6875rem;
    color: #146c43;
    white-space: nowrap;
}

/* A product that is not on the order has nothing to make free. */
.promo-deal-free-none {
    flex: none;
    min-width: 5rem;
    text-align: center;
    font-size: .8125rem;
    color: #a4abb3;
}

/* ── Summary ───────────────────────────────────────────────────────────── */
.promo-deal-summary {
    padding: 1.25rem;
}

.promo-deal-summary-head {
    display: flex;
    align-items: flex-start;
    gap: .5rem;
    padding: .75rem 1rem;
    border: 1px solid #a3cfbb;
    border-left: 4px solid #198754;
    border-radius: .5rem;
    background: #f2fbf5;
    color: #146c43;
    font-weight: 600;
}

.promo-deal-summary-list {
    margin: 1rem 0 0;
    padding: 0;
    list-style: none;
}

.promo-deal-summary-list li {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    padding: .4rem 0;
    border-bottom: 1px dashed #eef1f4;
    font-size: .875rem;
}

.promo-deal-summary-list li:last-child {
    border-bottom: 0;
}

/* What THIS line earned, on a per-product ladder. Without it the summary is a
   list of names and quantities that says nothing about the four different prices
   the order is about to be charged. */
.promo-deal-summary-tier {
    margin-left: .5rem;
    padding: .0625rem .4rem;
    border-radius: 999px;
    background: #eefaf2;
    color: #146c43;
    font-size: .6875rem;
    font-style: normal;
    font-weight: 600;
    white-space: nowrap;
}

.promo-deal-note {
    margin: 1rem 0 0;
    font-size: .8125rem;
    color: #8a9199;
}

.promo-deal-body > .promo-deal-note {
    padding: 1.5rem 1.25rem;
    margin: 0;
    text-align: center;
}

/* ── Footer ────────────────────────────────────────────────────────────── */
.promo-deal-footer {
    justify-content: space-between;
    gap: .75rem;
    padding: .875rem 1.25rem;
    border-top: 1px solid #eef1f4;
    background: #f7f9fa;
}

.promo-deal-nav {
    display: flex;
    gap: .375rem;
}

/*
 * Back and forward are always both present and merely disabled at the ends.
 * Hiding one would move the other under the cursor mid-click, and a step nobody
 * can see a way back from is one they will not move off.
 */
.promo-deal-arrow {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.25rem;
    height: 2.25rem;
    border: 1px solid #dfe4e9;
    border-radius: .5rem;
    background: #fff;
    color: #4a5560;
    transition: background-color .15s ease-in-out, color .15s ease-in-out;
}

.promo-deal-arrow:hover:not(:disabled) {
    background: var(--pd-accent);
    border-color: var(--pd-accent);
    color: #fff;
}

.promo-deal-arrow:focus-visible {
    outline: 2px solid var(--pd-accent);
    outline-offset: 2px;
}

.promo-deal-arrow:disabled {
    opacity: .4;
    cursor: default;
}

.promo-deal-actions {
    display: flex;
    align-items: center;
    gap: .75rem;
    min-width: 0;
}

.promo-deal-cta {
    flex: none;
    padding: .5rem 1.25rem;
    border: 0;
    border-radius: .5rem;
    background: var(--pd-accent);
    color: #fff;
    font-size: .875rem;
    font-weight: 600;
    transition: background-color .15s ease-in-out;
}

.promo-deal-cta:hover {
    background: var(--pd-accent-dark);
}

.promo-deal-cta:focus-visible {
    outline: 2px solid var(--pd-accent-dark);
    outline-offset: 2px;
}

/* ── Narrow screens ────────────────────────────────────────────────────── */
@media (max-width: 575.98px) {
    .promo-deal-header,
    .promo-deal-progress,
    .promo-deal-rail,
    .promo-deal-footer {
        padding-right: .875rem;
        padding-left: .875rem;
    }

    .promo-deal-row {
        padding-right: .875rem;
        padding-left: .875rem;
    }

    /* The price column is the first thing to go: it is on the tile behind the
       modal, while the name and the stepper are the reason the modal is open. */
    .promo-deal-thumb {
        width: 2.5rem;
        height: 2.5rem;
    }

    .promo-deal-money {
        display: none;
    }
}

/* ── Inline mode ───────────────────────────────────────────────────────── */
/*
 * The promotion builder does not OPEN the deal builder — it shows it, all the
 * time, beside the form, as the preview of what the merchant is typing. So the
 * same shell has to sit in a page column instead of over a backdrop.
 *
 * Everything below undoes Bootstrap's modal positioning and nothing else. The
 * header, the rail, the rows and the footer are the ones above, unchanged, which
 * is the whole point: a preview built from a simplified copy stops being a
 * preview the first time the real thing changes.
 */
.promo-deal-inline {
    position: static;
    display: block;
    z-index: auto;
    width: auto;
    height: auto;
    overflow: visible;
    background: none;
    /*
     * `.fade` is `opacity: 0` until Bootstrap adds `.show`, and nothing adds
     * `.show` to a panel nobody opens. The shell already leaves `fade` off in
     * inline mode; this is here so that putting it back — or any other
     * transition class arriving on a modal — cannot make the preview render
     * perfectly and show nothing, which is exactly how it failed once.
     */
    opacity: 1;
}

.promo-deal-inline .modal-dialog {
    max-width: none;
    margin: 0;
    pointer-events: auto;
    /* Both dialog sizing classes measure against the VIEWPORT, which means
       nothing inside an auto-height card: `modal-dialog-centered` would push the
       panel to the middle of the screen and `-scrollable` would give it a
       percentage height with no percentage to resolve against. */
    align-items: stretch;
    height: auto;
    min-height: 0;
    /* `.modal.fade .modal-dialog` slides in from -50px. */
    transform: none;
}

.promo-deal-inline .modal-content {
    /* The pane already sits in a card, so a second drop shadow reads as a modal
       that failed to open. */
    box-shadow: none;
    border: 1px solid #e7eaee;
}

/* A preview has no page behind it to scroll, so the list is capped and scrolls
   inside itself — otherwise a forty-product promotion makes the form column
   shorter than the preview beside it. */
.promo-deal-inline .promo-deal-body {
    max-height: 22rem;
    overflow-y: auto;
}

/* The narrow-screen rule above hides the price column because on a phone the
   tile behind the modal already shows it. In the builder there is no tile, and
   the price is half of what the merchant is checking. */
@media (max-width: 575.98px) {
    .promo-deal-inline .promo-deal-money {
        display: flex;
    }
}

/* ── Named themes ──────────────────────────────────────────────────────── */
/*
 * The defaults above are the app's. This is the STOREFRONT's, and it lives here
 * rather than in mini_shop/assets/css/promotions.css so there is one definition of
 * the shop's green — the promotion builder's preview needs it too, and the app does
 * not load the storefront's stylesheet.
 *
 * A surface asks for a theme by class (common/promotion-deal-modal.php's
 * $promoDealTheme) instead of overriding the variables itself, so "what does the
 * shop look like" is answerable from one file.
 */
.promo-deal-theme-portal {
    --pd-header:      linear-gradient(135deg, #14663a 0%, #1a7f45 55%, #2f9d5f 100%);
    --pd-accent:      #1a7f45;
    --pd-accent-dark: #14663a;
    --pd-accent-soft: #8fd0aa;
}

/* ── The shelf ─────────────────────────────────────────────────────────── */
/*
 * A merchant does not meet their own promotion in the deal builder. They meet it
 * as a BADGE ON A PRODUCT CARD, which is the only thing a shopper sees until they
 * click it — so the preview shows the card first and opens the builder from it,
 * in that order.
 *
 * The card is the storefront's: Bootstrap's own .card/.card-body/.card-title
 * classes, laid out and coloured the way mini_shop/functions.php lays out a
 * catalogue tile. What is added here is only what Bootstrap does not give —
 * the image well, the grid, and the storefront's flex badge, which the app's own
 * .promo-tag rules would otherwise render as a compact app pill.
 *
 * Scoped entirely under .promo-shelf so none of it can reach the app's real tiles
 * or the storefront's.
 */
.promo-shelf {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
    gap: .75rem;
}

.promo-shelf .card {
    margin: 0;
    font-size: .8125rem;
}

/* The storefront shows the photograph on white, contained, never cropped — a
   wholesale case shot is mostly label, and cover would cut the label off. */
.promo-shelf-img {
    display: block;
    aspect-ratio: 1;
    margin: .5rem .5rem 0;
    border-radius: .25rem;
    background: #fff center no-repeat;
    background-size: contain;
}

.promo-shelf-img.is-empty {
    display: flex;
    align-items: center;
    justify-content: center;
    background: #f4f6f8;
    color: #c6ccd3;
    font-size: 1.5rem;
}

.promo-shelf .card-title {
    margin-bottom: .25rem;
    padding-top: .375rem;
    font-size: .9375rem;
}

.promo-shelf .card-text {
    margin-bottom: .25rem;
    line-height: 1.3;
}

/* The badge is the storefront's, not the app's: full width, the offer wrapping
   over as many lines as it needs, and the chevron pinned to the end so a two-line
   offer still ends in an arrow. */
.promo-shelf .promo-tag {
    display: flex;
    /* Both real surfaces centre the icon and the chevron against the text
       (`.promo-tag` in css/promotion-deal.css and in the portal's promotions.css
       both say so); this block only sets `display`, so on the builder's preview —
       the one page that loads neither of those — they defaulted to `stretch` and
       hung off the top of any tag that ran to two lines. A merchant writing a
       two-line message is now the normal case rather than an accident of width. */
    align-items: center;
    gap: .4rem;
    width: 100%;
    /* No font-size. It used to shrink the preview's pill to .6875rem, which was
       the mock card's scale talking — but the merchant is being shown this to
       decide what their shelf looks like, and a smaller, differently-weighted
       copy is not that shelf. The size now comes from the shared `.promo-tag`
       block above, which is the one every real card uses. */
    text-align: left;
}

.promo-shelf .promo-tag-text {
    flex: 1 1 auto;
    min-width: 0;
}

.promo-shelf .promo-tag-go {
    flex: none;
    margin-left: auto;
    font-size: .5625rem;
    opacity: .55;
}

.promo-shelf .promo-tag.is-clickable:hover {
    background-color: #d8f3e3 !important;
    border-color: #1a7f45 !important;
}

/* A product the promotion cannot make cheaper carries no badge on a real shelf
   either — PromotionStorefront::beatsShelf() refuses it. Saying so is the point:
   it is the merchant's cue that a $9.00 deal has nothing to offer an $8.00 item. */
.promo-shelf-nobadge {
    display: block;
    margin-top: .25rem;
    font-size: .6875rem;
    line-height: 1.25;
    color: #a4abb3;
}

/* ═════════════════════════════════════════════════════════════════════════
 * THE DEAL TAG, AS THE MERCHANT SET IT  (migration 070)
 * ═════════════════════════════════════════════════════════════════════════
 * A merchant can give one promotion's tag its own words, its own two colours
 * and its own position on the product card. The words and the position are
 * markup and belong to PHP (App\Promotions\PromotionBadge). Everything a
 * COLOUR implies past the two the merchant actually picked lives here:
 *
 *   the border tint      currentColor, faded
 *   the hover shade      the background, nudged toward the text colour
 *   the focus ring       currentColor
 *
 * Here rather than in PHP because there are three surfaces drawing this pill —
 * order entry, the customer portal, and the promotion builder's own preview —
 * and only one of them renders in PHP at all. Shipping five computed colours on
 * every card so the preview's JavaScript could reproduce this arithmetic would
 * be the second implementation, and the first thing to drift would be the
 * preview promising a colour the shop does not draw.
 *
 * WHY THIS FILE. It is the only stylesheet all three surfaces load, and it is
 * loaded LAST on every one of them (order.php, sales-order.php,
 * mini_shop/header.php, edit-promotion.php). That matters: the default green
 * hover in css/promotion-deal.css, mini_shop/assets/css/promotions.css and the
 * .promo-shelf block above are all `!important`, so these rules win on
 * specificity and, where that ties, on being last. Keep them at the end.
 * ═════════════════════════════════════════════════════════════════════════ */

/*
 * A line break the merchant typed is a line break on the card.
 *
 * "SUMMER SALE" over "10 for the price of 9" is a headline with a subtitle, which
 * is what a shelf tag looks like everywhere outside this system — and the message
 * box in the designer is a textarea, so a merchant who presses Enter has already
 * been told this works.
 *
 * `pre-line` rather than turning "\n" into a <br> in the renderer. The tag's text
 * is PLAIN TEXT everywhere else in this feature: PromotionMessages produces it,
 * and every caller escapes it for its own medium. Promoting one character to
 * markup would mean the PHP renderer and both JavaScript ones each deciding where
 * a <br> goes and each escaping around it — three chances to put a tag through an
 * HTML parser that should never have seen one.
 *
 * Runs of spaces and tabs still collapse, so a generated sentence renders exactly
 * as it did before this rule existed.
 *
 * Unscoped, and NOT limited to .promo-tag-custom: the words and the colours are
 * separate settings, and a merchant may write two lines without picking a colour.
 */
.promo-tag-text {
    white-space: pre-line;
}

/* ── The pill itself ────────────────────────────────────────────────────────
 *
 * Same reasoning as the corners below, and the same fix, for the properties
 * that were never brought over with them.
 *
 * The tag used to be surface furniture, and css/promotion-deal.css still says
 * so at the top: an app tile is denser than a shop card, so each sheet dressed
 * its own. Migration 070 changed what the tag IS — the merchant now designs one
 * object and the designer's own subtitle promises it on "a product card, in
 * order entry, in the customer portal, and in the preview beside this window".
 * Only order entry ever declared the weight, the padding and the line-height,
 * so the portal and the builder's preview fell through to Bootstrap's generic
 * `.badge`: 700 against 500, `.35em .65em` against `.2rem .5rem`. A merchant
 * comparing the preview with their own shelf saw two different components and
 * reasonably read it as the colours being wrong.
 *
 * LAYOUT is still each surface's own — `display`, `gap`, and whether the pill
 * spans the card or hugs its text are about the card around it, and that card
 * really is a different card in an app grid and in a shop. What is shared is
 * what the tag LOOKS like, which is what the merchant chose.
 *
 * Both sheets that also style `.promo-tag` load BEFORE this one and match its
 * specificity, so these win without `!important`.
 */
.promo-tag {
    padding: .2rem .5rem;
    font-size: .75rem;
    font-weight: 500;
    line-height: 1.25;
}

/* ── Corners ────────────────────────────────────────────────────────────────
 *
 * Two named shapes, and BOTH are declared here rather than only the non-default
 * one. Left implicit, "rounded" meant whatever each surface happened to say —
 * a full pill on the order page (css/promotion-deal.css sets 999px) and
 * Bootstrap's own `.badge` radius in the customer portal, which never declared
 * one at all. A merchant choosing between two shapes has to get the same shape
 * in both shops, so the choice is defined once, in the sheet all three surfaces
 * load. The visible effect is that the portal's tag becomes a full pill, which
 * is what the order page has drawn all along.
 *
 * Two classes rather than one variable: `border-radius` is the whole of the
 * setting, there is no third value, and a `--promo-tag-radius` on every card
 * would be a number travelling from the server to say one of two words.
 *
 * Specificity beats both sheets above ((0,2,0) against their (0,1,0)), so
 * neither of these needs `!important`.
 */
.promo-tag.promo-tag-rounded {
    border-radius: 999px;
}

.promo-tag.promo-tag-square {
    border-radius: 0;
}

/* The "Add N" button order-promotions.js and listing.js inject INTO the tag has
   its own radius, and a pill button in a square tag is the one place the two
   shapes would meet and disagree. It follows the tag. */
.promo-tag.promo-tag-square .promo-tag-add {
    border-radius: 0;
}

/* Only `--promo-tag-bg` and `color` arrive from the server; the pill is drawn
   from those two and nothing else. A promotion nobody has styled carries no
   `promo-tag-custom` class at all and none of this applies to it. */
.promo-tag.promo-tag-custom {
    background-color: var(--promo-tag-bg);
    border-color: color-mix(in srgb, currentColor 28%, transparent) !important;
}

.promo-tag.promo-tag-custom.is-clickable:hover,
.promo-tag.promo-tag-custom.is-clickable:focus-visible {
    /* Two declarations, on purpose. A browser without color-mix() drops the
       second and keeps the merchant's own background on hover — the tag stops
       responding to the pointer, which is untidy; without the first it would
       revert to the default green, which is wrong. */
    background-color: var(--promo-tag-bg) !important;
    background-color: color-mix(in srgb, var(--promo-tag-bg) 86%, currentColor) !important;
    border-color: color-mix(in srgb, currentColor 55%, transparent) !important;
}

.promo-tag.promo-tag-custom.is-clickable:focus-visible {
    outline-color: currentColor;
}

/* The "Add N" button listing.js and order-promotions.js inject INTO the tag is
   the storefront's amber by default, which reads as a second, unrelated offer
   once the pill around it is the merchant's own colour. Inverted instead: the
   button borrows the tag's text colour and prints in its background. */
.promo-tag.promo-tag-custom .promo-tag-add {
    background: currentColor;
}

.promo-tag.promo-tag-custom .promo-tag-add:hover:not(:disabled) {
    background: color-mix(in srgb, currentColor 85%, #000);
}

/* ── Where on the card ──────────────────────────────────────────────────────
 * One wrapper class per placement, so a card can print three slots and each
 * promotion lands in the one it asked for. Margins only: the pill itself, the
 * "Add N" injection and the deal-builder binding are identical wherever it sits.
 */

/* The wrapper's own layout, which the portal never had: its badges relied on a
   Bootstrap `mb-1` utility for the gap between two of them, and PromotionBadge
   emits the wrapper rather than per-pill spacing utilities. Identical to what
   css/promotion-deal.css already gives the order page, so nothing moves there. */
.promo-tags {
    display: flex;
    flex-wrap: wrap;
    gap: .25rem;
}

/* A ribbon across the very top, above the photograph. Inset to line up with the
   image well's own padding on both the order card (p-3) and the portal's (p-4). */
.promo-tags.promo-tags-top {
    margin: .5rem .5rem 0;
}

/* Straight under the price, where a shopper comparing prices is already looking.
   Tight above so it reads as part of the price block, spaced below so it does
   not run into the item code. */
.promo-tags.promo-tags-below-price {
    margin: 0 0 .35rem;
}

/* Under the product's details and above Add to cart — where every tag sat before
   migration 070, and therefore the default. */
.promo-tags.promo-tags-before-cart {
    margin: .25rem 0 0;
}

/* ═════════════════════════════════════════════════════════════════════════
 * THE CORNER BANNER  (migration 070)
 * ═════════════════════════════════════════════════════════════════════════
 * One word over the product photograph — SPECIAL, DEAL, BEST VALUE — in one of
 * four shapes, in either top corner. Not the deal tag: that STATES the offer in
 * a sentence a shopper reads and clicks, and it lives in the flow of the card.
 * This is what makes the card get noticed among the forty around it, before
 * anybody has focused on any of them.
 *
 * ─────────────────────────────────────────────────────────────────────────
 * ONE PAINTED FACE, FOUR OUTLINES
 * ─────────────────────────────────────────────────────────────────────────
 * Every shape is the same three elements:
 *
 *   .promo-banner        positions and CLIPS. Absolute against .card, which is
 *                        already `position: relative` on all three surfaces.
 *   .promo-banner-face   the painted part — gradient, shadow, frame. Identical
 *                        for all four shapes, which is what makes them look like
 *                        one family rather than four stickers.
 *   .promo-banner-text   the word.
 *
 * So a fifth shape is an outline, not a new design.
 *
 * ─────────────────────────────────────────────────────────────────────────
 * THE SERVER SENDS TWO COLOURS. EVERYTHING ELSE IS DERIVED HERE.
 * ─────────────────────────────────────────────────────────────────────────
 * `--pb-bg` and `--pb-fg`, and nothing more. The lit top edge, the shaded
 * bottom, the drop shadow and the inner hairline that frames it all come off
 * `--pb-bg` through color-mix() — because the preview that has to agree with
 * this is JavaScript, and five computed colours travelling per card is five
 * chances for the preview to promise a banner the shop does not draw.
 *
 * ─────────────────────────────────────────────────────────────────────────
 * IT IS NOT CLICKABLE
 * ─────────────────────────────────────────────────────────────────────────
 * `pointer-events: none` throughout. It sits over the product photograph, which
 * is a link to the lightbox on both storefronts and the biggest tap target on
 * the card; a shopper aiming at the product must not get a decoration instead.
 * ═════════════════════════════════════════════════════════════════════════ */

.promo-banner {
    /* The stylesheet's own crimson, for a banner whose merchant picked no
       colours. Overridden by the two custom properties the server sends. */
    --pb-bg: #c81e3c;
    --pb-fg: #ffffff;

    position: absolute;
    top: 0;
    z-index: 4;   /* above the legacy ribbons (2) and the card tools (3) */
    overflow: hidden;
    pointer-events: none;
    /* Inherited by every face below, so the family shares one voice: small,
       heavy, letter-spaced, upper case — a sticker, not a sentence. */
    font-size: .625rem;
    font-weight: 800;
    letter-spacing: .06em;
    line-height: 1.1;
    text-transform: uppercase;
    white-space: nowrap;
}

/* The painted part. Lit from the top-left and shaded to the bottom-right, with
   a white hairline inside the edge — that hairline is the "frame", and it is
   what keeps a saturated colour from bleeding into a busy product photo. */
.promo-banner-face {
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--pb-bg);
    background: linear-gradient(135deg,
        color-mix(in srgb, var(--pb-bg) 78%, #fff) 0%,
        var(--pb-bg) 52%,
        color-mix(in srgb, var(--pb-bg) 82%, #000) 100%);
    color: var(--pb-fg);
    box-shadow:
        0 .125rem .375rem rgba(16, 24, 32, .3),
        inset 0 0 0 1px rgba(255, 255, 255, .22),
        inset 0 1px 0 rgba(255, 255, 255, .3);
    text-shadow: 0 1px 1px rgba(0, 0, 0, .25);
}

/* ── Ribbon: a 45° band across the corner ──────────────────────────────── */
/*
 * The outer square clips at the card's own rounded corner, so the band appears
 * to pass under it rather than over it — which is the difference between a
 * ribbon that looks printed on the card and a sticker that looks pasted near it.
 *
 * ─────────────────────────────────────────────────────────────────────────
 * WHY THE BAND IS CENTRED WITH PERCENTAGES AND A translate(), NOT WITH OFFSETS
 * ─────────────────────────────────────────────────────────────────────────
 * The word is centred inside the band (`justify-content: center` on the face),
 * so it only LOOKS centred if the band's own centre sits on the midpoint of the
 * part of it that is visible. Everything outside the clipping square is cut off,
 * and the two cut-off lengths are not equal unless that midpoint is hit exactly.
 *
 * Positioned by fixed `top` and `left`/`right`, it never was. The face's vertical
 * centre depends on its HEIGHT — which is the font size, the line height and the
 * padding — so a `top` in rem lands the centre wherever that arithmetic happens
 * to put it, and the word drifts along the band. It drifted a different amount
 * again at the smaller font size on a phone and in the designer's miniature
 * previews, because those change the height and nothing else.
 *
 * `top`/`left` in PERCENT put the face's leading edge at a fraction of the
 * container; `translate(-50%, -50%)` then moves it back by half its own size,
 * whatever that size is. So the face's CENTRE lands at (35%, 35%) of the square
 * and stays there at any font size.
 *
 * That point is on the square's leading diagonal, which is the whole trick: the
 * band's centreline is then y = -x + 0.7S, it meets the edges at (0.7S, 0) and
 * (0, 0.7S), and the midpoint of that chord is (0.35S, 0.35S) — the face's centre.
 * The right-hand variant is its mirror: centre (0.65S, 0.35S), chord (0.3S, 0) to
 * (S, 0.7S), same midpoint identity.
 *
 * A consequence worth having: the face's WIDTH stops mattering. It only has to be
 * at least as long as the chord (0.99 × the square's side) — anything beyond that
 * is clipped off both ends equally.
 */
.promo-banner-ribbon {
    width: 6.25rem;
    height: 6.25rem;
}

.promo-banner-ribbon .promo-banner-face {
    position: absolute;
    width: 9rem;
    padding: .25rem 0;
}

.promo-banner-ribbon.promo-banner-right {
    right: 0;
    border-top-right-radius: var(--bs-card-border-radius, .375rem);
}

.promo-banner-ribbon.promo-banner-right .promo-banner-face {
    top: 35%;
    right: 35%;
    transform: translate(50%, -50%) rotate(45deg);
}

.promo-banner-ribbon.promo-banner-left {
    left: 0;
    border-top-left-radius: var(--bs-card-border-radius, .375rem);
}

.promo-banner-ribbon.promo-banner-left .promo-banner-face {
    top: 35%;
    left: 35%;
    transform: translate(-50%, -50%) rotate(-45deg);
}

/* ── Flag: a straight tab pinned to one edge ───────────────────────────── */
/*
 * The notch is cut with clip-path rather than drawn with a border triangle — a
 * triangle needs its own element in its own colour, and that colour is a second
 * value off --pb-bg that would then have to be computed somewhere. Browsers
 * without clip-path get a plain rectangle, which is a flag with a blunt tail.
 *
 * `overflow: visible` and a drop-shadow FILTER rather than the face's own
 * box-shadow: a box-shadow follows the element's box, not its clip path, so it
 * would draw a rectangular shadow behind a notched flag.
 */
.promo-banner-flag {
    top: .75rem;
    overflow: visible;
    filter: drop-shadow(0 .125rem .25rem rgba(16, 24, 32, .3));
}

.promo-banner-flag .promo-banner-face {
    padding: .3rem .7rem;
    box-shadow:
        inset 0 0 0 1px rgba(255, 255, 255, .22),
        inset 0 1px 0 rgba(255, 255, 255, .3);
}

.promo-banner-flag.promo-banner-left {
    left: 0;
}

.promo-banner-flag.promo-banner-left .promo-banner-face {
    padding-right: 1.05rem;
    clip-path: polygon(0 0, 100% 0, calc(100% - .45rem) 50%, 100% 100%, 0 100%);
}

.promo-banner-flag.promo-banner-right {
    right: 0;
}

.promo-banner-flag.promo-banner-right .promo-banner-face {
    padding-left: 1.05rem;
    clip-path: polygon(.45rem 50%, 0 0, 100% 0, 100% 100%, 0 100%);
}

/* ── Bar: a strip across the whole top ─────────────────────────────────── */
.promo-banner-bar {
    left: 0;
    right: 0;
    border-top-left-radius: var(--bs-card-border-radius, .375rem);
    border-top-right-radius: var(--bs-card-border-radius, .375rem);
}

.promo-banner-bar .promo-banner-face {
    padding: .3rem .75rem;
}

/* The word sits at the end the merchant chose. A bar spans the card, so "left"
   and "right" stop being a corner and become an alignment — which is the only
   reading of the setting that means anything on this shape. */
.promo-banner-bar.promo-banner-left .promo-banner-face {
    justify-content: flex-start;
}

.promo-banner-bar.promo-banner-right .promo-banner-face {
    justify-content: flex-end;
}

/* ── Corner: a quarter-disc filling the corner ─────────────────────────── */
.promo-banner-corner {
    width: 4.75rem;
    height: 4.75rem;
    font-size: .5625rem;
}

.promo-banner-corner .promo-banner-face {
    position: absolute;
    inset: 0;
}

/*
 * The word sits ON the corner's own diagonal, centred on it — the same
 * construction as the ribbon above, and fixed for the same reason. It used to be
 * `rotate(38deg) translate(.5rem, -.1rem)`: three numbers tuned by eye against
 * one font size, which stopped being centred the moment the type changed size on
 * a phone or in the designer's miniatures.
 *
 * 45°, not 38°: the disc fills a square corner, so its diagonal is at exactly 45°
 * and the word has to be square to it.
 *
 * The centre lands at 34% in from both edges. Measured from the corner along the
 * diagonal that is a little under half the radius, which is where the quarter
 * disc is widest and so where a word has the most room either side of it.
 *
 * This is also the one shape that lets a word WRAP. Everything else here is a
 * band the word runs along; this is a disc with a fixed diagonal, and "Best
 * Value" set on one line inside it either overflows the curve or shrinks to
 * unreadable. The width is fixed rather than a max, because `translate(±50%)`
 * has to move the box by half of a known width to land its centre on the mark.
 */
.promo-banner-corner .promo-banner-text {
    position: absolute;
    display: block;
    width: 3.4rem;
    text-align: center;
    white-space: normal;
}

.promo-banner-corner.promo-banner-right {
    right: 0;
    border-top-right-radius: var(--bs-card-border-radius, .375rem);
}

.promo-banner-corner.promo-banner-right .promo-banner-face {
    border-bottom-left-radius: 100%;
}

.promo-banner-corner.promo-banner-right .promo-banner-text {
    top: 34%;
    right: 34%;
    transform: translate(50%, -50%) rotate(45deg);
}

.promo-banner-corner.promo-banner-left {
    left: 0;
    border-top-left-radius: var(--bs-card-border-radius, .375rem);
}

.promo-banner-corner.promo-banner-left .promo-banner-face {
    border-bottom-right-radius: 100%;
}

.promo-banner-corner.promo-banner-left .promo-banner-text {
    top: 34%;
    left: 34%;
    transform: translate(-50%, -50%) rotate(-45deg);
}

/* ── Narrow cards ──────────────────────────────────────────────────────── */
/*
 * Two cards per row on a phone. A 6.25rem ribbon across a card barely 9rem wide
 * stops being a corner and becomes a diagonal stripe over the product, so the
 * whole family steps down together rather than each shape being retuned.
 *
 * Only the SQUARE and the type change. The band's position inside that square is
 * a percentage and its centring follows automatically — which is the point of
 * expressing it that way, and the reason this block used to have four more rules
 * in it, each re-tuning an offset that then had to be centred all over again.
 */
@media (max-width: 575.98px) {
    .promo-banner {
        font-size: .5625rem;
        letter-spacing: .04em;
    }

    .promo-banner-ribbon {
        width: 5rem;
        height: 5rem;
    }

    .promo-banner-corner {
        width: 3.9rem;
        height: 3.9rem;
    }

    /* The one thing that does not scale with the square: the wrapping width of
       the word inside the disc, which is a length rather than a proportion. */
    .promo-banner-corner .promo-banner-text {
        width: 2.8rem;
    }
}
