.cm-frontend { max-width: 960px; margin: 0 auto; }
.cm-frontend-search { display: flex; gap: 8px; margin-bottom: 24px; }
/* font-size below 16px triggers iOS Safari's auto-zoom-on-focus — see theme.css's
   .chinimall-hero-input for the full explanation; same fix applies to every real text input. */
.cm-frontend-search input[type="text"] { flex: 1; padding: 10px 12px; font-size: 16px; border: 1px solid #ccc; border-radius: 4px; }
.cm-frontend-search button { padding: 10px 20px; font-size: 15px; background: #1e1e1e; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.cm-frontend-search button:hover { background: #333; }

.cm-notice { padding: 12px 16px; border-radius: 4px; margin-bottom: 20px; }
.cm-notice-error { background: #fdecea; color: #7a2119; border: 1px solid #f5c2c0; }
/* Used in a few places already (e.g. Kill Switch messages) but never actually styled before —
   real gap found while adding the Quote Accept flow below, which needed both. */
.cm-notice-warning { background: #fff8e6; color: #7a5b00; border: 1px solid #f5dfa3; }
.cm-notice-success { background: #eaf7ed; color: #1e5b2c; border: 1px solid #b9e3c1; }

/* Background Add to Cart toast (2026-09-11) — a fixed overlay confirmation so the visitor never
   leaves the product page just to add an item; sits above the mobile sticky buy bar (z-index
   higher than .cm-buy-bar's 55) and above the bottom nav. Reuses .cm-notice-error/-success for
   the same color language as every other message on the site. */
.cm-toast {
    position: fixed;
    left: 50%;
    bottom: calc(90px + env(safe-area-inset-bottom));
    transform: translate(-50%, 12px);
    max-width: calc(100% - 32px);
    padding: 10px 18px;
    border-radius: 6px;
    font-size: 14px;
    font-weight: 600;
    text-align: center;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s ease, transform 0.2s ease;
    z-index: 200;
}
.cm-toast.is-visible { opacity: 1; transform: translate(-50%, 0); }
@media (min-width: 901px) {
    .cm-toast { bottom: 24px; }
}

/* Submit Request page (CHINIMALL_Requests::shortcode()) — owner spec, 2026-09-17: ONE box
   holding every line (was: each line its own separately-bordered card) — a plain rule between
   rows instead, and a Products/Estimated Total footer as the box's own closing section rather
   than a floating line below it. */
/* Tightened top spacing 2026-09-17 (the page's own WP title used to duplicate this heading right
   above it — removed in page.php instead of fixed here, see its own comment — so this no longer
   needs as much clearance above it). */
.chinimall-request-heading { font-size: 18px; font-weight: 700; margin: 0 0 12px; }
.chinimall-request-summary {
    display: flex;
    flex-direction: column;
    border: 1px solid #eee;
    border-radius: 8px;
    padding: 0 16px;
    margin-bottom: 24px;
}
.chinimall-request-summary-row {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 14px 0;
    border-bottom: 1px solid #eee;
}
.chinimall-request-summary-image { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #f5f5f5; }
/* Shown instead of an <img> with an empty/missing src — a bare empty src renders the browser's
   own broken-image icon, which read as a real bug (owner feedback, 2026-09-17). */
.chinimall-request-summary-image-placeholder { background: #f0f0f0; border: 1px solid #e8e8e8; }
.chinimall-request-summary-body { flex: 1 1 auto; min-width: 0; }
.chinimall-request-summary-meta { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.chinimall-request-summary-meta .cm-source-badge { position: static; }
.chinimall-request-summary-id { font-size: 11px; color: #999; }
/* Owner report, 2026-09-17: a title/spec line still waiting on its auto-translate AJAX call (see
   initAutoTitleTranslation()/initVariantTranslation() in frontend.js) used to sit there in plain
   raw Chinese with no visual cue, then suddenly swap to English a beat later — read as a glitch/
   inconsistency rather than a deliberate loading state. Dimmed here (shared by both Cart's
   .cm-cart-item-title/-variant and Review Request's own matching classes) for as long as the
   `data-cm-translate="1"` marker is present; the same JS that swaps the text also removes the
   marker right after, so this clears the instant a real answer (translated or, on failure, the
   original text) actually lands. */
.cm-auto-translate-title[data-cm-translate="1"],
.cm-auto-translate-variant[data-cm-translate="1"] {
    opacity: 0.5;
    transition: opacity 0.2s ease;
}
/* Weight/Dimensions (owner request, 2026-09-17) — same tone/size as the Product ID line above. */
.chinimall-request-summary-dims { font-size: 11px; color: #999; margin-top: 2px; }
/* Smaller/lighter + a real 2-line clamp with ellipsis (owner feedback, 2026-09-17 — was too large
   and could run to an unbounded number of lines). */
.chinimall-request-summary-title {
    font-weight: 600;
    font-size: 15px;
    line-height: 1.35;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
/* Tighter and lighter (owner feedback, 2026-09-17), clamped the same way the title is so a
   product with many attributes never pushes the row taller than its neighbors. */
.chinimall-request-summary-variant {
    color: #888;
    font-size: 12px;
    margin-top: 3px;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
/* "100 × $1.80 = $180.00" — the Subtotal (after the final "=") is the only bold part; qty and
   unit price stay regular weight so the eye lands on the one number that matters here. */
.chinimall-request-summary-calc { margin-top: 6px; font-size: 13px; color: #666; }
.chinimall-request-summary-calc strong { color: #1e1e1e; font-weight: 700; }
/* Reuses the last row's own border-bottom as the one divider before this footer, instead of
   adding a second border here that would double up right next to it. Wraps (owner feedback,
   2026-09-17: the tablet-width row used to squeeze both halves and break) rather than forcing
   both halves onto one line at every width — see the breakpoints below for how each half then
   behaves at narrower widths. */
.chinimall-request-summary-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; padding: 16px 0; font-size: 14px; color: #555; }
.chinimall-request-total-amount { font-weight: 700; font-size: 16px; color: #1e1e1e; white-space: nowrap; }
@media (max-width: 900px) {
    /* Tablet: each half keeps its own line's worth of room instead of squeezing together —
       Products stays left, Total takes the rest of the row and can wrap onto its own line
       without the two ever overlapping. */
    .chinimall-request-summary-footer { row-gap: 4px; }
    .chinimall-request-total-amount { font-size: 15px; }
}
@media (max-width: 500px) {
    /* Mobile: stacked, Total's own weight brought down a step further so it doesn't dominate a
       narrow column the way the desktop-sized amount did. */
    .chinimall-request-summary-footer { flex-direction: column; align-items: flex-start; gap: 4px; }
    .chinimall-request-total-amount { font-size: 14px; }
}
/* Text + link wrapped in one <span> (a single flex item) — see the PHP's own comment for why a
   bare text run + a separate inline <a> as two direct children of a flex <label> was the actual
   cause of the link visually breaking away from its sentence. */
.chinimall-request-ack { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #555; margin-bottom: 24px; line-height: 1.5; cursor: pointer; }
/* margin-top tuned to line up with the text span's own first line (owner feedback, 2026-09-17:
   the checkbox previously sat a bit low against the first line of text). */
.chinimall-request-ack input { flex-shrink: 0; margin-top: 2px; width: 17px; height: 17px; }
.chinimall-request-ack span { flex: 1 1 auto; }
.chinimall-request-ack a { color: #1e1e1e; text-decoration: underline; }

/* My Orders (docs/UI-UX-SPEC.md §8) — one compact box per Customer Request, existing Status
   only, items collapsed into a native <details> accordion (closed by default). */
.cm-orders-list { display: flex; flex-direction: column; gap: 12px; }
.cm-order-box { border: 1px solid #eee; border-radius: 6px; padding: 14px; }
.cm-order-box-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: 13px; color: #666; }
.cm-order-number { font-weight: 600; color: #1e1e1e; }
.cm-order-status { margin-top: 6px; font-size: 14px; font-weight: 600; }
.cm-order-items { margin-top: 10px; }
.cm-order-items summary { cursor: pointer; font-size: 13px; color: #666; list-style: none; }
.cm-order-items summary::-webkit-details-marker { display: none; }
.cm-order-items summary::after { content: ' \25BE'; }
.cm-order-items[open] summary::after { content: ' \25B4'; }
.cm-order-items-list { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; }
.cm-order-item-row { display: flex; align-items: center; gap: 10px; }
.cm-order-item-row img { width: 44px; height: 44px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
.cm-order-item-body { flex: 1 1 0%; min-width: 0; }
.cm-order-item-title { font-size: 13px; line-height: 1.4; }
.cm-order-item-variant { font-size: 12px; color: #888; margin-top: 2px; }
.cm-order-item-id { font-size: 11px; color: #999; margin-top: 2px; }
.cm-order-item-qty { flex-shrink: 0; font-size: 13px; color: #666; }

/* Phase D.2 — Quote Versioning (2026-09-11): the confirmed price/qty the team locked with the
   supplier, and the Accept flow beneath it on My Orders. Pending = still needs the customer's
   own review (amber, matches .cm-notice-warning); accepted = done (green, matches .cm-notice-
   success); superseded never actually renders here (latest_for_request() only ever returns the
   newest version per line), kept for forward compatibility only. */
.cm-order-item-quote { font-size: 12px; margin-top: 4px; font-weight: 600; }
.cm-order-item-quote-pending { color: #7a5b00; }
.cm-order-item-quote-accepted { color: #1e5b2c; }
.cm-order-item-quote-superseded { color: #999; font-weight: 400; text-decoration: line-through; }
.cm-accept-quote-form { margin-top: 12px; }
.cm-accept-quote-btn { background: #1e1e1e; color: #fff; border-color: #1e1e1e; }
.cm-accept-quote-btn:hover { background: #333; }

/* Phase D.4 — Substitution suggestions (2026-09-11): "Original Item → Suggested Replacement →
   Customer Approval", per line item, on My Orders. Same amber pending-review tone as the Quote
   Accept flow above, scoped to just this one item row instead of the whole order. */
.cm-order-item-substitution { font-size: 12px; margin-top: 6px; padding: 8px; background: #fff8e6; border: 1px solid #f5dfa3; border-radius: 4px; }
.cm-substitution-response-form { margin-top: 6px; }
.cm-substitution-approve-btn { background: #1e1e1e; color: #fff; border-color: #1e1e1e; }
.cm-substitution-approve-btn:hover { background: #333; }
/* Full-width + the theme's own primary-CTA sizing (owner spec, 2026-09-17) — matches
   .cm-cart-summary-cta (the Cart page's "Review Cart" button, chinimall-theme/assets/theme.css)
   in padding/weight/radius so this reads as the same family of button, not a smaller one-off. */
/* Same box model (width/height/padding/radius) whether enabled or disabled, so the button never
   shifts size when the checkbox is toggled — only the color changes (owner feedback, 2026-09-17:
   consistent size/centering across all three viewports). */
.chinimall-request-submit-btn { display: block; width: 100%; background: #1e1e1e; color: #fff; border: none; padding: 12px 22px; border-radius: 8px; cursor: pointer; font-size: 15px; font-weight: 600; text-align: center; }
.chinimall-request-submit-btn:hover { background: #000; }
/* Gray/disabled until the Purchase Policy checkbox is checked (owner feedback, 2026-09-17) — see
   the small inline script in the PHP that toggles the `disabled` attribute this selects on. */
.chinimall-request-submit-btn:disabled { background: #ccc; color: #888; cursor: not-allowed; }
@media (max-width: 500px) {
    .chinimall-request-summary-row { flex-wrap: wrap; }
    .chinimall-request-summary-image { width: 44px; height: 44px; }
    .chinimall-request-summary-body { flex-basis: 0; min-width: 0; }
}
/* Owner feedback, 2026-09-17: on desktop the 60px thumbnail read as too small next to a taller
   ~4-line text block (title, Selected Specifications, the Qty×Price=Subtotal line) — sized up so
   the image roughly matches that text block's own height instead of leaving visible empty space
   below it. */
@media (min-width: 901px) {
    .chinimall-request-summary-image,
    .chinimall-request-summary-image-placeholder { width: 96px; height: 96px; }
}

/* minmax(0, 1fr), not plain 1fr: an implicit-`auto`-minimum grid track (which is what plain
   `1fr` really is) won't shrink below its content's intrinsic size — the main photo's own
   natural pixel width (~790px on the product actually reported broken) was forcing this
   whole track, and the page along with it, wider than a mobile viewport. minmax(0, 1fr) is
   the standard fix at the track level; min-width: 0 on the grid item (below) is the matching
   fix at the item level — both were needed, one alone wasn't enough (verified live). */
/* align-items: stretch (was start) — owner request, 2026-09-16: when the Info column (title,
   options, price) is naturally taller than the fixed-height gallery beside it, "start" left the
   gallery short with dead empty space below the thumbnails while Info kept going. stretch makes
   both grid columns match the row's real height; .cm-main-media below picks that up via
   min-height + flex-grow instead of a fixed height, so the main photo grows to fill that space
   — .cm-thumbs' own max-height cap (unchanged) means the thumbnail strip itself never grows
   past its normal N-thumbnail size, exactly as asked ("without touching the thumbnail system").
   When Info is the SHORTER column (the more common case), .cm-main-media's min-height keeps it
   at exactly the same size as before — this only ever makes the photo bigger, never smaller. */
.cm-product { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 32px; align-items: stretch; }
/* 900px, not 700px — owner's mobile redesign (2026-09-09) is meant to cover tablets too, not
   just phones; matches the .cm-buy-bar/.cm-frontend breakpoints below so all three switch
   together instead of the layout and the bar disagreeing at some in-between width. */
/* Real bug found live (2026-09-10, item 18): the 32px `gap` above is meant as the desktop
   2-column layout's own horizontal gap (Gallery | Info) — on mobile, once this collapses to
   ONE column, that same `gap` becomes a large VERTICAL row-gap between .cm-gallery and .cm-info
   too, stacking on top of .cm-id-row's own 12px margin-top (44px combined, confirmed live) even
   though .cm-tabs-wrap's separate 20px margin-top already handles its own spacing independent
   of this. Zeroed here so only each element's own intentional margin remains. */
@media (max-width: 900px) { .cm-product { grid-template-columns: minmax(0, 1fr); gap: 0; } }

/* Gallery layout, owner's mobile redesign (2026-09-09): main photo + a NARROW vertical
   thumbnail column beside it (not a horizontal strip below) — matches the reference wireframe.
   A row/flex layout, not a grid, since the thumb column's width is content-driven (a fixed
   thumbnail size) while the main photo should absorb all remaining space. */
.cm-gallery { min-width: 0; display: flex; gap: 8px; align-items: stretch; }
/* Product page spec item 1 (2026-09-11, revised): the main photo's height is a FIXED rule now,
   not an organic square (the old aspect-ratio:1/1) and not stretched to fit whatever the
   thumbnail column happens to need — exactly the opposite direction from the item 7 fix earlier
   this session. The owner's own words: "fix the image size so exactly N fixed thumbnails sit
   beside it" — so this height IS the N-thumbnail formula (must stay in sync with .cm-thumbs'
   own max-height and .cm-thumb's own 56px below); a photo beyond N scrolls inside that same
   fixed height, same as the old (pre-item-7) behavior, just without that version's half-cut
   sliver. --cm-thumb-count is set inline per-render from Theme Settings → Search & Product
   Display → "Thumbnails beside main photo" (class-chinimall-frontend.php), defaulting to 5 if
   that's ever missing — changing the count needs no code/CSS edit, just that one setting. */
/* height -> min-height (owner request, 2026-09-16): this used to be a hard fixed height, always
   exactly the N-thumbnail formula regardless of how tall the Info column next to it happened to
   be — left dead empty space under the gallery whenever Info was taller (a real product with a
   longer title/more option groups). min-height keeps the EXACT same size as before by default
   (nothing shrinks); flex:1 1 auto (unchanged) is what lets it grow past that minimum on
   desktop, where .cm-gallery is a column flex (photo on top, thumbnail strip below, see the
   min-width:901px block further down) and .cm-product's own align-items:stretch (above) hands
   .cm-gallery extra height to distribute whenever Info is the taller column — the thumbnail
   strip stays flex:0 0 auto there (unchanged, never grows), so 100% of that extra height goes
   to the photo alone, never touching "exactly N thumbnails" sizing. */
.cm-main-media { position: relative; flex: 1 1 auto; min-width: 0; min-height: calc(var(--cm-thumb-count, 5) * 56px + (var(--cm-thumb-count, 5) - 1) * 8px); border-radius: 10px; border: 1px solid #e5e5e5; background: #fafafa; overflow: hidden; }
.cm-main-image, .cm-main-video { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* Real defect found live 2026-09-17 (owner screenshot: a visible empty gray bar down the side
   of the main photo, and again along the top on mobile) — same root cause as the product-grid
   card fix earlier the same day (see that rule's own comment in this file): contain (chosen
   2026-09-09 specifically so a photo is never cropped) leaves a real, often large, letterbox gap
   whenever the photo's own aspect ratio doesn't match .cm-main-media's box — which for this box
   is common, since its width tracks the info column's own width (can be much wider than tall on
   desktop) while most product photos are closer to square. A gap of #fafafa against a photo
   that isn't itself near-white reads as a visible, undesired hole, not "graceful." Reverted to
   cover, matching the same 2026-09-17 owner decision already applied to the product-grid cards:
   the photo always fills the box edge-to-edge, at the cost of a small, proportional crop on a
   non-matching-aspect photo — no more "empty space inside the image box," on any screen size. */
.cm-main-image { object-fit: cover; }
.cm-main-video { object-fit: cover; }
.cm-main-image[hidden], .cm-main-video[hidden] { display: none; }
/* "1 / 12" position overlay — bottom-left, a dark translucent chip (matches .cm-thumb-play's
   own overlay treatment) so it reads on top of any photo without needing its own background. */
.cm-image-counter {
    position: absolute;
    left: 8px;
    bottom: 8px;
    z-index: 2;
    background: rgba(0, 0, 0, 0.6);
    color: #fff;
    font-size: 11px;
    font-variant-numeric: tabular-nums;
    padding: 3px 9px;
    border-radius: 12px;
    line-height: 1.3;
}
/* Vertical scrolling strip, ~4.5 thumbs visible at once (per the reference wireframe) — a
   fixed column width/height, not flex-wrap, so it scrolls instead of pushing the main photo
   narrower as more thumbnails are added. Lazy-loading is the plain `loading="lazy"` attribute
   on each <img> (see class-chinimall-frontend.php) — deliberately no custom JS lazy-loader on
   top of that; the browser already defers anything below the visible ~4-5 slots. */
.cm-thumbs {
    flex: 0 0 56px;
    display: flex;
    flex-direction: column;
    gap: 8px;
    /* Product page spec item 1 (2026-09-11, revised): back to a fixed cap, per explicit owner
       instruction — but an EXACT multiple of (thumb height + gap) this time, matching
       .cm-main-media's own height above exactly (same --cm-thumb-count variable), so the
       boundary always lands right on a full thumbnail's edge. A photo beyond that count scrolls
       below, same plain overflow-y:auto/hidden scrollbar as the original (pre-item-7) version —
       no partial tile ever visible, at whatever fixed count Theme Settings has configured. */
    max-height: calc(var(--cm-thumb-count, 5) * 56px + (var(--cm-thumb-count, 5) - 1) * 8px);
    overflow-y: auto;
    scrollbar-width: none;
}
.cm-thumbs::-webkit-scrollbar { display: none; }
/* Same hairline-frame treatment as .cm-main-media (owner, 2026-09-09) — a light gray ground +
   1px border so the tile reads as its own object against a white page even when the photo on
   it is itself white. object-fit: cover (2026-09-17, same fix/reasoning as .cm-main-image just
   above — contain left visible letterbox gaps on a non-matching-aspect photo, more so at this
   small 56px size). box-sizing: border-box keeps the rendered tile exactly 56x56 whether its
   border is the plain 1px or the active state's 2px, so nothing shifts size on selection. */
.cm-thumb { box-sizing: border-box; flex-shrink: 0; width: 56px; height: 56px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #e5e5e5; background: #fafafa; padding: 0; }
.cm-thumb:hover { border-color: #999; }
/* The one thumbnail matching what the main photo currently shows — set on page load (see
   class-chinimall-frontend.php, the gallery's own first entry) and kept in sync on every thumb
   click or variant-image swap (see initGallery()/initForm() in frontend.js). Monochrome, same
   black the swatches use for "selected" — no second accent color to track. */
.cm-thumb.is-active { border: 2px solid #1e1e1e; }
.cm-thumb-video { position: relative; overflow: hidden; background: none; }
.cm-thumb-video img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cm-thumb-play {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.35);
    color: #fff;
}
.cm-thumb-play svg { width: 22px; height: 22px; }

/* Desktop only (owner, 2026-09-09, revised same day): the horizontal strip UNDER the photo —
   the side-by-side vertical column above is a mobile/tablet-specific change, not a universal
   one. Same box size, same counter overlay, same lazy-loading; only the thumbnail row's own
   direction/position reverts. Revised same day: a single scrollable row (no wrap) sized to the
   photo's own width above it (~6-6.5 tiles visible at typical widths, same idea as the mobile
   column's own bounded-then-scroll behavior) instead of wrapping every thumbnail onto the page
   — a heavily-photographed product could otherwise push a long multi-row grid below the fold. */
@media (min-width: 901px) {
    .cm-gallery { flex-direction: column; }
    .cm-thumbs {
        flex: 0 0 auto;
        flex-direction: row;
        flex-wrap: nowrap;
        max-height: none;
        overflow-x: auto;
        overflow-y: visible;
        margin-top: 10px;
        /* Owner, 2026-09-09: with more than ~7 photos, a hidden scrollbar gave no visual sign
           there was anything to scroll to at all (real complaint: photo 10 of 10 was
           effectively unreachable, undiscoverable). .cm-swatches (the variant-option row) never
           hides its own scrollbar for exactly this reason — matched here instead of hiding it.
           The base rule above (mobile's own vertical column) sets scrollbar-width:none — that's
           a property this desktop block inherits unless explicitly overridden right back, which
           a later media query does NOT do automatically just by not repeating it. */
        padding-bottom: 6px;
        scrollbar-width: auto;
        /* The bottom fade mask above is a vertical (column-layout) hint — wrong here, where
           overflow is horizontal and a real scrollbar (just below) already signals it. */
        mask-image: none;
        -webkit-mask-image: none;
    }
    .cm-thumbs::-webkit-scrollbar { display: block; height: 8px; }
    .cm-thumb { width: 60px; height: 60px; }
}

/* Product ID + Favorite/Copy/Share, owner's mobile redesign (2026-09-09): one row, right
   under the gallery, ahead of the title — see class-chinimall-frontend.php's .cm-id-row. */
.cm-id-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; }
.cm-id-row .cm-product-id { margin: 0; }
.cm-id-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
/* Desktop only (owner request, 2026-09-16) — this 12px top margin is exactly what left the
   Info column's own content starting visibly lower than the gallery beside it (both are
   top-aligned grid items, so with nothing else pushing .cm-id-row down, its own margin was the
   whole gap). Zeroed only above the 901px breakpoint the rest of this desktop-vs-mobile gallery
   layout already switches at — mobile keeps its original 12px (there, .cm-id-row sits below a
   full-width image, not beside it, so there's no "gap between two columns" to align away). */
@media (min-width: 901px) {
    .cm-id-row { margin-top: 0; }
}

/* Max 2 lines, ellipsis after — owner's mobile redesign (2026-09-09). The standard
   -webkit-line-clamp incantation; every mobile/tablet browser this project targets (iOS
   Safari, Chrome/Android) supports it despite the vendor prefix. */
.cm-title {
    /* 20px/1.35 (2026-09-09, real complaint: 22px read oversized on desktop) — matches the
       real range major marketplaces use for a desktop product title (Amazon ~20px/1.3,
       AliExpress ~20-22px, Shein ~18-20px); mobile's own 15px override further down is
       unaffected. */
    font-size: 20px;
    line-height: 1.35;
    margin: 10px 0 6px;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
/* Owner request, 2026-09-16: only 6-8px separated this from the swatches row above it (that
   row's own bottom padding was the only gap at all — .cm-category itself had no top margin),
   noticeably tighter than the ~14px rhythm used everywhere else on this page. Standardized to
   match. */
.cm-category { color: #666; font-size: 13px; margin: 12px 0 14px; display: flex; align-items: center; gap: 6px; }
.cm-category a, .cm-category span { color: #666; text-decoration: none; }
.cm-category a:hover { text-decoration: underline; }
.cm-category-icon { width: 15px; height: 15px; flex-shrink: 0; color: #999; }
/* Stock deliberately never uses green/red — ChiniMall's whole design system is black/white/
   gray only (owner's rule, 2026-09-09); "out of stock" is distinguished by weight, not hue. */
.cm-price { font-size: 26px; font-weight: 600; margin: 0 0 4px; }
.cm-stock { color: #777; font-size: 13px; min-height: 18px; margin: 0; }
.cm-stock.cm-stock-out { color: #1e1e1e; font-weight: 600; }

/* 360px suits a phone's own full-width column; on desktop the info column (half the page,
   see .cm-product's 1fr/1fr grid) is far wider than that, so this same cap left a large empty
   gap to the right of Color/Length and squeezed Price+Qty+Add-to-cart into a narrow strip
   instead of using the room actually available (owner, 2026-09-09, live screenshot). Widened
   for desktop below; mobile/tablet keep 360px unchanged. */
.cm-add-form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; }
/* Owner request, 2026-09-16: 360px is a phone-width cap; tablets (single-column layout below
   .cm-product's 900px grid breakpoint, but far wider than a phone) were still stuck with it,
   leaving a large dead strip beside the option rows. Widened starting well under the tablet
   range instead of only at the desktop breakpoint above; true narrow phones (<600px) keep the
   360px cap unchanged. */
@media (min-width: 600px) {
    .cm-add-form { max-width: 100%; }
}
.cm-option-label { font-size: 13px; font-weight: 600; margin-bottom: -4px; }
/* Real bug found live (2026-09-10): under 16px, which is iOS Safari's own threshold for
   auto-zooming the whole page on focus (its heuristic for "too small to read comfortably") —
   see .chinimall-hero-input's identical fix/comment in the theme's own theme.css. */
.cm-add-form select { padding: 8px; font-size: 16px; border: 1px solid #ccc; border-radius: 4px; width: 100%; }

/* docs/UI-UX-SPEC.md §14: every attribute is its own horizontal-scroll row — many options
   must never wrap into several vertical rows. Text-only variants use this exact same
   .cm-swatches/.cm-swatch markup as image variants (see class-chinimall-frontend.php), just
   without an <img>, so both look and behave identically instead of one being a dropdown. */
.cm-swatches { display: flex; flex-wrap: nowrap; gap: 8px; overflow-x: auto; -webkit-overflow-scrolling: touch; padding: 2px 2px 6px; }
/* Explicit color here on purpose (owner, 2026-09-09) — a button's text otherwise has no color
   declared anywhere and was picking up a blue tint somewhere it shouldn't; forcing gray/black
   here guarantees this stays inside the black/white/gray system regardless of the actual cause. */
/* Real bug found live (owner report, 2026-09-16): height:75px used to sit right here, on the
   BASE rule — meant only to lock the image+text variant's height steady (see
   .cm-swatch-has-image below) but wrongly applied to EVERY swatch, including plain text-only
   ones (e.g. "Stepless dimming with remote control"), which used to be their own natural
   (shorter) height and suddenly all grew to match. Moved to .cm-swatch-has-image only — a
   text-only swatch is back to exactly its original auto-height, untouched. */
.cm-swatch { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 44px; box-sizing: border-box; padding: 6px 10px; border: 2px solid #ddd; border-radius: 6px; background: #fff; color: #333; cursor: pointer; font-size: 12px; text-align: center; white-space: nowrap; }
.cm-swatch:hover { border-color: #999; }
.cm-swatch.is-selected { border-color: #1e1e1e; background: #f5f5f5; color: #1e1e1e; }
.cm-swatch img { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
.cm-swatch span { color: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
/* Image+text swatch (owner spec, 2026-09-16): photo on the left, label beside it wrapping up
   to 3 lines — see the .cm-swatch-has-image class in class-chinimall-frontend.php's own
   comment for why. height:75px is scoped to just this variant (a text-only swatch keeps its
   own natural auto-height, unaffected) — locks this one's size steady regardless of how much
   text wraps in, since box height isn't meant to change here either. */
.cm-swatch-has-image { flex-direction: row; align-items: center; gap: 8px; width: 168px; height: 75px; box-sizing: border-box; text-align: left; white-space: normal; }
.cm-swatch-has-image span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; line-height: 1.25; text-overflow: clip; max-width: none; }

/* Quantity stepper, docs/UI-UX-SPEC.md §16: "− 1 +", not a bare number input — the middle
   number stays directly editable (a B2B buyer typing "5000" shouldn't have to tap "+" 5000
   times), see initQtyStepper() in frontend.js. */
.cm-qty-stepper { display: inline-flex; align-items: stretch; border: 1px solid #ccc; border-radius: 4px; overflow: hidden; width: fit-content; flex-shrink: 0; }
.cm-qty-btn { all: unset; box-sizing: border-box; width: 40px; min-width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: #fff; font-size: 18px; line-height: 1; cursor: pointer; }
.cm-qty-btn:hover { background: #f5f5f5; }
.cm-qty-btn:disabled { color: #ccc; cursor: not-allowed; }
/* Real bug found live (2026-09-10) — this is the exact "type a quantity, the whole page
   zooms" iOS bug reported: under 16px, Safari's own auto-zoom threshold on a focused input. */
.cm-qty-stepper .cm-qty { width: 56px; height: 40px; border: none; border-left: 1px solid #ccc; border-right: 1px solid #ccc; text-align: center; font-size: 16px; padding: 0 4px; }
.cm-add-btn { padding: 12px 20px; font-size: 15px; background: #1e1e1e; color: #fff; border: none; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
.cm-add-btn:hover:not(:disabled) { background: #000; }
.cm-add-btn:disabled { background: #ccc; cursor: not-allowed; }

/* Owner request, 2026-09-16: the border here (originally meant as a section divider) read as
   a stray extra line sitting right under .cm-category, with no other content between them to
   justify it — removed; .cm-tabs' own border-bottom already separates the tab row from its
   panels below. */
.cm-tabs-wrap { grid-column: 1 / -1; margin-top: 20px; padding-top: 16px; }
/* Sticky, docs/UI-UX-SPEC.md §18: 56px matches the global header's own fixed height
   (.chinimall-header-inner in the theme) so the tab bar docks directly under it instead of
   overlapping or leaving a gap once it starts sticking. */
.cm-tabs { display: flex; gap: 4px; border-bottom: 1px solid #ddd; margin-bottom: 16px; position: sticky; top: 56px; background: #fff; z-index: 10; }
.cm-tab { padding: 10px 18px; font-size: 14px; font-weight: 600; background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; color: #666; }
.cm-tab:hover { color: #1e1e1e; }
.cm-tab.is-active { color: #1e1e1e; border-bottom-color: #1e1e1e; }

/* Buy Bar — Price/Stock + Quantity + Add to Cart, owner's mobile redesign (2026-09-09): the
   ONLY place these three controls exist at all now (previously duplicated between the main
   body and a hidden mirror bar; see class-chinimall-frontend.php's own note on the
   `form="cm-add-to-cart-form"` association this relies on). Desktop default here is a plain
   inline row, in normal flow, right where the old inline controls used to sit; the mobile/
   tablet media query below is what actually pins it to the viewport bottom as a permanent bar. */
.cm-buy-bar {
    display: flex;
    align-items: center;
    gap: 16px;
    margin-top: 4px;
}
/* flex:1 1 auto + min-width:0 (real bug found live 2026-09-11, owner report): before a customer
   has picked every required option, the price shows as a RANGE ("0.13 USD – 6.42 USD") — long
   enough that at the base 26px .cm-price size it overflowed straight over the qty stepper/Add
   button next to it (no overflow handling existed at all), on both desktop and mobile. Without
   flex:1 the column never actually shrank to make room for its nowrap sibling. */
.cm-buy-bar-price-col { display: flex; flex-direction: column; justify-content: center; gap: 2px; flex: 1 1 auto; min-width: 0; }
/* Real bug found live (owner report, 2026-09-16, desktop): a real price RANGE ("15.24 USD –
   167.69 USD") was still getting ellipsis-truncated at 20px, same underlying issue the mobile
   breakpoint below already got fixed for — smaller font, wrapping allowed instead of a hard
   nowrap+ellipsis cutoff, so the full price is always readable on desktop too. .cm-buy-bar's
   own align-items:center (above) + this column's own justify-content:center (above) are what
   actually vertically center this against the qty stepper/button — unchanged, and only
   reliable once the text itself isn't being clipped oddly by the old nowrap/overflow:hidden
   combination this removes. */
.cm-buy-bar-price-col .cm-price { margin: 0; font-size: 15px; white-space: normal; line-height: 1.3; max-width: 100%; }
/* Real bug found live (owner report, 2026-09-16): #cm-stock starts out empty (stock text only
   ever gets filled in once a visitor picks every required option — see .cm-add-form's own
   data-stock-target) but an empty <span> still reserves a full line's worth of height, which is
   exactly why the price LOOKED off-center against the qty stepper even though the price-col
   block itself was correctly centered as a whole — the visible price text just wasn't the
   vertical middle of that block while an invisible empty line sat below it. :empty collapses
   that reserved space away until there's real stock text to show. */
.cm-buy-bar-price-col .cm-stock:empty { display: none; }
.cm-buy-bar-price-col .cm-stock { margin: 0; }

/* Mobile + tablet, per the owner's own spec (2026-09-09): a real, ALWAYS-visible fixed bar —
   no more scroll-triggered show/hide — three columns (Price+Stock | Qty | Add to Cart), same
   fixed offset above the bottom nav the old bar already used (see .chinimall-bottom-nav in the
   theme for the matching 58px). `.cm-frontend`'s own bottom padding (further down) keeps the
   tabs/description/reviews content from ever sitting underneath it. */
@media (max-width: 900px) {
    /* Real standard for a mobile product title (checked against Amazon/AliExpress/Shein's own
       mobile apps, all ~14-16px) — 22px (fine on desktop, where the title has a whole side
       column to itself) read oversized and heavy on a narrow phone screen. Owner feedback,
       2026-09-09, verified live. */
    .cm-title { font-size: 15px; font-weight: 600; line-height: 1.4; margin: 8px 0 6px; }

    /* Favorite/Copy/Share a bit smaller specifically in the ID row (owner, 2026-09-09) — still
       comfortably tappable (32px), just less dominant next to the small ID text beside them.
       The product-grid tile's own heart button (docs/UI-UX-SPEC.md §47's 40px touch target)
       is untouched; this only narrows the selector to .cm-id-actions. */
    .cm-id-actions .cm-heart-btn, .cm-id-actions .cm-copy-btn, .cm-id-actions .cm-share-btn, .cm-id-actions .cm-shop-btn { width: 32px; height: 32px; }
    .cm-id-actions .cm-heart { width: 16px; height: 16px; }
    .cm-id-actions .cm-copy-btn svg, .cm-id-actions .cm-share-btn svg, .cm-id-actions .cm-shop-btn svg { width: 16px; height: 16px; }

    /* Buy Bar, redone (owner feedback, 2026-09-09 — the first pass rendered "big and ugly" on
       a real phone): every control here is deliberately smaller than its desktop/inline
       equivalent, sized to fit one compact 56px-tall bar without any element wrapping onto a
       second line. Price + Stock stack in the left column (2 lines); Qty is the same stepper,
       just narrower; the button gets a fixed comfortable width instead of a shrink-prone
       flex-basis, which was exactly why "Add to cart" used to wrap onto two lines before. */
    .cm-buy-bar {
        position: fixed;
        left: 0;
        right: 0;
        /* Matches the bottom nav's real measured height (confirmed live via
           getBoundingClientRect() — its CSS is padding+content, not one fixed number, and
           actually renders ~66px, not the 58px this used to assume) so the two sit exactly
           flush — no gap, no overlap. */
        bottom: calc(66px + env(safe-area-inset-bottom));
        z-index: 55;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        padding: 8px 14px;
        background: #fff;
        border-top: 1px solid #ddd;
        box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
        margin-top: 0;
    }
    .cm-buy-bar-price-col { gap: 1px; min-width: 0; }
    /* Owner request, 2026-09-16: a real price RANGE ("17.06 USD – 187.67 USD") was still
       getting cut off with an ellipsis at 16px even after the flex:1/min-width:0 fix above —
       there just isn't 190+px of room left on a real phone once the qty stepper and Add to
       Cart button take their share. Smaller font, and wrapping allowed (no more nowrap here)
       instead of ellipsis-truncating, so the full price is always readable — the row's own
       align-items:center on .cm-buy-bar (unchanged) keeps this vertically centered against the
       qty stepper/button either way, whether the price sits on one line or wraps to two. */
    .cm-buy-bar-price-col .cm-price { font-size: 13px; white-space: normal; line-height: 1.3; text-overflow: clip; }
    .cm-buy-bar-price-col .cm-stock { font-size: 11px; white-space: nowrap; }
    .cm-buy-bar .cm-qty-stepper { flex-shrink: 0; }
    .cm-buy-bar .cm-qty-btn { width: 30px; min-width: 30px; height: 32px; font-size: 15px; }
    /* Real bug found live (2026-09-10) — this is the exact mobile "type a quantity, the whole
       page zooms" bug reported, and this media-query override was the actual live culprit:
       13px, well under iOS Safari's 16px auto-zoom threshold, on the one input a mobile
       visitor would actually be typing into here. font-size must stay at/above 16px — do not
       shrink it to make more room, that's exactly what reintroduces the auto-zoom bug.
       Owner report, 2026-09-16: 32px was too narrow for its OWN stated purpose — a real B2B
       order quantity like "1000" or "10000" didn't fully fit and got clipped. Widened instead
       of shrinking the font. */
    .cm-buy-bar .cm-qty-stepper .cm-qty { width: 50px; height: 32px; font-size: 16px; }
    .cm-buy-bar-btn { flex: 0 0 auto; width: 96px; padding: 9px 8px; font-size: 13px; line-height: 1.3; white-space: nowrap; text-align: center; }
    /* Reserves room for the now-permanent fixed bar (roughly its own height) so the tabs/
       description/reviews further down the page are never hidden underneath it — same idea as
       the sitewide bottom-nav's own body padding, scoped to just this page's wrapper.
       Real bug found live (2026-09-10, Image Search Results spec item 1): .cm-frontend is the
       generic wrapper class render() opens for EVERY [chinimall_lookup] invocation, not just
       the single-product view that actually has a buy bar — every page under 900px (the
       search-results page's own hidden paste-a-link box included) was silently getting this
       76px gap even with no buy bar in sight. :has() scopes it to only the instance that
       actually renders one. */
    .cm-frontend:has(#cm-buy-bar) { padding-bottom: 76px; }
}
.cm-tab-panel { display: none; }
.cm-tab-panel.is-active { display: block; }

/* Raw 1688 description HTML is arbitrary seller-authored content, not ours — confirmed a real
   product's description embeds a hardcoded `<div style="width: 790.0px">` wrapper straight
   from their own template, which nothing here was overriding: `max-width:100%` on the <img>
   alone doesn't help when its *ancestor* has a fixed inline pixel width, since the image's own
   own inline `width:100%` just resolves against that 790px box instead of the real page width,
   blowing out the whole layout on mobile. The `!important` is load-bearing, not stylistic: an
   inline `style="width:790px"` attribute out-specificities a plain class rule, so only
   `!important` can reliably cap it regardless of what the seller's markup does. overflow-x on
   the wrapper is a last-resort safety net for anything even this can't tame (e.g. a wide
   <table>), not the primary fix. */
.cm-description { max-width: 100%; overflow-x: auto; }
.cm-description * { max-width: 100% !important; }
.cm-description img { height: auto; }

.cm-preview { display: grid; grid-template-columns: 200px 1fr; gap: 24px; align-items: start; max-width: 600px; margin-bottom: 20px; }
@media (max-width: 500px) { .cm-preview { grid-template-columns: 1fr; } }
.cm-preview-image { width: 100%; height: auto; border-radius: 6px; display: block; }
.cm-preview-title { font-size: 18px; margin: 0 0 8px; }
.cm-preview-price { font-size: 20px; font-weight: 600; margin: 0 0 10px; }
.cm-preview-note { font-size: 12px; color: #888; margin: 0 0 14px; }
.cm-preview-cta { display: inline-block; padding: 10px 18px; background: #1e1e1e; color: #fff; border-radius: 4px; text-decoration: none; font-size: 14px; }
.cm-preview-cta:hover { background: #000; color: #fff; }

.cm-specs-table { width: 100%; border-collapse: collapse; }
.cm-specs-table tr:nth-child(odd) { background: #f9f9f9; }
.cm-specs-table th, .cm-specs-table td { text-align: left; padding: 8px 12px; border: 1px solid #eee; font-size: 13px; }
.cm-specs-table th { width: 30%; color: #555; font-weight: 600; }
/* On-demand translate loading feedback (owner request, 2026-09-16) — #cm-specs-panel needs
   position:relative for this to cover just the table, not the whole viewport. Grayscale only,
   same rule as everywhere else on this site — no accent color even for a "busy" state. */
#cm-specs-panel { position: relative; }
.cm-specs-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); font-size: 14px; color: #333; z-index: 2; }
.cm-specs-loading[hidden] { display: none; }
/* .cm-vendor (the old full-width "🏬 Vendor Name →" row) removed, spec item 3 (2026-09-11) —
   replaced by .cm-shop-btn, a plain icon beside the Heart button (see the styles shared with
   .cm-copy-btn/.cm-share-btn above). */
.cm-reviews-summary { color: #555; font-size: 13px; margin: 0 0 12px; }
.cm-reviews-list { list-style: none; margin: 0; padding: 0; }
.cm-review { padding: 12px 0; border-bottom: 1px solid #eee; }
.cm-review-head { font-size: 13px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.cm-review-stars { color: #e5a12a; }
.cm-review-date { color: #999; }
.cm-review-spec { color: #888; font-size: 12px; margin-top: 2px; }
.cm-review-content { margin: 6px 0 0; font-size: 14px; line-height: 1.5; }

.cm-categories { }
.cm-cat-trail { margin-bottom: 14px; font-size: 13px; color: #666; }
.cm-cat-trail a { color: #1e1e1e; text-decoration: underline; }

.cm-flyout { margin-bottom: 20px; max-width: 320px; border: 1px solid #eee; border-radius: 6px; background: #fff; }
.cm-flyout-toggle { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 12px 16px; background: #1e1e1e; color: #fff; border: none; border-radius: 6px 6px 0 0; cursor: pointer; font-size: 14px; font-weight: 600; text-align: left; }
.cm-flyout-caret { font-size: 11px; }
.cm-flyout-menu, .cm-flyout-submenu { list-style: none; margin: 0; padding: 0; }
.cm-flyout-menu { padding: 4px 0; }
.cm-flyout-item { position: relative; border-bottom: 1px solid #f3f3f3; }
.cm-flyout-item:last-child { border-bottom: none; }
.cm-flyout-label { display: block; padding: 9px 40px 9px 16px; text-decoration: none; color: #1e1e1e; font-size: 13px; }
.cm-flyout-label:hover { text-decoration: underline; }
.cm-flyout-expand { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; border: none; background: none; cursor: pointer; font-size: 10px; color: #888; border-radius: 4px; }
.cm-flyout-expand:hover { background: #f5f5f5; color: #1e1e1e; }
.cm-flyout-submenu { padding-left: 14px; background: #fafafa; }
.cm-flyout-submenu .cm-flyout-label { padding-left: 16px; font-size: 12.5px; }
.cm-flyout-empty { padding: 9px 16px; font-size: 12px; color: #999; }

/* Results count + Price sort, one row above the grid (Image Search Results spec item 6,
   2026-09-10 — was a sort-only, right-aligned bar before). Count on the left, Sort on the
   right, same gray/black-only, no-button-chrome-for-a-simple-link look as .cm-pager-link. */
.cm-results-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.cm-results-count { font-size: 13px; color: #666; }
.cm-sort-link { font-size: 13px; color: #555; text-decoration: none; }
.cm-sort-link:hover { color: #1e1e1e; text-decoration: underline; }

/* Searched-photo thumbnail + match-feedback (Image Search Results spec items 1-3, 12,
   2026-09-10) — one compact unit right under the header, before the results count/sort row:
   thumbnail on the left, "Match your image?" beside it. Back to Search Results (item 12) was
   dropped entirely — no real destination on this page. Replaces the old full-size uploaded
   image and the separate feedback row that used to sit below the grid. */
.cm-image-search-preview { display: flex; align-items: center; gap: 12px; margin: 8px 0 16px; flex-wrap: wrap; }

/* On-demand title translation trigger (2026-09-11) — same circular icon-button style as the
   product page's shop/copy/share row (.cm-shop-btn etc.), just reused here beside the search
   thumbnail/results header. Spins briefly while a translation request is in flight. */
.cm-translate-btn {
    all: unset;
    box-sizing: border-box;
    width: 32px;
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    color: #888;
    border: 1px solid #ddd;
    border-radius: 50%;
    flex-shrink: 0;
}
.cm-translate-btn svg { width: 16px; height: 16px; }
.cm-translate-btn:hover { border-color: #999; color: #1e1e1e; }
.cm-translate-btn.is-busy svg { animation: cm-translate-spin 0.9s linear infinite; }
.cm-translate-btn.is-done { border-color: #1e1e1e; background: #1e1e1e; color: #fff; cursor: default; }
@keyframes cm-translate-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.cm-image-search-thumb { flex-shrink: 0; width: 56px; height: 56px; border: 1px solid #e4e4e4; border-radius: 6px; overflow: hidden; }
.cm-image-search-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

.cm-cat-products { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; }
/* Global Product Card, docs/UI-UX-SPEC.md §6 — the outer .cm-cat-product is just a
   position:relative frame; .cm-cat-product-link is the actual clickable tile (Image, Source,
   Title, Price), with the Heart button as an absolutely-positioned sibling, not a descendant
   of the link (a <button> can't legally nest inside an <a>). */
/* Owner request, 2026-09-17: border was #eee (barely visible against a white page background)
   — darkened one step to #ddd, same tile size/frame otherwise untouched. */
.cm-cat-product { position: relative; border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.cm-cat-product-link { display: block; text-decoration: none; color: #1e1e1e; }
/* Real defect found live 2026-09-17 (owner screenshot, multiple real search-result photos):
   switched to object-fit:contain earlier the same day to stop cover from cropping into a
   product photo's own padding — but contain has its own unavoidable cost that screenshot
   exposed: whenever a photo's aspect ratio doesn't exactly match this box, contain letterboxes
   it, and that gap is only invisible if the photo's own edge pixels happen to be pure white
   (matching .cm-cat-product's own background). A photo with a colored/gradient background near
   its edge (a lavender product shot, a yellow/dark ad-style listing image — both in that
   screenshot) leaves a visible color SEAM in the gap, most obvious right at the rounded corner
   (border-radius clips the box, not the letterboxed content, so a curved sliver of the card's
   white shows through against the photo's own different-colored edge). No amount of padding/
   corner-radius tuning fixes this — it's an inherent contain-vs-non-white-edge conflict, not a
   regression of the box/corner fix from earlier the same day (that fix, verified against a
   coincidentally near-square/white test photo, is still correct on its own terms — it just
   didn't expose this separate problem). Reverted to cover: this is what the box's own size/
   corners actually need for every photo to reach every edge with zero seam, matching how 1688's
   own listing thumbnails (and every major marketplace's grid thumbnails) are shown — accepts a
   small, proportional edge crop on a non-matching-aspect photo, never the dramatic mid-subject
   crop the original 2026-09-09 complaint was about (that was a different, more extreme case).
   Box's own size (160px height, full width) is unchanged either way. */
.cm-cat-product img { width: 100%; height: 160px; object-fit: cover; display: block; }
/* Real bug found live 2026-09-17: this was a <span> (inline) with block-level children
   (.cm-cat-product-title's own -webkit-box, .cm-cat-product-price's own block) — an inline
   box's padding does not contain block-level children (they lay out against the inline's own
   containing block instead, escaping its padding entirely), so this padding was silently having
   almost no effect: the title measured ~1px from the card's edge live, not the 10px the CSS
   claimed. display:block here is the actual fix; padding still 10px each side, tightened
   top/bottom per owner request (image-to-title and price-to-bottom both felt too loose). */
.cm-cat-product .cm-cat-product-body { display: block; padding: 6px 10px 8px; }
/* Real bug found live (2026-09-10): `height: 2.8em` reserved 2 lines' worth of space
   UNCONDITIONALLY, even for a title that only needed 1 — leaving real, visible empty space
   between the title and the price below it on every tile with a short title. -webkit-line-
   clamp (already used for the exact same reason on the cart item title, class-chinimall-
   frontend.php's own .cm-cart-item-title) still caps at 2 lines but only takes the height the
   actual text needs, same fix as that one. */
.cm-cat-product .cm-cat-product-title { font-size: 12px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.cm-cat-product .cm-cat-product-price { font-size: 13px; font-weight: 600; margin-top: 4px; display: block; }

.cm-source-badge { position: absolute; top: 8px; left: 8px; background: #1e1e1e; color: #fff; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 3px; letter-spacing: 0.02em; z-index: 2; }
.cm-preview-image-wrap { position: relative; }

/* Image-search relevance feedback (Image Search Results spec item 5, 2026-09-10; repositioned
   2026-09-10 to sit under the Back link, beside the thumbnail) — one question about the whole
   result set, not per-product (see render_image_feedback() in class-chinimall-frontend.php); a
   plain outline pair, black/white/gray only (this project's one deliberate color exception is
   the red saved-heart, not this). .is-active marks whichever of the pair was last pressed. */
.cm-image-feedback { display: flex; align-items: center; gap: 8px; }
.cm-image-feedback-question { font-size: 13px; color: #555; }
.cm-feedback-btn {
    all: unset;
    box-sizing: border-box;
    width: 32px;
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    color: #888;
    border: 1px solid #ddd;
    border-radius: 6px;
}
.cm-feedback-btn svg { width: 16px; height: 16px; }
.cm-feedback-btn:hover { border-color: #999; color: #1e1e1e; }
.cm-feedback-btn.is-active { background: #1e1e1e; border-color: #1e1e1e; color: #fff; }
/* Owner correction, 2026-09-15: this was green (#1e5b2c) — ChiniMall has a strict no-color
   rule (docs/UI-UX-SPEC.md line 13: "no decorative color, gradient, colored banner, or accent
   color at all" — see also line 301's own "gray, not green" stock-line rule). Matches
   .cm-feedback-btn.is-active's own #1e1e1e ink color used elsewhere in this file. */
.cm-image-feedback-thanks { font-size: 13px; color: #1e1e1e; font-weight: 600; }

/* Search Quality Feedback reason picker (Phase F.3), shown only after a 👎 click — see
   initImageResultFeedback() in theme.js. Deliberately plain/compact: this is a quick optional
   signal, not a form the visitor is required to fill out. */
.cm-feedback-reasons { margin: 8px 0 16px; padding: 12px; border: 1px solid #eee; border-radius: 6px; background: #fafafa; max-width: 420px; }
.cm-feedback-reasons-title { margin: 0 0 8px; font-size: 13px; font-weight: 600; color: #333; }
.cm-feedback-reason { display: block; font-size: 13px; color: #444; margin-bottom: 6px; cursor: pointer; }
.cm-feedback-note { width: 100%; box-sizing: border-box; font-size: 13px; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; margin: 6px 0; resize: vertical; }
.cm-feedback-reasons-submit { padding: 6px 16px; font-size: 13px; background: #1e1e1e; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.cm-feedback-reasons-submit:hover { background: #333; }

/* .cm-shelf — a "featured row" section (Popular on ChiniMall, docs/PRINCIPLES-AND-ROADMAP.md UI
   list item 9), always exactly one row that scrolls horizontally rather than wrapping to a
   second row like a real search-results grid should. Scoped to override .cm-cat-products'
   own wrapping-grid layout only inside a .cm-shelf section — search results, category browse,
   and image search results all keep the normal wrapping grid untouched. */
.cm-shelf .cm-cat-products {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: thin;
}
.cm-shelf .cm-cat-product {
    flex: 0 0 160px;
    width: 160px;
}

/* Minimal Grid Style — Home page (owner spec, 2026-09-17, front-page.php's own
   #chinimall-rv-minimal-grid) — exact per-breakpoint column counts rather than
   .cm-cat-products' own auto-fill/minmax (which yields a variable count depending on
   available width, not the specific 2/3-4/6 the spec calls for). Mobile: 2. Tablet: 3, then 4
   as it gets wider. Desktop (1280px+): 6. Same shared .cm-cat-product tile/gap styling either
   way — only the column count changes. */
.cm-rv-minimal-grid { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 600px) {
    .cm-rv-minimal-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 900px) {
    .cm-rv-minimal-grid { grid-template-columns: repeat(4, 1fr); }
}
@media (min-width: 1280px) {
    .cm-rv-minimal-grid { grid-template-columns: repeat(6, 1fr); }
}
/* Sentinel — an invisible 1px marker at the end of the grid; initRecentlyViewedMinimalGrid()
   (theme.js) observes it via IntersectionObserver to know when to render the next batch. Never
   meant to be seen. */
#chinimall-rv-minimal-sentinel { height: 1px; }

/* Heart button — the one deliberate color exception in the whole design system (the actual
   red/outline look is .cm-heart itself, defined once in the theme alongside the rest of the
   B/W/gray design tokens). Sized to clear the ~40px real touch-target minimum (spec §38) even
   though the visible icon is smaller. Reused as-is on the product page (see .cm-title-row
   below) and in theme.js's client-rendered Recently Viewed tiles.
   Owner request, 2026-09-17 (first pass): background was translucent (0.85 alpha) — against a
   busy/colorful product photo this let enough of the photo show through at the circle's own
   edge to read as an unwanted "gray ring" around it. Tried solid white next, but the owner then
   asked for the white disc gone entirely — the black-stroke outline (unsaved) / red fill
   (saved) heart icon alone, directly on the photo, is enough on its own. No background at all
   now; kept the same 40x40 box (spec §38 touch-target minimum) so tap target size is unchanged,
   just invisible until the heart itself is drawn inside it. */
.cm-heart-btn {
    all: unset;
    box-sizing: border-box;
    position: absolute;
    top: 4px;
    right: 4px;
    z-index: 3;
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
}
.cm-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.cm-title-row .cm-title { flex: 1; min-width: 0; }
/* .cm-heart-btn's own base rule above is `position: absolute` (built for overlaying a product
   grid tile) — cancelled here the same way for its OTHER home, the ID row (owner's mobile
   redesign, 2026-09-09: moved out of the title row into .cm-id-actions alongside Copy/Share).
   Real bug caught live while testing this move: without this override the heart rendered
   completely invisible, pinned top-right of whatever positioned ancestor it found instead of
   sitting in the row. */
.cm-title-row .cm-heart-btn, .cm-id-actions .cm-heart-btn { position: static; background: none; flex-shrink: 0; }

/* Copy/Share, siblings of the Heart button in .cm-title-actions on the product page — same
   40x40 touch target as .cm-heart-btn (docs/UI-UX-SPEC.md §47 touch-target minimum), just an
   outline icon instead of a filled circle since these two don't have a "saved" state to show. */
.cm-title-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.cm-copy-btn, .cm-share-btn, .cm-shop-btn {
    all: unset;
    box-sizing: border-box;
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    color: #555;
    border-radius: 50%;
}
.cm-copy-btn:hover, .cm-share-btn:hover, .cm-shop-btn:hover { background: #f3f3f3; color: #1e1e1e; }
.cm-copy-btn svg, .cm-share-btn svg, .cm-shop-btn svg { width: 20px; height: 20px; }
.cm-copy-btn.is-copied { color: #166534; }

.cm-product-id { font-size: 12px; color: #999; margin: 0 0 12px; }

/* Real numbered pagination (Image Search Results spec, 2026-09-10 — was a bare Previous/Next
   pair before). Page numbers + arrows, current page filled solid like .cm-feedback-btn.is-
   active, same gray/black-only look as the rest of this bar-of-controls family. */
.cm-pager { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; margin: 16px 0; }
.cm-pager-link { min-width: 32px; height: 32px; padding: 0 8px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; color: #1e1e1e; background: #fff; font-size: 13px; }
.cm-pager-link:hover { border-color: #1e1e1e; }
.cm-pager-arrow { font-size: 16px; }
.cm-pager-current { background: #1e1e1e; border-color: #1e1e1e; color: #fff; }
.cm-pager-disabled { color: #ccc; border-color: #eee; }
.cm-pager-ellipsis { color: #999; padding: 0 2px; }

.cm-search { }
.cm-image-search-form { display: flex; gap: 8px; align-items: center; margin-bottom: 20px; flex-wrap: wrap; }
.cm-image-search-form input[type="file"] { flex: 1; min-width: 200px; }
.cm-image-search-form button { padding: 10px 20px; font-size: 15px; background: #1e1e1e; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.cm-image-search-preview img { max-width: 160px; max-height: 160px; border-radius: 6px; border: 1px solid #eee; object-fit: cover; }

/* [chinimall_credit] — docs/PRINCIPLES-AND-ROADMAP.md Phase B. */
.cm-credit-balance { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
.cm-credit-balance.cm-credit-low { color: #b3261e; }
.cm-credit-warning { font-size: 13px; color: #b3261e; margin: 0 0 20px; }
.cm-credit-ledger { list-style: none; margin: 0; padding: 0; border-top: 1px solid #eee; }
/* Real request 2026-09-09: date/time + real $ cost added under the label/delta row (see
   CHINIMALL_Credit::shortcode()/recent()) — li is now two stacked rows, not one. */
.cm-credit-ledger li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-bottom: 1px solid #eee; font-size: 14px; }
.cm-credit-ledger-main { display: flex; justify-content: space-between; gap: 12px; }
.cm-credit-ledger-label { color: #444; }
.cm-credit-ledger-delta { font-variant-numeric: tabular-nums; color: #1e1e1e; flex-shrink: 0; }
.cm-credit-ledger-sub { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; color: #999; }
.cm-credit-ledger-when { font-variant-numeric: tabular-nums; }
.cm-credit-ledger-cost { font-variant-numeric: tabular-nums; flex-shrink: 0; }

/* [chinimall_wallet] / [chinimall_wallet_recharge] — docs/PRINCIPLES-AND-ROADMAP.md Phase D,
   USDT (TRC20) Recharge (2026-09-11). Same visual language as [chinimall_credit] above
   (balance figure + a plain stacked ledger list) plus the Add Funds sub-flow's own form/payment
   instructions block. */
.cm-wallet-balance { font-size: 24px; font-weight: 600; margin: 0 0 20px; font-variant-numeric: tabular-nums; }
.cm-wallet-history-title { font-size: 15px; margin: 24px 0 4px; }
.cm-wallet-ledger { list-style: none; margin: 0; padding: 0; border-top: 1px solid #eee; }
.cm-wallet-ledger li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-bottom: 1px solid #eee; font-size: 14px; }
.cm-wallet-ledger-main { display: flex; justify-content: space-between; gap: 12px; }
.cm-wallet-ledger-label { color: #444; }
.cm-wallet-ledger-delta { font-variant-numeric: tabular-nums; color: #166534; flex-shrink: 0; }
.cm-wallet-ledger-when { font-size: 12px; color: #999; font-variant-numeric: tabular-nums; }

.cm-wallet-recharge { margin: 20px 0; padding: 16px; border: 1px solid #eee; border-radius: 8px; }
.cm-wallet-amount-form { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
.cm-wallet-amount-form label { display: block; font-size: 13px; color: #666; margin-bottom: 4px; }
.cm-wallet-amount-form input[type="number"] { padding: 10px 12px; font-size: 16px; border: 1px solid #ccc; border-radius: 4px; width: 140px; }
.cm-wallet-amount-form button, .cm-wallet-proof-form button {
    padding: 10px 20px; font-size: 15px; background: #1e1e1e; color: #fff; border: none; border-radius: 4px; cursor: pointer;
}
.cm-wallet-pay { display: flex; flex-direction: column; gap: 10px; }
.cm-wallet-address code { font-size: 13px; background: #f5f5f5; padding: 3px 8px; border-radius: 4px; word-break: break-all; }
.cm-wallet-qr { width: 180px; height: 180px; border: 1px solid #eee; border-radius: 6px; }
.cm-wallet-exact-amount-value { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.cm-wallet-countdown { font-size: 12px; color: #999; }
.cm-wallet-proof-form { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #eee; }
.cm-wallet-proof-form label { font-size: 13px; color: #666; }
.cm-wallet-proof-form input[type="text"], .cm-wallet-proof-form input[type="url"] { padding: 8px 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; }
