/* src/components/campaigns/surfaces/surfaces.css */
/*
 * Campaign surfaces — the seven formats, as rendered for both the builder's
 * preview and (later) live delivery.
 *
 * Token-only, and scoped under `.tui-campaign` so these rules cannot leak into
 * a host page. The authored background and accent arrive as inline custom
 * properties (`--c-bg`, `--c-accent`) and the derived ink as `--c-fg`/`--c-fg-muted`
 * — see `ink.ts` for why ink is derived rather than authored.
 *
 * One deliberate omission: nothing here sets a font family. TUI's
 * `--tui-typography-font-family` is `inherit`, so a delivered campaign picks up
 * the host page's type and reads as part of the product rather than as an
 * advert pasted on top of it.
 */

/*
 * A hidden surface must actually hide.
 *
 * Every format sets its own `display` (topbar is flex, cards are grid, and so
 * on), and an author-stylesheet `display` beats the UA stylesheet's
 * `[hidden] { display: none }`. So a consumer that hides a surface with the
 * `hidden` attribute — the obvious, correct thing to do — gets nothing, and the
 * surface stays on screen.
 *
 * Found live: the WordPress plugin rotates campaigns in a slot by setting
 * `hidden` on the ones it is not showing. Two topbars both rendered, stacked,
 * on a real marketing site. The script was right and the stylesheet defeated
 * it; neither side is wrong alone, which is exactly why nobody's tests caught
 * it.
 *
 * `!important` because the per-format `display` rules are more specific than
 * an attribute selector on its own, and losing this race silently is the whole
 * bug.
 */
.tui-campaign[hidden] {
  display: none !important;
}

.tui-campaign {
  --c-bg: #eef1fe;
  --c-accent: #2942d1;
  --c-accent2: #8fa6ff;
  --c-fg: #0b1020;
  --c-fg-muted: rgba(11, 16, 32, 0.66);
  --c-on2: #0b1020;

  box-sizing: border-box;
  background: var(--c-bg);
  color: var(--c-fg);
  line-height: var(--tui-typography-line-height);
}

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

.tui-campaign__eyebrow {
  font-size: var(--tui-typography-size-xs);
  font-weight: var(--tui-typography-weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--c-fg-muted);
}

.tui-campaign__headline {
  margin: 0;
  font-size: var(--tui-typography-size-lg);
  font-weight: var(--tui-typography-weight-semibold);
  line-height: var(--tui-typography-line-height-tight);
  color: var(--c-fg);
}

.tui-campaign__body {
  margin: 0;
  color: var(--c-fg-muted);
  font-size: var(--tui-typography-size-sm);
}

.tui-campaign__footnote {
  margin: 0;
  color: var(--c-fg-muted);
  font-size: var(--tui-typography-size-xs);
}

/*
 * The CTA is the accent's only job.
 *
 * v1's design round surfaced this as a finding and Julia's v02 promoted it to
 * help text under the accent picker: a palette with two accents invites a
 * second coloured thing, and the second coloured thing is always what kills the
 * first one's pull. One accent, one button.
 */
.tui-campaign__cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 8px 16px;
  border: 0;
  border-radius: var(--tui-radius-md);
  background: var(--c-accent);
  color: #fff;
  font: inherit;
  font-size: var(--tui-typography-size-sm);
  font-weight: var(--tui-typography-weight-semibold);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
}

/* Dismiss. Rendered only where the slot class can honour it — bars and
 * overlays. An inline card has no X, because the page owns that space. */
.tui-campaign__dismiss {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  flex: 0 0 28px;
  border: 0;
  border-radius: var(--tui-radius-full);
  background: transparent;
  color: var(--c-fg-muted);
  font: inherit;
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
}

.tui-campaign__dismiss:hover {
  color: var(--c-fg);
}

/* ------------------------------------------------------------------ topbar */

.tui-campaign--topbar {
  display: flex;
  align-items: center;
  gap: var(--tui-spacing-md);
  width: 100%;
  padding: 10px var(--tui-spacing-lg);
}

.tui-campaign--topbar .tui-campaign__headline {
  font-size: var(--tui-typography-size-sm);
  font-weight: var(--tui-typography-weight-medium);
}

/* Arrangement, not restyling: the three layouts move the same four blocks. */
.tui-campaign--topbar[data-layout='centered'] {
  justify-content: center;
}

.tui-campaign--topbar[data-layout='centered'] .tui-campaign__dismiss,
.tui-campaign--topbar[data-layout='split'] .tui-campaign__dismiss {
  margin-inline-start: auto;
}

.tui-campaign--topbar[data-layout='split'] .tui-campaign__copy {
  margin-inline-end: auto;
}

.tui-campaign--topbar[data-layout='stacked'] {
  flex-direction: column;
  gap: var(--tui-spacing-xs);
  text-align: center;
  padding-block: var(--tui-spacing-md);
}

.tui-campaign--topbar[data-layout='stacked'] .tui-campaign__dismiss {
  position: absolute;
  inset-block-start: 6px;
  inset-inline-end: 6px;
}

.tui-campaign--topbar[data-layout='stacked'] {
  position: relative;
}

.tui-campaign__copy {
  display: flex;
  align-items: center;
  gap: var(--tui-spacing-sm);
  min-width: 0;
}

.tui-campaign--topbar[data-layout='stacked'] .tui-campaign__copy {
  flex-direction: column;
  gap: 2px;
}

/* ------------------------------------------------- banner / inline / modal */

.tui-campaign--banner,
.tui-campaign--inlinecard,
.tui-campaign--modal,
.tui-campaign--slidein,
.tui-campaign--sidebar {
  border-radius: var(--tui-radius-lg);
}

.tui-campaign--banner {
  display: flex;
  align-items: center;
  gap: var(--tui-spacing-lg);
  padding: var(--tui-spacing-lg);
}

.tui-campaign--banner .tui-campaign__stack {
  flex: 1 1 auto;
  min-width: 0;
}

.tui-campaign--inlinecard,
.tui-campaign--modal,
.tui-campaign--sidebar,
.tui-campaign--slidein {
  padding: var(--tui-spacing-lg);
}

