/* ============================================================================
   card-art.css — the stamp card's artwork, and the ONLY stylesheet that draws
   it. Loaded by the customer webview (customer/app.html) and by every portal
   page that previews a card (stamp-card-form.html, public/onboarding.html).

   Before this file existed the webview drew .sc-hero from customer.css and the
   portal drew a lookalike .stampcard-preview from portal.css, each with its own
   gradient formula. That is why the portal preview stopped matching the
   customer card. One stylesheet + one renderer (card-art.js) makes them
   incapable of disagreeing — the same argument CardProgressMapper's class
   comment makes about reward counts.

   SELF-CONTAINED BY CONTRACT. The portal does not define the webview's
   palette, so every token consumed here carries an inline fallback. Do not add
   a bare var(--x) to this file.

   Variation is expressed ONLY as data-* attributes and custom properties set by
   the renderer. No option may branch the markup — that is what keeps each
   option a CSS rule that is tested once instead of a layout that has to be
   re-verified per tenant.
   ============================================================================ */

/* ======================== SCOPED RESET (load-bearing) ======================
   The card must render identically in any document, and the two documents that
   render it do NOT agree on their base styles: customer.css sets
   *{box-sizing:border-box}, zeroes default heading/paragraph margins and sets a
   base font; portal.css does none of those. Inheriting them made the same card
   10px taller in the portal than in the webview, with a 2px taller type pill —
   caught by the parity check, invisible to a reading of the CSS.

   So the card resets what it relies on, scoped to itself. Nothing here may be
   dropped on the assumption that "the page already does that": the whole point
   of this file is that it does not know what page it is on. */
.sc-hero, .sc-hero *, .sc-hero *::before, .sc-hero *::after{ box-sizing:border-box; }
.sc-hero h2, .sc-hero p, .sc-hero small{ margin:0; }

