/*
 * PulseSoc Marketplace — commerce design system for the web storefront.
 *
 * Scope and naming
 * ----------------
 * Every selector here is namespaced `mkt-`. That is not decoration: the
 * `pulse_social_shell()` page in bot.py ships an inline <style> block that
 * already owns the bare class names a storefront would reach for first —
 * `.card`, `.grid`, `.pill`, `.actions`, `.nav`, plus element rules on
 * `button`, `input`, `select`, `textarea`, `p`, `small`, `a` and `h1`.
 * Marketplace pages render *inside* that shell, so an unnamespaced `.card`
 * rule here would silently restyle the feed, the drawer and every other
 * shell surface on the same page. The namespace is the only thing keeping
 * this file additive.
 *
 * The shell's element-level rules still apply to our markup (they are
 * lower specificity but they do apply), so a few rules below exist purely
 * to undo a shell default on a commerce control — those are marked.
 *
 * Two things that list deliberately does NOT include, because the audit
 * behind it found them absent: the shell paints no `img` rule, and neither
 * do any of the eight stylesheets it links. Do not add an "undo" for one.
 * The shell's own dark defaults are reached instead by re-declaring its
 * `--muted` and `--line` aliases on `.mkt` (see that block), because those
 * two are the only aliases any rule matching inside this subtree reads.
 *
 * Tokens
 * ------
 * Spacing, radius, type and motion come from `pulsesoc-tokens.css`, which
 * the shell links first. Spacing is that sheet's 8px base grid.
 *
 * COLOUR DOES NOT. The storefront is light, because the native app's
 * storefront is light, and it resolves its palette from a `--store-*` block
 * declared on `.mkt` further down — transcribed from
 * `mobile-native/src/theme/storeLight.ts` and `marketplaceLight.ts`. That
 * block also shadows the dark semantic names (`--text-primary`,
 * `--surface-raised`, `--border-subtle`, …) for the subtree, which is why
 * rules below can keep using them and still resolve light. The long comment
 * on `.mkt` explains why the app makes this one surface an exception to a
 * dark-theme app, and `tests/test_marketplace_light_parity.py` fails if the
 * transcription drifts from the native file.
 *
 * Fallbacks are supplied on every `var()`, and they are LIGHT values. A dark
 * fallback here is a bug even though it cannot fire today: it can only
 * become reachable by someone deleting a declaration from the `.mkt` block,
 * and at that moment it would paint near-white text onto a white card.
 *
 * There are no hard-coded brand colours here that are not either a token or
 * a documented fallback of one. Three families of literal remain on purpose
 * and each says so at its site: the badge scrim and its white hairline (a
 * badge is drawn over a seller's photograph, so its contrast partner is
 * near-black, not the card), the colour-swatch rings, and the low-alpha
 * washes derived from `text.primary`.
 */

/* ---------------------------------------------------------------- *
 * 0. Shell measure
 *
 * The member shell lays its content out as `.layout` — a two-column grid
 * of `minmax(0,1fr)` plus a 320px social rail ("PulseSoc Intelligence"),
 * which every page using `pulse_social_shell()` inherits. On a 1440px
 * viewport that leaves the page 614px once the shell's own 214px desktop
 * nav rail is also deducted, and `.mkt-grid` is `auto-fill minmax(230px)`,
 * so 614px fits two product columns. The same route rendered to a signed-
 * out visitor goes through `public_document()`, has no social rail, and
 * fits four. Measured, not assumed: the signed-in storefront was a
 * narrower storefront than the one strangers see, which is backwards for
 * commerce — the shopper with an account is the one who can buy.
 *
 * Reclaiming the rail is the call four other wide surfaces in bot.py
 * already made for themselves (`creator-studio`, `pulse-music-page`,
 * `undx-core-page`, `pulse-notifications-page` all collapse `.layout` to
 * one column). This is that same decision, expressed here rather than in
 * the shared shell so it cannot affect a page that does not load this
 * stylesheet, and scoped by `:has(.mkt)` so it cannot affect one that
 * loads it without rendering a storefront.
 *
 * `> aside` is deliberate: it matches only the shell's own rail, which is
 * a direct child of `.layout`. The storefront's own panels live inside
 * `.layout > div`, so nothing this file renders can be hidden by it.
 * ---------------------------------------------------------------- */

body:has(.mkt) .layout {
  grid-template-columns: minmax(0, 1fr);
}

body:has(.mkt) .layout > aside {
  display: none;
}

/*
 * The shell reveals its 214px desktop nav rail at 1100px but keeps `.wrap`
 * capped at 1180px, so crossing that breakpoint costs the page 232px of
 * rail and hands back only 184px of measure. Measured on the storefront:
 * four product columns at a 1024px viewport, three at 1440px — the window
 * gets bigger and the grid gets smaller. Widening the measure above the
 * same breakpoint that introduces the rail pays for it, and restores the
 * signed-in grid to the four columns the public page already showed.
 *
 * 1500px is the figure `creator-studio` chose for the same problem, kept
 * here rather than invented so two wide surfaces in one product do not
 * disagree about how wide "wide" is.
 *
 * Confined to a min-width query on purpose. The shell's own phone rules
 * live in `@media(max-width:900px)` and set `.wrap{width:100%}`; an
 * unscoped `body:has(.mkt) .wrap` outranks that on specificity and would
 * take the full-bleed phone measure away.
 */
@media (min-width: 1100px) {
  body:has(.mkt) .wrap {
    width: min(100% - 28px, 1500px);
  }
}

/* ---------------------------------------------------------------- *
 * 1. Page scaffolding
 * ---------------------------------------------------------------- */

/* ---------------------------------------------------------------- *
 * The storefront palette is the NATIVE app's, and the native app's
 * storefront is light.
 *
 * This file used to resolve its colour from `pulsesoc-tokens.css` — the
 * dark blue-graphite layer that mirrors `mobile-native/src/theme/colors.ts`
 * — on the reasonable assumption that the app is a dark-theme app and the
 * web should match it. That assumption is true of every other surface and
 * false of this one. `mobile-native/src/theme/storeLight.ts:1` states the
 * exception in its own opening lines: "PulseSoc is a dark-theme app. This
 * is the one surface that is deliberately light, because it is a dense
 * data-management screen where hairline borders and white cards do more
 * work than the dark chrome does elsewhere."
 *
 * Every marketplace screen in the app paints from it —
 * `MarketplaceScreen.tsx` and `MarketplaceProductScreen.tsx` both set
 * `root: { backgroundColor: storeLight.bg.page }`, a #EAEDED page carrying
 * #FFFFFF cards and #0F1111 text. So the web storefront and the app
 * storefront were not two tunings of one palette that had drifted apart;
 * they were opposite polarities. No amount of substituting a dark token
 * for a dark hex would have closed that, which is why this block replaces
 * the resolution source rather than adjusting the values it resolved to.
 *
 * Values below are transcribed from `storeLight.ts` and `marketplaceLight.ts`
 * and must stay equal to them. `tests/test_marketplace_light_parity.py`
 * fails if either side moves, so the transcription is checked rather than
 * trusted — the native file is the authority and this is its copy.
 *
 * Two consequences worth stating, because both look like bugs otherwise:
 *
 *   - The tokens are redeclared ON `.mkt`, not on `:root`. The shell in
 *     bot.py links `pulsesoc-tokens.css` on every HTML page (and
 *     `pulse_inject_design_tokens` splices it in where no link exists), so
 *     the dark semantic names are always defined and always inherited.
 *     Overriding them here is a scoped shadow: storefront markup resolves
 *     light, the surrounding feed / drawer / bottom-nav keep resolving
 *     dark, and no other page can be reached by an edit to this file. It
 *     also means the ~330 `var()` references already in this file flip
 *     polarity without being touched, which is the only reason a change
 *     this broad is reviewable.
 *
 *   - The shell chrome stays dark, and that is parity rather than a seam.
 *     The app's storefront supplies its OWN near-black header
 *     (`storeLight.bg.headerFrom` #0B0B0C) above its light page, so
 *     near-black chrome over a light catalogue is what the app already
 *     looks like. The web shell's dark topbar and bottom nav land in the
 *     same relationship.
 * ---------------------------------------------------------------- */

/* Palette only. `body.mkt-public` shares this block rather than getting a copy
 * of it. `marketplace_storefront.public_document` sets that class on the
 * signed-out storefront document, which has no member shell around it and
 * therefore no other source for these tokens. Sharing the declaration keeps
 * one palette: `tests/test_marketplace_light_parity.py` scans the whole
 * stylesheet for `--store-*` declarations, so a second block would be a second
 * thing to keep in step with `storeLight.ts`, and the one that drifted would be
 * the one strangers see. The member body is deliberately not listed — it is
 * the dark shell, and only the storefront subtree inside it turns light.
 *
 * Add colours here; add layout to the `.mkt` block below it. A document
 * element and a content slab want the same colours and emphatically not the
 * same box. */