/* ── Design widths ──────────────────────────────────────────────────────
 *
 * A surface carries its own width. `CAMPAIGN_FORMATS` has held these numbers
 * since the format table was written, but only `PreviewStage` ever read them —
 * for the builder's caption. Live delivery got the right width by accident: in
 * the app a sidebar card sits in a narrow column, so it looked correct without
 * anyone ever saying how wide it was.
 *
 * Dropped into a marketing site's content column, the same card stretched to
 * 1260px and a narrow ticket became a letterbox. Same markup, same stylesheet,
 * different neighbour.
 *
 * A width that depends on the host is not a design. `max-width` rather than
 * `width` so a surface still shrinks on a phone, and `data-size` carries the
 * tier for the formats that have several — the consumer never learns sizes
 * exist. `designWidths.test.ts` pins these to the format table, since the
 * numbers now live in a TS object and a stylesheet at once.
 *
 * `topbar` and `mat` are deliberately absent: their 1440 is a design reference
 * for the preview, not a cap. A topbar that stopped at 1440 on a wide monitor
 * would be a bug rather than a fix.
 */
.tui-campaign--sidebar {
  max-width: 224px;
}

.tui-campaign--slidein {
  max-width: 360px;
}

.tui-campaign--banner[data-size='lg'],
.tui-campaign--inlinecard[data-size='lg'] {
  max-width: 1080px;
}

.tui-campaign--banner[data-size='md'],
.tui-campaign--inlinecard[data-size='md'] {
  max-width: 712px;
}

.tui-campaign--banner[data-size='sm'],
.tui-campaign--inlinecard[data-size='sm'] {
  max-width: 344px;
}

.tui-campaign--modal[data-size='lg'] {
  max-width: 720px;
}

.tui-campaign--modal[data-size='md'] {
  max-width: 560px;
}

.tui-campaign--modal[data-size='sm'] {
  max-width: 400px;
}

.tui-campaign__stack {
  display: flex;
  flex-direction: column;
  gap: var(--tui-spacing-sm);
  align-items: flex-start;
  min-width: 0;
}

.tui-campaign--sidebar .tui-campaign__headline {
  font-size: var(--tui-typography-size-md);
}

.tui-campaign--sidebar .tui-campaign__cta,
.tui-campaign--slidein .tui-campaign__cta {
  width: 100%;
}

.tui-campaign--modal {
  box-shadow: var(--tui-shadow-lg);
}

.tui-campaign--modal .tui-campaign__headline {
  font-size: var(--tui-typography-size-xl);
}

/* Overlays carry their dismiss in the corner, over the copy. */
.tui-campaign--modal,
.tui-campaign--slidein,
.tui-campaign--mat {
  position: relative;
}

.tui-campaign--modal > .tui-campaign__dismiss,
.tui-campaign--slidein > .tui-campaign__dismiss,
.tui-campaign--mat > .tui-campaign__dismiss {
  position: absolute;
  inset-block-start: var(--tui-spacing-sm);
  inset-inline-end: var(--tui-spacing-sm);
}

.tui-campaign--slidein {
  box-shadow: var(--tui-shadow-lg);
}

/* --------------------------------------------------------------------- mat */

.tui-campaign--mat {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--tui-spacing-md);
  width: 100%;
  min-height: 520px;
  padding: var(--tui-spacing-xxl) var(--tui-spacing-lg);
  text-align: center;
}

.tui-campaign--mat .tui-campaign__headline {
  font-size: var(--tui-typography-size-xxl);
  max-width: 28ch;
}

.tui-campaign--mat .tui-campaign__stack {
  align-items: center;
  text-align: center;
}

.tui-campaign--mat .tui-campaign__body {
  max-width: 52ch;
}

/* ------------------------------------------------------------- ornaments */

/*
 * The v3 decoration kit: up to two placed tokens plus the countdown, each
 * {kind, text, anchor, size}. Every fill comes from --c-accent or --c-accent2
 * and every ink is derived (--c-on2 from accent 2, --c-fg from the background),
 * so the whole kit re-colours with the theme and no kind can be set to two
 * clashing values. Ported from design-preview/campaign/ornaments.html — keep
 * the two in step when a recipe changes.
 */

.tui-orn {
  flex: none;
}

/* label — the undecorated one. The v2 eyebrowStyle:'text', renamed. */
.tui-orn--label {
  text-transform: uppercase;
  letter-spacing: 0.11em;
  font-weight: var(--tui-typography-weight-semibold);
  color: var(--c-fg-muted);
  white-space: nowrap;
}
.tui-orn--label[data-size='sm'] {
  font-size: 0.62rem;
}
.tui-orn--label[data-size='md'] {
  font-size: 0.7rem;
}
.tui-orn--label[data-size='lg'] {
  font-size: 0.8rem;
}

