/* One Pet Connect — site.css
 *
 * THE single CSS file for this platform (3.12.0 — rebuilt from scratch after
 * the old tokens.css/site.css/admin.css/timegrid.css/theme.css split proved
 * too easy to get out of sync with itself). Loaded on EVERY site page —
 * this component's own views AND everywhere else (Cassiopeia chrome,
 * Joomla's own profile page, articles, other extensions) — by
 * plg_system_opctheme, a system plugin that fires regardless of which
 * extension is active. There is no template-level copy of this file: it's
 * one physical file, in this component's own media/ folder, linked directly
 * by path from the system plugin. Also registered normally (WebAssetManager)
 * by this component's own site views — same file either way, never a second
 * copy to drift out of sync.
 *
 * SCOPE, DELIBERATELY, FOR NOW: brand-colour hook (so BrandingHelper's
 * per-instance colour keeps working) + a real, elegant dark mode covering
 * Bootstrap/Cassiopeia site-wide. Component-specific look (cards, badges,
 * the booking calendar, rewards widgets...) is NOT rebuilt yet — those
 * render with stock Bootstrap styling until asked for.
 *
 * DARK MODE ACTIVATION: driven by `data-bs-theme="dark"` on <html>, set by
 * plg_system_opctheme — either an explicit pet-parent choice, or (the
 * default, for guests and anyone who's never touched the toggle) resolved
 * client-side from the device's own prefers-color-scheme signal, so the
 * WHOLE site follows the visitor's browser/OS automatically with no manual
 * step (see OpcTheme::themeScript(); restored in 3.15.1 after a brief
 * "light always" detour in 3.14.5). Every dark rule below is scoped under
 * that one attribute — nothing here reacts to anything else.
 */

/* Platform default typography (design-refresh pass) — Quicksand for headings
   (a soft, rounded display face that reads as "friendly pet brand" without
   tipping into novelty), Inter for body copy. @import MUST be the first
   real rule in the file (browsers silently ignore an @import that follows
   any other rule) — hence placed here, before @view-transition below, with
   only comments preceding it. These are PLATFORM DEFAULTS, not a hardcoded
   look: --opc-font-heading/--opc-font-body (set in :root just below) are the
   exact same tokens BrandingHelper::buildCss() already overrides per
   instance from Options -> Branding -> brand_font_heading/brand_font_body,
   so any customer can still replace this with their own webfont with zero
   code changes. */
@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@500;600;700&family=Inter:wght@400;500;600&display=swap');

/* Disables Cassiopeia/big_goose's stock `@view-transition { navigation: auto; }`
 * (media/scss/blocks/_global.scss — plain upstream Joomla 6.1 template CSS, never
 * touched when big_goose forked it). 3.32.4/3.32.8 built and then reverted a custom
 * page-slide transition on top of this (site.css's own view-transition-name rules +
 * mobilenav.js's direction handler — "reported too glitchy live"), but only removed
 * OUR OWN layer; the underlying template rule that actually OPTS INTO Chrome/Edge's
 * automatic same-origin cross-document view transition was left at `auto`. Every
 * navigation is a plain full-page Joomla reload (no view-transition-name coordination
 * anywhere in this codebase), so that default transition has nothing meaningful to
 * animate between two unrelated pages — confirmed live via browser profiling as a
 * reproducible "AbortError: Transition was skipped" console exception on 100% of
 * navigations, every section tested, since the destination page's own scripts/layout
 * work routinely interrupts the browser's transition capture before it resolves.
 * Setting `navigation: none` finishes what 3.32.8 meant to revert.
 */
@view-transition {
    navigation: none;
}

:root {
    /* "light dark" tells the browser this page supports both and to use the
       user's own preference for anything not explicitly covered by the
       data-bs-theme rules below (native scrollbars, form-control chrome,
       etc.) — consistent with plg_system_opctheme resolving the SAME
       device signal for data-bs-theme itself, so there's no brief mismatch
       between UA-styled bits and this file's own dark rules. */
    color-scheme: light dark;

    /* Brand colour — BrandingHelper (Options → Branding) overrides this per
       instance via an inline :root block it emits on every page load. Falls
       back to the template's own --bs-primary, then a fixed default, so an
       unbranded instance still looks intentional. */
    --opc-primary: var(--bs-primary, #1f3c88);
    --opc-primary-contrast: #fff;
    --opc-accent-contrast: #fff;

    /* Platform default type stack (see the @import above). Same override
       mechanism as every other --opc-* token: BrandingHelper::buildCss()
       replaces these from Options -> Branding when a customer sets their
       own brand_font_heading/brand_font_body. */
    --opc-font-heading: 'Quicksand', var(--opc-font-body);
    --opc-font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    /* Hover/selection wash, derived from --opc-primary via color-mix() against
       --opc-surface/transparent rather than a precomputed literal — so the
       same declaration already reads correctly once --opc-surface flips dark
       below, with no separate override needed. BrandingHelper mirrors this
       exact formula for a per-instance brand_primary override. */
    --opc-primary-tint: color-mix(in srgb, var(--opc-primary) 8%, var(--opc-surface));
    --opc-primary-ring: color-mix(in srgb, var(--opc-primary) 16%, transparent);

    /* Status/semantic — overridable via brand_danger (Options → Branding). */
    --opc-danger: var(--bs-danger, #dc3545);
    /* Overridable via brand_success — a genuine semantic state colour (in
       stock / booking confirmed / etc.), independent of --opc-primary or
       --opc-accent so a customer's brand colours never accidentally read as
       a status. See .hcc-stock-pill--in in this file. */
    --opc-success: var(--bs-success, #198754);
    /* Over-capacity — its own colour, never --opc-danger, which already means "fully
       blocked" on the same calendar. See tokens.css for the full reasoning. */
    --opc-congested: #d6336c;
    --opc-muted: var(--bs-secondary-color, #64748b);
    /* Manager Portal (Phase 1) — added for manager.css's reimplementation of the
       .hcc-* vocabulary admin.css/availability.css already style for the admin
       Availability calendar (this component's fixed light palette by design).
       These are semantically-named, reusable status colours, not one-off
       literals — same values admin.css/tokens.css already use for danger/
       warning-bg/-border, so the two calendars read identically. */
    --opc-danger-bg: #f8d7da;
    --opc-danger-border: #f1aeb5;
    --opc-warning: #fd7e14;
    --opc-warning-bg: #ffe5d0;
    --opc-warning-border: #fecba1;
    --opc-status-confirmed: #d1f3df;
    --opc-status-completed: #0dcaf0;
    --opc-status-pending: #ffc107;
    --opc-text-closed: #94a3b8;
    /* 3.4x: was #f1f5f9 (near-white) — sat far brighter than the softened
       --opc-surface below it, so anything drawn on this token (Rewards/
       product panels, the Month/Week toggle fill below) reintroduced the
       glare the surface tokens were just cut to fix. Kept a shade lighter
       than --opc-surface, not equal, so the two-tier depth cue survives. */
    --opc-surface-alt: #ececec;
    --opc-radius-badge: 6px;

    /* Options → Branding "Button/tab shape" (brand_shape, BrandingHelper). A hard
       binary switch — 999px (full pill) or 0 (literal square corners) — scoped
       ONLY to buttons and tab switches (see every consumer below: .btn, .opc-tab-
       switch*, .opc-tab-label). Deliberately NOT applied to the booking calendar's
       day/week/month pickers — tried (a pill-shaped Week/Day booking block, a
       pill Month-grid day cell) and it looked wrong in a live screenshot; those
       stay their normal rounded-rectangle look (--opc-radius/-sm) regardless of
       this setting. Also separate from --opc-radius/-sm/-lg/-badge more generally
       — cards, inputs, modals and badges must never resize just because an admin
       flipped this. Default here matches BrandingHelper's own 'round' default, so
       an unbranded/param-less request (can't reach BrandingHelper at all, e.g. a
       CSS-only preview) still renders correctly. */
    --opc-shape: 999px;

    /* Surfaces & lines the booking calendar (Month grid + timegrid.css's
       Week view) is built on — overridable via brand_surface/brand_border
       (Options → Branding). */
    /* 3.35: softened off pure white — glare complaint on the live site.
       --opc-surface now a neutral light grey instead of #fff (earlier warm/
       cream attempts read as "yellow" once tiled across the Month grid's
       30+ day cells — .hcc-book-day in booking.css — even though the same
       tint looked fine on a single card), sunken/border darkened to match
       so the tiered-elevation contrast relationships (surface floats above
       sunken, border still reads against both) stay intact. Chosen
       interactively against the actual booking UI, not picked blind.
       3.4x: business owner reported the 3.35 pass ("#f2f2f2") still
       "blinding" in real use. Dropped to #e2e2e2; a further drop to
       #cfcfcf was tried and called too grey. Final call: page wallpaper
       (body, via --opc-surface-sunken) at #cccccc, with component
       backgrounds (cards, .hcc-book-controls, etc — --opc-surface) LIGHTER
       than that at #dbdbdb — the normal "sunken page, raised card"
       relationship this pair already implied elsewhere in the file.
       --opc-border widened to match so it still reads as a real edge
       against the lighter surface. */
    --opc-surface: #f0f0f0;
    --opc-surface-sunken: #cccccc;
    --opc-border: #a3a3a3;
    /* Was a separate hardcoded #c2c2c2 — same fix as dark mode's own
       --opc-border-light (see that block's comment): derived from
       --opc-border via color-mix() instead of an independent literal, so
       the two never drift apart and raising --opc-border's contrast later
       automatically raises this too. Resolves to ~#c6c6c6, functionally the
       same value light mode already had. */
    --opc-border-light: color-mix(in srgb, var(--opc-border) 55%, var(--opc-surface));
    /* Phantom token fix (THEME_AUDIT.md §0): var(--opc-text) is used at 15+
       call sites across this file but was never actually defined here — it
       only exists under this name in the admin-only tokens.css, which site
       pages don't load. Every one of those call sites was silently resolving
       to whatever colour happened to be inherited from its ancestor instead
       of a real token, which is why "make it theme-aware" fixes routed
       through var(--opc-text) kept visibly failing (confirmed live: header
       business-name text stayed white in light mode after being pointed at
       this token). Defining it for real here backs every existing call site
       at once — none of them need to change. */
    --opc-text: #1e293b;
    --opc-text-faint: #94a3b8;
    --opc-text-soft: #475569;
    --opc-text-placeholder: #cbd5e1;

    /* Component-level vars several Bootstrap parts read directly — the
       light-mode counterpart to the same block dark mode retunes below.
       Without this, .card/.modal/.dropdown/.list-group (e.g. .opc-modal-
       dialog's .card, which the slot-picker/rewards/cart modals all use)
       fall back to Bootstrap's own hardcoded white/grey defaults and never
       pick up the --opc-surface softening above — confirmed live via
       devtools on the slot-picker modal (.opc-modal-dialog.card showed
       Background: #FFFFFF while the rest of the page correctly softened). */
    --bs-card-bg: var(--opc-surface);
    --bs-card-border-color: var(--opc-border);
    --bs-modal-bg: var(--opc-surface);
    --bs-modal-content-bg: var(--opc-surface);
    --bs-modal-content-border-color: var(--opc-border);
    --bs-modal-header-border-color: var(--opc-border);
    --bs-modal-footer-border-color: var(--opc-border);
    --bs-dropdown-bg: var(--opc-surface);
    --bs-dropdown-border-color: var(--opc-border);
    --bs-list-group-bg: var(--opc-surface);
    --bs-list-group-border-color: var(--opc-border);

    /* Elevation — a tight contact shadow + a soft diffuse one, so a lifted
       calendar event/card reads as an actual object rising off the page.
       --opc-shadow-color is the single tint every shadow on the platform is
       mixed from (Options -> Branding -> Shadow colour, BrandingHelper
       overrides it) — resting/lifted derive from it via color-mix() rather
       than baking a literal rgba(), same pattern --opc-primary-tint/-ring
       already use, so an admin-set shadow colour propagates to every
       --opc-shadow-resting/--opc-shadow-lifted consumer site-wide (dozens —
       see the token's own usages) with nothing else to touch. */
    --opc-shadow-color: #0f172a;
    --opc-shadow-resting: 0 1px 2px color-mix(in srgb, var(--opc-shadow-color) 6%, transparent),
                          0 1px 1px color-mix(in srgb, var(--opc-shadow-color) 4%, transparent);
    --opc-shadow-lifted: 0 20px 30px -14px color-mix(in srgb, var(--opc-shadow-color) 35%, transparent),
                         0 8px 12px -6px color-mix(in srgb, var(--opc-shadow-color) 14%, transparent);

    --opc-radius: 10px;
    --opc-radius-sm: 6px;
    --opc-radius-lg: 10px;

    /* Accent — second gradient stop alongside --opc-primary, overridable per
       instance via brand_accent (Options → Branding; BrandingHelper already
       read this token before it had a default here). Used for the dark-mode
       gradient/glow identity (see --opc-gradient-primary/--opc-glow-shadow
       below), the existing press-flash on every .btn (buttonfx.js), AND now
       the resting fill of every SECONDARY/navigational pill button
       (.btn.opc-home-cta, .opc-home-hero-toggle — "See more"/"Visit
       Store"/"Expand Article") — a warm coral against --opc-primary's teal,
       so a "take me somewhere" action reads as visually distinct from a
       "do the primary task" one (Book/checkout stay on --opc-primary,
       untouched). Design-refresh default, not hardcoded: still overridden
       per instance via brand_accent exactly as before. */
    --opc-accent: #ea580c;
    --opc-gradient-primary: linear-gradient(135deg, var(--opc-primary), var(--opc-accent));

    /* Soft-shadow surface pair (3.26 "neomorphic-hybrid" redesign) — a light
       shadow + a dark shadow on opposite corners, both colour-mixed FROM
       --opc-surface itself (never a flat literal) so a per-instance
       brand_surface override still drives the right tone automatically.
       Deliberately used ONLY on passive/structural surfaces (cards, panels,
       inputs at rest) — never on .btn-primary/.btn-danger, which keep a
       solid fill + real contrast so actionable buttons stay unambiguous.
       Dark mode does not get an --opc-neo-shadow-* pair at all: on a near-
       black surface a light/dark dual shadow reads as noise, not depth — the
       dark side's equivalent "soft" identity is --opc-glow-shadow instead. */
    --opc-neo-shadow-out: 6px 6px 14px color-mix(in srgb, var(--opc-surface) 80%, #000 20%),
                         -6px -6px 14px color-mix(in srgb, var(--opc-surface) 96%, #fff 4%);
    --opc-neo-shadow-in: inset 3px 3px 8px color-mix(in srgb, var(--opc-surface) 82%, #000 18%),
                        inset -3px -3px 8px color-mix(in srgb, var(--opc-surface) 97%, #fff 3%);

    /* The card system's base elevation, indirected through one token so a
       SPECIFIC card (e.g. a star-tier pet card, see .opc-star-glow) can add
       its own extra shadow layer on top without fighting the generic card
       rule's specificity for control of the box-shadow property — both
       reference this same variable instead of each hard-coding the raw
       shadow value. */
    --opc-card-shadow: var(--opc-neo-shadow-out);

    /* Rewards medal colours (referenced by templates since v3.3.0, never
       actually defined after the v3.12.0 CSS rebuild — confirmed missing by
       grep, 3.15.1). Literal, deliberately NOT brand-overridable — a medal
       colour means something specific regardless of a Business's own brand
       hue, same precedent as the booking-status cues elsewhere. Same values
       in both themes on purpose: a gold star should read as gold whether the
       page around it is light or dark. */
    --opc-star-bronze: #b0764a;
    --opc-star-silver: #9aa4b2;
    --opc-star-gold: #d4a72c;

    /* Notification badge (cart count, PWA bottom-bar indicators) — fixed
       high-contrast red, deliberately NOT tied to --opc-primary. The old
       badge used bg-primary, which matched the button it sat on (and could
       match --opc-primary-derived surfaces like the mobile bar's centre
       icon) and lost all affordance as an "action needed" signal. #dc2626
       on white passes WCAG AA (4.5:1+) and is left un-themed like the medal
       colours above — a notification badge should read the same regardless
       of light/dark. */
    --opc-badge-notify-bg: #dc2626;
    --opc-badge-notify-fg: #fff;
}

/* ======================================================================
   Scrollbar visually hidden, scrolling still works (3.25.8x) — third attempt
   at this exact problem. History: negative-margin overlay trick (3.25.71) ->
   scrollbar-gutter: stable (3.25.80, reintroduced a permanent blank gutter
   on every non-scrolling page, the same bug that got `stable` dropped the
   FIRST time round at 3.23.28) -> this. Scrolling itself is untouched (wheel/
   touch/keyboard/trackpad all still work); only the visible track/thumb and
   any reserved gutter space are removed, on both the root scroller and any
   internal `overflow:auto` panel. Deliberate tradeoff, explicitly requested:
   desktop mouse users lose the scroll affordance and can't drag a thumb.
   Do not silently swap this for scrollbar-gutter or the negative-margin
   trick again without a fresh discussion — this exact spot has flip-flopped
   three times already. */
* {
    scrollbar-width: none; /* Firefox */
    -ms-overflow-style: none; /* legacy Edge/IE */
}
*::-webkit-scrollbar {
    display: none; /* Chrome/Safari/Edge — covers html's own scrollbar too,
    unlike the old dark-mode rule this file already got bitten by (see the
    ::-webkit-scrollbar block further down): a bare `*` universal selector
    has no descendant-combinator gap, so it matches <html> itself. */
    width: 0;
    height: 0;
}

/* ======================================================================
   Light-mode elevation (3.15.1) — the missing half of the "tiered surfaces"
   idea dark mode already has (body recedes, cards visibly float above it,
   see the dark palette block below). Light mode never got its own version
   of that: body sat at pure white, identical to every card on it, so
   nothing ever visually lifted off the page — one root cause of the "dog's
   breakfast" read on My Rewards/My Pets, on top of the missing component
   CSS fixed further down. --opc-surface-sunken already existed as a token
   (used by the booking calendar) but nothing ever applied it to body
   itself. Dark mode's own body rule (below) uses !important and wins
   regardless of source order, so this is safe to state unconditionally.
   ====================================================================== */
body {
    background-color: var(--opc-surface-sunken);
    font-family: var(--opc-font-body);
}
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6 {
    font-family: var(--opc-font-heading);
    font-weight: 700;
}

/* ======================================================================
   Paw-print background (3.16.2, broadened to every page in 3.19.1 — was
   inconsistently scoped to `body.opc-home` only, so most of the site still
   sat on the plain --opc-surface-sunken fill while the Home page alone got
   the pattern; now every page gets it, standardising the look site-wide).
   `body.opc-home` (see OpcTheme::isHomePage()/applyHomeBackgroundClass() —
   "Home" means Joomla's own default Main Menu item) still exists and still
   drives the SEPARATE Home-page article polish further down this file
   (the hero-photo/card treatment) — only the background itself moved off
   that class onto plain `body`. No image asset exists for this, so the pattern is a small
   inline SVG tile (three paws at varied size/rotation so the repeat doesn't
   read as a rigid grid) — no request, no file to ship. Each paw is drawn
   THREE times at the same spot, offset by 1px: a dark copy, a light copy,
   then a faint base fill — that's what gives the "embossed/pressed into the
   surface" look rather than a flat printed paw, done inside the SVG itself
   so no CSS filter (which would also blur real content) is needed. Two full
   tiles, not one recoloured via currentColor — a background-image data URI
   is loaded as an external image context, so `fill="currentColor"` inside
   it does NOT reliably inherit the page's `color` in every engine; explicit
   colours per theme is the safe choice already used elsewhere in this file.
   Never verified in a real browser (see this file's own header + CLAUDE.md's
   FullCalendar history for why that's an honest, standing limitation of this
   dev sandbox) — low-opacity and additive-only (background-image on top of
   the existing background-color), so a rendering surprise here is cosmetic,
   never a layout break. */
body {
    /* Design-refresh pass: the repeating paw-print tile that used to render
       here site-wide is REMOVED (was reported busy/cluttered across every
       page). Replaced with a plain, subtle top-to-bottom gradient over
       --opc-surface-sunken instead of a flat fill — "clean solid or subtle
       gradient" per the brief. Light mode never overrode the template's own
       --body-bg (#fff, big_goose/media/css/template.css:80); this still
       gives light mode the same "sunken page, raised card" treatment every
       card on the platform already assumes (cards sit on --opc-surface),
       just without the tile. */
    background-color: var(--opc-surface-sunken);
    background-image: linear-gradient(180deg,
        color-mix(in srgb, var(--opc-surface-sunken) 92%, white),
        var(--opc-surface-sunken) 480px);
    background-attachment: fixed;
}

/* ======================================================================
   Home page article polish (3.16.3) — reported directly from a live
   screenshot: the Home page's own content (a raw, full-column-width
   uploaded photo + plain WYSIWYG paragraphs) renders as bare, unstyled
   Joomla core article markup — no different from any other article on the
   site, and now visibly at odds with the paw-print treatment just given the
   page around it. Scoped under `body.opc-home` (see above) plus Joomla
   core's OWN guaranteed article wrapper class, `.item-page` — present in
   com_content's article view across every recent Joomla version, so this
   doesn't depend on knowing this Business's actual article HTML in advance.
   Same "best-effort, inert if the live markup doesn't match" caveat as
   every other Cassiopeia-chrome selector in this file (see this file's own
   header — no real browser here to verify against) — only the Home page is
   touched, nothing else on the site.

   Four moves, all reversible/inert independently if one doesn't match:
    1. The whole article sits on its own card surface (--opc-surface, same
       language as every card elsewhere on this platform), so it visibly
       lifts off the paw-print background instead of floating bare on it —
       "tiered surfaces" carried through to this page too.
    2. Cassiopeia's own page-header title ("Home") is shrunk to a small
       uppercase kicker rather than hidden outright — the article's OWN
       heading ("Welcome to ...") already says more, so a second full-size
       title directly above it was pure repetition, but removing the
       heading entirely would lose the admin's inline "Edit" affordance
       Cassiopeia attaches to it.
    3. The hero photo — the actual ask ("make the image smaller") — goes
       from its raw uploaded size to a fixed, cropped banner height,
       rounded and lifted with a shadow, so it reads as a designed hero
       strip instead of a raw photo dropped into the page.
    4. Body copy gets a capped line length (readability) and real vertical
       rhythm between headings/paragraphs instead of the WYSIWYG default's
       cramped spacing. The "What We Offer" list specifically — each
       service is a bold emoji-led label followed by its own paragraph —
       gets a quiet left-rail treatment via `:has(> strong:first-child)`,
       styling paragraphs by their actual existing shape rather than
       requiring the article content itself to be restructured with new
       classes (which a Business's own content editor doesn't control).
   ====================================================================== */
/* Deliberately NOT scoped to body.opc-home (unlike the page-header rules
   just below, which stay scoped — that's the separate, still-open "borrowed
   Home title" question). This card wrap is the generic "an article shouldn't
   render as bare unstyled text" treatment and applies to any .item-page,
   including a plain permalink like catid=2's "What One Pet Connect Does"
   opened directly — confirmed live that page rendered as flat, uncontained
   text with none of this. */
.item-page {
    max-width: 900px;
    margin-inline: auto;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: 16px;
    padding: 1.75rem clamp(1rem, 4vw, 2.5rem) 2.25rem;
    box-shadow: var(--opc-shadow-resting);
    /* Contains the hero image's float below (see that rule's own comment) so
       the card's own background/padding always wraps the full article
       height, never collapsing around a floated child the way a plain block
       formatting context would — a modern, non-clipping alternative to the
       classic "overflow: hidden" clearfix (which would risk cutting off the
       image's own box-shadow). Live-screenshot-confirmed bug (3.16.3 first
       pass): without this, the card visibly failed to wrap the article at
       all — its box shrank to just the text beside the still-floated image. */
    display: flow-root;
}
body.opc-home .item-page .page-header {
    border: 0;
    margin-bottom: .25rem;
    padding-bottom: 0;
}
body.opc-home .item-page .page-header h1 {
    font-size: .78rem;
    text-transform: uppercase;
    letter-spacing: .08em;
    font-weight: 600;
    color: var(--opc-muted);
    margin: 0;
}
/* Two-layer float fix (3.16.5 — the 3.16.4 attempt targeted only the <img>
   itself and a second live screenshot, taken AFTER that version genuinely
   reached the site this time, showed the exact same wrap persisting: the
   image's own rounded corners/shadow (only ever declared on the <img> tag)
   rendered correctly, proving this selector DOES match — but the image
   stayed narrow and text still wrapped beside it, which only makes sense if
   `float`/an explicit width live on the element WRAPPING the image (Joomla's
   WYSIWYG typically inserts a bare image inside its own `<p>`), not the
   `<img>` tag itself — so overriding those two properties on the img alone
   was correctly fixing the wrong element. Layer 1 below neutralises
   whichever direct child of `.item-page` actually wraps the image, via
   `:has()`, without needing to know its tag/class up front. Layer 2 is a
   second, independent net: `clear: both` on every other direct child means
   nothing can ever wrap beside a float again regardless of which element
   turns out to carry it — even one this codebase hasn't anticipated. Same
   "force it directly, don't just retune a value" precedent this file has
   already reached for more than once (v3.9.8/v3.12.6's own dark-mode
   specificity hotfixes).

   Deliberately NOT scoped to body.opc-home (unlike the surrounding card/
   width-cap rules) — this article was reached at its own plain permalink
   (component/content/article/..., no Itemid), which the opc-home menu-match
   fix correctly does NOT flag as home, so a home-only rule here would never
   fire for it. A floated WYSIWYG image wrapping text is a generic Joomla
   core-content problem, not a home-page-only one, so the fix applies to
   every article's .item-page regardless of where it's viewed from. */
.item-page > :has(> img) {
    float: none !important;
    width: auto !important;
    max-width: none !important;
    margin: 0 0 1.5rem !important;
}
.item-page > :not(.page-header) {
    clear: both;
}
/* Static height, NOT width:100%+cover — the article's raw upload can be any
   aspect ratio, and forcing full article width with a capped height (was
   max-height:300px on a width:100% box) meant object-fit:cover cropped away
   most of any image that wasn't already ~3:1 (live example: a 600x400 photo
   stretched to ~900px wide and clamped to 300px tall lost most of its
   height). Fixed height + width:auto lets the browser scale from the img
   tag's own width/height attributes preserving aspect, so the rendered box
   already hugs the scaled image — no separate "container" to size, and nothing
   is cropped. object-fit:contain is the safety net for the rare case
   max-width kicks in on a narrow viewport with a wide image. margin:auto
   centers the now-narrower box instead of it sitting flush left. */
.item-page img {
    display: block !important;
    float: none !important;
    width: auto !important;
    height: 375px !important; /* was 300px, +25% */
    max-width: 100%;
    object-fit: contain;
    border-radius: 12px;
    /* Realigned to --opc-shadow-color (Options -> Branding -> Shadow colour,
       light/dark) instead of a hardcoded --opc-accent tie-in — same token
       every other elevation shadow on the platform now derives from (see
       :root's own comment on --opc-shadow-color), so this image follows
       whatever an admin sets there rather than its own independent colour
       source. Three layers, not one: the first two are a plain drop shadow
       (0 x-offset), which alone only reads below the image since their
       negative spread pulls the sides in; the third adds a positive
       x-offset so the glow shows to the right too, behind the image rather
       than only under it. */
    box-shadow: 0 20px 30px -14px color-mix(in srgb, var(--opc-shadow-color) 45%, transparent),
                0 8px 12px -6px color-mix(in srgb, var(--opc-shadow-color) 25%, transparent),
                14px 8px 26px -12px color-mix(in srgb, var(--opc-shadow-color) 40%, transparent);
    margin: 0 auto 1.5rem !important;
}
/* Mobile: the fixed 375px height above only works while width:auto can pick
   whatever width matches that height. Once max-width:100% clamps the width
   on a narrow viewport, height stays locked at 375px regardless — the box
   no longer matches the image's scaled height, so object-fit:contain
   letterboxes it inside dead space. width:100% + height:auto here makes the
   box (and its shadow/radius) hug the image at whatever height its own
   aspect ratio produces at full column width — genuinely dynamic, not just
   a smaller fixed number. */
@media (max-width: 767.98px) {
    .item-page img {
        width: 100% !important;
        height: auto !important;
    }
}
/* Same reasoning as the card wrap above — generic body-copy rhythm for any
   article, not home-only. Lists (ul/ol/li) added here too (the original
   home-only version never covered them) so a numbered/bulleted section
   reads as its own segment instead of running flush against the
   paragraph above/below it — the "more segmented" ask. */
.item-page :is(h2, h3) {
    margin-top: 1.75rem;
    margin-bottom: .5rem;
}
.item-page > :is(h2, h3, p):first-child {
    margin-top: 0;
}
.item-page p {
    max-width: 68ch;
    line-height: 1.65;
}
.item-page :is(ul, ol) {
    margin-top: .5rem;
    margin-bottom: 1.25rem;
}
.item-page li {
    margin-bottom: .5rem;
}
.item-page p:has(> strong:first-child) {
    max-width: none;
    background: var(--opc-surface-sunken);
    border-left: 3px solid var(--opc-primary);
    border-radius: 0 8px 8px 0;
    padding: .65rem .9rem;
    margin: .6rem 0;
}
.item-page p:has(> strong:first-child) > strong {
    display: block;
    margin-bottom: .15rem;
    color: var(--opc-primary);
}

/* ======================================================================
   Pet status notice (Lost/Ran Away/Adopted auto-articles — see
   PetArticleService::buildIntrotext()) — a pet-profile poster, not a data
   dump. Scoped to `.opc-pet-notice` so this NEVER touches a plain info
   article (e.g. "What One Pet Connect Does"), which keeps the generic
   `.item-page` WYSIWYG treatment above untouched. Several rules here need
   `.item-page` in the selector purely to out-specificity/override that
   generic treatment's own `!important`s (the photo in particular) — this
   card is always rendered INSIDE an `.item-page`, never standalone. */
/* The generic `.item-page` cap (900px) is sized for body-copy articles: a
   poster-shaped notice inside it left wide dead gutters either side of the
   centered card content (reported live). Scoped to `:has(> .opc-pet-notice)`
   so this narrower measure applies ONLY to these auto-articles, never a
   plain info page. */
.item-page:has(> .opc-pet-notice) {
    max-width: 30rem;
}
.opc-pet-notice {
    text-align: center;
    max-width: 100%;
    margin: 0 auto;
}
.opc-pet-notice-badge {
    display: inline-block;
    font-size: .8rem;
    font-weight: 800;
    letter-spacing: .09em;
    text-transform: uppercase;
    padding: .4rem 1.05rem;
    border-radius: 999px;
    margin-bottom: 1.25rem;
}
.opc-pet-notice--lost .opc-pet-notice-badge {
    color: var(--bs-danger-text-emphasis, #842029);
    background-color: var(--bs-danger-bg-subtle, #f8d7da);
    border: 1px solid var(--bs-danger-border-subtle, #f1aeb5);
}
.opc-pet-notice--runaway .opc-pet-notice-badge {
    color: var(--bs-warning-text-emphasis, #997404);
    background-color: var(--bs-warning-bg-subtle, #fff3cd);
    border: 1px solid var(--bs-warning-border-subtle, #ffe69c);
}
.opc-pet-notice--adopted .opc-pet-notice-badge,
.opc-pet-notice--found .opc-pet-notice-badge {
    color: var(--bs-success-text-emphasis, #146c43);
    background-color: var(--bs-success-bg-subtle, #d1e7dd);
    border: 1px solid var(--bs-success-border-subtle, #a3cfbb);
}
/* The ring is the status colour, painted as padding around the photo rather
   than a border on the <img> itself — a border would be clipped by the
   img's own border-radius:50% on some browsers. */
.opc-pet-notice-photo {
    width: 13rem;
    height: 13rem;
    margin: 0 auto 1.25rem;
    border-radius: 50%;
    padding: .3rem;
}
.opc-pet-notice--lost .opc-pet-notice-photo {
    background: var(--bs-danger, #dc3545);
}
.opc-pet-notice--runaway .opc-pet-notice-photo {
    background: var(--bs-warning, #ffc107);
}
.opc-pet-notice--adopted .opc-pet-notice-photo,
.opc-pet-notice--found .opc-pet-notice-photo {
    background: var(--bs-success, #198754);
}
/* Overrides `.item-page img`'s own !importants (fixed 375px height, no
   radius) — see this block's own header comment on why `.item-page` has to
   be repeated here. */
.item-page .opc-pet-notice-photo img {
    display: block !important;
    float: none !important;
    width: 100% !important;
    height: 100% !important;
    max-width: none !important;
    object-fit: cover;
    border-radius: 50%;
    border: 3px solid var(--opc-surface);
    box-shadow: none;
    margin: 0 !important;
}
.opc-pet-notice-chips {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: .5rem;
    margin-bottom: 1rem;
}
/* --opc-text-soft (not --text) read as grey-on-grey against
   --opc-surface-sunken — reported live off a dark-mode screenshot — so this
   uses the same higher-contrast pairing the contact block already used. */
.opc-pet-notice-chips span {
    font-size: .88rem;
    font-weight: 600;
    color: var(--opc-text);
    background: var(--opc-surface-sunken);
    border: 1px solid var(--opc-border);
    padding: .35rem .9rem;
    border-radius: 999px;
}
.item-page .opc-pet-notice-posted {
    font-size: .95rem;
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--opc-text-soft);
    max-width: none;
    margin: 0 0 1.5rem;
}
/* Notes and Contact previously both sat on flat --opc-surface-sunken —
   barely distinguishable from each other OR the --opc-surface card behind
   them in dark mode (reported live: "colors can not fade into each other").
   Notes now gets a neutral left accent stripe + a visibly LIGHTER fill
   (color-mix toward --opc-surface, not the darker sunken tone) so it lifts
   off the card; Contact keeps its own distinct primary-tinted fill so the
   two boxes read as different things at a glance, not two identical greys. */
.opc-pet-notice-notes {
    text-align: left;
    background: color-mix(in srgb, var(--opc-surface) 55%, var(--opc-surface-sunken) 45%);
    border: 1px solid var(--opc-border);
    border-left: 4px solid var(--opc-text-faint);
    border-radius: var(--opc-radius);
    padding: 1.1rem 1.25rem;
    margin: 0 0 1.1rem;
}
.opc-pet-notice-notes strong {
    display: block;
    font-size: .75rem;
    font-weight: 700;
    letter-spacing: .07em;
    text-transform: uppercase;
    color: var(--opc-text-soft);
    margin-bottom: .45rem;
}
.item-page .opc-pet-notice-notes p {
    max-width: none;
    margin: 0;
    font-size: 1rem;
    line-height: 1.6;
    color: var(--opc-text);
}
.opc-pet-notice-contact {
    text-align: left;
    background: color-mix(in srgb, var(--opc-primary) 14%, var(--opc-surface));
    border: 1px solid var(--opc-primary);
    border-left: 4px solid var(--opc-primary);
    border-radius: var(--opc-radius);
    padding: 1.15rem 1.3rem;
}
.opc-pet-notice-contact strong {
    display: block;
    font-size: .75rem;
    font-weight: 700;
    letter-spacing: .07em;
    text-transform: uppercase;
    color: var(--opc-primary);
    margin-bottom: .65rem;
}
.opc-pet-notice-contact-line {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 1rem;
    font-size: 1rem;
    color: var(--opc-text);
    padding: .45rem 0;
    border-top: 1px solid color-mix(in srgb, var(--opc-primary) 30%, var(--opc-border));
}
.opc-pet-notice-contact-line:first-of-type {
    border-top: 0;
}
.opc-pet-notice-contact-line a {
    color: var(--opc-text);
    font-weight: 700;
    text-decoration: none;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}
.opc-pet-notice-contact-line a:hover {
    text-decoration: underline;
}

/* ======================================================================
   Branding — makes Bootstrap's OWN ".btn-primary"/".btn-outline-primary"/
   ".bg-primary"/plain links actually follow --opc-primary. Needed because
   Bootstrap pre-computes a button's hover/active shade at ITS OWN build
   time from ITS OWN Sass $primary, not live from a CSS custom property —
   so without this, every "primary" action silently stays the template's
   stock blue regardless of what an instance is branded with.
   ====================================================================== */

/* Primary CTA gradient/glow identity — same in light and dark (3.37: was
   dark-mode-only via [data-bs-theme="dark"], but the gradient is decoration
   on top of an already-unambiguous solid-fill button, not a contrast fix, so
   there's no reason light mode should look flatter/duller than dark for the
   same action (Add to cart etc). --opc-gradient-primary/--opc-glow-shadow
   are defined in both theme blocks, so this needs no theme scoping at all. */
.btn-primary {
    background: var(--opc-gradient-primary);
    /* border: none, not border-color: transparent — a "transparent" 1px
       border still occupies geometry, and on a fully-rounded (999px) pill
       cap that geometry is nearly the whole visible edge, so the
       anti-aliasing seam between the border ring and the gradient fill
       showed as a grey fringe at the rounded caps (confirmed live via
       artifact diagnostic, 2026-09-06). Removing the border box entirely
       is the fix, not recolouring it. */
    border: none;
    color: var(--opc-primary-contrast);
}
.btn-primary:hover,
.btn-primary:focus-visible,
.btn-primary.is-pressed {
    background: var(--opc-gradient-primary);
    border: none;
    box-shadow: var(--opc-glow-shadow);
    color: var(--opc-primary-contrast);
}
/* 3.23.20 gave dark mode a solid fill at rest ("light mode's white page
   already gives a bare outline plenty of contrast" — true for contrast,
   but 3.4x is a brand-match request: every primary action should carry
   the brand fill at rest, not just once touched, in both themes. Folded
   into one rule instead of a light/dark pair.
   !important REQUIRED on every declaration here too, not just on
   .is-pressed below (3.4x, confirmed live): buttonfx.js drops .is-pressed
   on `pointerleave` — dragging off the button while still holding the
   mouse button down — but native :active does NOT reliably let go at the
   same moment (most browsers keep the ORIGINAL pressed element :active
   until actual mouseup, even once the pointer has moved off it). That gap
   left .is-pressed's own !important protection absent for a window where
   big_goose/template.css's (0,3,0) `:not(.btn-check) + .btn:active` rule
   (template.css:3055) could still be matching and winning against this
   rule's un-!important'd (0,1,0)/(0,2,0). Since JS state can never be
   guaranteed to track native :active timing exactly, the only reliable
   fix is making every state here immune to the same collision, not just
   the is-pressed one. */
.btn-outline-primary {
    background-color: var(--opc-primary) !important;
    border-color: var(--opc-primary) !important;
    color: var(--opc-primary-contrast) !important;
    transition: background-color .45s ease, border-color .45s ease;
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
    background-color: color-mix(in srgb, var(--opc-primary) 85%, black) !important;
    border-color: color-mix(in srgb, var(--opc-primary) 85%, black) !important;
    color: var(--opc-primary-contrast) !important;
}
/* Tap flash — same primary→accent→primary language as .opc-tab-switch-thumb's
   is-flash: instant swap to --opc-accent on press (no transition, reads as an
   immediate "tap"), fades back to --opc-primary via the base rule's own
   transition once buttonfx.js removes .is-pressed on release. Declared after
   :hover so a press-while-hovering still shows the flash, not the hover shade.
   !important REQUIRED (3.4x, confirmed live): a real physical press also
   matches native :active, and big_goose/template.css's OWN compiled button
   state rule — `:not(.btn-check) + .btn:active` (template.css:3055) — is
   (0,3,0) specificity (three class-level selectors) against this rule's
   (0,2,0), so Bootstrap wins regardless of source order and paints
   `var(--btn-active-bg)`, which .btn-outline-primary itself precompiles to a
   literal, unbranded #010156 (template.css:3217) — completely bypassing
   --opc-accent. Reported live: button showed raw template navy while
   actually held, only reading the correct accent colour once something
   (alt-tabbing away and back) happened to drop native :active matching and
   unmask this rule underneath. !important is the only reliable way to beat
   a same-origin rule with genuinely higher selector specificity. */
.btn-outline-primary.is-pressed {
    background-color: var(--opc-accent) !important;
    border-color: var(--opc-accent) !important;
    color: var(--opc-primary-contrast) !important;
    transition: none;
}
.badge.bg-primary {
    background-color: var(--opc-primary) !important;
}
/* .badge.bg-warning/.bg-danger — same gap as .badge.bg-primary above: every
   status badge (booking status, stock, promotions) renders as
   `badge bg-{status_class}`, but only bg-primary/bg-secondary were ever
   wired to the branded token. bg-warning had no live contrast risk either
   theme (--opc-warning is a light orange, badges keep their own text-dark).
   bg-danger deliberately matches --opc-danger's EXISTING value, same as
   .btn-danger/.btn-outline-danger already use — this only makes the badge
   agree with buttons that already ship this colour, not a new choice. */
.badge.bg-warning {
    background-color: var(--opc-warning) !important;
}
.badge.bg-danger {
    background-color: var(--opc-danger) !important;
}
/* Plain links go through Bootstrap's own --bs-link-color, which it DOES
   read live — remapping the variable is enough here, no element selector
   needed. --bs-code-color rides along for the same reason: the dark-mode
   block already remaps it (see below) so <code> — voucher codes — reads as
   an intentional accent there; light mode never got the matching remap and
   fell through to Bootstrap's stock pink. */
:root {
    --bs-link-color: var(--opc-primary);
    --bs-link-hover-color: color-mix(in srgb, var(--opc-primary) 80%, black);
    --bs-code-color: var(--opc-primary);
}

/* Same treatment as .btn-outline-primary above, for delete/destructive
   actions (.btn-outline-danger) — those were left on Bootstrap's stock
   pre-computed red instead of following --opc-danger, the one button style
   that didn't match the rest of the branded button set. */
.btn-outline-danger {
    color: var(--opc-danger);
    border-color: var(--opc-danger);
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible,
.btn-outline-danger.is-pressed {
    background-color: var(--opc-danger);
    border-color: var(--opc-danger);
    color: #fff;
}
[data-bs-theme="dark"] .btn-outline-danger {
    background-color: var(--opc-danger) !important;
    border-color: var(--opc-danger) !important;
    color: #fff !important;
}
[data-bs-theme="dark"] .btn-outline-danger:hover,
[data-bs-theme="dark"] .btn-outline-danger:focus-visible,
[data-bs-theme="dark"] .btn-outline-danger.is-pressed {
    background-color: color-mix(in srgb, var(--opc-danger) 85%, black) !important;
    border-color: color-mix(in srgb, var(--opc-danger) 85%, black) !important;
    color: #fff !important;
}
.btn-danger {
    background: var(--opc-danger);
    border-color: var(--opc-danger);
    color: #fff;
}
.btn-danger:hover,
.btn-danger:focus-visible,
.btn-danger.is-pressed {
    background: color-mix(in srgb, var(--opc-danger) 85%, black);
    border-color: color-mix(in srgb, var(--opc-danger) 85%, black);
    color: #fff;
}
/* Uniform, shorter height for every delete button site-wide, regardless of
   whether the markup also carries .btn-sm (cart/products/product-edit do,
   the availability modal's Delete + Reject buttons don't) — min-height, not
   height, so a longer translated label still wraps instead of clipping.
   rem, not px, so it scales with the OS/browser text-size setting. */
.btn-outline-danger,
.btn-danger {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 1.75rem;
    padding: 0 .65rem;
    font-size: .8rem;
    line-height: 1;
}

/* ======================================================================
   JS-driven press effect + ripple (3.15.1) — fixes a real bug: the old
   `:active`/`:focus`-only "wave" darkened a tapped `<a class="btn">` and
   never reliably cleared, because a link that navigates away stays
   logically focused, and a mobile back-gesture restoring the page from the
   browser's bfcache brings that stuck `:focus` state back with it — no real
   interaction happening, no `:active` involved (that pseudo-class can't
   persist without a pointer actually down). Fixed by owning the "pressed"
   look with an explicit class instead of the pseudo-classes it can't fully
   control — see media/com_onepetconnect/js/buttonfx.js, which adds/removes
   `.is-pressed` on pointer events and explicitly clears it (plus any
   leftover ripple) on `pageshow` when the page was restored from bfcache.
   `:focus-visible` is untouched above — a real keyboard focus ring is a
   different, legitimate concern from the tap "pressed" look and isn't what
   was buggy. Ripple is a plain radial `<span>` buttonfx.js injects at the
   tap point; `@keyframes` scales/fades it, `animationend` removes it.

   3.23.20 fix: `overflow: hidden` used to live directly on `.btn` to clip
   the ripple to the button's shape — but that also clipped anything ELSE
   positioned to stick outside the button's box, including the Cart badge
   (`#hcc-cart-count`, `position-absolute` + `translate-middle` so half of
   it sits outside the button on purpose) — only its bottom-right sliver
   survived the clip. The ripple now gets its own absolutely-positioned,
   full-size `.opc-btn-ripple-mask` wrapper (buttonfx.js creates/reuses one
   per button) that owns `overflow: hidden` instead of `.btn` itself — the
   ripple still clips to the button's shape, but a sibling like the cart
   badge is never inside that wrapper, so it's never clipped by it. */
.btn {
    position: relative;
    /* Options -> Branding "Button/tab/calendar shape" (brand_shape). Overrides
       Bootstrap's own --bs-btn-border-radius/--bs-border-radius, which .btn would
       otherwise fall through to (site.css never overrode this before --opc-shape
       existed). */
    border-radius: var(--opc-shape);
    /* Site-wide "rise" hover/press lift — same treatment
       .opc-home-hero-toggle already had on its own, requested live for
       every button on the site. Explicit full list (Bootstrap 5's own
       compiled default color/background-color/border-color/box-shadow
       transition, PLUS transform) rather than just adding `transform` on
       its own — `transition` is a single shorthand property, so a bare
       `transition: transform .12s ease` here would silently REPLACE
       Cassiopeia's compiled .btn transition instead of adding to it,
       losing the smooth colour/background fade every button already had
       on hover. */
    transition: color .15s ease-in-out, background-color .15s ease-in-out,
        border-color .15s ease-in-out, box-shadow .15s ease-in-out,
        transform .12s ease;
}
.btn:hover,
.btn:focus-visible {
    transform: translateY(-1px);
}
.btn:active,
.btn.is-pressed {
    transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
    .btn:hover,
    .btn:focus-visible {
        transform: none;
    }
}

/* Single-line form controls — full pill, everywhere (Customers search box +
   Search button comparison, confirmed live). Deliberately a bare 999px
   literal, NOT var(--opc-shape): the token comment above is explicit that
   --opc-shape is scoped only to buttons/tab-switches and inputs must never
   resize just because an admin flips brand_shape to square — this is a
   separate, fixed design call, not something the branding toggle should be
   able to undo. Covers .form-select too (customselect.js's .opc-select-
   trigger button always carries the original <select>'s own "form-select"
   class alongside it, so it inherits this same rule with no extra
   selector). .form-control-plaintext has no visible box to shape, so it's
   untouched. */
.form-control:not(textarea),
.form-select,
.input-group-text {
    border-radius: 999px;
}
/* Multi-line boxes stay OUT of the full pill — a stadium-shaped textarea
   reads wrong once it has any real height. Fixed 10px literal, deliberately
   NOT wired to any admin branding override (--opc-radius is a separate,
   independently admin-configurable card/panel radius that can be 0 on an
   instance that still wants round buttons — confirmed live that coupling
   this to it made a textarea go square while its buttons stayed round;
   --opc-shape itself is reserved for buttons/tab-switches only, per its own
   token comment above). */
textarea.form-control {
    border-radius: 10px;
}
/* Icon + label spacing inside buttons. CAN'T be a structural selector like
   `:not(:last-child)` — an icon span followed by a plain text label (no
   wrapping element) IS still the button's only *element* child, so
   :last-child stays true and the rule silently never fires (found live:
   the pets-grid "Book a service" button, icon span + bare text node,
   got no margin at all despite matching that selector). Explicit class
   instead, applied only where a label actually follows the icon. */
.btn .opc-btn-icon {
    margin-right: .4rem;
}

/* Shared high-contrast notification badge (3.27) — cart count in the header
   button and the mobile PWA bottom-bar icons both use this for colour, so a
   badge always reads as "action needed" instead of blending into whatever
   colour its parent button/icon happens to be. Geometry/position stay
   per-context (Bootstrap's position-absolute/translate-middle on the header
   badge, .opc-mobilebar-badge's own sizing on the bottom bar) since the two
   live on very differently sized parents; only colour + shape are shared. */
.opc-badge-notify {
    background: var(--opc-badge-notify-bg);
    color: var(--opc-badge-notify-fg);
    border-radius: 9999px;
    font-weight: 700;
    line-height: 1;
    text-align: center;
}
.opc-badge-notify:empty {
    display: none;
}
.opc-btn-ripple-mask {
    position: absolute;
    inset: 0;
    overflow: hidden;
    border-radius: inherit;
    pointer-events: none;
}
/* buttonfx.js appends this mask as the button's LAST child (needed so the
   ripple always paints above earlier content) — but Joomla's own core
   password-toggle script updates its label by grabbing the button's
   `:last-child`, assuming that's its own `.visually-hidden` span. Once the
   ripple mask became the actual last child, Joomla started writing
   "Show/Hide Password" into THIS empty span instead, and because it's
   absolutely positioned over the whole button, that stray text rendered
   visibly on top of the icon (confirmed live via outerHTML — the real
   `.visually-hidden` span was untouched and still correctly hidden).
   font-size: 0 makes any text landing in here invisible regardless of DOM
   order, without touching buttonfx.js itself (a site-wide effect on every
   .btn) or its ripple children, which are sized via inline width/height and
   filled with `background: currentColor`, neither of which depends on
   font-size. */
.input-password-toggle .opc-btn-ripple-mask {
    font-size: 0;
}
.opc-btn-ripple {
    position: absolute;
    border-radius: 50%;
    background: currentColor;
    opacity: .35;
    transform: scale(0);
    pointer-events: none;
    animation: opc-btn-ripple .55s ease-out forwards;
}
@keyframes opc-btn-ripple {
    to {
        transform: scale(2.6);
        opacity: 0;
    }
}
@media (prefers-reduced-motion: reduce) {
    .opc-btn-ripple {
        animation: none;
        display: none;
    }
}

/* Submit-in-flight state (3.29.29) — see formstate.js. Bootstrap's own
   `:disabled`/`[disabled]` styling already dims the button (default ~.65
   opacity); this only adds the "please wait" cursor and a touch more dimming
   so a busy button reads as distinctly different from a plain disabled one
   (e.g. an already-unavailable cart line), not just "greyed out". */
.btn.is-busy {
    cursor: wait;
    opacity: .75;
}

/* Cart button "just added" pulse (3.23.20) — see cartpulse.js. A plain
   grow/shrink scale, twice over ~3s, so it reads as a deliberate pulse
   rather than a single blip; scoped to transform only so it composes fine
   with the ripple/press effects above (both animate different properties). */
#hcc-cart-btn.opc-cart-pulse {
    animation: opc-cart-pulse 3s ease-in-out;
}
@keyframes opc-cart-pulse {
    0%, 30%, 60%, 100% { transform: scale(1); }
    15%, 45%           { transform: scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
    #hcc-cart-btn.opc-cart-pulse {
        animation: none;
    }
}

/* ======================================================================
   Dark mode — a real, elegant palette: soft charcoal-slate (Replit/VS
   Code/Office 365 style), never near-black, never pure-white text. Retunes
   Bootstrap 5.3's OWN dark custom-property map, so anything not explicitly
   listed below still gets Bootstrap's own sensible dark value rather than
   nothing.
   ====================================================================== */

[data-bs-theme="dark"] {
    color-scheme: dark;

    /* Body */
    --bs-body-bg: #0e131d;
    --bs-body-bg-rgb: 14, 19, 29;
    --bs-body-color: #dce2ec;
    --bs-body-color-rgb: 220, 226, 236;
    --bs-emphasis-color: #f5f6f8;
    --bs-emphasis-color-rgb: 245, 246, 248;
    --bs-heading-color: #eef0f3;

    /* Tiered surfaces — page recedes, cards visibly float above it */
    --bs-secondary-bg: #161d2c;
    --bs-secondary-bg-rgb: 22, 29, 44;
    --bs-tertiary-bg: #1f2a3f;
    --bs-tertiary-bg-rgb: 31, 42, 63;
    --bs-secondary-color: #a7b0c4;
    --bs-secondary-color-rgb: 167, 176, 196;
    --bs-tertiary-color: #77839b;
    --bs-tertiary-color-rgb: 119, 131, 155;

    /* Borders */
    --bs-border-color: #2c3650;
    --bs-border-color-translucent: rgba(190, 205, 235, .12);

    /* Links — same brand colour, lifted slightly for AA contrast on a dark
       body (a saturated indigo that reads fine on white can go muddy on a
       dark surface without a touch more lightness). */
    --bs-link-color: color-mix(in srgb, var(--opc-primary, #1f3c88) 70%, white);
    --bs-link-hover-color: color-mix(in srgb, var(--opc-primary, #1f3c88) 55%, white);

    /* Component-level vars several Bootstrap parts read directly */
    --bs-card-bg: var(--bs-secondary-bg);
    --bs-card-cap-bg: var(--bs-tertiary-bg);
    --bs-card-border-color: var(--bs-border-color);
    --bs-dropdown-bg: var(--bs-secondary-bg);
    --bs-dropdown-border-color: var(--bs-border-color);
    --bs-dropdown-link-color: var(--bs-body-color);
    --bs-dropdown-link-hover-color: var(--bs-emphasis-color);
    --bs-dropdown-link-hover-bg: var(--bs-tertiary-bg);
    --bs-modal-bg: var(--bs-secondary-bg);
    --bs-modal-content-bg: var(--bs-secondary-bg);
    --bs-modal-content-border-color: var(--bs-border-color);
    --bs-modal-header-border-color: var(--bs-border-color);
    --bs-modal-footer-border-color: var(--bs-border-color);
    --bs-table-color: var(--bs-body-color);
    --bs-table-bg: transparent;
    --bs-table-border-color: var(--bs-border-color);
    --bs-table-striped-bg: rgba(190, 205, 235, .04);
    --bs-table-hover-bg: rgba(190, 205, 235, .05);
    --bs-list-group-bg: var(--bs-secondary-bg);
    --bs-list-group-border-color: var(--bs-border-color);
    --bs-list-group-action-hover-bg: var(--bs-tertiary-bg);
    --bs-nav-tabs-link-active-bg: var(--bs-secondary-bg);
    --bs-nav-tabs-link-active-border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-secondary-bg);
    --bs-nav-link-color: var(--bs-body-color);
    --bs-nav-link-hover-color: var(--bs-emphasis-color);
    --bs-code-color: #f0a032;

    /* --opc-* surfaces/lines the booking calendar (Month grid above +
       timegrid.css's Week view) is built on — read from the SAME --bs-*
       custom properties this block just retuned, not a second independent
       copy, so the calendar can never drift out of sync with the rest of
       the dark palette. --opc-primary-tint/-ring need no entry here: they're
       color-mix() expressions against var(--opc-surface) in the :root block
       above, so they already resolve correctly the moment --opc-surface
       flips dark on the next line. */
    --opc-danger: var(--bs-danger, #ea868f);
    /* Lifted for dark mode — the light crimson goes muddy on a dark ground. */
    --opc-congested: #f06595;
    /* Lifted for dark mode, same reasoning as --opc-danger just above — the
       light-mode green (#198754) goes muddy against a near-black surface. */
    --opc-success: var(--bs-success, #2fb380);
    --opc-muted: var(--bs-secondary-color, #aeb3bc);
    /* Manager Portal (Phase 1) — dark-safe equivalents of the light-mode status/
       warning tokens added above. Lifted for contrast against a near-black
       surface, same reasoning as --opc-danger's own lift on this line. */
    --opc-danger-bg: rgba(234, 134, 143, .18);
    --opc-danger-border: rgba(234, 134, 143, .4);
    --opc-warning: #ffb454;
    --opc-warning-bg: rgba(255, 180, 84, .18);
    --opc-warning-border: rgba(255, 180, 84, .4);
    --opc-status-confirmed: #2f9e5c;
    --opc-status-completed: #22b8cf;
    --opc-status-pending: #d4a017;
    --opc-text-closed: var(--bs-tertiary-color, #77839b);
    --opc-surface-alt: var(--bs-tertiary-bg, #1f2a3f);
    --opc-surface: var(--bs-secondary-bg, #161d2c);
    --opc-surface-sunken: var(--bs-body-bg, #0e131d);
    --opc-border: var(--bs-border-color, #2c3650);
    /* Was rgba(190, 205, 235, .10) — a 10%-opacity overlay that read as
       basically no border at all against --opc-surface (#161d2c), the root
       cause of cards/dividers reading edge-less across the site (Activity's
       .opc-list-card, several Store product-page dividers, etc. — 16+ rules
       share this one token). Derived from --opc-border itself (already a
       visible value) rather than another hand-picked literal, so raising
       --opc-border's own contrast in future automatically raises this too. */
    --opc-border-light: color-mix(in srgb, var(--opc-border) 55%, var(--opc-surface));
    --opc-text: var(--bs-body-color, #e6e9f0);
    --opc-text-faint: var(--bs-tertiary-color, #77839b);
    --opc-text-soft: var(--bs-secondary-color, #a7b0c4);
    /* 3.35+: was #566078 on --opc-surface-sunken (#0e131d) — ~1.9:1, fails
       WCAG AA. Lifted for ~4.6:1 (disabled/past calendar dates, see
       .hcc-day-card.hcc-day-past / .hcc-book-past below). */
    --opc-text-placeholder: #8b95ad;
    /* Same --opc-shadow-color derivation as the light :root block above,
       just a black default here instead of slate — dark surfaces need a
       darker/denser shadow to read as elevated at all. */
    --opc-shadow-color: #000000;
    --opc-shadow-resting: 0 1px 2px color-mix(in srgb, var(--opc-shadow-color) 30%, transparent),
                          0 1px 1px color-mix(in srgb, var(--opc-shadow-color) 20%, transparent);
    --opc-shadow-lifted: 0 20px 30px -14px color-mix(in srgb, var(--opc-shadow-color) 60%, transparent),
                         0 8px 12px -6px color-mix(in srgb, var(--opc-shadow-color) 40%, transparent);

    /* Dark-mode identity — brighter accent (dark surfaces need more
       lightness to read the same hue as they do on white, same reasoning as
       the --bs-link-color lift above) + a glow shadow standing in for light
       mode's neomorphic shadow pair. Applied to hero/CTA elements only (see
       .opc-glow / .btn-primary below) — most surfaces stay flat-and-dark.
       Brighter than light mode's coral (#ea580c) for the same reason this
       comment already states for the rest of dark-mode identity — the
       secondary/navigational pill buttons (.btn.opc-home-cta,
       .opc-home-hero-toggle) read this token too, see the light :root's own
       accent comment. */
    --opc-accent: #f97316;
    --opc-gradient-primary: linear-gradient(135deg, var(--opc-primary), var(--opc-accent));
    --opc-glow-shadow: 0 0 1px color-mix(in srgb, var(--opc-accent) 60%, transparent),
                      0 0 40px color-mix(in srgb, var(--opc-primary) 35%, transparent);
    --opc-card-shadow: var(--opc-shadow-lifted);
}

/* Bootstrap's own compiled `body` rule is supposed to read --bs-body-bg/
   -color live, which would make the retuning above enough on its own — but
   a live screenshot showed body staying light while every compound-
   selector rule below (.card etc) correctly went dark, meaning Cassiopeia's
   own compiled CSS is winning that fight for body specifically, the same
   class of problem the component-hotfix section further down exists to
   solve. Same fix: set the rendered colour directly, with !important. */
[data-bs-theme="dark"] body {
    background-color: var(--bs-body-bg, #1a1d24) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}

/* Dark-mode background (design-refresh pass) — the paw-print tile that used
   to layer on top of --bs-body-bg here is REMOVED, same as the light-mode
   rule above. A matching subtle gradient instead of a flat fill — not
   !important, since nothing else sets background-image in dark mode after
   this removal, so a plain declaration already layers correctly on top of
   the background-color set with !important just above. */
[data-bs-theme="dark"] body {
    background-image: linear-gradient(180deg,
        color-mix(in srgb, var(--bs-body-bg, #1a1d24) 90%, white),
        var(--bs-body-bg, #1a1d24) 480px);
}

[data-bs-theme="light"] {
    color-scheme: light;
}

/* Base elements Bootstrap doesn't fully hand off to variables */
[data-bs-theme="dark"] hr {
    border-color: var(--bs-border-color);
    opacity: .5;
}
[data-bs-theme="dark"] ::selection {
    background-color: rgba(240, 160, 50, .35);
    color: var(--bs-emphasis-color);
}

/* Shadows — Bootstrap's utility shadow classes are pure black at a fixed
   alpha regardless of theme, far too heavy against an already-dark body */
[data-bs-theme="dark"] .shadow,
[data-bs-theme="dark"] .shadow-lg {
    box-shadow: var(--opc-shadow-lifted) !important;
}
[data-bs-theme="dark"] .shadow-sm {
    box-shadow: var(--opc-shadow-resting) !important;
}

/* Cassiopeia chrome — best-effort selector names (no local copy of the
   template to read class names back from); an unmatched selector here is
   simply inert, never destructive. */
/* Not scoped to [data-bs-theme="dark"] — --opc-surface/--opc-border already
   resolve to the right value per theme on their own (light default in
   :root, branded/derived value under [data-bs-theme="dark"] further down
   this file), so one unscoped rule covers both instead of needing a light
   copy and a dark copy that could drift apart. Before this, LIGHT mode
   header/footer were never touched at all and stayed on big_goose's own
   stock --big_goose-color-primary navy (#112855) — completely unrelated to
   branding — while only dark mode got the --opc-surface fix.
   `header.header`/`footer.footer` (element+class, specificity 0-1-1)
   deliberately beat big_goose's own `.container-header`/`.footer`
   (0-1-0) regardless of stylesheet load order — same reasoning as the
   sticky-header rule above.
   background-image: none is required alongside background-color —
   big_goose's _header.scss/_footer.scss also paint an opaque
   linear-gradient keyed to --big_goose-color-primary/-hover, a completely
   separate, un-branded variable pair. background-image and background-color
   are different properties, so overriding only the colour never contests
   that gradient; it keeps painting over the branded fill regardless of
   selector specificity. */
.header,
header.header,
#header {
    background-color: var(--opc-surface);
    background-image: none;
    border-bottom: 1px solid var(--opc-border);
}
[data-bs-theme="dark"] .site-title,
[data-bs-theme="dark"] .site-title a {
    color: var(--bs-heading-color);
}
.footer,
footer.footer,
#footer {
    background-color: var(--opc-surface);
    background-image: none;
    border-top: 1px solid var(--opc-border);
    color: var(--opc-text-soft);
}
[data-bs-theme="dark"] .offcanvas,
[data-bs-theme="dark"] .offcanvas-body {
    background-color: var(--bs-secondary-bg) !important;
    color: var(--bs-body-color) !important;
}
[data-bs-theme="dark"] .search input.form-control,
[data-bs-theme="dark"] .finder-selected-tag {
    background-color: var(--bs-tertiary-bg);
    border-color: var(--bs-border-color);
    color: var(--bs-body-color);
}
[data-bs-theme="dark"] .metismenu .nav-link,
[data-bs-theme="dark"] .metismenu a {
    color: var(--bs-body-color);
}
[data-bs-theme="dark"] .metismenu .nav-link:hover,
[data-bs-theme="dark"] .metismenu a:hover {
    background-color: var(--bs-tertiary-bg);
}

/* Form controls — light mode (3.26). Recessed/"pressed-in" neomorphic
   treatment (same --opc-neo-shadow-in token .opc-surface-inset uses) so a
   field reads as part of the soft-shadow surface system, not stock
   Bootstrap chrome. Includes .opc-select-trigger (the visible closed box
   customselect.js swaps in for every real <select> — see that section
   below) explicitly rather than relying on it merely inheriting the
   .form-select class it also carries, so this can't silently stop applying
   if that inheritance ever changes. Scoped like the Button/Card sections
   above so core Joomla forms elsewhere on the site are untouched. */
[class^="com-onepetconnect-"] .form-control,
[class^="com-onepetconnect-"] .form-select,
[class^="com-onepetconnect-"] .opc-select-trigger,
.opc-profile-wrap .form-control,
.opc-profile-wrap .form-select,
.opc-profile-wrap .opc-select-trigger,
.opc-profile-page .form-control,
.opc-profile-page .form-select,
.opc-profile-page .opc-select-trigger {
    background-color: var(--opc-surface);
    box-shadow: var(--opc-neo-shadow-in);
    border-color: transparent;
}
/* The chevron belongs ONLY to the select-shaped controls — .form-select and
   the .opc-select-trigger customselect.js swaps in for one. It was previously
   set in the block above, which also covers .form-control, so EVERY plain text
   input on a profile/component page was painted with a dropdown arrow and read
   as a select that could not be opened (reported live on the emergency-contact
   name/number fields, but it affected every text input these selectors reach).
   Splitting it out is the whole fix: the surface + inset-shadow treatment above
   still applies to all three, only the arrow narrows.

   Replaces big_goose's stock chevron sprite (media/images/select-bg.svg) — see
   the dark-mode block below for why: that sprite is ~97% an opaque white rect,
   and at its own background-size:116rem it paints solid white over almost the
   whole control face in EITHER theme, not just dark. This small inline chevron
   is the only image actually rendered, correctly sized, so background-color is
   what shows through everywhere else. */
[class^="com-onepetconnect-"] .form-select,
[class^="com-onepetconnect-"] .opc-select-trigger,
.opc-profile-wrap .form-select,
.opc-profile-wrap .opc-select-trigger,
.opc-profile-page .form-select,
.opc-profile-page .opc-select-trigger {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23495057' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M2 5l6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right .9rem center;
    background-size: 14px 10px;
}
/* A text input must carry no image at all — big_goose's own .form-control rule
   still points at that stock select-bg.svg sprite, so simply omitting the
   chevron above would let the sprite through and paint the control face solid
   white instead. `none` is what actually clears it. */
[class^="com-onepetconnect-"] .form-control,
.opc-profile-wrap .form-control,
.opc-profile-page .form-control {
    background-image: none;
}
[class^="com-onepetconnect-"] .form-control:focus,
[class^="com-onepetconnect-"] .form-select:focus,
[class^="com-onepetconnect-"] .opc-select-trigger:focus-visible,
.opc-profile-wrap .form-control:focus,
.opc-profile-wrap .form-select:focus,
.opc-profile-wrap .opc-select-trigger:focus-visible,
.opc-profile-page .form-control:focus,
.opc-profile-page .form-select:focus,
.opc-profile-page .opc-select-trigger:focus-visible {
    /* Inset shadow stays (still "pressed in") + a real outer ring on top —
       focus visibility must never depend on the soft shadow alone. */
    box-shadow: var(--opc-neo-shadow-in), 0 0 0 .2rem var(--opc-primary-ring);
    border-color: var(--opc-primary);
}

/* customselect.js's popup (.opc-select-menu, built from Bootstrap's own
   .dropdown-menu/.dropdown-item) never had a LIGHT-mode rule at all — only
   the dark-mode hotfix further down (~L1114) exists, so in light mode it was
   always big_goose's stock white (template.css ~L15053, `.dropdown-menu {
   background-color: #fff }`), stock white read wrong against every other
   neomorphic surface on the page even though it's "correct" Bootstrap
   default. Matches the trigger's own light-mode treatment immediately above
   instead of leaving the open list the only unstyled piece of the control. */
[class^="com-onepetconnect-"] .dropdown-menu,
.opc-profile-wrap .dropdown-menu,
.opc-profile-page .dropdown-menu {
    background-color: var(--opc-surface);
    border-color: var(--opc-border-light);
    box-shadow: var(--opc-card-shadow);
}
[class^="com-onepetconnect-"] .dropdown-item:hover,
[class^="com-onepetconnect-"] .dropdown-item:focus,
.opc-profile-wrap .dropdown-item:hover,
.opc-profile-wrap .dropdown-item:focus,
.opc-profile-page .dropdown-item:hover,
.opc-profile-page .dropdown-item:focus {
    background-color: var(--opc-surface-sunken);
}

/* Form controls — dark mode. !important-forced same as the .card/.table/
   .dropdown-menu hotfixes above: confirmed (3.17.0) this block was the one
   component-local dark-mode consumer that never got that treatment, so a
   select's own compiled Bootstrap background could still beat the bare
   attribute rule exactly the same way .card's did before its own fix. */
[data-bs-theme="dark"] .form-control,
[data-bs-theme="dark"] .form-select,
[data-bs-theme="dark"] .opc-select-trigger,
[data-bs-theme="dark"] input.form-control,
[data-bs-theme="dark"] textarea.form-control {
    background-color: var(--bs-tertiary-bg) !important;
    border-color: var(--bs-border-color) !important;
    color: var(--bs-body-color) !important;
    /* <select>'s own native OPTIONS POPUP doesn't inherit CSS colour at
       all — some browsers key its light/dark rendering off color-scheme
       on the control itself, not only an ancestor. */
    color-scheme: dark;
}
[data-bs-theme="dark"] .form-control::placeholder {
    color: var(--bs-tertiary-color) !important;
}
/* Recolours the chevron for dark backgrounds — !important needed to beat the
   light-mode chevron set above (same selector, no theme guard on that one).
   The root cause this whole control replaces (big_goose's stock chevron
   sprite painting solid white over background-color in EITHER theme) is
   explained where the light-mode version of this image is set, above. */
[data-bs-theme="dark"] .form-select,
[data-bs-theme="dark"] .opc-select-trigger {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23d7dae0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M2 5l6 6 6-6'/%3E%3C/svg%3E") !important;
    background-repeat: no-repeat !important;
    background-position: right .9rem center !important;
    background-size: 14px 10px !important;
}
/* Dark-mode half of the "a text input carries no image" rule set in the
   light-mode block above — needed separately because big_goose's stock
   select-bg.svg sprite is set unconditionally, not per theme. */
[data-bs-theme="dark"] .form-control:not(.form-select) {
    background-image: none !important;
}
[data-bs-theme="dark"] .form-control:focus,
[data-bs-theme="dark"] .form-select:focus,
[data-bs-theme="dark"] .opc-select-trigger:focus-visible {
    background-color: var(--bs-tertiary-bg) !important;
    color: var(--bs-body-color) !important;
    border-color: var(--bs-primary) !important;
    box-shadow: 0 0 0 .25rem rgba(240, 160, 50, .15);
}
/* Custom dropdown (media/com_onepetconnect/js/customselect.js) — replaces
   every native <select class="form-select"> site-wide. A plain <select>'s
   closed box CAN be dark-styled (see the .form-select rule above), but its
   OPEN OPTIONS POPUP on mobile is rendered by the OS/browser itself and
   cannot be themed by any CSS — confirmed from a live device screenshot
   still showing a stark white popup despite the closed box being correctly
   dark. This builds a real HTML popup instead (Bootstrap's own
   .dropdown-menu/.dropdown-item, already dark-styled above), so both the
   closed control and the open list are actually this platform's CSS, in
   both themes, on every device. */
.opc-select {
    position: relative;
}
.opc-select-trigger {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    text-align: left;
}
.opc-select-trigger:disabled {
    opacity: .65;
    cursor: not-allowed;
}
.opc-select-native {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}
.opc-select-menu {
    display: none;
    position: absolute;
    inset-inline: 0;
    top: 100%;
    z-index: 1055;
    margin-top: .25rem;
    max-height: 15rem;
    overflow-y: auto;
    min-width: 100%;
    /* The open list is its own scroll container. Without an explicit pan-y, a
       touch-drag started on an option can be claimed as a page gesture rather
       than scrolling the list — same class of bug as the calendar blocks in
       timegrid.js. overscroll-behavior stops a scroll that reaches the end of
       the list from chaining into the page behind it. */
    touch-action: pan-y;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
}
/* Once open, customselect.js sets max-height/overflow-y inline from the real
   space between the trigger and the mobile bar, so a list that fits shows in
   FULL and only an oversized one scrolls. The 15rem above is just the
   pre-JS/unenhanced fallback. */
/* customselect.js switches this to position:fixed (inline styles) the moment
   it opens, computed from the trigger's live viewport rect, so a trigger
   sitting near the bottom of an `overflow: hidden` card (e.g.
   .hcc-product-card) isn't clipped before the menu can render below it. The
   position:absolute values above are only the closed/pre-JS fallback. */
.opc-select-menu.show {
    display: block;
}
.opc-select-option {
    display: block;
    width: 100%;
    text-align: left;
    white-space: normal;
}

/* Combobox picker — a filtered suggestion dropdown under a plain text
   input, same positioned-menu shape as .opc-select-menu above, reusing the
   same already-dark-styled .dropdown-menu/.dropdown-item. Unlike .opc-select
   this never replaces the underlying control — the input stays a normal,
   focusable text field. Currently used by breedpicker.js (pet edit form's
   free-text Breed field, local array match); kept as a general-purpose
   primitive for any future free-text-with-suggestions field, not tied to
   that one consumer. */
.opc-combo-picker {
    position: relative;
}
.opc-combo-picker-menu {
    display: none;
    position: absolute;
    inset-inline: 0;
    top: 100%;
    z-index: 1055;
    margin-top: .25rem;
    max-height: 15rem;
    overflow-y: auto;
    min-width: 100%;
}
.opc-combo-picker-menu.show {
    display: block;
}
.opc-combo-picker-option {
    display: block;
    width: 100%;
    text-align: left;
    white-space: normal;
}

[data-bs-theme="dark"] .form-check-input {
    background-color: var(--bs-tertiary-bg) !important;
    border-color: var(--bs-border-color) !important;
}

/* Breadcrumbs / pagination */
[data-bs-theme="dark"] .breadcrumb {
    background-color: transparent;
}
[data-bs-theme="dark"] .page-link {
    background-color: var(--bs-secondary-bg);
    border-color: var(--bs-border-color);
    color: var(--bs-body-color);
}
[data-bs-theme="dark"] .page-item.active .page-link {
    background-color: var(--bs-primary);
    border-color: var(--bs-primary);
    color: var(--bs-primary-contrast, #fff);
}

/* ----------------------------------------------------------------------
   Card surface treatment (3.26) — every component card picks up the same
   .opc-surface-raised look (soft-shadow in light, flat+lifted in dark;
   see the Surface system section further down for the full rationale).
   Scoped to component views + the two profile pages, same convention the
   Button system section below uses, so core Joomla/article cards elsewhere
   on the site are untouched. Radius/shadow only — background/border colour
   in dark mode is still owned by the hotfix immediately below (it needs
   !important to win a Bootstrap specificity fight this rule doesn't need
   to enter, since border-radius/box-shadow aren't in that fight).

   --card-border-radius (3.26 follow-up, corrected 3.26.x): a card's OWN
   border-radius was set directly above, but the compiled template's own
   .card-header:first-child / .card-footer:last-child rules compute THEIR
   corner radius from a separate variable, not from the .card element's own
   rendered border-radius — still its stock small default until set here, so
   any card with a header/footer (every modal, the pet-card footer button,
   the profile page's card headers) had a small round-off next to the card's
   now much bigger 18px corners: the exact "layers overlapping, corners
   sticking out" bug reported live, one level up from the thumbnail-image
   version of the same mismatch fixed just above this section.

   REAL BUG fixed here: this originally set `--bs-card-border-radius` — the
   Bootstrap-5.3-with-bs-prefix name — but big_goose (media/css/template.css)
   is a standalone Cassiopeia fork compiled WITHOUT the `bs-` prefix (its own
   `.card` rule defines `--card-border-radius`/`--card-inner-border-radius`,
   never a `--bs-` variant), so that variable was never read by anything and
   the footer/header corner mismatch silently never actually got fixed —
   confirmed by reading the compiled template.css directly, not guessed.
   Every OTHER `--bs-*` custom property in this file is harmless despite the
   same wrong prefix, because each of those pairs a `var(--bs-x, fallback)`
   read with a hardcoded fallback colour AND a direct-property `!important`
   override elsewhere (see the "Component background hotfix" block below) —
   this border-radius property had no such fallback, so it was the one place
   the wrong prefix actually mattered. Named correctly now, the template's
   own calc() keeps every nested corner in sync automatically — no need to
   chase each child individually.

   STILL not enough on its own, though (confirmed live — footer corners kept
   sticking out even after the rename above): .card-footer:last-child's
   radius is drawn from `--card-inner-border-radius`, and the template's own
   .card rule computes THAT from the global `--border-radius` token
   (`calc(var(--border-radius) - var(--card-border-width))`), never from
   `--card-border-radius` at all — the two are independent variables that
   only coincidentally share a name prefix. Overriding just the outer one
   fixed the card's own corner but left the footer's calc untouched, still
   resolving to the template's small default. Both must be set here. */
[class^="com-onepetconnect-"] .card,
.opc-profile-wrap .card,
.opc-profile-page .card {
    --card-border-radius: var(--opc-radius-lg);
    --card-inner-border-radius: calc(var(--opc-radius-lg) - var(--card-border-width, 1px));
    border-radius: var(--opc-radius-lg);
    box-shadow: var(--opc-card-shadow);
    border-color: var(--opc-border-light);
}
/* A full-width button FILLING a .card-footer (e.g. the product grid's
   Delete button) is a different problem from the footer element itself:
   .card-footer:last-child already gets the right bottom corners for free
   from --card-inner-border-radius above (it's the compiled template's own
   calc()), but a <button> sitting inside it is just another .btn — after
   the pill-shape rule above, that means a full 999px radius, clipped flat
   by the footer's own straight edges, i.e. square in practice, not
   matching the footer's actual curve at all. Read the SAME inherited
   --card-inner-border-radius custom property directly (no separate token,
   so it can never drift from the footer's own radius the way the old
   `rounded-0 rounded-bottom` utility pair did — that pair used Bootstrap's
   generic .25rem, smaller than this card's real ~9px corner, so the
   button's corner sat inside the card's own curve and visibly poked out
   past it, confirmed live). Top corners stay flat (0) since the footer
   sits directly under the card body, no gap to round there. */
.opc-card-footer-btn {
    width: 100%;
    border-radius: 0 0 var(--card-inner-border-radius, .25rem) var(--card-inner-border-radius, .25rem);
}

/* Secondary/outline buttons — light mode ONLY (3.26). Per the plan: keep
   .btn-primary/.btn-danger fully solid so an actionable CTA never loses
   contrast, but a non-critical action (Store, My Pets, the calendar's
   Month/Week toggle) can take the same soft-shadow surface language as a
   card. Explicitly reset to flat inside a .btn-group — adjacent buttons in
   a group share touching edges/collapsed border-radius, and each carrying
   its own outer shadow would visually fight its neighbours instead of
   reading as one control.
   3.4x: that .btn-group reset zeroes the shadow but the base rule's own
   border-color was `transparent` — so any button inside a group (the
   booking page's Month/Week toggle, #hcc-prev/#hcc-next either side of
   it) had NEITHER a shadow NOR a border and vanished at rest. Real border
   + fill now, and .btn-group's own reset only needs to drop the shadow.
   !important REQUIRED on background-color/border-color here and on
   :hover (3.4x, confirmed live on the sibling .btn-outline-primary case):
   buttonfx.js drops .is-pressed on `pointerleave`, but native :active
   doesn't reliably let go at the same moment — so there's a real window
   where big_goose/template.css's (0,3,0) `:not(.btn-check) + .btn:active`
   rule (template.css:3055) can still be matching this element while
   NEITHER .is-pressed NOR :hover apply, meaning THIS base rule is what's
   actually on the hook, and its un-!important'd (0,2,0) loses that fight. */
[class^="com-onepetconnect-"] .btn-outline-secondary,
[class^="com-onepetconnect-"] .btn-secondary,
.opc-profile-wrap .btn-outline-secondary,
.opc-profile-page .btn-outline-secondary {
    box-shadow: var(--opc-neo-shadow-out);
    border-color: var(--opc-border) !important;
    background-color: var(--opc-surface-alt) !important;
    color: var(--opc-text-soft);
    transition: background-color .45s ease, border-color .45s ease, color .45s ease;
}
[class^="com-onepetconnect-"] .btn-outline-secondary:hover,
[class^="com-onepetconnect-"] .btn-secondary:hover,
.opc-profile-wrap .btn-outline-secondary:hover,
.opc-profile-page .btn-outline-secondary:hover {
    border-color: var(--opc-primary) !important;
    color: var(--opc-primary);
}
[class^="com-onepetconnect-"] .btn-group .btn,
.opc-profile-wrap .btn-group .btn,
.opc-profile-page .btn-group .btn {
    box-shadow: none;
}
/* Bootstrap ships no .active styling at all for .btn-outline-secondary, so
   a toggle's selected segment (Month vs Week) rendered identically to the
   unselected one — the specific "which view am I on" complaint. Solid
   primary fill, same "unmistakably picked" treatment used everywhere else
   keyed off --opc-primary (e.g. the timegrid's selected-slot pill). */
[class^="com-onepetconnect-"] .btn-outline-secondary.active,
[class^="com-onepetconnect-"] .btn-secondary.active,
.opc-profile-wrap .btn-outline-secondary.active,
.opc-profile-page .btn-outline-secondary.active {
    background-color: var(--opc-primary) !important;
    border-color: var(--opc-primary) !important;
    color: var(--opc-primary-contrast) !important;
    box-shadow: none;
}
/* Tap flash — same primary→accent→primary language as .opc-tab-switch-thumb's
   is-flash: applies regardless of .active (tapping Week while Month is still
   active flashes Week; the .active fill lands after buttonfx.js clears
   .is-pressed and view-toggle.js updates which button carries .active).
   Declared last so it wins the tie against both the rest and .active rules.
   !important REQUIRED (3.4x, same root cause as .btn-outline-primary.is-
   pressed above): this selector is (0,3,0) specificity, an exact TIE with
   big_goose/template.css's own compiled `:not(.btn-check) + .btn:active`
   rule (template.css:3055), which a real physical press also matches and
   which paints var(--btn-active-bg) — a literal, unbranded colour baked
   into .btn-outline-secondary at template build time. A specificity tie is
   decided by source order, which this platform cannot guarantee against a
   template file it doesn't control the load position of; !important is the
   only reliable win. */
[class^="com-onepetconnect-"] .btn-outline-secondary.is-pressed,
[class^="com-onepetconnect-"] .btn-secondary.is-pressed,
.opc-profile-wrap .btn-outline-secondary.is-pressed,
.opc-profile-page .btn-outline-secondary.is-pressed {
    background-color: var(--opc-accent) !important;
    border-color: var(--opc-accent) !important;
    color: var(--opc-primary-contrast) !important;
    box-shadow: none;
    transition: none;
}

/* Booking calendar's OUTER panel (3.26) — the box the Month/Week/Day grid
   sits inside gets the soft-shadow card treatment; the grid's own cells
   (timegrid.css / availability.css) are untouched — functional, high-risk
   to restyle, explicitly out of scope. */
.hcc-book-cal {
    background: var(--opc-surface);
    border-radius: var(--opc-radius-lg);
    box-shadow: var(--opc-card-shadow);
    padding: .75rem;
    max-width: 640px;
    margin-inline: auto;
}

/* Shared frame for the Service/Option/Pet row + Month/Week/nav row, above
   the calendar. Wider than .hcc-book-cal's own 640px cap on purpose — the
   Week/Day timegrid (timegrid.css) flex-shrinks cleanly and reads best with
   more room, while Month's narrower calendar just centers inside this. */

/* Standardised content column (3.35) — every component page gets the same
   reading-focused 900px column, keeping the user's eye centered instead of
   sprawling across a wide desktop viewport (same fix as the booking page
   above, generalised). Sole exception: .com-onepetconnect-home (the site
   root) keeps its own full-width hero layout — not a form/list page,
   narrowing it would fight its design. Pet profile (.opc-profile-wrap) is
   NOT exempted — its own max-width rule further down is set to the same
   900px instead of a separate 1100px, so it matches every other page. */
.com-onepetconnect-booking,
.com-onepetconnect-bookings,
.com-onepetconnect-store,
.com-onepetconnect-activity,
.com-onepetconnect-pet-edit,
.com-onepetconnect-product,
.com-onepetconnect-dashboard,
.com-onepetconnect-pets,
.com-onepetconnect-orders,
.com-onepetconnect-order,
.com-onepetconnect-cart,
.opc-profile-page {
    max-width: 900px;
    margin-inline: auto;
}
/* Suppresses every transition below while booking.js's initial single-pet/
   single-choice auto-fill cascade is resolving (see #hcc-booking's own
   hcc-no-anim comment in booking.js) — without this, a page load that lands
   straight on a completed Pet+Service+Option selection could visibly
   animate through the intermediate narrow/locked states instead of just
   landing on the final one. Removed via double rAF once the cascade
   settles, so real, user-driven changes still animate normally. */
#hcc-booking.hcc-no-anim #hcc-book-fields-row,
#hcc-booking.hcc-no-anim #hcc-book-fields-row .hcc-field {
    transition: none !important;
}
.hcc-book-controls {
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius-lg);
    box-shadow: var(--opc-card-shadow);
    padding: 1rem 1.1rem .85rem;
    margin-bottom: .4rem;
}
/* Mobile (<1200px, before the desktop flex layout below takes over): the
   Pet/Service/Option fields stack as plain col-md-4s, so Bootstrap's g-3
   row-gutter is what puts space ABOVE each stacked field via --bs-gutter-y
   margin-top. Trimmed from the 1rem default to match the rest of this box's
   tightened spacing. */
#hcc-book-fields-row {
    --bs-gutter-y: .5rem;
}
/* Deliberately NOT sticky (was, briefly — see git history). A sticky
   .hcc-book-controls stacked against the booking header's own sticky top:0
   (since removed — the header now shares plain .opc-page-header mb-3 with
   every other page) needed a live-measured offset between them, and broke silently
   whenever either bar's real height changed (e.g. header.header collapsing
   to 0 when mobile chrome moved to the nav bar) — the two bars landed on
   the same offset and overlapped instead of stacking. Plain flow avoids the
   whole offset-tracking problem: the Pet/Service/Option selectors and
   Month/Week toggle scroll away with the rest of the page like everything
   else on the site, at the cost of needing a scroll back up to change them
   while deep in a tall Week/Day view. */

/* Desktop width reveal — same progressive idea as the mobile sticky bar
   above, but growing WIDTH instead of scroll position: the Pet/Service/
   Option ROW ONLY (#hcc-book-fields-row) starts just wide enough for the Pet
   field, centered, then widens as Service then Option come into play
   (booking.js sets [data-fields] via updateChainVisibility()). Deliberately
   scoped to that one row, not the whole .hcc-book-controls box — the
   Month/Week toggle + calendar-nav row below it (#hcc-book-nav-row) needs
   its full width regardless of how many dropdowns are showing; shrinking
   the whole container squeezed that row too (live-screenshot regression,
   fixed). Gated at 1200px — this project's own actual mobile/desktop line
   (.opc-mobilebar / desktop chrome switch at the same breakpoint, see
   plg_system_opctheme) — NOT Bootstrap's generic 768px "md". A "PC" browser
   narrower than 1200px is still mobile chrome here, so it keeps the plain
   stacked layout (nothing to animate — reveals by height instead, the
   sticky rule above); ≥1200px is the only range where side-by-side is
   promised.

   Field layout is a plain, ungapped-math flex row (display:flex;
   flex-wrap:nowrap) with every visible .hcc-field sharing space equally
   (flex:1 1 0) — NOT percentage widths cloned from Bootstrap's .col-md-4.
   A percentage-based attempt here previously still wrapped the 3rd column
   onto its own line (live-screenshot regression): this template's compiled
   .col-md-4 (big_goose/media/css/template.css) is `flex: none; width:
   33.3333%`, and even after overriding both `flex`/`width` explicitly,
   Bootstrap's own .row gutter math (negative margins + column padding from
   .g-3) left just enough cumulative overflow to trigger wrap. Sidestepping
   that entirely — flex:1 1 0 with flex-wrap:nowrap can't wrap and needs no
   gutter-percentage arithmetic at all, regardless of 1, 2, or 3 fields
   being shown.

   --opc-book-nav-w (measured live by booking.js's syncNavRowWidth(), since
   CSS can't reference a sibling's natural width) is used as a floor via
   max() so the narrow steps never look thinner than #hcc-book-nav-row's own
   unwrapped content width — falls back to the fixed rem value if JS hasn't
   run yet or is unavailable. */
@media (min-width: 1200px) {
    #hcc-book-fields-row {
        display: flex;
        flex-wrap: nowrap;
        gap: 1rem;
        margin-inline: auto;
        transition: max-width .35s ease;
    }
    #hcc-book-fields-row[data-fields="1"] { max-width: max(22rem, var(--opc-book-nav-w, 22rem)); }
    #hcc-book-fields-row[data-fields="2"] { max-width: max(42rem, var(--opc-book-nav-w, 42rem)); }
    #hcc-book-fields-row[data-fields="3"] { max-width: 100%; }

    #hcc-book-fields-row .hcc-field {
        flex: 1 1 0;
        width: auto;
        max-width: none;
        transition: flex-grow .35s ease;
    }
}
[data-bs-theme="dark"] .hcc-book-cal {
    border: 1px solid var(--opc-border);
}
/* Greys out Month/Week and blocks clicks until Service + Option are both
   chosen (booking.js toggles [hidden] via updateChainVisibility()) — avoids
   ever reaching the slot modal / timegrid with no priced, scheduled Option
   to attach a booking to. #hcc-calendar-area is the shared positioning
   context for both #hcc-month-wrap and #hcc-timegrid (only one of the two
   is ever unhidden at once — see renderCurrentView()), so one overlay
   covers whichever is showing. Scrim uses the same rgba(0,0,0,.5) as
   .opc-modal-backdrop (theme-neutral, already proven to read fine in both
   modes); the message card itself draws from --opc-surface/--opc-border so
   it stays legible in both themes without a separate dark-mode override. */
.hcc-calendar-area {
    position: relative;
}
.hcc-calendar-lock {
    position: absolute;
    inset: 0;
    z-index: 5;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 1.5rem;
    background: rgba(0, 0, 0, .55);
    border-radius: var(--opc-radius-lg);
}
.hcc-calendar-lock[hidden] { display: none; }
.hcc-calendar-lock p {
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius-lg);
    padding: .85rem 1.25rem;
    max-width: 26rem;
    box-shadow: var(--opc-card-shadow);
}

/* ----------------------------------------------------------------------
   Component background hotfix — Bootstrap's OWN compiled CSS declares
   --bs-card-bg (and siblings) LOCALLY on component selectors like .card,
   .list-group-item, .dropdown-menu, .modal-content — a selector that can
   beat a bare [data-bs-theme="dark"] attribute rule on specificity/cascade
   order. Confirmed live: without this, the body goes dark correctly but
   every card/table/dropdown stays stuck light. Fixed by setting the actual
   rendered colours directly, with !important — the only place in this file
   that reaches for it, deliberately, because there's no real browser here
   to verify a subtler specificity-only fix actually wins.
   ---------------------------------------------------------------------- */
[data-bs-theme="dark"] .card {
    /* --opc-surface/--opc-border, not --bs-secondary-bg/--bs-border-color —
       the latter never pick up brand_dark_bg/brand_dark_surface, which left
       plain .card components on a different dark tone than .opc-list-card/
       .hcc-book-cal (both already read --opc-surface directly). */
    background-color: var(--opc-surface) !important;
    border-color: var(--opc-border) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .card-footer {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .table-responsive {
    background-color: var(--bs-secondary-bg, #23272f) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
}
[data-bs-theme="dark"] .list-group-item {
    background-color: var(--bs-secondary-bg, #23272f) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .dropdown-menu {
    background-color: var(--bs-secondary-bg, #23272f) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
}
[data-bs-theme="dark"] .dropdown-item {
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .dropdown-item:hover,
[data-bs-theme="dark"] .dropdown-item:focus {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
    color: var(--bs-emphasis-color, #f5f6f8) !important;
}
[data-bs-theme="dark"] .modal-content {
    background-color: var(--bs-secondary-bg, #23272f) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .accordion-item,
[data-bs-theme="dark"] .accordion-button {
    background-color: var(--bs-secondary-bg, #23272f) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .accordion-button:not(.collapsed) {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
}
[data-bs-theme="dark"] .nav-tabs .nav-link.active {
    background-color: var(--bs-secondary-bg, #23272f) !important;
    border-color: var(--bs-border-color, #3a4049) var(--bs-border-color, #3a4049) var(--bs-secondary-bg, #23272f) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .table {
    --bs-table-bg: transparent !important;
    --bs-table-striped-color: var(--bs-body-color, #d7dae0) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
/* Bootstrap's per-cell rule (`.table > :not(caption) > * > *`) reads
   --bs-table-color, "inherit" as its own fallback — inheriting from a
   custom property set on the ANCESTOR <table> should already reach every
   <th>/<td>, but this file has now been wrong trusting that kind of
   indirection on `body` and `.card` too (see those fixes above), so this
   is the same "stop trusting it, force it directly" treatment applied to
   table cells specifically, found from a live screenshot showing thead
   text nearly invisible against its own header background. */
[data-bs-theme="dark"] .table > :not(caption) > * > * {
    color: var(--bs-body-color, #d7dae0) !important;
    border-bottom-color: var(--bs-border-color, #3a4049) !important;
}
[data-bs-theme="dark"] .table > thead > tr > th {
    color: var(--bs-body-color, #d7dae0) !important;
    border-bottom-color: var(--bs-border-color, #3a4049) !important;
}
/* .text-muted/.text-body-secondary — Bootstrap's own utility for "de-
   emphasised" text (a booking's variation name, a pet's species/breed
   label, sub-notes throughout this component). Found nearly invisible in
   a live screenshot: whatever value it actually resolves to isn't reliably
   following --bs-secondary-color's retuning above (same trust-the-
   indirection problem, different consumer) — forced directly instead. */
[data-bs-theme="dark"] .text-muted,
[data-bs-theme="dark"] .text-body-secondary,
/* .form-text — same indirection failure as .text-muted above: Bootstrap's
   default field-description colour stays near-black regardless of theme.
   Reported live on the booking modal's "already booked" warning line,
   unreadable against the dark modal card. Folded into the same rule rather
   than a new one, since both are the identical bug on a different utility
   class. */
[data-bs-theme="dark"] .form-text {
    color: var(--bs-secondary-color, #aeb3bc) !important;
}
/* .text-body — same indirection failure, different utility: the Store
   product-name link (default.php) sits on a dark card via .text-body and
   was rendering near-invisible for the same reason .text-muted was. */
[data-bs-theme="dark"] .text-body {
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .alert:not(.alert-info):not(.alert-danger):not(.alert-warning):not(.alert-success) {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}

/* Secondary/neutral buttons + badges — Bootstrap's own --bs-secondary (the
   semantic colour .btn-outline-secondary/.btn-secondary/.badge.bg-secondary
   derive from) is a DIFFERENT variable family from the surface-tier ones
   above and is never retuned by anything, anywhere — at its stock
   light-mode value (mid-grey, built to read on white) it goes muddy on any
   dark surface here. Deliberately NOT retuning --bs-secondary itself (it
   also feeds .text-secondary/.border-secondary utilities elsewhere, with
   less predictable fallout); targeted component overrides instead.

   3.23.20: solid fill by default, not just on hover — reported live as
   unreadable against the Home page's paw-print background (3.16.2): a
   border-only outline button in a colour this close to --bs-body-bg
   visually disappears into a background that's already faintly textured,
   not just a flat colour. Every "outline" button on this platform now
   reads as a real, visible button at rest, not only once touched. */
[data-bs-theme="dark"] .btn-outline-secondary {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
    color: var(--bs-body-color, #d7dae0) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
}
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus,
[data-bs-theme="dark"] .btn-outline-secondary:active {
    background-color: var(--bs-border-color, #3a4049) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
    color: var(--bs-emphasis-color, #f5f6f8) !important;
}
[data-bs-theme="dark"] .btn-secondary {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus,
[data-bs-theme="dark"] .btn-secondary:active {
    background-color: var(--bs-border-color, #3a4049) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
    color: var(--bs-emphasis-color, #f5f6f8) !important;
}
[data-bs-theme="dark"] .badge.bg-secondary {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
/* 3.4x: dark mode never got the light-mode Month/Week toggle fix above
   (.active selected-state fill, .is-pressed accent flash) — this block
   only ever carried the 3.23.20 "solid at rest" treatment. Same two gaps,
   same fix, dark-appropriate colours. --opc-primary/--opc-accent are used
   directly (not --bs-* substitutes like the rest of this block) because
   both already resolve correctly under [data-bs-theme="dark"] via the
   dark :root block earlier in this file — these are brand-colour fills,
   not neutral-surface colours, so there's no dark-specific literal to
   pick. !important required on both for the same reason as the light-mode
   versions: big_goose/template.css's compiled `:not(.btn-check) +
   .btn:active` rule (template.css:3055, specificity (0,3,0)) isn't
   theme-scoped and applies identically in dark mode, beating any
   un-!important'd rule here regardless of theme. */
[data-bs-theme="dark"] .btn-outline-secondary.active,
[data-bs-theme="dark"] .btn-secondary.active {
    background-color: var(--opc-primary) !important;
    border-color: var(--opc-primary) !important;
    color: var(--opc-primary-contrast) !important;
}
[data-bs-theme="dark"] .btn-outline-secondary.is-pressed,
[data-bs-theme="dark"] .btn-secondary.is-pressed {
    background-color: var(--opc-accent) !important;
    border-color: var(--opc-accent) !important;
    color: var(--opc-primary-contrast) !important;
    transition: none;
}

/* Contextual alerts (.alert-info/-success/-warning/-danger) — same class of
   gap as --bs-secondary above, third time this exact bug shape has bitten a
   screenshot. Bootstrap 5.3's alerts read the "subtle" trio
   (--bs-*-bg-subtle/-border-subtle/-text-emphasis) that Bootstrap itself
   only retunes as part of ITS OWN compiled dark mode — which nothing here
   ever enabled (Cassiopeia's own dark-mode Sass flag is off in this
   codebase's reference copy of the template), so on a dark page these sat
   at their stock light-mode pastel values untouched: a pale blue "not yet
   assessed" notice on an otherwise-dark page. Retuned to Bootstrap's own
   documented dark-mode values (not invented — these are the values
   Bootstrap 5.3 itself ships for exactly this purpose) AND forced directly
   onto the alert selectors with !important, the same belt-and-braces
   pattern used everywhere else in this file — trusting "retune the
   variable, Bootstrap will pick it up" has been wrong twice already here
   (body, .card). --bs-light also retuned: anything using .bg-light/
   .text-bg-light (a common "neutral panel" utility) had the identical
   problem. */
/* 3.26.32: contextual alert banners disabled platform-wide per product
   decision — hidden outright rather than restyled. The dark-mode retuning
   below is now dead in practice (nothing left to retune once display:none
   applies) but left in place as harmless should the hide rule ever be
   reverted.

   Two distinct markup shapes needed covering, confirmed live via screenshot
   after the first pass missed one: (1) Bootstrap's own .alert-success/
   -danger/-warning/-info classes, used by this component's own templates
   (21 files under com_onepetconnect/*/tmpl), and (2) Joomla core's system
   messages ("You have been logged out", "Please login first", etc.), which
   render as a <joomla-alert type="success|error|warning|info|notice|message">
   custom element in #system-message-container, styled entirely by the
   [type="..."] attribute — no .alert-* class ever lands on that element, so
   the Bootstrap-class rule alone never touched it. */
.alert-success,
.alert-danger,
.alert-warning,
.alert-info {
    display: none !important;
}
#system-message-container joomla-alert {
    display: none !important;
}

/* EXCEPTION (registration, 3.35.x): the alert-hiding rules above swallowed
   Joomla's OWN core registration errors too — a duplicate username/email, a
   password core's own rules reject, an expired session token, a failed
   CAPTCHA — with zero visible explanation. The user was bounced back to a
   blank Step 1 with no way to tell why (see big_goose/html/com_users/
   registration/default.php, which now renders the message queue explicitly
   on this page for exactly that reason). Scoped via body.view-registration
   (Cassiopeia/big_goose's own index.php already stamps `view-<view>` on
   <body> for every page — no extra markup needed here) so every OTHER
   page's alerts stay hidden exactly as before; this is the one deliberate
   carve-out, not a reversal of the platform-wide decision. */
body.view-registration .alert-success,
body.view-registration .alert-danger,
body.view-registration .alert-warning,
body.view-registration .alert-info,
/* EXCEPTION (assessment/report-card question delete guards): both question
   builders' delete tasks can REFUSE for a deliberate data-integrity reason —
   AssessmentQuestionHelper::blockedDeleteReason() / ReportCardQuestionHelper::
   blockedDeleteReason() won't delete a question that already has real
   answers on file (would orphan a historical assessment's question_id) or
   that another question still depends on. That refusal is correct and by
   design — see CLAUDE.md's pets-are-never-hard-deleted precedent for the
   same instinct — but with the platform-wide alert-hide rule, it LOOKED
   identical to "nothing happened": the page redirects back to the list, no
   error visible anywhere (confirmed live). Scoped to the four views a
   question save/delete can land back on. */
body.view-assessmentquestionedit .alert-success,
body.view-assessmentquestionedit .alert-danger,
body.view-assessmentquestionedit .alert-warning,
body.view-assessmentquestionedit .alert-info,
body.view-assessmentquestions .alert-success,
body.view-assessmentquestions .alert-danger,
body.view-assessmentquestions .alert-warning,
body.view-assessmentquestions .alert-info,
body.view-reportcardquestionedit .alert-success,
body.view-reportcardquestionedit .alert-danger,
body.view-reportcardquestionedit .alert-warning,
body.view-reportcardquestionedit .alert-info,
body.view-assessments .alert-success,
body.view-assessments .alert-danger,
body.view-assessments .alert-warning,
body.view-assessments .alert-info {
    display: block !important;
}
/* display: flex, NOT block — confirmed against Cassiopeia's own vendored
   joomla-alert.css (reference/cassiopeia-6.1.2/media/css/vendor/joomla-
   custom-elements/joomla-alert.css): the element lays out its icon/heading/
   body internally via flex, and forcing `block` here would break that
   internal layout rather than just make the element visible. */
body.view-registration #system-message-container joomla-alert,
body.view-assessmentquestionedit #system-message-container joomla-alert,
body.view-assessmentquestions #system-message-container joomla-alert,
body.view-reportcardquestionedit #system-message-container joomla-alert,
body.view-assessments #system-message-container joomla-alert {
    display: flex !important;
}

[data-bs-theme="dark"] {
    --bs-info-bg-subtle: #032830;
    --bs-info-border-subtle: #055160;
    --bs-info-text-emphasis: #6edff6;
    --bs-success-bg-subtle: #051b11;
    --bs-success-border-subtle: #0a3622;
    --bs-success-text-emphasis: #75b798;
    --bs-warning-bg-subtle: #332701;
    --bs-warning-border-subtle: #664d03;
    --bs-warning-text-emphasis: #ffda6a;
    --bs-danger-bg-subtle: #2c0b0e;
    --bs-danger-border-subtle: #58151c;
    --bs-danger-text-emphasis: #ea868f;
    --bs-light: var(--bs-tertiary-bg);
    --bs-light-rgb: 44, 49, 59;
}
[data-bs-theme="dark"] .alert-info {
    background-color: var(--bs-info-bg-subtle, #032830) !important;
    border-color: var(--bs-info-border-subtle, #055160) !important;
    color: var(--bs-info-text-emphasis, #6edff6) !important;
}
[data-bs-theme="dark"] .alert-success {
    background-color: var(--bs-success-bg-subtle, #051b11) !important;
    border-color: var(--bs-success-border-subtle, #0a3622) !important;
    color: var(--bs-success-text-emphasis, #75b798) !important;
}
[data-bs-theme="dark"] .alert-warning {
    background-color: var(--bs-warning-bg-subtle, #332701) !important;
    border-color: var(--bs-warning-border-subtle, #664d03) !important;
    color: var(--bs-warning-text-emphasis, #ffda6a) !important;
}
[data-bs-theme="dark"] .alert-danger {
    background-color: var(--bs-danger-bg-subtle, #2c0b0e) !important;
    border-color: var(--bs-danger-border-subtle, #58151c) !important;
    color: var(--bs-danger-text-emphasis, #ea868f) !important;
}
[data-bs-theme="dark"] .bg-light,
[data-bs-theme="dark"] .text-bg-light {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}

/* Table/list-group CONTEXT classes (.table-light, .table-warning, ...,
   .list-group-item-success, ...) — a real bug caught from live markup: a
   <tr class="table-light"> inside an already-dark-styled table was still
   rendering light. Different mechanism from the .table fix above, not the
   same bug again — Bootstrap sets --bs-table-bg DIRECTLY on the row/cell
   for these context classes, which shadows whatever the ancestor <table>
   has for that property. Inheritance always loses to a more specific
   element's own declaration, !important or not — !important only resolves
   conflicts on the SAME element, and a <table> and its <tr> are never the
   same element. So overriding .table's --bs-table-bg, however forcefully,
   was never going to reach a row that sets its own. Fixed by targeting
   every context variant directly, on the row/cell/list-item itself, with a
   background+text pairing that stays legible without losing the semantic
   colour entirely (a "light" row stays visually DISTINCT from a plain row,
   just a dark-mode-appropriate distinction instead of literal light grey).
   Bootstrap ships table-primary/-secondary/-success/-danger/-warning/-info/
   -light/-dark and the identical set for list-group-item-*; covering all
   of them now rather than one at a time as each happens to show up in a
   screenshot. */
[data-bs-theme="dark"] .table-primary,
[data-bs-theme="dark"] .table-primary > th,
[data-bs-theme="dark"] .table-primary > td {
    --bs-table-bg: color-mix(in srgb, var(--opc-primary, #1f3c88) 22%, var(--bs-secondary-bg, #23272f)) !important;
    --bs-table-color: var(--bs-body-color, #d7dae0) !important;
    background-color: var(--bs-table-bg) !important;
    color: var(--bs-table-color) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
}
[data-bs-theme="dark"] .table-secondary,
[data-bs-theme="dark"] .table-secondary > th,
[data-bs-theme="dark"] .table-secondary > td,
[data-bs-theme="dark"] .table-light,
[data-bs-theme="dark"] .table-light > th,
[data-bs-theme="dark"] .table-light > td,
[data-bs-theme="dark"] .table-active,
[data-bs-theme="dark"] .table-active > th,
[data-bs-theme="dark"] .table-active > td {
    --bs-table-bg: var(--bs-tertiary-bg, #2c313b) !important;
    --bs-table-color: var(--bs-body-color, #d7dae0) !important;
    background-color: var(--bs-table-bg) !important;
    color: var(--bs-table-color) !important;
    border-color: var(--bs-border-color, #3a4049) !important;
}
[data-bs-theme="dark"] .table-success,
[data-bs-theme="dark"] .table-success > th,
[data-bs-theme="dark"] .table-success > td {
    --bs-table-bg: var(--bs-success-bg-subtle, #051b11) !important;
    --bs-table-color: var(--bs-success-text-emphasis, #75b798) !important;
    background-color: var(--bs-table-bg) !important;
    color: var(--bs-table-color) !important;
    border-color: var(--bs-success-border-subtle, #0a3622) !important;
}
[data-bs-theme="dark"] .table-danger,
[data-bs-theme="dark"] .table-danger > th,
[data-bs-theme="dark"] .table-danger > td {
    --bs-table-bg: var(--bs-danger-bg-subtle, #2c0b0e) !important;
    --bs-table-color: var(--bs-danger-text-emphasis, #ea868f) !important;
    background-color: var(--bs-table-bg) !important;
    color: var(--bs-table-color) !important;
    border-color: var(--bs-danger-border-subtle, #58151c) !important;
}
[data-bs-theme="dark"] .table-warning,
[data-bs-theme="dark"] .table-warning > th,
[data-bs-theme="dark"] .table-warning > td {
    --bs-table-bg: var(--bs-warning-bg-subtle, #332701) !important;
    --bs-table-color: var(--bs-warning-text-emphasis, #ffda6a) !important;
    background-color: var(--bs-table-bg) !important;
    color: var(--bs-table-color) !important;
    border-color: var(--bs-warning-border-subtle, #664d03) !important;
}
[data-bs-theme="dark"] .table-info,
[data-bs-theme="dark"] .table-info > th,
[data-bs-theme="dark"] .table-info > td {
    --bs-table-bg: var(--bs-info-bg-subtle, #032830) !important;
    --bs-table-color: var(--bs-info-text-emphasis, #6edff6) !important;
    background-color: var(--bs-table-bg) !important;
    color: var(--bs-table-color) !important;
    border-color: var(--bs-info-border-subtle, #055160) !important;
}

/* Same context-class family, list-group flavour (booking/order lists that
   use list-group instead of a table hit the identical bug). */
[data-bs-theme="dark"] .list-group-item-light,
[data-bs-theme="dark"] .list-group-item-secondary {
    background-color: var(--bs-tertiary-bg, #2c313b) !important;
    color: var(--bs-body-color, #d7dae0) !important;
}
[data-bs-theme="dark"] .list-group-item-success {
    background-color: var(--bs-success-bg-subtle, #051b11) !important;
    color: var(--bs-success-text-emphasis, #75b798) !important;
}
[data-bs-theme="dark"] .list-group-item-danger {
    background-color: var(--bs-danger-bg-subtle, #2c0b0e) !important;
    color: var(--bs-danger-text-emphasis, #ea868f) !important;
}
[data-bs-theme="dark"] .list-group-item-warning {
    background-color: var(--bs-warning-bg-subtle, #332701) !important;
    color: var(--bs-warning-text-emphasis, #ffda6a) !important;
}
[data-bs-theme="dark"] .list-group-item-info {
    background-color: var(--bs-info-bg-subtle, #032830) !important;
    color: var(--bs-info-text-emphasis, #6edff6) !important;
}

/* ======================================================================
   Pet parent profile page (big_goose/html/com_users/profile/*.php — a real
   template override of Joomla core's com_users profile DISPLAY page, 3.12.9).
   Everything else on that page is plain .card/.list-group-flush/.text-muted,
   already covered by the dark-mode rules above — only the initial-letter
   avatar badge is genuinely new markup needing its own rule. */
/* 4.5rem, not the original 3rem — "make the circle 50% bigger" so a face is
   actually recognisable in it (3.25.8, alongside the avatar upload feature
   below). */
.opc-profile-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 4.5rem;
    height: 4.5rem;
    border-radius: 50%;
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
    font-size: 1.875rem;
    font-weight: 600;
    flex-shrink: 0;
}
.opc-profile-avatar-img {
    display: inline-block;
    width: 4.5rem;
    height: 4.5rem;
    border-radius: 50%;
    object-fit: cover;
    flex-shrink: 0;
}
/* Ring so the circle reads as sitting ON the banner rather than merging into
   it — box-sizing:border-box keeps the rendered circle at the same 4.5rem
   the camera badge's offsets were tuned against, instead of growing by the
   border width. */
.opc-profile-avatar,
.opc-profile-avatar-img {
    box-sizing: border-box;
    border: 4px solid var(--opc-surface);
}
/* Small card-thumbnail avatar — Manager Portal Customers list (site/tmpl/
   customers/default.php). Same "coloured circle, initials or a photo" idiom
   as .opc-profile-avatar/-img above, just sized for a list-card row instead
   of a full profile banner (no upload-edit affordance, no border ring — this
   is a read-only reference, not the account's own editable avatar). */
.opc-avatar-sm {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 3rem;
    height: 3rem;
    border-radius: 50%;
    object-fit: cover;
    flex-shrink: 0;
}
.opc-avatar-sm-initials {
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
    font-size: 1.1rem;
    font-weight: 600;
}

/* Positioning host for the avatar + its own upload trigger — an <a> can't
   contain another interactive element, so editing is a separate explicit
   button (.opc-profile-edit-btn) rather than this control's own link. */
.opc-profile-avatar-wrap {
    position: relative;
    display: inline-block;
}
.opc-profile-avatar-edit {
    position: absolute;
    right: -.15rem;
    bottom: -.15rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    /* var(--opc-shape), not a literal 50% — same reasoning as .opc-mobilebar-
       center .opc-mobilebar-icon: a square box, so Round (999px) renders
       identically to 50% today, and Square (0) is a real alternative. */
    border-radius: var(--opc-shape);
    background: var(--opc-surface);
    color: var(--opc-text);
    border: 2px solid var(--opc-surface-sunken);
    cursor: pointer;
    font-size: .85rem;
    transition: transform .15s ease;
}
.opc-profile-avatar-edit:hover,
.opc-profile-avatar-edit:focus-visible {
    transform: scale(1.08);
}

/* Discord/Meta-profile shape: gradient banner + overlapping avatar + pill
   "chips" row, instead of the old flat card / whole-card-is-a-link design
   (see default.php's docblock). The edit page gets its own matching
   consolidated card + sticky Save/Cancel bar + live password checklist
   (see edit.php's docblock). */
/* 900px, not the original 720px: big_goose's .container-component fills 3 of
   its 4 main grid columns (~950px) whenever a right sidebar module is
   published but no left one is (see template.css's `body:not(.has-sidebar-
   left) .container-component { grid-column-start: main-start }` rule) — a
   720px card centred in that column left a large, oddly empty strip between
   the card and the sidebar menu. Widening close to the column's real width
   removes that gap instead of just shrinking it. */
.opc-profile-card {
    max-width: 900px;
    /* Matches the .is-editing override below exactly (was 1.5rem — reported
       live: the card visibly shifts up when Edit Profile is tapped, since
       the two states used two different values here). Same number in both
       places now, so the card's top edge never moves when toggling. */
    margin-top: .5rem;
    overflow: hidden;
    /* Positioning context for .opc-guide-fab/.opc-portal-toggle, pinned to
       this card's bottom corners. VIEW MODE ONLY — reserves blank space
       below the last content row so those absolutely-positioned controls
       never float on top of real text (reported live: the portal-toggle
       pill overlapping the Appearance row, since .opc-profile-scroll itself
       carries no bottom padding of its own). Explicitly zeroed under
       .is-editing below — that state has its own carefully-tuned box model
       (see its own comment) and hides both controls outright anyway. */
    padding-bottom: 4rem;
    position: relative;
    /* Reported live: content mid-scroll passing under the fixed footer/
       mobile bar. `body`'s own padding-bottom already reserves the combined
       height (see the --opc-mobilebar-height/--opc-footer-height rules
       further down), but that compensation lives at the body level only —
       reinforced here directly on the card so any scroll-into-view targeting
       this element (native or scripted) respects the same clearance, rather
       than depending solely on where the document happens to end. Falls
       back to 0 on viewports where the mobile bar never renders (the custom
       properties are still declared globally regardless of media query). */
    scroll-margin-bottom: calc(var(--opc-mobilebar-height, 0px) + var(--opc-footer-height, 0px));
    /* Reported live: the card visibly loads short, then grows, on refresh.
       Not a bug in the sizing logic itself — it's the timing gap between
       the browser's FIRST paint (CSS-only: max-height caps it, but nothing
       sets `height` yet, so it shrink-wraps to content) and
       profileedit.js's initCardMaxHeight() running a beat later and
       setting an inline `height` that fills all the way down to the
       footer. Making the JS run before first paint would need a
       synchronous, render-blocking script instead of the current deferred
       one — a bigger, riskier change (a stray error there breaks the whole
       page, not just this card) for what's fundamentally a cosmetic
       transition. Animating the resize instead turns the jump into a
       deliberate-looking grow. Applies to `height` specifically, not
       `max-height` — transitioning max-height too would also animate the
       (rare) case content genuinely overflows and needs to clip, which
       should snap, not glide. */
    transition: height .18s ease;
}
@media (prefers-reduced-motion: reduce) {
    .opc-profile-card {
        transition: none;
    }
}
/* .opc-card-measuring — confirmed live as the working fix for the "card
   visibly loads short then grows" problem described in the comment above
   (see default.php's own inline script for an earlier attempt that still
   visibly resized: suppressing the height transition alone still left an
   instant, visible snap). Plain full-card hide until
   initCardMaxHeight()'s first `update()` (profilebundle.js/profileedit.js)
   has already given the card its correct, final height — nothing is left
   to visibly resize by the time it's revealed. A skeleton placeholder
   shown alongside this (as a sibling, so it could stay visible while the
   card itself is hidden) was tried on top of this working version and
   reverted — keep this simple. */
.opc-profile-card.opc-card-measuring {
    opacity: 0;
}
/* View mode gets the SAME capped/internally-scrolling treatment as
   .is-editing below (reported live: a pet parent with a lot of profile data
   — many pets, a long address, several agreements — could grow this card
   past the viewport with no cap). `max-height` here is the pre-JS/no-JS
   fallback only (matches edit mode's own calc, same reasoning) —
   profileedit.js's initCardMaxHeight() sets an inline `height` too, in BOTH
   modes, so the card always exactly fills down to the reserved footer/
   mobile-bar band: never a gap above it, never content hidden behind it.
   Forcing `height` off the card's rendered top is only safe because
   initEditToggle() now locks the WHOLE page (view mode included, not just
   edit) via `.opc-edit-lock` — an earlier version tried this in view mode
   while only edit mode was scroll-locked, and it was a genuine feedback
   loop: with the page still scrollable, the card's top kept shrinking as
   the user scrolled, so the forced height grew by the same amount every
   frame, and the distance to whatever followed the card on the page never
   decreased. With the page permanently locked in both modes, the card's
   top can only change from a resize or a content-height change, never from
   scrolling — see initCardMaxHeight()'s own comment for the full history.
   z-index 20 matches edit mode's, still well under the footer's 1040 — the
   footer stays the topmost strip. */
.opc-profile-page:not(.is-editing) .opc-profile-card {
    display: flex;
    flex-direction: column;
    position: sticky;
    top: .5rem;
    z-index: 20;
    max-height: calc(100vh - 1rem);
    max-height: calc(100dvh - 1rem);
    /* `max-height` is the pre-JS/no-JS fallback only, deliberately NOT
       mirrored with a matching `height` here — TRIED, and reverted: a
       static CSS guess at the reserved footer/mobile-bar band (a flat
       4rem) is wrong for pages with a DIFFERENT bottom bar than the one it
       was guessed from — confirmed live, the Manager Portal's own bottom
       bar (Products/Assessments/Calendar/Customers/Profile) has a
       different real height than the customer-facing one, and the guessed
       value made the card overshoot past it. Only JS
       (initCardMaxHeight()'s computeBottomReserve(), profilebundle.js/
       profileedit.js) can correctly measure whichever bar variant is
       actually rendered — that adaptiveness is exactly why it exists, and
       a static value can't replace it. The visible jump while that JS
       measurement is pending is handled separately, by hiding the card
       entirely (`.opc-card-measuring` below, opacity:0 — see default.php's
       own inline script) until that measurement has run, rather than by
       trying to make this card's own first-paint size already correct. */
}
@media (max-width: 1199.98px) {
    body:has(.opc-mobile-chrome) .opc-profile-page:not(.is-editing) .opc-profile-card {
        max-height: calc(100vh - 1rem - 4rem - env(safe-area-inset-bottom));
        max-height: calc(100dvh - 1rem - 4rem - env(safe-area-inset-bottom));
    }
}
/* My Pets tab panel — a sibling of My Profile's .opc-profile-card (both
   live inside the same .opc-tabs, see default.php), but with no scrolling
   of its own: it always relied on the OUTER page scrolling normally.
   Reported live once the whole page became permanently non-scrolling (see
   initEditToggle()'s .opc-edit-lock comment in profileedit.js) — a pet
   parent with enough pets to overflow one viewport had no way to reach the
   rest. Same cap-and-scroll-internally fix as the profile card, scoped to
   THIS page only (#opc-profile-page) since `.opc-tab-panel` is a shared,
   reusable component used elsewhere (the Activity page's My Bookings/My
   Orders tabs) that must keep relying on normal page scroll there.
   `max-height` here is the pre-JS/no-JS fallback only — profileedit.js's
   initPetsPanelHeight() sets an inline `height` too, exactly mirroring
   initCardMaxHeight()'s own reasoning. */
#opc-profile-page .opc-tab-panel[data-opc-tab-panel="pets"] {
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    max-height: calc(100vh - 1rem);
    max-height: calc(100dvh - 1rem);
    /* `max-height` is the pre-JS/no-JS fallback only — see the matching
       reversion + explanation on .opc-profile-card's own rule above (a
       static `height` guess here was tried and reverted: it can't be
       correct for every bottom-bar variant this page can show). */
}
@media (max-width: 1199.98px) {
    body:has(.opc-mobile-chrome) #opc-profile-page .opc-tab-panel[data-opc-tab-panel="pets"] {
        max-height: calc(100vh - 1rem - 4rem - env(safe-area-inset-bottom));
        max-height: calc(100dvh - 1rem - 4rem - env(safe-area-inset-bottom));
    }
}
.opc-profile-banner {
    height: 96px;
    background:
        radial-gradient(circle at 20% -20%, rgba(255, 255, 255, .55), transparent 55%),
        var(--opc-gradient-primary);
    background-size: 100% 100%, 180% 180%;
    animation: opc-banner-sheen 6s ease-in-out infinite alternate;
    position: relative;
}
@media (prefers-reduced-motion: reduce) {
    .opc-profile-banner {
        animation: none;
    }
}
/* Second gradient-primary stop shifts position, not hue — moving the same
   --opc-primary/--opc-accent stops (whatever this instance's brand colors
   are) rather than a literal hue-rotate, which would push an arbitrary
   tenant's palette toward colors they never chose. */
@keyframes opc-banner-sheen {
    from { background-position: 0 0, 0% 50%; }
    to   { background-position: 0 0, 100% 50%; }
}
@media (max-width: 575.98px) {
    .opc-profile-banner {
        height: 72px;
    }
}
/* "Book a service" in the page-header action row is redundant here on
   phones — the mobile bottom bar's fixed 5-slot layout already carries a
   Book destination — so it's hidden only on this page, only at phone width
   (Bootstrap's md cutoff, matching the rest of the platform's mobile
   breakpoint). .opc-btn-book is shared markup from _header-actions.php, so
   this rule stays scoped to .opc-profile-page rather than hiding the
   button everywhere it's used. */
@media (max-width: 767.98px) {
    /* Extra .opc-page-header-actions qualifier beats `.opc-profile-page .btn`
       (site.css's shared button-sizing rule, further down this file) on
       specificity — that rule and a bare `.opc-profile-page .opc-btn-book`
       are equal specificity, and being later in the cascade it was silently
       winning and re-showing this button. */
    .opc-profile-page .opc-page-header-actions .opc-btn-book {
        display: none;
    }
    /* Same reasoning as Book above — My Rewards duplicates the mobile bottom
       bar's own Rewards/dashboard destination, so it's redundant as a
       full-width row on this page at phone width. .opc-btn-rewards is
       shared markup (_header-actions.php's 'rewards' case, also used on the
       My Pets page), so this stays scoped to .opc-profile-page. */
    .opc-profile-page .opc-page-header-actions .opc-btn-rewards {
        display: none;
    }
}
/* Frosted pill over the gradient rather than a solid button — reads on any
   --opc-primary/--opc-accent combination a customer instance picks, since
   it never needs its own contrasting fill colour. Shared base for both
   banner buttons (Edit Profile / Log Out); each side-specific class only
   sets which corner it sits in. */
.opc-profile-banner-btn {
    position: absolute;
    top: .75rem;
    display: inline-flex;
    align-items: center;
    gap: .35rem;
    padding: .4rem .9rem;
    border-radius: var(--opc-shape);
    background: rgba(255, 255, 255, .18);
    border: 1px solid rgba(255, 255, 255, .4);
    color: #fff;
    font-size: .8rem;
    font-weight: 600;
    text-decoration: none;
    backdrop-filter: blur(4px);
    transition: background .15s ease, transform .15s ease;
}
/* Belt-and-braces for the JS-side logoutBtn.disabled toggle in
   profileedit.js — normally this button is also display:none (the
   .opc-logout-slot hide rule) during a normal edit session, so this never
   renders in the ordinary case. Only matters if that hide rule ever fails
   to apply for some reason: a disabled control must still visibly read as
   inert, not identical to the live one sitting under it. */
.opc-profile-banner-btn:disabled {
    opacity: .5;
    cursor: not-allowed;
    pointer-events: none;
}
.opc-profile-banner-btn:hover,
.opc-profile-banner-btn:focus-visible {
    background: rgba(255, 255, 255, .3);
    color: #fff;
    transform: translateY(-1px);
}
.opc-profile-edit-btn,
.opc-profile-logout-btn {
    padding: .3rem .7rem;
    font-size: .72rem;
}
/* Log Out on the left, Edit Profile on the right — deliberate swap from the
   class names' natural left-to-right reading order; positioning is driven
   entirely by these two rules so the markup order never has to match. Fixed
   `width` (not min-width) forces the two pills to be pixel-identical
   regardless of their different text length or any native <button> vs <a>
   chrome/appearance differences — a min-width floor alone left it up to
   each element's own intrinsic sizing, which is what actually produced the
   "Log Out looks bigger" mismatch. */
.opc-profile-banner-btn {
    width: 9rem;
    justify-content: center;
    white-space: nowrap;
    box-sizing: border-box;
    appearance: none;
    -webkit-appearance: none;
}
.opc-profile-edit-btn {
    right: .75rem;
}
.opc-profile-logout-btn {
    left: .75rem;
}
/* The logout control used to be a <form><button> here, with the form kept
   out of the banner's box model via display:contents. The form is now
   hoisted OUTSIDE the banner entirely (see default.php's own comment on
   why — nested <form> elements are invalid HTML and the browser silently
   drops them, which was the actual root cause of the show/hide rule below
   never applying) and the button associates to it purely via the HTML5
   form="" attribute, so there's no wrapper element left to reset here. */
.opc-profile-banner-btn.opc-profile-logout-btn {
    /* font-family only — NOT the `font` shorthand. The shorthand resets
       font-size/weight/line-height too, and being two classes it outranks
       the .72rem font-size set on .opc-profile-edit-btn/.opc-profile-logout-
       btn above, silently kicking this button back to its UA-default 16px
       and inflating its height past the Edit Profile anchor next to it —
       confirmed via computed-style inspection (button: 16px/36.05px tall,
       anchor: 11.52px/29.36px tall, same padding). */
    font-family: inherit;
    cursor: pointer;
}
/* Pulls the avatar up so it overlaps the banner (Discord/Meta signature
   move) — negative margin sized to roughly half the avatar's 4.5rem height,
   not the whole thing, so the ring still sits mostly inside the card body. */
.opc-profile-card-header {
    border-bottom: 1px solid var(--opc-border-light);
    padding-top: 0;
    margin-top: -2.5rem;
}
/* Reported live: the gap between the chips row and the border-bottom
   divider below it (Bootstrap's own `.card-body` bottom padding — 1rem,
   unset by this rule above only for padding-TOP) read as too tall,
   pushing the divider further down than it needs to be. An earlier
   attempt gated this behind `@media (min-width: 1200px)` on the theory
   that only desktop needed it — reported live as having NO visible effect
   at all, which means the real viewport being tested was narrower than
   1200px despite looking like a desktop layout (the 2-column
   .opc-profile-grid below breaks at 575.98px, not 1200px, so a mid-width
   window can show a "desktop-looking" 2-column layout while still missing
   this gate entirely). Unconditional now — no breakpoint to get wrong.
   The freed space isn't lost: .opc-profile-scroll is `flex: 1 1 auto`
   inside the same flex column (see the .is-editing/:not(.is-editing)
   .opc-profile-card rules), so it automatically claims whatever height
   this header gives up — no separate change needed there. Scoped to view
   mode only — edit mode already has its own tuned header padding a few
   rules down (.is-editing .opc-profile-card-header). */
.opc-profile-page:not(.is-editing) .opc-profile-card-header {
    padding-bottom: .25rem;
}
.opc-profile-username {
    display: block;
    color: var(--opc-text-soft);
    font-size: .875rem;
}
.opc-profile-chips {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: .5rem;
    margin-top: .85rem;
}
.opc-profile-chip {
    display: inline-flex;
    align-items: baseline;
    gap: .3rem;
    padding: .4rem .85rem;
    border-radius: 999px;
    background: linear-gradient(135deg, var(--opc-primary-tint), color-mix(in srgb, var(--opc-accent) 16%, var(--opc-surface)));
    border: 1px solid var(--opc-primary-ring);
    color: var(--opc-text-soft);
    font-size: .8rem;
}
.opc-profile-chip-value {
    font-weight: 700;
}
.opc-profile-chip-theme {
    background: var(--opc-primary-tint);
    color: var(--opc-primary);
    font-weight: 600;
    text-transform: capitalize;
}
.opc-profile-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 0 1.5rem;
    padding: 1rem 1.25rem 1.25rem;
}
@media (max-width: 575.98px) {
    .opc-profile-grid {
        grid-template-columns: 1fr;
    }
}
.opc-profile-row {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    gap: .1rem;
    padding: .5rem .25rem;
}
/* Groups more than one .opc-profile-row into a single grid cell (Email +
   Contact number, view mode) — each row keeps its own centering/padding,
   this just stacks them so the pair counts as ONE cell in the grid's
   default 2-per-row flow, not two. */
.opc-profile-cell {
    display: flex;
    flex-direction: column;
}

/* Edit page — consolidated card, 2-up field grid, bottom-left action bar
   (deliberately NOT sticky-top any more — that competed with the header
   row's Book/My Pets/Dashboard actions for the same top-right space).
   3.30: enlarged from a thin, visually-disconnected row into a clear,
   properly-spaced action bar — Save reads as the obviously primary action,
   both buttons get comfortable tap targets, and the bar sits its own
   margin below the card rather than butting straight up against it. */
.opc-edit-actions-bottom {
    display: flex;
    gap: .75rem;
    justify-content: flex-start;
    margin-top: 1.5rem;
    padding: 0;
}
.opc-edit-actions-bottom .btn {
    min-height: 3rem;
    padding-inline: 1.5rem;
    font-size: .95rem;
    font-weight: 600;
}
@media (max-width: 575.98px) {
    .opc-edit-actions-bottom {
        flex-direction: column-reverse;
    }
    .opc-edit-actions-bottom .btn {
        width: 100%;
    }
}
.opc-edit-section {
    padding: 1rem 1.25rem;
    border-bottom: 1px solid var(--opc-border-light);
}
.opc-edit-section:last-child {
    border-bottom: none;
}
.opc-edit-section-title {
    font-size: .8rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .04em;
    color: var(--opc-text-soft);
    margin-bottom: .75rem;
}
/* Fields laid out as two INDEPENDENT columns, each its own vertical flex
   stack (.opc-edit-col), not a single row-paired CSS grid. Two earlier
   attempts at row-pairing via CSS-grid dense auto-placement both broke in
   production — a full-width checklist injected mid-sequence produced empty
   grid cells, and forcing it into its own row instead produced a doubled
   row-gap. A plain two-column flex layout has no auto-placement step to go
   wrong: each column just renders its own fields top to bottom, in DOM
   order — safe to run side-by-side at EVERY width, including phones, unlike
   the old dense grid. The two columns are a reading-order grouping (Left:
   Name/Phone/Password; Right: Username/Email/Theme), not a left-right field
   pairing, so each stays internally in its own natural top-to-bottom order
   regardless of viewport. */
.opc-edit-columns {
    display: flex;
    flex-direction: row;
    align-items: flex-start;
    gap: 1rem;
}
@media (min-width: 480px) {
    .opc-edit-columns {
        gap: 1.5rem;
    }
}
@media (min-width: 768px) {
    .opc-edit-columns {
        gap: 2rem;
    }
}
.opc-edit-col {
    display: flex;
    flex-direction: column;
    flex: 1 1 0;
    gap: 0;
    min-width: 0;
}
/* Legacy 2-up grid — still used for the rare "leftover" fieldset (anything
   not named in edit.php/registration default.php's explicit column lists,
   e.g. a future core/plugin field). Row-gap stays 0: vertical spacing comes
   from each Joomla-rendered field's own Bootstrap "mb-3" margin, not the
   grid — adding a row-gap on top of that doubles the spacing. */
.opc-edit-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 0 .5rem;
}
@media (min-width: 480px) {
    .opc-edit-grid {
        gap: 0 .75rem;
    }
}
@media (min-width: 768px) {
    .opc-edit-grid {
        gap: 0 1.5rem;
    }
}
/* Small note under a field (e.g. "contact us to change your username") —
   same faint/small treatment as core's own field help text, just ours. */
.opc-field-note {
    margin: -.65rem 0 .75rem;
    font-size: .8125rem;
    color: var(--opc-text-faint);
}
.opc-pw-checklist-wrap {
    margin: .5rem 0 1rem;
}
/* --- Appearance cycle button ------------------------------------------
   theme_preference is still an untouched `type="radio"` field
   (plg_user_opcprofile/forms/opcprofile.xml) — three real radio inputs,
   round-tripped on Save exactly as before. They no longer render visibly:
   `.opc-theme-switch` carries Bootstrap's own `.visually-hidden` utility in
   the template now (same pattern already used for the locked Name/Username/
   Email/Contact fields on this page), so no custom hiding CSS is needed
   here — the only visible control is the .cycle-btn below, driven by
   profileedit.js flipping which radio is checked and firing `change`.
   Was a three-segment always-visible pill living inside the Address grid;
   reported live as reading odd, paired with Address for no structural
   reason. Replaced with one icon button in its own row next to Banner
   Colour — see .opc-theme-row below, which is just `.opc-collapse` +
   `.opc-collapse-summary` (the exact same row component Banner/Agreements/
   Comm Prefs/Password already use), not a new layout. */
.opc-theme-row .opc-collapse-summary {
    cursor: default;
}
.cycle-btn {
    flex: 0 0 auto;
    width: 2.35rem;
    height: 2.35rem;
    min-width: 2.35rem;
    border-radius: var(--opc-shape);
    border: 1px solid var(--opc-border);
    background: var(--opc-surface-sunken);
    color: var(--opc-primary);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: transform .18s ease, border-color .15s ease;
}
.cycle-btn:hover {
    border-color: var(--opc-primary);
}
.cycle-btn:active {
    transform: scale(.92);
}
.cycle-btn:focus-visible {
    outline: 2px solid var(--opc-primary);
    outline-offset: 2px;
}
.cycle-btn svg {
    width: 1.15rem;
    height: 1.15rem;
}
/* Every other field-description ".form-text" on this page — reported live:
   the "Start typing and pick your address..." text under Find your address
   didn't match the rest of the dark-mode page (same underlying issue as
   the theme-switch block above, just never generalised past that one
   spot). Bootstrap's own .form-text colour is too low-contrast against
   this page's dark surface; same themed token, applied page-wide instead
   of one field at a time. Lower specificity than the two more detailed
   overrides above/below (theme-switch, password) — those still win their
   own extra layout resets, but all three agree on the same colour. */
.opc-profile-page .form-text {
    color: var(--opc-text-soft) !important;
}
/* 3.34.81 — replaces the 3.34.77/79 approach of reskinning the real inputs
   to LOOK like the view-mode address row (never came out pixel-identical,
   only similar, however the CSS was tuned). Now two fully separate blocks
   exist in the DOM — #opc-addr-fields (the real, normal editable inputs)
   and #opc-addr-readout (literally the same .opc-profile-row markup as
   the non-edit-mode view above) — and places.js swaps which is visible.
   ID selectors here are deliberate, not just convenient: they guarantee
   this wins over .opc-profile-row's own `display: flex` (a bare class
   rule) regardless of source order, the same class of bug that made the
   3.34.77/78 Log Out hide rules unreliable. #opc-addr-readout starts
   hidden in CSS (a no-JS/pre-JS fallback only) — but as of 3.35.x,
   places.js's init() flips it visible (and hides #opc-addr-fields)
   IMMEDIATELY on page load whenever a Places key is configured, before any
   interaction, on the assumption Places will work; it only falls back to
   #opc-addr-fields if Places genuinely fails (see places.js's
   lockNowByDefault()/unlockManualFields() docblocks). */
#opc-addr-readout {
    display: none;
}
.opc-address-col {
    display: flex;
    flex-direction: column;
}
/* .opc-field-panel is the shared border/radius/padding treatment, used by
   the Address/Contact panel below and the emergency-contact panel further
   down. */
.opc-field-panel {
    background: var(--opc-surface);
    /* border: 1px solid var(--opc-border); */
    border-radius: .875rem;
    /* Top/bottom tightened from 1.1rem/1.25rem — reported live as too much
       air alongside the rest of edit mode's trimmed vertical rhythm. */
    padding: .65rem 1.15rem .75rem;
}
/* Address + Contact panel (3.34.9x: was Address alone, full width, with
   Email/Contact isolated in their own strip above it — reported live as an
   oversized panel with wasted side margin and an orphaned strip. Appearance
   moved out into its own .opc-theme-row next to Banner Colour earlier and
   is not part of this panel at all). One outer panel now frames the search
   field, a divider, then a two-column row: Contact (Email/Phone, moved out
   of the top strip via opc-view-only there) on the left, Address details
   on the right. */
.opc-address-contact-panel .opc-edit-field {
    min-width: 0;
}
.opc-address-contact-divider {
    height: 1px;
    background: var(--opc-border);
    margin: 1rem 0;
}
.opc-address-contact-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    align-items: start;
    gap: 0 1.75rem;
}
@media (max-width: 575.98px) {
    .opc-address-contact-grid {
        grid-template-columns: 1fr;
        gap: 1.25rem;
    }
}
.opc-contact-col {
    display: flex;
    flex-direction: column;
    justify-content: center;
}
/* Left-align, not the centered .opc-profile-row default (used everywhere
   else on this page) — two columns of real data read as columns when
   left-aligned; centered, they read as two disconnected captions. */
.opc-address-contact-grid .opc-profile-row {
    align-items: flex-start;
    text-align: left;
}

/* Emergency contact + veterinarian (3.34.90). Two columns of NAME + NUMBER
   pairs, so each pair reads as one line on a wide screen and the name always
   sits directly above its own number. Deliberately its own grid rather than
   reusing .opc-field-panel's contents as a grid directly: this is four equal
   fields inside ONE panel and needs a real row-gap between them, where the
   address panel above has none. Collapses to a single column at the same
   575.98px breakpoint the rest of this form uses, so the whole edit form
   reflows at one point rather than several. */
.opc-emergency-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    align-items: start;
    gap: .35rem 1.5rem;
}
.opc-emergency-grid .opc-edit-field {
    min-width: 0;
}
@media (max-width: 575.98px) {
    .opc-emergency-grid {
        grid-template-columns: 1fr;
    }
}
/* Core's own password field renders a static "Minimum Requirements —
   Characters: 12, Numbers: 1, ..." line under the input (Bootstrap5
   .form-text, aria-describedby'd from the input) — redundant now that the
   compact chips below cover the same thresholds live. 3.34.45: scoped via
   the template's own [data-opc-pw-field] wrapper (one per password field)
   instead of :has(#jform_password1) — the :has() form only ever matched
   password1's own wrapper by construction, but relying on :has() support at
   all was an unnecessary risk for a rule this easy to scope directly. */
[data-opc-pw-field] .form-text {
    display: none;
}
/* Compact password requirements checklist (3.30) — replaces the earlier
   "cramped outlined pills" (3.29.36 pass) and, before that, an even
   cramped row of tiny pills — both read as clutter. Plain vertical list,
   each row just a small tick + label, no boxes/borders/backgrounds. Shared
   by registration Step 2 and the profile edit page's password section;
   `.opc-pw-chip`/`.opc-pw-tick` class names kept as-is (renaming would
   touch passwordcheck.js's DOM contract for no benefit).
   3.34.44: a satisfied rule now collapses out of the list instead of just
   flipping muted→satisfied in place — explicit requirement, reversing the
   3.30 call to keep every row always visible. `gap` is not used on the
   flex container any more (a flex `gap` doesn't collapse for one child
   shrinking to zero height, leaving a stray blank band); each chip carries
   its own bottom margin instead, so height/opacity/margin can all collapse
   together. `max-height` (not `height`) is what a CSS transition can
   actually animate on an auto-sized element. */
.opc-pw-chips-heading {
    margin: 0 0 .4rem;
    font-size: .8rem;
    font-weight: 600;
    color: var(--opc-text-faint);
    transition: opacity .3s ease, max-height .3s ease, margin .3s ease;
    overflow: hidden;
    max-height: 1.4rem;
}
.opc-pw-chips {
    display: flex;
    flex-direction: column;
}
.opc-pw-chip {
    display: flex;
    align-items: center;
    gap: .55rem;
    font-size: .85rem;
    line-height: 1.3;
    color: var(--opc-text-faint);
    max-height: 1.6rem;
    margin-bottom: .4rem;
    opacity: 1;
    overflow: hidden;
    transition: color .15s ease, opacity .3s ease, max-height .3s ease, margin .3s ease;
}
.opc-pw-chip.is-met {
    color: var(--opc-text);
    opacity: 0;
    max-height: 0;
    margin-bottom: 0;
}
/* Once every rule has faded out there's nothing left under the heading —
   fade it away too rather than leaving it labelling an empty list.
   passwordcheck.js already hides the whole wrap in that state
   (`wrap.hidden = allMet`); this covers the moment just before that, while
   the collapse transition is still playing. */
.opc-pw-chips-heading:has(~ .opc-pw-chips:not(:has(.opc-pw-chip:not(.is-met)))) {
    opacity: 0;
    max-height: 0;
    margin: 0;
}
.opc-pw-tick {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.05rem;
    height: 1.05rem;
    flex: 0 0 auto;
    border-radius: 50%;
    border: 1.5px solid var(--opc-border);
    line-height: 1;
    transition: background-color .15s ease, border-color .15s ease;
}
.opc-pw-chip.is-met .opc-pw-tick {
    background: var(--opc-success, #2e9e57);
    border-color: var(--opc-success, #2e9e57);
}
.opc-pw-tick::before {
    content: '';
}
.opc-pw-chip.is-met .opc-pw-tick::before {
    content: '\2713';
    display: block;
    font-size: .55rem;
    color: var(--opc-surface, #fff);
}
@media (prefers-reduced-motion: reduce) {
    .opc-pw-chip,
    .opc-pw-chips-heading {
        transition: none;
    }
}
/* Profile edit page only (3.35): a real grid instead of the shared vertical
   flex list above — this section sits between the password fields and the
   rest of a long scrolling form, so an even grid buys back top-to-bottom
   space registration's single-column layout doesn't need. Scoped to
   .opc-password-section so registration Step 2 keeps the vertical list.
   Column count is set at deliberate breakpoints (2 → 3 → 5 columns) rather
   than `auto-fit`, which sizes columns purely off available width and, for
   a 5-item list, reliably strands a lone item on its own row (4+1) at
   in-between widths — the exact "not really horizontal" complaint this
   replaced. Fixed steps always land on a balanced split (2+2+1, 3+2, or a
   single row of 5) and still respond to the card's width, just at named
   thresholds instead of every pixel. Columns are `1fr`, not `auto` — `auto`
   sizes each column to its own content and packs the whole grid to the
   left, leaving the rest of the card's width empty; `1fr` stretches columns
   to fill it. A satisfied rule is removed outright (`display: none`) rather
   than animated closed — a live grid reflow can't be FLIP-animated in pure
   CSS, and a stray empty cell reads worse than an instant remove for a list
   this short. */
.opc-password-section .opc-pw-chips {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    column-gap: 1.1rem;
    row-gap: .4rem;
}
@media (min-width: 480px) {
    .opc-password-section .opc-pw-chips {
        grid-template-columns: repeat(3, 1fr);
    }
}
@media (min-width: 700px) {
    .opc-password-section .opc-pw-chips {
        grid-template-columns: repeat(5, 1fr);
    }
}
.opc-password-section .opc-pw-chip {
    max-height: none;
    margin: 0;
    transition: color .15s ease, opacity .15s ease;
}
.opc-password-section .opc-pw-chip.is-met {
    display: none;
}

/* ======================================================================
   Pets & Rewards — card grid, thumbnails, points widgets (3.15.1).
   site/tmpl/pets/default.php + dashboard/default.php + the shared
   _pet-points-widget.php partial reference ~30 opc-* classes that had ZERO
   CSS anywhere in this file (confirmed by grepping every class used against
   every selector actually defined) — not a tuning gap, entirely unstyled:
   the points ring rendered as bare text, the hairline was invisible, pet
   thumbnails had no aspect-ratio/crop treatment at all. This is the direct
   fix for the "dog's breakfast" report, and deliberately placed and built
   FIRST among this component's card-based views (ahead of the booking
   calendar section below, which already got its own dedicated pass in
   v3.13.0) — Pets is the identity surface every other feature hangs off,
   so it gets the CSS attention first.
   ====================================================================== */

/* Card lift + full-card click target — shared by every card grid in this
   section (Pets grid, memorialized section, dashboard pet cards). Built on
   the existing elevation tokens so both themes come for free. */
.opc-card-interactive {
    position: relative;
    background: var(--opc-surface);
    border-color: var(--opc-border);
    box-shadow: var(--opc-shadow-resting);
    transition: transform .15s ease, box-shadow .15s ease;
}
.opc-card-interactive:hover,
.opc-card-interactive:focus-visible {
    transform: translateY(-3px);
    box-shadow: var(--opc-shadow-lifted);
}
/* NOT :focus-within (was, until 3.37.x) — simplemodal.js deliberately
   RETURNS focus to whatever card opened a modal once it closes (its own
   docblock: real keyboard-dialog behavior). :focus-within matches that
   programmatic focus regardless of input device, so after closing a modal
   with a mouse the card stayed permanently "lifted" as if still hovered,
   with no cursor over it. :focus-visible only matches when the browser
   judges the focus should be visibly indicated (keyboard nav), which is
   exactly the case this lift is meant to cover — a mouse click closing the
   modal no longer re-triggers it. */
/* The whole-card click target on the dashboard's pet cards (3.19.16) is a
   plain div[role="button"], not a native control — cursor/focus-ring need
   spelling out explicitly since a div gets neither by default. */
.opc-card-interactive[role="button"] {
    cursor: pointer;
}
.opc-card-interactive[role="button"]:focus-visible {
    outline: 2px solid var(--opc-primary);
    outline-offset: 2px;
}
/* NOTE: deliberately no `position: relative` here — the whole point of a
   stretched-link is that its ::after sizes against the nearest *positioned*
   ancestor. .opc-card-interactive (the actual .card) already carries
   position:relative above; if this link were positioned too, its own
   (text-sized) box would become that ancestor instead, and the ::after would
   only ever cover the link text, not the card — exactly the "only the image/
   name is clickable" bug this fixes. */
.opc-card-stretched-link::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
}
/* Real bug (found from a live report — "not all cards are clickable"): the
   overlay above always paints above any non-positioned sibling regardless of
   DOM order, so it was silently swallowing taps on the Pets grid's OWN
   footer controls — the "Book" quick-action and the Lost/Memorialized card's
   Reactivate/Hide buttons — landing on the profile page instead of doing
   what the button said. Lifting those specific controls into their own
   stacking context (z-index above the overlay's 1) restores them without
   touching the overlay itself or any card that has no footer. */
.opc-card-interactive .card-footer,
.opc-card-interactive a.hcc-card-thumb {
    position: relative;
    z-index: 2;
}
/* A Lost/Memorialized pet's card is still fully usable (Reactivate/Hide) but
   visually reads as "not an active part of the roster" — a soft desaturated
   treatment rather than hiding anything. */
.opc-pet-readonly {
    opacity: .78;
    filter: saturate(.7);
}

/* Same soft desaturated "not currently active, but not gone" treatment for an
   unpublished Store product — products are never deleted, only unpublished
   (Manager Portal Products screen), so this reads as "parked", not "removed".
   The overlay badge stays fully opaque so it's still legible against the
   dimmed image underneath. */
.opc-product-unpublished {
    opacity: .78;
    filter: saturate(.7);
}
.opc-product-unpublished .badge {
    opacity: 1;
}

/* Pet card thumbnail (3.17.0: was a fixed 4:3 cover-crop — now sizes to the
   photo's own aspect ratio via object-fit:contain, so a portrait photo isn't
   cropped. 3.23.7: the box itself is a fixed height (not just capped) so
   every card's image slot — real photo, placeholder, or business logo — is
   the same height across a row; otherwise the content below (name/breed/
   age/points/footer button) starts at a different Y per card and rows never
   line up. object-fit:contain still letterboxes rather than cropping. */
.hcc-card-thumb {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    height: 11rem;
    overflow: hidden;
    background: var(--opc-surface-sunken);
    /* Must match the parent .card's own border-radius (--opc-radius-lg,
       3.26) — this used the smaller --opc-radius until now, so the photo's
       square top corners poked past the card's more-rounded edge ("strange
       radius" reported live). */
    border-top-left-radius: var(--opc-radius-lg);
    border-top-right-radius: var(--opc-radius-lg);
}
.hcc-card-thumb-badge {
    position: absolute;
    top: .5rem;
    right: .5rem;
    z-index: 1;
}
.opc-card-out-of-stock .hcc-card-thumb img {
    opacity: .5;
}
.opc-card-out-of-stock .fw-bold.fs-5 {
    color: var(--opc-text-muted);
}
.hcc-card-thumb img {
    display: block;
    width: 100%;
    max-height: 100%;
    object-fit: contain;
    transition: transform .25s ease;
}
.opc-card-interactive:hover .hcc-card-thumb img {
    transform: scale(1.045);
}
.hcc-card-thumb-placeholder {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 2.25rem;
    color: var(--opc-text-faint);
}

/* My Pets + My Rewards dashboard pet-card grid — capped column count per
   breakpoint (max 2 per row on mobile, max 3 on desktop) that always
   CENTERS its row regardless of how many cards it holds, per explicit
   request ("no matter if min or max div should center"). A hard
   `grid-template-columns` (3.19.14) filled the full row width even with a
   single card, so 1 pet never centered — flex-wrap with a capped
   flex-basis is the direct fix: a row with fewer cards than the cap simply
   doesn't stretch to fill it, and `justify-content: center` centers
   whatever's actually on that row, 1 up to the cap. Shared by both grids
   using these same two class names: My Pets (site/tmpl/pets/default.php,
   incl. the memorialized section) and the My Rewards dashboard
   (dashboard/default.php). */
.opc-pets-grid {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 1rem;
}
.opc-pets-grid-item {
    flex: 0 1 calc(50% - .5rem);
    max-width: calc(50% - .5rem);
}
@media (min-width: 768px) {
    .opc-pets-grid-item {
        flex-basis: calc(33.333% - .667rem);
        max-width: calc(33.333% - .667rem);
    }
}
/* Slightly tighter card body — the request specifically asked for the cards
   to be "a little more vertically compact" once the thumbnail stopped
   forcing a fixed crop height. */
.opc-pets-grid-item .card-body,
.opc-dash-pet-card .card-body {
    padding: .85rem 1rem;
}
.opc-pets-grid-item .card-title {
    margin-bottom: .3rem;
}
.opc-pets-grid-item ul.list-unstyled li {
    line-height: 1.3;
}
/* 3.23.6 tried anchoring the age/weight details + points widget to the
   BOTTOM of the card (margin-top: auto on a flex column) so they'd line up
   across a row regardless of how much text wrapped above them — confirmed
   live to be exactly backwards from what was actually wanted: it opened a
   large dead gap between the breed line and the age/weight list whenever a
   sibling card's photo forced this card taller via h-100. The real ask was
   the opposite — pet details (name/species/breed) and the points section
   directly BELOW them, anchored to the TOP as one compact block, with no
   gap. Plain top-down flow does that on its own; nothing needs to be
   pushed anywhere. Only the footer button (a sibling of .card-body, not
   inside it) still naturally lands at the bottom of the card. */
.opc-pets-grid-item .card-body > ul.list-unstyled,
.opc-dash-pet-card .card-body > .opc-points-widget {
    margin-top: 0;
}

/* Memorialized-section divider/toggle (site/tmpl/pets/default.php) — full
   width of the active-pets block, doubling as the visual separator between
   the active grid above and the memorialized grid below (replaces the old
   header heart-icon toggle). A plain themed button, not a card. */
.opc-pets-section-divider {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: .5rem;
    width: 100%;
    margin-top: 1.5rem;
    padding: .6rem 1rem;
    background: var(--opc-surface-sunken);
    border: 1px dashed var(--opc-border);
    border-radius: var(--opc-radius);
    color: var(--opc-text-soft);
    font-size: .9rem;
    font-weight: 600;
    cursor: pointer;
    transition: background-color .15s ease, color .15s ease;
}
.opc-pets-section-divider:hover,
.opc-pets-section-divider:focus-visible {
    background: var(--opc-surface);
    color: var(--opc-text);
}
.opc-pets-section-divider:focus-visible {
    outline: 2px solid var(--opc-primary);
    outline-offset: 2px;
}

/* Star-tier glow/pulse (3.17.0). Progresses with the pet's CURRENT tier only
   (bronze -> silver -> gold), never regresses — driven purely by the tier
   computed from the pet's live point total, so there's nothing to persist
   server-side for "don't regress." The pulse is separate and temporary: JS
   (confetti.js) removes it the first time that tier's card has been opened,
   tracked client-side via localStorage since there's no "seen" column. */
/* 3.26 (retuned): first pass kept a 1px solid ring alongside the blur —
   still read as a drawn border, not a glow, on a live screenshot. Removed
   the ring outright: two pure blurred shadow layers only (a tighter one for
   definition near the edge, a wider one for the ambient halo), no hard line
   at any width. Also fixes the earlier specificity bug — this selector
   matches `.card`'s own specificity (paired with .opc-card-interactive,
   which every glow-eligible card already carries), so it can't be silently
   replaced by the generic card rule above, and composes with --opc-card-
   shadow (the same token that rule draws from) so the glow layers ON TOP of
   the card's normal elevation instead of replacing it. */
.opc-star-glow.opc-card-interactive {
    --opc-glow-color: var(--opc-star-bronze);
    box-shadow: 0 6px 16px -4px color-mix(in srgb, var(--opc-glow-color) 55%, transparent),
                0 0 28px 4px color-mix(in srgb, var(--opc-glow-color) 35%, transparent),
                var(--opc-card-shadow);
}
.opc-star-glow[data-opc-tier="silver"] { --opc-glow-color: var(--opc-star-silver); }
.opc-star-glow[data-opc-tier="gold"]   { --opc-glow-color: var(--opc-star-gold); }
.opc-star-glow.opc-card-interactive:hover,
.opc-star-glow.opc-card-interactive:focus-within {
    box-shadow: 0 10px 22px -4px color-mix(in srgb, var(--opc-glow-color) 70%, transparent),
                0 0 40px 8px color-mix(in srgb, var(--opc-glow-color) 50%, transparent),
                var(--opc-shadow-lifted);
}
.opc-star-glow.opc-pulse {
    animation: opc-star-pulse 2.2s ease-in-out infinite;
}
@keyframes opc-star-pulse {
    0%, 100% {
        box-shadow: 0 6px 16px -4px color-mix(in srgb, var(--opc-glow-color) 55%, transparent),
                    0 0 28px 4px color-mix(in srgb, var(--opc-glow-color) 35%, transparent),
                    var(--opc-card-shadow);
    }
    50% {
        box-shadow: 0 10px 26px -4px color-mix(in srgb, var(--opc-glow-color) 80%, transparent),
                    0 0 48px 10px color-mix(in srgb, var(--opc-glow-color) 60%, transparent),
                    var(--opc-shadow-lifted);
    }
}
@media (prefers-reduced-motion: reduce) {
    .opc-star-glow.opc-pulse {
        animation: none;
    }
}

/* First-reveal confetti (media/com_onepetconnect/js/confetti.js). Falls
   from the top of the viewport with randomized drift/rotation, cleaned up
   (JS-forced) after 3s — "no confetti after 3 seconds, screen should be
   clean" per the request. */
.opc-confetti-layer {
    position: fixed;
    inset: 0;
    z-index: 1090;
    pointer-events: none;
    overflow: hidden;
}
.opc-confetti-star {
    position: absolute;
    top: -2rem;
    animation-name: opc-confetti-fall;
    animation-timing-function: ease-in;
    animation-fill-mode: forwards;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35));
}
@keyframes opc-confetti-fall {
    0% {
        transform: translateY(0) translateX(0) rotate(0deg);
        opacity: 1;
    }
    100% {
        transform: translateY(105vh) translateX(var(--opc-confetti-drift, 40px)) rotate(360deg);
        opacity: .15;
    }
}
@media (prefers-reduced-motion: reduce) {
    .opc-confetti-layer {
        display: none;
    }
}

/* Dashboard ("My Rewards") pet-card avatar — a round crop distinct from the
   Pets grid's rectangular one, since this card's own layout puts it beside
   the name rather than above it. */
.opc-dash-pet-thumb {
    width: 3.5rem;
    height: 3.5rem;
    border-radius: 50%;
    overflow: hidden;
    background: var(--opc-surface-sunken);
    border: 1px solid var(--opc-border);
    display: flex;
    align-items: center;
    justify-content: center;
}
.opc-dash-pet-thumb img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}
.opc-dash-pet-thumb-placeholder {
    font-size: 1.4rem;
    color: var(--opc-text-faint);
}

/* Dashboard hero — total points + rules + bronze/silver/gold tally, laid out
   as an actual strip on the brand colour (never a hardcoded literal — see
   this component's own "platform product" rule) instead of the plain block
   it was rendering as with no CSS behind it at all. */
.opc-stat-strip {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 1.25rem;
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
    border-radius: var(--opc-radius);
    padding: 1.35rem 1.5rem;
    box-shadow: var(--opc-shadow-lifted);
}
/* Dark mode (3.26): the site's single highest-visibility hero block gets the
   gradient + glow identity — matching a solid-fill CTA, this is decoration
   on top of an already-full-contrast element (white text stays white),
   never a substitute for contrast. */
[data-bs-theme="dark"] .opc-stat-strip {
    background: var(--opc-gradient-primary);
    box-shadow: var(--opc-shadow-lifted), var(--opc-glow-shadow);
}
/* 3.17.0: the strip is now itself a <button> opening the rewards modal —
   reset the button-default font/cursor, everything else is inherited. */
.opc-stat-strip-btn {
    cursor: pointer;
    font: inherit;
    transition: transform .15s ease, box-shadow .15s ease;
}
.opc-stat-strip-btn:hover,
.opc-stat-strip-btn:focus-visible {
    transform: translateY(-2px);
    box-shadow: var(--opc-shadow-resting), 0 0 0 3px rgba(255, 255, 255, .25);
}
.opc-rewards-modal-body .opc-stat-rules {
    color: var(--opc-text-soft);
}
.opc-rewards-ledger {
    max-height: 50vh;
    overflow-y: auto;
}
/* Pet-filter step (simplemodal.js) toggles the native `hidden` attribute on
   rows — but each row also carries Bootstrap's `.d-flex` utility, which is
   `!important` and (being an authored rule) beats the UA default `[hidden]
   { display: none }` regardless of attribute-selector specificity. Forced
   directly so the filter actually hides rows instead of silently no-op'ing. */
.opc-rewards-ledger li[hidden] {
    display: none !important;
}
.opc-stat-total {
    flex-shrink: 0;
    text-align: center;
    padding-right: 1.25rem;
    border-right: 1px solid rgba(255, 255, 255, .25);
}
.opc-stat-total-value {
    display: block;
    font-size: 2.35rem;
    font-weight: 800;
    line-height: 1;
}
.opc-stat-total-label {
    display: block;
    margin-top: .2rem;
    font-size: .68rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: .06em;
    opacity: .85;
}
.opc-stat-mid {
    flex: 1 1 240px;
    min-width: 0;
}
.opc-stat-mid p {
    margin-bottom: .4rem;
}
.opc-stat-rules {
    font-size: .85rem;
    opacity: .92;
}
.opc-stat-history-btn {
    border-color: rgba(255, 255, 255, .55);
    color: #fff;
}
.opc-stat-history-btn:hover,
.opc-stat-history-btn:focus-visible {
    background: #fff;
    border-color: #fff;
    color: var(--opc-primary);
}
@media (max-width: 575.98px) {
    .opc-stat-strip {
        padding: 1.1rem;
        gap: .85rem;
    }
    .opc-stat-total {
        border-right: none;
        padding-right: 0;
    }
}

/* Per-pet "progress to next star" widget (_pet-points-widget.php) — a
   conic-gradient ring reading --opc-ring-pct/--opc-ring-color, the two
   custom properties the template already emits inline per pet. Nothing ever
   turned those into an actual ring before this — it rendered as plain text
   sitting next to an invisible 0x0 element. */
.opc-points-widget {
    padding-top: .75rem;
    border-top: 1px solid var(--opc-border-light);
}
.opc-points-widget-top {
    display: flex;
    align-items: center;
    gap: .75rem;
}
.opc-points-ring {
    --opc-ring-pct: 0;
    --opc-ring-color: var(--opc-primary);
    position: relative;
    flex-shrink: 0;
    width: 3.1rem;
    height: 3.1rem;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: conic-gradient(var(--opc-ring-color) calc(var(--opc-ring-pct) * 1%), var(--opc-border) 0);
    transition: background .3s ease;
}
.opc-points-ring::before {
    content: '';
    position: absolute;
    inset: 4px;
    border-radius: 50%;
    background: var(--opc-surface);
}
.opc-points-ring-mark {
    position: relative;
    z-index: 1;
    font-size: .72rem;
    font-weight: 700;
    color: var(--opc-text-soft);
}
.opc-points-ring-mark.opc-star {
    font-size: 1.15rem;
    color: var(--opc-ring-color);
}
.opc-points-ring-mark sup {
    font-size: .55em;
}
.opc-points-summary {
    display: flex;
    flex-direction: column;
    min-width: 0;
}
.opc-points-value {
    font-weight: 700;
    font-size: .95rem;
}
.opc-points-caption {
    font-size: .78rem;
}
.opc-hairline {
    display: flex;
    gap: 3px;
    margin: .5rem 0 0;
    cursor: help;
}
.opc-hairline-seg {
    position: relative;
    flex: 1 1 0;
    height: 4px;
    border-radius: 2px;
    background: var(--opc-border);
    overflow: hidden;
}
.opc-hairline-bronze { --opc-seg-color: var(--opc-star-bronze); }
.opc-hairline-silver { --opc-seg-color: var(--opc-star-silver); }
.opc-hairline-gold { --opc-seg-color: var(--opc-star-gold); }
.opc-hairline-seg.is-earned {
    background: var(--opc-seg-color);
}
.opc-hairline-seg.is-current::after {
    content: '';
    position: absolute;
    inset: 0;
    width: var(--opc-seg-fill, 0%);
    background: var(--opc-seg-color);
    border-radius: inherit;
    transition: width .3s ease;
}
.opc-points-widget-footer {
    font-size: .78rem;
}
/* Per-pet star-tier badges (3.23.11) — see _pet-points-widget.php's own
   comment: this is the bronze/silver/gold pill look the dashboard's "Total
   Points" banner used to show as an account-level tally (wrong — removed
   3.23.4), reapplied correctly per pet, right under the hairline. Earned =
   solid fill in the tier's own colour; the tier currently being chased gets
   a coloured outline so it reads as "next up," not equal to an already-won
   one; anything further out stays flat/muted so the row never looks broken
   or empty even at 0 points. */
.opc-tier-badges {
    display: flex;
    width: 100%;
    gap: 3px; /* matches .opc-hairline's own gap, so badges stay aligned under their segment */
    margin-top: .6rem;
}
.opc-tier-badge {
    display: inline-flex;
    flex: 1;
    min-width: 0;
    align-items: center;
    justify-content: center;
    padding: .15rem .35rem;
    border-radius: 1rem;
    font-size: .68rem;
    font-weight: 600;
    line-height: 1.4;
    background: var(--opc-surface-sunken);
    color: var(--opc-text-soft);
    border: 1px solid var(--opc-border);
}
.opc-tier-badge-star {
    font-size: .8em;
}
.opc-tier-badge-bronze .opc-tier-badge-star { color: var(--opc-star-bronze); }
.opc-tier-badge-silver .opc-tier-badge-star { color: var(--opc-star-silver); }
.opc-tier-badge-gold   .opc-tier-badge-star { color: var(--opc-star-gold); }
.opc-tier-badge.is-current {
    border-color: var(--opc-tier-badge-color, var(--opc-primary));
    color: var(--opc-text);
}
.opc-tier-badge-bronze.is-current { --opc-tier-badge-color: var(--opc-star-bronze); }
.opc-tier-badge-silver.is-current { --opc-tier-badge-color: var(--opc-star-silver); }
.opc-tier-badge-gold.is-current   { --opc-tier-badge-color: var(--opc-star-gold); }
.opc-tier-badge.is-earned {
    background: var(--opc-tier-badge-color, var(--opc-primary));
    border-color: var(--opc-tier-badge-color, var(--opc-primary));
    color: #fff;
}
.opc-tier-badge-bronze.is-earned { --opc-tier-badge-color: var(--opc-star-bronze); }
.opc-tier-badge-silver.is-earned { --opc-tier-badge-color: var(--opc-star-silver); }
.opc-tier-badge-gold.is-earned   { --opc-tier-badge-color: var(--opc-star-gold); }
.opc-tier-badge.is-earned .opc-tier-badge-star { color: #fff; }

/* ======================================================================
   Store & product — grid card media, gallery hero/thumbnails, buy box.
   Same "confirmed missing by grep" story as the Pets/Rewards section above:
   these classes were referenced in store/default.php and product/default.php
   with zero CSS behind them.
   ====================================================================== */
.hcc-media-card {
    display: block;
    width: 100%;
    aspect-ratio: 4 / 3;
    object-fit: cover;
}
.hcc-media-hero {
    display: block;
    aspect-ratio: 4 / 3;
    overflow: hidden;
    border-radius: var(--opc-radius);
    background: var(--opc-surface-sunken);
}
.hcc-media-hero img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: contain;
    cursor: zoom-in;
}
.hcc-media-hero-empty {
    aspect-ratio: 4 / 3;
    background: var(--opc-surface-sunken);
    color: var(--opc-text-faint);
    font-size: 2.5rem;
}
.hcc-media-tile {
    width: 4.5rem;
    height: 4.5rem;
    object-fit: cover;
    cursor: pointer;
    opacity: .82;
    border-radius: var(--opc-radius-sm);
    box-shadow: var(--opc-shadow-resting);
    transition: opacity .15s ease, transform .15s ease;
}
.hcc-media-tile:hover,
.hcc-media-tile:focus-visible {
    opacity: 1;
    transform: translateY(-2px);
}
.hcc-thumbs {
    display: flex;
    gap: .5rem;
    flex-wrap: wrap;
    margin-top: .65rem;
}

/* Product detail page (view=product) — one card matching the store grid's
   .opc-card-interactive aesthetic (--opc-card-shadow/--opc-radius-lg/
   --opc-surface) instead of a bare Bootstrap row, so it stops reading as an
   unstyled generic store page. */
.hcc-product-card {
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius-lg);
    box-shadow: var(--opc-card-shadow);
    overflow: hidden;
}
.hcc-product-gallery {
    padding: 1.5rem;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}
.hcc-product-gallery .hcc-media-hero,
.hcc-product-gallery .hcc-media-hero-empty {
    width: 100%;
}
@media (min-width: 768px) {
    .hcc-product-gallery {
        border-right: 1px solid var(--opc-border-light);
    }
}
.hcc-product-details {
    padding: 1.5rem;
    display: flex;
}
.hcc-product-details-inner {
    display: flex;
    flex-direction: column;
    gap: .85rem;
    width: 100%;
}
.hcc-product-title {
    font-size: 1.5rem;
    font-weight: 800;
    margin: 0;
}
.hcc-product-meta {
    display: flex;
    align-items: center;
    gap: .7rem;
    flex-wrap: wrap;
}
.hcc-sku {
    font-family: ui-monospace, SFMono-Regular, monospace;
    font-size: .74rem;
    color: var(--opc-text-faint);
}
.hcc-stock-pill {
    display: inline-flex;
    align-items: center;
    gap: .35rem;
    padding: .28rem .65rem;
    border-radius: 999px;
    font-size: .74rem;
    font-weight: 700;
}
/* color-mix against --opc-success/--opc-danger + --opc-surface (same formula
   as --opc-primary-tint above) rather than a fixed literal, so both states
   theme automatically in dark mode with no separate override needed.
   --opc-success, NOT --opc-accent — stock status is a semantic state (in
   stock / out of stock), independent of brand colour, same reasoning
   --hcc-stock-pill--out already correctly applied with --opc-danger. Was
   --opc-accent before this fix, which meant a customer whose accent colour
   happened to be red/amber shipped a confusing "in stock" badge that read
   as a warning. */
.hcc-stock-pill--in {
    background: color-mix(in srgb, var(--opc-success) 16%, var(--opc-surface));
    color: color-mix(in srgb, var(--opc-success) 65%, var(--opc-text));
}
.hcc-stock-pill--out {
    background: color-mix(in srgb, var(--opc-danger) 14%, var(--opc-surface));
    color: var(--opc-danger);
}
.hcc-product-price {
    font-size: 2rem;
    font-weight: 800;
    background: var(--opc-gradient-primary);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}
.hcc-product-desc {
    color: var(--opc-text-soft);
    line-height: 1.65;
    padding-top: .6rem;
    border-top: 1px solid var(--opc-border-light);
    font-size: .92rem;
}
.hcc-buy-box-wrap {
    margin-top: auto;
    background: var(--opc-surface-sunken);
    border-radius: var(--opc-radius);
    padding: 1rem;
}
.hcc-buy-row {
    display: flex;
    gap: .6rem;
    align-items: flex-end;
    flex-wrap: wrap;
}
.hcc-buy-row .field.grow {
    flex: 1;
}
.hcc-buy-row .form-control {
    text-align: center;
}
.hcc-buy-row button[type="submit"] {
    min-height: 2.75rem;
    flex: 1 1 100%;
}
/* Add to cart is a plain .btn-primary — reverted back to inheriting the
   sitewide primary->accent gradient (--opc-gradient-primary, site.css
   ~line 767) like every other .btn-primary (e.g. "Book a service"), per
   direct request. Was briefly flattened to a scoped flat-primary override;
   removed. */

/* Gallery lightbox (media/com_onepetconnect/js/lightbox.js) */
.opc-lightbox-backdrop {
    position: fixed;
    inset: 0;
    z-index: 1080;
    background: rgba(10, 12, 16, .92);
    display: flex;
    align-items: center;
    justify-content: center;
}
.opc-lightbox-img-el {
    max-width: min(92vw, 1100px);
    max-height: 88vh;
    object-fit: contain;
    border-radius: var(--opc-radius);
}
/* Desktop: a floating ~75% panel rather than the near-fullscreen sizing
   mobile keeps (appropriate there — a phone viewport IS the frame). A
   pointer-driven viewport has room to spare, so full-bleed read as
   "forced/stretched" rather than a deliberate lightbox. */
@media (min-width: 768px) {
    .opc-lightbox-img-el {
        max-width: 75vw;
        max-height: 75vh;
        box-shadow: 0 20px 60px color-mix(in srgb, var(--opc-shadow-color) 50%, transparent);
    }
}
.opc-lightbox-btn {
    position: absolute;
    background: rgba(255, 255, 255, .12);
    color: #fff;
    border: 1px solid rgba(255, 255, 255, .3);
    /* var(--opc-shape), not a literal 50% — square box, so Round (999px)
       renders identically to 50% today. */
    border-radius: var(--opc-shape);
    width: 2.75rem;
    height: 2.75rem;
    font-size: 1.6rem;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: background .15s ease;
}
.opc-lightbox-btn:hover,
.opc-lightbox-btn:focus-visible {
    background: rgba(255, 255, 255, .25);
}
/* `.opc-lightbox-btn`'s own authored `display: flex` above beats the
   browser's UA-default `[hidden]{display:none}` (an author rule always wins
   over a UA rule, regardless of selector specificity) — so JS setting
   `.hidden = true` on Prev/Next alone did nothing visually. Force it. */
.opc-lightbox-btn[hidden] {
    display: none !important;
}
.opc-lightbox-close {
    top: 1rem;
    right: 1rem;
}
.opc-lightbox-prev {
    left: 1rem;
    top: 50%;
    transform: translateY(-50%);
}
.opc-lightbox-next {
    right: 1rem;
    top: 50%;
    transform: translateY(-50%);
}
.opc-lightbox-hint {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
    opacity: 0;
}
.opc-lightbox-hint.is-visible {
    opacity: 1;
    transition: opacity .25s ease;
}
.opc-lightbox-hint-icon {
    font-size: 2.75rem;
    animation: opc-lightbox-swipe 1s ease-in-out;
    filter: drop-shadow(0 2px 6px rgba(0, 0, 0, .5));
}
@keyframes opc-lightbox-swipe {
    0%   { transform: translateX(30px); opacity: 0; }
    30%  { opacity: 1; }
    70%  { opacity: 1; }
    100% { transform: translateX(-30px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
    .opc-lightbox-hint-icon {
        animation: none;
    }
}

/* ======================================================================
   Surface system (3.26) — the shared vocabulary behind the "soft-shadow
   light / glow-gradient dark" redesign. Two surface utilities + two accent
   utilities, all token-driven so a per-instance BrandingHelper override
   still flows through correctly:

   .opc-surface-raised — a structural panel lifted off the page. Light mode:
   a soft neomorphic dual-shadow (--opc-neo-shadow-out) on a --opc-surface
   background. Dark mode: flat --opc-surface + the existing
   --opc-shadow-lifted (no neomorphic shadow on a near-black surface — see
   --opc-neo-shadow-out's own comment in :root).

   .opc-surface-inset — a control at rest (input fields). Light mode: the
   inverse, "pressed in" shadow (--opc-neo-shadow-in). Dark mode: a plain 1px
   border instead of a fake inset shadow, which reads as noise on dark.

   .opc-gradient-text / .opc-glow — dark mode ONLY. Light mode intentionally
   never gets a gradient (that's the dark side's identity marker, softness
   there comes from shadow instead) — both are no-ops outside
   [data-bs-theme="dark"].
   ====================================================================== */
.opc-surface-raised {
    background-color: var(--opc-surface);
    border-radius: var(--opc-radius-lg);
    box-shadow: var(--opc-neo-shadow-out);
}
[data-bs-theme="dark"] .opc-surface-raised {
    box-shadow: var(--opc-shadow-lifted);
    border: 1px solid var(--opc-border);
}
.opc-surface-inset {
    background-color: var(--opc-surface);
    border-radius: var(--opc-radius);
    box-shadow: var(--opc-neo-shadow-in);
    border: none;
}
[data-bs-theme="dark"] .opc-surface-inset {
    box-shadow: none;
    border: 1px solid var(--opc-border);
}
.opc-gradient-text {
    display: inline-block;
}
[data-bs-theme="dark"] .opc-gradient-text {
    background: var(--opc-gradient-primary);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}
[data-bs-theme="dark"] .opc-glow {
    box-shadow: var(--opc-glow-shadow);
}

/* ======================================================================
   Button system — single source of truth (3.25.9, retuned 3.25.10). Before
   this, header-nav buttons used bare `.btn-sm` (Bootstrap stock: ~31px) and
   in-page CTAs (Add to cart, Save, upload triggers) used bare `.btn` (~38px)
   — two different, un-tokenised heights bouncing around the same pages —
   and individual pages (pet profile's mobile query, the pets-page header)
   each hand-rolled their own shrink/stack fix instead of sharing one.
   Collapses BOTH Bootstrap sizes to one token-driven height for every
   button inside a com_onepetconnect view OR either com_users override
   this platform ships (`.opc-profile-wrap` = the PET profile page,
   site/tmpl/pet/profile.php; `.opc-profile-page` = the ACCOUNT/"Pet Parent"
   profile, big_goose/html/com_users/profile/{default,edit}.php — easy to
   conflate, they are two different pages) — `.btn-sm`'s own smaller
   Bootstrap padding/font-size still computes, it's just overridden after by
   a rule with higher specificity, so it doesn't matter which size class a
   template uses. Scoped (not a bare `.btn` rule) so the rest of the site
   (articles, core Joomla forms) is untouched — site.css loads on every
   front-end page, not just this component's.

   40px/1rem/.9rem (3.25.9) read as a bigger jump than intended once seen
   next to the untouched "Pet Parent" page's stock ~31px buttons — retuned
   to sit between the two Bootstrap stock sizes rather than above both, and
   the "Pet Parent" page (missed in 3.25.9 — it lives outside this
   component's own tmpl/ entirely) is now actually in scope.

   `min-height`, not `height` — a fixed height risks clipping a button whose
   label wraps (translations run longer than English); min-height gets the
   same uniform floor without that failure mode. */
:root {
    --opc-btn-height: 36px;
    --opc-btn-padding-x: .875rem;
    --opc-btn-font-size: .85rem;
}
[class^="com-onepetconnect-"] .btn,
.opc-profile-wrap .btn,
.opc-profile-page .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: var(--opc-btn-height);
    padding: 0 var(--opc-btn-padding-x);
    font-size: var(--opc-btn-font-size);
    line-height: 1.2;
}

/* Page header — title + action buttons. Shared by every view's top row
   (previously each template hand-wrote its own `d-flex justify-content-
   between align-items-center flex-wrap gap-2`, and only the Pets page ever
   got a mobile stacking fix — `.opc-pets-header`, folded into this rule and
   removed as its own copy below). Below 768px the row stacks — heading on
   its own line, actions in a full-width wrapping row underneath it —
   instead of the two squeezing onto one line and crowding/overflowing. */
.opc-page-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: .75rem;
}
/* One-line subtitle under a page's <h1> — currently only the Profile page
   uses it ("Manage your account and preferences."), but generic/tokenised
   so any other view's header can adopt it later. */
.opc-page-subheading {
    margin-top: .2rem;
    font-size: .9rem;
    color: var(--opc-text-faint);
}
.opc-page-header-actions {
    display: flex;
    flex-wrap: wrap;
    gap: .5rem;
}
@media (max-width: 767.98px) {
    .opc-page-header {
        flex-direction: column;
        align-items: flex-start;
    }
    .opc-page-header-actions {
        width: 100%;
    }
    .opc-page-header-actions .btn {
        flex: 1 1 auto;
    }
    /* Back-to-My-Pets on the pet profile page is redundant on phones — the
       mobile back gesture/nav already gets you there, and as a full-width
       flex-1 bar (see rule above) it's the whole top row. Extra
       .opc-page-header-actions qualifier for specificity parity with the
       .btn rule above, same fix as the profile-page Book button below. */
    .com-onepetconnect-pet-profile .opc-page-header-actions .opc-btn-back-to-pets {
        display: none;
    }
}

/* ======================================================================
   Segmented tab switcher — a small reusable component (`.opc-tabs`, see
   mobilenav.js's generic click-wiring), used on the Activity page (My
   Bookings / My Orders) and the Profile page (My Profile / My Pets).
   Triggers are plain <button data-opc-tab-trigger="KEY">; JS toggles
   `.is-active` on the trigger and the `hidden` attribute on the matching
   `[data-opc-tab-panel="KEY"]`. The initial active/hidden state is set
   server-side (PHP) — this CSS only needs to style the two states, not
   decide which one is showing.
   ====================================================================== */
/* Holds a `.opc-tab-switch` plus one trailing action button (e.g. the
   Activity page's My Bookings/My Orders switch + its history-range toggle)
   on the same row — switch left, action right. `.opc-tab-switch` keeps its
   own margin-bottom for every OTHER caller that doesn't wrap it in this row;
   here that margin is neutralised and moved onto the row itself so the two
   don't stack. `min-width: 0` lets the switch's own horizontal scroll
   (overflow-x: auto, above) still work inside a flex item. */
.opc-tab-switch-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .75rem;
    margin-bottom: 1rem;
}
.opc-tab-switch-row .opc-tab-switch {
    margin-bottom: 0;
    min-width: 0;
}
.opc-tab-switch-row .opc-filter-toggle {
    flex-shrink: 0;
}
.opc-tab-switch {
    position: relative;
    display: inline-flex;
    max-width: 100%;
    gap: .25rem;
    padding: .25rem;
    margin-bottom: 1rem;
    border-radius: var(--opc-shape);
    background: var(--opc-surface-sunken);
    /* More than a couple of labels (e.g. the pet profile's 4-tab Upcoming/
       Completed/Assessment Qs/Report Card Qs switch) can outgrow the
       viewport at normal zoom on a narrow phone, or ANY tab count can
       outgrow it once the page is zoomed in — labels have a min-height but
       nothing capped their width, so they'd otherwise get squeezed into
       unreadable, overlapping pills. Scroll the track itself horizontally
       instead: labels keep their natural width (flex-shrink:0 below) and
       any overflow pans left/right rather than being crushed. The thumb is
       positioned absolute within this same scroll container, so its
       translateX stays correct regardless of scroll offset. */
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    /* Sunken-groove shadow so the track reads as recessed below the page,
       not just a flat grey pill. Own dedicated values rather than the
       shared --opc-neo-shadow-in token — that token is tuned subtle for
       form inputs elsewhere and other callers shouldn't get pulled along
       if this needs tuning again. Top-edge shadow + bottom-edge highlight
       (or the reverse in dark mode) is what sells "recessed" — a single
       shadow alone reads as a drop shadow, not a groove. */
    box-shadow: inset 0 2px 4px rgba(15, 23, 42, .3),
                inset 0 -1px 0 rgba(255, 255, 255, .8);
}
[data-bs-theme="dark"] .opc-tab-switch {
    box-shadow: inset 0 2px 4px rgba(0, 0, 0, .65),
                inset 0 1px 0 color-mix(in srgb, white 14%, transparent);
}
.opc-tab-switch::-webkit-scrollbar {
    display: none;
}
/* Sliding "thumb" — one absolutely-positioned pill that JS (mobilenav.js /
   admin-tabs.js) moves under the active label via `transform`/`width`,
   instead of each label just flipping its own background on/off. That's
   what makes the switch actually SLIDE left/right between tabs rather than
   cross-fading two static pills. JS sets `left:0` once and drives position
   purely through `transform: translateX()` (cheaper than animating `left`,
   stays on the compositor). Inserted as the switch's first child so it
   paints below the labels (z-index below `.opc-tab-label`'s own stacking
   context, see below) without needing z-index on itself. `is-flash` is
   added for the duration of the move (JS-timed to match the transform
   transition) to swap the fill to the branding accent colour immediately
   (no transition on that swap — it should read as an instant "tap"), then
   removed once landed so the base rule's own background-color transition
   fades it back to the branding primary colour. */
.opc-tab-switch-thumb {
    position: absolute;
    top: .25rem;
    bottom: .25rem;
    left: 0;
    width: 0;
    border-radius: var(--opc-shape);
    background: var(--opc-primary);
    opacity: 0;
    transition: transform .32s cubic-bezier(.65, 0, .35, 1),
                width .32s cubic-bezier(.65, 0, .35, 1),
                background-color .45s ease;
    pointer-events: none;
}
.opc-tab-switch.has-thumb .opc-tab-switch-thumb {
    opacity: 1;
}
.opc-tab-switch-thumb.is-flash {
    background: var(--opc-accent);
    transition: transform .32s cubic-bezier(.65, 0, .35, 1),
                width .32s cubic-bezier(.65, 0, .35, 1);
}
.opc-tab-label {
    position: relative;
    z-index: 1;
    display: flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    min-height: 2.75rem;
    padding: .4rem 1.1rem;
    border: none;
    border-radius: var(--opc-shape);
    background: none;
    color: var(--opc-text-soft);
    font: inherit;
    font-size: .85rem;
    font-weight: 600;
    white-space: nowrap;
    cursor: pointer;
    user-select: none;
    transition: color .18s ease;
}
.opc-tab-label:hover {
    color: var(--opc-text);
}
.opc-tab-label:focus-visible {
    outline: 2px solid var(--opc-primary);
    outline-offset: 2px;
}
/* Baseline (no-JS / not-yet-mounted) fallback — a flat fill, same as
   before the sliding thumb existed. `.opc-tab-switch.has-thumb` (added by
   JS once it successfully mounts `.opc-tab-switch-thumb`) turns this off so
   the thumb is the only thing painting the active fill; if the thumb never
   mounts, the tab still reads correctly instead of going colourless. */
.opc-tab-label.is-active {
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
}
.opc-tab-switch.has-thumb .opc-tab-label.is-active {
    background: none;
}
.opc-tab-label:disabled {
    color: var(--opc-text-faint);
    cursor: not-allowed;
}
.opc-tab-panel[hidden] {
    display: none;
}

/* ======================================================================
   Home hub (Unified Navigation follow-on) — expandable hero built from an
   admin-picked article, "What's New" articles, Store/Services previews.
   Same token set as everywhere else, no new colours.
   ====================================================================== */
.opc-home-article-full {
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius-lg);
    box-shadow: var(--opc-shadow-resting);
    padding: 1.75rem 2rem;
    color: var(--opc-text-soft);
    line-height: 1.65;
}
/* Same float-neutralising + fit-not-crop treatment as .item-page below
   (see that block's own comments) — this branch renders when Feature
   Toggles -> Homepage Hero Hub is OFF, straight WYSIWYG content.prepare
   output with no clamp. Screenshot-confirmed live: Joomla's WYSIWYG wraps
   an image in <figure class="left item-image">, whose "left" class floats
   it, so the article text wrapped beside the photo instead of flowing
   below it ("Word document" look) and the image was never centred. */
.opc-home-article-full > :has(> img) {
    float: none !important;
    width: auto !important;
    max-width: none !important;
    margin: 0 0 1.5rem !important;
    text-align: center;
}
.opc-home-article-full > * {
    clear: both;
}
.opc-home-article-full img {
    display: block !important;
    float: none !important;
    width: auto !important;
    height: 375px !important; /* was 300px, +25% */
    max-width: 100%;
    object-fit: contain;
    border-radius: var(--opc-radius);
    box-shadow: var(--opc-shadow-lifted);
    margin: 0 auto 1.5rem !important;
}
/* Body-copy rhythm — same treatment .item-page gets further down (that
   block's own comments explain the "What We Offer" left-rail choice), just
   ported here since this branch (Hero Hub OFF) had none of it: raw WYSIWYG
   heading/paragraph spacing only, no vertical rhythm, no readable line
   length, no visual separation for the emoji-led "label: description"
   paragraphs — which is what "some styling before but no image of it" was
   almost certainly referring to. Applies to both plain top-level paragraphs
   and the same shape nested inside <li> (How It Works / What We Offer use
   <ul>/<ol><li><p>...), since :has(> strong:first-child) matches by shape,
   not by depth. */
/* The page's own H1 ("The Hound-Human Connection") — reported live as
   reading too close in weight/size to the body paragraph right under it.
   Full-strength text colour (the surrounding article defaults to
   --opc-text-soft, muted, for body copy) plus a deliberately larger size
   than the h2/h3 sub-headings below give it real hierarchy over both.
   font-weight is already 700 from the site-wide h1..h6 rule near the top
   of this file (Quicksand); repeated here so this rule is self-contained. */
.opc-home-article-full > h1 {
    font-size: clamp(1.75rem, 1.35rem + 2vw, 2.25rem);
    font-weight: 700;
    color: var(--opc-text);
    margin-bottom: 1rem;
}
.opc-home-article-full :is(h2, h3) {
    margin-top: 1.75rem;
    margin-bottom: .5rem;
}
.opc-home-article-full > :is(h2, h3, p):first-child {
    margin-top: 0;
}
.opc-home-article-full p {
    max-width: 68ch;
    line-height: 1.65;
}
.opc-home-article-full :is(ul, ol) {
    margin-top: .5rem;
    margin-bottom: 1.25rem;
}
.opc-home-article-full li {
    margin-bottom: .5rem;
}
.opc-home-article-full p:has(> strong:first-child) {
    max-width: none;
    background: var(--opc-surface-sunken);
    border-left: 3px solid var(--opc-primary);
    border-radius: 0 8px 8px 0;
    padding: .65rem .9rem;
    margin: .6rem 0;
}
.opc-home-article-full p:has(> strong:first-child) > strong {
    display: block;
    margin-bottom: .15rem;
    color: var(--opc-primary);
}

.opc-home-hero {
    position: relative;
    overflow: hidden;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius-lg);
    box-shadow: var(--opc-shadow-resting);
    padding: 1.5rem 1.75rem;
    margin-bottom: 2rem;
}
.opc-home-hero-bg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    pointer-events: none;
    transition: opacity .35s ease;
    /* .opc-home-hero's own `overflow: hidden` SHOULD clip this to its
       border-radius on its own, but an absolutely-positioned `inset: 0`
       image is exactly the case where that clip is unreliable cross-
       browser — a well-known WebKit/Safari issue where such a child gets
       promoted to its own compositing layer and paints square corners
       straight over the parent's rounded edge/border, reported live as the
       photo overlapping the card's border. Giving the image the SAME
       radius directly is the standard fix — it no longer depends on the
       ancestor's clip alone. */
    border-radius: inherit;
}
/* Desktop (≥1200px): fully hidden while collapsed, fades in once
   .opc-home-hero-expanded lands (see mobilenav.js's expand-toggle click
   handler) so the photo grows in step with the card instead of just being
   cropped-and-revealed by the overflow:hidden card. Mobile flips this —
   see the @media (max-width: 1199.98px) block below the hero section,
   which is what the plain `opacity` (no default here) falls back to
   under 1200px: initial value, i.e. visible from first paint. */
@media (min-width: 1200px) {
    .opc-home-hero-bg {
        opacity: 0;
    }
    .opc-home-hero-expanded .opc-home-hero-bg {
        opacity: 1;
    }
}
/* Options -> Home Page -> Hero Image (HomeModel::getHeroImageUrl()). The
   image sits behind a fixed dark wash so the headline stays readable
   regardless of the photo's own tones or which theme the visitor is in —
   deliberately NOT theme-aware itself, since a bright hero photo needs the
   same legible-white-text treatment in light mode as in dark. Unset, the
   hero keeps its plain surface/border above untouched.

   No z-index anywhere in this trio (.opc-home-hero-bg / -overlay /
   -content) — all three are positioned with z-index:auto, so they paint in
   plain DOM/tree order: photo, then wash, then content, each on top of the
   last. Simpler and less error-prone than juggling z-index numbers, and
   the earlier ::before-based version of this got that ordering backwards
   (a pseudo-element is generated as the FIRST child, painting UNDER a
   later real sibling at the same stacking level) — the photo painted over
   the wash and hid it completely. Keep this as real siblings in markup
   order, not a pseudo-element. */
/* The real border is always transparent once an image is present — see the
   ::after ring below, which is this state's ONLY visible border mechanism
   from here on. Reasoning: `.opc-home-hero`'s own border paints as part of
   its own background/border layer, which happens BEFORE its descendants —
   so even with the corner-clip fix on .opc-home-hero-bg above, the photo
   sits on the SAME side of that paint order as "after the border", and any
   remaining sub-pixel clip mismatch would still show as the photo covering
   a sliver of it (requested live: "bring the border to front of the
   image"). A `::after` pseudo-element is generated LAST in the box tree —
   after the real .opc-home-hero-bg/-overlay/-content siblings — so its own
   render (this ring) unconditionally paints on top of all three, the same
   reasoning the .opc-home-hero-overlay comment above already uses for why
   a real sibling beats z-index juggling, just carried one step further. */
.opc-home-hero-has-image {
    border-color: transparent;
    /* The ambient elevation shadow moves to the ::after ring below too —
       .opc-home-hero's OWN box-shadow (like its border) still paints
       before the photo, so a customer whose Branding "Shadow Color" is
       vivid enough to read as a hard edge (confirmed live: a pink
       instance's shadow structurally matches --opc-shadow-resting's
       dark-mode value exactly) is just as exposed to the same
       image-covers-it problem the border ring was fixed for. One
       front-painted layer now owns BOTH edge effects, not two separate
       ones on two different paint steps. */
    box-shadow: none;
}
.opc-home-hero-has-image::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    /* Border ring + ambient shadow are BOTH unconditional now — requested
       live: the border should stay visible in every state, not just
       fade out while expanded/collapsed as the pre-existing design (now
       retired) had it. No per-state @media override left for this
       property; the base value here is the only one. */
    box-shadow: inset 0 0 0 1px var(--opc-border), var(--opc-shadow-resting);
    pointer-events: none;
    transition: box-shadow .35s ease;
}
/* Image set, no article (see home/default.php) — a plain photo banner, not
   a caption-over-photo card: no dark legibility wash (nothing to protect),
   and the <img> is a normal in-flow block (width 100%, height auto) instead
   of the .opc-home-hero-bg absolute-inset trick, so the card's height IS
   the image's own rendered height rather than a fixed box the photo gets
   cropped into. */
.opc-home-hero-image-only {
    padding: 0;
    width: 75%;
    margin-left: auto;
    margin-right: auto;
    /* .opc-home-hero's own box-shadow is --opc-shadow-resting (a barely-there
       1-2px card edge, derived from --opc-shadow-color) — fine for a
       full-width surface sitting flush against the page, but this banner
       now floats at 75% width with page background showing on both sides,
       where that subtle shadow reads as flat. --opc-shadow-lifted is the
       same brand-tinted shadow system (Options -> Branding -> Shadow Color
       / Shadow Color (Dark), see BrandingHelper), just the stronger
       elevation step already used for modals/dropdowns — no separate
       --opc-accent tint here, so it stays part of that one shadow system
       instead of a one-off. */
    box-shadow: var(--opc-shadow-lifted);
}
.opc-home-hero-banner-img {
    display: block;
    width: 100%;
    height: auto;
}
.opc-home-hero-overlay {
    position: absolute;
    inset: 0;
    /* Same cross-browser clip fix as .opc-home-hero-bg above — this is the
       same absolutely-positioned inset:0 shape over the same rounded
       parent, so it's exposed to the identical corner-clipping bug. */
    border-radius: inherit;
    /* Flat, not a top-to-bottom gradient — a uniform wash keeps the photo
       visible everywhere in the card while still giving white text enough
       contrast, rather than fading the top of the photo out entirely. An
       inset vignette rides along on this same element/opacity fade — depth
       around the card edges without a second element or a second
       transition to keep in sync. */
    background: rgba(0, 0, 0, .6);
    box-shadow: inset 0 0 3rem rgba(0, 0, 0, .5);
    pointer-events: none;
    transition: opacity .35s ease;
}
/* Desktop (≥1200px): fades with .opc-home-hero-bg above — the wash exists
   only to keep text legible over the photo, so it has no reason to be
   visible while the photo itself is hidden. Mobile mirrors .opc-home-hero-bg
   the same way, in the @media block below the hero section. */
@media (min-width: 1200px) {
    .opc-home-hero-overlay {
        opacity: 0;
    }
    .opc-home-hero-expanded .opc-home-hero-overlay {
        opacity: 1;
    }
}
.opc-home-hero-content {
    position: relative;
}
/* Targets .opc-home-hero-content h1 generally, not just the one nested in
   .opc-home-hero-head — the no-article-selected fallback heading
   (COM_ONEPETCONNECT_HOME_WELCOME_TITLE) renders as a direct child of
   .opc-home-hero-content instead, and would otherwise keep the default
   dark heading colour over this same dark photo wash.

   Keyed off BOTH .opc-home-hero-has-image AND .opc-home-hero-expanded, not
   has-image alone — white text is only legible while the photo/wash is
   actually visible. Collapsed, the card is back to a plain --opc-surface
   (near-white in light mode), where forced white text would be unreadable. */
.opc-home-hero-content h1,
.opc-home-hero-text,
.opc-home-hero-content .text-muted {
    transition: color .35s ease;
}
/* Desktop only (≥1200px) — see the mobile-flip @media block below the hero
   section for the ≤1199.98px inverse (white while COLLAPSED there). */
@media (min-width: 1200px) {
    .opc-home-hero-has-image.opc-home-hero-expanded .opc-home-hero-content h1,
    .opc-home-hero-has-image.opc-home-hero-expanded .opc-home-hero-text,
    .opc-home-hero-has-image.opc-home-hero-expanded .text-muted {
        color: #fff;
    }
}
.opc-home-hero-text {
    max-height: 6.5rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    position: relative;
    color: var(--opc-text-soft);
    line-height: 1.6;
}
.opc-home-hero-text:not(.is-expanded)::after {
    content: "";
    display: block;
    position: sticky;
    left: 0;
    right: 0;
    bottom: 0;
    height: 2.5rem;
    margin-top: -2.5rem;
    background: linear-gradient(to bottom, transparent, var(--opc-surface));
    pointer-events: none;
}
.opc-home-hero-text.is-expanded {
    /* REAL BUG fixed here: overflow-y:auto + overscroll-behavior:contain
       were left active on the expanded state too — a mobile-browser quirk
       (WebKit/Chrome touch handling) can lock the WHOLE PAGE's scroll
       gesture inside a still-`auto`-overflow box even once its content no
       longer overflows it, confirmed live (expanding the hero froze page
       scroll on mobile). Expanded means "no clamp, no internal scroll at
       all" — reset both so the box is a plain in-flow block and the page
       itself handles scrolling normally, same as any other content. */
    max-height: none;
    overflow: visible;
    overscroll-behavior: auto;
}
.opc-home-hero-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
}
.opc-home-hero-head h1 {
    min-width: 0;
}
/* 3.4x: background/border-color !important (confirmed live, same root
   cause as .btn-outline-primary/.btn-outline-secondary above): adding the
   `.btn` class in home/default.php means big_goose/template.css's own
   (0,3,0) `:not(.btn-check) + .btn:active` rule (template.css:3055) can
   match on a real press or drag-off, painting `background-color:
   transparent` (var(--btn-active-bg) is undefined for this element, so it
   falls back to background-color's own initial value) over this rule's
   un-!important'd (0,1,0). */
.opc-home-hero-toggle {
    /* border: none, not a border matching the fill colour — same reasoning
       as .btn-primary's own "border: none, not border-color: transparent"
       note below: on a fully-rounded pill the border ring's anti-aliasing
       seam against the fill shows as a visible fringe at the caps, and a
       solid-fill button doesn't need an edge to read as a button anyway. */
    flex-shrink: 0;
    appearance: none;
    border: none !important;
    border-radius: var(--opc-shape);
    background: var(--opc-primary) !important;
    color: var(--opc-primary-contrast);
    font-size: .85rem;
    font-weight: 600;
    /* Matches .opc-profile-banner-btn's rendered height (~36px: .3rem padding
       @16px + .72rem font + browser default line-height ≈ 29-36px there) via
       an explicit box instead of copying its padding/font-size verbatim —
       this button sits on a plain surface, not a fixed-height banner strip,
       so it needs solid fill + real size to read as a control at all next
       to an <h1>, not just a height match. */
    min-height: 2.25rem;
    padding: 0 1.1rem;
    cursor: pointer;
    box-shadow: none;
    transition: transform .12s ease, background .15s ease, color .15s ease;
    /* Space is reserved from first paint regardless of whether the article
       actually overflows — mobilenav.js toggles `visibility`, not the
       `hidden` attribute, specifically so revealing/hiding this button
       never shifts the text or CTA row below it (the "jump on load" this
       replaces — see mobilenav.js's own sync() comment). */
    visibility: hidden;
}
/* NOT --opc-primary-tint — that token is primary mixed at only 8% into
   --opc-surface (built for a subtle wash under body copy elsewhere in this
   file), so on this solid brand-filled button it read as the button fading
   almost to transparent/off-brand on hover instead of staying a solid brand
   fill (reported live). A real button's hover state stays solid — just a
   shade darker, same convention as a native Bootstrap .btn-primary hover. */
.opc-home-hero-toggle:hover {
    background: color-mix(in srgb, var(--opc-primary) 85%, black) !important;
    color: var(--opc-primary-contrast);
    transform: translateY(-1px);
}
.opc-home-hero-toggle:active {
    transform: translateY(0);
}
/* Tap flash — primary at rest, instant swap to --opc-accent on press, then
   fades back to --opc-primary via this rule's own base `transition:
   background .15s ease` once buttonfx.js removes .is-pressed on release
   (reverted back to this — was briefly changed to a resting-accent/darker-
   accent-flash two-tone treatment, reverted per direct request). Needs the
   plain `.btn` class added in home/default.php — buttonfx.js only tracks
   press state on `.closest('.btn')`.
   !important REQUIRED (3.4x): adding the plain `.btn` class makes this
   element also match big_goose/template.css's own compiled
   `:not(.btn-check) + .btn:active` rule (template.css:3055, specificity
   (0,3,0) vs this rule's (0,2,0)) on a real physical press. Unlike
   .btn-outline-primary, nothing here defines --btn-active-bg, so that
   collision doesn't paint the wrong colour — it paints `transparent` (an
   unresolved var() with no fallback falls back to background-color's
   initial value), which would flash the photo/page through the button
   instead of the accent colour. Same fix, same reason as the other two
   is-pressed rules in this file. */
.opc-home-hero-toggle.is-pressed {
    background: var(--opc-accent) !important;
    color: #fff !important;
    transition: none;
}
/* Suppresses the plain-surface fade-out mask above over the photo: that
   mask fades to var(--opc-surface), which read as a stray light/grey box
   across the last clamped line once the text sits over the dark photo
   wash instead of a plain card surface (same issue reported on mobile's
   equivalent state below). Gated on .opc-home-hero-expanded too (not
   has-image alone) — collapsed, the photo and wash are faded out, so that
   state must fall back to the plain-surface mask above instead. */
.opc-home-hero-has-image.opc-home-hero-expanded .opc-home-hero-text:not(.is-expanded)::after {
    background: transparent;
}

/* Mobile (≤1199.98px — OPC's mobile/desktop-chrome boundary, see Settled
   decisions' Unified Navigation): the hero photo shows BEFORE the article
   is expanded, not after — the exact opposite of desktop, requested live.
   On a phone the collapsed hero is the small, glanceable state, where a
   photo carries more than 2-3 lines of clamped text can; once expanded to
   actually read the article, the photo only competes with reading it, so
   it hides instead and reappears the moment the article collapses back.
   Desktop's own rules (photo grows in AS you expand) are wrapped in their
   own @media (min-width: 1200px) blocks above specifically so they don't
   apply here — this block is the mobile complement of every one of them,
   same properties, states swapped. */
@media (max-width: 1199.98px) {
    .opc-home-hero-bg,
    .opc-home-hero-overlay {
        opacity: 1;
    }
    .opc-home-hero-expanded .opc-home-hero-bg,
    .opc-home-hero-expanded .opc-home-hero-overlay {
        opacity: 0;
    }
    .opc-home-hero-has-image:not(.opc-home-hero-expanded) .opc-home-hero-content h1,
    .opc-home-hero-has-image:not(.opc-home-hero-expanded) .opc-home-hero-text,
    .opc-home-hero-has-image:not(.opc-home-hero-expanded) .text-muted {
        color: #fff;
    }
    /* Reported live: the clamped text's own fade-out mask (the plain
       ::after above, faded to var(--opc-surface)) was still painting its
       light "fade to card surface" gradient here, but collapsed-on-mobile
       now sits the text over the dark photo wash instead of a plain
       surface — the light gradient read as a stray grey box across the
       last line. A dark-gradient fade was tried next (matching the desktop
       rule above) but read as a stray dark shadow instead — same fix as
       desktop's own state below: suppress the mask outright rather than
       recolour it, so the clamped text just hard-clips over the photo. */
    .opc-home-hero-has-image:not(.opc-home-hero-expanded) .opc-home-hero-text:not(.is-expanded)::after {
        background: transparent;
    }
}

.opc-home-section {
    margin-bottom: 2.25rem;
}
.opc-home-section-head {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 1rem;
    margin-bottom: 1rem;
}
.opc-home-section-head a,
.opc-home-section-head button {
    font: inherit;
    font-size: .82rem;
    font-weight: 600;
    color: var(--opc-primary);
    text-decoration: none;
    background: none;
    border: none;
    padding: 0;
    cursor: pointer;
}
/* Filled pill CTA ("Visit Store" — reused both in the home hub's section
   head and inside the store quick-view modal's browse row) — the
   plain-text link/button treatment above (and .opc-store-modal-browse-row
   button's own, same shape) is right for a subtler "View all" style
   control, but this one needs to read as a button: brand background, pill
   shape, and the SAME press-driven accent flash every other .btn on this
   site gets (see buttonfx.js — :active alone doesn't survive a real
   ~100ms tap, which is why every pressable control here is driven by the
   JS-added .is-pressed class instead). Carrying the plain `.btn` class in
   the template is what makes buttonfx.js's delegated `closest('.btn')`
   pick this element up for the ripple + .is-pressed handling in the
   first place.
   !important + a 2-class selector on the colour props: same requirement
   documented above .btn-outline-primary — this control also sits under
   Cassiopeia's compiled `:not(.btn-check) + .btn:active` rule
   (template.css:3055, (0,3,0) specificity), which repaints an unbranded
   navy the instant a real pointer-down registers unless beaten with
   !important. Non-colour props (padding/pill radius) only need to
   out-rank the two flatten rules above (each (0,1,1)) — (0,2,0) already
   clears that. */
.btn.opc-home-cta {
    /* border: none, not a border matching the fill — same anti-aliasing-
       fringe reasoning as .btn-primary's own note below, and buttons
       shouldn't carry one at all per direct request. */
    display: inline-flex;
    align-items: center;
    padding: .35rem 1rem;
    border-radius: 999px;
    background-color: var(--opc-primary) !important;
    border: none !important;
    color: var(--opc-primary-contrast) !important;
    font-size: .82rem;
    font-weight: 600;
    text-decoration: none;
    transition: background-color .45s ease;
}
.btn.opc-home-cta:hover,
.btn.opc-home-cta:focus-visible {
    background-color: color-mix(in srgb, var(--opc-primary) 85%, black) !important;
    color: var(--opc-primary-contrast) !important;
}
/* Tap flash — primary at rest, --opc-accent on press only (reverted back
   to this from a brief resting-accent two-tone treatment, per direct
   request). */
.btn.opc-home-cta.is-pressed {
    background-color: var(--opc-accent) !important;
    color: var(--opc-primary-contrast) !important;
    transition: none;
}
/* Section-head placement only: nudges it off the head's flush right edge
   (live "too far right" report) — not wanted on the modal's centred
   browse-row copy of this button. */
.opc-home-section-head .btn.opc-home-cta {
    margin-right: 5px;
}

.opc-home-articles-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
    gap: 1.1rem;
    padding-top: 5px;
}
/* News Feed AND What's New (both home hub sections) — dynamically fills the
   row: 1 item stretches to the full width, 2 split it in half, and so on up
   to getNewsFeedArticles()'s own cap of 5 items, after which the row is
   exactly full and "See more" takes over (query-limited to 5, so a 6th
   card can never reach this grid to wrap onto a second row). `auto-fit`
   (not `auto-fill`) is what makes empty tracks collapse so existing cards
   grow to fill the gap, rather than leaving empty column-shaped gaps
   beside a single card. Naturally responsive on its own — narrower
   viewports fit fewer 200px-minimum columns and cards stack, no explicit
   breakpoints needed. Shared, uncapped column count — this is the mobile-
   carousel/card-visibility class both sections use; the desktop-only "max
   3 columns" cap below is a SEPARATE modifier applied only to What's New,
   not this one, since News Feed was explicitly asked to stay unchanged. */
.opc-home-articles-grid--fixed5 {
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* What's New ONLY (--max3, added alongside --fixed5, never on its own) —
   dynamic but never more than 3 columns per row, even on a very wide
   screen ("dynamic but with a max of 3", explicitly scoped to What's New
   only — News Feed keeps the uncapped rule above untouched). Standard
   capped-RAM grid technique: each track's minimum is `max(200px, 1/3 of
   the available width)` — forcing a 4th column would require every track
   to shrink below that 1/3 floor, which auto-fit refuses to do, so a 4th+
   card wraps to a second row instead of ever rendering a 4th column.
   `calc(2 * 1.1rem)` subtracts the two gaps between 3 columns before
   dividing by 3, matching this grid's own `gap: 1.1rem` so the 1/3 math is
   exact. Combined with HomeModel::getLatestArticles()'s own 3-item default
   (see that method), What's New in practice never even reaches a 4th card
   to test this cap against — the column math and the query cap both land
   on 3, which is what keeps it to a true single row, not just 3-wide. */
.opc-home-articles-grid--fixed5.opc-home-articles-grid--max3 {
    grid-template-columns: repeat(auto-fit, minmax(max(200px, calc((100% - 2 * 1.1rem) / 3)), 1fr));
}
/* The 6th "See more" slide — desktop already has the section head's own
   "See more" CTA at this width, so the card stays hidden there; it only
   shows inside the mobile swipe carousel below.
   Two-class selector (not just `.opc-home-see-more-card`) is REQUIRED: this
   element also carries `.opc-home-article-card`/`.opc-home-product-card`,
   whose own `display: block` rules (below, later in the cascade) are the
   SAME (0,1,0) specificity as a single-class `.opc-home-see-more-card`
   rule — same specificity means source order decides, and those later
   rules were winning, silently un-hiding this on desktop (confirmed live).
   Compounding both classes bumps this to (0,2,0), which wins regardless of
   where it sits in the file. */
.opc-home-article-card.opc-home-see-more-card,
.opc-home-product-card.opc-home-see-more-card {
    display: none;
}
/* Swipe-hint arrow (see the mobile media query below for its shown state)
   — hidden by default/desktop for the same reason as the see-more card
   above: a plain <span> has no display rule of its own otherwise and
   would render as stray visible inline text next to the grid. */
.opc-home-scroll-arrow {
    display: none;
}
/* Mobile (≤1199.98px, same boundary as the hero's own mobile block below):
   What's New / News Feed become a native horizontal swipe carousel — 2
   cards visible per "row", the rest reached by swiping. `scroll-snap-type:
   x mandatory` + each card's own `scroll-snap-align: start` is what makes
   the swipe settle smoothly on a card boundary rather than stopping
   mid-card; native browser scrolling handles the actual gesture (momentum,
   touch, trackpad, arrow-scroll), so unlike the availability calendar's
   drag interaction (see CLAUDE.md's roadmap notes on that one) this needs
   no hand-rolled pointer/touch JS at all — a plain scrollable flex row is
   the whole implementation. Both grids are already query-limited to 5 real
   cards (see the --fixed5 comment above), so 5 article cards + the 6th
   "See more" slide is a fixed 6-slide carousel, never more. */
@media (max-width: 1199.98px) {
    .opc-home-articles-grid--fixed5 {
        display: flex;
        flex-wrap: nowrap;
        overflow-x: auto;
        scroll-snap-type: x mandatory;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;
        /* Explicit, not inherited from the base rule's 1.1rem — matches
           .opc-home-products-grid's own mobile gap exactly so both
           carousels' cards compute to the same width (see the card rule
           below). */
        gap: 1rem;
        /* `overflow-x: auto` forces the browser to compute `overflow-y` as
           `auto` too (per spec, a non-visible value on one axis can't leave
           the other as `visible`) — so with zero padding, a card's own
           box-shadow (which extends a couple px beyond its own box) gets
           unconditionally clipped top/bottom by this container's edge,
           since there's no vertical scroll to "reveal" it into (reported
           live: "shadows are contained in the parent container on mobile,
           should not be the case"). Padding gives the shadow's blur+offset
           room to render before hitting that clip, on every edge.
           !important REQUIRED: `.opc-home-articles-grid`'s own unconditional
           `padding-top: 5px` rule (added earlier, unrelated request) is a
           DIFFERENT selector at the SAME (0,1,0) specificity — confirmed
           this exact collision already silently clobbered
           .opc-home-products-grid's identical padding fix below (that base
           rule sits LATER in this file, so it was winning). !important
           here makes this immune to source-order regardless of what gets
           added around it later — same reasoning as every other
           !important in this file that beats a same-specificity rule. */
        padding: 8px !important;
    }
    .opc-home-articles-grid--fixed5::-webkit-scrollbar {
        display: none;
    }
    .opc-home-articles-grid--fixed5 .opc-home-article-card {
        /* .5rem, not .55rem — this grid's own desktop `gap` is 1.1rem
           while .opc-home-products-grid's is 1rem, so each card's
           own gap-compensated 50%-minus-half-gap math used to differ
           between the two carousels (reported live: "not same width").
           `gap: 1rem` right below overrides both to the SAME value at
           this breakpoint, so both carousels' cards now compute to the
           exact same pixel width for the same viewport, regardless of
           what each grid's desktop gap happens to be. */
        flex: 0 0 calc(50% - .5rem);
        scroll-snap-align: start;
    }
    /* Options -> Mobile Options -> Scroll Hint Style = "Peeking Edge"
       (FeatureHelper::homeCarouselHintStyle()) — narrower than the plain
       50% above so the next card's edge is always visibly peeking into
       view, no separate arrow element needed for this style at all. */
    .opc-home-carousel-wrap[data-opc-hint-style="peek"] .opc-home-article-card {
        flex: 0 0 calc(46% - .4rem);
    }
    /* A single article with no "See more" slide alongside it (too few
       articles to clear ARCHIVE_LINK_MIN_TOTAL) is the grid's only child —
       the fixed 2-up flex-basis above would otherwise still pin it to half
       width with nothing beside it to fill the rest of the row, cramping
       its excerpt text (live report). :only-child only matches when
       there's truly nothing else to swipe to. */
    .opc-home-articles-grid--fixed5 .opc-home-article-card:only-child {
        flex: 1 1 100%;
    }
    .opc-home-articles-grid--fixed5 .opc-home-see-more-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        text-align: center;
        gap: .4rem;
        color: var(--opc-primary);
        border-style: dashed;
    }
    .opc-home-see-more-icon {
        font-size: 1.4rem;
        line-height: 1;
    }
    .opc-home-see-more-label {
        font-size: .85rem;
        font-weight: 600;
    }

    /* "New in the Store" — same carousel treatment: 2 cards visible per
       "row", swipe for the rest. Products are fetched up to 8 (desktop's
       own preview count, unchanged), but mobile caps the swipeable list at
       5 real cards + the 6th "See more" slide, matching the News Feed
       carousel's own cap exactly — cards 6-8 stay in the DOM (desktop still
       shows all of them) and are simply hidden here rather than re-queried.
       display: flex !important REQUIRED (confirmed live): the base,
       unconditional `.opc-home-products-grid { display: grid; }` rule sits
       LATER in this file than this media-query block, and both are equal
       (0,1,0) single-class specificity — same tie-breaks-on-source-order
       trap that already bit the see-more card's visibility above, just on
       the container this time. Without !important the base rule silently
       wins at every viewport and the carousel never activates. */
    .opc-home-products-grid {
        display: flex !important;
        flex-wrap: nowrap;
        overflow-x: auto;
        scroll-snap-type: x mandatory;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;
        /* Same overflow-x-forces-overflow-y-clip fix as the articles
           carousel above — gives card shadows room instead of being cut
           off by this container's own edge.
           !important REQUIRED (confirmed live): `.opc-home-products-grid`'s
           OWN unconditional `padding-top: 5px` rule sits LATER in this
           file than this media-query block, same (0,1,0)-specificity/
           source-order collision already fixed for `display: flex` above
           on this exact selector — it was silently overriding this
           padding's top value back to 5px (only 1px off, but the same
           unreliable mechanism, not something to leave half-fixed). */
        padding: 8px !important;
    }
    .opc-home-products-grid::-webkit-scrollbar {
        display: none;
    }
    .opc-home-products-grid .opc-home-product-card {
        flex: 0 0 calc(50% - .5rem);
        scroll-snap-align: start;
    }
    /* Same "Peeking Edge" style override as the articles carousel above. */
    .opc-home-carousel-wrap[data-opc-hint-style="peek"] .opc-home-product-card {
        flex: 0 0 calc(46% - .4rem);
    }
    .opc-home-products-grid .opc-home-product-card:nth-of-type(n+6) {
        display: none;
    }
    /* Same flat, centered, dashed treatment as the articles carousel's own
       "See more" card (icon + label directly under the anchor, no product-
       media/name substructure) — the two must look identical. */
    .opc-home-products-grid .opc-home-see-more-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        text-align: center;
        gap: .4rem;
        /* .opc-home-product-card itself carries no padding (its padding
           normally lives on the .opc-home-product-media/-name children,
           both absent here) — needed so the icon/label don't sit flush
           against the card edge, matching .opc-home-article-card's own
           self-padding that the articles-grid version of this card
           inherits for free. */
        padding: 1.1rem 1.2rem;
        color: var(--opc-primary);
        border-style: dashed;
    }
    /* Swipe-right hint (3rd rebuild) — a real SIBLING element
       (.opc-home-scroll-arrow in home/default.php), not a pseudo-element
       glued to the scrolling grid itself. The two earlier attempts both
       attached the arrow to `.opc-home-articles-grid--fixed5`/
       `.opc-home-products-grid` — the SAME box that scrolls — which meant
       the arrow shared that box's own content/scroll geometry and, per a
       live report, moved with the scroll instead of staying pinned, on
       top of needing padding-carved-out-of-the-scroll-content hacks just
       to avoid sitting on the last card. A sibling inside a plain
       `position: relative` wrapper (.opc-home-carousel-wrap) that itself
       never scrolls has none of that: it floats over the wrapper at a
       fixed screen position regardless of what's scrolling underneath,
       no padding tricks needed, matching the .opc-scroll-hint pattern
       already established on the profile page.
       A solid flat-colour circular chip, not just a bare glyph — reported
       live as not noticeable enough over a busy/light photo card, where a
       plain coloured arrow with nothing behind it can disappear entirely.
       Still explicitly NO shadow/blur/gradient anywhere (a flat solid
       background is not a shadow) — the earlier "NO SHADOWS" request was
       about the dark smudge from a translucent gradient backdrop
       overlapping a card's own shadow, not about having any backdrop
       at all. */
    .opc-home-carousel-wrap {
        position: relative;
    }
    /* Visibility is now entirely JS-driven (home.js's initCarouselArrows(),
       same shape as profileedit.js's "Scroll for more" pill) — hidden
       whenever the carousel doesn't actually overflow OR is already
       scrolled to its end, requested live: "should disappear when reached
       scroll end, like scroll for more does". The old CSS-only
       :has(> :nth-child(3)) gate could only ever answer "is there a 3rd
       card", never "has the user already scrolled past it" — an actual
       scroll-position check needs JS, there's no CSS-only way to read
       scrollLeft. Structurally always present (so it can be measured/
       positioned without a flash), just opacity:0 + no animation until
       `.is-visible` is added. */
    .opc-home-scroll-arrow {
        display: flex;
        align-items: center;
        justify-content: center;
        position: absolute;
        bottom: .5rem;
        right: .5rem;
        width: 2.25rem;
        height: 2.25rem;
        border-radius: 50%;
        background: var(--opc-accent);
        color: var(--opc-primary-contrast);
        font-size: 1.3rem;
        font-weight: 700;
        line-height: 1;
        pointer-events: none;
        opacity: 0;
        transition: opacity .2s ease;
    }
    .opc-home-scroll-arrow.is-visible {
        opacity: 1;
        animation: opc-see-more-pulse 1.6s ease-in-out infinite;
    }
}
/* Drives the swipe-hint overlay's pulse above — a right-shift + fade,
   reading as "swipe this way for more" rather than a generic
   attention-grab. Top-level (not nested in the mobile media query): a
   @keyframes definition itself isn't restricted by where it's declared,
   only by where it's referenced via `animation`, which is already
   correctly mobile-scoped. Plain translateX now that the chip is
   corner-anchored (bottom/right) rather than vertically centered via its
   own transform — no static transform left to preserve alongside it. */
@keyframes opc-see-more-pulse {
    0%, 100% {
        transform: translateX(0);
        opacity: 1;
    }
    50% {
        transform: translateX(8px);
        opacity: .4;
    }
}
@media (prefers-reduced-motion: reduce) {
    /* Must match .is-visible's own (0,2,0) specificity, not just the bare
       (0,1,0) class — the animation is declared on the compound
       `.opc-home-scroll-arrow.is-visible` rule now (JS toggles that class
       rather than display), and a lower-specificity override here would
       never win against it regardless of source order. */
    .opc-home-scroll-arrow.is-visible {
        animation: none;
    }
}
.opc-home-article-card {
    display: block;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    box-shadow: var(--opc-shadow-resting);
    padding: 1.1rem 1.2rem;
    text-decoration: none;
    color: inherit;
    transition: transform .16s ease, box-shadow .16s ease;
}
.opc-home-article-card:hover,
.opc-home-article-card:focus-visible {
    transform: translateY(-3px);
    box-shadow: var(--opc-shadow-lifted, var(--opc-shadow-resting));
    color: inherit;
    text-decoration: none;
}
/* The card reuses `.opc-pet-notice-badge` verbatim (see _article-card.php)
   for its status pill — same shape/weight/colour as the full article, just
   its own `margin-bottom` override: the article page's 1.25rem gap (built
   for a full poster layout) is too tall for this compact card. Purely a
   spacing override — everything else about the badge (shape, colour per
   `opc-pet-notice--<status>`) comes from the existing rules below/on the
   article page, unchanged. */
.opc-home-article-card .opc-pet-notice-badge {
    margin-bottom: .5rem;
}
/* News Feed archive page ONLY (view=newsfeed, _article-card-photo.php) —
   round pet-photo avatar in a status-coloured ring, alongside the SAME
   `.opc-pet-notice-badge` this page's cards now share with the home hub
   and the article's own body (requested live — this page used to have its
   own separate `.opc-home-article-badge` 2-colour scheme, removed). */
.opc-home-article-top {
    display: flex;
    align-items: center;
    gap: .6rem;
    margin-bottom: .6rem;
}
.opc-home-article-top .opc-pet-notice-badge {
    margin-bottom: 0;
}
.opc-home-article-avatar {
    display: inline-flex;
    flex-shrink: 0;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: 50%;
    padding: .15rem;
}
.opc-home-article-avatar img {
    display: block;
    width: 100%;
    height: 100%;
    border-radius: 50%;
    object-fit: cover;
    border: 2px solid var(--opc-surface);
}
/* Three variants now, matching .opc-pet-notice-badge's own 3-colour status
   scheme (danger/warning/success for lost/runaway/adopted-found) instead
   of the old 2-colour collapse. */
.opc-home-article-avatar--danger {
    background: var(--bs-danger, #dc3545);
}
.opc-home-article-avatar--warning {
    background: var(--bs-warning, #ffc107);
}
.opc-home-article-avatar--success {
    background: var(--bs-success, #198754);
}
.opc-home-article-date {
    display: block;
    font-size: .68rem;
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--opc-muted);
    margin-bottom: .35rem;
}
.opc-home-article-title {
    margin: 0 0 .35rem;
    font-size: .98rem;
    font-weight: 700;
    color: var(--opc-text);
}
.opc-home-article-excerpt {
    margin: 0;
    font-size: .84rem;
    line-height: 1.5;
    color: var(--opc-text-soft);
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
/* Pet-status card details (Species/Breed/Age/Gender) — one fact per line
   instead of the `.opc-home-article-excerpt` run-on sentence, same
   font/colour so swapping between the two (see HomeModel::hydrateArticles())
   doesn't shift the card's type scale. Plain <ul>, not the excerpt's
   clamped box — four short lines never need clamping. */
.opc-home-article-details {
    margin: 0;
    padding: 0;
    list-style: none;
    font-size: .84rem;
    line-height: 1.5;
    color: var(--opc-text-soft);
}

.opc-home-products-grid {
    display: grid;
    /* auto-fit (not auto-fill): collapses empty tracks so a short list
       (e.g. 4 cards on a screen wide enough for 8) stretches to fill the
       row instead of leaving invisible trailing columns of whitespace. */
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 1rem;
    padding-top: 5px;
}
.opc-home-product-card {
    display: block;
    width: 100%;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    box-shadow: var(--opc-shadow-resting);
    overflow: hidden;
    text-decoration: none;
    color: inherit;
    /* Now a <button> (opens the quick-view modal, see home.js), not an <a> —
       these reset the UA button chrome that would otherwise fight the
       explicit background/border/etc above (default button font, centered
       text, own padding, pointer cursor missing on some browsers). */
    font: inherit;
    text-align: left;
    padding: 0;
    cursor: pointer;
    transition: transform .16s ease, box-shadow .16s ease;
}
.opc-home-product-card:hover,
.opc-home-product-card:focus-visible {
    transform: translateY(-3px);
    box-shadow: var(--opc-shadow-lifted, var(--opc-shadow-resting));
}
.opc-home-product-media {
    /* aspect-ratio (not a fixed height) — a true 1:1 square regardless of
       the grid column's own computed width (auto-fit above means that
       width isn't constant), so every product photo crops consistently
       whether the grid renders 3 or 8 columns wide. */
    display: flex;
    align-items: center;
    justify-content: center;
    aspect-ratio: 1 / 1;
    background: var(--opc-surface-sunken);
    color: var(--opc-muted);
    font-size: 1.5rem;
}
.opc-home-product-media img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Same subtle treatment as .opc-home-service-art img below — one
       consistent image system across Store/Services instead of assorted
       stock-photo contrast/saturation. Deliberately light: still shows
       real product detail, not a heavy duotone overlay. */
    filter: saturate(.92) brightness(1.02);
}
.opc-home-product-name {
    display: block;
    padding: .65rem .8rem .8rem;
    font-size: .84rem;
    font-weight: 600;
    color: var(--opc-text);
}

.opc-home-services-grid {
    display: grid;
    /* auto-fit (not auto-fill) collapses empty tracks so existing cards
       stretch to fill the row — 1 service spans full width, N services
       share it evenly, resizing automatically as services are added. */
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 1.1rem;
    padding-top: 5px;
}
.opc-home-service-card {
    display: block;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    box-shadow: var(--opc-shadow-resting);
    overflow: hidden;
    text-align: center;
    cursor: pointer;
    text-decoration: none;
    color: inherit;
    transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease;
}
.opc-home-service-card:hover,
.opc-home-service-card:focus-visible {
    border-color: var(--opc-primary);
    color: inherit;
    text-decoration: none;
    transform: translateY(-3px);
}
.opc-home-service-tap {
    display: inline-block;
    font-size: .78rem;
    font-weight: 600;
    color: var(--opc-primary);
}
/* Options -> Services -> Home Page Image (BookingModel::getServices() ->
   HomeModel image_url). Unset, falls back to a gradient built from the
   Service's own colour (--opc-service-color, set inline per card) via
   color-mix() — the same technique --opc-primary-tint already uses above,
   so a Service with no image still reads as deliberately coloured, never a
   blank/broken block. */
.opc-home-service-art {
    /* aspect-ratio (not a fixed height) — a consistent 16:9 for every
       service tile, same "one image system" reasoning as
       .opc-home-product-media above, instead of mismatched square product
       shots next to wide lifestyle service photos. */
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    aspect-ratio: 16 / 9;
    background: linear-gradient(135deg,
        var(--opc-service-color, var(--opc-primary)),
        color-mix(in srgb, var(--opc-service-color, var(--opc-primary)) 55%, #000));
    /* This box (not .opc-home-service-card two levels up) is the img's
       actual positioned containing block — relying solely on the CARD's
       overflow:hidden/border-radius to clip an absolutely-positioned
       inset:0 image two levels down is the same unreliable cross-browser
       case already fixed on the home hero photo (square corners painting
       over the rounded edge). Only the top corners round here — the body
       section below covers the bottom of the card, so top-only matches
       the card's own visible top edge exactly. */
    overflow: hidden;
    border-radius: var(--opc-radius) var(--opc-radius) 0 0;
}
.opc-home-service-art img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: inherit;
    /* Same subtle treatment as .opc-home-product-media img — one visual
       system across Store/Services. */
    filter: saturate(.92) brightness(1.02);
}
.opc-home-service-icon {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 50%;
    background: rgba(255, 255, 255, .2);
    color: #fff;
    font-size: 1.1rem;
}
.opc-home-service-body {
    display: block;
    padding: 1rem 1.1rem 1.15rem;
}
.opc-home-service-card h3 {
    font-size: .95rem;
    font-weight: 700;
    margin: 0 0 .5rem;
}
@media (prefers-reduced-motion: reduce) {
    .opc-home-service-card,
    .opc-home-article-card,
    .opc-home-product-card {
        transition: none;
    }
    .opc-home-service-card:hover,
    .opc-home-service-card:focus-visible,
    .opc-home-article-card:hover,
    .opc-home-article-card:focus-visible,
    .opc-home-product-card:hover,
    .opc-home-product-card:focus-visible {
        transform: none;
    }
}

/* ======================================================================
   Lists-as-cards — My Bookings / My Orders (3.17.0). A plain <table> never
   reads as "elegant" per the request, and each row now carries less data
   (Price dropped from Bookings per the request) — replaced with a vertical
   stack of row-cards built on the same elevation tokens as the Pets grid.
   ====================================================================== */
.opc-list-cards {
    display: flex;
    flex-direction: column;
    gap: .65rem;
}
.opc-list-card {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .5rem 1.25rem;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border-light);
    border-radius: var(--opc-radius-lg);
    /* Same --opc-card-shadow token the generic .card rule draws from (3.26)
       — a row-card is visually the same "raised panel" as a .card, just a
       different layout, so it should share the identical elevation. */
    box-shadow: var(--opc-card-shadow);
    padding: .9rem 1.1rem;
    color: inherit;
}
.opc-list-card-link {
    text-decoration: none;
    cursor: pointer;
    transition: transform .15s ease, box-shadow .15s ease;
}
.opc-list-card-link:hover,
.opc-list-card-link:focus-visible {
    transform: translateY(-2px);
    box-shadow: var(--opc-shadow-lifted);
}
.opc-list-card-main {
    flex: 1 1 220px;
    min-width: 0;
}
.opc-list-card-title {
    font-weight: 700;
    color: var(--opc-text);
}
.opc-list-card-sub {
    font-size: .85rem;
    color: var(--opc-text-soft);
}
.opc-list-card-when {
    flex: 0 0 auto;
    font-size: .88rem;
    color: var(--opc-text-soft);
}
.opc-list-card-status {
    flex: 0 0 auto;
    margin-left: auto;
}
@media (max-width: 575.98px) {
    .opc-list-card {
        flex-direction: column;
        align-items: flex-start;
    }
    /* .opc-list-card-main's base rule is `flex: 1 1 220px` — a flex-basis
       meant as a MINIMUM WIDTH for the row layout above. Flipping to
       flex-direction: column here rotates the main axis, so that same
       "220px" is read as a starting HEIGHT, and flex-grow: 1 then stretches
       it further to fill the column — a huge dead space between the
       title/sub text and the date/status line below it (confirmed live: a
       504x311px card on the Activity page for what should be a ~120px
       three-line card). Same bug, same fix already applied to
       .opc-bookings-scroll .opc-list-card-main (profile page's mini
       scroller) — needs no basis/grow at all in column mode, only as tall
       as its own two lines of text. */
    .opc-list-card-main {
        flex: 0 1 auto;
        height: auto;
        min-height: 0;
    }
    .opc-list-card-status {
        margin-left: 0;
    }
}
/* Compact variant (3.19.0) — Cart items, a "scaled down" reuse of the same
   pattern rather than a new one, per the request. */
.opc-list-cards-compact .opc-list-card {
    padding: .6rem .85rem;
    gap: .4rem .9rem;
}
.opc-list-cards-compact .opc-list-card-title {
    font-size: .92rem;
}

/* Unavailable cart line — the slot was taken (or the product sold out) after it
   was added, so the line is shown but excluded from the total and blocks
   checkout (see CartModel::getDetailedCart()). Deliberately dimmed rather than
   hidden: the buyer needs to see WHICH item went, especially when retrying a
   failed payment. Uses the existing danger token + a left accent, the same
   device as .opc-list-card-highlight, so it reads correctly in both themes
   without its own dark-mode override. */
.opc-list-card-unavailable {
    border-left: 3px solid var(--opc-danger);
    opacity: .68;
}
.opc-list-card-unavailable .opc-list-card-title {
    text-decoration: line-through;
    text-decoration-color: var(--opc-danger);
}
/* The remove button must stay fully legible — it is the one action that
   resolves this state, so it is exempt from the dimming above. */
.opc-list-card-unavailable form,
.opc-list-card-unavailable .badge {
    opacity: 1;
}

/* Upcoming-booking row accent (3.19.6, pet profile's Bookings & history —
   replaces the old bare <table class="table-light"> row, which never had a
   real dark-mode treatment of its own). A left accent bar keyed to the brand
   primary, not a full background tint, so it reads correctly against the
   card surface in either theme without needing its own light/dark override. */
.opc-list-card-highlight {
    border-left: 3px solid var(--opc-primary);
}

/* Cart page (3.19.0) — a centered, card-styled panel rather than a
   full-width table; reads as a "screen-centered modal" without actually
   gatekeeping a page that needs its own bookmarkable/navigable URL (the
   payment gateway return flow depends on it) behind client-side modal
   state — see this round's plan notes for why. */
.opc-cart-panel {
    max-width: 900px;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    box-shadow: var(--opc-shadow-lifted);
    padding: 1.5rem 1.75rem;
}
@media (max-width: 767.98px) {
    .opc-cart-panel {
        padding: 1.1rem 1rem;
    }
}
.opc-cart-total {
    padding-top: .75rem;
    border-top: 1px solid var(--opc-border-light);
}
.hcc-qty-box {
    flex: 0 0 auto;
}
.hcc-qty-box input[type="number"] {
    width: 4.5rem;
}

/* Cart items — single column at every width (tried a 2/3-col grid like My
   Pets, reverted: single column read better here). Kept as its own
   wrapper/item class pair (rather than plain .opc-list-cards) so
   .opc-cart-card below can target it specifically. */
.opc-cart-grid {
    display: flex;
    flex-direction: column;
    gap: .65rem;
}
.opc-cart-grid-item {
    display: flex;
}
/* .opc-list-card's row layout only collapses to a column below 575.98px
   (see the plain-row usage on Bookings/Orders lists); Cart forces a
   compact column at every width instead, which is what actually fixed
   the "massive" cards — full-width rows with a wide empty gap between
   wrapped children. */
.opc-cart-card {
    flex: 1 1 auto;
    flex-direction: column;
    align-items: stretch;
    gap: .35rem;
    padding: .7rem .85rem;
}
.opc-cart-card .opc-list-card-main,
.opc-cart-card .opc-list-card-when,
.opc-cart-card .opc-list-card-status {
    flex: 0 0 auto;
    margin-left: 0;
}
.opc-cart-card .opc-list-card-title {
    font-size: .92rem;
}
.opc-cart-card .hcc-qty-box {
    width: 100%;
}
.opc-cart-card .hcc-qty-box input[type="number"] {
    width: 3.5rem;
}
.opc-cart-card .hcc-qty-box select {
    flex: 1 1 auto;
    min-width: 0;
}

/* ======================================================================
   Booking calendar — Month grid + slot side-panel (site/tmpl/booking/
   default.php, driven by media/com_onepetconnect/js/booking.js).
   Week view reuses the shared media/com_onepetconnect/css/timegrid.css
   (same file the admin availability calendar uses) — that file reads the
   same --opc-* tokens defined in this file's :root/dark blocks above, so
   the two calendars stay visually consistent without a second copy of the
   values anywhere. DELIBERATELY NO DAY VIEW on the site side (see
   booking/default.php's view-toggle — a pet parent picks a date from a
   month or a week, a single day's column adds nothing a login-gated,
   one-slot-at-a-time booking flow needs; admin gets full Month/Week/Day,
   see admin.css).
   ====================================================================== */

.hcc-book-head,
.hcc-book-body {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 4px;
}
.hcc-book-head { margin-bottom: 4px; }
.hcc-book-dow {
    text-align: center;
    font-weight: 600;
    font-size: .75rem;
    color: var(--opc-muted);
}
.hcc-book-cell {
    /* 3.19.0: was a rigid 1:1 square — trimmed ~15% off the height for a
       sleeker rectangle (width unchanged, height now 85% of width). */
    aspect-ratio: 1 / 0.85;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    cursor: pointer;
    font-weight: 500;
    background: var(--opc-surface);
    transition: background .12s ease, border-color .12s ease;
}
.hcc-book-cell[data-date]:hover,
.hcc-book-cell[data-date]:focus {
    border-color: var(--opc-primary);
    background: var(--opc-primary-tint);
    outline: none;
}
.hcc-book-empty { border: none; background: transparent; cursor: default; }
.hcc-book-past {
    color: var(--opc-text-placeholder);
    background: var(--opc-surface-sunken);
    cursor: not-allowed;
}
.hcc-book-today { border-color: var(--opc-primary); }
/* --opc-accent, not --opc-primary — the selected date is the calendar's
   "this is what you've chosen" moment; --opc-book-today (above), the Month/
   Week toggle and nav arrows stay --opc-primary. Same two-colour read as
   .hcc-slot.active above and .hcc-day-card.hcc-day-selected below. */
.hcc-book-selected {
    background: var(--opc-accent) !important;
    color: var(--opc-accent-contrast);
    border-color: var(--opc-accent);
}

.hcc-slot-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
    gap: .4rem;
    margin-bottom: .2rem;
}
/* --opc-accent, not --opc-primary — the calendar's own secondary/"this is
   what you've chosen" moment (Month/Week toggle, Today marker, nav arrows
   and every unselected slot stay --opc-primary; only the SELECTED slot
   switches to accent, same two-colour read as .hcc-day-card.hcc-day-
   selected/.hcc-book-selected below). Used to be a hardcoded, non-
   admin-configurable magenta (--opc-slot-selected) chosen purely for
   contrast against both themes — accent already gives the same "distinct
   from primary" guarantee (see contrastTextFor() in BrandingHelper.php)
   while staying on-brand and customer-configurable.
   !important: .btn-outline-primary's own base rule (above) is !important'd
   in both themes (3.4x — beats big_goose/template.css's compiled
   `:active` collision), which otherwise always wins regardless of this
   rule's specificity and silently keeps a selected slot looking identical
   to every unselected one. Confirmed live: without !important here,
   tapping a slot changed nothing visually. */
.hcc-slot.active {
    background: var(--opc-accent) !important;
    border-color: var(--opc-accent) !important;
    color: var(--opc-accent-contrast) !important;
}

.hcc-price-box {
    margin-bottom: .35rem;
    padding-bottom: .25rem;
    border-bottom: 1px solid var(--opc-border-light);
}
.hcc-price-final {
    font-size: 1.5rem;
    font-weight: 700;
}
.hcc-price-was {
    text-decoration: line-through;
    color: var(--opc-text-faint);
    margin-left: .25rem;
}

/* #hcc-timegrid (Week view host) needs no layout rule of its own beyond a
   touch of breathing room above it — timegrid.css owns everything inside. */
#hcc-timegrid {
    margin-top: .25rem;
}

@media (max-width: 575.98px) {
    .hcc-book-cell { font-size: .85rem; }
    .hcc-price-final { font-size: 1.25rem; }
}

/* ======================================================================
   Mobile "Book a Service" refactor — day-strip date picker, 48px touch
   targets, edge-to-edge layout. Utility classes scoped under
   #hcc-booking so the generic-sounding names can't leak onto any other
   page; breakpoint matches this file's existing 767.98px convention
   (.opc-page-header etc.) — the fields-row width-reveal above is a
   separate 1200px concern (desktop layout polish, not the phone line).
   ====================================================================== */
/* Each mobile-only element is hidden by default, then un-hidden per its own
   display type inside the media query below — a blanket `display:initial`
   would resolve to `inline` for these (7-col grid / block bar / icon
   button), collapsing the day strip's layout, so each gets its real value
   with a selector specific enough (id + 2 classes) to beat this default
   rule. `!important` on both sides is required because some of these
   elements (the month-modal card-header) also carry Bootstrap's `.d-flex`,
   itself `!important` — without matching `!important` here, `.d-flex`
   silently wins on every viewport regardless of selector specificity (see
   the identical `.opc-rewards-ledger li[hidden]` note above). */
#hcc-booking .hcc-mobile-only { display: none !important; }
@media (max-width: 767.98px) {
    #hcc-booking .hcc-desktop-only { display: none !important; }
    #hcc-booking #hcc-day-strip-header.hcc-mobile-only { display: flex !important; }
    #hcc-booking #hcc-day-strip-area.hcc-mobile-only { display: block !important; }
    #hcc-booking #hcc-slots-inline.hcc-mobile-only { display: block !important; }
    #hcc-booking #hcc-month-modal .card-header.hcc-mobile-only { display: flex !important; }

    /* Reversed per direct request (was edge-to-edge — background:
       transparent, box-shadow: none, border-radius: 0, padding: 0 0 .5rem
       — reasoned at the time that a bordered box nested inside the page's
       own edge read as a redundant frame at this width). Now keeps the
       card chrome on mobile too: rounded corners restored, and real
       left/right padding so the rounded edges have room to actually read
       as rounded rather than butting straight into the page edge. */
    #hcc-booking .hcc-book-controls {
        border-radius: var(--opc-radius-lg);
        padding: 1rem .9rem .85rem;
    }

    /* Selects span full width — both the real <select> (no-JS fallback) and
       customselect.js's .opc-select-trigger replacement, which clones the
       select's own classes and needs the same rule. */
    #hcc-booking .hcc-field select,
    #hcc-booking .hcc-field .opc-select-trigger {
        width: 100%;
    }

    /* 48x48 minimum touch target floor (WCAG 2.5.5) for every control this
       refactor introduces — day cards, the date-jump icon, and the now
       full-width selects all sit below this file's normal 36px
       --opc-btn-height. `3rem`, not `48px` — a raw px floor stays fixed
       under the OS/browser "larger text" accessibility setting (which scales
       the root font-size, not physical pixels), so the card stopped growing
       with its own now-larger day-number text; rem keeps the whole card
       scaling together with it. */
    #hcc-booking .hcc-day-card,
    #hcc-booking #hcc-date-jump,
    #hcc-booking .hcc-field select,
    #hcc-booking .hcc-field .opc-select-trigger,
    #hcc-booking #hcc-slots-inline .hcc-add {
        min-height: 3rem;
    }
    #hcc-booking #hcc-date-jump {
        min-width: 3rem;
    }
}

/* Mobile compact header — visible date range + jump icon, replacing the
   desktop nav row's prev/next/month-label (those stay for the Month/Week
   grid only, see .hcc-desktop-only above). Booking.js's pageDayStrip()
   keeps #hcc-day-strip-range's text in sync with whichever 7 days are
   actually showing. */
.hcc-day-strip-header {
    align-items: center;
    justify-content: space-between;
    margin-bottom: .5rem;
}
.hcc-day-strip-header strong {
    font-size: .95rem;
}

/* Drag viewport — clips the strip during a swipe so dragged-past content
   doesn't spill outside the card's rounded corners. touch-action:pan-y
   leaves vertical page scroll to the browser and reserves horizontal drag
   for the strip itself. */
.hcc-day-strip-viewport {
    overflow: hidden;
    touch-action: pan-y;
    cursor: grab;
    border-radius: var(--opc-radius);
}
.hcc-day-strip-viewport.hcc-day-strip-dragging {
    cursor: grabbing;
}

/* Track — THREE day-strip pages side by side (prev/current/next), 300%
   wide, resting at -33.3333% so the current page fills the viewport.
   Dragging translates THIS, not an individual page — that's what makes
   adjacent days slide in from off-screen instead of the visible 7 cards
   just sliding within their own box and leaving blank space behind them
   (the bug a single-strip version of this had). booking.js keeps the
   prev/next pages rendered for whatever they'd need to show BEFORE any
   drag starts, so there's always real content behind the drag, not a
   blank page revealed partway through one. */
.hcc-day-strip-track {
    display: flex;
    width: 300%;
    transform: translateX(-33.3333%);
    transition: transform .22s ease;
}
.hcc-day-strip-track > .hcc-day-strip {
    flex: none;
    width: 33.3333%;
}

/* Day strip — the mobile date picker, standing in for the Month grid
   (Month/Week + Today are hidden via .hcc-desktop-only; the grid itself
   only reappears inside #hcc-month-modal when #hcc-date-jump is tapped).
   Clicking a card reuses state.selectedDate / renderSlots() exactly like a
   Month-grid cell click (see booking.js's renderDayStrip()/selectDate()) —
   same data, rendered into #hcc-slots-inline instead of a popup. */
/* Grid, not a scrollable flex row — always exactly 7 cards per page (see
   renderDayStrip()), so there is never anything to scroll TO; a fixed-width
   flex row with overflow-x just left dead space on wider phones and forced
   the same 3.25rem card width on narrow ones. Seven equal columns fill
   whatever width the page actually has. */
.hcc-day-strip {
    display: grid;
    /* minmax(0, 1fr), NOT plain 1fr — a bare `1fr` track's minimum is its
       content's natural (min-content) size, not 0, so once the viewport
       got narrow enough (browser zoom, mainly) the 7 tracks couldn't
       shrink past that any further and started overflowing/clipping
       instead of continuing to scale down. minmax(0, 1fr) lets a track
       shrink all the way, so the row always fits the real width it has. */
    grid-template-columns: repeat(7, minmax(0, 1fr));
    gap: .4rem;
    padding: .2rem .1rem .35rem;
    margin-bottom: .1rem;
}
.hcc-day-card {
    /* Height tracks the column's own width (same idea as .hcc-book-cell's
       aspect-ratio in the month grid) instead of sitting at a flat
       min-height regardless of it — without this, a wider phone gave wider
       columns but the exact same card height, so cards looked squashed
       instead of actually scaling with the screen. min-height (above, in
       the touch-target rule) is the accessibility FLOOR; aspect-ratio is
       what makes it grow past that floor on a roomier screen. */
    aspect-ratio: 1 / 1.05;
    min-width: 0;   /* grid items default to min-width:auto (content-sized) —
                       same overflow trap as the track above, at the item level. */
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: .15rem;
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    background: var(--opc-surface);
    color: var(--opc-text);
    cursor: pointer;
    overflow: hidden;
}
.hcc-day-card .hcc-day-dow {
    font-size: .62rem;
    font-weight: 600;
    color: var(--opc-muted);
    text-transform: uppercase;
    letter-spacing: .03em;
}
.hcc-day-card .hcc-day-num {
    font-size: 1.05rem;
    font-weight: 700;
}
.hcc-day-card.hcc-day-today { border-color: var(--opc-primary); }
/* --opc-accent, not --opc-primary — same "this is what you've chosen"
   reasoning as .hcc-book-selected/.hcc-slot.active above; .hcc-day-today
   stays --opc-primary. */
.hcc-day-card.hcc-day-selected {
    background: var(--opc-accent);
    border-color: var(--opc-accent);
    color: var(--opc-accent-contrast);
}
.hcc-day-card.hcc-day-selected .hcc-day-dow {
    color: color-mix(in srgb, var(--opc-accent-contrast) 80%, transparent);
}
.hcc-day-card.hcc-day-past {
    color: var(--opc-text-placeholder);
    background: var(--opc-surface-sunken);
    cursor: not-allowed;
}

/* Date-jump icon — opens #hcc-month-modal (the real month grid, see below)
   via simplemodal.js's data-opc-open-modal convention. */
#hcc-date-jump {
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* #hcc-month-modal — the month grid's ONLY container (single #hcc-cal, no
   duplicate DOM for booking.js to keep in sync). On mobile it's a real
   modal via the base .opc-modal-backdrop rules (fixed, dimmed, centered
   card) — nothing extra needed here. On desktop it's forced permanently
   visible and stripped of every modal affordance so #hcc-calendar-area
   renders exactly where the old plain inline container used to, pixel for
   pixel: the [hidden] override matters because #hcc-date-jump (the only
   thing that would ever open/close it) is itself mobile-only, so this
   modal would otherwise sit permanently hidden on desktop. */
@media (min-width: 768px) {
    #hcc-booking #hcc-month-modal,
    #hcc-booking #hcc-month-modal[hidden] {
        display: block !important;
        position: static;
        inset: auto;
        background: none;
        padding: 0;
        z-index: auto;
    }
    #hcc-booking #hcc-month-modal .opc-modal-dialog {
        max-width: none;
        width: 100%;
        box-shadow: none;
        background: transparent;
        border: none;
    }
    #hcc-booking #hcc-month-modal .card-body {
        padding: 0;
    }
}
/* [data-bs-theme="dark"] .card sets background-color with !important (the
   Bootstrap card-bg hotfix, above) — same specificity as, and loads after,
   the transparent .opc-modal-dialog rule above, and !important always wins
   regardless of order. Left alone, the dialog silently re-paints itself
   var(--opc-surface) on desktop-dark — the SAME colour as the centered
   .hcc-book-cal sitting inside it, so the two boxes have no visible seam
   and the whole area reads as one flat block instead of a card with room
   around it. Re-declared here, scoped + !important, to win that fight. */
[data-bs-theme="dark"] #hcc-booking #hcc-month-modal .opc-modal-dialog {
    background: transparent !important;
}

/* Inline slot panel — mobile's replacement for #hcc-slot-modal. Same
   priceLine()/.hcc-slot-grid markup booking.js already builds, just
   rendered directly under the day strip instead of inside a popup, and
   themed off --opc-surface/--opc-text like the rest of the page instead of
   a Bootstrap .card's own background (the thing that read as "a light
   block" against a dark page). */
#hcc-slots-inline {
    border-top: 1px solid var(--opc-border-light);
    padding-top: .7rem;
    margin-top: .2rem;
    color: var(--opc-text);
}
.hcc-slots-inline-date {
    font-weight: 700;
    font-size: .9rem;
}
#hcc-slots-inline .hcc-slot-grid {
    grid-template-columns: repeat(3, 1fr);
    gap: .5rem;
}
/* Chip-sized, not "giant primary button" — explicit min-height/padding
   instead of this file's normal --opc-btn-height (36-48px depending on
   context), which read as oversized once three fit per row. Colour is
   filled unconditionally (both themes, not just dark) — a deliberate
   departure from the site's generic outline-button convention, so a slot
   reads as a selectable chip on sight rather than a bordered button.
   var(--opc-primary)/var(--opc-primary-contrast), NOT a literal #238899 —
   this platform is multi-tenant (see CLAUDE.md) and --opc-primary is this
   one instance's branded colour; a literal hex here would paint every
   OTHER business's pills with Hound Human Connection's teal regardless of
   their own branding. !important for the same reason as .active below:
   beats .btn-outline-primary's own base-rule !important (both themes). */
#hcc-slots-inline .hcc-slot {
    border-radius: 999px;
    min-height: 40px;
    padding: .5rem .75rem;
    font-size: .85rem;
    font-weight: 600;
    background: var(--opc-primary) !important;
    border-color: var(--opc-primary) !important;
    color: var(--opc-primary-contrast) !important;
}
/* Re-states .active explicitly: #hcc-slots-inline .hcc-slot (id-scoped,
   above) otherwise outranks the bare .hcc-slot.active rule on specificity
   alone, regardless of source order, and would keep a selected pill looking
   unselected. --opc-accent, not --opc-primary — see the plain .hcc-slot.active
   rule above for why. */
#hcc-slots-inline .hcc-slot.active {
    background: var(--opc-accent) !important;
    border-color: var(--opc-accent) !important;
    color: var(--opc-accent-contrast) !important;
}
/* Disabled/unavailable — muted, low-contrast on purpose (it's not a real
   choice), matching the dark surface underneath it rather than standing out
   as another actionable-looking chip. Not exercised by drawSlots() today
   (only bookable times are ever sent to the client), kept for when a
   partially-available day starts rendering fully-booked times as inert. */
#hcc-slots-inline .hcc-slot:disabled {
    background: var(--opc-surface-sunken) !important;
    border-color: var(--opc-border) !important;
    color: var(--opc-text-placeholder) !important;
    opacity: 1;
}

/* Assessment hint — deliberately NOT .alert-info: every .alert-* class is
   display:none!important platform-wide (3.26.32 product decision), which
   left this exact message unable to render at all, in any theme, on any
   device. Its own class + token-driven colours instead. */
.hcc-hint-banner {
    display: flex;
    gap: .5rem;
    align-items: flex-start;
    border: 1px solid color-mix(in srgb, var(--opc-primary) 35%, var(--opc-border));
    background: var(--opc-primary-tint);
    color: var(--opc-text);
    border-radius: var(--opc-radius);
    padding: .6rem .75rem;
    font-size: .85rem;
    line-height: 1.4;
}

/* ======================================================================
   CSS-class-driven modal base (3.15.0) — the SAME convention already used
   by the site pet-profile page's "Set Status" modal (site/tmpl/pet/
   profile.php, pet-status.js) and the admin Pet edit screen's identical
   markup. admin.css has carried this since that modal shipped; site.css
   never got its own copy, so that modal has been rendering fully unstyled
   on the site side this whole time — a real, previously-undetected gap,
   fixed here as a side effect of building the cart drawer on the exact same
   base rather than inventing a second modal pattern. Unlike admin.css's
   fixed-light copy, this one is dark-mode aware
   via the --opc-* tokens already defined in this file's :root/dark blocks.
   ====================================================================== */
.opc-modal-backdrop {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, .5);
    display: flex;
    align-items: flex-start;
    justify-content: center;
    padding: 4rem 1rem 1rem;
    z-index: 1055;
}
.opc-modal-backdrop[hidden] { display: none; }
.opc-modal-dialog {
    width: 100%;
    max-width: 420px;
    /* Same token the card/list-card system draws from (3.26) — a modal is
       the same "raised panel" concept, just positioned fixed. */
    box-shadow: var(--opc-card-shadow);
}
body.opc-modal-open { overflow: hidden; }
/* #__opc_calendars.description is a plain textarea (admin/forms/calendar.xml)
   — stored as literal \n, no <p> markup. Set via textContent (XSS-safe), so
   plain HTML whitespace-collapsing would run two admin-typed paragraphs into
   one line; pre-line preserves \n as breaks and blank lines as paragraph
   gaps without needing innerHTML. */
#opc-home-guest-desc {
    white-space: pre-line;
}
/* Wider variants for modals whose content needs more room than the 420px
   default (a multi-column slot grid, a scrollable ledger list). */
#hcc-slot-modal .opc-modal-dialog,
#opc-rewards-modal .opc-modal-dialog,
#opc-store-modal .opc-modal-dialog,
.opc-manager-tile-modal .opc-modal-dialog {
    max-width: 560px;
}
/* Manager dashboard tile modals (3.37.x) — same "cap it, let it scroll"
   treatment as .opc-rewards-ledger, since a tile can list up to 50 rows. */
.opc-manager-tile-list {
    max-height: 50vh;
    overflow-y: auto;
}

/* Manager dashboard stat tiles (3.37.x) — small "?" corner badge is a pure
   visual affordance that a native `title` tooltip exists on the card
   (otherwise nothing signals "hover me" until a manager stumbles onto it).
   .opc-card-interactive already sets position:relative, so this positions
   against the tile itself, not the page. */
.opc-stat-tile-hint {
    position: absolute;
    top: .4rem;
    right: .5rem;
    width: 1.1rem;
    height: 1.1rem;
    line-height: 1.1rem;
    border-radius: 50%;
    background: var(--opc-surface-alt);
    color: var(--opc-text-soft);
    font-size: .7rem;
    font-weight: 700;
    text-align: center;
}

/* Manager dashboard revenue comparison bars (3.37.x) — deliberately plain
   DOM + CSS (bar width is a server-computed percentage), no chart library,
   matching this codebase's "no third-party JS calendar library" stance
   (CLAUDE.md Settled decisions) extended to charts: one more vendored
   dependency to break on the next update, for two bars. */
.opc-revenue-compare-title {
    font-weight: 600;
    margin-bottom: .5rem;
    display: flex;
    align-items: center;
    gap: .5rem;
}
.opc-revenue-compare-row {
    display: flex;
    align-items: center;
    gap: .5rem;
    margin-bottom: .4rem;
}
.opc-revenue-compare-label {
    flex: 0 0 6rem;
    color: var(--opc-text-soft);
    font-size: .8rem;
}
.opc-revenue-compare-bar-track {
    flex: 1 1 auto;
    background: var(--opc-surface-alt);
    border-radius: .25rem;
    height: 1rem;
    overflow: hidden;
}
.opc-revenue-compare-bar {
    display: block;
    height: 100%;
    border-radius: .25rem;
    transition: width .3s ease;
}
.opc-revenue-compare-value {
    flex: 0 0 auto;
    font-variant-numeric: tabular-nums;
    font-size: .85rem;
    min-width: 5.5rem;
    text-align: right;
}

/* ======================================================================
   Image crop modal (imagecrop.js) — pan/zoom-to-crop staging step shared
   by the pet photo upload and the account avatar upload. The frame IS the
   crop (see imagecrop.js's own docblock), so this is deliberately just a
   fixed square box with `overflow: hidden` — no separate drag-handle
   rectangle to draw.
   ====================================================================== */
.opc-cropmodal-dialog {
    max-width: 380px;
}
.opc-cropmodal-body {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .75rem;
}
.opc-cropmodal-frame {
    position: relative;
    width: min(100%, 300px);
    aspect-ratio: 1 / 1;
    overflow: hidden;
    border-radius: .5rem;
    background: var(--opc-surface-sunken);
    /* Own touch-action so a one-finger drag pans the image instead of the
       page trying to scroll/refresh underneath the modal. */
    touch-action: none;
    cursor: grab;
    border: 1px solid var(--opc-border);
}
.opc-cropmodal-frame:active {
    cursor: grabbing;
}
.opc-cropmodal-frame-circle {
    border-radius: 50%;
}
.opc-cropmodal-img {
    position: absolute;
    top: 0;
    left: 0;
    max-width: none;
    transform-origin: 0 0;
    user-select: none;
    -webkit-user-drag: none;
}
.opc-cropmodal-zoom {
    display: flex;
    align-items: center;
    gap: .5rem;
    width: 100%;
}
.opc-cropmodal-zoom-icon {
    font-size: .85rem;
    color: var(--opc-muted);
    flex-shrink: 0;
    line-height: 1;
}
.opc-cropmodal-zoom-icon-lg {
    font-size: 1.1rem;
}
.opc-cropmodal-zoom-range {
    flex: 1;
}
.opc-cropmodal-hint {
    text-align: center;
}

/* Shared skeleton-loader placeholder (3.25.9x) — a shaped, animated block
   standing in for content that hasn't arrived yet (an AJAX fetch: the cart
   drawer's line items, the store quick-view modal's "All Products" grid).
   Previews the real layout instead of a spinner/text line so nothing
   reflows once the fetch resolves — same "reserve the space up front"
   reasoning as the hero's own Expand Article button fix, applied to async
   content instead of a toggle. Respects prefers-reduced-motion. */
.opc-skeleton {
    display: block;
    border-radius: var(--opc-radius-sm, 6px);
    background: linear-gradient(
        100deg,
        var(--opc-surface-sunken) 40%,
        color-mix(in srgb, var(--opc-surface-sunken) 50%, var(--opc-border)) 55%,
        var(--opc-surface-sunken) 70%
    );
    background-size: 200% 100%;
    animation: opc-skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes opc-skeleton-shimmer {
    0% { background-position: 150% 0; }
    100% { background-position: -50% 0; }
}
@media (prefers-reduced-motion: reduce) {
    .opc-skeleton {
        animation: none;
        background: var(--opc-surface-sunken);
    }
}
.opc-skeleton-card {
    height: 100px;
    border-radius: var(--opc-radius);
    margin-bottom: .5rem;
}
.opc-skeleton-line {
    height: .85rem;
    width: 70%;
    margin: .65rem .8rem 0;
}
.opc-skeleton-line + .opc-skeleton-line {
    width: 45%;
    margin-top: .4rem;
    margin-bottom: .8rem;
}
.opc-skeleton-row {
    height: 3.25rem;
    margin-bottom: .6rem;
}
.opc-skeleton-text {
    height: .75rem;
    margin-bottom: .35rem;
}
.opc-skeleton-text:last-child {
    margin-bottom: 0;
}
.opc-skeleton-slot {
    height: 2.4rem;
}
/* Keeps the previous view's content in place (dimmed) while a re-fetch is
   in flight, instead of clearing it to a short placeholder first — the
   clear-then-refill pattern collapses the grid's height for a moment and
   snaps back once data lands, which is its own layout-shift source on
   month/week navigation, not just first paint. */
.opc-grid-loading {
    opacity: .45;
    pointer-events: none;
}

/* Home Hub store quick-view modal (home.js) — one product's detail, or up to
   6 in a grid, inside the same dialog rather than a second modal. */
#opc-store-modal .card-body {
    max-height: 70vh;
    overflow-y: auto;
}
.opc-store-modal-media {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 200px;
    border-radius: var(--opc-radius);
    background: var(--opc-surface-sunken);
    color: var(--opc-muted);
    font-size: 2rem;
    overflow: hidden;
    margin-bottom: 1rem;
}
.opc-store-modal-media img {
    /* `contain`, not `cover` — a fixed 200px box with `cover` crops off
       whatever doesn't fit the box's aspect ratio (reported: the Dog
       Jacket photo showing cut off). `contain` always shows the whole
       image, letterboxed on the sunken background if the aspect differs. */
    width: 100%;
    height: 100%;
    object-fit: contain;
}
.opc-store-modal-price {
    font-size: 1.4rem;
    font-weight: 700;
    color: var(--opc-text);
    margin-bottom: .25rem;
}
.opc-store-modal-browse-row {
    margin-top: 1rem;
    padding-top: 1rem;
    border-top: 1px solid var(--opc-border);
    text-align: center;
}
.opc-store-modal-browse-row button {
    font: inherit;
    font-size: .85rem;
    font-weight: 600;
    color: var(--opc-primary);
    background: none;
    border: none;
    padding: 0;
    cursor: pointer;
}
.opc-store-modal-back {
    display: inline-flex;
    align-items: center;
    font: inherit;
    font-size: .85rem;
    font-weight: 600;
    color: var(--opc-primary);
    background: none;
    border: none;
    padding: 0;
    margin-bottom: 1rem;
    cursor: pointer;
}

/* In-app PDF viewer (3.23.60 — see pdfviewer.js's own docblock: pdf.js
   renders the document onto plain <canvas> elements, so nothing here
   depends on the host WebView's own PDF support). Near-fullscreen, not the
   420px modal default — a document needs real room, not a small dialog. */
.opc-pdf-modal-backdrop {
    padding: 1rem;
    align-items: stretch;
}
.opc-pdf-modal-dialog {
    max-width: 900px;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    margin: 0 auto;
}
.opc-pdf-modal-body {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    background: var(--opc-surface-sunken);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .5rem;
    padding: .5rem;
}
.opc-pdf-modal-status {
    color: var(--opc-text-soft);
    padding: 2rem 1rem;
    text-align: center;
}
.opc-pdf-modal-pages {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .5rem;
    width: 100%;
}
.opc-pdf-modal-page {
    max-width: 100%;
    height: auto;
    box-shadow: var(--opc-shadow-resting);
    background: #fff;
}

/* Order detail modal (ordermodal.js) — an invoice card opens the order
   in-place instead of navigating to view=order. Sized like the PDF viewer
   (near-fullscreen, scrollable body) since the fetched content is a whole
   page's worth of order detail, not a small confirmation. */
.opc-order-modal-backdrop {
    padding: 1rem;
    align-items: stretch;
}
.opc-order-modal-dialog {
    max-width: 700px;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    margin: 0 auto;
}
.opc-order-modal-dialog .card-header {
    flex: 0 0 auto;
    padding: .5rem .75rem;
}
/* Bootstrap's .btn-close draws its X as a background-image SVG with a
   HARDCODED black fill (big_goose/template.css: fill='hsl(0,0%,0%)') —
   only opacity is a live variable, the icon's actual colour never is, so
   there's no way to brand it via `color` or a CSS custom property the
   normal way. mask-image instead: the same X path, but a mask only reads
   the SVG's alpha channel — the visible colour comes from this element's
   own background-color, which CAN be var(--opc-accent) (a real per-
   instance branding token, never a literal hex baked into a per-tenant
   SVG data URI — see CLAUDE.md's "no customer-specific value in code").
   Plain `.btn-close`, not just `[data-opc-order-modal-close]` — this used
   to be scoped to that one JS-generated order-detail modal only, so every
   OTHER modal's close button (site-wide — booking edit, availability,
   pet, store quick-view, guest/assessment gates, etc.) still showed
   Bootstrap's stock black X. Requested live: "all modal close X button
   should be accent color", not just this one. */
.btn-close {
    background-image: none;
    -webkit-mask-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");
    mask-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-size: 1em auto;
    mask-size: 1em auto;
    background-color: var(--opc-accent);
    opacity: 1;
}
.btn-close:hover,
.btn-close:focus-visible {
    background-color: color-mix(in srgb, var(--opc-accent) 85%, black);
    opacity: 1;
}
.opc-order-modal-body {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
}
.opc-order-modal-status {
    color: var(--opc-text-soft);
    padding: 2rem 1rem;
    text-align: center;
}

/* Post-add-to-cart utility bar (3.17.0) — replaces the old redirect-to-cart
   behaviour: adding a slot now surfaces this instead of navigating away, so
   browsing/booking more stays uninterrupted. */
.opc-cart-toast {
    position: fixed;
    left: 50%;
    bottom: 1.25rem;
    transform: translateX(-50%);
    z-index: 1060;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .75rem;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    box-shadow: var(--opc-shadow-lifted);
    padding: .75rem 1rem;
    max-width: calc(100vw - 2rem);
}
.opc-cart-toast[hidden] { display: none; }
.opc-cart-toast-actions {
    display: flex;
    flex-wrap: wrap;
    gap: .4rem;
}
@media (max-width: 575.98px) {
    .opc-cart-toast {
        left: 1rem;
        right: 1rem;
        bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px));
        transform: none;
        max-width: none;
        flex-direction: column;
        align-items: stretch;
    }
}

/* ======================================================================
   Site-wide cart widget (3.25.18) — header-style trigger (desktop) + a
   floating action button (mobile, hidden until the cart has items — see
   cartdrawer.js's own docblock for why this is a FAB rather than a 6th
   bottom-bar slot) sharing one preview drawer, plus the AJAX add-to-cart
   toast. Injected site-wide by plg_system_opctheme's
   OpcTheme::buildCartWidgetHtml(), same reach as the mobile chrome block
   below. Fixed-position rather than literally nested inside big_goose's own
   <header> markup — this file has no reliable hook into the forked
   template's header height/layout, and a fixed top-right control reads the
   same way visually without coupling to it. NOT visually verified against a
   live page (no browser in this dev sandbox) — check on deploy.
   ====================================================================== */
/* Solid brand-primary circle, same visual language as .opc-cart-fab below —
   a live check flagged the original pale surface-coloured version as
   low-contrast against the gradient header and reading as "hidden in the
   corner" rather than a deliberate nav control. top:.9rem here is only the
   fallback for before cartdrawer.js's alignTriggerToHeader() runs (or if it
   can't find .header at all) — that JS re-centres it against the template
   header's own real rendered height, which isn't a fixed value (custom logo
   size, optional site-description row). right is pulled in from the bare
   viewport edge for the same "not glued into the corner" reason. */
.opc-cart-trigger {
    display: none;
    position: fixed;
    top: .9rem;
    right: 1.5rem;
    z-index: 1045;
    align-items: center;
    justify-content: center;
    width: 2.75rem;
    height: 2.75rem;
    padding: 0;
    border: none;
    border-radius: var(--opc-shape);
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
    box-shadow: var(--opc-shadow-lifted);
    cursor: pointer;
    /* top/right above are only the pre-JS fallback — cartdrawer.js's
       alignTriggerToHeader() overwrites both against the real rendered
       header/main geometry. Left visible, that swap reads as the button
       flying in from the bare viewport edge on every load. Stay invisible
       until JS confirms it has placed the real position (.is-positioned,
       added by alignTriggerToHeader()), then fade in already correct. */
    opacity: 0;
    transition: opacity .15s ease-out;
}
.opc-cart-trigger.is-positioned {
    opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
    .opc-cart-trigger {
        transition: none;
    }
}
.opc-cart-trigger:hover,
.opc-cart-trigger:focus-visible {
    background: color-mix(in srgb, var(--opc-primary) 85%, black);
}
.opc-cart-trigger svg {
    width: 1.3rem;
    height: 1.3rem;
}
@media (min-width: 1200px) {
    .opc-cart-trigger {
        display: inline-flex;
    }
}

.opc-cart-fab {
    display: none;
    position: fixed;
    right: 1rem;
    bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
    z-index: 1045;
    align-items: center;
    justify-content: center;
    width: 3.25rem;
    height: 3.25rem;
    padding: 0;
    border: none;
    border-radius: var(--opc-shape);
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
    box-shadow: var(--opc-shadow-lifted);
    cursor: pointer;
    animation: opc-cart-fab-in .2s ease-out;
}
.opc-cart-fab svg {
    width: 1.4rem;
    height: 1.4rem;
}
.opc-cart-fab:not([hidden]) {
    display: inline-flex;
}
@media (min-width: 1200px) {
    .opc-cart-fab {
        display: none !important;
    }
}
/* Clears the fixed bottom tab bar (see the mobile-chrome block below) and/or
   the fixed .footer (~L7965) when either is actually present — mirrors
   body:has(.opc-mobile-chrome)'s own padding rule so the FAB never needs to
   know the other features' exact heights, just whether they rendered at all.
   Without this the FAB (fixed, z-index 1045) sat at a flat bottom:1rem and
   visually overlaid the footer band, since both are position:fixed in the
   same corner regardless of scroll position. */
body:has(.footer) .opc-cart-fab {
    bottom: calc(1rem + var(--opc-footer-height) + env(safe-area-inset-bottom, 0px));
}
body:has(.opc-mobile-chrome) .opc-cart-fab {
    bottom: calc(5rem + env(safe-area-inset-bottom, 0px));
}
body:has(.opc-mobile-chrome):has(.footer) .opc-cart-fab {
    bottom: calc(5rem + var(--opc-footer-height) + env(safe-area-inset-bottom, 0px));
}
@keyframes opc-cart-fab-in {
    from { transform: scale(.5); opacity: 0; }
    to   { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
    .opc-cart-fab {
        animation: none;
    }
}

.opc-cart-trigger .opc-cart-widget-badge,
.opc-cart-fab .opc-cart-widget-badge {
    position: absolute;
    top: -.25rem;
    right: -.25rem;
    min-width: 1.2rem;
    height: 1.2rem;
    padding: 0 .3rem;
    font-size: .68rem;
    display: flex;
    align-items: center;
    justify-content: center;
}

/* The preview drawer both the trigger and the FAB open — built on the same
   .opc-modal-backdrop/.opc-modal-dialog base the "Set Status" modal uses; a
   phone-width override below turns it into a bottom sheet.
   Restates the dimming overlay explicitly, scoped to this ID, rather than
   trusting the shared .opc-modal-backdrop rule alone — a live screenshot
   showed this specific drawer opening fully see-through with no dimming at
   all, unlike the site's other .opc-modal-backdrop dialogs (root cause not
   reproducible here, no browser in this dev sandbox — this is a targeted,
   deliberate belt-and-braces fix, not a guess dressed up as one). */
#opc-cart-drawer-backdrop.opc-modal-backdrop {
    background: rgba(0, 0, 0, .55);
}
.opc-cart-drawer {
    max-width: 480px;
}
.opc-cart-drawer-body {
    max-height: 50vh;
    overflow-y: auto;
}
.opc-cart-drawer-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: .65rem;
}
.opc-cart-drawer-line {
    display: flex;
    align-items: center;
    gap: .65rem;
}
.opc-cart-drawer-line-unavailable {
    opacity: .55;
}
.opc-cart-drawer-thumb {
    flex: 0 0 auto;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: var(--opc-radius-sm);
    background: var(--opc-surface-sunken);
    object-fit: cover;
}
.opc-cart-drawer-thumb-icon {
    display: block;
}
.opc-cart-drawer-line-main {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
}
.opc-cart-drawer-line-name {
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.opc-cart-drawer-line-sub {
    font-size: .8rem;
    color: var(--opc-text-soft);
}
.opc-cart-drawer-line-price {
    flex: 0 0 auto;
    font-weight: 700;
    white-space: nowrap;
}
/* Loading-state placeholder rows (cartdrawer.js's own load()) — same
   flex shape as a real .opc-cart-drawer-line so the drawer doesn't resize
   once real lines replace them. A dedicated thumb size rather than reusing
   .opc-cart-drawer-thumb: that class sets its own solid background, which
   (declared later in this file) would win over .opc-skeleton's shimmer
   gradient if both classes were combined on one element. */
.opc-cart-drawer-skeleton-thumb {
    flex: 0 0 auto;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: var(--opc-radius-sm);
}
.opc-cart-drawer-skeleton-main {
    flex: 1 1 auto;
    min-width: 0;
}
.opc-cart-drawer-remove {
    flex: 0 0 auto;
    width: 1.6rem;
    height: 1.6rem;
    padding: 0;
    border: none;
    border-radius: var(--opc-shape);
    background: transparent;
    color: var(--opc-text-soft);
    font-size: 1.15rem;
    line-height: 1;
    cursor: pointer;
}
.opc-cart-drawer-remove:hover,
.opc-cart-drawer-remove:focus-visible {
    background: var(--opc-surface-sunken);
    color: var(--opc-danger);
}
@media (max-width: 575.98px) {
    .opc-cart-drawer-backdrop {
        align-items: flex-end;
        padding: 0;
    }
    .opc-cart-drawer {
        max-width: none;
        width: 100%;
        border-radius: var(--opc-radius-lg) var(--opc-radius-lg) 0 0;
        max-height: 85vh;
        display: flex;
        flex-direction: column;
    }
    .opc-cart-drawer-body {
        max-height: none;
    }
}

/* AJAX add-to-cart confirmation toast — reuses .opc-cart-toast's positioning
   (see the booking calendar's own copy, just above) so both read as the same
   platform-wide affordance; only the thumbnail bit is new here. */
.opc-cart-widget-toast,
.opc-cart-toast {
    position: fixed;
    left: 50%;
    bottom: 1.25rem;
    transform: translateX(-50%);
    z-index: 1060;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .75rem;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    box-shadow: var(--opc-shadow-lifted);
    padding: .75rem 1rem;
    max-width: calc(100vw - 2rem);
}
.opc-cart-widget-toast[hidden] {
    display: none;
}
.opc-cart-widget-toast-thumb {
    flex: 0 0 auto;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: var(--opc-radius-sm);
    background: var(--opc-surface-sunken);
    overflow: hidden;
}
.opc-cart-widget-toast-thumb[hidden] {
    display: none;
}
.opc-cart-widget-toast-thumb img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}
.opc-cart-widget-toast-text {
    font-weight: 600;
}
@media (max-width: 575.98px) {
    .opc-cart-widget-toast {
        left: 1rem;
        right: 1rem;
        bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px));
        transform: none;
        max-width: none;
    }
}

/* ======================================================================
   Mobile app chrome — a native-app-style, strict 5-tab bottom bar (My
   Rewards / Our Store / Book a Service / Activity / My Profile), no drawer,
   replacing Cassiopeia's own slide-out `offcanvas` mobile menu. Injected
   site-wide by plg_system_opctheme's injectMobileChrome() (every front-end
   page, not just this component's own), driven by media/com_onepetconnect/
   js/mobilenav.js.

   BREAKPOINT: max-width 1199.98px (Bootstrap's `xl` cutoff) — deliberately
   wider than the usual phone-only breakpoint so iPad Pro landscape (1194px
   logical width) stays in "app chrome" mode rather than reverting to
   Cassiopeia's desktop nav; ≥1200px is completely untouched. The block below
   defaults to `display: none` and is only switched on inside the media
   query, so nothing here has any effect at desktop widths at all.
   ====================================================================== */
/* Nominal bar height, overwritten with the bar's REAL measured offsetHeight
   by mobilenav.js (ResizeObserver on #opc-mobilebar) as soon as it paints —
   this is only the pre-JS/no-JS fallback. 3.34.9x: replaced three separate
   hardcoded `4rem` constants (here, the force-mobile twin below, and both
   .footer `bottom` rules) that assumed a fixed bar height. The bar has no
   explicit height of its own — it sizes to its tallest item's content,
   which varies with item count (e.g. the raised center "Book" item adds
   height that a 4-item bar without one doesn't) — so a literal `4rem`
   silently drifted out of sync with reality and left the footer either
   overlapping the bar or floating with a gap above it. The measured value
   already includes the bar's own `env(safe-area-inset-bottom)` padding, so
   nothing here adds that separately. */
:root {
    --opc-mobilebar-height: 4rem;
}
.opc-mobilebar {
    display: none;
}

@media (max-width: 1199.98px) {
    /* Room for the fixed bottom bar so it never covers the page's own
       footer/last content. Scoped to :has(.opc-mobile-chrome) (3.19.19) so
       the "Mobile Site" Feature Toggle, off, doesn't leave dead padding
       behind once the chrome it was reserving room for stops rendering at
       all. 3.34.49: also reserves --opc-footer-height, now that the fixed
       .footer stacks above this bar (see the .footer rules ~L6780) instead
       of sitting in normal flow. */
    body:has(.opc-mobile-chrome) {
        padding-bottom: calc(var(--opc-mobilebar-height) + var(--opc-footer-height));
    }

    /* REMOVED (per explicit request): this block used to force-hide
       Cassiopeia's stock "Main Menu"/"Login Form" modules (and the
       hamburger/offcanvas/sidebar wrappers around them) whenever our own
       bottom bar was present. The Business wants to manage those modules
       themselves the normal Joomla way (Module Manager: unpublish, or
       unassign from a position) — our chrome is purely additive now, never
       suppressing anything on its own. Nothing else in this codebase reads
       or depends on .header-toggler/#offcanvas-mobile-menu/#sidebar/
       .sidebar/#form-login/ul.menu being hidden or present (confirmed: only
       cartdrawer.js touches `.header` itself, unrelated), so removing this
       is safe — if a Business does unpublish those modules, everything
       else keeps working exactly as before. */

    .opc-mobilebar {
        display: flex;
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 1050;
        background: var(--opc-surface);
        border-top: 1px solid var(--opc-border);
        box-shadow: var(--opc-shadow-lifted);
        padding: .25rem .25rem calc(.25rem + env(safe-area-inset-bottom));
    }
    .opc-mobilebar-item {
        flex: 1 1 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: .15rem;
        padding: .35rem .25rem;
        color: var(--opc-muted);
        text-decoration: none;
        font-size: .68rem;
        font-weight: 600;
        text-align: center;
        border-radius: var(--opc-radius-sm);
        transition: color .12s ease;
    }
    .opc-mobilebar-icon {
        display: flex;
        align-items: center;
        justify-content: center;
        position: relative;
    }
    .opc-mobilebar-icon svg {
        width: 1.35rem;
        height: 1.35rem;
    }
    /* Cart item-count badge — same live count as the Booking/Store header
       Cart buttons already show (cartpulse.js), just overlaid on the icon
       here instead of appended after it. */
    .opc-mobilebar-badge {
        position: absolute;
        top: -.3rem;
        right: -.5rem;
        min-width: 1.05rem;
        height: 1.05rem;
        padding: 0 .3rem;
        font-size: .6rem;
        line-height: 1.05rem;
    }
    .opc-mobilebar-item:hover,
    .opc-mobilebar-item:focus-visible,
    .opc-mobilebar-item.is-active {
        color: var(--opc-primary);
        outline: none;
    }

    /* Centre item ("Book a Service") — raised accent circle, native-app
       "primary action" styling. Only the icon box is pulled up (negative
       margin), never the whole flex item, so its label stays aligned with
       every other item's label instead of overlapping a neighbour. */
    .opc-mobilebar-center .opc-mobilebar-icon {
        width: 3rem;
        height: 3rem;
        margin-top: -1.4rem;
        /* var(--opc-shape), not a literal 50% — on this square (3rem x 3rem) box
           999px (Round, the default) renders pixel-identical to 50%, so this is a
           zero-change default; Square (0) gives the raised Book icon the same
           rectangular treatment every other button gets under that setting. */
        border-radius: var(--opc-shape);
        background: var(--opc-primary);
        color: var(--opc-primary-contrast);
        box-shadow: var(--opc-shadow-resting);
        transition: background-color .12s ease;
    }
    .opc-mobilebar-center:hover .opc-mobilebar-icon,
    .opc-mobilebar-center:focus-visible .opc-mobilebar-icon,
    .opc-mobilebar-center.is-active .opc-mobilebar-icon {
        background: color-mix(in srgb, var(--opc-primary) 85%, black);
    }
    .opc-mobilebar-center.is-active {
        color: var(--opc-muted); /* the raised circle carries the "active" colour instead */
    }
}

/* ======================================================================
   "Desktop Site" feature toggle off (FeatureHelper::desktopSiteEnabled()
   === false) — plg_system_opctheme sets `data-force-mobile` on <html> (see
   OpcTheme::applyTheme()) instead of the earlier, confirmed-broken forced
   narrow <meta name="viewport"> tag (Cassiopeia's own template always
   overwrote it — see that method's own comment for the full render-order
   finding). This block mirrors the visibility-triggering declarations from
   the @media block directly above it, selector-for-selector, so a forced
   desktop-width "mobile" render is identical to a real narrow viewport.
   Deliberately NOT a full duplicate of that block — the cosmetic/position/
   colour rules above already apply unconditionally once `display` flips to
   flex, so only the declarations that actually flip visibility need a
   second copy here. Keep this block's selector list in sync with the
   @media block's if either one changes.
   ====================================================================== */
html[data-force-mobile] body:has(.opc-mobile-chrome) {
    padding-bottom: calc(var(--opc-mobilebar-height) + var(--opc-footer-height));
}

html[data-force-mobile] .opc-mobilebar {
    display: flex !important;
}

/* Persistent header (3.25.9x) — the brand mark (also the Home link) must
   always stay reachable, on every page, without depending on the admin
   opting into Cassiopeia's own "Sticky Header" template style option
   (off by default; a Business that never finds/flips that setting would
   otherwise lose the header on scroll, per explicit report). Forced here
   unconditionally rather than relying on that param.
   `header.header` (element+class, specificity 0-1-1) rather than plain
   `.header` (0-1-0) — deliberately beats Cassiopeia's own `.container-header`
   rule of equal specificity regardless of stylesheet load order, same
   defensive-specificity reasoning as the dark-mode Bootstrap context-class
   overrides elsewhere in this file. `.container-header` already carries an
   opaque gradient background (big_goose's own template.css), so sticking it
   never reveals see-through content scrolling underneath. */
header.header {
    position: sticky;
    top: 0;
    z-index: 1040;
}

/* height:0 only from 1200px up: at that breakpoint the relocate script
   (OpcTheme::buildDesktopChromeHtml()) physically moves the real
   .navbar-brand node OUT of .header into .opc-desktop-chrome, so .header has
   no visible content left and collapsing it is correct there. Below 1200px
   the brand row stays inside .header and must be counted in normal flow —
   collapsing it there used to make the logo float with zero reserved
   space, so the hero article and everything else painted underneath/behind
   it instead of below it. */
@media (min-width: 1200px) {
    header.header {
        height: 0px;
    }
}

/* Mobile brand row (< 1200px only — see the height:0 note above for what
   happens at 1200px+). Business name left, logo right, spanning the full
   row so the two sit at opposite edges instead of Cassiopeia's own
   .container-header .navbar-brand (template.css) inline-block/left-aligned
   default. Because .header is no longer height:0 on mobile (see above),
   this now takes real, reserved space — page content flows below it
   instead of underneath it. */
@media (max-width: 1199.98px) {
    /* header.header (0-1-1), not .header (0-1-0) — must outrank
       .container-header .navbar-brand (0-2-0, template.css) regardless of
       stylesheet load order; same defensive-specificity reasoning as
       header.header's own rule above. Targets .brand-logo, not .navbar-brand
       itself — the business-name span lives INSIDE the same <a> as the logo
       (index.php) so tapping the name also navigates home, not just the
       image, which means the name/logo space-between row is this anchor's
       own content, not a split across .navbar-brand's direct children. */
    header.header .navbar-brand {
        display: block;
        width: 100%;
    }
    header.header .brand-logo {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: .75rem;
        width: 100%;
        /* The business-name span is now inline text inside this anchor
           (moved here so tapping the name also navigates home) — without
           this it would pick up the browser's default link underline,
           template.css's .container-header .navbar-brand a rule sets colour
           only, never text-decoration. */
        text-decoration: none;
    }
    .header .brand-name-text {
        display: block;
        color: var(--opc-text);
        font-weight: 600;
        font-size: 1.1rem;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    /* template.css's .container-header .site-description rule (0-2-0) hardcodes
       #fff, left over from the brand-gradient background this header no longer
       has (see header.header's own background override above) — dead-white
       text with no theme awareness. header.header (0-2-1) to reliably outrank
       it regardless of load order, same as .navbar-brand above. Optional param
       (Template Style "Site Description"), off by default. */
    header.header .site-description {
        color: var(--opc-text-soft);
    }
    /* .brand-name-fallback (index.php): the actual brand mark when no logo
       image is uploaded — this instance's case. Same #fff-inherited-from-
       .container-header .navbar-brand bug as .site-description above, just
       via inheritance rather than a direct rule (the fallback span has no
       color of its own). Not scoped like .brand-name-text (which only shows
       alongside an uploaded logo) because this IS the whole brand mark when
       it renders — no separate image to pair it with. */
    header.header .brand-name-fallback,
    header.header .brand-logo-paw {
        color: var(--opc-text);
    }
    /* Header background grade (mobile only — desktop's header has no visible
       fill once the brand mark relocates into .opc-desktop-chrome, see the
       height:0 rule above). Left = the header's current flat colour
       (--opc-surface), right = the page's own background (--bs-body-bg), so
       the bar visually dissolves into the page instead of ending in a hard
       edge. */
    header.header {
        background-image: linear-gradient(to right, var(--opc-surface) 0%, var(--bs-body-bg) 100%);
    }
    /* Dark mode: swap the right-hand stop to --opc-surface-sunken instead of
       --bs-body-bg. Both resolve to the same literal by default (site.css's
       dark block sets --opc-surface-sunken: var(--bs-body-bg, ...)), but they
       diverge the moment a Business sets Options → Branding → "Dark mode
       background" (brand_dark_bg): BrandingHelper::buildCss() points that
       param at --opc-surface-sunken (and --opc-surface itself, via "Dark mode
       surface colour"/brand_dark_surface) but writes the body's own dark
       background as a literal `!important`, never updating --bs-body-bg — so
       a light-mode --bs-body-bg gradient would silently ignore a custom dark
       background while everything else on the page correctly re-themed. */
    [data-bs-theme="dark"] header.header {
        background-image: linear-gradient(to right, var(--opc-surface) 0%, var(--opc-surface-sunken) 100%);
    }
}

/* Business name label — hidden by default (desktop chrome shows only the
   logo image, never this text); the mobile media query above is the only
   place that turns it on. */
.brand-name-text {
    display: none;
}

/* Uploaded brand logo has no guaranteed aspect ratio or resolution — it's a
   free-form admin upload (BrandingHelper::logoUrl(), same field used for
   invoices/emails), not a pre-cropped template asset, so Cassiopeia's own
   .navbar-brand never constrains it. Cap it to a sane header height instead
   of trusting the source file. */
.header .brand-logo-img {
    display: block;
    height: 32px;
    max-height: 32px;
    width: auto;
    max-width: 100%;
    object-fit: contain;
}
/* Light/dark logo pair (BrandingHelper::logoUrl()/logoUrlDark()) — big_goose's
   index.php only emits BOTH <img> tags when a dark-specific logo is actually
   set and differs from the light one; when it does, this is what picks the
   right one for the live data-bs-theme attribute. Not scoped under
   [data-bs-theme="dark"] .header for the light rule — data-bs-theme is
   always set explicitly to "light" or "dark" (never absent, see
   plg_system_opctheme), so hiding -dark by default and only showing it once
   dark is confirmed is the safer default if that ever changes. */
/* Also matched under .opc-desktop-chrome, not just .header — at the
   >=1200px breakpoint buildDesktopChromeHtml()'s relocate script moves the
   real .navbar-brand node into the tab strip (see that method's docblock),
   so a .header-only selector would stop matching there and both images
   would fall back to visible (confirmed live in an earlier version of this
   feature that used a separate overlay: two stacked logos). Two ancestors,
   not a class swap on the images themselves, because it's the same DOM
   node either way — only which container holds it changes. */
.header .brand-logo-img-dark,
.opc-desktop-chrome .brand-logo-img-dark {
    display: none;
}
[data-bs-theme="dark"] .header .brand-logo-img-light,
[data-bs-theme="dark"] .opc-desktop-chrome .brand-logo-img-light {
    display: none;
}
[data-bs-theme="dark"] .header .brand-logo-img-dark,
[data-bs-theme="dark"] .opc-desktop-chrome .brand-logo-img-dark {
    display: block;
}

/* ======================================================================
   Unified Navigation — desktop tab strip (3.25.58). Same 5 destinations as
   .opc-mobilebar above, rendered by OpcTheme::buildDesktopTabsHtml() and
   spliced in right before </body> exactly like the mobile bar (see
   injectMobileChrome()). The two are mutually exclusive purely by breakpoint
   — this block defaults to `display: none` and only switches on at
   `min-width: 1200px`, the exact inverse of .opc-mobilebar's own
   `max-width: 1199.98px` block, so there is never a width where both (or
   neither) are visible.

   REAL BUG fixed here (3.25.63): unlike .opc-mobilebar, which is
   `position: fixed` and so never cares where the DOM injection point put
   it, this block had NO position rule at all — since it's spliced in right
   before </body>, it rendered exactly there: the very bottom of the page,
   after the footer. Confirmed live via screenshot (no tab strip anywhere
   near the header). OpcTheme::buildDesktopChromeHtml() now runs an inline
   script that relocates the node to right after the real `.header` element
   before first paint, so `position: sticky` here works correctly as a
   normal-flow element instead of needing a measured `top:` offset.

   Stock Joomla nav (Main Menu/Admin menu/Login Form, wherever a Business
   has them positioned) is deliberately left untouched — per explicit
   request, this chrome is purely additive, never suppressing anything on
   its own. A Business that wants those modules gone manages that
   themselves the normal Joomla way (Module Manager).
   ====================================================================== */
.opc-desktop-chrome {
    display: none;
}

@media (min-width: 1200px) {
    /* Layout, fifth iteration — architectural fix, not another gap tweak.
       Every previous version (grid, flex-cluster at various gaps,
       edge-pin) docked the cart INSIDE this bar, as a sibling of
       .opc-desktop-tabs. That's the actual root cause of "it was moving
       the tabs" — hiding/showing a flex sibling (see the cart's new
       hidden-when-empty behaviour, cartdrawer.js) changes what
       `justify-content: center` centres, so the tabs visibly shift left/
       right every time the cart appears or disappears.
       Fixed by moving the cart OUT of this bar entirely: it now docks
       into .header (the top brand bar, absolutely positioned top-right —
       see the `.header .opc-cart-trigger` rule below cartdrawer.js docks
       into with dockTrigger()). Two completely separate rows: this one
       only ever contains tabs, so nothing here can ever move when the
       cart's visibility changes. */
    .opc-desktop-chrome {
        display: flex;
        justify-content: center;
        align-items: center;
        position: sticky;
        top: 4.5rem; /* pre-JS fallback guess; overwritten immediately */
        z-index: 1030;
        background: var(--opc-surface);
        border-bottom: 1px solid var(--opc-border);
        box-shadow: var(--opc-shadow-lifted);
        padding: 0 1rem;
        /* Explicit here (not left to derive from .opc-desktop-tab-item's own
           min-height) so the bar's real height is a known, exact number —
           .opc-desktop-chrome .brand-logo-img sizes itself directly off
           this value (74px bar - 2px top - 2px bottom = 70px logo). */
        min-height: 74px;
        /* REAL BUG fixed here (reported as "icons and names jumping
           around" / "some content loads before other"): this bar is
           spliced into the HTML right before </body>, then buildDesktopChromeHtml()'s
           inline script physically moves it up next to .header — that DOM
           move was happening visibly after the browser started rendering.
           Left visible throughout, that reads as the whole tab strip
           jumping into place on every load. Same fix as .opc-cart-trigger:
           stay invisible until the relocate script confirms the move is
           done (.is-relocated, added at the end of that script), then
           reveal already in its final position — never a jump. */
        opacity: 0;
        transition: opacity .15s ease-out;
    }
    .opc-desktop-chrome.is-relocated {
        opacity: 1;
    }
    @media (prefers-reduced-motion: reduce) {
        .opc-desktop-chrome {
            transition: none;
        }
    }

    .opc-desktop-tabs {
        display: flex;
        align-items: stretch;
        gap: .25rem;
    }

    /* Brand mark, relocated here from .header by buildDesktopChromeHtml()'s
       script at this same breakpoint (see that method's docblock). Absolute,
       not a flex sibling of .opc-desktop-tabs — .opc-desktop-chrome is
       already `position: sticky` above, which is enough to anchor this;
       staying out of flow means it can never disturb .opc-desktop-tabs' own
       `justify-content: center` on the parent (an in-flow logo would shift
       what "center" means the moment it appeared/disappeared). Left-aligned
       to the same content-column edge the cart trigger uses on the other
       side of the header, so it doesn't just glue to the bare viewport edge
       on wide screens. */
    .opc-desktop-chrome .navbar-brand {
        position: absolute;
        top: 50%;
        left: max(1.5rem, calc((100vw - 900px) / 2));
        transform: translateY(-50%);
        display: flex;
        align-items: center;
    }
    .opc-desktop-chrome .brand-logo-img {
        height: 70px;
        max-height: 70px;
        width: auto;
        max-width: 100%;
        object-fit: contain;
    }

    /* The docked cart trigger — lives in .header now (see this block's own
       docblock above), absolutely positioned so it takes zero space in
       that row's normal flow: showing/hiding it can never push or resize
       anything else in the header, satisfying "nothing must shift" for
       real rather than just by observation. Overrides the base
       .opc-cart-trigger fixed/top/right rule (descendant selector wins
       regardless of source order); that base rule remains the fallback
       for when .header isn't found at all (cartdrawer.js's
       alignTriggerToHeader() fallback path). */
    .header .opc-cart-trigger {
        position: absolute;
        /* NOT 50% — .header is height:0 at this breakpoint (see the
           header.header height:0 rule above), so top:50% centres against
           nothing and translateY(-50%) pushes half the icon above the
           viewport ("joined the header and went half MIA", live report).
           .header sits flush above .opc-desktop-chrome (the actual visible
           74px bar, see its min-height above), so centre against THAT bar's
           height instead. */
        top: calc(74px / 2);
        /* Aligns to the right edge of the centered 900px content container
           (see .container-component, ~L1350) that the page cards — including
           the "Expand Article" button — sit inside, so the trigger stays
           horizontally above that button at any viewport width instead of
           an arbitrary fixed/vw offset. */
        right: max(1.5rem, calc((100vw - 900px) / 2));
        left: auto;
        transform: translateY(-50%);
    }

    .opc-desktop-tab-item {
        display: flex;
        align-items: center;
        gap: .5rem;
        padding: 1.35rem 1.1rem;
        min-height: 74px;
        color: var(--opc-muted);
        text-decoration: none;
        font-size: .9rem;
        font-weight: 600;
        border-bottom: 2px solid transparent;
        transition: color .12s ease, border-color .12s ease;
    }

    .opc-desktop-tab-icon {
        display: flex;
        align-items: center;
        justify-content: center;
    }

    .opc-desktop-tab-icon svg {
        width: 1.15rem;
        height: 1.15rem;
    }

    .opc-desktop-tab-item:hover,
    .opc-desktop-tab-item:focus-visible {
        color: var(--opc-text);
        outline: none;
    }

    .opc-desktop-tab-item.is-active {
        color: var(--opc-primary);
        border-bottom-color: var(--opc-primary);
    }
}

/* "Desktop Site" feature toggle off equivalent for forced-mobile: a
   `data-force-mobile` render must never show the desktop strip alongside
   the forced .opc-mobilebar just above, even at a genuinely wide viewport. */
html[data-force-mobile] .opc-desktop-chrome {
    display: none !important;
}

/* ======================================================================
   Pet profile page (3.17.0) — centered layout, photo container, details
   list, Pet Documents card grid, compact "Points earned" card.
   ====================================================================== */
.opc-profile-wrap {
    max-width: 900px;
}

/* "Liquid" 2-track grid (3.25.2) — each card is pinned to a column (1 or 2)
   and an explicit `order` via its data-opc-area, NOT DOM position, so the
   two tracks behave like independent masonry columns instead of paired
   rows: Photo/Bookings/Documents/Status stack down column 1, Details/
   Purchases/Points stack down column 2. align-items: start is required for
   that — without it Grid stretches every row to its tallest cell, which
   only makes sense when rows are meant to pair up (they no longer are).
   A card absent from the DOM (feature toggle off, e.g. Purchases/Points/
   Status) just leaves the rest of its column to ride up — plain Grid
   auto-placement, no JS needed for that part. `data-opc-collapsed` is set
   by profilegrid.js only for the (currently unreachable, since Photo/
   Details/Bookings/Documents are unconditional) case where an entire
   column ends up with nothing in it — CSS alone can't detect "zero
   children with grid-column: 2" across siblings without a fragile :has()
   selector, so that specific check is left to the script. */
.opc-profile-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 1rem;
    align-items: start;
}
.opc-profile-grid[data-opc-collapsed] {
    grid-template-columns: 1fr;
}
/* Every card below gets a UNIQUE order value (no cross-column ties) — see
   3.40.0's own fix note: two same-order cards in different columns (Report
   Cards/Purchases were both order:4) relies on the grid's shared row-cursor
   landing them in the same row, which only happens when BOTH render. The
   moment one side is toggle-gated absent (Purchases needs
   FeatureHelper::petParentEnabled()) or simply missing, the other column's
   cell for that row goes empty — a visible gap. Unique, strictly ascending
   orders per column sidestep this: each column's own top-to-bottom sequence
   only depends on its OWN values being ascending, never on matching the
   other column's numbering. */
.opc-profile-grid [data-opc-area="photo"]       { grid-column: 1; order: 1; }
.opc-profile-grid [data-opc-area="details"]     { grid-column: 2; order: 2; }
.opc-profile-grid [data-opc-area="bookings"]    { grid-column: 1; order: 3; }
.opc-profile-grid [data-opc-area="assessment"]  { grid-column: 2; order: 4; }
.opc-profile-grid [data-opc-area="purchases"]   { grid-column: 1; order: 5; }
.opc-profile-grid [data-opc-area="reportcards"] { grid-column: 2; order: 6; }
.opc-profile-grid [data-opc-area="documents"]   { grid-column: 1; order: 7; }
.opc-profile-grid [data-opc-area="points"]      { grid-column: 2; order: 8; }
.opc-profile-grid [data-opc-area="status"]      { grid-column: 1; order: 9; }

@media (max-width: 767px) {
    /* Numeric order 1-7 (not DOM order) drives mobile stacking too — a
       single grid-template-columns: 1fr collapses both tracks into one,
       `order` alone decides the sequence. */
    .opc-profile-grid {
        grid-template-columns: 1fr;
    }
    .opc-profile-grid [data-opc-area] {
        grid-column: 1;
    }
}

/* Take Photo / Choose from Library sit flush against the bottom of the
   photo card. Previously relied on Grid stretching the row to match a
   taller sibling (align-items: stretch); now that align-items: start
   means the photo card is always exactly its own content height, this
   still anchors the upload control to the bottom of the fixed-height
   .hcc-pet-photo image area above it rather than the top. */
.opc-profile-grid [data-opc-area="photo"] .card-body {
    display: flex;
    align-items: flex-end;
}
.opc-profile-grid [data-opc-area="photo"] .card-body form {
    width: 100%;
}

.hcc-pet-photo {
    display: flex;
    align-items: center;
    justify-content: center;
    /* Square, not 4/3 — this box now sits in a half-width grid column at
       every screen size (see .opc-profile-grid above), so a wide-ish ratio
       read as too short/cropped-feeling once the column itself got
       narrower; 1/1 gives it real height without depending on the column
       being full page width the way the old single-column layout was. */
    aspect-ratio: 1 / 1;
    /* Hard cap, not just aspect-ratio — a broken/oversized child (e.g. a
       failed photo request rendering raw response content instead of a
       small broken-image glyph, see profile.php's onerror handler) must
       never be able to blow this box past a sane height regardless of its
       own intrinsic size. Still scales dynamically with the column's own
       width via aspect-ratio below this cap — only the upper bound moved. */
    max-height: 400px;
    overflow: hidden;
    background: var(--opc-surface-sunken);
    /* Must match the parent .card's own border-radius (--opc-radius-lg,
       3.26) — same corner-mismatch bug as .hcc-card-thumb, just a separate
       class that got missed in that first pass. */
    border-top-left-radius: var(--opc-radius-lg);
    border-top-right-radius: var(--opc-radius-lg);
}
.hcc-pet-photo img {
    display: block;
    width: 100%;
    height: 100%;
    /* Fit the image into the container rather than stretch/crop it — the
       box stays a fixed shape (needed for a consistent card), the photo
       inside it stays true to its own aspect ratio, centered. */
    object-fit: contain;
}
.hcc-pet-photo-placeholder {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 3rem;
    color: var(--opc-text-faint);
}

/* Pet details — column-first sub-grid (left column Species/Age/Gender,
   right column Breed/Weight/Spayed, per the wireframe's own grouping) plus
   free-text notes as their own full-width tiles below it. Each field is
   always rendered (missing values show an em dash — see profile.php's
   $genderLabel etc.) so the column split never shifts around. */
.opc-details-subgrid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-auto-flow: column;
    grid-template-rows: repeat(3, auto);
    gap: .5rem;
}
.opc-detail-tile {
    background: var(--opc-surface-sunken);
    border: 1px solid var(--opc-border-light);
    border-radius: var(--opc-radius);
    padding: .5rem .65rem;
    text-align: center;
    transition: border-color .15s ease;
}
.opc-detail-tile:hover {
    border-color: var(--opc-border);
}
.opc-detail-tile.opc-detail-wide {
    text-align: left;
}
.opc-tel-link {
    color: var(--opc-primary, inherit);
    text-decoration: none;
    display: inline-block;
    min-height: 1.75rem;
    line-height: 1.75rem;
}
.opc-tel-link:hover,
.opc-tel-link:focus {
    text-decoration: underline;
}

/* Customer page Details/Emergency&Vet tiles — .opc-detail-tile above reads
   as a bordered, sunken box that looks editable (reported live: it read
   like a disabled form input everywhere except Discount, the one field
   that actually IS editable). .opc-detail-field is the plain layout shell
   (label + value, no box of its own) and the value itself is either a flat
   .opc-value-pill (read-only text) or a .opc-value-btn (phone/address —
   an action, not a value, so it gets the site's real button treatment).
   Deliberately NOT touching .opc-detail-tile itself: the pet profile page
   (site/tmpl/pet/profile.php) still uses it for its subgrid + free-text
   notes tiles. */
.opc-detail-field {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .3rem;
    text-align: center;
    min-width: 0;
}
.opc-detail-field.opc-detail-wide {
    align-items: flex-start;
    text-align: left;
}
.opc-field-label {
    font-size: .72rem;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--opc-text-faint);
}
.opc-value-pill {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: .35rem;
    min-height: 1.9rem;
    padding: .2rem .75rem;
    max-width: 100%;
    border-radius: var(--opc-shape);
    font-weight: 500;
    background: color-mix(in srgb, var(--opc-text-faint) 16%, transparent);
    color: var(--opc-text);
}
.opc-value-pill.is-empty {
    color: var(--opc-text-faint);
    font-weight: 400;
}
/* Same visual language as .btn-outline-primary (branded fill, --opc-shape
   pill) written out directly rather than reusing the Bootstrap classes —
   this sits inside a narrow flex tile, not a toolbar, so it needs its own
   compact sizing rather than Bootstrap's .btn-sm padding/line-height. */
.opc-value-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: .4rem;
    min-height: 2rem;
    padding: .25rem .9rem;
    max-width: 100%;
    border: 1px solid var(--opc-primary);
    border-radius: var(--opc-shape);
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
    font-weight: 600;
    font-size: .85rem;
    text-decoration: none;
    cursor: pointer;
    transition: background-color .15s ease, border-color .15s ease, transform .12s ease;
}
.opc-value-btn:hover,
.opc-value-btn:focus-visible {
    background: color-mix(in srgb, var(--opc-primary) 85%, black);
    border-color: color-mix(in srgb, var(--opc-primary) 85%, black);
    color: var(--opc-primary-contrast);
    transform: translateY(-1px);
}
.opc-value-btn svg {
    width: .85rem;
    height: .85rem;
    flex: none;
}
.opc-value-btn .txt {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
    .opc-value-btn:hover,
    .opc-value-btn:focus-visible {
        transform: none;
    }
}

/* Quiet icon-button affordance for an editable tile (currently just the
   Customer page's Discount tile) — rem-sized, normal flex flow, no
   position:absolute (breaks mobile, see CLAUDE.md). At rest it's barely
   visible against the tile; hover/focus lights it up to --opc-primary so it
   reads as "this field is editable" rather than a stray toolbar button. */
.opc-tile-edit-btn {
    appearance: none;
    border: none;
    background: transparent;
    color: var(--opc-text-faint);
    width: 1.6rem;
    height: 1.6rem;
    border-radius: var(--opc-shape);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    cursor: pointer;
    transition: background .15s ease, color .15s ease;
}
.opc-tile-edit-btn svg {
    width: .85rem;
    height: .85rem;
}
.opc-tile-edit-btn:hover,
.opc-tile-edit-btn:focus-visible {
    background: color-mix(in srgb, var(--opc-primary) 16%, transparent);
    color: var(--opc-primary);
    outline: none;
}

/* Pet profile "Pet Assessment" card (data-opc-area="assessment") — one
   .opc-assessment-round block per kind (initial/annual), each a compact
   preview of a few answered questions + "view full" link, not the full
   editable form. */
.opc-assessment-round {
    padding: .65rem .8rem;
    border-radius: var(--opc-radius);
    background: var(--opc-surface-sunken);
}
.opc-assessment-round + .opc-assessment-round {
    margin-top: .65rem;
}
.opc-assessment-round-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: .5rem;
    margin-bottom: .4rem;
}
.opc-assessment-round-title {
    font-weight: 700;
    font-size: .88rem;
}
.opc-assessment-round-meta {
    font-size: .78rem;
    color: var(--opc-text-faint);
    margin-top: .1rem;
}
.opc-qa-row {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    padding: .3rem 0;
    border-top: 1px solid var(--opc-border);
    font-size: .84rem;
}
.opc-qa-row:first-of-type {
    border-top: none;
}
.opc-qa-q {
    color: var(--opc-text-soft);
}
.opc-qa-a {
    font-weight: 600;
    text-align: right;
}

/* Bookings & history — scrolls internally past ~4 rows instead of growing
   the card (and the whole page) taller with every booking a pet racks up.
   This element is BOTH .opc-list-cards (the flex-column list container)
   AND .opc-bookings-scroll (see profile.php) — switched from flex to plain
   block flow here specifically, with margin-based spacing replacing flex
   `gap`, so a single short row can never be stretched to fill the column's
   full height the way a flex column's cross-axis sizing could. `height:
   auto !important` on the row itself is a second, redundant safety net for
   the same "never taller than its own content" guarantee. */
.opc-bookings-scroll.opc-list-cards {
    display: block;
    max-height: 280px;
    overflow-y: auto;
    padding-right: .25rem;
}
/* Real bug this "never taller than its own content" guarantee didn't
   actually cover: .opc-list-card's own base layout (row, flex-wrap: wrap,
   align-items: center) is fine at full list width, but in this card's
   narrow half-column context (.opc-profile-grid) the title/sub block and
   the price/status block don't fit side by side — they wrap onto two
   lines, and a WRAPPED flex container's default align-content (normal,
   which resolves to stretch when there's spare cross-axis room) spreads
   those two wrapped lines apart to fill whatever height the row ends up
   with, instead of packing them tight — that's the empty middle gap, not
   a min-height/flex:1 rule (there wasn't one). Fixed at the source: this
   row switches to a real vertical stack in this narrow context, so there's
   only ever one line's worth of content per line and nothing left to
   stretch apart. Scoped to .opc-bookings-scroll specifically, not the
   shared .opc-list-cards-compact class Cart also uses — Cart's rows stay
   full-width and genuinely want the side-by-side layout. */
.opc-bookings-scroll .opc-list-card {
    height: auto !important;
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    flex-wrap: nowrap;
    gap: .35rem;
    padding: .65rem .75rem;
    margin-bottom: .5rem;
}
.opc-bookings-scroll .opc-list-card:last-child {
    margin-bottom: 0;
}
/* The bug that survived the previous pass: .opc-list-card-main's base rule
   is `flex: 1 1 220px` — a flex-basis meant for the ROW layout, where 220px
   is a MINIMUM WIDTH so the title block doesn't get crushed next to the
   price. Switching this card to flex-direction: column (above) rotates the
   main axis, so that same "220px" is now read as a starting HEIGHT, and
   flex-grow: 1 then lets it stretch further to fill the column — exactly
   the tall-card-with-a-dead-middle bug, just relocated from the row to
   .opc-list-card-main itself. It needs no basis/grow at all here; it should
   only ever be as tall as its own two lines of text. */
.opc-bookings-scroll .opc-list-card-main {
    flex: 0 1 auto;
    height: auto;
    min-height: 0;
}
.opc-bookings-scroll .opc-list-card-when {
    flex: 0 0 auto;
    margin-left: 0;
}

/* Compact scrollable preview of the same ledger the modal shows in full. */
.opc-points-mini-history {
    max-height: 130px;
    overflow-y: auto;
}
.opc-points-mini-history .list-group-item {
    padding: .3rem 0;
    font-size: .82rem;
}

/* Pet Documents — card grid (max 4 visible + "View all"), mime-based icon,
   fully clickable (image cards open the lightbox; PDFs open inline in a new
   tab; anything else downloads). */
.opc-doc-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
    gap: .75rem;
}
.opc-doc-card {
    text-align: center;
}
.opc-doc-thumb {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    aspect-ratio: 1 / 1;
    background: var(--opc-surface-sunken);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius);
    overflow: hidden;
    color: var(--opc-text-soft);
    text-decoration: none;
    transition: transform .15s ease, box-shadow .15s ease;
}
.opc-doc-thumb:hover,
.opc-doc-thumb:focus-visible {
    transform: translateY(-2px);
    box-shadow: var(--opc-shadow-resting);
}
.opc-doc-thumb img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}
.opc-doc-icon {
    font-size: 2rem;
}
.opc-doc-kind {
    position: absolute;
    margin-top: 2.5rem;
    font-size: .65rem;
    font-weight: 700;
    letter-spacing: .04em;
}
.opc-doc-name {
    font-size: .75rem;
    margin-top: .35rem;
    color: var(--opc-text-soft);
}
.opc-doc-delete {
    font-size: .75rem;
}

.opc-points-earned-card {
    cursor: pointer;
    font: inherit;
}

/* ==========================================================================
   MOBILE COMPACT MODE — direct response to "it looks super clunky, mobile
   devices are not as big as PCs, we need a compact feel ... not smaller,
   just more compact ... remove dead space". Every page's actual content
   sits inside one of this component's own wrapper divs
   (`<div class="com-onepetconnect-{view}">` — one per site view, always
   present, confirmed by grepping every template), which is what every rule
   below is scoped to via the `[class^="com-onepetconnect-"]` prefix
   selector — deliberately NOT a blind, page-wide override of Bootstrap's
   utility classes (mb-3/mb-4/gap-2/gap-3 etc. are reused by Cassiopeia and
   any other module/component sharing the same page; overriding them
   unscoped would tighten content this platform doesn't own). Phone-width
   only (max-width: 767.98px, Bootstrap's own `md` cutoff) — the 1199.98px
   breakpoint above is reserved for "is the app chrome active", a different
   question from "is the screen small enough that padding reads as waste".
   Sizes drop, spacing tightens — nothing here removes a feature or hides
   content, only the dead space around it. ========================== */
@media (max-width: 767.98px) {
    /* Page title row — every view opens with <h1 class="h3 mb-0"> plus a
       header action row; both were full desktop size regardless of screen
       width. */
    [class^="com-onepetconnect-"] h1.h3 {
        font-size: 1.25rem;
    }
    [class^="com-onepetconnect-"] h2.h5,
    [class^="com-onepetconnect-"] h2.h6 {
        font-size: 1rem;
    }

    /* Bootstrap's mb-3/mb-4 vertical rhythm (1rem/1.5rem) is the single
       biggest source of "dead space" stacking down a mobile page — every
       header row, card, and section break uses one or the other. Tightened
       only inside this platform's own markup, never touching Cassiopeia
       chrome or any other module sharing the page. */
    [class^="com-onepetconnect-"] .mb-4 {
        margin-bottom: 1rem !important;
    }
    [class^="com-onepetconnect-"] .mb-3 {
        margin-bottom: .65rem !important;
    }
    [class^="com-onepetconnect-"] .gap-2 {
        gap: .4rem !important;
    }
    [class^="com-onepetconnect-"] .gap-3 {
        gap: .6rem !important;
    }

    /* Card padding — Bootstrap's default --bs-card-spacer-y/-x (1rem each)
       reads as generous on a phone-width card. Overriding the variable
       (rather than card-body's padding directly) keeps card-header/
       card-footer, which read the same variable, tightened consistently
       too, with nothing to keep in sync by hand. */
    [class^="com-onepetconnect-"] .card {
        --bs-card-spacer-y: .65rem;
        --bs-card-spacer-x: .75rem;
    }

    /* The dashboard's "Total Points" hero strip — the single largest fixed
       block of padding on the site, sized for a desktop hero card. */
    .opc-stat-strip {
        padding: .85rem 1rem;
    }
    .opc-stat-total-value {
        font-size: 1.75rem;
    }

    /* Pet/product card grids and the Bookings/Orders row-card lists — same
       "smaller gap, tighter body" treatment as the card padding above. */
    .opc-pets-grid {
        gap: .65rem;
    }
    .opc-list-cards {
        gap: .5rem;
    }

    /* Modal dialogs (Set Status, My Rewards ledger, the cart drawer) —
       full desktop padding on a phone-width sheet was most of the reason
       they read as oversized rather than native-app-compact. */
    .opc-modal-dialog .card-header,
    .opc-modal-dialog .card-body,
    .opc-modal-dialog .card-footer {
        padding: .75rem .9rem;
    }

    /* Pet profile — phone-width tightening on top of the structural grid
       defined outside this media query (.opc-profile-grid, .hcc-pet-photo,
       .opc-details-subgrid/.opc-detail-tile, .opc-bookings-scroll — all
       apply at every width; only spacing/sizing tweaks live here). */
    .opc-profile-grid {
        gap: .6rem;
    }
    .hcc-pet-photo {
        /* Still square (see the base rule) and still scales dynamically
           with the column's own width — only the phone-width upper bound
           moved, slightly taller than before. */
        max-height: 300px;
    }
    .hcc-pet-photo-placeholder {
        font-size: 1.75rem;
    }
    .opc-details-subgrid {
        gap: .35rem;
    }
    .opc-detail-tile {
        padding: .3rem .4rem;
        line-height: 1.25;
    }
    .opc-bookings-scroll.opc-list-cards {
        max-height: 240px;
    }

    /* Pet Documents grid — smaller minimum tile so more fit per row on a
       narrow screen, same reasoning as the pets/product card grids above. */
    .opc-doc-grid {
        grid-template-columns: repeat(auto-fill, minmax(82px, 1fr));
        gap: .5rem;
    }

    /* Card padding/margins on this page specifically go tighter than the
       site-wide compact defaults above — a live device screenshot showed
       even those still reading as "one big box per field group". */
    .opc-profile-wrap .card {
        --bs-card-spacer-y: .5rem;
        --bs-card-spacer-x: .6rem;
    }
    .opc-profile-wrap .card-header {
        font-size: .9rem;
    }
    .opc-profile-wrap h1.h3 {
        font-size: 1.1rem;
    }
}

/* Pet edit form — Spayed/Neutered as a real settings row (title + live
   status on the left, a switch on the right), not a bare control floating
   in open space next to a text label — that read thin against the
   full-width, boxed look of every other field on this form (a live
   screenshot showed the gap plainly). Two earlier cuts at this also hand-
   rolled the switch itself (track/thumb via custom CSS); replaced here with
   Bootstrap's own `.form-switch` — a real, tested Bootstrap 5.3 primitive
   this whole site already runs on, toggled by the input's own native
   `:checked` state rather than a hand-written sibling/`:has()` rule reliant
   on guessing what Joomla's core checkbox-field layout renders (no local
   copy of that layout exists in this repo, and there's no browser here to
   check against it either — see this file's standing caveat on verifying
   Joomla-core-internals assumptions). The row itself is one big `<label
   for="...">` — click/tap anywhere in the row toggles it, not just the
   small switch control, the same larger-hit-area convention already used
   for this platform's card-stretched-link pattern. */
.opc-switch-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    width: 100%;
    min-height: calc(1.5em + 1.05rem + 2px); /* match .form-control's rendered height */
    padding: .6rem .9rem;
    background: var(--opc-surface-sunken);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius, .5rem);
    cursor: pointer;
    transition: border-color .15s ease;
}
.opc-switch-row:hover {
    border-color: var(--opc-primary);
}
.opc-switch-row-text {
    display: flex;
    flex-direction: column;
    line-height: 1.3;
}
.opc-switch-row-title {
    font-weight: 500;
    color: var(--bs-body-color);
}
.opc-switch-row-desc {
    font-size: .85rem;
    color: var(--opc-text-soft);
}
.opc-switch-row-desc-on {
    display: none;
}
.opc-switch-row:has(.form-check-input:checked) .opc-switch-row-desc-off {
    display: none;
}
.opc-switch-row:has(.form-check-input:checked) .opc-switch-row-desc-on {
    display: inline;
}
.opc-switch-row-control {
    flex: 0 0 auto;
    margin: 0;
    padding: 0;
}
.opc-switch-row-control .form-check-input {
    width: 2.75rem;
    height: 1.5rem;
    margin: 0;
    cursor: pointer;
}
.opc-switch-row-control .form-check-input:checked {
    background-color: var(--opc-primary) !important;
    border-color: var(--opc-primary) !important;
}
.opc-switch-row-control .form-check-input:focus {
    box-shadow: 0 0 0 .2rem color-mix(in srgb, var(--opc-primary) 25%, transparent) !important;
}

/* ======================================================================
   Manager Portal — Availability toolbar (3.4x). site/tmpl/availability/
   default.php only (the admin twin, admin/tmpl/availability/default.php,
   keeps its own plain Bootstrap .btn-group/.form-switch — this is the
   customer-facing brand-matched treatment, deliberately not shared).
   ====================================================================== */

/* State-holding toggle button (3.4x, replaces an earlier <input
   type=checkbox role=switch> pass) — same "true/false button, not a form
   control" language as the Profile page's Appearance cycle button
   (.cycle-btn, further up this file), just binary instead of 3-state and
   text-labelled instead of icon-only, so a pill rather than a circle.
   aria-pressed IS the state; no hidden checkbox behind it — neither
   filter round-trips to the server (availability.js's own comment: "no
   round-trip"), so there's nothing a real form control needed to hold.
   Off = solid --opc-primary fill, on = solid --opc-accent fill — 3.4x:
   was outlined/transparent at rest, reported live as not matching the
   solid-filled brand-primary buttons elsewhere in this toolbar (Today,
   .btn-outline-primary's own base rule — both filled at rest, not just
   on hover/press). Same primary-off/accent-on branding convention the
   earlier switch version used, carried over rather than reinvented —
   just solid on both sides now, not solid-on/outline-off. */
.opc-filter-toggle {
    display: inline-flex;
    align-items: center;
    border-radius: 999px;
    border: 1px solid var(--opc-primary);
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
    padding: .35rem .9rem;
    font-size: .85rem;
    font-weight: 600;
    cursor: pointer;
    transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.opc-filter-toggle:hover {
    background: color-mix(in srgb, var(--opc-primary) 85%, black);
    border-color: color-mix(in srgb, var(--opc-primary) 85%, black);
}
.opc-filter-toggle[aria-pressed="true"] {
    background: var(--opc-accent);
    border-color: var(--opc-accent);
    color: var(--opc-primary-contrast);
}
.opc-filter-toggle[aria-pressed="true"]:hover {
    background: color-mix(in srgb, var(--opc-accent) 85%, black);
    border-color: color-mix(in srgb, var(--opc-accent) 85%, black);
}
.opc-filter-toggle:focus-visible {
    outline: 2px solid var(--opc-primary);
    outline-offset: 2px;
}

/* Sits beside a .opc-tab-switch's own .opc-tab-label pills (e.g. the Customer
   page's Pets tab: Active/Memorialized sub-tabs + the family toggle in the
   same row) — matches .opc-tab-label's box exactly (min-height/padding/
   font-size) so the two pill families read as one row at a glance, rather
   than reusing .opc-tab-label itself, which is wired to mobilenav.js's
   sliding-thumb tab-selection behaviour and is not a real on/off control. */
.opc-filter-toggle--tab-size {
    min-height: 2.75rem;
    padding: .4rem 1.1rem;
    font-size: .85rem;
}

/* Legend — was three always-visible full-width lines, reported as taking
   too much space above the grid. One compact toggle line; availability.js
   flips `hidden` + aria-expanded on click. No transition/animation —
   deliberately instant, this is a density control, not a decorative
   reveal. */
.opc-legend-toggle {
    display: inline-flex;
    align-items: center;
    gap: .35rem;
    background: none;
    border: none;
    padding: 0;
    margin-bottom: .75rem;
    font-size: .85rem;
    font-weight: 600;
    color: var(--opc-text-soft);
    cursor: pointer;
}
.opc-legend-toggle:hover,
.opc-legend-toggle:focus-visible {
    color: var(--opc-primary);
}
.opc-legend-toggle-caret {
    font-size: .65rem;
    transition: transform .15s ease;
}
.opc-legend-toggle[aria-expanded="true"] .opc-legend-toggle-caret {
    transform: rotate(180deg);
}
.opc-legend-detail {
    margin-bottom: .75rem;
}

/* View toggle — deliberately carries NO width/flex-basis rule anywhere,
   at any screen size: plain inline-flex (its own base rule, further up),
   sized to exactly its three buttons — Month's start to Day's end — and
   nothing else. The mobile line-break below is forced by the NAV group
   instead (.hcc-avail-toolbar-nav), never by the toggle itself, precisely
   so the toggle's width can never change with viewport width. */

/* 3.4x: back to centered/grouped (was briefly justify-content:space-between,
   splitting the two switches to opposite ends of their container —
   reverted). */
.hcc-avail-toolbar-switches {
    justify-content: center;
}

/* 3.4x: chevron/Today shape match — reported live (dark mode screenshot):
   the pill-shaped view toggle and Today sat next to prev/next chevrons
   still on Bootstrap's stock squarish radius, reading as two different
   button languages side by side rather than one toolbar. Both themes —
   this is a shape fix, not a colour one; the dark-mode colour treatment
   for .btn-outline-secondary (--bs-tertiary-bg etc, further up this file)
   is untouched. Icon-only chevrons go fully circular (they're square
   already via padding, so 999px reads as a circle, not an oval); Today
   goes full pill to match the view toggle it sits beside. */
.hcc-avail-toolbar-nav .btn,
.hcc-avail-toolbar-today {
    border-radius: 999px;
}

/* Toolbar mobile reorder — desktop AND tablet keep the original
   left-to-right DOM order (view toggle, nav [prev/date/next/Today],
   switches) untouched; only a true phone width reorders anything. 3.4x:
   an earlier pass widened this to 991.98px to fix "poor on tablet", but
   the actual requirement is tablet == desktop, not tablet == mobile —
   reverted to 767.98px, matching the row's own col-md split (site/tmpl/
   availability/default.php) so tablet gets the exact same side-by-side,
   single-row layout desktop does. Requested shape below this breakpoint:
   the date + both chevrons read as one adjacent cluster ("date, chevron,
   chevron" — not the desktop order of chevron-date-chevron), Today
   trailing that same cluster (it lives inside .hcc-avail-toolbar-nav in
   the markup, so without its own order here it would default to order:0
   and jump in FRONT of the date/chevrons instead of following them); the
   view toggle and the switches wrap onto a second line below rather than
   being hidden. `.hcc-avail-toolbar-nav`'s flex-basis:100% is what forces
   that line break — flex-wrap does the rest, since everything after it
   shares the same `order` and simply follows in DOM order on the
   newly-wrapped line. Giving the NAV group the flex-basis (not the
   toggle) is what keeps the toggle's own width fixed to its content at
   every screen size — see its own note above. */
@media (max-width: 767.98px) {
    .hcc-avail-toolbar {
        justify-content: center;
    }
    .hcc-avail-toolbar-nav {
        order: 1;
        flex-basis: 100%;
        justify-content: center;
    }
    #hcc-month-label {
        order: 1;
    }
    #hcc-prev-month {
        order: 2;
    }
    #hcc-next-month {
        order: 3;
    }
    #hcc-today {
        order: 4;
    }
    .hcc-avail-toolbar-toggle,
    .hcc-avail-toolbar-switches {
        order: 2;
    }
    .hcc-avail-toolbar-switches {
        flex-basis: 100%;
    }
}

/* ======================================================================
   Registration page — big_goose/html/com_users/registration/default.php.
   Reuses .opc-edit-form / .opc-edit-grid / .opc-profile-card from the
   profile-edit override above; only the consent card, strength meter and
   inline field feedback are new here. Tokens only, no colour literals —
   this page renders in dark mode too (theme_preference).
   ====================================================================== */

.opc-register-shell {
    max-width: 600px;
    margin-inline: auto;
}
/* Step 3's two-column review/consent split (.opc-review-grid, further down)
   needs more room than the single-column steps do to read as two real
   columns rather than two cramped ones — widened only while step 3 is the
   active step, via the wizard's own toggled .is-active class, so steps 1/2
   keep the narrower single-column measure they were tuned for. */
.opc-register-shell:has(.opc-wizard-step[data-step="3"].is-active) {
    max-width: 720px;
}

/* --- Auth heading (3.29.36) ---------------------------------------------
   Replaces the old "shy" sticky banner (a coloured --opc-primary bar) that
   used to sit across the top of registration — explicitly removed, not
   hidden: no coloured background, no sticky/scroll behaviour, just a plain
   heading + one-line subtitle sitting directly in the page. Shared by both
   registration and login for identical typography/spacing on both auth
   pages. Not scoped under .opc-register-page/.opc-login-page since both
   pages use it identically. */
.opc-auth-heading {
    text-align: center;
    max-width: 30rem;
    margin: 0 auto 1.25rem;
}
.opc-auth-heading h1 {
    margin: 0 0 .35rem;
    font-size: 1.6rem;
    font-weight: 700;
    color: var(--opc-text);
}
.opc-auth-heading p {
    margin: 0;
    font-size: .95rem;
    color: var(--opc-text-faint);
}

/* --- Registration complete (3.34.90) -------------------------------------
   The post-registration confirmation page. Reuses .opc-field-panel for the
   card surface so it matches the profile/registration forms rather than
   introducing a third panel treatment; only the list and the action row
   below need rules of their own. */
.opc-reg-complete-steps {
    margin: 0;
    padding-left: 1.25rem;
    display: grid;
    gap: .6rem;
    font-size: .95rem;
    color: var(--opc-text);
}
.opc-reg-complete-steps li::marker {
    color: var(--opc-primary);
    font-weight: 700;
}
.opc-reg-complete-note {
    margin: 1rem 0 0;
    font-size: .875rem;
    color: var(--opc-text-faint);
}
/* Centred and full-width on a phone so the single action is an obvious
   target rather than a small link floating under the text. */
.opc-reg-complete-actions {
    margin-top: 1.25rem;
    display: flex;
    justify-content: center;
}
@media (max-width: 575.98px) {
    .opc-reg-complete-actions .btn {
        width: 100%;
    }
}

/* --- Stepper (3.29.36) ---------------------------------------------------
   Deliberately subtle — three small numbered markers connected by a thin
   line, never a large or visually dominant element. The active step is
   identified by BOTH a filled marker and aria-current="step" (set in JS),
   never by colour alone. */
.opc-stepper {
    display: flex;
    align-items: flex-start;
    justify-content: center;
    gap: 0;
    list-style: none;
    margin: 0 auto 1rem;
    padding: 0;
    max-width: 26rem;
}
.opc-stepper-step {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .3rem;
    flex: 1 1 0;
    min-width: 0;
    position: relative;
}
/* Connecting line, drawn as a pseudo-element on every step except the
   first — sits behind the marker circle (z-index) so it reads as one
   continuous line through the row, not three separate underlines. */
.opc-stepper-step:not(:first-child)::before {
    content: '';
    position: absolute;
    top: .7rem;
    right: 50%;
    width: 100%;
    height: 2px;
    background: var(--opc-border);
    z-index: 0;
}
.opc-stepper-step.is-done:not(:first-child)::before {
    background: var(--opc-primary);
}
.opc-stepper-num {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 1.4rem;
    height: 1.4rem;
    border-radius: 50%;
    font-size: .75rem;
    font-weight: 700;
    background: var(--opc-surface);
    border: 2px solid var(--opc-border);
    color: var(--opc-text-faint);
    transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.opc-stepper-step.is-active .opc-stepper-num {
    border-color: var(--opc-primary);
    color: var(--opc-primary);
}
/* Completed step shows a checkmark instead of its number — the digit stays
   in the DOM (colour: transparent, not display:none) so it's still announced
   to screen readers; sighted users see only the tick. */
.opc-stepper-step.is-done .opc-stepper-num {
    background: var(--opc-primary);
    border-color: var(--opc-primary);
    color: transparent;
}
.opc-stepper-step.is-done .opc-stepper-num::after {
    content: '\2713';
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--opc-primary-contrast);
    font-size: .7rem;
    font-weight: 700;
}
.opc-stepper-label {
    font-size: .7rem;
    color: var(--opc-text-faint);
    white-space: nowrap;
}
.opc-stepper-step.is-active .opc-stepper-label {
    color: var(--opc-text);
    font-weight: 600;
}
@media (max-width: 420px) {
    /* The label text is the first thing to go on very narrow phones — the
       numbered markers + connecting line alone still communicate progress. */
    .opc-stepper-label {
        display: none;
    }
}

/* --- Wizard shell (3.30) --------------------------------------------------
   Replaces the old `.card.opc-profile-card` wrapper around the three steps
   — an explicit "remove the nested boxes" requirement. Plain container,
   no border/background/shadow; `.opc-edit-section`'s existing hairline
   bottom-border still separates sub-groups within a step. */
.opc-wizard {
    padding: 0;
}

/* --- Wizard steps (3.30, stability pass) ---------------------------------
   All three steps exist in the DOM at once (see default.php's own note on
   why — a CAPTCHA widget in particular should only ever init once). JS
   toggles the `hidden` attribute + this class together; the animation only
   plays on `.is-active` picking up it fresh each time because `hidden`
   removes and re-adds the element to the render tree, which restarts a CSS
   animation naturally.
   Reduced to a short opacity-only fade (was translateY(14px) via the same
   keyframes .opc-field-enter uses for individual fields) — the slide
   distance combined with the browser's own instant reflow when a
   differently-sized step swaps in read as a "jump", reported as glitchy.
   A plain fade has no directional motion to clash with that reflow. */
.opc-wizard-step {
    animation: opc-wizard-step-enter .2s ease-out;
}
@keyframes opc-wizard-step-enter {
    from { opacity: 0; }
    to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
    .opc-wizard-step {
        animation: none;
    }
}
.opc-step-nav {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .75rem;
    padding: 1.5rem 0 0;
    margin-top: 1.5rem;
}
@media (max-width: 575.98px) {
    .opc-step-nav {
        flex-direction: column-reverse;
    }
    .opc-step-nav .btn {
        width: 100%;
    }
}

/* --- Step 3 layout: identity left, verify + consent + submit right --------
   (3.34.44 — replaces a single stacked column where the review recap,
   CAPTCHA, and consent card all read as one undifferentiated wall). Single
   column below 700px: the whole point of the split is two segments reading
   side by side, which a phone-width column has no room for anyway — under
   that width this is just the previous stacked order. No boxed card around
   either column (kept flat, per the 3.30 "remove nested boxes" call below);
   the identity column is set apart with a hairline divider only — see the
   right column's own override block further down for why its INTERNAL
   dividers (the ones between captcha/consent/step-nav) are removed instead
   of kept, in favour of one consistent flex `gap`. */
.opc-review-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 1.75rem;
}
@media (min-width: 700px) {
    .opc-review-grid {
        grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr);
        align-items: start;
    }
    .opc-review-col-identity {
        padding-right: 1.75rem;
        border-right: 1px solid var(--opc-border-light);
    }
}
/* The right column stacks 2-3 pieces that used to be top-level sections of
   ONE long single-column step (leftover fields/CAPTCHA, the consent card,
   the step nav) — each still carrying the vertical padding/margin that made
   sense spread down a full-length page. Stacked inside this narrower
   column, those add up (1rem + 1rem + 1.5rem + 1.25rem ≈ 76px, confirmed
   live as a visibly broken gap between Captcha and "Before you continue").
   Zeroed here and replaced with ONE flex `gap`, so the column reads as a
   single connected block with consistent rhythm instead of stacked
   leftover spacing from a different layout. */
.opc-review-col-identity > .opc-edit-section,
.opc-review-col-action > .opc-edit-section {
    padding-top: 0;
    padding-bottom: 0;
}
/* 3.34.49: closes a leak the padding-zeroing above didn't catch — Captcha's
   OWN `.opc-edit-field` wrapper carries `.opc-register-page .opc-edit-field
   { margin-bottom: 1.5rem }` (added for Step 1's field rhythm, further down
   this file), which isn't scoped to Step 1 and was landing here too. A flex
   item establishes its own formatting context, so that margin doesn't
   collapse into `.opc-review-col-action`'s edge — it just ADDS to the
   column's own `gap`, doubling 1.5rem into 3rem. Zeroed here, at the exact
   selector that was leaking, rather than touching the Step 1 rule itself. */
.opc-review-col-identity .opc-edit-field,
.opc-review-col-action .opc-edit-field {
    margin-bottom: 0;
}
.opc-review-col-action {
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
}
.opc-review-col-action .opc-consent-card {
    margin-top: 0;
    padding-top: 0;
    border-top: none;
}
/* Back/Create account (3.34.49): moved OUT of .opc-review-col-action in the
   markup — nested under just the action column, the row read as belonging
   to Captcha/Consent alone and sat off-centre under the narrower half of
   the page instead of closing the whole step. Now a sibling of
   `.opc-review-grid`, spanning both columns. The base `.opc-step-nav` rule
   (padding-top 1.5rem + margin-top 1.5rem = 3rem) was tuned for the single-
   column steps 1/2; here it would double up on top of the grid's own
   1.75rem `gap` below, so this collapses it to one clean value instead. */
.opc-step-nav-review {
    margin-top: 1.75rem;
    padding-top: 0;
}
/* Confirmed via the interactive slider preview (3.34.65) — a matched
   min-width on both buttons rather than letting Back size to its shorter
   text and Create account to its longer one, which read as an unbalanced
   pair. `min-width`, not `width`: still a normal flex row, so this can
   shrink and the existing @media (max-width:575.98px) stack-to-full-width
   rule (base .opc-step-nav, untouched) keeps working on phones. */
.opc-step-back,
.opc-register-submit {
    min-width: 14rem;
}

/* --- Review summary (Step 3) ---------------------------------------------
   Read-only name/email/mobile recap — deliberately never the password. */
.opc-review-summary {
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: .6rem;
}
.opc-review-row {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
}
.opc-review-row dt {
    font-size: .8rem;
    color: var(--opc-text-faint);
}
.opc-review-row dd {
    margin: 0;
    font-weight: 600;
    color: var(--opc-text);
    text-align: right;
    overflow-wrap: anywhere;
}

/* --- Inline validation errors (3.29.36) -----------------------------------
   Only ever added to the DOM by JS after a field has been touched (blurred
   invalid, or a Continue/Create-account attempt) — see validateField() in
   default.php. Never present at page load. */
.opc-field-error {
    margin: .3rem 0 0;
    font-size: .8125rem;
    color: var(--opc-danger);
}
.form-control.is-invalid,
.form-check-input.is-invalid {
    border-color: var(--opc-danger) !important;
    box-shadow: 0 0 0 .15rem color-mix(in srgb, var(--opc-danger) 20%, transparent) !important;
}

/* A Terms & Conditions/Privacy Policy trigger inside consent label text —
   reads and behaves like the plain-text link it replaced (target=_blank
   anchor), just opening the shared modal instead of a new tab. */
.opc-inline-link {
    background: none;
    border: none;
    padding: 0;
    margin: 0;
    font: inherit;
    color: var(--opc-primary);
    text-decoration: underline;
    cursor: pointer;
}

/* --- Policy attestation modal (T&Cs / Privacy / Animal & pet risk) ------
   Replaces the old iframe-based legal modal (pre-3.35.0). The body is now
   the article's own HTML fetched from policy.document, so it inherits this
   site's tokens instead of rendering a whole second page inside a frame —
   and, critically, gives the forced-read gate a real end-of-document
   sentinel to observe. See policygate.js for why an iframe could not.

   Colour literals are banned here (dark mode) — everything draws from
   --opc-* tokens, same as the rest of this stylesheet. */
.opc-policy-dialog {
    width: 100%;
    max-width: 720px;
    display: flex;
    flex-direction: column;
    /* Caps the dialog so the BODY scrolls rather than the page behind it —
       the scroll container has to be the document itself for the sentinel
       to mean anything. */
    max-height: min(82vh, 900px);
}

.opc-policy-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex: 0 0 auto;
}

.opc-policy-body {
    flex: 1 1 auto;
    overflow-y: auto;
    /* Momentum scrolling on iOS, and a scroll container that never chains to
       the page behind the backdrop. */
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
    padding: 1.25rem;
    line-height: 1.6;
    color: var(--opc-text);
}

/* The article's own markup, normalised to this site's type scale — a policy
   authored in com_content carries whatever heading sizes the editor left
   behind, which would otherwise tower over the modal. */
.opc-policy-content > :first-child { margin-top: 0; }
.opc-policy-content > :last-child { margin-bottom: 0; }
.opc-policy-content h1,
.opc-policy-content h2,
.opc-policy-content h3,
.opc-policy-content h4 {
    color: var(--opc-text);
    font-weight: 600;
    line-height: 1.3;
    margin: 1.5rem 0 .6rem;
}
.opc-policy-content h1 { font-size: 1.3rem; }
.opc-policy-content h2 { font-size: 1.15rem; }
.opc-policy-content h3 { font-size: 1.05rem; }
.opc-policy-content h4 { font-size: 1rem; }
.opc-policy-content p,
.opc-policy-content li { font-size: .95rem; }
.opc-policy-content ul,
.opc-policy-content ol { padding-left: 1.35rem; }
.opc-policy-content li { margin-bottom: .35rem; }
.opc-policy-content a {
    color: var(--opc-primary);
    text-decoration: underline;
}
.opc-policy-content img {
    max-width: 100%;
    height: auto;
}
/* A wide table in a policy document must scroll inside itself, never widen
   the dialog. */
.opc-policy-content table {
    display: block;
    max-width: 100%;
    overflow-x: auto;
    font-size: .9rem;
}

.opc-policy-loading,
.opc-policy-error {
    margin: 0;
    padding: 2rem 0;
    text-align: center;
    color: var(--opc-text-muted);
}

/* The forced-read target. Zero height and invisible: it marks the END of the
   document rather than adding anything to it. It must still be observable,
   so it keeps a 1px box — a 0x0 element is never reported as intersecting. */
.opc-policy-sentinel {
    height: 1px;
    margin-top: .5rem;
}

.opc-policy-footer {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
}

.opc-policy-hint {
    margin: 0;
    font-size: .85rem;
    color: var(--opc-text-muted);
    flex: 1 1 12rem;
}

.opc-policy-close:disabled,
.opc-policy-x:disabled {
    opacity: .5;
    cursor: not-allowed;
}

/* A blocked close attempt shakes the footer rather than doing nothing — a
   dead click reads as a broken button. */
@keyframes opc-policy-nudge {
    0%, 100% { transform: translateX(0); }
    25%      { transform: translateX(-4px); }
    75%      { transform: translateX(4px); }
}
.opc-policy-modal.is-nudging .opc-policy-footer {
    animation: opc-policy-nudge .4s ease;
}
@media (prefers-reduced-motion: reduce) {
    .opc-policy-modal.is-nudging .opc-policy-footer { animation: none; }
}

/* A consent row whose document still has to be read. Deliberately quiet —
   this is a hint about what will happen, not an error state. */
.opc-consent-check.needs-read .opc-policy-link::after,
.opc-consent-row.needs-read .opc-policy-link::after {
    content: "";
    display: inline-block;
    width: .4rem;
    height: .4rem;
    margin-left: .3rem;
    vertical-align: middle;
    border-radius: 50%;
    background: var(--opc-primary);
}

/* The in-label document link. A <button> rather than an <a> so it never
   navigates away from a half-completed registration form; styled back to
   look like the link it behaves as. */
.opc-policy-link {
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    color: var(--opc-primary);
    text-decoration: underline;
    cursor: pointer;
}
.opc-policy-link:hover,
.opc-policy-link:focus-visible {
    color: var(--opc-primary-dark, var(--opc-primary));
}

/* Mobile: a full-height sheet. A centred 720px dialog on a phone leaves a
   cramped reading column with the gate at the bottom of it — the one place
   the user actually has to reach. */
@media (max-width: 575.98px) {
    .opc-policy-modal {
        padding: 0;
        align-items: stretch;
    }
    .opc-policy-dialog {
        max-width: none;
        max-height: none;
        height: 100%;
        border-radius: 0;
    }
    .opc-policy-body {
        padding: 1rem;
    }
    .opc-policy-footer {
        /* Keeps the close control clear of the iOS home indicator. */
        padding-bottom: max(.75rem, env(safe-area-inset-bottom));
    }
    .opc-policy-footer .opc-policy-close {
        width: 100%;
    }
    .opc-policy-hint {
        flex-basis: 100%;
        text-align: center;
    }
}

/* "Already have an account? Log in" — plain text under the wizard, not a
   button, on both auth pages. */
.opc-auth-switch {
    text-align: center;
    margin: 1.25rem 0 0;
    font-size: .9rem;
    color: var(--opc-text-faint);
    display: none;
}
.opc-auth-switch a {
    /* No accent colour here either — explicit, repeated "no teal, EVER"
       requirement, same as the field labels. NOT --opc-primary: that token
       (#1f3c88 navy) has no dark-mode override anywhere in this file, so it
       reads as low-contrast on this dark background — which is what made
       this link look uninviting in the first place. Plain, high-contrast
       and unmistakably a link from weight + underline alone, no colour
       coding needed. */
    color: inherit;
    font-weight: 700;
    text-decoration: underline;
    text-underline-offset: .15em;
}

/* The leftover-fields fallback section in registration/default.php still
   used the old 2-column .opc-edit-grid from before the 3.27.0 single-column
   redesign — the only field that ever lands there is Captcha, so it sat in
   a 2-up grid with an empty, awkward second column. Full width keeps it
   consistent with every other field on this page. */
.opc-register-page .opc-edit-grid {
    grid-template-columns: 1fr;
}
/* No outer card any more (3.30) — .opc-edit-section's card-oriented
   horizontal padding would just read as unexplained indentation without
   one, so it's zeroed here. The hairline border-bottom between sections is
   ALSO removed (explicit ask: "all these break lines... look tacky") — Step
   3 in particular stacks Review summary / Captcha / Consent as three
   sibling .opc-edit-section blocks, so that border was firing three times
   down one step. */
.opc-register-page .opc-edit-section {
    padding-left: 0;
    padding-right: 0;
    border-bottom: none;
}

/* Comfortable tap targets on phones; 16px prevents iOS Safari zoom-on-focus.
   Colour/size are repeated here with a scoped !important:
   confirmed live (3.29.32) that the typed value was rendering both too dark
   and too small on these two pages — a Bootstrap/dark-mode cascade these
   overrides sit downstream of was winning on specificity even though this
   block comes later in the file. Same fix category as the dark-mode
   .form-control hotfix elsewhere in this file (see its own comment). */
.opc-register-page .form-control,
.opc-register-page .form-select,
.opc-login-page .form-control,
.opc-login-page .form-select {
    min-height: 3rem;
    /* Symmetric, so the typed value sits dead centre in the box. It used to
       be deliberately top-heavy (1.05rem / .35rem) purely to reserve room
       for the floating label that sat inside the box; the label is now a
       normal element ABOVE the box, so that reserved space had nothing left
       in it and just pushed the value low. */
    padding-top: .5rem;
    padding-bottom: .5rem;
    font-size: 1rem !important;
    color: var(--opc-text) !important;
}
/* 3.34.45: registration-only bump, layered on top of the block above rather
   than edited in place — login's 1rem is untouched (never part of the
   complaint). Needs its own !important for the same reason the block above
   does (see its comment): without one, this loses to the very rule it's
   trying to override, same specificity or not. */
.opc-register-page .form-control,
.opc-register-page .form-select {
    font-size: 1.0625rem !important;
}

/* Login's two fields (username/password) centered on request — floating
   label positioning above is unaffected since that's vertical padding,
   this is only the typed value's horizontal alignment. login/default.php
   (a separate, competing full-page rewrite with its own #form-login id)
   is gone — core's own trivial login/default.php dispatcher now delegates
   straight to this override's default_login.php, whose actual form id is
   #com-users-login__form. Scoped to that id rather than .opc-register-page
   so this never bleeds onto the registration page. */
/* #com-users-login__form .form-control {
    text-align: center;
} */

.opc-register-page .opc-edit-field,
.opc-login-page .opc-edit-field {
    /* Login's own fields also render inside .opc-edit-field (confirmed live
       via DevTools — NOT the .control-group structure this file's login
       section assumed), so the floating label's absolute positioning had no
       correct anchor here at all — it was positioning against whatever
       positioned ancestor it found further up the tree, not this field. */
    position: relative;
    margin-bottom: 1rem;
}
/* 3.34.45: Step 1's field rhythm opened up on request ("space it a little
   more... name from surname from username from email from mobile") — 1rem
   read as cramped once the page was seen live. Registration-only override
   (login keeps the tighter 1rem — its two fields were never the complaint)
   layered on top of the shared rule above rather than editing it, so this
   doesn't also affect login or the profile page reusing the same class. */
.opc-register-page .opc-edit-field {
    margin-bottom: 1.5rem;
}
/* Edge/Chromium's native password reveal + clear icons render INSIDE the
   input's own box, on top of our floating-label overlay and padding —
   confirmed live as garbled overlapping text ("Hide"/"Passwo…" with the
   ambient background showing through). Registration/Profile provide no
   custom reveal toggle of their own, so the native one is simply disabled
   rather than fought with z-index tricks. Login is untouched/out of scope
   (see this file's own login section) even though its password field could
   show the same native artifact — not this pass's problem to fix. */
.opc-register-page input[type="password"]::-ms-reveal,
.opc-register-page input[type="password"]::-ms-clear,
.opc-profile-page input[type="password"]::-ms-reveal,
.opc-profile-page input[type="password"]::-ms-clear {
    display: none;
}
/* Login has no per-field .opc-edit-field wrapper — its two fields
   (username/password) come straight out of core's own
   $form->renderFieldset('credentials', …). Ground-truthed from the 6.1.2
   core source (see default_login.php's header comment): despite this being
   Joomla 6, $field->renderField() still uses the LEGACY B/C layout
   (joomla.form.renderfield — Bootstrap 2/3's `.control-group` >
   `.control-label` + `.controls`, not the Bootstrap5 `.mb-3`/`.form-label`
   shape used elsewhere on this site), because Cassiopeia (and this fork)
   ships no site-side override of that layout — only the admin Atum
   template does. That wrapper — .control-group — is reused here as the
   floating-label positioning context instead of introducing a parallel
   markup convention for a two-field form. Registration's Joomla-rendered
   fields (username/e-mail/password/confirm) get the same .control-group
   treatment below; only First name/Surname are our own hand-written
   .form-label markup. */
.opc-login-page .control-group {
    position: relative;
    margin-bottom: 1rem;
}

/* --- Static labels above the input -------------------------------------
   NOT a floating label any more. The floating version was absolutely
   positioned over the input and vertically anchored to its wrapper
   (.opc-edit-field / .control-group) — but that wrapper GROWS whenever an
   inline feedback or error <p> is appended below the input ("Looks good.",
   "Please include an '@'…"), so the label kept sliding out of position the
   moment a field gained a message. Every attempt to pin it (line-height
   matched to the box, top:50%, a fixed half-height offset) fixed one state
   and broke another, because the label's position was coupled to markup
   that changes at runtime.

   A label in NORMAL FLOW above its input cannot drift: nothing rendered
   after the input can move it, there is no overlap with the typed value to
   tune, and no focus/filled/autofill state has to be tracked at all. This
   is option (a) from the brief ("labels sit cleanly ABOVE the input box
   with adequate spacing").

   Two label shapes are covered: our own hand-written `.form-label` (First
   name/Surname), and core's `.control-group > .control-label > label`
   (Joomla-rendered fields on both pages). Consent checkboxes / remember-me
   use `.form-check-label`, so they are untouched by this block. */
.opc-register-page .opc-edit-field .form-label,
.opc-register-fields .opc-edit-field .control-label label,
.opc-login-page .control-group .control-label label,
.opc-login-page .opc-edit-field .form-label {
    display: block;
    margin: 0 0 .35rem;
    padding: 0;
    font-size: .8125rem;
    font-weight: 500;
    line-height: 1.3;
    color: var(--opc-text-faint);
}
/* 3.34.45: "a little more size on the text" — registration-only, same
   reasoning as the spacing override above (login untouched). Labels up one
   step (.8125rem -> .9rem) and the typed value up from 1rem -> 1.0625rem
   (overrides the shared !important rule below by matching its own
   specificity + coming later in source order, not by adding a second
   !important). */
.opc-register-page .opc-edit-field .form-label {
    font-size: .9rem;
}
/* No focus / filled / autofill / reduced-motion state rules any more — a
   static label has no states to track and nothing to animate, which is the
   entire point of moving off the floating version. The required "*" inside
   the label markup inherits the colour above. */
/* Core's own `.control-label` div (login/registration's Joomla-rendered
   fields) and `.controls` div carry no Bootstrap CSS of their own on this
   Bootstrap5-only site — they're unstyled generic wrapper divs — so
   `.controls` needs nothing extra; the label above simply leaves it as the
   only thing occupying normal flow. Scoped to `.opc-register-fields`, not
   the whole page, so the leftover-fieldset fallback (Captcha, rendered in
   `.opc-edit-grid` outside this container — the only field that ever lands
   there) never gets its label wrenched out of flow and laid over the
   widget; it keeps Joomla's plain default look instead. */
.opc-register-fields .opc-edit-field .control-label,
.opc-login-page .control-group .control-label {
    margin: 0;
}

/* Password1's "Minimum Requirements — Characters: 12, …" line — confirmed
   live (3.29.33) this is NOT the field's .form-text description at all.
   registration.xml sets rules="true" on password1, which makes Joomla's
   password field LAYOUT (layouts/joomla/form/field/password.php, ground-
   truthed from the 6.1.2 core source) render a wholly separate
   `<div id="{name}-rules" class="small text-muted">Minimum Requirements —
   …</div>` BEFORE the actual `.password-group` (input), both still inside
   the same `.controls` div. That div sitting in normal flow at the top of
   `.controls` — the exact spot the floated-out label overlays — is what
   caused the visible text collision; hiding the wrong selector (.form-text)
   left it untouched and also pushed the real input down below it. Matched
   by its `-rules`-suffixed id rather than `.small.text-muted`, which are
   generic enough to risk matching something unrelated later. */
.opc-password1-field [id$="-rules"],
[data-opc-pw-field] [id$="-rules"] {
    display: none;
}

/* --- Ambient background (3.29.31) --------------------------------------
   Slow-drifting blurred colour blobs behind the card on both auth pages —
   the "2026 feels alive" touch. `.opc-ambient-bg` is `position: fixed` and
   sits BEFORE the sticky shy header in document order but is deliberately
   never made a positioning/overflow ancestor of anything — `position:
   fixed` needs no positioned or overflow:hidden container to work, which
   matters here specifically because an overflow:hidden ancestor is exactly
   what would break the registration page's `position: sticky` shy header.
   `z-index: -1` keeps it behind all real content without needing every
   other element on the page to be pulled into a stacking context.
   Token-driven (--opc-primary/--opc-accent mixed with transparent), no
   literal colours, so it reads correctly in dark mode. `filter: blur()` is
   cheap here since only `transform` is animated, not the blur radius
   itself. */
.opc-ambient-bg {
    position: fixed;
    inset: 0;
    z-index: -1;
    overflow: hidden;
    pointer-events: none;
}
.opc-ambient-bg span {
    position: absolute;
    width: 42vmax;
    height: 42vmax;
    border-radius: 50%;
    filter: blur(70px);
    opacity: .28;
    will-change: transform;
}
[data-bs-theme="dark"] .opc-ambient-bg span {
    opacity: .16;
}
.opc-ambient-bg span:nth-child(1) {
    top: -18vmax;
    left: -12vmax;
    background: var(--opc-primary);
    animation: opc-orb-drift-a 26s ease-in-out infinite;
}
.opc-ambient-bg span:nth-child(2) {
    bottom: -20vmax;
    right: -14vmax;
    background: var(--opc-primary);
    animation: opc-orb-drift-b 32s ease-in-out infinite;
}
.opc-ambient-bg span:nth-child(3) {
    top: 40%;
    left: 60%;
    width: 30vmax;
    height: 30vmax;
    background: var(--opc-success, #2e9e57);
    animation: opc-orb-drift-c 22s ease-in-out infinite;
}
@keyframes opc-orb-drift-a {
    0%, 100% { transform: translate(0, 0) scale(1); }
    50%      { transform: translate(6vmax, 8vmax) scale(1.12); }
}
@keyframes opc-orb-drift-b {
    0%, 100% { transform: translate(0, 0) scale(1); }
    50%      { transform: translate(-7vmax, -5vmax) scale(1.08); }
}
@keyframes opc-orb-drift-c {
    0%, 100% { transform: translate(0, 0) scale(1); }
    50%      { transform: translate(-5vmax, 6vmax) scale(.92); }
}
@media (prefers-reduced-motion: reduce) {
    .opc-ambient-bg span {
        animation: none;
    }
}
/* Hidden on narrow phones — at that viewport the blobs' 30-42vmax radius is
   large enough relative to the card that the blur reads as a colour cast
   over the whole screen rather than an ambient background detail. */
@media (max-width: 480px) {
    .opc-ambient-bg {
        display: none;
    }
}
/* Registration-only dampener (3.30): the redesign asks for the background
   to stay subtle and never compete with the (now un-boxed) content. A new
   override layered on top of the shared rule above — .opc-ambient-bg's own
   base opacity is untouched so login's identical background is unaffected. */
.opc-register-page .opc-ambient-bg span {
    opacity: .16;
}
[data-bs-theme="dark"] .opc-register-page .opc-ambient-bg span {
    opacity: .09;
}

/* --- Entrance motion (3.29.31) ------------------------------------------
   Each field row fades/slides up on load, staggered by a short delay per
   row — `animation-fill-mode: backwards` holds the 0% state during the
   delay so nothing flashes at full opacity before its turn. Pure CSS (no
   JS): it only needs to run once, on initial paint, which is exactly what
   a plain `animation` on page load already does without any trigger logic.
   Registration's rows are `.opc-field-pair` / `.opc-edit-field` /
   `.opc-pw-checklist-wrap` direct children of `.opc-register-fields`, plus
   the consent card and the action row below the card, which sit outside it;
   login's rows are the `.control-group` fields inside `.com-users-login__input`
   (see the .control-group comment above — core's legacy field layout, not
   `.mb-3`), plus the remember-me row and submit row. Both lists are covered
   explicitly rather than with a generic `> *` — a generic selector would
   also catch the hidden CSRF/return input Joomla's renderControlFields()
   appends, animating something invisible for no reason. */
@keyframes opc-field-enter {
    from { opacity: 0; transform: translateY(14px); }
    to   { opacity: 1; transform: none; }
}
.opc-register-fields > .opc-field-pair,
.opc-register-fields > .opc-edit-field,
.opc-register-fields > .opc-pw-checklist-wrap,
.opc-consent-card,
.opc-register-page .opc-edit-actions-bottom,
.com-users-login__input > .control-group,
.com-users-login__remember,
.com-users-login__submit {
    animation: opc-field-enter .5s cubic-bezier(.16, 1, .3, 1) backwards;
}
.opc-register-fields > :nth-child(1) { animation-delay: .02s; }
.opc-register-fields > :nth-child(2) { animation-delay: .08s; }
.opc-register-fields > :nth-child(3) { animation-delay: .14s; }
.opc-register-fields > :nth-child(4) { animation-delay: .20s; }
.opc-register-fields > :nth-child(5) { animation-delay: .26s; }
.opc-register-fields > :nth-child(6) { animation-delay: .32s; }
.opc-register-fields > :nth-child(n+7) { animation-delay: .36s; }
.opc-consent-card                     { animation-delay: .40s; }
.opc-register-page .opc-edit-actions-bottom { animation-delay: .46s; }
.com-users-login__input > :nth-child(1) { animation-delay: .02s; }
.com-users-login__input > :nth-child(2) { animation-delay: .1s; }
.com-users-login__remember              { animation-delay: .18s; }
.com-users-login__submit                { animation-delay: .26s; }
.opc-login-fields                       { animation: opc-field-enter .45s cubic-bezier(.16, 1, .3, 1) backwards; }
.opc-login-remind                       { animation: opc-field-enter .45s cubic-bezier(.16, 1, .3, 1) .32s backwards; }
@media (prefers-reduced-motion: reduce) {
    .opc-register-fields > .opc-field-pair,
    .opc-register-fields > .opc-edit-field,
    .opc-register-fields > .opc-pw-checklist-wrap,
    .opc-consent-card,
    .opc-register-page .opc-edit-actions-bottom,
    .com-users-login__input > .control-group,
    .com-users-login__remember,
    .com-users-login__submit,
    .opc-login-fields,
    .opc-login-remind {
        animation: none;
    }
}

/* --- Login page ----------------------------------------------------------
   big_goose/html/com_users/login/default_login.php. No outer card (aligned,
   by explicit request, with registration's own 3.30 card removal — login
   was previously deliberately EXCLUDED from that redesign; that exclusion
   is now reversed, so the old LOGIN-ONLY glassmorphic-card rule that used
   to live here is gone, not just hidden). Reuses the floating-label block
   above for username/password and .opc-ambient-bg/.opc-field-enter for the
   same motion language as registration — built to feel like the same
   product, not a reskinned Joomla core page next to a bespoke one. */
.opc-login-page {
    max-width: 460px;
    margin-inline: auto;
}
.opc-login-page .com-users-login__input .control-group {
    margin-bottom: 1rem;
}
/* Remember-me + "Forgot password?" on one row (3.29.34) — previously
   remember-me sat in the card while reset/remind lived in a visually
   separate list-group panel below it, flagged live as "disconnected
   floating elements". */
.opc-login-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
    margin: .25rem 0 1.1rem;
}
.opc-login-page .com-users-login__remember {
    margin: 0;
}
.opc-login-page .com-users-login__remember .form-check-input:checked {
    background-color: var(--opc-primary);
    border-color: var(--opc-primary);
}
.opc-login-forgot {
    font-size: .875rem;
    color: var(--opc-primary);
    white-space: nowrap;
}
.opc-login-submit {
    width: 100%;
    min-height: 2.75rem;
}
.opc-login-page .com-users-login__submit + .com-users-login__submit {
    margin-top: .5rem;
}
/* "Forgot your username?" — relocated inside the card (3.29.34), directly
   under the submit button, rather than a separate panel below it. */
.opc-login-remind {
    margin: .9rem 0 0;
    text-align: center;
    font-size: .8125rem;
}
.opc-login-remind a {
    color: var(--opc-text-faint);
}
@media (max-width: 575.98px) {
    .opc-login-page {
        max-width: none;
    }
}

/* --- Consent (3.30: flattened) -----------------------------------------
   Previously a boxed, blue-left-bordered "card" — explicitly one of the
   nested boxes the redesign asks to remove. Now a plain section, same
   treatment as any other .opc-edit-section (a hairline top border, no
   background/box), so it reads as part of the one continuous form. */
.opc-consent-card {
    margin-top: 1.5rem;
    padding-top: 1.25rem;
    border-top: 1px solid var(--opc-border-light);
}

.opc-consent-title {
    margin: 0 0 .75rem;
    color: var(--opc-text);
    font-weight: 600;
}

.opc-consent-check {
    display: flex;
    align-items: flex-start;
    gap: .65rem;
    margin: 0;
    padding: .4rem 0;
    min-height: 2.5rem;
}

.opc-consent-check + .opc-consent-check {
    border-top: 1px solid color-mix(in srgb, var(--opc-primary) 14%, transparent);
}

/* Bootstrap floats .form-check-input and pads the label; this row is flex
   instead, so both are reset to sit on the shared baseline. */
.opc-consent-check .form-check-input {
    float: none;
    margin: .15rem 0 0;
    flex: 0 0 auto;
    width: 1.15rem;
    height: 1.15rem;
}

.opc-consent-check .form-check-label {
    padding-left: 0;
    line-height: 1.45;
    cursor: pointer;
}

.opc-consent-check .form-check-input.is-invalid {
    border-color: var(--opc-danger);
    box-shadow: 0 0 0 .2rem color-mix(in srgb, var(--opc-danger) 25%, transparent);
}

.opc-consent-error {
    margin: .6rem 0 0;
    color: var(--opc-danger);
    font-size: .875rem;
    font-weight: 500;
}

/* --- Password strength meter ----------------------------------------- */
.opc-pw-strength {
    display: flex;
    align-items: center;
    gap: .6rem;
    margin-top: -.35rem;
    margin-bottom: .5rem;
}

.opc-pw-strength-track {
    flex: 1 1 auto;
    height: 5px;
    border-radius: 999px;
    background: var(--opc-border);
    overflow: hidden;
}

.opc-pw-strength-bar {
    display: block;
    width: 0;
    height: 100%;
    border-radius: 999px;
    background: var(--opc-danger);
    transition: width .18s ease, background-color .18s ease;
}

.opc-pw-strength-label {
    flex: 0 0 auto;
    min-width: 4.5rem;
    font-size: .8rem;
    color: var(--opc-text-faint);
}

.opc-pw-strength[data-score="2"] .opc-pw-strength-bar { background: #d98324; }
.opc-pw-strength[data-score="3"] .opc-pw-strength-bar { background: #b0a02b; }
.opc-pw-strength[data-score="4"] .opc-pw-strength-bar { background: var(--opc-success, #2e9e57); }

@media (prefers-reduced-motion: reduce) {
    .opc-pw-strength-bar { transition: none; }
}

/* --- Inline field feedback ------------------------------------------- */
.opc-inline-feedback {
    margin: .3rem 0 0;
    font-size: .8125rem;
    min-height: 1.1em;
    color: var(--opc-text-faint);
}

/* No message yet (page just loaded, or the field hasn't been touched) means
   nothing to reserve space for — the min-height above only earns its keep
   once there's actually a line of text to hold steady. */
.opc-inline-feedback:empty {
    margin: 0;
    min-height: 0;
}

.opc-inline-feedback.is-ok  { color: var(--opc-success, #2e9e57); }
.opc-inline-feedback.is-bad { color: var(--opc-danger); }

.opc-required-note {
    margin-top: .85rem;
    font-size: .8125rem;
    color: var(--opc-text-faint);
}

/* Sits above the form instead of below it (3.27.33) — tighter bottom margin
   than the trailing-note spacing above, no top margin since the shy header
   already carries its own bottom margin. */
.opc-required-note-top {
    margin: 0 0 1rem;
}

/* Full-width, stacked actions on phones — a 50/50 split leaves both buttons
   too narrow for their labels. */
@media (max-width: 575.98px) {
    .opc-register-page .opc-edit-actions-bottom {
        display: flex;
        flex-direction: column;
        gap: .6rem;
    }
    .opc-register-page .opc-edit-actions-bottom .btn {
        width: 100%;
    }
    .opc-consent-card {
        padding: .9rem 1rem;
    }
}

/* ==========================================================================
   Mandatory profile completion (3.27.0)
   Address / consent / communication-preference sections on the profile edit
   page, plus the callout shown when the gate has redirected the user here.
   Tokens only — no colour literals, so this themes correctly in dark mode.
   ========================================================================== */

/* The "why am I here" callout. Bootstrap's .alert-warning sets its own
   --bs-alert-* custom properties on the element itself, which shadow any
   ancestor theme variables regardless of !important — so the palette is
   restated here on the element rather than inherited. */
/* Sits INSIDE .opc-profile-scroll (see the template) so it scrolls away as
   the user moves down to the fields, instead of permanently occupying the top
   of the viewport. Deliberately compact: at five outstanding requirements the
   old full-size version (an .h6 <h2> plus default alert padding) filled most
   of a phone screen and left almost nothing to scroll in — reported live. */
.opc-gate-callout {
    margin-bottom: 1rem;
    padding: .75rem .9rem;
    border-radius: var(--opc-radius, .5rem);
}
/* A <p>, not a heading element, because this is a UI notice rather than a
   document section — and it must not compete with the page's real <h1>.
   Sized just above the body text so it still reads as a title. */
.opc-gate-heading {
    margin: 0 0 .2rem;
    font-size: .95rem;
    font-weight: 700;
    line-height: 1.3;
}
.opc-gate-intro {
    margin: 0 0 .4rem;
    font-size: .875rem;
    line-height: 1.35;
}
.opc-gate-list {
    padding-left: 1.1rem;
    font-size: .875rem;
    line-height: 1.35;
}
.opc-gate-list li + li {
    margin-top: .15rem;
}

/* The Places autocomplete box spans both grid columns — it is a lookup for
   the fields beneath it, not a peer of them. */
.opc-address-search {
    grid-column: 1 / -1;
}
/* Google renders its suggestion dropdown in a .pac-container appended to
   <body>, OUTSIDE this form's DOM — so it cannot inherit anything from here
   and must be themed globally. Without an explicit z-index it also renders
   BEHIND any positioned card/modal on the page. */
.pac-container {
    z-index: 2000;
    background: var(--opc-surface, #fff);
    border-radius: var(--opc-radius, .5rem);
    box-shadow: var(--opc-shadow-lifted);
    font-family: inherit;
}
.pac-item {
    padding: .35rem .6rem;
    color: var(--opc-text-faint);
    border-top-color: var(--opc-border);
    cursor: pointer;
}
.pac-item:hover,
.pac-item-selected {
    background: var(--opc-surface-alt, rgba(0, 0, 0, .04));
}
.pac-item-query,
.pac-matched {
    color: var(--opc-text);
}

/* Consent rows. An outstanding consent is a live checkbox; one already on
   file renders as a tick plus the recorded date (never a pre-ticked box —
   see the template for why). */
/* Indented to read as children of the "Agreements" summary row above them
   (reported live: with no indent, the document rows sat flush with the
   summary and looked like siblings, not contents of it). 2.85rem matches
   the summary's own icon (2.1rem) + gap (.75rem), so a child row's icon
   lines up under where the summary's title text starts. */
.opc-consent-section {
    padding-left: 2.85rem;
}
.opc-consent-row + .opc-consent-row {
    margin-top: .35rem;
}
.opc-consent-row.is-outstanding {
    padding: .6rem .75rem;
    background: var(--opc-surface-alt, rgba(0, 0, 0, .03));
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius, .5rem);
}
.opc-consent-row .form-check-input.is-invalid {
    border-color: var(--opc-danger, #dc3545);
}
.opc-consent-done {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: .4rem;
    font-size: .9rem;
    color: var(--opc-text-faint);
}
.opc-consent-tick::before {
    content: "✓";
    color: var(--opc-success, #198754);
    font-weight: 700;
}
.opc-consent-date {
    font-size: .8125rem;
    color: var(--opc-text-faint);
}

/* Communication preferences. Rendered from CommPreferenceService's registry,
   so this must lay out an arbitrary number of channels and categories. */
.opc-comm-channel + .opc-comm-channel {
    margin-top: 1rem;
    padding-top: 1rem;
    border-top: 1px solid var(--opc-border);
}
.opc-comm-channel-title {
    margin-bottom: .5rem;
    color: var(--opc-text-faint);
    text-transform: uppercase;
    letter-spacing: .04em;
    font-size: .75rem;
}
/* REQUIRED/OPTIONAL group labels (3.30) — split the previously interleaved
   category list so it's obvious at a glance which preferences cannot be
   turned off, without needing to read every row's own "Required" chip. */
.opc-comm-group-title {
    margin: .75rem 0 .1rem;
    font-size: .7rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--opc-text-soft);
}
.opc-comm-group-title-optional {
    margin-top: 1rem;
}
.opc-comm-option {
    padding-top: .4rem;
    padding-bottom: .4rem;
}
.opc-comm-option .form-check-label {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: .4rem;
}
.opc-comm-label {
    font-weight: 600;
}
/* A mandatory category is shown ticked and disabled rather than hidden — the
   user should see that transactional mail exists and know it is compulsory,
   not discover it by receiving something they never opted into. */
.opc-comm-required {
    padding: .05rem .4rem;
    font-size: .6875rem;
    text-transform: uppercase;
    letter-spacing: .03em;
    color: var(--opc-text-faint);
    background: var(--opc-surface-alt, rgba(0, 0, 0, .05));
    border-radius: 999px;
}
.opc-comm-desc {
    flex-basis: 100%;
    font-size: .8125rem;
    color: var(--opc-text-faint);
}

/* Profile DISPLAY view sections (3.27.0) — consent + comms summary shown
   below the details grid. Reuses .opc-consent-* from the edit page above
   where the treatment is identical; only the display-only states are new. */
.opc-profile-section {
    padding: 1rem 1.25rem;
    border-top: 1px solid var(--opc-border);
}
.opc-profile-section-title {
    margin: 0 0 .6rem;
    font-size: .75rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .04em;
    color: var(--opc-text-faint);
}
/* An outstanding consent on the display page. Warning-toned rather than
   error-toned: the user is not in trouble, there is simply something to do. */
.opc-consent-pending {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: .4rem;
    font-size: .9rem;
}
.opc-consent-warn::before {
    content: "!";
    display: inline-block;
    width: 1.05rem;
    height: 1.05rem;
    line-height: 1.05rem;
    text-align: center;
    font-weight: 700;
    font-size: .75rem;
    color: var(--opc-surface, #fff);
    background: var(--opc-warning, #f0ad4e);
    border-radius: 50%;
}
.opc-consent-action {
    font-weight: 600;
}
/* Read-only comms summary: every category listed, the inactive ones muted,
   so "what am I NOT getting" is answerable at a glance rather than only
   showing what is switched on. */
.opc-comm-summary {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: .5rem;
}
.opc-comm-summary + .opc-comm-summary {
    margin-top: .5rem;
}
.opc-comm-summary-channel {
    font-size: .8125rem;
    font-weight: 600;
    color: var(--opc-text-faint);
}
.opc-comm-summary-list {
    display: flex;
    flex-wrap: wrap;
    gap: .35rem;
}
.opc-comm-pill {
    padding: .1rem .5rem;
    font-size: .75rem;
    border-radius: 999px;
    border: 1px solid var(--opc-border);
    color: var(--opc-text-faint);
    opacity: .55;
}
.opc-comm-pill.is-on {
    opacity: 1;
    color: var(--opc-text);
    border-color: transparent;
    background: var(--opc-surface-alt, rgba(0, 0, 0, .06));
}

/* Profile edit: single-column layout + concertina sections (3.27.0).
   The two-column .opc-edit-columns is still used by the REGISTRATION page —
   do not delete those rules on the strength of this page no longer using
   them. */
/* Cap the line length on wide screens. A single column of inputs stretched
   to a full-width card reads as broken; the card itself stays full width so
   the section dividers still span it. The cap lives on the container
   itself (not one selector per child class) so every direct child stretches
   to the exact same width via align-items:stretch, whatever class it
   carries — the old per-child selector meant anything without
   .opc-edit-field/.opc-pw-checklist-wrap silently got no cap at all. */
.opc-edit-single {
    display: flex;
    flex-direction: column;
    max-width: 34rem;
}

/* Concertina, built on native <details>/<summary> — no JS, keyboard and
   screen-reader accessible for free, and it cannot fail closed if a script
   doesn't load. That matters: the address is mandatory, so a section that
   refused to open would be a hard lockout. */
/* 3.30: enlarged from a thin .35rem-padding row with a near-invisible 8px
   chevron — flagged as "hiding important information behind a tiny arrow"
   (Address is mandatory; Password is a real security action, not trivia).
   The whole row is still the native <summary> click/keyboard target, just
   sized and weighted to read as an obvious control rather than a hairline. */
/* 3.34.45: horizontal padding moved onto .opc-collapse itself (was 0 on
   both the summary and the body) — on the merged profile page these
   collapses sit directly under a plain wrapper div with no padding of its
   own (unlike Agreements' old .opc-edit-section, which had 1.25rem), so the
   icon sat flush against the card's left edge and the chevron flush against
   its right edge. Matches .opc-edit-section's own 1.25rem so every section
   on the card lines up. */
.opc-collapse {
    padding: 0 1.25rem;
}
.opc-collapse > .opc-collapse-summary {
    display: flex;
    align-items: center;
    gap: .75rem;
    /* Tightened from .75rem 0, then further from .5rem 0 — reported live as
       still too much air between stacked collapse rows (Banner/Appearance/
       Address/Emergency/Agreements/Comm Prefs/Password all sit one after
       another in edit mode). */
    padding: .4rem 0;
    cursor: pointer;
    list-style: none;
}
/* Both are needed: ::marker is the standard, ::-webkit-details-marker is the
   older WebKit/Safari spelling. */
.opc-collapse > .opc-collapse-summary::marker,
.opc-collapse > .opc-collapse-summary::-webkit-details-marker {
    display: none;
    content: "";
}
.opc-collapse > .opc-collapse-summary:focus-visible {
    outline: 2px solid var(--opc-primary, #0d6efd);
    outline-offset: 2px;
    border-radius: var(--opc-radius, .5rem);
}
.opc-collapse-title {
    font-size: .8rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .04em;
    color: var(--opc-text-faint);
    white-space: nowrap;
}
/* One-line summary of the collapsed content (Password section, and
   Address's own empty-state fallback). Truncates rather than wrapping, so
   the closed header stays exactly one row tall. */
.opc-collapse-preview {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: .9rem;
    color: var(--opc-text-faint);
}
/* Hidden once open — the fields below are then the content, and leaving a
   stale summary above them reads as a duplicate. */
.opc-collapse[open] > .opc-collapse-summary .opc-collapse-preview,
.opc-collapse[open] > .opc-collapse-summary .opc-address-preview {
    display: none;
}
/* Explicit "Edit"/paw control — an obvious affordance next to the
   paw rather than the paw being the only clickable-looking thing. */
.opc-collapse-edit {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    gap: .35rem;
    font-size: .8125rem;
    font-weight: 600;
    color: var(--opc-primary);
    white-space: nowrap;
}
/* 3.34.45: `margin-left: auto` pins this to the row's far right on its own —
   previously it relied on .opc-collapse-preview's `flex: 1 1 auto` to push
   it there, which only worked while the preview was visible. Once open, the
   preview is hidden (display:none, just below) and disappears from the flex
   layout entirely, so with nothing left to push against, it collapsed back
   to sit right next to the title instead of staying in place — exactly the
   reported bug. Was a CSS border-triangle chevron through 3.34.9x; replaced
   with an inline paw-print <svg> (5 ellipses, see default.php) at the same
   spot in the row. Outline = --opc-primary (collapsed state), infill on
   open = --opc-accent — both read from BrandingHelper's admin-set values
   (see its own PHP), not currentColor, since the ask was specifically the
   Business's two brand colours rather than whatever the row text happens
   to be. */
.opc-collapse-paw {
    flex: 0 0 auto;
    margin-left: auto;
    width: 1rem;
    height: 1rem;
    stroke: var(--opc-primary);
    fill: none;
}
/* Fill-in on open, no rotation — a paw doesn't read as a directional arrow
   the way the old chevron's 45deg-corner shape did, so this reaches for
   "stepping down" instead of "turning". */
.opc-collapse[open] > .opc-collapse-summary .opc-collapse-paw {
    fill: var(--opc-accent);
    stroke: var(--opc-accent);
}
/* Squash-bounce plays on BOTH open and close (not just open) — the ask was
   a reaction to being tapped, either direction, not a state indicator on
   its own (fill/stroke above already cover that). A plain `[open]`
   attribute selector can only ever catch one direction (removing `open`
   removes the animation instantly, with nothing to play on the way out),
   so this is a separate JS-toggled class instead: initAccordion()
   (profileedit.js/profilebundle.js) adds `.is-bouncing` on every
   `<details class="opc-collapse">` toggle event, forcing a reflow first so
   the animation restarts even if the class was never removed from the
   previous toggle. */
.opc-collapse-paw.is-bouncing {
    animation: opc-paw-bounce .65s cubic-bezier(.34, 1.56, .64, 1);
}
/* Bigger swing + an extra settle step than the first pass (was 1.3/.92,
   two steps, .5s ease) — reported live as too subtle to register as
   feedback. cubic-bezier(.34,1.56,.64,1) ("back out") overshoots past 100%
   on its own before settling, so it's doing part of the bounce work the
   keyframe steps used to carry alone. */
@keyframes opc-paw-bounce {
    0%, 100% { transform: scale(1) rotate(0); }
    35% { transform: scale(1.5) rotate(-16deg); }
    60% { transform: scale(.8) rotate(10deg); }
    82% { transform: scale(1.12) rotate(-4deg); }
}
.opc-collapse-body {
    /* Tightened from .75rem — no padding-bottom by design: the gap to
       whatever comes after (next collapse's own summary padding-top, or
       the scroll region's own bottom edge) is single-sided, same as
       before, just smaller now that the summary padding above was
       reduced too. */
    padding-top: .5rem;
}
@media (prefers-reduced-motion: reduce) {
    .opc-collapse-paw.is-bouncing {
        animation: none;
    }
}

/* --- Address summary (3.30) ------------------------------------------
   Address is mandatory, so its collapsed state reads as real address text
   (each line on its own row, like a mailing label) rather than a single
   ellipsis-truncated line — the .opc-collapse-preview treatment above still
   covers the "no address on file" fallback text. */
.opc-address-summary {
    align-items: flex-start;
}
.opc-address-preview {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    padding-top: .1rem;
}
.opc-address-line {
    font-size: .9rem;
    color: var(--opc-text);
    line-height: 1.4;
}

/* Registration layout (3.27.0) — single column with one deliberate two-up row
   for First name + Surname. Replaces the two-column split, whose two halves
   drifted out of vertical alignment as soon as inline feedback appeared under
   a field on one side only.
   3.34.44: the row is now UNCONDITIONAL, not gated behind a min-width — First
   name/Surname sit side by side on a phone the same as they do on desktop
   (explicit requirement; the two floating-label inputs stay comfortably
   readable down to the narrowest supported phone width). */
.opc-field-pair {
    display: flex;
    flex-direction: row;
    gap: .6rem;
}
/* Equal halves. min-width:0 stops a long value from forcing one side wider
   than the other — flex items default to min-width:auto, which refuses to
   shrink below their content. */
.opc-field-pair > .opc-edit-field {
    flex: 1 1 0;
    min-width: 0;
}
@media (min-width: 576px) {
    .opc-field-pair {
        gap: 1rem;
    }
}

/* --- Registration Country Code toggle (feature flag, off by default) -----
   Only rendered when Options -> Feature Toggles -> "Registration Country
   Code" is on; the free-text phone input (default) needs none of this. */
.opc-phone-row {
    display: flex;
    align-items: stretch;
    gap: .5rem;
}
.opc-phone-prefix {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    padding: 0 .75rem;
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius, .6rem);
    background: var(--opc-surface-sunken);
    color: var(--opc-text-faint);
    font-size: .9rem;
    font-weight: 600;
    white-space: nowrap;
}
.opc-phone-row .form-control {
    flex: 1 1 auto;
    min-width: 0;
}
/* Password and its meter/chips span the full column width. .opc-edit-single
   caps its children at 34rem on the profile page; registration deliberately
   opts out for these, since a password is the longest thing typed here and
   the strength bar needs the room to read as a meter. */
.opc-register-fields > .opc-edit-field,
.opc-register-fields > .opc-pw-checklist-wrap,
.opc-register-fields > .opc-field-pair {
    max-width: none;
}


/* ======================================================================
   My Profile — edit-in-place (3.34.42)
   Both the read-only view and the real Joomla edit form render in the same
   response now (default.php); this is purely the show/hide + new-component
   CSS the merge needs. Tokens only, same as everything else on this page —
   both themes already resolve correctly with no extra work here.
   ====================================================================== */

/* The whole toggle mechanism: two classes, shown/hidden by whether
   .opc-profile-page carries .is-editing. profileedit.js only ever adds/
   removes that one class — Save/Cancel are real submits, not JS. */
.opc-profile-page:not(.is-editing) .opc-edit-only { display: none; }
.opc-profile-page.is-editing .opc-view-only { display: none; }
/* .opc-profile-grid's own children are ALL .opc-view-only (Email/Phone/
   Address/Emergency/Vet read-out rows) — hidden one line up, but the grid
   container itself isn't, so its own padding/gap survived as a big blank
   box between Appearance and Find Your Address. Reported live. Can't just
   delete/comment out .opc-profile-grid's base rule (below) — the same
   class is what lays VIEW mode's 2-up summary grid out; this collapses it
   only once editing hides everything inside it. */
.opc-profile-page.is-editing .opc-profile-grid { display: none; }
/* The Logout slot: visible in plain view mode AND while gated (Cancel is
   withheld then — see the template). Hidden only for a normal, non-gated
   edit session, where Cancel occupies this slot instead.
   .opc-logout-slot is now a class on the BUTTON itself (it used to be on a
   <form> wrapping the button — that form was nested inside #member-profile,
   which is invalid HTML; the browser silently dropped the nested <form>
   tag from the DOM entirely, so this selector's ancestor never existed and
   the rule never matched, no matter what deployed. See default.php's own
   comment on the fix — button now associates to its (relocated, outside
   #member-profile) form via the HTML5 form="" attribute instead. */
.opc-profile-page.is-editing:not(.is-gated) .opc-logout-slot,
.opc-profile-page.is-editing:not(.is-gated) .opc-guide-fab,
.opc-profile-page.is-editing:not(.is-gated) .opc-portal-toggle {
    display: none;
}

/* Pinned banner, scrollable body (3.34.56, reworked 3.34.60) — edit mode
   only. Save/Cancel and the avatar upload live in .opc-profile-banner; on a
   long edit (Address, Appearance, Agreements, Comm Prefs, Password, 2FA)
   scrolling the whole card carried those controls away with it. Fixed by
   capping the card's own height and making .opc-profile-scroll (everything
   from the banner colour picker down — see the template) the one part that
   scrolls internally, while the banner + avatar/name/chips header keep
   their natural height and never move.

   3.34.60 — the original version only capped max-height in normal document
   flow, which left the card's TOP wherever the page header/tabs/gate
   banner above it happened to push it — a variable, unmeasurable amount.
   Reported live: the inner region scrolled fine, but once it was
   exhausted, part of the (still off-screen) card required a SECOND, outer
   page scroll to reach — the max-height cap was real, but nothing pinned
   WHERE the capped box sat, so its bottom edge could land below the fold.
   Fixed with `position: sticky; top: .5rem` — once scrolled to that fixed,
   KNOWN point, the box's bottom edge is `.5rem + max-height`, a fixed point
   too, so the whole capped card is always fully on-screen once reached and
   the inner scroll alone can reach every bit of it. `top` is a small
   reasoned guess (no browser here — see CLAUDE.md): if this platform's
   optional sticky site header is ever turned on, tune `top` upward so the
   card sticks just below it rather than under/behind it. */
/* 3.34.69 — outer-page scroll lock while editing. Reported live: touching
   ANY field or button inside the sticky card made the whole page jump.
   Cause: the card is only visually pinned (position:sticky) — the page
   underneath is still a real, independently-scrollable ancestor, and
   focusing an input runs the browser's default scroll-into-view walk over
   EVERY scrollable ancestor, not just .opc-profile-scroll. That second
   scroll fights the sticky recalculation on the same frame — the jump.
   profileedit.js adds this class to <html>+<body> for as long as editing
   is open (both, since Safari anchors the scroll lock to <html> while
   Chrome/Firefox honour it on <body>), removing the outer page as a
   scrollable ancestor entirely so there is nothing left for the browser to
   fight over. Card-visibility on entry is handled in JS (scrollIntoView
   BEFORE this class is added — locking freezes wherever the page already
   was, so without that the card could freeze off-screen). */
html.opc-edit-lock,
body.opc-edit-lock {
    overflow: hidden;
}

.opc-profile-page.is-editing .opc-profile-card {
    display: flex;
    flex-direction: column;
    /* Now the SAME value as the base (non-editing) .opc-profile-card rule
       above — kept as an explicit override (not left to inherit) so the
       two stay in sync deliberately rather than by accident if one is ever
       tuned again. Also matches the sticky `top` below, so the gap stays
       visually constant whichever state you're in. */
    margin-top: .5rem;
    /* Cancels the view-mode padding-bottom reserved for .opc-guide-fab/
       .opc-portal-toggle above — this state's box model is exactly tuned
       (see the comment just below) and both controls are hidden outright
       while editing anyway, so no clearance is needed here. */
    padding-bottom: 0;
    /* 3.34.67 — position:sticky RE-ADDED. A live DevTools Layout check
       (3.34.66 round) confirmed the box model here is exact:
       banner(96px) + header(56.98px) + .opc-profile-scroll(389.02px) =
       542px, matching the 34rem cap almost to the pixel — nothing is
       clipped, nothing miscalculated. What WAS happening: without sticky,
       this card is a completely normal block, so scrolling the OUTER page
       moves the banner off-screen exactly like any non-pinned element —
       that was misread as a "clipping" bug when it was really "this was
       never actually pinned since 3.34.63 removed sticky over an earlier,
       unconfirmed failure." Retrying sticky now that the underlying sizing
       is proven correct — the earlier failure may have been this exact
       same normal-scroll behaviour, misdiagnosed as sticky not engaging.
       If the banner still scrolls fully out of view after this, that is
       real evidence sticky itself is the problem, not the box model. */
    position: sticky;
    top: .5rem;
    z-index: 20;
    /* 3.34.82 — was a fixed 34rem regardless of the actual viewport
       (reported live: plenty of unused space below the card on a taller
       screen/browser window, since 34rem is a fixed ceiling far short of
       most desktop viewport heights). Now scales with the real viewport
       instead: `top: .5rem` + an equal .5rem reserved below is 1rem
       consumed total, so max-height fills whatever's left. `vh` line
       first as a fallback for browsers without `dvh` support, `dvh`
       second so it wins where available (accounts for mobile browser
       chrome show/hide, `vh` alone does not) — same progressive-
       enhancement pattern already used elsewhere on this page. No upper
       cap: on a very tall viewport the card can now grow past what
       34rem ever allowed, which is the point. */
    max-height: calc(100vh - 1rem);
    max-height: calc(100dvh - 1rem);
    /* `max-height` is the pre-JS/no-JS fallback only — see the matching
       reversion + explanation on the base (non-editing) .opc-profile-card
       rule above (a static `height` guess here was tried and reverted: it
       can't be correct for every bottom-bar variant this page can show). */
    overflow: hidden;
}
/* 3.34.83 — the calc above doesn't know about the fixed mobile bottom bar
   (.opc-mobilebar, ~4rem tall + the device's own safe-area inset), which
   sits on TOP of everything (position:fixed, z-index above this card) —
   reported live: the card's dynamic height ran the full viewport, so its
   bottom edge sat right under the bar instead of stopping above it. Same
   breakpoint and `:has(.opc-mobile-chrome)` gating already used for the
   body's own bottom padding just above in this file, so this only
   subtracts space when the bar is actually rendered (the "Mobile Site"
   Feature Toggle off case needs no adjustment here either). */
@media (max-width: 1199.98px) {
    body:has(.opc-mobile-chrome) .opc-profile-page.is-editing .opc-profile-card {
        max-height: calc(100vh - 1rem - 4rem - env(safe-area-inset-bottom));
        max-height: calc(100dvh - 1rem - 4rem - env(safe-area-inset-bottom));
    }
}
.opc-profile-page .opc-profile-banner {
    flex-shrink: 0;
}
.opc-profile-page .opc-profile-card-header {
    /* Needed in both states now that the card is a flex column in both —
       without it flexbox's default shrink:1 could compress the (fixed-
       content) avatar header instead of only .opc-profile-scroll giving up
       space, same reasoning as the banner just above. */
    flex-shrink: 0;
    /* THE actual source of the huge blank gap reported live between the
       chips row and the divider below it — not padding, despite how it
       looked. This element's classes are `card-body ... opc-profile-card-
       header` (see default.php), and Bootstrap's `.card-body` sets `flex:
       auto`, i.e. `flex-grow: 1`. Once .opc-profile-card got a JS-forced
       total height (see profileedit.js), that leftover flex-grow:1 made
       this header compete with .opc-profile-scroll (also flex-grow:1) for
       ALL the freed vertical space instead of sizing to its own content —
       stretching its box (and the border-bottom on it) far below the
       actual content, which is exactly the gap that was reported. The
       padding-bottom reduction below this rule was real but nowhere near
       big enough to explain a gap that size; this is the actual fix —
       flex-grow: 0 stops the header claiming any of that space, so
       .opc-profile-scroll (unopposed now) gets all of it instead. */
    flex-grow: 0;
}
.opc-profile-page.is-editing .opc-profile-card-header {
    /* Was the full card-body padding — the identity text (h2/username/
       chips) is hidden while editing now, so the avatar alone doesn't need
       as much air around it. */
    padding-top: .6rem;
    padding-bottom: .5rem;
    /* 3.34.66: the base (non-editing) rule pulls this whole block up
       -2.5rem to overlap the banner — a deliberate "avatar straddles the
       cover photo" look that only works with the ORIGINAL full-size
       avatar/header. In edit mode's shrunk, flex-shrink:0 header that same
       -2.5rem overlap is far too aggressive: reported live as clipping the
       avatar and throwing off where the scroll region visually starts.
       Overridden back to a normal, non-overlapping position here. */
    margin-top: 0;
}
.opc-profile-page.is-editing .opc-profile-avatar-img,
.opc-profile-page.is-editing .opc-profile-avatar {
    width: 2.4rem;
    height: 2.4rem;
}
.opc-profile-page.is-editing .opc-profile-avatar {
    font-size: 1rem;
}
.opc-profile-page.is-editing .opc-profile-avatar-edit {
    width: 1.15rem;
    height: 1.15rem;
    font-size: .6rem;
}
.opc-profile-page .opc-profile-scroll {
    /* min-height:0 overrides the flex item default of `auto`, which would
       otherwise refuse to shrink below its content's natural height and
       defeat the whole point — the card would just grow past max-height
       instead of this region scrolling internally. Applies in view mode too
       now (was .is-editing-only) — see the view-mode .opc-profile-card rule
       above for why. */
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    /* Reported live: content looked narrower/shifted in edit mode vs view
       mode. Cause: a scrollbar takes its width OUT of the content box it
       sits in, and view mode never scrolls (no scrollbar) while edit mode
       often does — so the same content rendered ~10px narrower the moment
       it needed to scroll, reading as a resize. `stable` reserves that
       gutter unconditionally, whether or not the content currently
       overflows, so the width never changes based on scroll state.
       Chromium/Firefox only (no Safari support as of this writing) — on
       Safari this is a no-op, not a regression: falls back to exactly
       today's before-this-fix behaviour, not something worse. */
    scrollbar-gutter: stable;
}
/* Deliberate, scoped exception to the site-wide hidden-scrollbar rule
   above (the `*`/`*::-webkit-scrollbar` block, explicitly "do not swap
   without a fresh discussion") — a class selector beats a bare `*`
   regardless of source order, so no !important is needed to win here.
   This one specific scrollbar IS the affordance: without it, a mouse user
   has no visual sign the pinned banner is hiding more content below, since
   they've already lost the usual drag-thumb cue everywhere else on the
   site. Plain grey (--opc-text-faint), not a brand/accent colour — this is
   a system-chrome affordance, not a themed UI element, and should read as
   an ordinary OS scrollbar. ::-webkit-scrollbar-button hides the up/down
   arrow buttons some browsers still draw on an unstyled/thin scrollbar. */
.opc-profile-page .opc-profile-scroll {
    scrollbar-width: thin;
    scrollbar-color: var(--opc-text-faint) var(--opc-surface-sunken);
}
.opc-profile-page .opc-profile-scroll::-webkit-scrollbar {
    display: block;
    width: 10px;
}
.opc-profile-page .opc-profile-scroll::-webkit-scrollbar-button {
    display: none;
    height: 0;
    width: 0;
}
.opc-profile-page .opc-profile-scroll::-webkit-scrollbar-track {
    background: var(--opc-surface-sunken);
}
.opc-profile-page .opc-profile-scroll::-webkit-scrollbar-thumb {
    background-color: var(--opc-text-faint);
    border-radius: 999px;
    border: 2px solid var(--opc-surface-sunken);
    background-clip: padding-box;
}
.opc-profile-page .opc-profile-scroll::-webkit-scrollbar-thumb:hover {
    background-color: var(--opc-text-soft);
}

/* "Scroll for more" hint pill — a coloured scrollbar alone wasn't obvious
   enough (every OTHER scrollbar on this platform is deliberately hidden, so
   there's no learned expectation this one differs; touch/trackpad never
   show one at all). Floats over the bottom edge of the scroll region,
   pointer-events:none so it never blocks a tap/click on the real content
   under it. `.is-visible` is added/removed by profileedit.js based on
   actual scroll position — hidden entirely if the content is short enough
   not to overflow, and once you've scrolled to the bottom there's nothing
   left to hint at. Starts hidden (opacity/transform) so there's no flash
   before JS measures the content on load. */
.opc-scroll-hint {
    position: absolute;
    left: 50%;
    bottom: .85rem;
    transform: translate(-50%, 6px);
    z-index: 5;
    display: flex;
    align-items: center;
    gap: .4rem;
    padding: .4rem .85rem;
    border-radius: 999px;
    /* 3.34.64: was --opc-text on --opc-surface — an inverted, one-off
       colour scheme unrelated to anything else on the page. 3.34.9x: that
       neutral chip look was itself reported live as reading like a disabled
       control rather than a hint — now accent-toned in light mode,
       primary-toned in dark (see the [data-bs-theme="dark"] override below),
       composed from existing tokens via color-mix() since no accent-tint/
       -ring tokens exist yet (mirrors how --opc-primary-tint is built). */
    background: var(--opc-accent-tint, color-mix(in srgb, var(--opc-accent) 8%, var(--opc-surface)));
    border: 1px solid color-mix(in srgb, var(--opc-accent) 35%, var(--opc-border));
    color: var(--opc-accent);
    font-size: .75rem;
    font-weight: 700;
    letter-spacing: .01em;
    white-space: nowrap;
    box-shadow: 0 6px 16px -4px color-mix(in srgb, var(--opc-shadow-color) 25%, transparent),
                0 2px 6px color-mix(in srgb, var(--opc-shadow-color) 30%, transparent);
    pointer-events: none;
    opacity: 0;
    transition: opacity .2s ease, transform .2s ease;
}
.opc-scroll-hint.is-visible {
    opacity: 1;
    transform: translate(-50%, 0);
    animation: opc-scroll-hint-bob 1.6s ease-in-out infinite;
}
/* Manager accounts only — customers never render the "Manager mode /
   Switch to client view" pill (.opc-portal-toggle, pinned bottom-right)
   that motivated this: centered (left: 50%, the base rule customers still
   get) put the scroll hint's own bob animation right where that pill sits,
   visually competing with it. `.has-portal-toggle` mirrors the exact same
   condition the toggle itself renders under (see default.php) rather than
   "is a manager right now" — a manager who's switched to client view still
   sees the toggle (that's how they switch back), so the hint still needs
   to move for them too.
   A flat 25% was a guess at the toggle's width, and drifted wrong once its
   content changed (the toggle isn't fixed-width — its label hides under
   420px, see .opc-portal-toggle-label's own media query — so no single
   percentage is ever right at every viewport). profileedit.js's
   positionScrollHint() now measures .opc-guide-fab and .opc-portal-toggle's
   real rects and sets an exact px `left` at the true midpoint of the gap
   between them, overriding this rule at runtime. This 25% survives only as
   the pre-JS/no-JS fallback paint, roughly centered in that gap rather than
   exact. `transform: translate(-50%, …)` above still just centers the pill
   ON whatever `left` is, so no other rule needs to change. Edit mode is
   unaffected either way — nothing lives at top-center there regardless of
   account type. */
.opc-profile-page.has-portal-toggle:not(.is-editing) .opc-scroll-hint {
    left: 25%;
}
[data-bs-theme="dark"] .opc-scroll-hint {
    background: var(--opc-primary-tint);
    border-color: color-mix(in srgb, var(--opc-primary) 35%, var(--opc-border));
    color: var(--opc-primary);
}
.opc-scroll-hint-chevron {
    display: inline-block;
    font-size: .65rem;
    line-height: 1;
    animation: opc-scroll-hint-chevron 1.6s ease-in-out infinite;
}
@keyframes opc-scroll-hint-bob {
    0%, 100% { transform: translate(-50%, 0); }
    50% { transform: translate(-50%, -4px); }
}
@keyframes opc-scroll-hint-chevron {
    0%, 100% { transform: translateY(0); }
    50% { transform: translateY(2px); }
}
@media (prefers-reduced-motion: reduce) {
    .opc-scroll-hint,
    .opc-scroll-hint.is-visible {
        animation: none;
        transition: opacity .2s ease;
    }
    .opc-scroll-hint-chevron {
        animation: none;
    }
}
/* No separator hairlines between sections on this page (approved mockup) —
   spacing alone does the work. Scoped to .opc-profile-page so the shared
   .opc-edit-section rule (registration/login still use it) is untouched. */
.opc-profile-page .opc-edit-section {
    border-bottom: none;
    /* Tightened from the shared 1rem 1.25rem (registration/login keep that
       — see the base .opc-edit-section rule) — reported live as too much
       air stacked against the .opc-collapse rows' own reduced padding just
       above. */
    padding: .4rem 1.25rem;
}

/* Banner-colour picker (3.34.62 — replaces the 6-swatch-circle + native
   <input type="color"> version from 3.34.42). Two bars: Default (always
   left, always .opc-gradient-primary) and Custom (right) — the whole
   spectrum, expands on press to swallow the row, collapses back to a solid
   swatch on release. The spectrum fill on .opc-banner-bar-custom is
   deliberately literal (not tokens) — same reasoning the old swatch colours
   used: it's meant to look identical regardless of theme. --custom is set
   inline by the template (server-rendered initial state) and live by
   profileedit.js while dragging/after commit. */
.opc-banner-picker {
    position: relative;
    display: flex;
    align-items: stretch;
    gap: .6rem;
    height: 3rem;
}
.opc-banner-bar {
    /* `<button>` as a flex item is a known cross-browser inconsistency
       spot — some engines size it to content instead of honouring
       align-items:stretch/flex-basis unless box-sizing/line-height are
       pinned explicitly. Reported live as rendering several times its
       intended size (3rem/. 8rem font clearly not what was showing) —
       these are the properties that close that gap. */
    box-sizing: border-box;
    flex: 1 1 0;
    align-self: stretch;
    min-width: 0;
    max-height: 3rem;
    line-height: 1;
    margin: 0;
    border-radius: var(--opc-shape);
    border: 2px solid transparent;
    cursor: pointer;
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: inherit;
    font-size: .8rem;
    font-weight: 700;
    color: #fff;
    text-shadow: 0 1px 3px rgba(0, 0, 0, .45);
    transition: flex-grow .28s cubic-bezier(.2, .8, .3, 1), border-color .15s ease, transform .15s ease;
    overflow: hidden;
    -webkit-user-select: none;
    user-select: none;
    padding: 0 .5rem;
}
.opc-banner-bar:active {
    transform: scale(.98);
}
.opc-banner-bar:focus-visible {
    outline: 2px solid var(--opc-primary);
    outline-offset: 2px;
}
.opc-banner-bar-default {
    background: var(--opc-gradient-primary);
}
.opc-banner-bar-default.is-selected {
    border-color: var(--opc-surface);
    box-shadow: 0 0 0 2px var(--opc-primary);
}
.opc-banner-bar-custom {
    background: linear-gradient(
        90deg,
        hsl(0 90% 55%), hsl(60 90% 55%), hsl(120 90% 45%),
        hsl(180 90% 45%), hsl(240 90% 60%), hsl(300 90% 55%), hsl(360 90% 55%)
    );
    /* Stops the browser from treating a press-and-drag on this bar as a
       page scroll/swipe gesture before pointermove ever fires — reported
       live as "does not slide, it exits" on mobile. Without this, touch
       input defaults to panning the page; pointer events alone don't
       override that. */
    touch-action: none;
}
.opc-banner-bar-custom.has-color {
    background: var(--custom, var(--opc-gradient-primary));
}
/* While actively picking, ALWAYS show the spectrum — reported live: with
   .has-color's solid fill winning even mid-drag, there was no way to see
   where on the spectrum you were relative to where you started. Three
   classes beats .has-color's two, so this wins on specificity alone, no
   !important needed. */
.opc-banner-picker.is-expanded .opc-banner-bar-custom {
    background: linear-gradient(
        90deg,
        hsl(0 90% 55%), hsl(60 90% 55%), hsl(120 90% 45%),
        hsl(180 90% 45%), hsl(240 90% 60%), hsl(300 90% 55%), hsl(360 90% 55%)
    );
}
.opc-banner-bar-custom.is-selected {
    border-color: var(--opc-surface);
    box-shadow: 0 0 0 2px var(--custom, var(--opc-primary));
}
/* Expanded: the custom bar grows to swallow both bars' combined width; the
   default bar fades out rather than reflowing, so nothing jumps. */
.opc-banner-picker.is-expanded .opc-banner-bar-default {
    flex-grow: 0;
    width: 0;
    min-width: 0;
    padding: 0;
    border: none;
    opacity: 0;
    pointer-events: none;
}
.opc-banner-picker.is-expanded .opc-banner-bar-custom {
    flex-grow: 1;
}
.opc-banner-bar-label {
    position: relative;
    z-index: 2;
    pointer-events: none;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    transition: opacity .12s ease;
}
.opc-banner-picker.is-expanded .opc-banner-bar-custom .opc-banner-bar-label {
    opacity: 0;
}
.opc-banner-hue-thumb {
    position: absolute;
    top: 50%;
    left: 0;
    width: 2.4rem;
    height: 2.4rem;
    border-radius: 50%;
    background: #fff;
    border: 3px solid var(--custom, var(--opc-primary));
    box-shadow: 0 4px 12px color-mix(in srgb, var(--opc-shadow-color) 40%, transparent);
    transform: translate(-50%, -50%);
    pointer-events: none;
    opacity: 0;
    transition: opacity .15s ease;
    z-index: 3;
}
.opc-banner-picker.is-expanded .opc-banner-hue-thumb {
    opacity: 1;
}
.opc-banner-hint {
    margin: .6rem 0 0;
    font-size: .8125rem;
    color: var(--opc-text-faint);
}
/* Plain black swatch (reported live: the hue bar above can't reach it — it
   fixes saturation/lightness and only sweeps hue, so achromatic values
   were never on that axis to begin with; a white swatch was added the
   same way and then removed on request). Literal #000, not a token, for
   the same reason the hue spectrum fill above is literal: meant to look
   identical regardless of theme — this IS the colour, not themed chrome. */
.opc-banner-swatches {
    display: flex;
    gap: .6rem;
    margin-top: .6rem;
}
.opc-banner-swatch {
    box-sizing: border-box;
    width: 2.25rem;
    height: 2.25rem;
    /* var(--opc-shape), not a literal 50% — square box, so Round (999px)
       renders identically to 50% today. */
    border-radius: var(--opc-shape);
    padding: 0;
    cursor: pointer;
    transition: box-shadow .15s ease, transform .15s ease;
}
.opc-banner-swatch:active {
    transform: scale(.92);
}
.opc-banner-swatch:focus-visible {
    outline: 2px solid var(--opc-primary);
    outline-offset: 2px;
}
.opc-banner-swatch-black {
    background: #000000;
    border: 2px solid #000000;
}
.opc-banner-swatch.is-selected {
    box-shadow: 0 0 0 2px var(--opc-primary);
}

/* List-row: icon box + label/sub + trailing control — Agreements (already-
   agreed state) and the Communication Preferences / Password & Security
   collapse headers all use this, matching the approved mockup exactly. */
.opc-list-row {
    display: flex;
    align-items: center;
    gap: .75rem;
    text-decoration: none;
    color: inherit;
    width: 100%;
}
a.opc-list-row:hover .opc-list-label,
button.opc-list-row:hover .opc-list-label {
    text-decoration: underline;
}
.opc-list-icon {
    width: 2.1rem;
    height: 2.1rem;
    border-radius: .6rem;
    background: var(--opc-surface-sunken);
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    color: var(--opc-primary);
}
.opc-list-icon svg {
    width: 1.05rem;
    height: 1.05rem;
}
.opc-list-body {
    flex: 1 1 auto;
    min-width: 0;
}
.opc-list-label {
    font-size: .9rem;
    font-weight: 600;
    color: var(--opc-text);
}
.opc-list-sub {
    display: block;
    font-size: .75rem;
    color: var(--opc-text-faint);
    margin-top: .1rem;
}
/* Consent "done" row reuses .opc-list-row wholesale — .opc-consent-row still
   carries the outstanding/is-outstanding treatment for the live-checkbox
   case, untouched.

   The trigger is a <button>, not an <a> (3.35.0): the document opens in the
   policy modal, so there is no href to navigate to and an <a href="#"> would
   be a dead link. A button carries the browser's own control chrome —
   background, border, centred text, inherited-font opt-out — none of which a
   row like this wants, so it is reset back to a plain block here. Keep the
   <a> selector alongside it: registration still renders an anchor. */
a.opc-consent-row.opc-list-row,
button.opc-consent-row.opc-list-row {
    padding: .3rem 0;
}
button.opc-list-row {
    background: none;
    border: 0;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

/* Password show/hide eye toggle — button injected by profileedit.js next to
   each core-rendered password input. */
.opc-pw-input-wrap {
    position: relative;
    display: block;
}
.opc-pw-input-wrap input[type="password"],
.opc-pw-input-wrap input[type="text"] {
    padding-right: 2.4rem;
}
.opc-pw-eye {
    position: absolute;
    right: .4rem;
    top: 50%;
    transform: translateY(-50%);
    background: none;
    border: none;
    color: var(--opc-text-faint);
    cursor: pointer;
    padding: .3rem;
    display: flex;
    border-radius: .35rem;
}
.opc-pw-eye:hover {
    color: var(--opc-text);
}
.opc-pw-eye svg {
    width: 1rem;
    height: 1rem;
}

/* Generic in-box "clear" (×) button for a text search input — same
   positioned-wrapper idiom as .opc-pw-input-wrap/.opc-pw-eye above, reusable
   on any plain text search field (first use: Manager Portal Customers
   search, wired up by customers-search.js). Toggled based on whether the
   input has a value, so it only appears once there's something to clear. */
.opc-search-clear-wrap {
    position: relative;
    display: block;
}
.opc-search-clear-wrap input[type="text"],
.opc-search-clear-wrap input[type="search"] {
    padding-right: 2.4rem;
}
.opc-search-clear {
    position: absolute;
    right: .4rem;
    top: 50%;
    transform: translateY(-50%);
    background: none;
    border: none;
    color: var(--opc-text-faint);
    cursor: pointer;
    padding: .3rem;
    display: flex;
    border-radius: .35rem;
}
.opc-search-clear:hover {
    color: var(--opc-text);
}
.opc-search-clear svg {
    width: 1rem;
    height: 1rem;
}

/* ======================================================================
   Guided Tour onboarding (3.29.x) — see tour.js and OpcTheme::linkTourScript()/
   buildTourSteps(). Spotlight design (per the reference screenshot supplied):
   a uniform dark scrim over the WHOLE screen with a soft circular "hole" cut
   out of it directly over the current step's target — CSS `mask-image`, not a
   gradient background — so the real target is left fully undimmed/crisp right
   at the tap point, fading smoothly back to full dim a short distance out. A
   separate soft white glow (`.opc-tour-glow`, mix-blend-mode: screen) sits at
   the same spot to actively brighten it, matching the reference's halo rather
   than merely "not dimmed." Back/Skip live ON the card itself (see
   .opc-tour-card-controls) rather than a page-fixed top bar, so they travel
   with the card instead of competing with whatever the page's own header/nav
   is doing at a given moment.

   The card is DELIBERATELY theme-independent (a fixed dark glass panel, never
   --opc-surface/--opc-text) rather than following the site's own light/dark
   mode — it always sits on this same dark scrim regardless of which mode the
   underlying page is in, so a fixed dark-glass-plus-light-text card is what
   stays readable in BOTH modes, rather than what best matches either one.

   "Tour Finished/Skipped" confirmation reuses .opc-modal-backdrop/
   .opc-modal-dialog instead (a plain centred dialog, no spotlight involved).
   ====================================================================== */
.opc-tour-backdrop {
    position: fixed;
    inset: 0;
    background: rgba(10, 14, 26, .72);
    z-index: 1300;
    /* No CSS transition here deliberately — mask-image transitions on a
       radial-gradient are unreliable across browsers (several don't
       interpolate the gradient smoothly at all, just jump). tour.js's
       animateSpotlight() drives the mask by hand instead (requestAnimationFrame,
       eased), repainting it every frame alongside the glow/hit so all three
       move in lockstep on every browser regardless of mask-image transition
       support. Fallback for a browser with no mask-image support at all: the
       scrim simply stays solid, no cutout — degrades to a plain dim overlay,
       never breaks the tour. */
}
@supports not ((mask-image: none) or (-webkit-mask-image: none)) {
    .opc-tour-backdrop {
        background: rgba(10, 14, 26, .6);
    }
}
body.opc-tour-open {
    overflow: hidden;
}
/* Soft white halo brightening the spotlighted area beyond just "undimmed" —
   screen-blended so it lifts whatever's beneath (backdrop or real page)
   rather than painting a flat white disc over it. Purely decorative
   (pointer-events: none); .opc-tour-hit below is the actual tap target. */
.opc-tour-glow {
    position: fixed;
    z-index: 1301;
    border-radius: 50%;
    /* Fallback only — tour.js's positionSpotlight() overwrites this inline on
       every render with a gradient sized to the CURRENT target's own real
       footprint (a fully transparent hole out to the target's edge, THEN the
       bright ring), so the glow never washes out whatever icon/label sits
       under it regardless of how big that element is. This static version
       only matters for the split second before the first render runs. */
    background: radial-gradient(circle,
        rgba(255, 255, 255, 0) 0px,
        rgba(255, 255, 255, 0) 30%,
        rgba(255, 255, 255, .6) 55%,
        rgba(255, 255, 255, .22) 75%,
        rgba(255, 255, 255, 0) 100%);
    mix-blend-mode: screen;
    pointer-events: none;
    animation: opc-tour-pulse 1.8s ease-in-out infinite;
    /* Position/size are animated by hand too (tour.js's animateSpotlight()),
       same reasoning as .opc-tour-backdrop above — kept in one rAF loop with
       the mask so the glow and the "hole" it sits inside never drift out of
       sync with each other mid-transition. No CSS transition here; the pulse
       keyframes above are independent of that and still apply. */
}
@keyframes opc-tour-pulse {
    0%, 100% { opacity: .85; transform: scale(1); }
    50%      { opacity: 1; transform: scale(1.045); }
}
@media (prefers-reduced-motion: reduce) {
    .opc-tour-glow {
        animation: none;
    }
}
/* The actual tap target — an invisible <button> exactly over the highlighted
   element (never the real link underneath, so tapping it can never navigate
   away mid-tour). cursor:pointer is the only visual cue besides the glow's
   own pulse; deliberately borderless/transparent per "an invisible section
   where they should tap their finger." Position/size animated by hand, same
   rAF loop as the glow/backdrop — see .opc-tour-glow's own comment. */
.opc-tour-hit {
    position: fixed;
    z-index: 1302;
    background: transparent;
    border: 0;
    border-radius: 50%;
    padding: 0;
    cursor: pointer;
}
/* Back (left, hidden on step 1) / Skip Tour (right) now live IN the card
   itself, not a fixed top bar — a page-top-fixed bar sat wherever the real
   page nav/header happened to be at the time (3.29.x), and a header/nav
   reshuffle (HONK — header hidden, nav promoted to act as header) put the
   two in each other's way. Anchoring Back/Skip to the card instead means
   they always travel with it, independent of whatever the underlying page
   chrome is doing. */
.opc-tour-card-controls {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: -.15rem -.15rem .6rem;
}
.opc-tour-back {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.9rem;
    height: 1.9rem;
    background: rgba(255, 255, 255, .14);
    border: 0;
    border-radius: var(--opc-shape);
    color: #fff;
    cursor: pointer;
}
.opc-tour-back svg {
    width: 16px;
    height: 16px;
}
.opc-tour-back[hidden] {
    visibility: hidden;
}
.opc-tour-skip {
    margin-left: auto;
    padding: 0;
    background: none;
    border: 0;
    font-size: .75rem;
    font-weight: 600;
    letter-spacing: .01em;
    text-transform: uppercase;
    color: #fff;
    text-decoration: underline;
    cursor: pointer;
}
.opc-tour-skip:hover,
.opc-tour-skip:focus-visible {
    color: rgba(255, 255, 255, .8);
}
/* The popover card — deliberately theme-independent, see this section's own
   docblock above for why. */
.opc-tour-card {
    position: fixed;
    z-index: 1303;
    width: min(300px, calc(100vw - 24px));
    background: rgba(15, 18, 28, .92);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    color: #fff;
    border-radius: var(--opc-radius-lg);
    box-shadow: 0 20px 40px -12px rgba(0, 0, 0, .5);
    padding: 1rem 1.1rem;
    /* Plain numeric top/left, so (unlike mask-image) a CSS transition handles
       this reliably everywhere — matches the 320ms/ease-out feel of the
       spotlight's own hand-rolled rAF tween (tour.js's animateSpotlight()) so
       the card glides to the next step in step with the glow, not ahead of or
       behind it. */
    transition: top .32s cubic-bezier(.33, 1, .68, 1), left .32s cubic-bezier(.33, 1, .68, 1);
}
@media (prefers-reduced-motion: reduce) {
    .opc-tour-card {
        transition: none;
    }
}
.opc-tour-card-title,
.opc-tour-card-body {
    /* Crossfaded across a step change by goToStep() (tour.js) — an instant text
       swap while the card/spotlight are still gliding to the next step reads
       as a mismatch, so the text fades out/in a little quicker than the
       320ms position glide instead of snapping. */
    transition: opacity .13s ease;
}
@media (prefers-reduced-motion: reduce) {
    .opc-tour-card-title,
    .opc-tour-card-body {
        transition: none;
    }
}
.opc-tour-card-title {
    font-size: .95rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .01em;
    margin: 0 0 .5rem;
}
.opc-tour-card-body {
    font-size: .85rem;
    line-height: 1.4;
    color: rgba(255, 255, 255, .85);
    margin: 0;
    padding-left: .6rem;
    border-left: 2px solid rgba(255, 255, 255, .3);
}
/* Step count + "tap to continue" hint are announced to screen readers only —
   the reference design relies on the glow/pulse alone to communicate
   interactivity, with no visible on-card chrome besides title/body. */
.opc-tour-card-step,
.opc-tour-card-hint {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* Confirmation modal ("Tour Finished!"/"Tour Skipped.") — centred on the
   shared .opc-modal-backdrop base (full click-catching overlay), but the
   dialog itself is restyled to match the tour card's own dark-glass look
   rather than the plain default .card — same reasoning as .opc-tour-card's
   own docblock (deliberately theme-independent, always readable regardless
   of the site's light/dark mode), plus a small status icon and better
   spacing so this reads as a deliberate close to the tour instead of a
   generic system dialog. Sits above the tour's own layer since it only ever
   appears after teardown() has already removed the backdrop/glow/card. */
.opc-tour-done-backdrop {
    z-index: 1305;
    background: rgba(6, 8, 16, .78);
}
.opc-tour-done-dialog {
    max-width: 340px;
    background: rgba(15, 18, 28, .95);
    backdrop-filter: blur(14px);
    -webkit-backdrop-filter: blur(14px);
    border: 1px solid rgba(255, 255, 255, .08);
    border-radius: var(--opc-radius-lg);
    box-shadow: 0 24px 48px -16px rgba(0, 0, 0, .65);
}
.opc-tour-done-dialog .card-body {
    padding: 2rem 1.5rem 1.5rem;
}
.opc-tour-done-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 3rem;
    height: 3rem;
    margin: 0 auto 1rem;
    border-radius: 50%;
    background: var(--opc-gradient-primary);
    color: #fff;
}
.opc-tour-done-icon svg {
    width: 22px;
    height: 22px;
}
.opc-tour-done-dialog h3 {
    color: #fff;
    font-weight: 700;
    font-size: 1.15rem;
}
.opc-tour-done-dialog p {
    color: rgba(255, 255, 255, .78);
    font-size: .87rem;
    line-height: 1.45;
}
.opc-tour-done-dialog .btn-primary {
    min-width: 8rem;
}

/* Guide button — relaunches the tour. Round, icon-only, pinned to the
   bottom-left corner of .opc-profile-card (which supplies the
   position:relative context) rather than sitting in the page-header action
   row, where it crowded Pets/Rewards/Book into a 4-wide bar on mobile.
   rem-sized fixed-diameter icon button — nothing here needs to reflow, so
   this is exempt from the "no position:absolute + px" rule: that rule
   guards against fixed px content that can't shrink for narrow viewports. */
.opc-guide-fab {
    position: absolute;
    left: .85rem;
    bottom: .85rem;
    width: 2.75rem;
    height: 2.75rem;
    min-width: 2.75rem;
    min-height: 2.75rem;
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    border-radius: var(--opc-shape);
    background: var(--opc-primary);
    color: var(--opc-primary-contrast);
    box-shadow: var(--opc-shadow-lifted);
    cursor: pointer;
    z-index: 2;
}
.opc-guide-fab svg {
    width: 1.15rem;
    height: 1.15rem;
}
.opc-guide-fab:hover,
.opc-guide-fab:focus-visible {
    background: color-mix(in srgb, var(--opc-primary) 85%, black);
}

/* Manager Portal (Phase 1) — instant mode-switch pill, pinned to the profile
   banner card's bottom-right corner (mirrors .opc-guide-fab's bottom-left
   placement so the two never collide). Own small POST form — see
   Site\Controller\ManagerController::toggleMode() and the template markup in
   big_goose/html/com_users/profile/default.php. Tokens only, dark-mode-safe
   automatically. */
.opc-portal-toggle {
    position: absolute;
    right: .85rem;
    bottom: .85rem;
    z-index: 2;
    display: flex;
    align-items: center;
    gap: .4rem;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-shape);
    padding: .3rem .3rem .3rem .75rem;
    box-shadow: var(--opc-shadow-lifted);
}
.opc-portal-toggle-label {
    font-size: .75rem;
    font-weight: 600;
    color: var(--opc-text-soft);
    white-space: nowrap;
}
/* 3.4x: colour is now a status indicator for which mode you're CURRENTLY
   in — --opc-primary while in manager mode, --opc-accent while in client
   mode — not a flat primary fill regardless of state. is-manager-mode/
   is-client-mode are set server-side from $opcIsManagerModeNow (see
   big_goose/html/com_users/profile/default.php), same variable the label
   text and the button's own destination text already key off. */
.opc-portal-toggle-btn {
    border: none;
    border-radius: var(--opc-shape);
    color: var(--opc-primary-contrast);
    font-size: .72rem;
    font-weight: 600;
    padding: .35rem .75rem;
    cursor: pointer;
    white-space: nowrap;
}
.opc-portal-toggle-btn.is-manager-mode {
    background: var(--opc-primary);
}
.opc-portal-toggle-btn.is-manager-mode:hover,
.opc-portal-toggle-btn.is-manager-mode:focus-visible {
    background: color-mix(in srgb, var(--opc-primary) 85%, black);
}
.opc-portal-toggle-btn.is-client-mode {
    background: var(--opc-accent);
}
.opc-portal-toggle-btn.is-client-mode:hover,
.opc-portal-toggle-btn.is-client-mode:focus-visible {
    background: color-mix(in srgb, var(--opc-accent) 85%, black);
}
@media (max-width: 420px) {
    .opc-portal-toggle-label { display: none; }
}

/* Manager Portal — thin, very-slow-spinning two-colour screen-edge ring,
   shown site-wide only while a manager is in manager mode (see OpcTheme::
   buildManagerModeIndicatorHtml()). Modelled on ServiceNow's own
   impersonation-mode border: unmistakable, but deliberately quiet — thin
   ring, slow spin, minimal glow, so it reads as ambient status rather than
   a distraction. Colours are the Business's own Branding Primary/Accent,
   50/50 split via conic-gradient — already brand-configurable with zero new
   plumbing, since --opc-primary/--opc-accent are BrandingHelper's own :root
   overrides.

   The "ring, not a filled border" shape is the standard dual-layer CSS mask
   trick: padding sets the ring's thickness, and mask-composite:exclude XORs
   a content-box mask against a border-box one, leaving only the padding
   area painted. The spinning gradient itself lives on ::before, oversized
   to 200vmax and centred with margin (not transform) so `transform` stays
   free for the rotation keyframe alone — combining translate+rotate in one
   keyframe is unnecessary complexity this doesn't need. */
.opc-manager-mode-ring {
    position: fixed;
    inset: 0;
    z-index: 100000;
    pointer-events: none;
    padding: 4px;
    /* Rounds BOTH the outer edge and the inner cutout — mask-clip's content-box/
       border-box keywords follow the element's actual (rounded) box shape, the
       same way background-clip already does, so one border-radius here is
       enough for both mask layers; no separate rounding needed on ::before. */
    border-radius: var(--opc-radius-lg);
    -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    overflow: hidden;
    /* No glow — tried as a separate unmasked layer (.opc-manager-mode-glow)
       and removed again by request. Just the ring itself. */
}
/* Mobile only (this project's own actual mobile/desktop line, matching
   .opc-mobilebar's own breakpoint — NOT Bootstrap's generic 768px "md") —
   top-left/top-right go sharp, bottom two corners stay rounded. Order is
   top-left, top-right, bottom-right, bottom-left. */
@media (max-width: 1199.98px) {
    .opc-manager-mode-ring {
        padding: 3px;
        border-radius: 0 0 var(--opc-radius-lg) var(--opc-radius-lg);
    }
}
.opc-manager-mode-ring::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: 140vmax;
    height: 140vmax;
    margin-top: -70vmax;
    margin-left: -70vmax;
    /* Promotes this layer to the compositor so the 5s rotate doesn't force a
       full software repaint of a viewport-covering masked layer every frame
       — mask-composite generally blocks cheap GPU promotion on its own, and
       this ran on EVERY page for the whole manager-mode session, which read
       as site-wide sluggishness (reported live). 140vmax (70vmax radius)
       still clears the ~60vmax worst-case corner-to-center distance with
       margin, at roughly half the rasterized area of the old 200vmax. */
    will-change: transform;
    /* Smoothly blended, not a hard 50/50 split — explicit stops at 0%/50%/100%
       give an even, symmetric blend: primary -> accent across the first
       half-turn, then accent back to primary across the second. The one
       hard "seam" (where it wraps 100% back to 0%) is placed at 45deg
       (the top-right corner) rather than 0deg (dead-centre top) — a seam
       sitting in a corner reads far less noticeably than one bisecting a
       straight edge. */
    background: conic-gradient(from 45deg, var(--opc-primary) 0%, var(--opc-accent) 50%, var(--opc-primary) 100%);
    animation: opc-manager-ring-spin 5s linear infinite;
}
@keyframes opc-manager-ring-spin {
    to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
    .opc-manager-mode-ring::before {
        animation: none;
    }
}
/* Same conic-gradient spin as .opc-manager-mode-ring above, scaled down to a
   circle around the profile avatar (~4.5rem, see .opc-profile-avatar-img)
   instead of framing the viewport edge. .opc-profile-avatar-wrap is already
   position:relative, so this just needs to be its positioned child — no
   z-index needed, the mask keeps the centre transparent so document order
   alone puts the avatar photo/initial on top regardless of paint order. */
.opc-manager-mode-avatar-ring {
    position: absolute;
    inset: -4px;
    border-radius: 50%;
    pointer-events: none;
    padding: 3px;
    -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    overflow: hidden;
}
.opc-manager-mode-avatar-ring::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: 200%;
    height: 200%;
    margin-top: -100%;
    margin-left: -100%;
    background: conic-gradient(from 45deg, var(--opc-primary) 0%, var(--opc-accent) 50%, var(--opc-primary) 100%);
    animation: opc-manager-ring-spin 5s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
    .opc-manager-mode-avatar-ring::before {
        animation: none;
    }
}

/* Checkout failure reasons on the cart page (Cart\HtmlView::$checkoutErrors).
   NOT an .alert-* box on purpose: those are hidden platform-wide, which is how a
   refused checkout came to present as a dead Pay button with no explanation. Built
   from tokens only, so it themes with the rest of the site in dark mode. */
.opc-checkout-error {
    border: 1px solid var(--opc-danger);
    border-left-width: 4px;
    border-radius: var(--opc-radius, .5rem);
    background: color-mix(in srgb, var(--opc-danger) 8%, var(--opc-surface));
    color: var(--opc-body-color, inherit);
    padding: .75rem 1rem;
}
.opc-checkout-error ul {
    padding-left: 1.1rem;
}

/* "Next available" status line on the booking calendar. Plain inline text rather than an
   .alert-* box, which is hidden platform-wide. Tokens only, so it themes in dark mode. */
/* Boxed to match the btn-outline-secondary/btn-outline-primary controls either
   side of it (same surface/border tokens, same btn-sm-ish height) instead of
   sitting as bare text between two buttons. Fixed-width so "Aug 2026" vs
   "Sep 2026" never nudges the prev/next buttons — gap-2 already fixes button
   spacing, this fixes the label's own footprint and centers its text in it. */
#hcc-month-label {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 6.5rem;
    min-height: var(--opc-btn-height);
    padding: 0 var(--opc-btn-padding-x);
    border: 1px solid var(--opc-border);
    border-radius: var(--bs-border-radius, .375rem);
    background: var(--opc-surface);
    font-size: var(--opc-btn-font-size);
    text-align: center;
}

/* Fixed footer, pinned to the browser viewport bottom on every page (3.34.49
   — explicit product decision, overriding the earlier "never fixed" stance:
   a fixed footer is expected UX and the mobile-bar conflict noted below is
   handled by stacking, not by avoiding position:fixed altogether).

   Superseded: the previous "sticky-when-short" approach kept the footer in
   normal document flow and used a flex-grown `.site-grid` to push it to the
   viewport bottom only on short pages. That flex-grow was ALSO the root
   cause of a separate bug — .site-grid is a CSS Grid with 8 auto-sized rows
   (banner/top-a/top-b/comp/...), and Grid's default align-content:normal
   stretches all of them to fill flex-grown height equally, shoving `comp`
   (the actual page content, row 4) down on short pages. Removing the flex
   machinery here fixes that at the root — no band-aid needed on .site-grid.

   Height is fixed (not padding-driven) so the space reserved below the page
   content (`body`'s padding-bottom, below) can be calculated exactly instead
   of guessed. Single line of text — overflow:hidden, no wrap. */
:root {
    --opc-footer-height: 2.25rem;
}
.footer {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 1040; /* below .opc-mobilebar's 1050 so the bar stays on top when both render */
    height: var(--opc-footer-height);
    display: flex;
    align-items: center;
    overflow: hidden;
    /* Same gradient as the header (.container-header, big_goose's own
       template.css ~L13701) rather than --opc-surface — a plain white bar
       read as visually unrelated to the rest of the chrome (reported live:
       "does not match the site header color"). Same tokens, so a per-
       instance brand colour recolours both together automatically. */
    background-color: var(--big_goose-color-primary);
    background-image: linear-gradient(135deg, var(--big_goose-color-primary) 0%, var(--big_goose-color-hover) 100%);
    /* Was a hardcoded #fff — fine only by accident (brand primary happens to
       be dark in the one instance this shipped on). A light brand colour
       would make this unreadable; var(--opc-text) is the same token every
       other themed surface uses, so it's guaranteed legible in both modes
       against --opc-surface-toned surfaces, but note this background is
       still the brand gradient, not a surface token, so a light brand colour
       can still collide with light-mode --opc-text. Flagged, not solved —
       revisit if a light-brand instance reports low contrast here. */
    color: var(--opc-text);
    /* Shy footer (3.34.49): slides fully off-screen on scroll-down, back in
       on scroll-up/at-top — see mobilenav.js for the class toggle. Transform
       (not display/visibility) so the slide is what animates, and it moves
       correctly regardless of the mobile breakpoint's `bottom` offset below
       since translateY is relative to the element's own box either way. */
    transform: translateY(0);
    transition: transform .25s ease;
}
.footer.opc-footer-hidden {
    transform: translateY(100%);
}
.footer .grid-child {
    width: 100%;
    padding-top: 0;
    padding-bottom: 0;
    color: var(--opc-text);
}
.footer a {
    color: var(--opc-text);
    text-decoration: underline;
}
body {
    padding-bottom: var(--opc-footer-height);
}

/* <=1199.98px (and the forced-mobile equivalent): stack the footer ABOVE
   the fixed Unified Navigation bottom bar instead of sitting under/behind
   it — the two are both position:fixed at the same edge, so without this
   they'd overlap. The matching body padding-bottom that reserves room for
   both the bar AND this footer lives with the bar's own rules (~L4167 and
   ~L4284), not duplicated here. */
@media (max-width: 1199.98px) {
    body:has(.opc-mobile-chrome) .footer {
        bottom: var(--opc-mobilebar-height);
    }
}
html[data-force-mobile] body:has(.opc-mobile-chrome) .footer {
    bottom: var(--opc-mobilebar-height);
}

/* Store card stock line — always occupies its row (even when empty) so every
   card's price sits on the same baseline regardless of stock state. */
.opc-store-card-stock {
    min-height: 1.5rem;
    margin-bottom: .25rem;
    line-height: 1.5rem;
}

/* ==========================================================================
   Pinned panel — generic "header/tabs stay put, content scrolls" pattern
   (3.37+). Same idea as .opc-profile-card/.opc-profile-scroll on My Profile
   (this file, ~L1968/8298) but with its own class names and its own JS
   (pinnedpanel.js) — deliberately NOT sharing Profile's implementation,
   which has extensive hard-won live-tested history (sticky feedback loops,
   scroll-lock races, flex-grow bugs, all documented inline on those rules).
   Do not merge the two without a deliberate reason; a separate module means
   a bug in one can never regress the other.

   Usage: wrap a tab-panel's (or a page's main list's) content in
   `.opc-pinned-panel` > `.opc-pinned-scroll`, add `.opc-pinned-page` to the
   page wrapper, and call `OpcPinnedPanel.init({ pageSelector, panelSelector,
   scrollSelector })` from the view (see Products/Customer/Customers). The
   page header and tabs are NOT wrapped — they stay in normal flow above the
   panel; what keeps them on screen is pinnedpanel.js's whole-page scroll
   lock (`.opc-pinned-lock` on html/body), the same reasoning as Profile's
   own `.opc-edit-lock` (this file, ~L8164-8181): with no OTHER scrollable
   ancestor, .opc-pinned-scroll is the only place left to scroll. */
.opc-pinned-panel {
    display: flex;
    flex-direction: column;
    position: sticky;
    top: .5rem;
    z-index: 5;
    max-height: calc(100vh - 1rem);
    max-height: calc(100dvh - 1rem);
    /* Pre-JS/no-JS fallback only — pinnedpanel.js sets an inline `height`
       once it runs, same reasoning as .opc-profile-card's own max-height
       fallback (this file, ~L1971). */
}
@media (max-width: 1199.98px) {
    body:has(.opc-mobile-chrome) .opc-pinned-panel {
        max-height: calc(100vh - 1rem - var(--opc-mobilebar-height, 0px) - env(safe-area-inset-bottom, 0px));
        max-height: calc(100dvh - 1rem - var(--opc-mobilebar-height, 0px) - env(safe-area-inset-bottom, 0px));
    }
}
.opc-pinned-scroll {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-gutter: stable;
}
html.opc-pinned-lock,
body.opc-pinned-lock {
    overflow: hidden;
}

/* ==========================================================================
   Assessment — numbered questionnaire read-out (3.38.0). Replaces disabled
   form controls (a select/textarea that LOOKS editable but isn't reads as a
   bug, not a record) for a past/completed round with plain numbered
   question/answer pairs — see AssessmentQuestionHelper::numberQuestions()/
   formatAnswer() (Administrator\Service), the single source both the site
   and admin fill-out templates number and format from. Mirrored in
   admin.css (fixed light palette, same class names, same token names) since
   admin's copy of the fill-out screen doesn't load this file. */
.hcc-aq-readout {
    display: flex;
    flex-direction: column;
}
.hcc-aq-row {
    padding: .65rem 0;
    border-bottom: 1px solid var(--opc-border-light);
}
.hcc-aq-row:last-child {
    border-bottom: none;
}
/* A dependent question ("9.1") gets a rail + indent so its relationship to
   its parent question reads at a glance — same idea in edit mode, applied
   to the ordinary form-field wrapper instead of a read-out row. */
.hcc-aq-child {
    margin-left: 1.25rem;
    padding-left: .85rem;
    border-left: 2px solid var(--opc-border);
}
.hcc-aq-q {
    font-weight: 600;
    color: var(--opc-text);
    margin-bottom: .3rem;
}
.hcc-aq-num {
    color: var(--opc-text-faint);
    font-variant-numeric: tabular-nums;
    margin-right: .35rem;
}
.hcc-aq-a {
    color: var(--opc-text-soft);
    white-space: pre-wrap;
}
.hcc-aq-a.hcc-aq-empty {
    font-style: italic;
    color: var(--opc-text-faint);
}

/* The `hidden` ATTRIBUTE must always win.
   Bootstrap's utilities (.d-flex, .d-block, .d-grid ...) are `display: X !important`,
   and an author rule beats the UA default `[hidden] { display: none }` regardless of
   specificity — so `el.hidden = true` on an element carrying one of them does nothing
   visually. That bit the booking modal's footer action groups: JS hid them correctly
   and .d-flex kept them on screen, so Approve/Reject and the attendance buttons showed
   on every booking at once.
   One rule here rather than another per-component patch (see .opc-lightbox-btn[hidden]
   in site.css for the same bug fixed locally before this existed). */
[hidden] {
    display: none !important;
}

/* Customer card contact lines (manager Customers list)
   ---------------------------------------------------
   Contact / emergency / vet, right-aligned opposite the Pets-count button.
   The icon trails the value rather than leading it, because the block is
   right-aligned: a leading icon on ragged-left text would sit at a different
   x on every line and read as noise. Trailing, the three icons form a clean
   vertical column against the card edge.

   Tints are by role, from the shared tokens (never literals), so both themes
   and every customer instance's brand palette follow automatically. */
.opc-customer-contacts {
    min-width: 0;
}

.opc-contact-line {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: .375rem;
    min-width: 0;
}

/* The value is the part that must give way when a card is narrow — the icon
   is the label and is worthless truncated, so it never shrinks. */
.opc-contact-line > span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.opc-contact-icon {
    flex: 0 0 auto;
    width: 14px;
    height: 14px;
}

.opc-contact-icon-phone {
    color: var(--opc-muted);
}

/* Siren, and it flashes - a slow opacity pulse rather than a colour cycle,
   so it stays legible against both themes and doesn't fight the card. Kept
   deliberately gentle: this sits in a scrollable list where a dozen cards may
   be on screen at once, and a sharp blink across all of them would be
   unusable. Honoured only when the viewer hasn't asked for less motion. */
.opc-contact-icon-emergency {
    color: var(--opc-danger);
    animation: opc-siren-flash 1.6s ease-in-out infinite;
}

@keyframes opc-siren-flash {
    0%, 100% { opacity: 1; }
    50%      { opacity: .35; }
}

@media (prefers-reduced-motion: reduce) {
    .opc-contact-icon-emergency {
        animation: none;
    }
}

.opc-contact-icon-vet {
    color: var(--opc-accent);
}

/* ======================================================================
   "ADD TO HOME SCREEN" PROMPT  (installprompt.js)
   ----------------------------------------------------------------------
   iOS Safari has no install prompt of its own and never will (Apple has
   not implemented `beforeinstallprompt`), so this banner IS the ask on
   iOS — and on Android it replaces Chrome's own mini-infobar so the
   prompt carries the Business's branding instead of the browser's.

   Anchored above the fixed bottom bar rather than to the viewport floor:
   --opc-mobilebar-height is the bar's REAL measured height, maintained by
   mobilenav.js's ResizeObserver, and already includes the bar's own
   safe-area padding. A literal offset here would drift the moment the
   bar's item count changed (the raised centre "Book" item makes it taller
   than a plain 4-item bar). When the bar isn't present the variable's
   4rem fallback is wrong in the other direction, so the safe-area inset
   is added directly for that case via the :not() rule below.
   ====================================================================== */
.opc-install-prompt {
    position: fixed;
    left: .75rem;
    right: .75rem;
    bottom: calc(var(--opc-mobilebar-height, 0px) + var(--opc-footer-height, 0px) + .75rem);
    z-index: 1060; /* above .opc-mobilebar (1050), below modals */
    display: flex;
    align-items: center;
    gap: .75rem;
    padding: .75rem .875rem;
    background: var(--opc-surface);
    border: 1px solid var(--opc-border);
    border-radius: var(--opc-radius-lg);
    box-shadow: var(--opc-shadow-lifted);

    /* Starts off-screen and non-interactive; .is-visible (added on the
       second animation frame) slides it in. Kept in the DOM either way so
       the exit transition has something to animate. */
    transform: translateY(calc(100% + 1.5rem));
    opacity: 0;
    pointer-events: none;
    transition: transform .32s cubic-bezier(.22, 1, .36, 1), opacity .32s ease;
}

/* No app chrome on the page = no bar to sit above, so the banner owns the
   bottom inset itself. */
body:not(:has(.opc-mobile-chrome)) .opc-install-prompt {
    bottom: calc(.75rem + env(safe-area-inset-bottom, 0px));
}

.opc-install-prompt.is-visible {
    transform: translateY(0);
    opacity: 1;
    pointer-events: auto;
}

@media (prefers-reduced-motion: reduce) {
    .opc-install-prompt {
        transition: opacity .2s ease;
        transform: none;
    }
}

/* Desktop: a full-width bar reads as a cookie notice. Pinned bottom-right
   at a readable width instead. */
@media (min-width: 576px) {
    .opc-install-prompt {
        left: auto;
        right: 1rem;
        width: 25rem;
    }
}

.opc-install-prompt-icon {
    flex: 0 0 auto;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: var(--opc-radius);
    object-fit: contain;
    background: var(--opc-surface-alt);
}

.opc-install-prompt-text {
    flex: 1 1 auto;
    min-width: 0; /* lets long business names ellipsis instead of forcing width */
    display: flex;
    flex-direction: column;
    gap: .125rem;
}

.opc-install-prompt-title {
    font-weight: 600;
    font-size: .9375rem;
    line-height: 1.3;
    color: var(--opc-text);
}

.opc-install-prompt-sub {
    font-size: .8125rem;
    line-height: 1.35;
    color: var(--opc-text-muted);
}

.opc-install-prompt-sub:empty {
    display: none;
}

.opc-install-prompt-steps {
    margin: .375rem 0 0;
    font-size: .8125rem;
    line-height: 1.5;
    color: var(--opc-text-muted);
}

.opc-install-prompt-steps strong {
    color: var(--opc-text);
    font-weight: 600;
    white-space: nowrap; /* "Add to Home Screen" is one label; never break it */
}

/* The iOS share glyph, sized and aligned to sit inline with the sentence
   that tells the reader to tap it. */
.opc-install-prompt-glyph {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    margin: 0 .0625rem;
    vertical-align: -.375rem;
    border-radius: var(--opc-radius-sm);
    background: var(--opc-primary-tint);
    color: var(--opc-primary);
}

.opc-install-prompt-close {
    flex: 0 0 auto;
    align-self: flex-start;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 0;
    border-radius: var(--opc-shape);
    background: transparent;
    color: var(--opc-text-faint);
    cursor: pointer;
    transition: background .18s ease, color .18s ease;
}

.opc-install-prompt-close:hover,
.opc-install-prompt-close:focus-visible {
    background: var(--opc-surface-alt);
    color: var(--opc-text);
}