.mkt,
body.mkt-public {
  /* -- storeLight.bg -- */
  --store-bg-page: #eaeded;
  --store-bg-card: #ffffff;
  --store-bg-header: #0b0b0c;
  --store-bg-strip: #141518;
  --store-bg-warning: #fcf5ee;
  --store-bg-skeleton: #e6e9ea;
  /* -- storeLight.border -- */
  --store-border-hairline: #d5d9d9;
  --store-border-secondary: #adb1b8;
  --store-border-warning: #f0d8b6;
  /* -- storeLight.text -- */
  --store-text-primary: #0f1111;
  --store-text-muted: #565959;
  --store-text-link: #0a7050;
  --store-text-link-active: #08553f;
  --store-text-on-dark: #ffffff;
  --store-text-on-dark-muted: #c7cdd3;
  /* -- storeLight.status -- */
  --store-status-success: #067d62;
  --store-status-warning: #c7511f;
  --store-status-error: #b12704;
  /* -- storeLight.accent. `brand` is a mint that all but vanishes as a rule
   * or a 1px border on #ffffff; `brandOnLight` is the same green one step
   * deeper and is what the native file reserves for accents drawn on a
   * card. Web has more hairlines than the app does, so `brandOnLight` is
   * the default accent here and `brand` is kept for fills. -- */
  --store-accent-brand: #2ee6a8;
  --store-accent-on-light: #22c48d;
  --store-accent-star: #ffa41c;
  /* -- storeLight.cta (STORE_CTA_PULSESOC) -- */
  --store-cta-from: #2ee6a8;
  --store-cta-to: #22c48d;
  --store-cta-text: #04231a;
  /* -- storeLight.select -- */
  --store-select-fill: #e8f6f1;
  --store-select-border: #189669;
  /* Low-alpha washes.
   *
   * The dark storefront used `rgba(255, 255, 255, 0.02 .. 0.24)` in roughly
   * twenty places to lift a surface a step off its background. That idiom
   * only works when the background is dark; on #eaeded and #ffffff a white
   * wash is either invisible or, over the page grey, brighter than the card
   * it is supposed to sit inside. The inversion is the same gesture in the
   * other direction — `text.primary` at the same alphas — so these are the
   * one family of values here NOT transcribed from the native file: the app
   * builds these steps from opaque greys (`bg.skeleton`, `select.disabled`)
   * because React Native has no cascade to blend against. A translucent
   * wash is the web expression of the same intent and keeps the rules
   * composable over both the page and the card.
   */
  --store-wash-faint: rgba(15, 17, 17, 0.03);
  --store-wash-soft: rgba(15, 17, 17, 0.05);
  --store-wash: rgba(15, 17, 17, 0.08);
  --store-wash-strong: rgba(15, 17, 17, 0.16);
  /* -- marketplaceLight.savedSearch.from — the app's "this is a banner,
   * not a product" wash. -- */
  --store-banner-from: #eef7f4;
  /* -- marketplaceLight.badge -- */
  --store-badge-featured-bg: #0b0b0c;
  --store-badge-featured-text: #2ee6a8;
  --store-badge-new-bg: #067d62;
  --store-badge-new-text: #ffffff;
  --store-badge-sold-overlay: rgba(11, 11, 12, 0.85);
  /* A translucent form of the same near-black, for the badge scrim. See
   * `.mkt-badge` for why a badge is the one storefront surface that must
   * stay dark on a light page: its real backdrop is a seller's photograph,
   * not the card. The app reaches the same conclusion — every badge plate
   * in `marketplaceLight.badge` is near-black or a near-black overlay. */
  --store-badge-scrim: rgba(11, 11, 12, 0.82);

  /* An error wash built from `status.error`, for the one panel that needs a
   * tinted fill rather than just red text. */
  --store-error-wash: rgba(177, 39, 4, 0.08);

  /* Elevation.
   *
   * The dark layer's card shadow is `0 20px 70px rgba(0,0,0,0.24)` — a
   * deep, wide black bloom that reads as depth against #050910 and as
   * grime against #ffffff. The native storefront carries no shadow at all:
   * `storeLight.ts:1` says hairline borders "do more work than the dark
   * chrome does elsewhere", and the card styles pair `bg.card` with
   * `border.hairline` and stop. These keep a much shallower lift so the
   * grid still has a sense of stacking on hover, without inventing a
   * depth the app does not have.
   */
  --elevation-card: 0 1px 2px rgba(15, 17, 17, 0.06);
  --elevation-overlay: 0 6px 16px rgba(15, 17, 17, 0.12);
  /* The dark CTA threw a mint glow (`0 14px 38px rgba(50,230,179,0.24)`).
   * A coloured glow on a light page looks like a rendering artefact, and
   * the app's CTA has none. */
  --elevation-glow: 0 1px 2px rgba(15, 17, 17, 0.1);

  /* Shadow the inherited dark semantics. Every one of these names is
   * defined by `pulsesoc-tokens.css` as part of the dark layer; the
   * storefront needs the same *meanings* against a light page. */
  --surface-primary: var(--store-bg-page);
  --surface-raised: var(--store-bg-card);
  --text-primary: var(--store-text-primary);
  --text-secondary: var(--store-text-muted);
  --text-on-action: var(--store-cta-text);
  --border-subtle: var(--store-border-hairline);
  --border-strong: var(--store-border-secondary);
  /* 3.75:1 on the card — the native file picked this value precisely
   * because the mint CTA green measures 2.25:1 there and would leave a
   * focus ring invisible to a low-vision shopper. */
  --border-focus: var(--store-select-border);
  --action-primary: var(--store-accent-on-light);
  --action-secondary: var(--store-accent-brand);

  /* The two aliases the member shell declares for itself, restated here.
   *
   * `pulse_social_shell` (bot.py:48452) opens with
   * `:root{--line:var(--border-subtle,…);--muted:var(--text-secondary,…)}`.
   * Shadowing `--border-subtle` and `--text-secondary` above does NOT reach
   * them: a custom property's value is computed on the element that declares
   * it, so `--muted` was already resolved against `:root`'s dark
   * `--text-secondary` and what inherits down into this subtree is the
   * finished dark grey. The alias itself has to be re-declared.
   *
   * `--muted` is not cosmetic. The shell styles `p,.muted,small{color:
   * var(--muted)}`, and the storefront renders three unclassed `<p>`
   * elements, so without this line they paint #9fb5c0 on the #eaeded page —
   * 1.82:1, well under the 4.5:1 body-text floor. The store's own muted grey
   * measures 6.27:1 there.
   *
   * Only these two are restated because only these two are reachable: of the
   * four aliases the shell declares, `--cyan` and `--green` appear in no rule
   * that can match inside `.mkt`. Every element-level rule the shell applies
   * to this subtree (`p,.muted,small`, `button,.button`,
   * `input,select,textarea`, `textarea`, `a`, `h1`) uses `--muted` or
   * `--line` and nothing else. Adding the other two would be decoration. */
  --muted: var(--store-text-muted);
  --line: var(--store-border-hairline);

  /* `:root` carries `color-scheme:dark`, which is what tells the UA to render
   * the sort `<select>`'s native dropdown, the scrollbars and any form-control
   * chrome dark. Those are painted by the platform, not by the rules above,
   * so a token cannot reach them — the subtree has to declare its own scheme
   * or a light storefront opens a black dropdown list. */
  color-scheme: light;

  --mkt-gap: var(--spacing-md, 16px);
  --mkt-card-radius: var(--radius-card, 16px);
  --mkt-line: var(--store-border-hairline);
  --mkt-line-strong: var(--store-border-secondary);
  --mkt-muted: var(--store-text-muted);
  --mkt-accent: var(--store-accent-on-light);
  --mkt-accent-2: var(--store-accent-brand);
  /* Was `--domain-intelligence`, a #9f7cff purple. The business surfaces
   * are locked to black / white / green and the app's storefront carries no
   * purple at all — `marketplaceLight.badge.featuredBg` is the near-black
   * plate, so a distinguishing border becomes that. */
  --mkt-commerce: var(--store-badge-featured-bg);
  /* The neutral plate product photography sits on. On the dark storefront
   * this was #060f1c, one step DARKER than the card so a contained image
   * read as a framed photo rather than a hole. The app inverts the same
   * intent: `galleryImage`/`cardImage` sit on `storeLight.bg.skeleton`,
   * one step darker than #ffffff. */
  --mkt-media-plate: var(--store-bg-skeleton);

  /* The light page itself. `.mkt` is the storefront's outermost element, so
   * painting it here is what turns the content column light while leaving
   * the shell around it alone. Without this the cards would be white
   * islands floating on the shell's dark gradient. */
  background: var(--store-bg-page);
  color: var(--store-text-primary);
}

/* Layout, and deliberately NOT shared with `body.mkt-public`.
 *
 * Everything above is palette — tokens, a colour scheme, a background — and a
 * document that has no member shell around it needs all of it. None of what
 * follows is palette, and the first arrangement of this file handed the body
 * the whole declaration list. The body then inherited a -16px inline margin
 * and started at x=-16 instead of 0: measured at 390px, `.mkt` landed at
 * left=-4 with 28px of dead space on the right, and `overflow-x:hidden` in the
 * public document's base rules hid the overflow rather than the asymmetry. */
.mkt {
  /* The light page needs its own inset, because `.mkt` is a surface rather
   * than a transparent pass-through: without it the first card sits flush
   * against the edge of the colour change. */
  padding: var(--spacing-md, 16px);
  border-radius: var(--pulse-radius-sm, 12px);
  display: grid;
  gap: var(--spacing-lg, 24px);
  min-width: 0;

  /* There is deliberately no full-bleed `margin-inline` here, though this rule
   * carried `calc(-1 * var(--spacing-md, 16px))` for most of its life. It was
   * meant to push the light slab back out to the member shell's own gutter so
   * the storefront read as a page that happens to be light rather than as one
   * more card on the feed's dark background. It never did that, for two
   * independent reasons, and measuring it is what showed both:
   *
   *   - The shell applies `max-width:100%` to everything, and `.mkt` is a grid
   *     item in `section.layout`'s single track. A negative margin can only
   *     widen a box that is allowed to grow past its containing block; clamped,
   *     it degrades into a pure leftward shift. At 430px the slab sat at
   *     left=-6 with the SAME 410px width as its parent — 6px of background
   *     pushed off the left edge and a 26px dark band left behind on the right.
   *     Asymmetric, which is worse than the symmetric inset it replaced.
   *
   *   - The number was wrong anyway. The shell's gutter is `main.wrap`'s 10px
   *     padding, not 16px, so even unclamped the arithmetic cancelled a gutter
   *     that was never there.
   *
   * Without it the slab simply takes the measure its parent gives it, which is
   * the same 10px inset every other member surface sits on. The remaining
   * `border-radius` is then doing real work: it reads as the shell's own card
   * rhythm instead of as a page that failed to reach the edge. */
}

/* In the public document the slab's inline padding is a second gutter.
 *
 * On the member page `.mkt` is a light surface inset in a dark one, so its
 * padding is the surface's own edge and has to be there. In
 * `public_document` the body is already `--store-bg-page`: the slab is the
 * same colour as the page behind it, there is no edge for the padding to hold
 * off, and `.mkt-doc` (`width:min(100% - 24px,1180px)`, centred) has already
 * supplied a 12px gutter. Keeping both indents the catalogue to 28px while the
 * masthead links and the footer sit at 12px — a misalignment a reader sees
 * even though neither value is wrong on its own. The block padding stays:
 * that is vertical rhythm, not a gutter. */
body.mkt-public .mkt {
  padding-inline: 0;
}

/* `hidden` is this storefront's no-JavaScript contract, and it needs an
 * author rule to keep it.
 *
 * The server renders Save, Report listing, Message seller, the two gallery
 * arrows, the description's "Show more" toggle and the variant stock line
 * into the DOM already carrying `hidden`; `pulse_marketplace.js` removes the
 * attribute as it binds each one. A visitor whose script never runs must
 * therefore never see them, because none of them can do anything. The sort
 * form's Apply button runs the same contract backwards: the script sets
 * `hidden` on it once `change` submits the form for you.
 *
 * The attribute by itself does not deliver that. `[hidden] { display: none }`
 * lives in the user-agent stylesheet, which loses to every author
 * declaration, so any component rule that sets `display` revives the element
 * it was meant to suppress. Every one of the controls above is `inline-flex`
 * or `grid` somewhere below, so every one of them was revived. Two of those
 * were visible in the rendered page and neither looked like a CSS bug:
 *
 *   - the product page showed a glowing green availability dot with no words
 *     beside it -- `.mkt-stock::before` drawing inside an empty `<p>` that
 *     was supposed to be hidden until a variant was chosen. A green dot is a
 *     claim about stock, so this was also the catalogue asserting something
 *     no row had said.
 *   - the discovery toolbar kept its Apply button after the script had
 *     already wired the select to auto-submit, leaving a dead control and a
 *     stretched gap in the row.
 *
 * `!important` is deliberate rather than a shortcut. `hidden` is not a style
 * preference, it is a statement that the element is not part of the page
 * yet, and no component rule is entitled to outrank it. Scoped to `.mkt` so
 * it stays this storefront's rule and not a global one. */