.sc-hero{
  /* ---- token fallbacks ----
     Font stacks are LITERAL, never read from the host: the webview calls its
     display family --disp while the portal calls it --font-disp and points it at
     a different typeface entirely (Bricolage Grotesque). Reading either name
     would make the card's type depend on which page it landed on. */
  --_text:'Hanken Grotesk',system-ui,sans-serif;
  --_disp:'Hanken Grotesk',system-ui,sans-serif;
  --_mono:'JetBrains Mono',ui-monospace,monospace;

  /* Brand ink and shadow tint come from the MODEL (--card-ink / --card-ring),
     emitted by the renderer from the tenant's brand colours. They used to be read
     from the webview's --brand-ink / --brand-ring, which the portal does not
     define — so a previewed card had a near-black tick and a grey shadow while
     the customer saw the tenant's accent colour. */
  --_ink:var(--card-ink,#1C1A17);
  --_ring:var(--card-ring,rgba(28,26,23,.18));
  --_gold-hi:var(--gold-hi,#EBD08A);
  --_ease:var(--ease,cubic-bezier(.32,.72,.3,1));
  font-family:var(--_text);

  /* ---- design defaults: these reproduce the pre-P1 card exactly, so a tenant
          who never opens Card Studio sees no change and any visual diff in
          verification is a real regression. ---- */
  --_base:var(--card-color,#8A5230);
  --_c1:color-mix(in srgb,var(--_base),#000 20%);
  --_c2:var(--card-accent,color-mix(in srgb,var(--_base),#000 52%));
  --_c3:color-mix(in srgb,var(--_base),#fff 24%);
  --_angle:var(--grad-angle,155deg);
  --_radius:var(--card-radius,24px);
  --_title:var(--title-color,#fff);
  --_desc:var(--desc-color,rgba(255,255,255,.76));

  position:relative; overflow:hidden;
  padding:18px;
  border-radius:var(--_radius);
  color:#fff;
  box-shadow:0 12px 30px var(--_ring);
  background:linear-gradient(var(--_angle),var(--_c1),var(--_c2));

  /* A flex column so `order` can move the header, description and stamp grid
     between named positions without the renderer emitting different markup.
     The background, texture and sheen layers are absolutely positioned, so they
     stay out of this flow. Default order matches the historical block layout. */
  display:flex; flex-direction:column;
}
.sc-head{ order:1; }
.sc-description{ order:2; }
.sc-grid{ order:3; }

/* ============================== SURFACE ====================================
   data-grad. LINEAR is the default and is byte-identical to the gradient the
   webview used inline before this file.

   --card-accent is what makes these worth having: without it both stops are
   color-mix(base,#000 x%), so every tenant's card is two shades of one hue and
   they all read as the same card recoloured. The var() fallback means an unset
   accent silently keeps the old derived stop. */
.sc-hero[data-grad="solid"]{ background:var(--_base); }
.sc-hero[data-grad="linear"]{ background:linear-gradient(var(--_angle),var(--_c1),var(--_c2)); }
.sc-hero[data-grad="radial"]{ background:radial-gradient(135% 115% at 18% -10%,var(--_c1),var(--_c2) 78%); }
.sc-hero[data-grad="duotone"]{ background:linear-gradient(var(--_angle),var(--_c1) 0 46%,var(--_c2) 46% 100%); }
.sc-hero[data-grad="mesh"]{
  background:
    radial-gradient(70% 60% at 12% 8%,var(--_c3),transparent 62%),
    radial-gradient(80% 70% at 92% 16%,var(--_c2),transparent 60%),
    radial-gradient(95% 90% at 52% 112%,var(--_c1),transparent 70%),
    linear-gradient(var(--_angle),var(--_c1),var(--_c2));
}

/* ================================ SHEEN ==================================== */
.sc-sheen{ position:absolute; inset:0; pointer-events:none;
  background:radial-gradient(120% 90% at 86% -8%,rgba(255,255,255,.22),transparent 55%); }
.sc-hero[data-sheen="none"] .sc-sheen{ display:none; }
.sc-hero[data-sheen="specular"] .sc-sheen{
  background:linear-gradient(112deg,transparent 26%,rgba(255,255,255,.30) 45%,rgba(255,255,255,.06) 53%,transparent 62%); }
.sc-hero[data-sheen="holo"] .sc-sheen{
  mix-blend-mode:overlay; opacity:.55;
  background:linear-gradient(112deg,
    rgba(255,120,180,.42) 8%, rgba(255,214,120,.42) 26%, rgba(140,240,200,.42) 44%,
    rgba(130,180,255,.42) 62%, rgba(210,140,255,.42) 80%);
  background-size:220% 220%;
  background-position:calc(50% + var(--tilt-x,0) * 42%) calc(50% + var(--tilt-y,0) * 32%);
  transition:background-position 120ms ease-out; }

/* =============================== TEXTURE ===================================
   data-texture on the hero, drawn by the .sc-pattern layer. DOTS is today's. */
.sc-pattern{ position:absolute; inset:0; pointer-events:none;
  opacity:var(--texture-op,.19);
  background-image:
    radial-gradient(circle at 20% 25%,rgba(255,255,255,.54) 0 1px,transparent 1.6px),
    linear-gradient(115deg,transparent 0 46%,rgba(255,255,255,.16) 46.5% 47.5%,transparent 48%);
  background-size:54px 54px,160px 160px; }
.sc-hero[data-texture="none"] .sc-pattern{ display:none; }
.sc-hero[data-texture="carbon"] .sc-pattern{
  background-image:
    repeating-linear-gradient(45deg,rgba(255,255,255,.20) 0 1px,transparent 1px 4px),
    repeating-linear-gradient(-45deg,rgba(0,0,0,.20) 0 1px,transparent 1px 4px);
  background-size:auto,auto; }
.sc-hero[data-texture="linen"] .sc-pattern{
  background-image:
    repeating-linear-gradient(0deg,rgba(255,255,255,.16) 0 1px,transparent 1px 3px),
    repeating-linear-gradient(90deg,rgba(255,255,255,.10) 0 1px,transparent 1px 3px);
  background-size:auto,auto; }
.sc-hero[data-texture="topo"] .sc-pattern{
  background-image:repeating-radial-gradient(circle at 26% 18%,
    transparent 0 13px,rgba(255,255,255,.30) 13px 14px);
  background-size:auto; }
.sc-hero[data-texture="noise"] .sc-pattern{
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size:120px 120px; }
.sc-hero[data-texture="glass"] .sc-pattern{
  background-image:
    linear-gradient(160deg,rgba(255,255,255,.34) 0 22%,transparent 42%),
    linear-gradient(0deg,rgba(0,0,0,.22),transparent 55%);
  background-size:auto,auto; }

/* ============================= TYPOGRAPHY ==================================
   data-font. Deliberately system stacks rather than webfonts: card-art.css is
   loaded by pages that do not share the webview's @import, and a webfont here
   would mean a second network dependency plus a flash of fallback text on the
   customer's card. */
.sc-hero[data-font="serif"]{ --_disp:'Iowan Old Style','Palatino Linotype',Palatino,Georgia,'Times New Roman',serif; }
.sc-hero[data-font="mono"]{ --_disp:'JetBrains Mono',ui-monospace,monospace; }
.sc-hero[data-font="rounded"]{ --_disp:ui-rounded,'SF Pro Rounded','Hiragino Maru Gothic ProN','Segoe UI',system-ui,sans-serif; }
.sc-hero[data-case="upper"] .sc-identity h2{ text-transform:uppercase; letter-spacing:.005em; }

/* ============================ HEAD / IDENTITY ============================== */
.sc-head{ position:relative; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.sc-identity{ min-width:0; display:flex; align-items:center; gap:10px; }
.sc-logo{ width:42px; height:42px; flex:none; display:flex; align-items:center; justify-content:center; overflow:hidden; border:1px solid rgba(255,255,255,.24); border-radius:13px; background:rgba(255,255,255,.16); box-shadow:0 6px 14px rgba(0,0,0,.14); }
.sc-logo img{ width:100%; height:100%; object-fit:cover; }
.sc-identity small{ display:block; max-width:160px; overflow:hidden; opacity:.7; font-size:8.5px; font-weight:850; letter-spacing:.1em; text-overflow:ellipsis; text-transform:uppercase; white-space:nowrap; }
.sc-identity h2{ max-width:190px; margin-top:3px; overflow-wrap:anywhere; color:var(--_title); font-family:var(--_disp); font-size:21px; font-weight:820; line-height:1.06; }
/* Scoped with .sc-hero deliberately. The reset above is `.sc-hero p { margin:0 }`
   at specificity (0,1,1); a bare `.sc-description` is (0,1,0) and loses to it, so
   the description's top margin vanished and every card lost 12px of height. */
.sc-hero .sc-description{ position:relative; max-width:84%; margin-top:12px; color:var(--_desc); font-size:11.5px; font-weight:620; line-height:1.45; }

/* ========================== STAMP GRID / SLOTS ============================= */
.sc-grid{ position:relative; display:grid; grid-template-columns:repeat(var(--stamp-cols,5),minmax(0,1fr)); gap:10px; margin:18px 2px 16px; }
.sc-slot{ position:relative; aspect-ratio:1; min-width:0; border-radius:50%; display:flex; align-items:center; justify-content:center; }
.sc-slot.empty{ background:rgba(255,255,255,.09); border:1.5px dashed rgba(255,255,255,.4); color:rgba(255,255,255,.72); }
.sc-slot.full{ background:#fff; color:var(--_ink); box-shadow:0 3px 8px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.9); }
.sc-slot.reward.empty{ border-style:solid; border-color:rgba(255,255,255,.6); background:rgba(255,255,255,.13); color:rgba(255,255,255,.92); }
.sc-slot svg{ width:44%; height:44%; }
.sc-slot .num{ font-family:var(--_mono); font-size:11px; font-weight:800; opacity:.85; }
.sc-slot img{ width:60%; height:60%; object-fit:contain; }
/* .has-icon strips the circle chrome so transparent PNG/SVG art renders as-is */
.sc-slot.has-icon, .sc-slot.reward.empty.has-icon{ background:none; border:none; box-shadow:none; border-radius:14px; }
.sc-slot.has-icon img{ width:92%; height:92%; }
.sc-slot.empty.has-icon img{ opacity:.42; }
.sc-slot.empty.has-icon.inv img{ filter:invert(1); }
.sc-slot.next:not(.has-icon){ border-style:solid; border-color:rgba(255,255,255,.66); background:rgba(255,255,255,.18); box-shadow:inset 0 0 0 1px rgba(255,255,255,.08); }
.sc-slot.next::after{ content:""; position:absolute; inset:auto auto -4px 50%; width:13px; height:3px; border-radius:999px; background:rgba(255,255,255,.88); transform:translateX(-50%); box-shadow:0 2px 7px rgba(0,0,0,.22); }
.sc-slot.next.has-icon img{ opacity:.72; filter:drop-shadow(0 3px 6px rgba(0,0,0,.22)); }
.sc-slot.reward.glow{ box-shadow:0 0 0 3px rgba(255,255,255,.28), 0 0 22px var(--_gold-hi); }
.sc-slot.reward.has-icon.glow{ box-shadow:none; filter:drop-shadow(0 0 10px var(--_gold-hi)); }

/* ============================== MOTION =====================================
   Entrance stagger is driven by --sc-i on each filled slot. data-anim="1" is
   set by the webview page container; the portal preview omits it, so the
   preview shows the settled state. `both` fill means the final frame is
   identical either way, which is what keeps preview parity exact. */
@keyframes scStampIn{ 0%{ transform:scale(1.9) rotate(-16deg); opacity:0; } 62%{ transform:scale(.94) rotate(2deg); opacity:1; } 100%{ transform:scale(1) rotate(0); opacity:1; } }
@keyframes scGlow{ 0%,100%{ filter:brightness(1); } 50%{ filter:brightness(1.14); } }
@media (prefers-reduced-motion:no-preference){
  /* WHICH animation a filled slot plays is the tenant's `motionEarn` choice, and
     it lives in the earn block further down — one definition serving both the
     card's entrance and a real earn. Only the fallback stagger lives here, for a
     card rendered without a data-earn attribute at all. */
  [data-anim="1"] .sc-slot.full{ animation-delay:calc(var(--sc-i,0)*45ms); }
  .sc-slot.reward.glow:not(.has-icon){ animation:scGlow 2.6s ease-in-out infinite; }
}

/* ========================= BACKGROUND IMAGERY ==============================
   The tenant's own photograph, composited UNDER the content and OVER the
   gradient surface.

   `data-bg` is the treatment, and it is not decoration. A merchant uploads a
   photo that is bright exactly where the white brand name sits; applied raw the
   card is unreadable, and no tooltip fixes that. The treatment is the mechanism
   that makes an arbitrary photo safe, which is why SCRIM — not COVER — is the
   default. --bg-dim drives the scrim, --bg-blur lets a busy photo become
   texture, and the focal point comes from object-position, matching the crop
   semantics the banner editor already uses. */
.sc-bg{ position:absolute; inset:0; overflow:hidden; pointer-events:none; border-radius:inherit; }
.sc-bg img{
  width:100%; height:100%;
  object-fit:cover;
  object-position:var(--bg-x,50%) var(--bg-y,50%);
  transform-origin:var(--bg-x,50%) var(--bg-y,50%);
  transform:scale(var(--bg-zoom,1));
  filter:blur(var(--bg-blur,0px));
}
/* TWO overlay layers, and the split matters.
   ::before TINTS (hue and saturation only, via the `color` blend mode).
   ::after DIMS (plain paint at --bg-dim).

   They were one layer at first, which made DUOTONE unreadable on a bright photo:
   `mix-blend-mode: color` takes hue and saturation from the wash but LUMINANCE
   FROM THE PHOTO, so a pale image stayed pale behind white text. Recolouring is
   not dimming. Duotone now does both — on-palette *and* legible. */
.sc-bg::before,
.sc-bg::after{ content:""; position:absolute; inset:0; pointer-events:none; }
.sc-bg::before{ display:none; }
.sc-bg::after{
  background:linear-gradient(var(--_angle),var(--_c1),var(--_c2));
  /* The 52 fallback IS the default overlay strength — the renderer omits any
     property equal to its default, so this value is what most cards actually
     use. It must track CardDesignResolver.DEFAULT_BACKGROUND_DIM; a check
     asserts every fallback here matches its JS default, because when this said
     45 the raised default silently did nothing. */
  opacity:calc(var(--bg-dim,52) / 100);
}

.sc-hero[data-bg="none"] .sc-bg{ display:none; }
/* COVER is the one treatment with no overlay at all. It is therefore the one that
   can be illegible; the editor's contrast check is what warns about it. */
.sc-hero[data-bg="cover"] .sc-bg::after{ display:none; }

/* brightness(.8) is what makes this a duotone rather than a tint: a real duotone
   maps the photo into the palette's own tonal RANGE, and the card's palette is
   dark. Without it a pale photo stayed pale — the title measured exactly 3.00:1,
   sitting on the AA limit with no headroom for local bright spots. */
.sc-hero[data-bg="duotone"] .sc-bg img{ filter:blur(var(--bg-blur,0px)) grayscale(1) contrast(1.08) brightness(.8); }
.sc-hero[data-bg="duotone"] .sc-bg::before{
  display:block;
  background:linear-gradient(var(--_angle),var(--_c1),var(--_c2));
  mix-blend-mode:color;
}

/* The image occupies a band and dissolves into flat colour, so the stamp grid
   always sits on solid ground. A mask, not a gradient overlay, so the photo
   genuinely fades rather than being veiled. */
.sc-hero[data-bg="top-bleed"] .sc-bg{
  -webkit-mask-image:linear-gradient(180deg,#000 0 46%,transparent 78%);
          mask-image:linear-gradient(180deg,#000 0 46%,transparent 78%);
}
.sc-hero[data-bg="bottom-fade"] .sc-bg{
  -webkit-mask-image:linear-gradient(0deg,#000 0 46%,transparent 78%);
          mask-image:linear-gradient(0deg,#000 0 46%,transparent 78%);
}

/* ---- the header's own legibility band ----
   Only when there is a photo. Dimming the WHOLE image enough to guarantee a
   readable title (measured: ~78%) leaves the photograph a ghost — the tenant
   uploaded art and got a veil. A local band behind the header instead keeps the
   body of the image bright while the text sits on something dark, which is what
   the overlay strength alone could not do.

   Painted as the header's own background with a bleeding negative margin, NOT as
   a child at z-index:-1: .sc-head is positioned but creates no stacking context,
   so a negative z-index child would drop behind the card's own background. */
.sc-hero:not([data-bg="none"])[data-header="top-left"] .sc-head,
.sc-hero:not([data-bg="none"])[data-header="top-center"] .sc-head{
  margin:-18px -18px 0; padding:18px 18px 12px;
  background:linear-gradient(180deg,rgba(0,0,0,.46),rgba(0,0,0,.20) 64%,transparent);
}
.sc-hero:not([data-bg="none"])[data-header="bottom"] .sc-head{
  margin:14px -18px -18px; padding:12px 18px 18px;
  /* The photo already has the card-wide treatment. Keeping another gradient
     on this negative-margin block creates a visible seam when the header is
     moved below the stamp grid. */
  background:none;
}

/* ============================ LAYOUT PLACEMENT =============================
   Every option moves content between NAMED positions, never to free pixels, so
   each is one CSS rule tested once at 320px and in RTL instead of a coordinate
   space that has to be re-validated per tenant. */

/* -- header -- */
.sc-hero[data-header="top-center"] .sc-head{ flex-direction:column; align-items:center; text-align:center; gap:9px; }
.sc-hero[data-header="top-center"] .sc-identity{ flex-direction:column; align-items:center; }
.sc-hero[data-header="top-center"] .sc-identity small{ max-width:none; }
.sc-hero[data-header="top-center"] .sc-identity h2{ max-width:none; }
.sc-hero[data-header="top-center"] .sc-description{ max-width:100%; text-align:center; }

/* The header moves below the stamps. This is what TOP_BLEED imagery is for:
   photo at the top, identity at the foot. */
.sc-hero[data-header="bottom"] .sc-head{ order:4; margin-top:14px; }
.sc-hero[data-header="bottom"] .sc-description{ order:5; }
.sc-hero[data-header="bottom"] .sc-grid{ margin-top:2px; }

/* Sat on the artwork, with its own legibility gradient — without one, an
   overlaid header is the single easiest way to make a card unreadable. */
.sc-hero[data-header="overlay"] .sc-head{
  order:4; position:relative; z-index:1;
  margin:14px -18px -18px; padding:34px 18px 18px;
  /* Avoid stacking a second photo band over the card-wide treatment. The
     continuous scrim keeps the artwork free of a hard horizontal artifact. */
  background:none;
}
.sc-hero[data-header="overlay"] .sc-description{ order:5; margin:-6px -18px -18px; padding:0 18px 16px; background:none; max-width:none; }

/* -- logo -- */
.sc-hero[data-logo="none"] .sc-logo{ display:none; }
/* Lifted into the trailing top corner. Absolute rather than reordered markup;
   the header reserves room so the type pill cannot slide underneath it. */
.sc-hero[data-logo="corner"] .sc-logo{ position:absolute; inset-block:18px auto; inset-inline:auto 18px; }
.sc-hero[data-logo="corner"] .sc-head{ padding-inline-end:54px; }
/* Large, faint, behind everything but above the artwork. Deliberately bleeds off
   the trailing edge and is clipped by the hero's overflow.
   Logical inset, not physical: with `inset: auto -6% -14% auto` the watermark
   stayed pinned to the right in RTL instead of mirroring with the layout. */
.sc-hero[data-logo="watermark"] .sc-logo{
  position:absolute;
  inset-block:auto -14%; inset-inline:auto -6%;
  width:58%; height:auto; aspect-ratio:1;
  border:0; border-radius:0; background:none; box-shadow:none;
  opacity:.12; pointer-events:none;
}
.sc-hero[data-logo="watermark"] .sc-logo img{ object-fit:contain; }
.sc-hero[data-logo="watermark"] .sc-logo svg{ width:100%; height:100%; }

.sc-hero[data-logoshape="circle"] .sc-logo{ border-radius:50%; }
.sc-hero[data-logoshape="bare"] .sc-logo{ border:0; border-radius:0; background:none; box-shadow:none; }
.sc-hero[data-logoshape="bare"] .sc-logo img{ object-fit:contain; }

/* -- stamp block -- */
/* margin-top:auto pushes the grid to the foot of the flex column; CENTER lets
   the free space fall either side of it. Neither shrinks the grid, so the stamps
   stay the card's dominant element at every placement. */
.sc-hero[data-stamps="card-bottom"] .sc-grid{ margin-top:auto; }
.sc-hero[data-stamps="center"] .sc-grid{ margin-block:auto; }

/* -- ratio -- */
/* WALLET is clamped by the renderer above 12 stamps (see effectiveRatio), so
   this rule is never asked to squeeze slots below a tappable size. */
.sc-hero[data-ratio="wallet"]{ aspect-ratio:1.586; }
.sc-hero[data-ratio="wallet"] .sc-grid{ margin-block:12px; }

/* ============================== DARK THEME =================================
   The hero paints on the tenant colour in both themes; only the filled slot's
   ink needs re-stating, because --brand-ink is re-pointed by the dark palette. */
:root[data-theme="dark"] .sc-slot.full,
.cust-app[data-theme="dark"] .sc-slot.full,
[data-theme="dark"] .sc-slot.full{ color:#1C1A17; }

/* ============================ NARROW SCREENS =============================== */
@media (max-width:360px){
  .sc-hero{ padding:15px; }
  .sc-grid{ gap:8px; }
  .sc-identity h2{ font-size:19px; }
}

/* ==========================================================================
   STAMP PRESENTATION (phase 3)

   The stamps are the point of a stamp card, so nothing here shrinks or demotes
   the grid — these options change how the slots READ. Attributes live on
   .sc-grid rather than the hero, because that is the element they describe.

   Layouts split two ways. GRID, ROW and LADDER are CSS flow. ARC, PATH and RING
   are absolutely positioned from per-slot --sc-x / --sc-y / --sc-rot computed by
   pure functions in card-art.js — JS rather than CSS trig so each arrangement
   can be asserted in Node (no overlap, inside the box, above the tap-target
   floor) instead of only being observable in a browser.
   ========================================================================== */

.sc-grid{ gap:var(--stamp-gap,10px); }
/* Identity at the default, so the historical card is untouched; the composition
   exists so scale, the sticker tilt and the absolute layouts' rotation can all
   apply at once without fighting over the transform property. */
.sc-slot{ transform:rotate(var(--sc-tilt,0deg)) scale(var(--stamp-scale,1)); }

/* ------------------------------- layouts -------------------------------- */
.sc-grid[data-layout="row"]{ grid-template-columns:repeat(var(--stamp-count,5),minmax(0,1fr)); }

/* A column of wide bars. Slots stop being square here, so aspect-ratio is
   released and a min-height keeps them tappable. */
.sc-grid[data-layout="ladder"]{ grid-template-columns:1fr; }
.sc-grid[data-layout="ladder"] .sc-slot{ aspect-ratio:auto; min-height:36px; border-radius:999px; }
.sc-grid[data-layout="ladder"] .sc-slot svg{ width:20px; height:20px; }
.sc-grid[data-layout="ladder"] .sc-slot img{ width:auto; height:70%; }

.sc-grid[data-layout="arc"],
.sc-grid[data-layout="path"],
.sc-grid[data-layout="ring"]{
  display:block;
  grid-template-columns:none;
  /* The aspect comes back with the geometry, so the block reserves exactly the
     height its arrangement needs and nothing overlaps what follows.

     Percentage padding, NOT aspect-ratio. The grid is a stretched item of the
     hero's flex column, and an aspect-ratio on such an item leaves the engine a
     width/height cycle to break: stretch the width and transfer it to the
     height, or take a height and transfer it back to the width. Blink takes the
     first road; in the webview deck, where the hero's height is definite
     (`.rc-deck-card .sc-hero{height:100%}`), Gecko measurably did not — a RING
     came out about 13% wider than the column containing it, overflowed to the
     trailing side, and carried the ring's centre ~14px off the card's with it.
     Percentage padding always resolves against a width, so there is no cycle
     left for an engine to break and every one of them reserves the same box.

     The slots' `top` percentages resolve against this padding box, which is why
     the height must live in the padding rather than in a zero content box plus
     a margin. And the side margins go, because percentage padding resolves
     against the CONTAINING BLOCK's width while the slots' `left` resolves
     against the grid's own: leave the 2px on and the two disagree, which is a
     ring 1.4% out of round. The flow layouts keep the margin; the absolute ones
     hold their slots well inside the box already. */
  height:0;
  margin-inline:0;
  padding-bottom:calc(100% / var(--stamp-aspect,2));
}
.sc-grid[data-layout="arc"] .sc-slot,
.sc-grid[data-layout="path"] .sc-slot,
.sc-grid[data-layout="ring"] .sc-slot{
  position:absolute;
  left:var(--sc-x,50%); top:var(--sc-y,50%);
  width:var(--stamp-size,18%); height:auto;
  transform:translate(-50%,-50%)
            rotate(calc(var(--sc-rot,0deg) + var(--sc-tilt,0deg)))
            scale(var(--stamp-scale,1));
}

/* -------------------------------- track --------------------------------- */
/* The connector that turns positions into a route. Drawn as a polyline through
   the computed slot centres, under the slots. */
.sc-track{
  position:absolute; inset:0; width:100%; height:100%;
  pointer-events:none; overflow:visible;
}
.sc-track polyline,
.sc-track polygon{
  fill:none;
  stroke:rgba(255,255,255,.40);
  stroke-width:2;
  stroke-linecap:round;
  stroke-linejoin:round;
  /* The viewBox is stretched (preserveAspectRatio:none), which would smear the
     stroke non-uniformly without this. */
  vector-effect:non-scaling-stroke;
}
.sc-grid[data-track="dotted"] .sc-track polyline,
.sc-grid[data-track="dotted"] .sc-track polygon{ stroke-dasharray:0.1 6; }
.sc-grid[data-track="dashed"] .sc-track polyline,
.sc-grid[data-track="dashed"] .sc-track polygon{ stroke-dasharray:7 6; }
/* Declared rather than left to the base rule: an unbroken stroke is a deliberate
   choice, and stating it means a dasharray leaking in from elsewhere cannot
   silently redefine what "solid" looks like. */
.sc-grid[data-track="solid"] .sc-track polyline,
.sc-grid[data-track="solid"] .sc-track polygon{ stroke-dasharray:none; }

/* ROW draws its own line rather than an SVG: a straight run needs no geometry. */
.sc-grid[data-layout="row"]:not([data-track="none"])::before{
  content:""; position:absolute; z-index:0;
  inset:50% 3% auto; height:2px; margin-top:-1px;
  background:rgba(255,255,255,.40);
}
.sc-grid[data-layout="row"][data-track="dotted"]::before{
  background:repeating-linear-gradient(90deg,rgba(255,255,255,.40) 0 2px,transparent 2px 8px);
}
.sc-grid[data-layout="row"][data-track="dashed"]::before{
  background:repeating-linear-gradient(90deg,rgba(255,255,255,.40) 0 8px,transparent 8px 14px);
}
.sc-grid[data-layout="row"] .sc-slot{ z-index:1; }

/* -------------------------------- shapes -------------------------------- */
.sc-grid[data-shape="squircle"] .sc-slot{ border-radius:30%; }
/* clip-path clips borders and shadows, so the clipped shapes carry their outline
   as an inset shadow instead and empties get a translucent fill to stay visible. */
.sc-grid[data-shape="hex"] .sc-slot,
.sc-grid[data-shape="ticket"] .sc-slot,
.sc-grid[data-shape="star"] .sc-slot,
.sc-grid[data-shape="shield"] .sc-slot{ border:0; border-radius:0; }
.sc-grid[data-shape="hex"] .sc-slot.empty,
.sc-grid[data-shape="ticket"] .sc-slot.empty,
.sc-grid[data-shape="star"] .sc-slot.empty,
.sc-grid[data-shape="shield"] .sc-slot.empty{}
.sc-grid[data-shape="hex"] .sc-slot:not(.has-icon){
  clip-path:polygon(50% 0%,100% 25%,100% 75%,50% 100%,0% 75%,0% 25%);
}
.sc-grid[data-shape="ticket"] .sc-slot:not(.has-icon){
  clip-path:polygon(0% 0%,100% 0%,100% 38%,88% 50%,100% 62%,100% 100%,0% 100%,0% 62%,12% 50%,0% 38%);
}
.sc-grid[data-shape="star"] .sc-slot:not(.has-icon){
  clip-path:polygon(50% 0%,63% 34%,99% 35%,71% 57%,80% 92%,50% 71%,20% 92%,29% 57%,1% 35%,37% 34%);
}
.sc-grid[data-shape="shield"] .sc-slot:not(.has-icon){
  clip-path:polygon(50% 0%,100% 16%,100% 58%,50% 100%,0% 58%,0% 16%);
}
/* A star's points leave little room for a glyph. */
.sc-grid[data-shape="star"] .sc-slot svg{ width:34%; height:34%; }
.sc-grid[data-shape="star"] .sc-slot .num{ font-size:9.5px; }

/* ---- fills on clipped shapes ----
   clip-path erases anything outside the shape, and an inset box-shadow lives on
   the inside edge of the BOX, not the shape — so OUTLINE's ring became four thin
   slivers on a hexagon and PUNCH lost its depth entirely. Backgrounds survive
   clipping, shadows do not, so the clipped shapes express both with gradients.
   Declared before the generic fill rules so the circle and squircle keep the
   sharper shadow-based treatment. */
.sc-grid[data-shape="hex"][data-fill="outline"] .sc-slot.full:not(.has-icon),
.sc-grid[data-shape="ticket"][data-fill="outline"] .sc-slot.full:not(.has-icon),
.sc-grid[data-shape="star"][data-fill="outline"] .sc-slot.full:not(.has-icon),
.sc-grid[data-shape="shield"][data-fill="outline"] .sc-slot.full:not(.has-icon){
  background:rgba(255,255,255,.30); box-shadow:none; color:#fff;
}
.sc-grid[data-shape="hex"][data-fill="punch"] .sc-slot.full:not(.has-icon),
.sc-grid[data-shape="ticket"][data-fill="punch"] .sc-slot.full:not(.has-icon),
.sc-grid[data-shape="star"][data-fill="punch"] .sc-slot.full:not(.has-icon),
.sc-grid[data-shape="shield"][data-fill="punch"] .sc-slot.full:not(.has-icon){
  /* Two layers: a lit rim along the top edge, then the hole itself. The rim is
     what reads as "punched" rather than "smudged" — on a circle that job is done
     by an inset shadow, which a clipped shape would erase. */
  background:
    linear-gradient(180deg,rgba(255,255,255,.30) 0 7%,transparent 7%),
    radial-gradient(120% 130% at 50% -10%,rgba(0,0,0,.62),rgba(0,0,0,.26));
  box-shadow:none;
}

/* --------------------------------- fills -------------------------------- */
.sc-grid[data-fill="glass"] .sc-slot.full:not(.has-icon){
  background:rgba(255,255,255,.26); color:#fff;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.44), 0 2px 8px rgba(0,0,0,.18);
  backdrop-filter:blur(3px);
}
.sc-grid[data-fill="outline"] .sc-slot.full:not(.has-icon){
  background:none; color:#fff;
  box-shadow:inset 0 0 0 2px rgba(255,255,255,.88);
}
.sc-grid[data-fill="sticker"] .sc-slot.full:not(.has-icon){
  box-shadow:0 4px 11px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.9);
}
/* Alternating tilt, so a run of stickers looks applied by hand rather than
   printed. nth-of-type, not nth-child: the track <svg> would shift the parity. */
.sc-grid[data-fill="sticker"] .sc-slot:nth-of-type(odd){ --sc-tilt:-4deg; }
.sc-grid[data-fill="sticker"] .sc-slot:nth-of-type(even){ --sc-tilt:4deg; }

/* A punch is material REMOVED, not ink added — so the collected slot is a hole,
   and a hole carries no tick. */
.sc-grid[data-fill="punch"] .sc-slot.full:not(.has-icon){
  background:rgba(0,0,0,.34); color:transparent;
  box-shadow:inset 0 2px 6px rgba(0,0,0,.55), inset 0 -1px 0 rgba(255,255,255,.16);
}
.sc-grid[data-fill="punch"] .sc-slot.full:not(.has-icon) svg{ display:none; }

/* ---------------------------- reward emphasis --------------------------- */
/* The historical treatment is the glow, which lives on .glow and is applied only
   when the reward is actually earned or ready. The others are decoration on the
   final slot and show regardless, so they read as "this one is the prize". */
.sc-grid[data-reward="none"] .sc-slot.reward.glow{ box-shadow:none; filter:none; animation:none; }

.sc-grid[data-reward="burst"] .sc-slot.reward::before,
.sc-grid[data-reward="crown"] .sc-slot.reward::before,
.sc-grid[data-reward="ribbon"] .sc-slot.reward::before{
  content:""; position:absolute; pointer-events:none;
}
.sc-grid[data-reward="burst"] .sc-slot.reward::before{
  inset:-26%; border-radius:50%; z-index:-1;
  background:repeating-conic-gradient(from 0deg,
    color-mix(in srgb,var(--_gold-hi) 62%,transparent) 0deg 7deg, transparent 7deg 20deg);
  opacity:.5;
}
/* z-index:-1 is safe here: .sc-slot is position:relative AND transformed, so it
   creates a stacking context and the ray layer cannot escape behind the card. */
.sc-grid[data-reward="crown"] .sc-slot.reward::before{
  inset:auto 50% 96% auto; width:44%; aspect-ratio:1.6;
  transform:translateX(50%);
  background:var(--_gold-hi);
  clip-path:polygon(0% 100%,0% 22%,25% 55%,50% 0%,75% 55%,100% 22%,100% 100%);
}
.sc-grid[data-reward="ribbon"] .sc-slot.reward::before{
  inset:auto -14% -6% -14%; height:26%;
  background:var(--_gold-hi);
  clip-path:polygon(0% 0%,100% 0%,88% 100%,12% 100%);
  border-radius:2px;
}

/* ==========================================================================
   STICKERS AND MOTION (phase 4)
   ========================================================================== */

/* ---- the layer scale ----
   Explicit, because this is where "the stamps are always on top" is ENFORCED.
   Sticker placement is genuinely free — drag anywhere, any rotation, any scale —
   but there is no anchor above the grid, so a tenant cannot bury their own
   stamps and nobody has to catch it in review.

   Stated as numbers rather than left to DOM order so the guarantee survives any
   future reordering of the markup. */
.sc-bg{ z-index:0; }
.sc-stickers[data-anchor="back"]{ z-index:1; }
.sc-pattern{ z-index:2; }
.sc-sheen{ z-index:3; }
/* Ambient motion rides directly above the static sheen — see .sc-motion below. */
.sc-motion{ z-index:4; }
.sc-head, .sc-hero .sc-description{ z-index:5; }
.sc-stickers[data-anchor="front"]{ z-index:6; }
.sc-grid{ z-index:7; }

.sc-stickers{
  position:absolute; inset:0;
  display:block;
  /* Never eats a tap meant for the card. */
  pointer-events:none;
}
.sc-sticker{
  position:absolute;
  left:var(--st-x,50%); top:var(--st-y,50%);
  width:22%; aspect-ratio:1;
  display:flex; align-items:center; justify-content:center;
  opacity:var(--st-o,1);
  transform:translate(-50%,-50%) rotate(var(--st-r,0deg)) scale(var(--st-s,1));
  transform-origin:50% 50%;
}
.sc-sticker img{ width:100%; height:100%; object-fit:contain; }
.sc-sticker-glyph{ font-size:min(9vw,44px); line-height:1; }
.sc-sticker[data-blend="screen"]{ mix-blend-mode:screen; }
.sc-sticker[data-blend="multiply"]{ mix-blend-mode:multiply; }
.sc-sticker[data-blend="overlay"]{ mix-blend-mode:overlay; }
.sc-sticker[data-blend="soft-light"]{ mix-blend-mode:soft-light; }

/* ============================== MOTION ====================================
   Every ambient option is a CSS animation on a composited property. There is no
   JS animation loop — the only script involved is TILT's throttled sensor read,
   which writes two custom properties and nothing else.

   --motion-intensity (0..1) scales the amplitude, so "subtle" and "obvious" are
   the same animation at different strengths rather than separate keyframes. */
@keyframes scSheenSweep{ 0%{ transform:translateX(-60%); } 100%{ transform:translateX(60%); } }
@keyframes scFloat{ 0%,100%{ transform:translateY(0); } 50%{ transform:translateY(calc(-4px * var(--motion-intensity,.6))); } }
@keyframes scShimmer{ 0%{ background-position:-150% 0; } 100%{ background-position:250% 0; } }
/* The blobs are large and soft, so a small position delta changes almost no
   pixels — the original 12%->30% drift measured under 7/255 of movement, i.e.
   nothing a customer could see. Wider travel over a larger painting area is
   what makes it read as drift. */
@keyframes scAurora{
  0%,100%{ background-position:4% 2%, 98% 22%, 62% 118%, 0 0; }
  50%{ background-position:52% 38%, 58% 0%, 20% 84%, 0 0; }
}
@keyframes scStampPress{
  0%{ transform:scale(1.32); }
  55%{ transform:scale(.92); }
  100%{ transform:scale(1); }
}
@keyframes scRipple{
  0%{ box-shadow:0 0 0 0 rgba(255,255,255,.55); }
  100%{ box-shadow:0 0 0 18px rgba(255,255,255,0); }
}
@keyframes scSparkle{
  0%{ opacity:0; transform:translate(-50%,-50%) scale(.3) rotate(0deg); }
  35%{ opacity:1; }
  100%{ opacity:0; transform:translate(-50%,-50%) scale(1.9) rotate(120deg); }
}

/* The travelling-highlight layer. It exists SEPARATELY from .sc-sheen because
   the two are different choices: the sheen is a static finish, and the tenant
   can set it to None or Holographic. While sheen-sweep and shimmer animated
   .sc-sheen, `Sheen: None` (display:none) made them paint nothing at all, and
   Holographic (overlay blend at .55 opacity) swamped them — so three of the six
   idle options silently did nothing, since AURORA falls back to SHIMMER off
   MESH. Painting on an always-present layer of its own makes idle movement
   independent of the finish. Inert unless an ambient option asks for it. */
.sc-motion{ position:absolute; inset:0; pointer-events:none; display:none; }

@media (prefers-reduced-motion:no-preference){
  .sc-hero[data-motion="sheen-sweep"] .sc-motion{
    display:block;
    /* A band rather than the sheen's corner glow: a sweep has to read as
       something crossing the card, and translating a corner radial barely
       changed a pixel. */
    background:linear-gradient(105deg,transparent 36%,
      rgba(255,255,255,calc(.30 * var(--motion-intensity,.6))) 50%,transparent 64%);
    animation:scSheenSweep 7s var(--_ease) infinite alternate;
  }
  .sc-hero[data-motion="float"]{
    animation:scFloat 5.5s ease-in-out infinite;
  }
  .sc-hero[data-motion="shimmer"] .sc-motion{
    display:block;
    background:linear-gradient(112deg,transparent 34%,
      rgba(255,255,255,calc(.34 * var(--motion-intensity,.6))) 47%,transparent 60%);
    background-size:260% 100%;
    animation:scShimmer 4.2s linear infinite;
  }
  /* AURORA drifts the mesh gradient's own blobs, which is why it needs MESH; the
     renderer substitutes SHIMMER when the gradient cannot supply them. */
  .sc-hero[data-motion="aurora"][data-grad="mesh"]{
    background-size:230% 230%, 230% 230%, 230% 230%, 100% 100%;
    animation:scAurora 14s ease-in-out infinite;
  }
  /* The drift is a keyframe on background-position, so the gyro cannot share
     that property — it gets the LIGHT instead. Two soft blooms on the motion
     layer slide against each other as the phone turns, over blobs that are
     already moving. Oversized inset so the travel never exposes an edge, and
     the travel is a transform, which is the one thing that can be interpolated
     between two throttled sensor reads. */
  .sc-hero[data-motion="aurora"] .sc-motion{
    display:block; inset:-24%;
    background:
      radial-gradient(40% 38% at 32% 30%,
        rgba(255,255,255,calc(.22 * var(--motion-intensity,.6))),transparent 68%),
      radial-gradient(46% 42% at 72% 68%,
        rgba(150,222,255,calc(.20 * var(--motion-intensity,.6))),transparent 70%);
    transform:translate(calc(var(--tilt-x,0) * 9% * var(--motion-intensity,.6)),
                        calc(var(--tilt-y,0) * 9% * var(--motion-intensity,.6)));
    transition:transform 140ms ease-out;
    will-change:transform;
  }
  /* TILT is driven by --tilt-x / --tilt-y, written by the webview's sensor read.
     With no sensor the variables stay 0 and the card simply does not move. */
  .sc-hero[data-motion="tilt"]{
    transform:perspective(900px) rotateX(calc(var(--tilt-y,0) * -2.8deg * var(--motion-intensity,.6)))
      rotateY(calc(var(--tilt-x,0) * 2.8deg * var(--motion-intensity,.6)));
    will-change:transform;
  }
  .sc-hero[data-motion="tilt"] .sc-bg{
    transform:translate(calc(var(--tilt-x,0) * 6px * var(--motion-intensity,.6)),
                        calc(var(--tilt-y,0) * 6px * var(--motion-intensity,.6)));
  }
  .sc-hero[data-motion="tilt"] .sc-stickers{
    transform:translate(calc(var(--tilt-x,0) * -10px * var(--motion-intensity,.6)),
                        calc(var(--tilt-y,0) * -10px * var(--motion-intensity,.6)));
  }

  /* ---- earn ----
     ONE definition per style, used at both moments a filled stamp appears:

       · the card's entrance, under [data-anim="1"] — every filled slot, walked
         by --sc-i;
       · a real earn, under [data-earn-play] — only the slots RCCardArt.playEarn
         marked .sc-earned, walked by the --sc-stagger it writes inline on them,
         so a stamp that was already on the card does not re-celebrate itself.

     Both come from the tenant's single `motionEarn` choice in the Card Studio.
     They were previously separate ideas, and the result was that three of the
     four options were dead: POP was hard-coded as the entrance regardless of the
     choice, and STAMP_PRESS / RIPPLE / SPARKLE_BURST rendered nothing anywhere,
     because nothing in the app ever set [data-earn-play]. */
  [data-anim="1"] .sc-hero[data-earn="pop"] .sc-slot.full,
  .sc-hero[data-earn="pop"][data-earn-play] .sc-slot.full.sc-earned{
    animation:scStampIn .5s var(--_ease) both;
    animation-delay:calc(var(--sc-stagger,var(--sc-i,0))*45ms);
  }
  [data-anim="1"] .sc-hero[data-earn="stamp-press"] .sc-slot.full,
  .sc-hero[data-earn="stamp-press"][data-earn-play] .sc-slot.full.sc-earned{
    animation:scStampPress .46s var(--_ease) both;
    animation-delay:calc(var(--sc-stagger,var(--sc-i,0))*45ms);
  }
  [data-anim="1"] .sc-hero[data-earn="ripple"] .sc-slot.full,
  .sc-hero[data-earn="ripple"][data-earn-play] .sc-slot.full.sc-earned{
    animation:scRipple .7s ease-out both;
    animation-delay:calc(var(--sc-stagger,var(--sc-i,0))*45ms);
  }
  /* Sparkle bursts OVER a stamp that also lands, or the flare fires above a slot
     that simply blinked into existence. */
  [data-anim="1"] .sc-hero[data-earn="sparkle-burst"] .sc-slot.full,
  .sc-hero[data-earn="sparkle-burst"][data-earn-play] .sc-slot.full.sc-earned{
    animation:scStampIn .5s var(--_ease) both;
    animation-delay:calc(var(--sc-stagger,var(--sc-i,0))*45ms);
  }
  [data-anim="1"] .sc-hero[data-earn="sparkle-burst"] .sc-slot.full::after,
  .sc-hero[data-earn="sparkle-burst"][data-earn-play] .sc-slot.full.sc-earned::after{
    content:""; position:absolute; inset:50% auto auto 50%;
    width:170%; aspect-ratio:1;
    background:conic-gradient(from 0deg,transparent 0 12deg,rgba(255,255,255,.9) 12deg 14deg,
      transparent 14deg 30deg);
    animation:scSparkle .72s ease-out both;
    animation-delay:calc(var(--sc-stagger,var(--sc-i,0))*45ms);
    pointer-events:none;
  }
  /* NONE must actually mean none, including the entrance. */
  .sc-hero[data-earn="none"] .sc-slot.full{ animation:none; }
}

/* Idle motion stops when nobody is looking at it: a phone must not burn battery
   animating four cards, only one of which is on screen. .is-idle is set by the
   deck on its non-active slots and on visibilitychange. */
.sc-hero.is-idle,
.sc-hero.is-idle .sc-sheen,
.sc-hero.is-idle .sc-motion,
.sc-hero.is-idle .sc-bg,
.sc-hero.is-idle .sc-stickers{ animation-play-state:paused; }

/* One media query, not a decision per option. */
@media (prefers-reduced-motion:reduce){
  .sc-hero, .sc-hero *{ animation:none!important; transition:none!important; }
  .sc-hero[data-motion="tilt"] .sc-bg,
  .sc-hero[data-motion="tilt"] .sc-stickers,
  .sc-hero[data-motion="aurora"] .sc-motion{ transform:none; }
  .sc-hero[data-sheen="holo"] .sc-sheen{ background-position:50% 50%; }
}