/* chip — the disciplined one; use for category. */
.tui-orn--chip {
  display: inline-flex;
  align-items: center;
  border-radius: var(--tui-radius-full);
  background: var(--c-accent2);
  color: var(--c-on2);
  font-weight: var(--tui-typography-weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  white-space: nowrap;
}
.tui-orn--chip[data-size='sm'] {
  padding: 2px 7px;
  font-size: 0.6rem;
}
.tui-orn--chip[data-size='md'] {
  padding: 3px 9px;
  font-size: 0.66rem;
}
.tui-orn--chip[data-size='lg'] {
  padding: 4px 11px;
  font-size: 0.74rem;
}

/* sticker — filled + hard offset shadow. The loud one. */
.tui-orn--sticker {
  display: inline-block;
  background: var(--c-accent2);
  color: var(--c-on2);
  font-weight: 800;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  box-shadow: 1px 2px 0 rgb(0 0 0 / 0.35);
  white-space: nowrap;
}
.tui-orn--sticker[data-size='sm'] {
  padding: 0.16rem 0.42rem;
  font-size: 0.6rem;
}
.tui-orn--sticker[data-size='md'] {
  padding: 0.24rem 0.55rem;
  font-size: 0.7rem;
}
.tui-orn--sticker[data-size='lg'] {
  padding: 0.34rem 0.7rem;
  font-size: 0.84rem;
}

/*
 * stamp — outline in the DERIVED ink, not a fill: it reads on a dark surface
 * where a filled accent sticker screams. This is the one that solves "sticker
 * on Black Friday looks like a warning".
 */
.tui-orn--stamp {
  display: inline-block;
  border: 1.5px solid currentColor;
  outline: 1.5px solid currentColor;
  outline-offset: 2px;
  border-radius: 2px;
  color: var(--c-fg);
  font-weight: 800;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  opacity: 0.9;
}
.tui-orn--stamp[data-size='sm'] {
  padding: 0.12rem 0.34rem;
  font-size: 0.58rem;
}
.tui-orn--stamp[data-size='md'] {
  padding: 0.2rem 0.5rem;
  font-size: 0.66rem;
}
.tui-orn--stamp[data-size='lg'] {
  padding: 0.3rem 0.66rem;
  font-size: 0.78rem;
}

/*
 * pricetag — the swing tag: notched leading edge + punched hole. Commerce
 * semantics without the shouting; the default clothing for the countdown.
 * The left padding must clear BOTH the clip-path notch (11px) and the punched
 * hole, or the first glyph lands inside the tip.
 */
.tui-orn--pricetag {
  position: relative;
  display: inline-flex;
  align-items: center;
  background: var(--c-accent2);
  color: var(--c-on2);
  font-weight: 800;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  clip-path: polygon(0 50%, 11px 0, 100% 0, 100% 100%, 11px 100%);
}
.tui-orn--pricetag::before {
  content: '';
  position: absolute;
  top: 50%;
  width: 5px;
  height: 5px;
  margin-top: -2.5px;
  border-radius: var(--tui-radius-full);
  background: rgb(0 0 0 / 0.42);
}
.tui-orn--pricetag[data-size='sm'] {
  padding: 0.16rem 0.5rem 0.16rem 1.35rem;
  font-size: 0.6rem;
}
.tui-orn--pricetag[data-size='sm']::before {
  left: 14px;
}
.tui-orn--pricetag[data-size='md'] {
  padding: 0.26rem 0.64rem 0.26rem 1.55rem;
  font-size: 0.72rem;
}
.tui-orn--pricetag[data-size='md']::before {
  left: 16px;
}
.tui-orn--pricetag[data-size='lg'] {
  padding: 0.36rem 0.82rem 0.36rem 1.8rem;
  font-size: 0.86rem;
}
.tui-orn--pricetag[data-size='lg']::before {
  left: 18px;
}

/*
 * spinwheel — an actual wheel, not a disc.
 *
 * Twelve alternating 15° segments via `repeating-conic-gradient`, the darker
 * half mixed from Accent 2 rather than authored — so one colour produces the
 * whole wheel and it cannot be set to two clashing values. Diameters from the
 * Figma component: 64 / 80 / 96.
 */
.tui-orn--spinwheel {
  position: relative;
  display: grid;
  place-items: center;
  border-radius: var(--tui-radius-full);
  background: repeating-conic-gradient(
    from 0deg,
    var(--c-accent2) 0 15deg,
    color-mix(in srgb, var(--c-accent2) 72%, #000) 15deg 30deg
  );
  box-shadow: 0 2px 10px -4px rgb(0 0 0 / 0.45);
  color: var(--c-on2);
  font-weight: 800;
  line-height: 0.98;
  text-align: center;
  /* Sat straight, a wheel looks like a badge. The tilt is what reads as motion. */
  transform: rotate(-7deg);
  transition: transform 0.9s cubic-bezier(0.2, 0.7, 0.2, 1);
  aspect-ratio: 1;
  flex-shrink: 0;
}

/*
 * The wheel's copy, in its own element: it needs a width to wrap against and
 * the disc cannot supply one. `overflow-wrap: anywhere` on the disc made
 * min-content ONE CHARACTER, so "40% OFF" rendered as six stacked letters.
 */
.tui-orn--spinwheel > span {
  display: block;
  max-width: 76%;
  word-break: normal;
  overflow-wrap: break-word;
}
.tui-orn--spinwheel[data-size='sm'] {
  width: 64px;
  height: 64px;
  font-size: var(--tui-typography-size-xs);
}
.tui-orn--spinwheel[data-size='md'] {
  width: 80px;
  height: 80px;
  font-size: var(--tui-typography-size-sm);
}
.tui-orn--spinwheel[data-size='lg'] {
  width: 96px;
  height: 96px;
  font-size: var(--tui-typography-size-md);
}

/* It spins. Only where the visitor has not asked us not to animate. */
@media (prefers-reduced-motion: no-preference) {
  .tui-campaign:hover .tui-orn--spinwheel {
    transform: rotate(353deg);
  }
}

/*
 * burst — 12-point star via clip-path. Same footprint rules as the wheel,
 * different register: hard-sell space-age where the wheel is fairground.
 * Flat fill, because a conic gradient inside a spiky silhouette reads as noise.
 */
.tui-orn--burst {
  display: grid;
  place-items: center;
  aspect-ratio: 1;
  background: var(--c-accent2);
  color: var(--c-on2);
  font-weight: 800;
  line-height: 0.98;
  text-align: center;
  flex-shrink: 0;
  clip-path: polygon(
    50% 0%,
    58.54% 18.12%,
    75% 6.7%,
    73.33% 26.67%,
    93.3% 25%,
    81.88% 41.46%,
    100% 50%,
    81.88% 58.54%,
    93.3% 75%,
    73.33% 73.33%,
    75% 93.3%,
    58.54% 81.88%,
    50% 100%,
    41.46% 81.88%,
    25% 93.3%,
    26.67% 73.33%,
    6.7% 75%,
    18.12% 58.54%,
    0% 50%,
    18.12% 41.46%,
    6.7% 25%,
    26.67% 26.67%,
    25% 6.7%,
    41.46% 18.12%
  );
}
.tui-orn--burst > span {
  display: block;
  max-width: 58%;
  word-break: normal;
  overflow-wrap: break-word;
}
.tui-orn--burst[data-size='sm'] {
  width: 64px;
  font-size: 0.56rem;
}
.tui-orn--burst[data-size='md'] {
  width: 80px;
  font-size: 0.66rem;
}
.tui-orn--burst[data-size='lg'] {
  width: 96px;
  font-size: 0.78rem;
}

/*
 * ribbon — diagonal corner banner. Corner seats only (the registry enforces
 * it). corner-tl is its own geometry, NOT a scaleX mirror — a mirror flips
 * the glyphs.
 */
.tui-orn--ribbon {
  position: absolute;
  top: 0;
  right: 0;
  overflow: hidden;
  pointer-events: none;
  border-top-right-radius: inherit;
  width: 104px;
  height: 104px;
}
.tui-orn--ribbon > span {
  position: absolute;
  display: block;
  text-align: center;
  background: var(--c-accent);
  color: #fff;
  font-weight: 800;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  transform: rotate(45deg);
  box-shadow: 0 1px 4px rgb(0 0 0 / 0.28);
  top: 19px;
  right: -30px;
  width: 140px;
  padding: 3px 0;
  font-size: 0.56rem;
}
.tui-orn--ribbon[data-anchor='corner-tl'] {
  right: auto;
  left: 0;
  border-top-right-radius: 0;
  border-top-left-radius: inherit;
}
.tui-orn--ribbon[data-anchor='corner-tl'] > span {
  right: auto;
  left: -30px;
  transform: rotate(-45deg);
}

/*
 * digits — Cristian's countdown component (Figma 2058:6189): unit cells, a big
 * value over a small label. Countdown-only; its copy is the clock.
 */
.tui-orn--digits {
  display: inline-flex;
  gap: 6px;
}
.tui-orn--digits .tui-orn__unit {
  display: flex;
  flex-direction: column;
  align-items: center;
  border-radius: 6px;
  background: color-mix(in srgb, var(--c-accent2) 26%, transparent);
  color: var(--c-fg);
}
.tui-orn--digits .tui-orn__unit > b {
  font-weight: 800;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.tui-orn--digits .tui-orn__unit > i {
  font-style: normal;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--c-fg-muted);
}
.tui-orn--digits[data-size='sm'] .tui-orn__unit {
  padding: 4px 7px 3px;
}
.tui-orn--digits[data-size='sm'] .tui-orn__unit > b {
  font-size: 0.9rem;
}
.tui-orn--digits[data-size='sm'] .tui-orn__unit > i {
  font-size: 0.44rem;
}
.tui-orn--digits[data-size='md'] .tui-orn__unit {
  padding: 6px 9px 4px;
}
.tui-orn--digits[data-size='md'] .tui-orn__unit > b {
  font-size: 1.2rem;
}
.tui-orn--digits[data-size='md'] .tui-orn__unit > i {
  font-size: 0.5rem;
}
.tui-orn--digits[data-size='lg'] .tui-orn__unit {
  padding: 8px 11px 5px;
}
.tui-orn--digits[data-size='lg'] .tui-orn__unit > b {
  font-size: 1.5rem;
}
.tui-orn--digits[data-size='lg'] .tui-orn__unit > i {
  font-size: 0.56rem;
}

/* ------------------------------------------------------------ anchor seats */

/*
 * Corner seats are absolutely positioned, so every surface needs a positioning
 * context (the bar and mat already had one for other reasons).
 */
.tui-campaign {
  position: relative;
}

/*
 * A lead or trail seat turns a stacked card into a media row: the token sits
 * beside the copy, not above it. Scoped with :has so a surface with no such
 * seat keeps its plain block flow.
 */
.tui-campaign--banner:has(> .tui-campaign__seat--lead),
.tui-campaign--banner:has(> .tui-campaign__seat--trail),
.tui-campaign--inlinecard:has(> .tui-campaign__seat--lead),
.tui-campaign--inlinecard:has(> .tui-campaign__seat--trail),
.tui-campaign--modal:has(> .tui-campaign__seat--lead),
.tui-campaign--sidebar:has(> .tui-campaign__seat--lead),
.tui-campaign--slidein:has(> .tui-campaign__seat--lead) {
  display: flex;
  align-items: center;
  gap: var(--tui-spacing-lg);
}
.tui-campaign--inlinecard:has(> .tui-campaign__seat) .tui-campaign__stack,
.tui-campaign--modal:has(> .tui-campaign__seat) .tui-campaign__stack,
.tui-campaign--sidebar:has(> .tui-campaign__seat) .tui-campaign__stack,
.tui-campaign--slidein:has(> .tui-campaign__seat) .tui-campaign__stack {
  flex: 1 1 auto;
  min-width: 0;
}
/* The mat stays a centred column; its lead seat reads as "before the copy". */
.tui-campaign--mat > .tui-campaign__seat {
  justify-content: center;
}

/*
 * Where placed tokens land. Inline seats participate in the surface's own
 * layout; corner seats are absolutely positioned and overhang by 9px — the
 * overhang is what makes a corner token read as APPLIED rather than printed,
 * which is also why the surface must not clip (a ribbon clips itself).
 */
.tui-campaign__seat {
  display: flex;
  align-items: center;
  flex: none;
}
.tui-campaign__seat:empty {
  display: none;
}
.tui-campaign__seat--corner-tl,
.tui-campaign__seat--corner-tr {
  position: absolute;
  z-index: 2;
}
.tui-campaign__seat--corner-tr {
  top: -9px;
  right: -9px;
}
.tui-campaign__seat--corner-tl {
  top: -9px;
  left: -9px;
}
.tui-campaign__seat--corner-tr:has(.tui-orn--ribbon),
.tui-campaign__seat--corner-tl:has(.tui-orn--ribbon) {
  top: 0;
}
.tui-campaign__seat--corner-tr:has(.tui-orn--ribbon) {
  right: 0;
}
.tui-campaign__seat--corner-tl:has(.tui-orn--ribbon) {
  left: 0;
}

/*
 * A corner ribbon reserves its corner: inset the copy instead of shrinking the
 * ornament — its size is the marketer's choice, the collision is ours to
 * prevent.
 */
.tui-campaign:has(.tui-campaign__seat--corner-tr .tui-orn--ribbon) .tui-campaign__stack {
  padding-inline-end: 74px;
}
.tui-campaign:has(.tui-campaign__seat--corner-tl .tui-orn--ribbon) .tui-campaign__stack {
  padding-inline-start: 74px;
}

/* ----------------------------------------------------------------- frames */

/*
 * The border treatment, orthogonal to ornaments and surface art. Enum is
 * plain | hairline | dashed — ticket and zigzag were cut in review (08-26).
 */

/* hairline — an inset rule a few px in from the edge. The print/label move. */
.tui-campaign[data-frame='hairline']::after {
  content: '';
  position: absolute;
  inset: 5px;
  border-radius: inherit;
  pointer-events: none;
  border: 1px solid color-mix(in srgb, var(--c-fg) 24%, transparent);
}

/* dashed — outline rather than border so toggling it cannot shift layout. */
.tui-campaign[data-frame='dashed'] {
  outline: 3px dashed color-mix(in srgb, var(--c-fg) 82%, transparent);
  outline-offset: -3px;
}

/* On a bar the pre-v1 look is top and bottom only — a full box outline round a
   1440px strip reads as a table cell. */
.tui-campaign--topbar[data-frame='dashed'] {
  outline: 0;
  border-top: 3px dashed color-mix(in srgb, var(--c-fg) 82%, transparent);
  border-bottom: 3px dashed color-mix(in srgb, var(--c-fg) 82%, transparent);
}

/* ------------------------------------------------------ second CTA + pills */

.tui-campaign__ctas {
  display: inline-flex;
  align-items: center;
  gap: var(--tui-spacing-sm);
  flex-wrap: wrap;
}

/*
 * The second CTA is always the quieter one — a text link rather than a filled
 * button. Two filled buttons is the failure mode "Accent 1 carries the CTA" is
 * guarding against: give a visitor two equally loud choices and the primary
 * stops being primary.
 */
.tui-campaign__cta--secondary {
  background: transparent;
  color: var(--c-fg);
  padding-inline: 4px;
  text-decoration: underline;
  text-underline-offset: 3px;
}

/*
 * Countdown pill. One line, always — a bar has no vertical room for stacked
 * DD:HH:MM units, and a device that changes shape by format is a device that
 * has to be re-designed per format.
 */
.tui-campaign__countdown {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;
  padding: 3px 10px;
  border: 1px solid color-mix(in srgb, var(--c-fg) 22%, transparent);
  border-radius: var(--tui-radius-full);
  font-size: var(--tui-typography-size-xs);
  font-weight: var(--tui-typography-weight-semibold);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  color: var(--c-fg);
}

/* ---------------------------------------------------------- product tiles */

.tui-campaign__tiles {
  display: flex;
  flex-wrap: wrap;
  gap: var(--tui-spacing-xs);
}

/*
 * A tile per associated product. Tinted from accent 2 like every other
 * decorative element, so a theme change carries them along.
 */
.tui-campaign__tile {
  display: inline-flex;
  align-items: center;
  padding: 4px 10px;
  border-radius: var(--tui-radius-md);
  background: color-mix(in srgb, var(--c-accent2) 34%, transparent);
  color: var(--c-fg);
  font-size: var(--tui-typography-size-xs);
  font-weight: var(--tui-typography-weight-medium);
  white-space: nowrap;
}

/* ------------------------------------------------------- background art */

/*
 * Seven presets, all pure CSS, all tinted from Accent 2 via `color-mix` so they
 * re-colour with the theme instead of carrying a palette of their own. That is
 * what makes them safe to offer as a one-click choice: no preset can clash with
 * the campaign it decorates.
 *
 * Painted with `background-image` on top of `--c-bg`, so the authored colour or
 * gradient still shows through wherever the art is transparent.
 *
 * Keyed on `.tui-art` rather than `.tui-campaign` so the editor's preset
 * dropdown can render an actual swatch of each one from these exact rules. A
 * second copy of the gradients for the picker would drift the first time one is
 * tweaked, and then the dropdown would advertise art the surface does not draw.
 */
.tui-art[data-art] {
  background-repeat: no-repeat;
}

.tui-art[data-art='glow'] {
  background-image: radial-gradient(
    circle at 78% 14%,
    color-mix(in srgb, var(--c-accent2) 44%, transparent) 0 17%,
    transparent 55%
  );
}

.tui-art[data-art='rays'] {
  background-image: repeating-conic-gradient(
    from 200deg at 88% -10%,
    color-mix(in srgb, var(--c-accent2) 30%, transparent) 0 4deg,
    transparent 4deg 12deg
  );
}

.tui-art[data-art='halo'] {
  background-image: radial-gradient(
    circle at 92% 50%,
    transparent 0 22%,
    color-mix(in srgb, var(--c-accent2) 34%, transparent) 22% 24%,
    transparent 25%
  );
}

.tui-art[data-art='grid'] {
  background-image:
    linear-gradient(color-mix(in srgb, var(--c-accent2) 26%, transparent) 1px, transparent 1px),
    linear-gradient(
      90deg,
      color-mix(in srgb, var(--c-accent2) 26%, transparent) 1px,
      transparent 1px
    );
  background-size: 22px 22px;
  background-repeat: repeat;
}

.tui-art[data-art='wash'] {
  background-image: linear-gradient(
    115deg,
    transparent 42%,
    color-mix(in srgb, var(--c-accent2) 40%, transparent) 100%
  );
}

/*
 * airmail — the par-avion edge: alternating diagonal dashes in accent and
 * accent 2, inset so the copy never sits on them. Two-tone from the theme, so
 * it re-colours like every other preset instead of hard-coding the red/blue
 * that would fight every palette we own. Painted on a ring pseudo-element:
 * the dashes are a border-width band with a hole in the middle, which is a
 * mask-exclude job, not a background-origin one.
 */
.tui-art[data-art='airmail']::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  padding: 8px;
  background: repeating-linear-gradient(
    45deg,
    var(--c-accent) 0 9px,
    transparent 9px 18px,
    var(--c-accent2) 18px 27px,
    transparent 27px 36px
  );
  -webkit-mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
  mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
  -webkit-mask-clip: content-box, border-box;
  mask-clip: content-box, border-box;
  -webkit-mask-composite: xor;
  mask-composite: exclude;
}

.tui-art[data-art='stripe'] {
  background-image: repeating-linear-gradient(
    90deg,
    color-mix(in srgb, var(--c-accent2) 22%, transparent) 0 9px,
    transparent 9px 18px
  );
  background-size: 18px 100%;
  background-repeat: repeat;
}

/*
 * A miniature of one art preset, for the picker.
 *
 * Wider than tall because the presets are positioned for a wide surface — the
 * corner glow sits at 78% across, the halo's ring at 92% — so a square swatch
 * would crop the very thing that identifies each one.
 */
.tui-artswatch {
  /* Positioned + clipped: airmail's ring is an `absolute; inset: 0` pseudo,
   * and without a positioned ancestor here it sizes itself to the Select
   * POPOVER — the whole dropdown wearing an envelope border. */
  position: relative;
  overflow: hidden;
  display: inline-block;
  flex: 0 0 auto;
  width: 44px;
  height: 26px;
  border: 1px solid var(--tui-color-border);
  border-radius: var(--tui-radius-sm);
  background-color: var(--c-bg);
}

/* At 44×26 the full-size 8/9px band is a blob; scale the ring to the swatch. */
.tui-artswatch[data-art='airmail']::before {
  padding: 3px;
  background: repeating-linear-gradient(
    45deg,
    var(--c-accent) 0 4px,
    transparent 4px 8px,
    var(--c-accent2) 8px 12px,
    transparent 12px 16px
  );
}

/*
 * The two tiling presets need their density scaled down for the swatch.
 *
 * Their tile sizes are absolute pixels — 22px for the grid, 18px for the stripe
 * — chosen for a surface hundreds of pixels wide. On a 44px swatch that is
 * barely one repeat, so the grid reads as a plain block and the stripe as two
 * bands. Only `background-size` is overridden: the gradients themselves stay the
 * ones the surface draws, so the swatch is a scale model rather than a
 * different picture.
 */
.tui-artswatch[data-art='grid'] {
  background-size: 8px 8px;
}

.tui-artswatch[data-art='stripe'] {
  background-size: 7px 100%;
}

.tui-artoption {
  display: inline-flex;
  align-items: center;
  gap: var(--tui-spacing-xs);
}

/* ------------------------------------------------------------- flow block */

/* The opt-in flow's anatomy: input + CTA on one line, notes beneath. Colors
   come from the campaign's own ink/accent vars like every other block. */
.tui-campaign__flow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.tui-campaign__flow-input {
  font: inherit;
  font-size: 0.85em;
  padding: 0.45em 0.8em;
  border-radius: 8px;
  border: 1px solid color-mix(in srgb, var(--c-fg) 30%, transparent);
  background: color-mix(in srgb, var(--c-bg, transparent) 60%, #fff 40%);
  color: var(--c-fg);
  min-width: 180px;
}

.tui-campaign__flow-input:focus-visible {
  outline: 2px solid var(--c-accent);
  outline-offset: 1px;
}

.tui-campaign__flow-note,
.tui-campaign__flow--done {
  font-size: 0.8em;
  opacity: 0.85;
}

/* An image-backed campaign must reserve a canvas even with little copy —
 * a 4:1 artwork on a content-height strip collapses to its CTA row. */
.tui-campaign[style*='background-image'] {
  min-height: 120px;
}

/* Whole-surface link (CTA href without a label): stretched under everything
 * interactive — dismiss and flows keep working above it. */
.tui-campaign__stretched-link {
  position: absolute;
  inset: 0;
  z-index: 1;
}

.tui-campaign__dismiss {
  z-index: 3;
}

/* src/components/campaigns/delivery/delivery.css */
/*
 * Delivery frames. The surfaces bring their own look (surfaces.css); these
 * own only placement geometry. The bar sits in layout flow — a topbar pushes
 * content, never floats over it. The overlay centers a modal (or lets a
 * slide-in place itself) above everything except TUI's own modal layer.
 */

.tgbl-campaign-bar > .tui-campaign--topbar {
  width: 100%;
  border-radius: 0;
}

.tgbl-campaign-overlay {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: grid;
  place-items: center;
  background: rgb(0 0 0 / 0.45);
  padding: 24px;
}

/* A slide-in is its own corner geometry: no backdrop, no centring. */
.tgbl-campaign-overlay:has(.tui-campaign--slidein) {
  background: transparent;
  pointer-events: none;
  place-items: end;
}

.tgbl-campaign-overlay:has(.tui-campaign--slidein) > * {
  pointer-events: auto;
}

/* The stage hands the surface its design width; the surface fills it. */
.tgbl-campaign-overlay__stage > .tui-campaign {
  width: 100%;
}

/* A welcome mat takes the whole viewport: no backdrop margin, no radius,
   content centred in the takeover. */
.tgbl-campaign-overlay:has(.tui-campaign--mat) {
  padding: 0;
}

/* The surface sits INSIDE the AnimatedSwap wrapper (.tgbl-swap), so height
   has to flow stage → swap → surface; a `>` straight to the surface never
   matches and the mat collapses to its min-height. */
.tgbl-campaign-overlay:has(.tui-campaign--mat) .tgbl-campaign-overlay__stage,
.tgbl-campaign-overlay:has(.tui-campaign--mat) .tgbl-swap {
  height: 100%;
}

.tgbl-campaign-overlay__stage .tui-campaign--mat {
  height: 100%;
  border-radius: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/*
 * Ticket stack (inline rotations): a diagonal paper pile. The top sheet sits
 * in flow; each under-sheet is the real next surface, absolutely stacked and
 * translated down-right so only its bottom-right corner peeks out. The slot
 * reserves that overhang via padding set inline from the shift constants.
 */
.tgbl-ticket-stack {
  position: relative;
}

.tgbl-ticket-stack__under {
  position: absolute;
  /* Match the TOP CARD's box, not the padded container — inset: 0 spans the
     reserved overhang padding and made every sheet taller than the card. */
  inset: 0 var(--tgbl-pile-x, 0px) var(--tgbl-pile-y, 0px) 0;
  overflow: hidden;
  pointer-events: none;
  border-radius: 8px;
  box-shadow: 0 1px 4px rgb(0 0 0 / 0.14);
  transition: transform 260ms ease;
}

/* Pin BOTH dimensions so a surface's own aspect-ratio customCss can't blow
   the width out past the wrapper (the XP banner's 2400/600 rule pushed the
   whole document into horizontal scroll). */
.tgbl-ticket-stack__under > .tui-campaign {
  width: 100%;
  height: 100%;
  aspect-ratio: auto !important;
  min-height: 0;
}

.tgbl-ticket-stack__top {
  position: relative;
  z-index: 2;
}

/* ── Swap motion, for hosts that show and hide ──────────────────────────
 *
 * In the app, `AnimatedSwap` drives these through the Web Animations API,
 * because React swaps the rendered child and needs to animate around that.
 * A marketing site cannot work that way: the HTML is baked at sync time and
 * the page is cached, so every campaign in a slot is already in the DOM and
 * rotation is a matter of hiding one and showing the next.
 *
 * The keyframes below are the same motion, expressed declaratively so it
 * travels with the stylesheet. The split that makes this work: THIS repo owns
 * what the ad looks like, including how it moves; the plugin owns when it
 * moves. Its script sets `data-leaving` and the motion is ours.
 *
 * Frames transcribed from AnimatedSwap so the two cannot disagree about what
 * the motion is — only about what triggers it. "Transcribed" was not enough on
 * its own: the ticket easing was already a different curve here than there
 * before either copy shipped, which nothing failed over and nobody would have
 * spotted. `swapMotionParity.test.ts` now pins the numbers below to the
 * exported constants, so the next divergence is a red test rather than an ad
 * that moves differently depending on where it rendered.
 */

/* The pile: a ticket tucks down-right and under its neighbours. */
@keyframes tgbl-swap-out-ticket {
  0% {
    transform: translate(0, 0) rotate(0deg);
    opacity: 1;
  }
  55% {
    transform: translate(35px, 84px) rotate(4deg) scale(0.98);
    opacity: 0.9;
  }
  100% {
    transform: translate(14px, 42px) scale(0.94);
    opacity: 0;
  }
}

/* The topbar: the split-flap, folding up on X like a departure board.
 *
 * `steps()` is the mechanism, not a stylistic flourish — a smooth curve here
 * reads as a squash and loses the airport-board identity entirely. No
 * `perspective`, matching AnimatedSwap: at -88deg the face collapses to a line
 * and the 3D suggestion buys nothing.
 *
 * This is the mode the first transcription dropped. `CampaignDelivery` routes
 * `placement === 'bar'` to flap and everything else to push; externally there
 * is no placement prop, only a class, so the routing is re-expressed below.
 */
@keyframes tgbl-swap-out-flap {
  0% {
    transform: rotateX(0deg);
  }
  100% {
    transform: rotateX(-88deg);
  }
}

/* Flat rotations (banners, inline cards): a cube-lite push off to the left. */
@keyframes tgbl-swap-out-push {
  0% {
    transform: perspective(1200px) translateX(0) rotateY(0deg);
    opacity: 1;
  }
  100% {
    transform: perspective(1200px) translateX(-40%) rotateY(18deg);
    opacity: 0;
  }
}

/* `--tui-campaign-duration` is set by the consumer from the campaign's own
 * slideDurationMs. Defaults match AnimatedSwap: 180ms push, 450ms ticket. */
.tui-campaign[data-leaving] {
  animation: tgbl-swap-out-push var(--tui-campaign-duration, 180ms) ease-in forwards;
}

/* A topbar flaps rather than sliding. Must outrank the default above, which is
   why it is a more specific selector rather than an earlier rule. */
.tui-campaign--topbar[data-leaving] {
  animation: tgbl-swap-out-flap var(--tui-campaign-duration, 150ms) steps(3, end) forwards;
  transform-origin: 50% 50%;
}

.tui-campaign--sidebar[data-leaving] {
  animation: tgbl-swap-out-ticket var(--tui-campaign-duration, 450ms) cubic-bezier(0.4, 0, 0.6, 1)
    forwards;
}

/* Reduced motion still leaves, it just fades — matching AnimatedSwap's 120ms
 * rather than cutting outright. A hard cut would also strip the animationend
 * the consumer's script waits on before hiding; its timeout would cover that,
 * but a 450ms stall is not what "reduced motion" should feel like. */
@media (prefers-reduced-motion: reduce) {
  .tui-campaign[data-leaving],
  .tui-campaign--sidebar[data-leaving] {
    animation: tgbl-swap-out-fade 120ms linear forwards;
  }
}

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

/* ── The pile, on a host that shows and hides ───────────────────────────
 *
 * In the app, `CampaignSlot` builds the pile as real structure —
 * `.tgbl-ticket-stack` with `__top` and `__under` children — because React is
 * already deciding what renders. A consumer has no such component, and asking
 * one to construct our wrapper would be exactly the coupling this whole design
 * exists to avoid: it would have to learn our class names, our nesting, and
 * which sheet is on top.
 *
 * It does not have to. A slot already arrives as markup that describes only
 * itself:
 *
 *   <div class="tgbl-campaign-slot" data-slot="inline" data-count="3">
 *     <div class="tgbl-campaign-placement" data-tgbl-placement="campaign:…">…
 *
 * That is a container, a count, and children in order — facts about what the
 * marketer placed, not about how it looks. Everything below is written against
 * those facts, so the pile is a decision made here and shipped over the styles
 * channel, and the consumer stays unaware there is a pile at all. Order in the
 * DOM is order in the stack; rotation is the script moving the front sheet to
 * the back.
 *
 * It also degrades honestly: with no script at all, three tickets render as a
 * pile rather than as three stacked ads down the page, which is what they did
 * before this block existed.
 */
.tgbl-campaign-slot[data-slot='inline']:not([data-count='1']) {
  position: relative;
  /* Reserve the overhang the under-sheets stick out into, so the pile does not
     collide with whatever the page puts after it. Two sheets deep is the most
     that reads as a pile rather than a mess, hence the cap. */
  padding-right: 14px;
  padding-bottom: 42px;
}

/* Dismiss down to one sheet and there is no overhang left to reserve — without
   this the slot keeps a 42px gap under a single ticket for no visible reason. */
.tgbl-campaign-slot[data-slot='inline']:not(
    :has(> .tgbl-campaign-placement:nth-child(2 of :not([hidden])))
  ) {
  padding-right: 0;
  padding-bottom: 0;
}

/* THE WRAPPER POSITIONS. THE SURFACE PAINTS.
 *
 * The pile's chrome — shadow, radius, clipping — used to sit on the PLACEMENT,
 * a plain block div that fills the content column. Capping the surface at its
 * design width left that box behind, so the back sheets painted a 632px
 * shadowed rectangle behind a 224px card. (Measured by Marketeer Man live.)
 *
 * The first fix was `width: fit-content` on the wrapper, and it was wrong in a
 * way only measuring caught: shrink-to-fit sizes to the CONTENT, and a surface
 * whose width is a `max-width` has no opinion to push back with. The three
 * tickets came out 203px, 173px and 186px — each hugging its own headline,
 * none of them the 224px the format specifies. Right shadow, wrong card.
 *
 * So the chrome moves to the surface instead, which is where it belonged: the
 * depth of a sheet is a property of the sheet. The wrapper goes back to being
 * a positioning box with no appearance at all, the surface keeps its own
 * design width from `surfaces.css`, and the number lives in exactly one place.
 *
 * The general shape, worth keeping: when a box needs a width to make its
 * decoration correct, the decoration is on the wrong box. */
.tgbl-campaign-slot[data-slot='inline']
  > .tgbl-campaign-placement:not(:nth-child(1 of :not([hidden])))
  > .tui-campaign {
  border-radius: 8px;
  box-shadow: 0 1px 4px rgb(0 0 0 / 0.14);
}

.tgbl-campaign-slot[data-slot='inline']:not([data-count='1']) > .tgbl-campaign-placement {
  /* Stacked against the front sheet's box. Anchored top-left and sized by
     content: with a fit-content wrapper an `inset` right/left pair would fight
     the width and silently win, stretching the sheet back across the column. */
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  pointer-events: none;
  transition: transform 260ms ease;
}

/* A dismissed sheet leaves the pile entirely rather than leaving a hole.
   Plain `:nth-child` counts hidden siblings, so dismissing the front ticket
   would take the only in-flow child out of flow and collapse the whole pile to
   zero height with two absolutely-positioned sheets floating over whatever
   followed. `of :not([hidden])` counts only the sheets that are still there,
   so the pile closes up. (Caught by Marketeer Man before it shipped —
   dismissal is theirs, the geometry is ours, and neither of us could see the
   interaction from one side.) */
.tgbl-campaign-slot[data-slot='inline'] > .tgbl-campaign-placement[hidden] {
  display: none;
}

/* The front sheet stays in flow: it is what gives the slot its height, and it
   is the only one anybody can click. */
.tgbl-campaign-slot[data-slot='inline']:not([data-count='1'])
  > .tgbl-campaign-placement:nth-child(1 of :not([hidden])) {
  /* No chrome to undo any more — the shadow and radius now live on the
     under-sheets' surfaces, which this selector excludes by construction. */
  position: relative;
  inset: auto;
  pointer-events: auto;
  z-index: 3;
}

/* 7px right / 21px down per sheet — the pile's own diagonal, matching the
   shift constants the swap keyframes tuck along. */
.tgbl-campaign-slot[data-slot='inline'] > .tgbl-campaign-placement:nth-child(2 of :not([hidden])) {
  transform: translate(7px, 21px);
  z-index: 2;
}

.tgbl-campaign-slot[data-slot='inline'] > .tgbl-campaign-placement:nth-child(3 of :not([hidden])) {
  transform: translate(14px, 42px);
  z-index: 1;
}

/* Beyond three, sheets pile up on the third rather than marching off the page.
   A marketer who puts six in one shortcode gets a deep pile, not a diagonal
   that leaves the viewport. */
.tgbl-campaign-slot[data-slot='inline']
  > .tgbl-campaign-placement:nth-child(n + 4 of :not([hidden])) {
  transform: translate(14px, 42px);
  z-index: 0;
}

/* An under-sheet must not be stretched by a surface's own aspect-ratio — the
   XP banner's 2400/600 rule pushed the whole document into horizontal scroll
   the first time this ran in-app.

   The `width: 100%; height: 100%` this rule used to carry is gone with the
   fit-content wrapper: percentages against a shrink-to-fit parent are at best
   circular and at worst resolve to auto, and every surface now carries its own
   max-width, which is the constraint that was actually wanted. */
.tgbl-campaign-slot[data-slot='inline']:not([data-count='1'])
  > .tgbl-campaign-placement:not(:nth-child(1 of :not([hidden])))
  .tui-campaign {
  aspect-ratio: auto !important;
  min-height: 0;
}

/* ── Trigger gating ─────────────────────────────────────────────────────
 *
 * A surface whose trigger has not fired yet is present in the DOM but not
 * shown. The consumer's script sets `data-open="1"` when the moment arrives —
 * after a delay, at a scroll depth, on exit intent.
 *
 * The app needs none of this: React does not mount a campaign until it is
 * eligible, so there is no waiting state to style. A marketing site must render
 * everything at sync time and reveal later, because the HTML is baked and the
 * page is cached. Without these rules the entire trigger system is inert
 * externally — a campaign set to appear after five seconds appears at once.
 *
 * `data-await` is what marks a surface as waiting. Its absence means "show me",
 * so a consumer that knows nothing about triggers is unaffected.
 *
 * It is emitted by the renderer, not set by the consumer's script. A script can
 * only add it after first paint, which made a delayed topbar paint at the top
 * of the page and then vanish — a flash on every load, on exactly the campaigns
 * whose trigger had never been seen working.
 */
.tui-campaign[data-await]:not([data-open]) {
  opacity: 0;
  pointer-events: none;
}

.tui-campaign[data-await][data-open] {
  opacity: 1;
  transition: opacity var(--tui-campaign-duration, 200ms) ease;
}

@media (prefers-reduced-motion: reduce) {
  .tui-campaign[data-await][data-open] {
    transition: none;
  }
}