.mkt [hidden] {
  display: none !important;
}

.mkt-section {
  display: grid;
  gap: var(--mkt-gap);
  min-width: 0;
}

/* A visually hidden label that screen readers still announce. Used for
 * the search field, the sort control and the gallery rail, all of which
 * are visually self-evident but would otherwise ship unlabelled. */
.mkt-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* One focus treatment for every interactive element in the storefront.
 * `:focus-visible` rather than `:focus` so a mouse click on a card does
 * not leave a ring behind, while keyboard traversal always shows one. */
.mkt a:focus-visible,
.mkt button:focus-visible,
.mkt input:focus-visible,
.mkt select:focus-visible,
.mkt summary:focus-visible,
.mkt [tabindex]:focus-visible {
  outline: 2px solid var(--border-focus, var(--mkt-accent-2));
  outline-offset: 2px;
  border-radius: var(--radius-xs, 8px);
}

/* ---------------------------------------------------------------- *
 * 2. Headings and breadcrumbs
 * ---------------------------------------------------------------- */

.mkt-head {
  display: grid;
  gap: var(--spacing-xs, 8px);
  min-width: 0;
}

.mkt-crumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--font-size-xs, 12px);
  color: var(--mkt-muted);
}

.mkt-crumbs li {
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  min-width: 0;
}

.mkt-crumbs li + li::before {
  content: "/";
  opacity: 0.45;
  flex: 0 0 auto;
}

.mkt-crumbs a {
  color: var(--mkt-muted);
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 22ch;
}

.mkt-crumbs a:hover {
  color: var(--text-primary, #0f1111);
  text-decoration: underline;
}

/* Truncated on the same terms as the links beside it. This crumb carries the
 * product heading, which can be a whole supplier sentence; left unclamped it
 * wraps the trail onto three lines and restates the `<h1>` directly above it. */
.mkt-crumbs [aria-current="page"] {
  color: var(--text-primary, #0f1111);
  /* `inline-block` and not the `inline-flex` it inherits from `.mkt-crumbs li`:
   * `text-overflow` only applies to a block container, so on a flex container
   * it is silently inert and the crumb clips mid-word with no ellipsis --
   * which reads as a rendering fault rather than as shortened text. */
  display: inline-block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 22ch;
  vertical-align: bottom;
}

/* `gap` is a flex property, and the rule above takes this one crumb out of
 * flex layout, so its separator loses the space every other crumb gets from
 * `.mkt-crumbs li` and renders as "/Upholstered". */
.mkt-crumbs [aria-current="page"]::before {
  margin-right: var(--spacing-2xs, 4px);
}

/* The heading and the cart link share one row. `align-items: flex-start`
 * rather than `center` because a supplier title can wrap to three lines and
 * a cart link floating in the vertical middle of it reads as unrelated to
 * the page; pinned to the first line it reads as part of the header. */
.mkt-head-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--spacing-sm, 12px);
  min-width: 0;
}

/* `flex: 0 0 auto` so the long-title case squeezes the heading, which wraps,
 * and never the link, which cannot. */
.mkt-cart-link {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  padding: var(--spacing-2xs, 4px) var(--spacing-sm, 12px);
  border: 1px solid var(--mkt-line-strong);
  border-radius: 999px;
  color: var(--text-primary, #0f1111);
  text-decoration: none;
  font-size: var(--font-size-xs, 12px);
  font-weight: 700;
  white-space: nowrap;
}

/* Mint on the stroke, the deeper green on the label. That split is the app's:
 * `accent.brandOnLight` is defined for a rule drawn on a white card and is
 * only 2.25:1 as type, while `status.success` is 5.11:1 and is what the native
 * file reaches for whenever a green has to be *read*. Every `[data-mkt-added]`
 * rule below follows the same pairing. */
.mkt-cart-link:hover {
  border-color: var(--mkt-accent);
  color: var(--store-status-success);
}

.mkt-cart-link:focus-visible {
  outline: 2px solid var(--border-focus, var(--mkt-accent-2));
  outline-offset: 2px;
}

/* `min-width` with centred text keeps a one-digit and a two-digit count the
 * same width, so the link does not change size as items are added. */
.mkt-cart-count {
  min-width: 1.6em;
  padding: 0 var(--spacing-2xs, 4px);
  border-radius: 999px;
  background: var(--mkt-accent);
  color: var(--store-cta-text);
  text-align: center;
  font-variant-numeric: tabular-nums;
  font-weight: 900;
}

/* `[hidden]` on an element this file gives `display: inline-flex`-adjacent
 * layout to would otherwise lose to the class selector, so it is restated.
 * The count ships hidden for an empty cart and the script unhides it. */
.mkt-cart-count[hidden] {
  display: none;
}

.mkt-title {
  margin: 0;
  font-size: var(--font-size-2xl, 28px);
  line-height: 1.15;
  font-weight: 900;
  letter-spacing: -0.01em;
  color: var(--text-primary, #0f1111);
  /* Supplier titles in this catalogue run to ~160 characters. They are
   * reproduced verbatim (they are the product's real name), so the
   * heading has to wrap gracefully rather than be rewritten. `display_title`
   * moves the tail into `.mkt-title-qualifier` where the seller's own
   * punctuation allows it, but most titles have none and still land here
   * whole. */
  overflow-wrap: anywhere;
}

/* The rest of a title the seller had already broken with a comma. Quieter than
 * the heading because it is a qualifier, and it must not read as a second
 * heading -- that was the problem it exists to solve. */
.mkt-title-qualifier {
  margin: 4px 0 0;
  font-size: var(--font-size-sm, 14px);
  line-height: 1.4;
  font-weight: 500;
  color: var(--mkt-muted);
  overflow-wrap: anywhere;
}

.mkt-subtitle {
  margin: 0;
  color: var(--mkt-muted);
  font-size: var(--font-size-sm, 14px);
  line-height: 1.5;
}

.mkt-count {
  font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------------- *
 * 3. Category navigation
 * ---------------------------------------------------------------- */

.mkt-cats {
  display: grid;
  gap: var(--spacing-xs, 8px);
  min-width: 0;
}

.mkt-chiprow {
  display: flex;
  gap: var(--spacing-xs, 8px);
  margin: 0;
  padding: 0 0 var(--spacing-2xs, 4px);
  list-style: none;
  overflow-x: auto;
  scrollbar-width: thin;
  /* Horizontal scroll must not become a vertical clip: the chips carry a
   * focus ring that would otherwise be cut off at the top edge. */
  scroll-padding: var(--spacing-2xs, 4px);
  -webkit-overflow-scrolling: touch;
}

.mkt-chiprow::-webkit-scrollbar {
  height: 6px;
}

.mkt-chiprow::-webkit-scrollbar-thumb {
  background: var(--mkt-line);
  border-radius: var(--radius-pill, 999px);
}

.mkt-chip {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  min-height: 36px;
  padding: var(--spacing-2xs, 4px) var(--spacing-sm, 12px);
  border: 1px solid var(--mkt-line);
  border-radius: var(--radius-pill, 999px);
  /* `marketplaceLight.chip`: white plate, primary text, hairline. */
  background: var(--store-bg-card);
  color: var(--store-text-primary);
  font-size: var(--font-size-sm, 14px);
  font-weight: 700;
  text-decoration: none;
  white-space: nowrap;
  transition: background var(--motion-fast, 88ms) var(--motion-ease, ease),
    border-color var(--motion-fast, 88ms) var(--motion-ease, ease);
}

.mkt-chip:hover {
  background: var(--store-select-fill);
  border-color: var(--mkt-line-strong);
}

/* `marketplaceLight.chip.activeBg` / `.activeText` — a filled near-black
 * plate with white text, not a tinted wash. This is the one place the app's
 * chip rail states a stronger opinion than the dark web storefront did: a
 * selected department is the same near-black as the FEATURED badge and the
 * app's own header, so selection reads as a solid object rather than as a
 * slightly brighter version of the thing beside it. */
.mkt-chip[aria-current="true"],
.mkt-chip.is-active {
  background: var(--store-badge-featured-bg);
  border-color: var(--store-badge-featured-bg);
  color: var(--store-text-on-dark);
}

/* The count beside a department name. It is a real row count from the
 * catalogue, so it is safe to show; it is de-emphasised because it is
 * secondary to the name. */
.mkt-chip-count {
  font-size: var(--font-size-xs, 12px);
  font-weight: 700;
  color: var(--mkt-muted);
  font-variant-numeric: tabular-nums;
}

/* The active chip is a near-black plate now, so the count on it takes the
 * on-dark text. Inheriting `--text-primary` here would have painted #0f1111
 * onto #0b0b0c and silently deleted the number. */
.mkt-chip[aria-current="true"] .mkt-chip-count,
.mkt-chip.is-active .mkt-chip-count {
  color: var(--store-text-on-dark-muted);
  opacity: 1;
}

/* Second-level (section) chips sit visually subordinate to departments. */
.mkt-chiprow.is-sub .mkt-chip {
  min-height: 32px;
  font-size: var(--font-size-xs, 12px);
  font-weight: 700;
  background: var(--store-wash-faint);
}

/* A selected *section* deliberately does not repeat the department's
 * near-black: the two rows are a hierarchy, and painting both selections
 * the same colour flattens it. The deeper CTA green fills that role while
 * staying inside the black/white/green vocabulary the business surfaces are
 * locked to. Its text is the CTA's own paired text colour, which the native
 * file keeps travelling with the fill precisely so contrast stays correct. */
.mkt-chiprow.is-sub .mkt-chip[aria-current="true"],
.mkt-chiprow.is-sub .mkt-chip.is-active {
  background: var(--store-accent-on-light);
  border-color: var(--store-accent-on-light);
  color: var(--store-cta-text);
}

.mkt-chiprow.is-sub .mkt-chip[aria-current="true"] .mkt-chip-count,
.mkt-chiprow.is-sub .mkt-chip.is-active .mkt-chip-count {
  color: var(--store-cta-text);
  opacity: 0.75;
}

/* ---------------------------------------------------------------- *
 * 4. Toolbar — search, sort, applied filters
 * ---------------------------------------------------------------- */

.mkt-toolbar {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--spacing-xs, 8px);
  align-items: center;
  min-width: 0;
}

.mkt-search {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--spacing-xs, 8px);
  min-width: 0;
}

/* `input` is styled by the shell (full width, 10px padding, #081323).
 * These overrides align it to the token grid and give it a pill shape so
 * it reads as search rather than as a generic form field. */
.mkt-search input[type="search"],
.mkt-search input[type="text"] {
  min-height: var(--touch-target-min, 48px);
  padding: var(--spacing-xs, 8px) var(--spacing-md, 16px);
  border: 1px solid var(--mkt-line);
  border-radius: var(--radius-pill, 999px);
  background: var(--surface-primary, #eaeded);
  color: var(--text-primary, #0f1111);
  font-size: var(--font-size-sm, 14px);
}

.mkt-search input::placeholder {
  color: var(--mkt-muted);
  opacity: 0.85;
}

.mkt-sort {
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-xs, 8px);
  min-width: 0;
}

.mkt-sort select {
  width: auto;
  min-width: 11rem;
  min-height: var(--touch-target-min, 48px);
  padding: var(--spacing-xs, 8px) var(--spacing-sm, 12px);
  border: 1px solid var(--mkt-line);
  border-radius: var(--radius-xs, 8px);
  background: var(--surface-raised, #ffffff);
  color: var(--text-primary, #0f1111);
  font-size: var(--font-size-sm, 14px);
  font-weight: 700;
}

/* Applied-filter pills. Each carries its own "remove" link whose href is
 * the same URL minus that one parameter, so filters stay URL-addressable
 * and the back button keeps working without any JavaScript. */
.mkt-applied {
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-xs, 8px);
  align-items: center;
  min-width: 0;
}

.mkt-applied-label {
  font-size: var(--font-size-xs, 12px);
  color: var(--mkt-muted);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.mkt-tag {
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  max-width: 100%;
  min-height: 32px;
  padding: var(--spacing-2xs, 4px) var(--spacing-xs, 8px);
  /* An applied filter is a selection, so it wears `storeLight.select` —
   * the wash and border the native file already uses to say "this one is
   * picked", rather than the purple it used to borrow from the social
   * palette. */
  border: 1px solid var(--store-select-border);
  border-radius: var(--radius-pill, 999px);
  background: var(--store-select-fill);
  color: var(--store-text-primary);
  font-size: var(--font-size-xs, 12px);
  font-weight: 700;
  text-decoration: none;
}

.mkt-tag-x {
  display: inline-grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-pill, 999px);
  background: var(--store-wash);
  font-size: 12px;
  line-height: 1;
}

.mkt-tag:hover .mkt-tag-x {
  background: var(--store-wash-strong);
}

/* ---------------------------------------------------------------- *
 * 5. Media box
 *
 * This is the highest-risk component in the storefront: the catalogue is
 * supplier-imported, so image aspect ratios are arbitrary and unknown at
 * render time. Three rules make that safe:
 *
 *   1. The *box* owns the aspect ratio (`aspect-ratio` on the wrapper),
 *      so the grid never reflows when an image loads. No CLS.
 *   2. The *image* is `object-fit: contain`, so nothing is ever stretched
 *      and nothing is ever cropped through the subject. A tall product
 *      letterboxes against the plate instead of losing its top and
 *      bottom.
 *   3. A skeleton lives behind the image and a failure state in front of
 *      it, so every one of the three states (pending, loaded, broken) is
 *      a designed state.
 * ---------------------------------------------------------------- */

.mkt-media {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  border-radius: var(--pulse-radius-sm, 12px);
  background: var(--mkt-media-plate);
  /* The dark storefront lit the plate with a soft cyan radial so a
   * contained image did not float in a flat void. The app does the
   * opposite and is right to: `cardImage` and `galleryImage` are a flat
   * `storeLight.bg.skeleton`. On a light plate a tint is not atmosphere,
   * it is a colour cast that lands on white-background product photography
   * — which is most of a supplier-imported catalogue — and reads as the
   * photo being the wrong colour rather than the plate being lit. Flat. */
  background-image: none;
  isolation: isolate;
}

.mkt-media.is-wide {
  aspect-ratio: 4 / 3;
}

.mkt-media.is-tall {
  aspect-ratio: 3 / 4;
}

.mkt-media img {
  position: relative;
  z-index: 2;
  display: block;
  width: 100%;
  height: 100%;
  /* `contain` is the default everywhere. See the note above: this
   * catalogue's images are not art-directed, so cropping is destructive.
   * `.mkt-media.is-cover` exists for the rare surface (a hero strip)
   * where a full-bleed fill is genuinely wanted. */
  object-fit: contain;
  object-position: center;
  /* Stay transparent and unframed so `--mkt-media-plate` on the wrapper is
   * what shows through, rather than the image drawing a second frame inside
   * the one the wrapper already has.
   *
   * These are belt-and-braces, not a fix for a known rule: neither
   * `pulse_social_shell` nor any of the eight stylesheets it links paints
   * `img` at all. They matter while an image is still loading or has failed,
   * when the UA's own broken-image chrome would otherwise sit on the plate. */
  border: 0;
  background: transparent;
  max-height: none;
  transition: opacity var(--motion-base, 168ms) var(--motion-ease, ease),
    transform var(--motion-slow, 288ms) var(--motion-ease, ease);
}

.mkt-media.is-cover img {
  object-fit: cover;
}

/* The skeleton. It sits at z-index 1 (behind the image) and is simply
 * covered up when the image paints, which means no JavaScript is needed
 * to remove it and it cannot get stuck on screen. */
.mkt-media::before {
  content: "";
  position: absolute;
  z-index: 1;
  inset: 0;
  background: linear-gradient(
    100deg,
    var(--store-wash-faint) 0%,
    var(--store-wash) 42%,
    var(--store-wash-faint) 68%
  );
  background-size: 220% 100%;
  animation: mkt-shimmer 1.5s var(--motion-ease, ease) infinite;
}

@keyframes mkt-shimmer {
  from {
    background-position: 120% 0;
  }
  to {
    background-position: -60% 0;
  }
}

/* An image that has not decoded yet is transparent, so the skeleton
 * shows. `data-mkt-loaded` is set by the tiny inline loader script on
 * `load`; the CSS `:not()` keeps the pre-script state correct too. */
.mkt-media img:not([data-mkt-loaded]) {
  opacity: 0;
}

.mkt-media img[data-mkt-loaded] {
  opacity: 1;
}

/* Failure state. Rendered in the markup always, displayed only when the
 * wrapper is marked broken, so a dead CDN URL produces a labelled plate
 * instead of a browser's broken-image glyph. */
.mkt-media-fallback {
  position: absolute;
  z-index: 3;
  inset: 0;
  display: none;
  place-items: center;
  padding: var(--spacing-sm, 12px);
  text-align: center;
  background: var(--store-bg-skeleton);
  color: var(--store-text-muted);
  font-size: var(--font-size-xs, 12px);
  line-height: 1.4;
}

.mkt-media.is-broken .mkt-media-fallback {
  display: grid;
}

.mkt-media.is-broken img,
.mkt-media.is-broken::before {
  display: none;
}

/* A listing with no image at all. Not an error — just an absence, so it
 * gets the same plate without the alarming copy. */
.mkt-media.is-empty::before {
  animation: none;
  background: none;
}

.mkt-media.is-empty .mkt-media-fallback {
  display: grid;
}

/* ---------------------------------------------------------------- *
 * 6. Product grid and cards
 * ---------------------------------------------------------------- */

.mkt-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: var(--mkt-gap);
  margin: 0;
  padding: 0;
  list-style: none;
  align-items: stretch;
  min-width: 0;
}

.mkt-grid > li {
  display: flex;
  min-width: 0;
}

/*
 * Card geometry is uniform by construction, not by luck:
 *   - the card is a flex column at 100% height of its grid row,
 *   - the media box has a fixed aspect ratio,
 *   - the title is clamped to exactly two lines and reserves that height
 *     whether it needs it or not (`min-height` on the clamp),
 *   - the text block packs from the top, so every optional field sits at
 *     a position decided only by the fields *above* it.
 * So a one-word title and a 160-character title produce cards of
 * identical height with their prices on the same baseline.
 *
 * That last rule replaced a `margin-top:auto` on the price, which pinned
 * the price *and everything after it* to the bottom of the card. That
 * aligns prices only while every card in the row carries the same tail
 * fields: a listing with no stock line has a tail one line shorter, so
 * its price rendered 42px below its neighbours'. Packing from the top
 * makes the price baseline depend on the title alone, and the title is
 * already a fixed height -- so alignment holds no matter which optional
 * fields any individual listing happens to have data for. The leftover
 * space collects below the last populated line, where it is invisible.
 */
.mkt-card {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  min-width: 0;
  gap: var(--spacing-xs, 8px);
  padding: var(--spacing-xs, 8px);
  border: 1px solid var(--mkt-line);
  border-radius: var(--mkt-card-radius);
  background: var(--surface-raised, #ffffff);
  box-shadow: var(--elevation-card, 0 1px 2px rgba(15, 17, 17, 0.06));
  transition: transform var(--motion-base, 168ms) var(--motion-ease, ease),
    border-color var(--motion-base, 168ms) var(--motion-ease, ease),
    box-shadow var(--motion-base, 168ms) var(--motion-ease, ease);
}

.mkt-card:hover {
  transform: translateY(-2px);
  border-color: var(--mkt-line-strong);
  box-shadow: var(--elevation-overlay, 0 6px 16px rgba(15, 17, 17, 0.12));
}

.mkt-card:hover .mkt-media img[data-mkt-loaded] {
  transform: scale(1.025);
}

/* The whole card is one link. Rather than nesting interactive elements
 * (which breaks keyboard order and screen-reader output), a single
 * anchor covers the card via a stretched ::after, and the visible text
 * stays in normal flow. Anything that must stay separately clickable
 * (the seller link, Save) is raised above it with z-index. */
.mkt-card-link {
  position: static;
  color: inherit;
  text-decoration: none;
  min-width: 0;
}

.mkt-card-link::after {
  content: "";
  position: absolute;
  z-index: 1;
  inset: 0;
  border-radius: var(--mkt-card-radius);
}

.mkt-card-link:focus-visible::after {
  outline: 2px solid var(--border-focus, var(--mkt-accent-2));
  outline-offset: 2px;
}

.mkt-card-body {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  gap: var(--spacing-2xs, 4px);
  min-width: 0;
  padding: 0 var(--spacing-2xs, 4px) var(--spacing-2xs, 4px);

  /* The vertical space one price line occupies, named once so the price
   * rule below and the reserve that stands in for a missing price cannot
   * drift apart. `--mkt-price-line` is the box itself, and it includes the
   * padding above the price because the sheet is `box-sizing: border-box`;
   * `--mkt-price-slot` adds the margin separating it from the metadata and
   * one `gap`, because a missing price also removes one flex item and so
   * one of this column's gaps -- leave that term out and the reserve comes
   * up 4px short, which is worth stating since 4px is small enough to read
   * as a rounding artefact rather than as a missing term.
   * The responsive block overrides `--mkt-price-line` alone. */
  --mkt-price-line: calc(1.2 * var(--font-size-lg, 18px) + var(--spacing-2xs, 4px));
  --mkt-price-slot: calc(var(--mkt-price-line) + var(--spacing-sm, 12px)
    + var(--spacing-2xs, 4px));
}

/* A listing with no price renders no price element -- correct, but it would
 * otherwise pull its seller name up onto the row where the priced cards
 * beside it show a price. Handing the slot's height to the title keeps the
 * metadata rows aligned across a grid row without printing an empty
 * paragraph to do it. */
.mkt-card-body.is-unpriced .mkt-card-title {
  margin-bottom: var(--mkt-price-slot);
}

.mkt-card-title {
  margin: 0;
  /* Two lines, always. `-webkit-line-clamp` is the only cross-browser
   * mechanism for this and is supported in every engine PulseSoc
   * targets; `min-height` reserves the second line so cards whose titles
   * fit on one line do not sit a row shorter. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: calc(2 * 1.35em);
  font-size: var(--font-size-sm, 14px);
  font-weight: 700;
  line-height: 1.35;
  color: var(--text-primary, #0f1111);
  overflow-wrap: anywhere;
}

/* Price is the loudest thing in the card after the photograph: it sits
 * below the title, above every piece of secondary metadata, at a larger
 * size and a heavier weight than anything else in the text block. */
/* Both of these are `<p>`, so an unset margin is not zero -- it is the
 * user agent's 1em, which is a different number of pixels for the 18px
 * price than for the 12px stock line. Stating them makes the rhythm
 * between title, price and metadata a decision rather than a side effect
 * of each line's font size. */
.mkt-card-price {
  margin: 0 0 var(--spacing-sm, 12px);
  padding-top: var(--spacing-2xs, 4px);
  min-height: var(--mkt-price-line);
  font-size: var(--font-size-lg, 18px);
  font-weight: 900;
  line-height: 1.2;
  color: var(--text-primary, #0f1111);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}

/* A price range ("$15.92 – $51.74") is longer than a single price and
 * must not wrap mid-range. It is also set two steps smaller, which makes
 * its line box shorter -- so without the `min-height` above, a card
 * showing a range would pull its own metadata 2px up relative to the
 * single-price cards beside it. The reserved height absorbs the
 * difference so the type size stays a typographic decision and not a
 * layout one. */
.mkt-card-price.is-range {
  font-size: var(--font-size-md, 16px);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.mkt-card-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  min-width: 0;
  font-size: var(--font-size-xs, 12px);
  color: var(--mkt-muted);
  line-height: 1.35;
}

.mkt-card-seller {
  position: relative;
  z-index: 2; /* above the card-wide link */
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  max-width: 100%;
  color: var(--mkt-muted);
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.mkt-card-seller:hover {
  color: var(--text-primary, #0f1111);
  text-decoration: underline;
}

.mkt-seller-avatar {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  border-radius: var(--radius-pill, 999px);
  object-fit: cover;
  border: 1px solid var(--mkt-line);
  background: var(--mkt-media-plate);
  display: grid;
  place-items: center;
  font-size: 10px;
  font-weight: 900;
  color: var(--text-primary, #0f1111);
  overflow: hidden;
}

/* Stock line. Only rendered when the data actually says something; there
 * is no "In stock" default anywhere in the template.
 *
 * All three states are `status.*`, not the brand accent. That is the app's
 * vocabulary: `storeLight.status.success` is commented "In stock, store open,
 * positive trend", and `.is-low` / `.is-out` below were already reading
 * `status.warning` / `status.neutral` -- the base state was the odd one out.
 *
 * It mattered more than a naming slip. The base state read `--mkt-accent`
 * (`storeLight.accent.brandOnLight`, #22c48d), which the native file defines
 * for "the active tab underline ... drawn on a white card" -- a 2px rule, not
 * text. As a 12px bold label on `bg.card` it measured 2.25:1, below the 4.5:1
 * floor and the worst contrast on the discovery page. `status.success` is the
 * app's answer and measures 5.11:1 on the same card. A mint that reads fine as
 * a stroke does not survive being reused as type; that is the general shape of
 * every bug this light inversion surfaced. */
.mkt-card-stock {
  margin: var(--spacing-xs, 8px) 0 0;
  font-size: var(--font-size-xs, 12px);
  font-weight: 700;
  color: var(--store-status-success);
}

.mkt-card-stock.is-low {
  color: var(--store-status-warning);
}

.mkt-card-stock.is-out {
  color: var(--mkt-muted);
}

/* The add-to-cart row. `margin-top: auto` inside `.mkt-card-body`'s flex
 * column pins it to the bottom of every card regardless of how many lines
 * the title above it wrapped to, so the buttons sit on one horizontal line
 * across the grid -- the same "uniform by construction" rule the rest of
 * this section follows, and not a fixed height a two-line title overflows. */
.mkt-card-actions {
  margin-top: auto;
  padding-top: var(--spacing-xs, 8px);
  display: flex;
  min-width: 0;
}

/* Raised above the card-wide link's stretched ::after, exactly as
 * `.mkt-card-seller` is and for the same reason: this button sits inside the
 * area that anchor covers, so without the raise every click on it would
 * navigate to the product page and the cart would never be posted to. */
.mkt-add {
  position: relative;
  z-index: 2;
  flex: 1 1 auto;
  min-width: 0;
  padding: var(--spacing-xs, 8px) var(--spacing-sm, 12px);
  /* The app fills this one. `MarketplaceScreen.tsx` styles `cardCta` as
   * `backgroundColor: storeLight.accent.brandOnLight` with
   * `cardCtaText: { color: storeLight.cta.text }` — a solid green button on
   * every grid card, not the outlined secondary this used to be. Copying
   * the app means copying that emphasis: adding to cart from the grid is a
   * primary action there. */
  border: 1px solid transparent;
  border-radius: var(--radius-sm, 10px);
  background: var(--store-accent-on-light);
  color: var(--store-cta-text);
  font: inherit;
  font-size: var(--font-size-xs, 12px);
  font-weight: 700;
  line-height: 1.2;
  cursor: pointer;
  transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
}

.mkt-add:hover {
  background: var(--store-text-link);
  color: var(--store-text-on-dark);
}

.mkt-add:focus-visible {
  outline: 2px solid var(--border-focus, var(--mkt-accent-2));
  outline-offset: 2px;
}

/* In-flight. The script owns the label and the disabled attribute, so a
 * double-tap cannot post the same listing twice while the first is open. */
.mkt-add[disabled] {
  opacity: 0.6;
  cursor: default;
}

.mkt-add[data-mkt-added] {
  border-color: var(--mkt-accent);
  color: var(--store-status-success);
}

/* The variant-choice link, which is an `<a>` wearing `.mkt-add` so the action
 * row keeps one height whether the card's action is a quick-add or a trip to
 * the picker. Everything here is either something an anchor does not inherit
 * from the button rules above (centring, text-decoration) or a deliberate
 * difference: the fill is dropped so it reads as the quieter of the two
 * actions. It must not look like a quick-add, because it is not one -- it
 * navigates, and a buyer who expected a cart line would be surprised twice. */
.mkt-add.is-choose {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  text-align: center;
  text-decoration: none;
}

.mkt-add.is-choose:hover {
  text-decoration: none;
}

/* ---------------------------------------------------------------- *
 * 7. Badges
 *
 * Every badge in this file corresponds to a column in the database.
 * There is no styling here for a rating, a review count, a "sold" count,
 * a shipping promise or a discount percentage, because the catalogue
 * holds no data that would justify rendering one.
 * ---------------------------------------------------------------- */

.mkt-badges {
  position: absolute;
  z-index: 2;
  top: var(--spacing-2xs, 4px);
  left: var(--spacing-2xs, 4px);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--spacing-2xs, 4px);
  max-width: calc(100% - var(--spacing-md, 16px));
  pointer-events: none;
}

.mkt-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  max-width: 100%;
  padding: 3px var(--spacing-xs, 8px);
  border-radius: var(--radius-pill, 999px);
  font-size: var(--font-size-xs, 12px);
  font-weight: 900;
  line-height: 1.4;
  letter-spacing: 0.02em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  backdrop-filter: blur(8px);
  /* White hairline and white text, deliberately NOT the light theme's
   * `--border-subtle` / `--text-primary`. A badge is drawn on the scrim
   * below, not on the card, so its contrast partner is near-black. Piping
   * the light tokens through here would have put #0f1111 text on an
   * #0b0b0c plate. */
  border: 1px solid rgba(255, 255, 255, 0.14);
  color: var(--store-text-on-dark);

  /* Two layers, and the split matters. `.mkt-badges` is absolutely
   * positioned over `.mkt-media`, so in production a badge's real backdrop
   * is a seller-supplied photograph — which may be a white studio shot.
   * `background-color` is the dark scrim that makes near-white badge text
   * legible over *any* photograph; `background-image` is the per-variant
   * hue. Variants set only `--mkt-badge-tint`, so a variant cannot take
   * the scrim away. They previously used the `background` shorthand, which
   * replaced both layers and left `is-new` and `is-digital` as a 20% wash
   * over the photo with no scrim at all. The local QA fixture renders
   * placeholder plates rather than photographs, so this was invisible to
   * measurement here and would have shipped. */
  background-color: var(--store-badge-scrim);
  background-image: linear-gradient(var(--mkt-badge-tint, transparent), var(--mkt-badge-tint, transparent));
}

/* `featured` — the listings table's own boolean.
 *
 * `marketplaceLight.badge.featuredBg` / `.featuredText`: an opaque
 * near-black plate carrying brand-green text. The native file documents
 * arriving here from the reference design's yellow-on-navy, and notes that
 * the plate is now "the same near-black as `storeLight.bg.headerFrom`, so a
 * FEATURED badge and the header it sits under are one colour rather than
 * two dark blues." Opaque, so it needs no scrim. */
.mkt-badge.is-featured {
  background-color: var(--store-badge-featured-bg);
  background-image: none;
  color: var(--store-badge-featured-text);
  border-color: transparent;
}

/* `new` — derived from a real `published_at`, never from an import
 * timestamp, and suppressed at page level when it would apply to most of
 * the page (which makes it a fact about the importer, not the product). */
/* `marketplaceLight.badge.newBg` / `.newText`: solid success green, white
 * text. Opaque like `is-featured`, so the scrim is dropped the same way. */
.mkt-badge.is-new {
  background-color: var(--store-badge-new-bg);
  background-image: none;
  color: var(--store-badge-new-text);
  border-color: transparent;
}

/* `digital` / `product_type` — a delivery fact, not a marketing claim.
 *
 * The app has no digital badge to copy, so this is the one badge whose
 * treatment is derived rather than transcribed. It was a #9f7cff purple
 * borrowed from the social palette, which the black/white/green lock on
 * business surfaces rules out. It keeps the scrim (so it stays a tint over
 * a photograph, matching the "delivery fact, not marketing claim" framing
 * above) and takes the brand mint, which distinguishes it from both the
 * opaque near-black `is-featured` and the opaque deep-green `is-new`. */
.mkt-badge.is-digital {
  --mkt-badge-tint: rgba(46, 230, 168, 0.26);
  border-color: rgba(46, 230, 168, 0.5);
}

/* `is-quiet` is the *fallback*, not a decoration: `_BADGE_CLASS.get(key,
 * "is-quiet")` in `marketplace_storefront.py` hands it to every badge key
 * the map does not know, so it is the variant most likely to appear and the
 * one least likely to be looked at.
 *
 * It keeps the scrim and only softens the text, which means its contrast
 * partner is the near-black plate — NOT the card. `--mkt-muted` is the
 * light theme's #565959 and measures 2.67:1 there, so using it would have
 * dimmed the fallback badge to the edge of illegibility on every card. This
 * is the same trap `.mkt-chip-count` hit: inside a badge, "muted" has to
 * mean muted-on-dark. `storeLight.text.onDarkMuted` is that value. */
.mkt-badge.is-quiet {
  color: var(--store-text-on-dark-muted);
}

/* ---------------------------------------------------------------- *
 * 8. States — loading, empty, error
 * ---------------------------------------------------------------- */

.mkt-state {
  display: grid;
  gap: var(--spacing-xs, 8px);
  justify-items: center;
  text-align: center;
  padding: var(--spacing-xl, 32px) var(--spacing-md, 16px);
  border: 1px dashed var(--mkt-line);
  border-radius: var(--mkt-card-radius);
  background: var(--store-wash-faint);
}

.mkt-state h2,
.mkt-state p {
  margin: 0;
  max-width: 46ch;
}

.mkt-state h2 {
  font-size: var(--font-size-lg, 18px);
  color: var(--text-primary, #0f1111);
}

.mkt-state p {
  color: var(--mkt-muted);
  font-size: var(--font-size-sm, 14px);
  line-height: 1.55;
}

/* An error is not an empty result. It gets its own treatment so a failed
 * query can never be mistaken for "there are no products". */
.mkt-state.is-error {
  border-style: solid;
  border-color: var(--store-status-error);
  background: var(--store-error-wash);
}

.mkt-state-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-xs, 8px);
  justify-content: center;
}

/* Skeleton cards, used for the no-JS-free server render of a slow page
 * and reusable by any future async grid. */
.mkt-skeleton {
  border: 1px solid var(--mkt-line);
  border-radius: var(--mkt-card-radius);
  padding: var(--spacing-xs, 8px);
  display: grid;
  gap: var(--spacing-xs, 8px);
}

.mkt-skeleton-line {
  height: 12px;
  border-radius: var(--radius-pill, 999px);
  background: var(--store-bg-skeleton);
}

.mkt-skeleton-line.is-short {
  width: 45%;
}

/* ---------------------------------------------------------------- *
 * 9. Pagination
 * ---------------------------------------------------------------- */

.mkt-pager {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--spacing-xs, 8px);
  padding-top: var(--spacing-xs, 8px);
}

.mkt-pager a,
.mkt-pager span {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--touch-target-min, 48px);
  min-height: var(--touch-target-min, 48px);
  padding: 0 var(--spacing-sm, 12px);
  border: 1px solid var(--mkt-line);
  border-radius: var(--radius-xs, 8px);
  background: var(--store-bg-card);
  color: var(--store-text-primary);
  font-weight: 800;
  text-decoration: none;
  font-variant-numeric: tabular-nums;
}

.mkt-pager a:hover {
  border-color: var(--mkt-line-strong);
  background: var(--store-select-fill);
}

.mkt-pager [aria-current="page"] {
  background: linear-gradient(135deg, var(--mkt-accent), var(--mkt-accent-2));
  color: var(--text-on-action, #04231a);
  border-color: transparent;
}

.mkt-pager .is-disabled {
  opacity: 0.4;
  cursor: default;
}

.mkt-pager .mkt-pager-gap {
  border: 0;
  background: none;
  min-width: 1.5rem;
  color: var(--mkt-muted);
}

/* ---------------------------------------------------------------- *
 * 10. Product detail — layout
 * ---------------------------------------------------------------- */

.mkt-detail {
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
  gap: var(--spacing-lg, 24px);
  align-items: start;
  min-width: 0;
}

.mkt-detail > * {
  min-width: 0;
}

/* The gallery stays put while the long specification list scrolls. */
.mkt-detail-media {
  position: sticky;
  top: var(--spacing-md, 16px);
  display: grid;
  gap: var(--spacing-xs, 8px);
}

.mkt-detail-info {
  display: grid;
  gap: var(--spacing-md, 16px);
}

.mkt-panel {
  display: grid;
  gap: var(--spacing-sm, 12px);
  padding: var(--spacing-md, 16px);
  border: 1px solid var(--mkt-line);
  border-radius: var(--mkt-card-radius);
  background: var(--surface-raised, #ffffff);
  min-width: 0;
}

/* The variant form is a child of the panel rather than the panel itself, so
 * the buyer actions can sit beside it without being inside a GET form they
 * do not submit. It inherits the panel's rhythm and contributes no chrome. */
.mkt-panel-form {
  display: grid;
  gap: var(--spacing-sm, 12px);
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
}

.mkt-panel > h2,
.mkt-panel > h3 {
  margin: 0;
  font-size: var(--font-size-md, 16px);
  font-weight: 900;
  color: var(--text-primary, #0f1111);
}

/* ---------------------------------------------------------------- *
 * 11. Product detail — gallery
 *
 * Note on this catalogue: every listing currently resolves to exactly one
 * image, so the thumbnail rail is hidden by default and only appears
 * when the server actually emits more than one item. The rail is not
 * "hidden with CSS on a single image" — the markup does not render it —
 * but `:only-child` handling below keeps the layout correct if a
 * single-item rail ever does reach the page.
 * ---------------------------------------------------------------- */

.mkt-gallery {
  display: grid;
  gap: var(--spacing-xs, 8px);
  min-width: 0;
}

.mkt-gallery-stage .mkt-media {
  aspect-ratio: 1 / 1;
  border-radius: var(--mkt-card-radius);
  border: 1px solid var(--mkt-line);
}

/* Only one slide is in the DOM flow at a time; the others are hidden
 * from both the viewport and the accessibility tree. */
.mkt-gallery-slide[hidden] {
  display: none;
}

.mkt-gallery-rail {
  display: flex;
  gap: var(--spacing-xs, 8px);
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  scrollbar-width: thin;
}

.mkt-gallery-rail:has(> li:only-child) {
  display: none;
}

/* Each thumb is an `<a>` wrapped in an `<li>`, so the rail's flex items are
 * the list items and any `flex` declared on the anchor is inert. The anchor
 * also stayed inline, where `width` does not apply at all -- the thumbs took
 * their size from the image inside and their border painted on the line box,
 * leaving a stray vertical rule taller than the picture beside each one. */
.mkt-gallery-rail > li {
  flex: 0 0 auto;
  display: flex;
  min-width: 0;
}

.mkt-gallery-thumb {
  display: block;
  width: 64px;
  padding: 0;
  border: 1px solid var(--mkt-line);
  border-radius: var(--pulse-radius-sm, 12px);
  background: var(--mkt-media-plate);
  cursor: pointer;
  overflow: hidden;
  /* Undo the shell's `button{min-height:44px;padding:10px 12px;...}`. */
  min-height: 0;
  transition: border-color var(--motion-fast, 88ms) var(--motion-ease, ease);
}

.mkt-gallery-thumb .mkt-media {
  border-radius: 0;
}

.mkt-gallery-thumb[aria-selected="true"],
.mkt-gallery-thumb.is-active {
  border-color: var(--mkt-accent);
  box-shadow: 0 0 0 1px var(--mkt-accent);
}

.mkt-gallery-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--spacing-xs, 8px);
}

/* These two are icon buttons and must stay square. `pulse_mobile_system.css`
 * gives every `button` on the site `width: 100%` below 768px, which is right
 * for a stacked action but wrong for a control sitting in a row: the two
 * arrows grew to 172px each, took the whole 370px between them, and left the
 * counter 11px to render "1 / 3" in -- so it wrapped to three lines.
 *
 * Fixed here rather than by narrowing the global rule, which legacy pages
 * still depend on. Two class names outrank a bare `button`, so no
 * `!important` is needed to win. */
.mkt-gallery-nav .mkt-ghost {
  flex: 0 0 auto;
  width: var(--touch-target-min, 48px);
  padding: 0;
}

.mkt-gallery-counter {
  font-size: var(--font-size-xs, 12px);
  color: var(--mkt-muted);
  font-variant-numeric: tabular-nums;
  /* Never the item that gives way. The arrows are fixed above, so there is
   * nothing left to squeeze it, but the counter losing its line is the visible
   * failure and it should not depend on a sibling's rule to stay on one. */
  flex: 0 0 auto;
  white-space: nowrap;
}

/* ---------------------------------------------------------------- *
 * 12. Product detail — price and purchase
 * ---------------------------------------------------------------- */

.mkt-price {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--spacing-xs, 8px);
  min-width: 0;
}

.mkt-price-value {
  font-size: var(--font-size-3xl, 32px);
  font-weight: 900;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--text-primary, #0f1111);
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}

.mkt-price-note {
  font-size: var(--font-size-xs, 12px);
  color: var(--mkt-muted);
}

/* The PDP's stock line. Same token as `.mkt-card-stock` and for the same
 * reason -- see the note there. The dot is `currentColor`, so this single
 * declaration moves the text and the dot together and they cannot drift. */
.mkt-stock {
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  font-size: var(--font-size-sm, 14px);
  font-weight: 700;
  color: var(--store-status-success);
}

.mkt-stock::before {
  content: "";
  width: 8px;
  height: 8px;
  border-radius: var(--radius-pill, 999px);
  background: currentColor;
  box-shadow: 0 0 10px currentColor;
}

.mkt-stock.is-out {
  color: var(--mkt-muted);
}

.mkt-stock.is-out::before {
  box-shadow: none;
}

/* Option groups. Each group is a fieldset so the group name is announced
 * with every option, and each option is a real radio input so keyboard
 * users get native arrow-key traversal inside the group. */
.mkt-options {
  display: grid;
  gap: var(--spacing-sm, 12px);
}

.mkt-optiongroup {
  display: grid;
  gap: var(--spacing-xs, 8px);
  margin: 0;
  padding: 0;
  border: 0;
  min-width: 0;
}

.mkt-optiongroup legend {
  padding: 0;
  font-size: var(--font-size-xs, 12px);
  font-weight: 900;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--mkt-muted);
}

.mkt-optiongroup-chosen {
  text-transform: none;
  letter-spacing: 0;
  font-weight: 700;
  color: var(--text-primary, #0f1111);
}

.mkt-optionlist {
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-xs, 8px);
  min-width: 0;
}

.mkt-option {
  position: relative;
  display: inline-flex;
}

/* The radio itself is hidden visually but remains focusable, so the
 * visible label can carry the focus ring via `:focus-visible + label`. */
.mkt-option input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  margin: 0;
  padding: 0;
  border: 0;
}

.mkt-option label {
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-2xs, 4px);
  min-height: 40px;
  padding: var(--spacing-2xs, 4px) var(--spacing-sm, 12px);
  border: 1px solid var(--mkt-line);
  border-radius: var(--radius-xs, 8px);
  background: var(--store-bg-card);
  color: var(--store-text-primary);
  font-size: var(--font-size-sm, 14px);
  font-weight: 700;
  cursor: pointer;
  transition: border-color var(--motion-fast, 88ms) var(--motion-ease, ease),
    background var(--motion-fast, 88ms) var(--motion-ease, ease);
}

.mkt-option label:hover {
  border-color: var(--mkt-line-strong);
}

.mkt-option input:checked + label {
  border-color: var(--mkt-accent);
  background: var(--store-select-fill);
}

.mkt-option input:focus-visible + label {
  outline: 2px solid var(--border-focus, var(--mkt-accent-2));
  outline-offset: 2px;
}

/* An option combination with no purchasable variant behind it is shown
 * but struck through, so the shopper can see it exists and is gone
 * rather than watching controls silently disappear. */
.mkt-option input:disabled + label {
  opacity: 0.45;
  cursor: not-allowed;
  text-decoration: line-through;
}

/* Colour swatches. The hex comes from a name→hex table in
 * `services/marketplace_web.py`; a value with no known hex renders as a
 * plain text option instead of a misleading grey dot. */
.mkt-option.is-color label {
  padding-left: var(--spacing-xs, 8px);
}

.mkt-swatch {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-pill, 999px);
  border: 1px solid rgba(15, 17, 17, 0.4);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55);
}

/* Buy actions. `.mkt-cta` is deliberately its own class rather than the
 * shell's `.primary` so the storefront's primary action cannot be
 * restyled by a change to the feed's buttons. */
.mkt-actions {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--spacing-xs, 8px);
  align-items: stretch;
}

.mkt-cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--spacing-xs, 8px);
  min-height: var(--touch-target-min, 48px);
  padding: var(--spacing-xs, 8px) var(--spacing-lg, 24px);
  border: 0;
  border-radius: var(--radius-xs, 8px);
  /* `STORE_CTA_PULSESOC`: mint into the deeper green, in that order. The
   * dark storefront ran the pair the other way round (accent → accent-2);
   * the native constant is `{ from: "#2EE6A8", to: "#22C48D" }` and its
   * `text` travels with it, which is the property the native file calls out
   * as the reason contrast stays correct if the pair is ever swapped. */
  background: linear-gradient(135deg, var(--store-cta-from), var(--store-cta-to));
  color: var(--store-cta-text);
  font-size: var(--font-size-md, 16px);
  font-weight: 900;
  text-decoration: none;
  cursor: pointer;
  box-shadow: var(--elevation-glow, 0 1px 2px rgba(15, 17, 17, 0.1));
  transition: filter var(--motion-fast, 88ms) var(--motion-ease, ease);
}

.mkt-cta:hover {
  filter: brightness(1.06);
}

.mkt-cta[disabled],
.mkt-cta[aria-disabled="true"] {
  background: var(--store-bg-skeleton);
  color: var(--mkt-muted);
  box-shadow: none;
  cursor: not-allowed;
}

/* Add to cart on the product page. Its own row rather than a cell in
 * `.mkt-actions`, which is a two-column grid built for one primary plus one
 * ghost; a second primary in there would either halve Message seller or push
 * Save onto a line by itself. The panel is a gapped grid, so this needs no
 * margin. */
.mkt-actions-buy {
  display: grid;
  min-width: 0;
}

/* Settled state, after the script has posted and the server has confirmed.
 * The fill is dropped rather than kept, because the label now reports a fact
 * ("In cart") instead of offering an action, and leaving it as the loudest
 * thing on the page sends the buyer to press it again. The mint survives as
 * the border, and the label takes `status.success`, so the row keeps its
 * height and the state is still legible without colour alone carrying it --
 * the word changes too. The two greens are not a mismatch: `select.fill` is a
 * pale mint, and the mint accent over it is 2.02:1, which is a stroke's
 * contrast and not a word's. See `.mkt-cart-link:hover`.
 *
 * The outline is an inset shadow and not a `border`, because `.mkt-cta` is
 * `border: 0` and adding one here would grow the button by 2px at the moment
 * it settles, nudging everything below it. */
.mkt-cta[data-mkt-added] {
  background: var(--store-select-fill);
  box-shadow: inset 0 0 0 1px var(--mkt-accent);
  color: var(--store-status-success);
}

.mkt-ghost {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--spacing-2xs, 4px);
  min-width: var(--touch-target-min, 48px);
  min-height: var(--touch-target-min, 48px);
  padding: var(--spacing-xs, 8px) var(--spacing-sm, 12px);
  border: 1px solid var(--mkt-line);
  border-radius: var(--radius-xs, 8px);
  background: var(--store-bg-card);
  color: var(--text-primary, #0f1111);
  font-size: var(--font-size-sm, 14px);
  font-weight: 800;
  text-decoration: none;
  cursor: pointer;
}

.mkt-ghost:hover {
  border-color: var(--mkt-line-strong);
  background: var(--store-select-fill);
}

.mkt-ghost[aria-pressed="true"] {
  border-color: var(--mkt-accent);
  background: var(--store-select-fill);
}

.mkt-ghost.is-danger:hover {
  border-color: var(--store-status-error);
  background: var(--store-error-wash);
}

.mkt-secondary-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-xs, 8px);
}

/* Owner/admin-only affordance. The visual treatment marks it as a
 * privileged control; the actual gate is server-side. */
.mkt-owner-tools {
  display: grid;
  gap: var(--spacing-xs, 8px);
  padding: var(--spacing-sm, 12px);
  border: 1px solid var(--store-border-secondary);
  border-radius: var(--radius-xs, 8px);
  background: var(--store-wash-soft);
}

.mkt-owner-tools > p {
  margin: 0;
  font-size: var(--font-size-xs, 12px);
  color: var(--mkt-muted);
}

/* ---------------------------------------------------------------- *
 * 13. Product detail — specification table and description
 *
 * The supplier feed packs specs into an undelimited `Key: Value` run.
 * `parse_description()` recovers them into pairs, which render as a real
 * definition list — not as a wall of prose and not as a fake table.
 * ---------------------------------------------------------------- */

.mkt-specs {
  display: grid;
  grid-template-columns: minmax(6rem, auto) minmax(0, 1fr);
  gap: 0;
  margin: 0;
  min-width: 0;
  font-size: var(--font-size-sm, 14px);
}

.mkt-specs > dt,
.mkt-specs > dd {
  margin: 0;
  padding: var(--spacing-xs, 8px) var(--spacing-sm, 12px);
  border-top: 1px solid var(--mkt-line);
  min-width: 0;
  overflow-wrap: anywhere;
  line-height: 1.45;
}

.mkt-specs > dt {
  color: var(--mkt-muted);
  font-weight: 700;
  padding-left: 0;
}

.mkt-specs > dd {
  color: var(--text-primary, #0f1111);
  padding-right: 0;
}

.mkt-specs > dt:first-of-type,
.mkt-specs > dt:first-of-type + dd {
  border-top: 0;
}

.mkt-prose {
  margin: 0;
  color: var(--text-primary, #0f1111);
  font-size: var(--font-size-sm, 14px);
  line-height: 1.6;
  overflow-wrap: anywhere;
}

.mkt-prose + .mkt-prose {
  margin-top: var(--spacing-xs, 8px);
}

/* Supplier notes (shipping caveats, measurement tolerances, machine
 * translation artefacts). Real content, but demoted behind a disclosure
 * so it cannot crowd out the canonical product facts. */
.mkt-notes {
  border: 1px solid var(--mkt-line);
  border-radius: var(--radius-xs, 8px);
  background: var(--store-wash-faint);
  padding: 0;
}

.mkt-notes > summary {
  padding: var(--spacing-xs, 8px) var(--spacing-sm, 12px);
  cursor: pointer;
  font-size: var(--font-size-sm, 14px);
  font-weight: 800;
  color: var(--text-primary, #0f1111);
  list-style-position: inside;
}

.mkt-notes[open] > summary {
  border-bottom: 1px solid var(--mkt-line);
}

.mkt-notes-body {
  display: grid;
  gap: var(--spacing-xs, 8px);
  padding: var(--spacing-sm, 12px);
  margin: 0;
  color: var(--mkt-muted);
  font-size: var(--font-size-xs, 12px);
  line-height: 1.55;
  list-style: none;
}

.mkt-notes-body li {
  overflow-wrap: anywhere;
}

/* A long description is clipped with a real "read more" disclosure
 * rather than a fixed-height fade that hides content from search
 * engines and from keyboard users. */
.mkt-clamp {
  display: -webkit-box;
  -webkit-line-clamp: 8;
  line-clamp: 8;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ---------------------------------------------------------------- *
 * 14. Seller card
 * ---------------------------------------------------------------- */

.mkt-seller {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--spacing-sm, 12px);
  align-items: center;
  min-width: 0;
}

.mkt-seller-figure {
  width: 48px;
  height: 48px;
  margin: 0;
  border-radius: var(--pulse-radius-sm, 12px);
  overflow: hidden;
  background: var(--mkt-media-plate);
  border: 1px solid var(--mkt-line);
  display: grid;
  place-items: center;
  font-weight: 900;
  color: var(--text-primary, #0f1111);
}

.mkt-seller-figure img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border: 0;
  background: transparent;
}

.mkt-seller-name {
  display: block;
  font-weight: 900;
  color: var(--text-primary, #0f1111);
  text-decoration: none;
  overflow-wrap: anywhere;
}

.mkt-seller-name:hover {
  text-decoration: underline;
}

.mkt-seller-meta {
  margin: 0;
  font-size: var(--font-size-xs, 12px);
  color: var(--mkt-muted);
  line-height: 1.45;
}

/* Fulfilment facts. Rendered only when the listing carries a real
 * `delivery_type` / `refund_policy`; there is no default copy. */
.mkt-facts {
  display: grid;
  gap: var(--spacing-xs, 8px);
  margin: 0;
  font-size: var(--font-size-sm, 14px);
}

.mkt-facts > div {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--spacing-xs, 8px);
  align-items: start;
}

.mkt-facts dt {
  margin: 0;
  color: var(--mkt-muted);
  font-weight: 700;
}

.mkt-facts dd {
  margin: 0;
  color: var(--text-primary, #0f1111);
  overflow-wrap: anywhere;
}

/* ---------------------------------------------------------------- *
 * 15. Related products
 * ---------------------------------------------------------------- */

.mkt-related {
  display: grid;
  gap: var(--mkt-gap);
}

.mkt-related > h2 {
  margin: 0;
  font-size: var(--font-size-xl, 22px);
  font-weight: 900;
  letter-spacing: -0.01em;
  color: var(--text-primary, #0f1111);
}

.mkt-related .mkt-grid {
  grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
}

/* ---------------------------------------------------------------- *
 * 16. App CTA and merchant panel
 *
 * Both are emitted by the route rather than the renderer: the app CTA
 * because only the app is allowed to know the deep-link registry, and
 * the merchant panel because only a request knows whether the viewer
 * has a seller row. They still have to look like they belong to the
 * storefront, so their rules live here with everything else.
 * ---------------------------------------------------------------- */

/* "Prefer the app?" — an addition to the page, never a gate across it.
 * Styled as a quiet aside rather than a banner: an interstitial that
 * shouts is the interstitial visitors learn to dismiss without reading,
 * and the web page it sits on is a complete experience on its own. */
.mkt-appcta {
  border: 1px solid var(--mkt-line);
  border-radius: var(--mkt-card-radius);
  /* Distinguishes this from a product card without competing with one.
   * `marketplaceLight.savedSearch` is the app's equivalent gesture — a
   * banner that is not a product — and it is a vertical wash from #EEF7F4
   * into white rather than a tinted accent gradient. Same idea, and it
   * sheds the purple the business surfaces do not allow. */
  background: linear-gradient(180deg, var(--store-banner-from), var(--store-bg-card));
  padding: var(--spacing-sm, 12px) var(--spacing-md, 16px);
}

.mkt-appcta p {
  margin: 0;
  color: var(--mkt-muted);
  font-size: var(--font-size-sm, 14px);
  line-height: 1.5;
}

.mkt-appcta a {
  color: var(--text-primary, #0f1111);
  font-weight: 800;
  /* Underlined, not a button. This is one of several ways off the page
   * and should not out-weigh the product's own primary action. */
  text-decoration: underline;
  text-decoration-color: var(--mkt-line-strong);
  text-underline-offset: 3px;
  /* 44px of touch target without 44px of visual weight. */
  display: inline-block;
  padding: 6px 2px;
}

.mkt-appcta a:hover {
  text-decoration-color: var(--mkt-accent-2);
}

/* The seller-facing panel: apply, await review, or create a product.
 * Only ever rendered to a signed-in viewer, so it sits below the
 * catalogue rather than above it — a shopper's first screenful belongs
 * to products. */
.mkt-merchant {
  border: 1px solid var(--mkt-line);
  border-radius: var(--mkt-card-radius);
  background: var(--surface-raised, #ffffff);
  padding: var(--spacing-md, 16px);
  display: grid;
  gap: var(--spacing-sm, 12px);
}

.mkt-merchant h2 {
  margin: 0;
  font-size: var(--font-size-lg, 18px);
  font-weight: 900;
  letter-spacing: -0.01em;
}

.mkt-merchant p {
  margin: 0;
  color: var(--mkt-muted);
  font-size: var(--font-size-sm, 14px);
  line-height: 1.55;
}

/* `.mkt-actions` is shared with the product page's button row; it is
 * declared in section 12 and needs nothing extra here beyond allowing
 * the buttons to fill the width on a narrow screen, which the
 * responsive section below handles for every `.mkt-actions`. */

/* ---------------------------------------------------------------- *
 * 17. Responsive
 *
 * Breakpoints match the shell's existing ones (900px, 1024px) so the
 * storefront changes shape at the same moments as the chrome around it.
 * ---------------------------------------------------------------- */

@media (max-width: 1024px) {
  .mkt-detail {
    grid-template-columns: minmax(0, 1fr);
  }

  .mkt-detail-media {
    position: static;
  }

  .mkt-gallery-stage .mkt-media {
    /* A single column gives the gallery the full width; a 1:1 box would
     * then push the price below the fold on a tablet. */
    aspect-ratio: 4 / 3;
  }
}

@media (max-width: 900px) {
  .mkt {
    gap: var(--spacing-md, 16px);
  }

  .mkt-toolbar {
    grid-template-columns: minmax(0, 1fr);
  }

  .mkt-sort {
    justify-content: space-between;
  }

  .mkt-sort select {
    flex: 1 1 auto;
    min-width: 0;
  }

  .mkt-title {
    font-size: var(--font-size-xl, 22px);
  }

  .mkt-price-value {
    font-size: var(--font-size-2xl, 28px);
  }

  .mkt-actions {
    grid-template-columns: minmax(0, 1fr);
  }

  .mkt-specs {
    /* Two columns at 360px wide turns every value into a two-character
     * ribbon. Stack instead. */
    grid-template-columns: minmax(0, 1fr);
  }

  .mkt-specs > dt {
    padding-bottom: 0;
    border-top: 1px solid var(--mkt-line);
  }

  .mkt-specs > dd {
    padding-top: var(--spacing-2xs, 4px);
    border-top: 0;
  }

  .mkt-specs > dt:first-of-type + dd {
    border-top: 0;
  }
}

@media (max-width: 560px) {
  .mkt-grid {
    /* Two columns survive down to ~360px because the card's text block
     * clamps rather than wraps. One column would waste a phone screen on
     * a browsing surface. */
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--spacing-xs, 8px);
  }

  .mkt-related .mkt-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  /* Tracks the smaller phone price size through both consumers at once --
   * the price's own reserved height and the stand-in reserve an unpriced
   * card gets -- so neither carries 2px of desktop slack on a phone. */
  .mkt-card-body {
    --mkt-price-line: calc(1.2 * var(--font-size-md, 16px) + var(--spacing-2xs, 4px));
  }

  .mkt-card-price {
    font-size: var(--font-size-md, 16px);
  }

  .mkt-card-price.is-range {
    font-size: var(--font-size-sm, 14px);
  }

  .mkt-badges {
    max-width: calc(100% - var(--spacing-xs, 8px));
  }
}

/* Restoring this storefront's type scale from the shell's phone display
 * scale.
 *
 * `pulse_mobile_system.css` loads on every PulseSoc page and, under
 * `max-width: 768px`, sets bare-element sizes with `!important`:
 *
 *     h1 { font-size: clamp(30px, 11vw, 46px) !important; ... }
 *     h2 { font-size: clamp(21px, 7vw, 31px) !important; line-height: 1.08 !important; }
 *     h3 { font-size: clamp(18px, 5.6vw, 24px) !important; }
 *
 * That is the shell's display scale for page and section headings, and it
 * is right for them -- this page's own `<h1>` takes it deliberately, which
 * is why no `h1` appears below. It is wrong for headings that are components
 * rather than document structure. Measured on a 390px viewport before this
 * block existed:
 *
 *     .mkt-card-title    14px intended ->  21.84px   (louder than the price)
 *     .mkt-panel > h2    16px intended ->  27.3px
 *     .mkt-merchant h2   18px intended ->  27.3px
 *     .mkt-related > h2  22px intended ->  27.3px
 *     .mkt-state h2      18px intended ->  27.3px
 *
 * The card title is the serious one: at 21.84px bold it outweighs the 16px
 * price directly beneath it, inverting the single hierarchy the card exists
 * to state. The rest merely flatten the page into one size.
 *
 * `!important` is the only thing that beats `!important`; between two
 * important author declarations specificity decides, and every selector here
 * carries a class (0,1,0 or better) against the shell's bare element
 * (0,0,1). The query matches the shell's so the override exists only at the
 * widths where it is contested.
 *
 * If the shell ever drops those rules this block becomes inert rather than
 * wrong: each declaration restates the value the component rule already
 * sets, so removing the `!important` layer changes nothing. */
@media (max-width: 768px) {
  .mkt-card-title {
    font-size: var(--font-size-sm, 14px) !important;
  }

  .mkt-panel > h2,
  .mkt-panel > h3 {
    font-size: var(--font-size-md, 16px) !important;
  }

  .mkt-merchant h2,
  .mkt-state h2 {
    font-size: var(--font-size-lg, 18px) !important;
  }

  .mkt-related > h2 {
    font-size: var(--font-size-xl, 22px) !important;
  }
}

/* ---------------------------------------------------------------- *
 * 18. Preference queries
 * ---------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .mkt *,
  .mkt *::before,
  .mkt *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }

  /* The shimmer is decorative; with motion reduced it becomes a flat
   * plate rather than a single frozen gradient frame. */
  .mkt-media::before {
    background: var(--store-wash-soft);
  }

  .mkt-card:hover {
    transform: none;
  }

  .mkt-card:hover .mkt-media img[data-mkt-loaded] {
    transform: none;
  }
}

/* High contrast. This block used to push the hairlines towards white and the
 * muted text towards a pale cyan, which is the correct direction on a dark
 * page and exactly backwards on a light one — it would have washed the
 * borders out of existence for the readers who asked for more contrast. On
 * #eaeded the hairlines darken towards the text colour and the muted grey
 * goes to full primary. */
@media (prefers-contrast: more) {
  .mkt {
    --mkt-line: rgba(15, 17, 17, 0.55);
    --mkt-line-strong: var(--store-text-primary);
    --mkt-muted: var(--store-text-primary);
  }

  /* The badge is the exception here too: it is drawn on a photograph over a
   * near-black scrim, so "more contrast" means a fully opaque plate and a
   * white hairline, not a darker line. */
  .mkt-badge {
    background-color: var(--store-badge-featured-bg);
    border-color: #ffffff;
  }
}

/* Print: a product page is a thing people print or save as PDF to
 * compare. Drop the chrome, keep the facts. */
@media print {
  .mkt-badges,
  .mkt-gallery-rail,
  .mkt-gallery-nav,
  .mkt-actions,
  .mkt-secondary-actions,
  .mkt-owner-tools,
  .mkt-pager,
  .mkt-chiprow {
    display: none !important;
  }

  .mkt-detail {
    grid-template-columns: minmax(0, 1fr);
  }

  .mkt-notes[open],
  .mkt-notes {
    border: 0;
  }

  .mkt-clamp {
    display: block;
    overflow: visible;
  }
}
