/* tryon.css - /try-on page. Flow-1 equation feel (operator chips + generating animation + a blurred
   result image) merged with the ORIGINAL EzUpload dropzones (cards 1 & 2 are untouched). App-specific
   styling (not a platform theme); colours resolve through --bs-* tokens so it adapts to light/dark. */

.to-presets { display: flex; justify-content: center; margin-bottom: 1.25rem; }
.to-presets-inner { width: 100%; max-width: 22rem; }

.to-op { flex: none; align-self: center; display: inline-flex; align-items: center; justify-content: center;
    width: 2rem; height: 2rem; border-radius: 50%; font-size: 0.85rem; color: var(--bs-secondary-color);
    background: var(--bs-secondary-bg); border: 1px solid var(--bs-border-color); }

/* Sweep-beam keyframe, shared by the live studio-track and hair/makeup generating animations below. */
@keyframes to-scany { to { top: 104%; } }

/* centred status line (scan mode): fill the box and centre both axes instead of pinning to the bottom */
.to-cap.to-cap--center { top: 0; bottom: 0; display: flex; align-items: center; justify-content: center;
    white-space: normal; font-size: 0.85rem; color: var(--bs-body-color); }

/* result area (card 3): blurred image / generating / idle - identical box shape to an EzUpload tile */
.to-rbox { position: relative; width: 100%; aspect-ratio: 3 / 4; min-height: 15rem; max-height: 60vh;
    border-radius: var(--bs-border-radius-lg); overflow: hidden; border: 1px solid var(--bs-border-color); background: var(--bs-secondary-bg);
    display: flex; align-items: center; justify-content: center; text-align: center; }
/* idle/failed click-zone styling is the shared .to-zone class, defined after .mv-card below (it must
   follow both card bases in the cascade). */

/* blur backdrop (the flow-1 fill) - shows the result's own colours behind the letterbox */
.to-rbox--fill::before { content: ""; position: absolute; inset: 0; background-image: var(--fill);
    background-size: cover; background-position: center; transform: scale(1.2); filter: blur(24px) brightness(0.55) saturate(1.25); }
.to-rbox-open { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center;
    padding: 0.5rem; border: 0; background: none; cursor: zoom-in; }
.to-rbox img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; border-radius: var(--bs-border-radius); }
/* Flat design: a crisp primary ring (0-blur "flat shadow"), no soft glow. */
.to-rbox--done { border-color: var(--bs-primary); box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.35); }
.to-pop { animation: to-pop 0.55s cubic-bezier(0.2, 1.4, 0.4, 1) both; }
@keyframes to-pop { from { transform: scale(0.7); opacity: 0; } to { transform: scale(1); opacity: 1; } }


/* status strip pinned to the bottom of a result box (video creating / created / failed, look under review).
   Rendered for a long time with no base rule at all, so it landed as an unstyled block AFTER the box and
   stole photo height; this is the "flat strip pinned to the bottom" the markup comment always described. */
.to-slot-status { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
    display: flex; align-items: center; justify-content: center; gap: 0.4rem;
    padding: 0.4rem 0.6rem; font-size: 0.8rem; line-height: 1.2;
    background: var(--bs-body-bg); color: var(--bs-body-color); border-top: 1px solid var(--bs-border-color); }
.to-slot-status--creating { color: var(--bs-primary); }
.to-slot-status--created { color: var(--bs-success); }
.to-slot-status--error { color: var(--bs-danger); }
/* a look under staff review: the result is withheld, so the strip is the whole message - fill the frame */
.to-slot-status--held { top: 0; flex-direction: column; gap: 0.5rem; padding: 1.25rem; border-top: 0;
    font-size: 0.9rem; color: var(--bs-secondary-color); }
.to-slot-status--held i { font-size: 1.5rem; }

.to-idle { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.8rem; padding: 1.25rem; }
.to-fail i { color: #e8637c; }
.to-credit { font-family: var(--bs-font-monospace); font-size: 0.72rem; color: var(--bs-secondary-color); }

.to-cap { position: absolute; bottom: 0.9rem; left: 0; right: 0; z-index: 2; text-align: center;
    font-family: var(--bs-font-monospace); font-size: 0.76rem; color: var(--bs-secondary-color);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0 1.5rem; }

/* ---- history: "Your looks" feed of kept looks (person + garment = result), newest first ----
   Same equation shape as the form, one row per saved look. Keep flies the trio down into a new row
   here (tryon.js FLIP flight), which then pops with a sparkle burst. */
/* history is grouped by the day each look was kept: a small sticky-free date heading over each day's
   rows, with generous separation between days. Flat - a hairline rule carries the divide, no panel. */
.to-daygroup + .to-daygroup { margin-top: 3.25rem; }
.to-dayhead { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase;
    color: var(--bs-secondary-color); margin: 0 0 1.5rem; padding-bottom: 0.6rem;
    border-bottom: 1px solid var(--bs-border-color); }
.to-rows { display: flex; flex-direction: column; gap: 2.75rem; }
/* the outfit-notes button carries a small dot when the look has any recorded notes (like an unread mark) */
.to-note-dot { position: absolute; top: -1px; right: -1px; width: 0.5rem; height: 0.5rem; border-radius: 50%;
    background: var(--bs-primary); border: 1px solid var(--bs-body-bg); pointer-events: none; }
/* Flat: no row card (no border/background/panel) - the whitespace between rows and the action-bar
   divider separate each look; the photos carry the row. */
.to-row { position: relative; display: grid; grid-template-columns: 1fr auto 1fr auto 1.2fr; gap: 0.7rem;
    align-items: center; justify-items: center; }
.to-row .to-op { width: 1.65rem; height: 1.65rem; font-size: 0.72rem; align-self: center; }
.to-slot { position: relative; width: 100%; aspect-ratio: 3 / 4; max-height: 42vh; border-radius: var(--bs-border-radius-lg);
    overflow: hidden; border: 1px solid var(--bs-border-color); background: var(--bs-secondary-bg);
    display: flex; align-items: center; justify-content: center; }
.to-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: 1; }
/* Blurred backdrop fills the letterbox with a darkened blow-up of the photo (matches the form cards).
   Driven by --fill:url(...); when unset, var(--fill) is invalid so no backdrop shows. */
.to-slot::before { content: ""; position: absolute; inset: 0; z-index: 0;
    background-image: var(--fill); background-size: cover; background-position: center;
    transform: scale(1.2); filter: blur(24px) brightness(0.55) saturate(1.25); }
.to-slot--result { border-color: var(--bs-primary);
    box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.35); }
/* cards: bundled example photos under an EMPTY upload card - a one-tap way to fill the card without
   hunting for an image. Flat (no depth shadows); the brand colour is the only hover affordance. */
/* Shared studio "try one of ours" strip (SampleCarousel, used on outfits/makeup/hair). A horizontal,
   scroll-snapping row of example thumbnails with chevron arrows (shown only when the row overflows,
   disabled at each end) and an optional Women | Men filter toggle. Namespaced .ss-* and loaded globally
   with tryon.css, so it works on the styling studios too. Arrow/scroll logic is in sampleStrip.js. */
.ss { margin-top: 0.75rem; }
.ss-head { display: flex; align-items: center; justify-content: center; gap: 0.6rem;
    margin-bottom: 0.4rem; flex-wrap: wrap; }
.ss-label { font-size: 0.78rem; color: var(--bs-secondary-color); }
/* (The Women | Men filter was removed - see SampleCarousel.razor. The photos communicate who an
   example suits faster than a control does, and the toggle cost a whole row of a card where vertical
   space is scarce; that space went to bigger thumbnails instead.) */

.ss-strip { position: relative; display: flex; align-items: center; justify-content: center; }
.ss-track { display: flex; gap: 0.4rem; overflow-x: auto; scroll-snap-type: x proximity; scroll-behavior: smooth;
    scrollbar-width: none; padding: 2px; }
.ss-track::-webkit-scrollbar { display: none; }
.ss-thumb { position: relative; flex: 0 0 auto; width: 3.25rem; aspect-ratio: 0.72; padding: 0; overflow: hidden;
    scroll-snap-align: start; border: 1px solid var(--bs-border-color); border-radius: var(--bs-border-radius, 10px);
    background: var(--bs-secondary-bg); cursor: pointer; transition: border-color 0.15s ease; }
/* Same treatment as the big photo frames: the whole image is fitted (never cropped), and a blurred,
   stretched copy of it fills whatever is left over - so a portrait and a square sample both read as
   complete pictures instead of tight centre-crops. --ss-fill is set per thumb by SampleCarousel. */
.ss-thumb::before { content: ""; position: absolute; inset: -15%; z-index: 0;
    background-image: var(--ss-fill); background-size: cover; background-position: center;
    filter: blur(6px) saturate(1.1); opacity: 0.55; }
.ss-thumb img { position: relative; z-index: 1; display: block; width: 100%; height: 100%; object-fit: contain; }
.ss-thumb:hover:not(:disabled) { border-color: var(--bs-primary); }
.ss-thumb:disabled { opacity: 0.45; cursor: default; }
.ss-thumb-busy { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    background: rgba(var(--bs-body-bg-rgb), 0.7); }

/* Arrows sit outside the track; hidden unless the row overflows, dimmed + inert at each end. */
.ss-arrow { display: none; flex: 0 0 auto; width: 1.6rem; height: 1.6rem; align-items: center; justify-content: center;
    padding: 0; border: 1px solid var(--bs-border-color); border-radius: var(--bs-border-radius-pill); background: var(--bs-body-bg);
    color: var(--bs-body-color); cursor: pointer; font-size: 0.7rem;
    transition: opacity 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.ss--overflow .ss-arrow { display: inline-flex; }
.ss-arrow:hover:not(.ss-arrow--disabled) { border-color: var(--bs-primary); color: var(--bs-primary); }
.ss-arrow--disabled { opacity: 0.3; cursor: default; pointer-events: none; }
.ss-arrow-prev { margin-right: 0.25rem; }
.ss-arrow-next { margin-left: 0.25rem; }

/* Admin sample-image list: a small portrait thumbnail beside each row. */
.ss-adminthumb { display: inline-flex; flex: 0 0 auto; width: 2.6rem; aspect-ratio: 0.72; overflow: hidden;
    border-radius: var(--bs-border-radius); border: 1px solid var(--bs-border-color); background: var(--bs-secondary-bg); }
.ss-adminthumb img { width: 100%; height: 100%; object-fit: cover; }

/* history: ONE simple tip (EzAlert) at the top of the feed, pointing at the per-row modelling buttons.
   No CTA of its own - the play icon under each look is the action. The wrapper owns the spacing
   (the alert's built-in bottom margin is zeroed so the gap is exact). */
/* (.to-histtip, the standing "bring your looks to life" banner above the feed, went with the banner
   itself - the bubble on the just-kept look's video button says it instead. See .kfb.) */

/* modelling-videos manager: a responsive grid of portrait tiles. Each video tile is [pose name on top]
   / [thumbnail] / [share + delete]; the last tile is the create card (or the generating spinner). The
   first frame shows via preload=metadata + a #t seek; pressing play sends the video fullscreen (tryon.js). */
.mv-grid { display: flex; flex-wrap: wrap; gap: 1.1rem;
    align-items: flex-start; justify-content: center; padding-bottom: 1.5rem; }
.mv-tile { display: flex; flex-direction: column; gap: 0.4rem; width: 12rem; min-width: 0; }
.mv-pose { font-size: 0.8rem; color: var(--bs-secondary-color); text-align: center;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mv-thumb { width: 100%; aspect-ratio: 3 / 4; border-radius: var(--bs-border-radius-lg); overflow: hidden;
    border: 1px solid var(--bs-border-color); background: var(--bs-secondary-bg); }
.mv-thumb video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
/* object-fit:cover crops the thumbnail nicely, but a video keeps it in fullscreen too and clips the
   figure - reset to contain (letterboxed, whole frame) while fullscreen. */
.mv-video:fullscreen { object-fit: contain; background: #000; }
.mv-video:-webkit-full-screen { object-fit: contain; background: #000; }
.mv-actions { display: flex; align-items: center; justify-content: center; gap: 0.4rem; }
/* create card: the SAME fixed 3/4 box as a video thumbnail, but styled like the card-3 result card -
   it hosts .to-idle (pose picker + Create + credit), .to-cap (creating) or .to-fail (failed),
   so every state matches card 3 and lines up with the video tiles. */
.mv-card { position: relative; width: 100%; aspect-ratio: 3 / 4; border-radius: var(--bs-border-radius-lg); overflow: hidden;
    border: 1px solid var(--bs-border-color); background: var(--bs-secondary-bg);
    display: flex; align-items: center; justify-content: center; text-align: center; }
.mv-card .to-idle { padding: 0.9rem; gap: 0.6rem; }

/* full-card click zone (card 3's idle + failed states, the modelling create card's failed state):
   looks and acts like an empty EzUpload dropzone - dashed border, brand highlight on hover, dimmed
   + neutral while disabled. Defined AFTER .to-rbox/.mv-card so it wins their solid 1px border.
   A zone hosting the style picker cannot BE a button (below), so it carries .is-off instead of
   :disabled - every zone rule below has to answer to both spellings. */
.to-zone { padding: 0; cursor: pointer; color: inherit; border: 2px dashed var(--bs-border-color);
    transition: border-color 0.15s ease-in-out, background-color 0.15s ease-in-out; }
.to-zone:hover:not(:disabled):not(.is-off) { border-color: var(--bs-primary); }
/* hovering ANYWHERE on the zone puts the visual action chip into its normal button hover state (the
   chip is a span, so its own :hover never fires for the rest of the card) */
.to-zone:hover:not(:disabled):not(.is-off) .btn-primary { color: var(--bs-btn-hover-color);
    background-color: var(--bs-btn-hover-bg); border-color: var(--bs-btn-hover-border-color); }
.to-zone:disabled,
.to-zone.is-off { cursor: default; opacity: 0.65; }

/* ---- a click-zone card that HOSTS a control (the style picker) --------------------------------
   Once a card contains a real control it cannot be a <button> - one cannot nest in another - so the
   whole-card click zone becomes a transparent button laid underneath, and the content on top is
   click-through. Only the picker takes its own clicks back. Used by card 3's idle result box and by
   the create card in the modelling-videos manager. */
.to-hit { position: absolute; inset: 0; z-index: 0; padding: 0; border: 0; background: transparent;
    cursor: pointer; }
.to-hit:disabled { cursor: default; }
.to-idle--hosted { width: 100%; pointer-events: none; }
.to-idle--hosted .st-wf { pointer-events: auto; }

/* ---- workflow picker (StylePicker.razor) -------------------------------------------------------
   Always-visible rows on the card, not a pill that opens a menu: the facts that decide the choice
   were behind a click, so the user chose before seeing them. Design 08t from the step 3 mockups.

   NO per-fact glyphs, on purpose. VCR renders on pure black, and a 0.65rem glyph there has almost no
   ink and no ground of its own - it reads as lint. The brand rule down the fact block does the
   grouping the icons were meant to do, at one mark per row rather than three. */
.st-wf { display: flex; flex-direction: column; gap: 0.35rem; width: 100%; max-width: 22rem;
    text-align: left; }

.st-wf-row { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.5rem;
    align-items: start; width: 100%; margin: 0; padding: 0.45rem 0.6rem;
    background: var(--bs-body-bg); border: 1px solid var(--bs-border-color);
    border-radius: var(--bs-border-radius-lg); cursor: pointer;
    transition: border-color 0.15s, background 0.15s; }
.st-wf-row:hover { border-color: var(--bs-primary); }
.st-wf-row.is-on { border-color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), 0.08); }
.st-wf-row:has(:disabled) { cursor: default; opacity: 0.55; }
.st-wf-row:has(:focus-visible) { outline: 2px solid var(--bs-primary); outline-offset: 2px; }

/* The real radio carries keyboard and assistive-tech behaviour; the ring is what is actually seen. */
.st-wf-radio { position: absolute; opacity: 0; pointer-events: none; }
.st-wf-ring { grid-row: 1; width: 0.85rem; height: 0.85rem; margin-top: 0.15rem;
    border: 2px solid var(--bs-border-color); border-radius: 50%; background: var(--bs-body-bg);
    transition: border-color 0.15s, box-shadow 0.15s; }
.st-wf-row.is-on .st-wf-ring { border-color: var(--bs-primary);
    box-shadow: inset 0 0 0 0.18rem var(--bs-primary); }

.st-wf-name { grid-row: 1; font-size: 0.82rem; line-height: 1.35; color: var(--bs-body-color);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-wf-row.is-on .st-wf-name { color: var(--bs-primary); }

/* 08t: one rule instead of three glyphs. Sits in the name's column so it lines up under the label
   rather than under the radio. */
.st-wf-facts { grid-column: 2; display: flex; flex-direction: column; gap: 0.1rem;
    padding-left: 0.5rem; border-left: 2px solid rgba(var(--bs-primary-rgb), 0.55);
    font-size: 0.7rem; line-height: 1.35; color: var(--bs-secondary-color); }
.st-wf-fact { white-space: nowrap; }

/* Facts stay STACKED at every count - one line per fact is the whole point of 08t, and running them
   inline at three or four options would have meant the design changed shape exactly when there was
   most to compare. Height is handled by scrolling instead: four stacked options exceed the card, so
   the list scrolls rather than the layout degrading. */
.st-wf { max-height: 17rem; overflow-y: auto; }

/* Room for the scrollbar so it never sits on top of a row border, and a little inset so a scrolled
   row does not touch the card edge. */
.st-wf { scrollbar-width: thin; padding-right: 0.15rem; }


/* ---- admin compare overlay: full-screen takeover (same family as the EzImageViewer lightbox),
   comparing card 1's photo with card 3's result. Always dark - it hosts photos edge to edge - so the
   captions/labels use fixed light inks rather than theme tokens. ---- */
.to-cmp { position: fixed; inset: 0; z-index: 1090; display: flex; flex-direction: column; align-items: center;
    gap: 1rem; padding: 1.25rem; background: rgba(0, 0, 0, 0.9); cursor: zoom-out;
    animation: to-cmp-fade 0.15s ease-out; }
@keyframes to-cmp-fade { from { opacity: 0; } to { opacity: 1; } }
.to-cmp-close { position: fixed; top: 1rem; right: 1.25rem; z-index: 2; }
.to-cmp:hover .ez-media-actions { opacity: 1; }
.to-cmp-toggle { display: flex; align-items: center; gap: 0.5rem; cursor: default; }
/* zoom cluster: separated from the mode buttons by a thin divider (flat, matching the dark overlay) */
.to-cmp-zoomctl { display: inline-flex; align-items: center; gap: 0.35rem;
    margin-left: 0.5rem; padding-left: 0.85rem; border-left: 1px solid rgba(255, 255, 255, 0.25); }
/* one body box for both modes: fills the remaining height and CLIPS the zoomed stage. The stage
   inside carries the zoom/pan transform (tryon.js); the labels sit on the body so they never scale. */
.to-cmp-body { position: relative; flex: 1 1 0; min-height: 0; width: 100%; overflow: hidden;
    display: flex; align-items: center; justify-content: center; cursor: default; touch-action: none; }
/* tryon.js publishes the zoom/pan as --cmp-z / --cmp-x / --cmp-y; the transform is declared here. */
.to-cmp-stage { display: flex; align-items: center; justify-content: center; height: 100%; width: 100%;
    will-change: transform;
    transform: translate(var(--cmp-x, 0px), var(--cmp-y, 0px)) scale(var(--cmp-z, 1)); }
.to-cmp-zoomed, .to-cmp-zoomed .to-cmp-wipe { cursor: grab; }
/* side by side: the two contain-fitted photos sit FLUSH against each other (no gap) - each element
   hugs its painted image (height-driven width), so the seam is real edge-to-edge contact. Only the
   outer corners are rounded, reading as one combined before/after frame. */
.to-cmp-img { height: 100%; max-width: 44vw; object-fit: contain; }
.to-cmp-img--left { border-radius: var(--bs-border-radius) 0 0 var(--bs-border-radius); }
.to-cmp-img--right { border-radius: 0 var(--bs-border-radius) var(--bs-border-radius) 0; }
/* wipe slider: both images stacked in one contain-fitted box; the photo on top is clipped from the
   right by --cmp (photo left, result right) and the divider tracks the same var. Pointer drags are
   handled in tryon.js (wipe vs pan); the invisible range input remains for keyboard arrows only.

   The box must HUG the painted photo, never be a wider frame the photo is letterboxed inside: the clip,
   the divider and the drag maths in tryon.js are all expressed against the box, so any empty band
   beside the photo is a band the divider can be dragged into (a portrait photo in the old full-width
   box left most of the travel outside the image). tryon.js measures both images and publishes the
   narrower aspect ratio as --cmp-ar, which sizes the box: height-driven normally, and width-driven via
   max-height once the width cap binds - either way an exact fit with no letterbox to escape into. */
.to-cmp-wipe { position: relative; flex: none; --cmp: 50%; --cmp-ar: 0.75; cursor: ew-resize;
    height: 100%; width: auto; aspect-ratio: var(--cmp-ar);
    max-width: min(92vw, 60rem); max-height: calc(min(92vw, 60rem) / var(--cmp-ar)); }
.to-cmp-wipe img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.to-cmp-over { clip-path: inset(0 calc(100% - var(--cmp)) 0 0); }
.to-cmp-range { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0;
    pointer-events: none; }
/* Flat design: solid 2px divider + a solid brand grip chip, no glow/shadow. pointer-events off so the
   range input beneath receives every drag. */
.to-cmp-bar { position: absolute; top: 0; bottom: 0; left: var(--cmp); width: 2px; margin-left: -1px;
    background: var(--bs-primary); pointer-events: none; }
.to-cmp-grip { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
    width: 2.2rem; height: 2.2rem; border-radius: 50%; background: var(--bs-primary); color: #fff;
    display: flex; align-items: center; justify-content: center; font-size: 0.85rem; }
/* keyboard focus on the invisible range shows as a ring on the grip chip (flat 0-blur ring) */
.to-cmp-range:focus-visible ~ .to-cmp-bar .to-cmp-grip { box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.85); }
/* labels: pinned to the body either side of the centre seam (outside the stage, so zooming the
   photos never scales them) - "Your photo" always left, "Result" always right in both modes */
.to-cmp-tag { position: absolute; bottom: 0.6rem; z-index: 1; padding: 0.2rem 0.55rem; border-radius: var(--bs-border-radius);
    font-size: 0.72rem; font-weight: 600; color: #fff; background: rgba(0, 0, 0, 0.55); pointer-events: none; }
.to-cmp-tag--left { right: calc(50% + 0.6rem); }
.to-cmp-tag--right { left: calc(50% + 0.6rem); }

/* Bootstrap < md: the equation stacks to one card per row - each card fills the page width (capped
   so a landscape phone doesn't get a giant portrait card) and centres, with the operator chips
   rotated to read downward. The history rows stack identically: one image per row, same width cap,
   centred, with each image immediately followed by its own action bar and the +/-> chips kept
   between the image groups so each look still reads as photo + outfit = result. */
@media (max-width: 767.98px) {
    .to-op i { transform: rotate(90deg); }
    /* .to-row is a 2-row grid on desktop (images on row 1, per-image action bars on row 2). At phone
       width switch it to a single-column flex stack and use `order` to interleave the fixed-order
       children into image -> its bar -> operator groups. Child order in the DOM is stable:
       1 person img, 2 (+) chip, 3 outfit img, 4 (->) chip, 5 result img, 6 person bar (--a),
       7 outfit bar (--b), 8 result bar. The delete-confirmation modal reuses .to-row with only the
       first five (no bars), which the same rules stack correctly. */
    .to-row { display: flex; flex-direction: column; align-items: center; }
    .to-row .to-slot { width: 100%; max-width: 26rem; }
    .to-row > :nth-child(1) { order: 1; }  /* person image */
    .to-row > :nth-child(6) { order: 2; }  /* person action bar (--a) */
    .to-row > :nth-child(2) { order: 3; }  /* + chip */
    .to-row > :nth-child(3) { order: 4; }  /* outfit image */
    .to-row > :nth-child(7) { order: 5; }  /* outfit action bar (--b) */
    .to-row > :nth-child(4) { order: 6; }  /* -> chip */
    .to-row > :nth-child(5) { order: 7; }  /* result image */
    .to-row > :nth-child(8) { order: 8; }  /* result action bar */
    /* .mv-grid descendant selector (not bare .mv-tile): the Styling module ships its own
       styling.css that also defines a plain `.mv-tile { width: 12rem }` and loads after this
       file, so a bare `.mv-tile` override here loses the cross-sheet tie. The extra class wins
       it (and gives the Styling modelling modal the same full-width tiles on phones). */
    .mv-grid .mv-tile { width: 100%; max-width: 26rem; }
}
@media (prefers-reduced-motion: reduce) {
    .mv-card--scan::after { display: none; }
    .to-slot-status { animation: none; }
    .to-cmp { animation: none; }
}

/* =====================================================================================
   KEEP CELEBRATION (star shower) - the effect that erupts on a look as it lands in the
   feed; the trio-flies-down flight itself is separate and always runs. Rendered by the
   shared TryOnKeepFx component. Particles carry --dx/--dy (px), --i (index). Overlay
   fills the row; pointer-events off.
   ===================================================================================== */
/* A flight TARGET image, hidden until the ghost lands on it. visibility, not display: the element must
   still lay out, because the flight measures the slot it is going to land in. Only the image is
   hidden - the slot's blurred backdrop stays on, so the row never reads as a gap. */
.to-await { visibility: hidden; }

.to-keepfx { position: absolute; inset: 0; z-index: 30; pointer-events: none; }
.kf-p { position: absolute; width: 9px; height: 9px; border-radius: 50%; }
/* shooting stars streak with trails */
.to-keepfx--star-shower .kf-p { width: 3px; height: 3px; border-radius: 50%; opacity: 0; background: #fff;
    box-shadow: 0 0 6px 2px rgba(255, 255, 255, 0.9); animation: kf-star 1s ease-in forwards; animation-delay: calc(var(--i) * 0.06s); }
.to-keepfx--star-shower .kf-p::after { content: ""; position: absolute; left: 2px; top: 0.5px; width: 34px; height: 2px;
    transform-origin: left center; transform: rotate(150deg); background: linear-gradient(to right, #fff, transparent); }
@keyframes kf-star { 0% { transform: translate(0, 0); opacity: 0; } 15% { opacity: 1; } 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; } }


/* honour reduced-motion: no keep celebration (the caption and the flight still read fine) */
@media (prefers-reduced-motion: reduce) {
    .to-keepfx *, .to-keepfx *::before, .to-keepfx *::after {
        animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
    .to-keepfx { display: none; }
}

/* ===== public share page (/s/{token}) - .sp-* ==================================================
   Anonymous, public-facing gallery / poll page rendered inside PublicShareLayout. One normalised
   structure for every state: hero header (.sp-head), framed look tiles (.sp-tile, same blurred
   self-fill letterbox as .to-rbox--fill), poll option cards, ranked results, and the about-us
   pitch banner (.sp-promo). Flat design: crisp primary rings, no depth shadows. */

.sp-head { max-width: 36rem; margin: 0 auto 2rem; }
.sp-eyebrow { display: inline-flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
    padding: 0.25rem 0.75rem; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.12em;
    text-transform: uppercase; color: var(--bs-primary); border-radius: var(--bs-border-radius-pill);
    background: rgba(var(--bs-primary-rgb), 0.1); border: 1px solid rgba(var(--bs-primary-rgb), 0.35); }
.sp-title { margin-bottom: 0.25rem; }
.sp-lede { color: var(--bs-secondary-color); margin: 0; }
.sp-alert { max-width: 36rem; margin-left: auto; margin-right: auto; }

/* look tile: fixed portrait frame; the image is contained (never cropped) over a blurred copy of
   itself so odd aspect ratios still fill the frame edge-to-edge - the established try-on look. */
.sp-tile { position: relative; display: block; width: 100%; aspect-ratio: 3 / 4; margin: 0;
    overflow: hidden; border-radius: var(--bs-border-radius-lg); border: 1px solid var(--bs-border-color);
    background: var(--bs-secondary-bg); }
.sp-tile::before { content: ""; position: absolute; inset: 0; background-image: var(--fill);
    background-size: cover; background-position: center; transform: scale(1.2);
    filter: blur(24px) brightness(0.55) saturate(1.25); }
.sp-tile img { position: absolute; inset: 0; margin: auto; max-width: 100%; max-height: 100%;
    width: auto; height: auto; object-fit: contain; }

/* poll option card: the real radio stays in the DOM (focusable, native required-validation) but is
   visually hidden; the card carries hover / checked / focus state via :has(). */
.sp-option { position: relative; display: block; cursor: pointer; }
.sp-option-radio { position: absolute; left: 50%; bottom: 0.5rem; width: 1px; height: 1px; opacity: 0; }
.sp-option .sp-tile { transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; }
.sp-option:hover .sp-tile { border-color: var(--bs-primary); transform: translateY(-2px); }
.sp-option-pick { display: flex; align-items: center; justify-content: center; gap: 0.5rem;
    margin-top: 0.5rem; padding: 0.25rem 0.5rem; font-size: 0.85rem; font-weight: 500;
    color: var(--bs-secondary-color); border: 1px solid var(--bs-border-color); border-radius: var(--bs-border-radius-pill);
    transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; }
.sp-option:has(.sp-option-radio:checked) .sp-tile { border-color: var(--bs-primary);
    box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.35); }
.sp-option:has(.sp-option-radio:checked) .sp-option-pick { color: #fff;
    background: var(--bs-primary); border-color: var(--bs-primary); }
.sp-option:has(.sp-option-radio:focus-visible) .sp-tile { outline: 2px solid var(--bs-primary); outline-offset: 2px; }
.sp-vote-btn { min-width: 10rem; }

/* poll results: ranked tiles with a share-of-votes bar; the leader gets the flat primary ring */
.sp-result--winner .sp-tile { border-color: var(--bs-primary);
    box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.35); }
.sp-result-meta { margin-top: 0.5rem; }
.sp-result-bar { height: 0.5rem; }
.sp-result-votes { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.25rem;
    font-size: 0.85rem; color: var(--bs-secondary-color); }
.sp-result-pct { font-weight: 600; color: var(--bs-body-color); }
.sp-flag { position: absolute; top: 0.5rem; z-index: 1; display: inline-flex; align-items: center;
    gap: 0.5rem; padding: 0.25rem 0.5rem; font-size: 0.72rem; font-weight: 600; border-radius: var(--bs-border-radius-pill);
    background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); color: var(--bs-body-color); }
.sp-flag--winner { left: 0.5rem; border-color: var(--bs-primary); color: var(--bs-primary); }
.sp-flag--mine { right: 0.5rem; }

/* unavailable / expired link */
.sp-empty { max-width: 26rem; margin: 2rem auto 0; }
.sp-empty-icon { display: flex; align-items: center; justify-content: center; width: 4rem; height: 4rem;
    margin: 0 auto 1rem; font-size: 1.5rem; border-radius: 50%; color: var(--bs-secondary-color);
    background: var(--bs-secondary-bg); border: 1px solid var(--bs-border-color); }

/* about-us pitch banner: flat primary-tinted panel with the CTA back to the app */
.sp-promo { max-width: 36rem; margin: 3rem auto 0; padding: 2rem 1.5rem;
    border: 1px solid rgba(var(--bs-primary-rgb), 0.35); border-radius: var(--bs-border-radius-xl);
    background: rgba(var(--bs-primary-rgb), 0.06); }
.sp-promo-icon { display: flex; align-items: center; justify-content: center; width: 3rem; height: 3rem;
    margin: 0 auto 1rem; font-size: 1.25rem; border-radius: 50%; background: var(--bs-primary); color: #fff; }
.sp-promo-title { margin-bottom: 0.5rem; }
.sp-promo-pitch { color: var(--bs-secondary-color); margin-bottom: 1.5rem; }
.sp-promo-cta i { margin-right: 0.25rem; }
/* The free-start offer, on a quiet line under the CTA. Rendered only when the admin's promo copy
   has not placed the {{FreeOffer}} token itself, so the offer is stated exactly once. */
.sp-promo-offer { display: flex; align-items: center; justify-content: center; gap: 0.375rem;
    margin: 0.75rem 0 0; font-size: 0.875rem; color: var(--bs-secondary-color); }
.sp-promo-offer i { color: var(--bs-primary); }

/* ---- studio mobile-economy pass (2026-07) ---------------------------------------------------- */
/* The one guidance line, under the card-1 heading where it is actionable. */
.to-cardhint { color: var(--bs-secondary-color); font-size: 0.8125rem; text-align: center; margin: 0 0 0.5rem; }
/* Mobile sticky generate: floats the swap action above the bottom tab bar once both photos are in
   (the stacked layout puts card 3 several screens down). Flat by design - no shadow. */
.to-sticky {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(3.75rem + env(safe-area-inset-bottom));
    display: flex;
    justify-content: center;
    padding: 0 1rem;
    z-index: 1045;
    pointer-events: none;
}
.to-sticky-btn { pointer-events: auto; display: inline-flex; align-items: center; gap: 0.5rem; }
.to-sticky-cost { font-size: 0.75rem; font-weight: 500; opacity: 0.85; }

/* ============================================================================================
   Studio card track (2026-07) - the responsive three-card studio, shared VERBATIM by all three
   studios (/outfits, /makeup, /hair) and by every kept look on /looks.
   ONE track, three modes:
     below md   focus - one full-width card at a time, swipe or arrows
     md to lg   peek  - the current card centred, neighbours peeking and dimmed
     lg and up  row   - all three cards side by side, no scrolling, no arrows
   The photo cap (--st-cap) is MEASURED AT RUNTIME by studio.js (real chrome above the track, the
   card's own header/bar, the bottom tab bar) - the rem values here are only the no-JS fallback,
   because a fixed budget under-measures tall chrome (MAUI WebView header, safe-area insets). The
   card WIDTH derives from the cap, so on short screens the whole card shrinks rather than clipping.
   The 3/4 photo frame is the common phone-camera aspect ratio, which is why every studio uses it.
   ============================================================================================ */

.st-studio {
    --st-cap: max(12rem, calc(100svh - 22.5rem));
    --st-pull: 0px;
    /* Single source of truth for the action bar's height: the CSS reserves it and studio.js budgets
       it into the photo cap, so an empty card (whose bar collapses) never resizes the photo. */
    --st-barh: 48px;
    /* Radius scale, largest on the outside: card > inset elements > controls (the icon buttons bring
       their own 8px). Inner values come from the platform --bs-border-radius token rather than being invented
       per element. Anything OPAQUE that fills the photo area edge to edge gets no radius at all - the
       card already clips it, and rounding it again just notches card background into the photo. */
    --st-radius-card: var(--bs-border-radius-xl);
    --st-radius-inner: var(--bs-border-radius, 10px);
    --st-avail: calc(100vw - 2.5rem);
    --st-w: min(var(--st-avail), calc(var(--st-cap) * 0.75));
    /* Both measured by studio.js and published as custom properties (it writes no style declarations):
       --st-band is the height of the band the card centres in, --st-overpull the residual overflow
       under the fixed tab bar that has to be absorbed. Both are 0 at lg+, where nothing is pulled. */
    --st-band: 0px;
    --st-overpull: 0px;
    margin-top: calc(var(--st-pull) * -1);
    margin-bottom: calc(var(--st-overpull) * -1);
}
@media (min-width: 768px) and (max-width: 991.98px) {
    .st-studio { --st-w: min(calc(var(--st-avail) * 0.72), calc(var(--st-cap) * 0.75)); }
}
@media (min-width: 1200px) {
    .st-studio { --st-cap: max(12rem, calc(100svh - 19rem)); }
}

/* Below xl the active bottom tab already names the page, so the page heading is pure chrome -
   hide it and give the space to the card. :has() scopes this to the studio page only (tryon.css
   is loaded in the global head, so an unscoped rule would strip every page's heading). */
@media (max-width: 1199.98px) {
    body:has(.st-studio) .page-header .page-title { display: none; }
}

/* studio.js gives this the exact height of the band between the header's ink and the tab bar (below
   lg only), so the card centres in it - the leftover splits evenly above and below whether the cap
   or the viewport width is the binding constraint. */
.st-wrap { position: relative; display: flex; flex-direction: column; justify-content: center;
    min-height: var(--st-band, 0px); }

/* Do not show the card until studio.js has measured it. --st-pull, --st-band and --st-cap all start at
   the fallbacks declared on .st-studio and are then replaced with measured pixel values a frame later,
   so the unmeasured card sits in a different PLACE and at a different SIZE to the one the user is
   meant to see. Painting it first and correcting it afterwards is a visible jump on every load - and
   it happens twice, because the circuit taking over re-renders the card and studio.js re-fits it.
   Hiding that first frame costs nothing: the reveal is the same frame the measured layout lands on.

   The animation is a dead-man's switch, not decoration. If studio.js never runs (module blocked, a JS
   error, an old WebView), nothing would ever add .st-ready - so after 400ms the card reveals itself
   anyway and falls back to the rem-based sizes in .st-studio. The studio can never end up permanently
   invisible because of this rule. */
.st-studio:not(.st-ready) .st-wrap {
    visibility: hidden;
    animation: st-reveal-unmeasured 0s linear 0.4s forwards;
}
@keyframes st-reveal-unmeasured { to { visibility: visible; } }
/* Card width comes from the viewport, NOT container percentages: a %-based width used in both
   grid-auto-columns and the centring padding resolves against two different boxes (the padding
   shrinks the grid content box) and compounds. */
.st-track {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: var(--st-w);
    /* Cards differ in height (an empty card collapses its action bar), so centre them on each other
       rather than letting the grid stretch the short one and re-open a dead band inside it. */
    align-items: center;
    gap: 0.65rem;
    padding-inline: max(0rem, calc((100% - var(--st-w)) / 2));
    scroll-padding-inline: max(0rem, calc((100% - var(--st-w)) / 2));
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
}
.st-track::-webkit-scrollbar { display: none; }
.st-track > * { scroll-snap-align: center; }
@media (min-width: 992px) {
    .st-track {
        grid-auto-flow: row;
        grid-template-columns: repeat(3, minmax(0, calc(var(--st-cap) * 0.75)));
        justify-content: center;
        gap: 0.9rem;
        overflow: visible;
        scroll-snap-type: none;
        padding-inline: 0;
    }
}

/* One-colour slab: the card header, photo frame, and action bar all share a single surface.
   body-bg is the raised-surface token in BOTH themes (white on the light lavender wash; #141416 on
   the dark wash) - tertiary/secondary melt into the page. The resting border stays transparent (but
   present, so the active ring never shifts layout); only the active card draws its brand outline. */
.st-card {
    display: flex;
    flex-direction: column;
    min-width: 0;
    border: 1px solid transparent;
    border-radius: var(--st-radius-card);
    overflow: hidden;
    background: var(--bs-body-bg);
}
@media (max-width: 991.98px) {
    .st-card {
        opacity: 0.55;
        transform: scale(0.975);
        transition: opacity 0.25s ease, transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
    }
    .st-card.is-active {
        opacity: 1;
        transform: none;
        border-color: var(--bs-primary);
        box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.14);
    }
    /* Until studio.js has marked the snapped card, show every card at full strength - otherwise the
       first paint (and the frame after any Blazor re-render that rewrites a card's class) flashes
       all three dimmed. */
    .st-studio:not(.st-ready) .st-card { opacity: 1; transform: none; }
}
@media (min-width: 992px) {
    /* Desktop needs a resting outline: nothing is dimmed here and no card carries the brand ring by
       default, so without one the three cards dissolve into the page. The current card still lights
       up - the old equation layout highlighted it at every width, and it is just as useful here as a
       "what to do next" cue. */
    .st-card { border-color: var(--bs-border-color); }
    .st-card.is-active {
        border-color: var(--bs-primary);
        box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.14);
    }
}

/* Card strip: three slim segments above the track, one per card - the story-bar pattern from the
   design mocks, promoted out of the cards. The cards themselves carry NO heading now: the photos say
   what they are, and the strip says where you are in making a look. It reads left to right like
   progress, which a per-card label never could, and it costs one 30px row for all three cards
   instead of a row inside each card. */
/* NOTE the class names: styling.css (also loaded globally) owns the .st-picker / .st-tile / .st-swatch
   family for the makeup + hair style picker. Keep every rule in this block .st-progress-* / .st-card /
   .st-photo / .st-bar so the two stylesheets never collide - a bare .st-card here once silently
   restyled the styling studios' own headings. */
.st-progress {
    --st-progress-gap: 0.4rem;
    position: relative;
    display: flex;
    gap: var(--st-progress-gap);
    margin: 0 0 0.55rem;
    padding: 0;
}
/* One lit bar that SLIDES between the three faded tracks, rather than three bars switching on and
   off - the movement itself is what says "you moved". studio.js owns data-card (it follows the card
   actually on screen, so swiping, the arrows and tapping a label all drive it). */
.st-progress-slider {
    position: absolute;
    top: 0;
    left: 0;
    height: 3px;
    width: calc((100% - var(--st-progress-gap) * 2) / 3);
    border-radius: 3px;
    background: var(--bs-primary);
    box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.14);
    transform: translateX(0);
    transition: transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
    pointer-events: none;
}
/* translateX is a % of the SLIDER's own width, which is exactly one segment - so each card is one
   segment plus one gap further along. */
.st-progress[data-card="2"] .st-progress-slider {
    transform: translateX(calc(100% + var(--st-progress-gap)));
}
.st-progress[data-card="3"] .st-progress-slider {
    transform: translateX(calc(200% + var(--st-progress-gap) * 2));
}
@media (prefers-reduced-motion: reduce) {
    .st-progress-slider { transition: none; }
}
.st-progress-card {
    flex: 1 1 0;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    padding: 0;
    margin: 0;
    border: 0;
    background: none;
    cursor: pointer;
}
.st-progress-bar {
    display: block;
    height: 3px;
    border-radius: 3px;
    /* Dull = the brand colour at low alpha, not a grey: one hue across the strip, so the lit segment
       reads as the same bar turned up rather than a different thing. A solid dark grey vanished
       against the page. */
    background: rgba(var(--bs-primary-rgb), 0.22);
    transition: background-color 0.25s ease, box-shadow 0.25s ease;
}
.st-progress-lbl {
    display: block;
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    line-height: 1.25;
    color: var(--bs-secondary-color);
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: color 0.25s ease;
}
/* The bars themselves stay faded on EVERY card - the sliding indicator above is the only lit one.
   (A middle "completed" tint was tried first and read as a legend to decode rather than an answer
   to "where am I".) Only the label brightens with the current card. */
.st-progress-card.is-current .st-progress-lbl { color: var(--bs-body-color); font-weight: 700; }
.st-progress-card:hover .st-progress-lbl { color: var(--bs-body-color); }

/* The photo frame: a 3/4 box capped by --st-cap. Cards 1-2 stack the EzUpload tile (which grows)
   over the sample strip (natural height, gone once a photo is set); card 3 holds the result box. */
.st-photo {
    position: relative;
    display: flex;
    flex-direction: column;
    aspect-ratio: 3 / 4;
    max-height: var(--st-cap);
    overflow: hidden;
}
.st-up { flex: 1 1 auto; min-height: 0; display: flex; }
/* Empty only: the dashed zone is a control, so it sits inset from the card edges - matching the
   sample strip's inset below it - while a real photo still bleeds edge to edge. No bottom inset:
   the sample strip supplies that gap itself. */
.st-card:not([data-filled]) .st-up { padding: 0.55rem 0.55rem 0; }
/* The shared tile insets its image by 0.5rem, which framed every photo in a uniform band of the
   blurred backdrop. Here the image fills the tile, so the blur only appears where the photo's own
   shape genuinely leaves space - and its corners are the card's to clip, not the image's. */
.st-up .ez-upload-tile-link { padding: 0; }
.st-up .ez-upload-tile-img { border-radius: 0; }
.st-up .ez-upload { flex: 1; min-height: 0; display: flex; flex-direction: column; }
/* The shared tile carries its own aspect-ratio/min-height; inside the card the frame governs. */
.st-up .ez-upload-zone--tile {
    flex: 1;
    aspect-ratio: auto;
    min-height: 0;
    max-height: none;
    /* Empty: a transparent dashed zone, so it reads as an inset control and keeps its corners. */
    border-radius: var(--st-radius-inner);
}
/* --result is the outfit/styling result card; --pick is the styling studios' card 2 (the chosen style's
   catalog tile, which opens the picker). Both fill their frame with a .to-rbox instead of an upload
   tile, so they share this whole block. */
.st-photo--result .to-rbox,
.st-photo--pick .to-rbox {
    flex: 1;
    aspect-ratio: auto;
    min-height: 0;
    max-height: none;
    /* Opaque and edge to edge (the photo, the blurred fill, the generating box): the card's own
       clipping owns the corners here - rounding again would cut card background into the image. */
    border-radius: 0;
}
.st-up .ez-upload-zone--tile.ez-upload-zone--filled { border-radius: 0; }
/* Same two overrides as the upload tile above, for the result photo: the base box insets its image
   by 0.5rem and rounds it, which left card 3 framed in a rim of blurred backdrop while its two
   neighbours bled to the card edge. */
.st-photo--result .to-rbox-open,
.st-photo--pick .to-rbox-open { padding: 0; }
.st-photo--result .to-rbox img,
.st-photo--pick .to-rbox img { border-radius: 0; }
/* The idle / failed click zones are transparent dashed boxes, not filled ones - same inset and
   corners as the upload dropzone, so all three cards read alike before anything is in them. */
.st-photo--result .to-rbox.to-zone,
.st-photo--pick .to-rbox.to-zone {
    border-radius: var(--st-radius-inner);
    margin-inline: 0.55rem;
    /* The base .to-rbox is width:100%; left as-is the margins would push it past the right edge. */
    width: auto;
}
/* (Card 2 used to wait on card 1 - that lock is gone: either input can be filled first, and the
   cards only go inert while a generation runs. State still comes from Blazor as the data-filled
   ATTRIBUTE rather than a class, because studio.js owns the is-active class on the same elements
   and Blazor rewrites the whole class attribute when its own expression changes.) */

/* Sample strip inside the card: the shared SampleCarousel, tightened for the narrower frame. */
/* Top padding runs slightly larger than the bottom so the visible gaps match: the bottom one is
   measured to the card's outer edge and so picks up its 1px border. */
.st-samples { flex: 0 0 auto; padding: 0.7rem 0.55rem 0.6rem; }
.st-samples .ss { margin-top: 0; }
.st-samples .ss-head { margin-bottom: 0.35rem; }
.st-samples .ss-label { font-size: 0.68rem; text-wrap: balance; text-align: center; }
/* The removed filter row went here: thumbnails are the content a user actually has to judge (is that
   outfit worth a credit / is that the photo of me I want), and they get harder to read as the strip
   fills with the user's own saved photos rather than catalog shots. */
.st-samples .ss-thumb { width: 4.9rem; }

/* Action bar: a constant height so the cards stay aligned whatever state they are in. */
.st-bar {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 0.4rem;
    padding: 0 0.55rem;
    height: var(--st-barh);
    flex: 0 0 auto;
}
/* The bar reads as [ what you can do with this card ......... everything else ]: the card's own
   buttons centred, and an action menu pinned to the right edge. It replaced a row of five to eight
   identical grey glyph squares, where nothing looked more important than anything else and the
   actions people actually came for were invisible among the chrome. Every card in every studio, and
   every kept look in the History, now has the same shape. */
/* The visible buttons. They take the free space and wrap INSIDE this box, which is what keeps the
   menu on the first line at every width - a bar-level wrap would drop the menu below the buttons on
   a narrow phone. */
.st-bar-actions {
    display: flex;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
    gap: 0.4rem;
    flex: 1 1 auto;
    min-width: 0;
}
/* Pinned right, including on a card whose bar holds nothing else. */
.st-bar-menu { flex: 0 0 auto; margin-left: auto; }
/* Centring that accounts for the menu: without this the buttons are centred in the space LEFT OVER
   beside it, so they sit half the trigger's width off the card's true centre - visible the moment
   two cards with different bars are side by side. A bar that has a menu grows an invisible twin of
   it on the other side; the bar's own gap applies to this box as well, so both margins match. */
.st-bar:has(.st-bar-menu)::before {
    content: "";
    flex: 0 0 auto;
    width: 2rem;
}
/* An empty card has no actions. At lg+ the row keeps the space reserved so the three cards stay the
   same height; below lg it collapses entirely and the reclaimed height goes straight to the photo
   area, so the dropzone grows and the card's TOTAL height is identical whether empty or filled -
   nothing jumps when a photo lands. The cap already budgets the bar either way. */
.st-card:not([data-filled]) .st-bar { visibility: hidden; }
/* ...except card 3 while a look is rendering: there is no result yet, but the bar carries the Cancel
   button, which is the only way out of a generation that has hung. The bar's height is already
   reserved, so nothing moves when it appears. */
.st-card--result[data-generating] .st-bar { visibility: visible; }
@media (min-width: 992px) {
    /* Same intent as the mobile rule below - the sample strip sits at the card's base rather than
       above a dead reserved strip - but the height cannot be computed here: at lg+ the card's width
       comes from the grid column, which may be narrower than the cap-derived --st-w. So instead the
       row stretches every card to the tallest (the filled one) and the empty card's photo grows into
       the space its collapsed bar left behind. With no card filled yet, all three are photo-only and
       already equal, so nothing has to grow. */
    .st-track { align-items: stretch; }
    .st-card:not([data-filled]):not(.st-card--result) .st-bar { display: none; }
    .st-card:not([data-filled]):not(.st-card--result) .st-photo { flex: 1 1 auto; max-height: none; }
}
@media (max-width: 991.98px) {
    .st-card:not([data-filled]):not(.st-card--result) .st-bar { display: none; }
    .st-card:not([data-filled]):not(.st-card--result) .st-photo {
        aspect-ratio: auto;
        max-height: none;
        height: calc(min((var(--st-w) - 2px) * 4 / 3, var(--st-cap)) + var(--st-barh));
    }
}
/* Arrows (scrolling modes only), on the card's midpoint. Not the photo's: an empty card collapses
   its action bar and grows its photo to compensate, so the photo's centre differs slightly between
   empty and filled cards. One shared pair cannot track both; the card's own centre lands well inside
   the photo either way. */
.st-nav {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 5;
    width: 2.6rem;
    height: 2.6rem;
    border-radius: 50%;
    border: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.45);
    color: #fff;
    font-size: 1rem;
    backdrop-filter: blur(2px);
    transition: opacity 0.2s ease;
}
.st-nav:disabled { opacity: 0.25; }
.st-nav--prev { left: 0.4rem; }
.st-nav--next { right: 0.4rem; }
@media (min-width: 992px) { .st-nav { display: none; } }

/* Phone in landscape: short and wide (~410px tall). There is no vertical room to give a card the
   screen one at a time, but plenty of width - so all three sit side by side like the desktop row,
   and the chrome that only exists to support swiping (arrows, dimmed neighbours) goes away. Keyed
   on height + orientation so a real tablet or desktop is untouched. Placed after the max-width
   rules above so it wins on source order. */
@media (orientation: landscape) and (max-height: 32rem) {
    .st-track {
        grid-auto-flow: row;
        grid-template-columns: repeat(3, minmax(0, calc(var(--st-cap) * 0.75)));
        justify-content: center;
        align-items: stretch;
        gap: 0.6rem;
        overflow: visible;
        scroll-snap-type: none;
        padding-inline: 0;
    }
    .st-nav { display: none; }
    .st-card { opacity: 1; transform: none; border-color: var(--bs-border-color); }
    .st-card.is-active {
        border-color: var(--bs-primary);
        box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.14);
    }
    /* Same empty-card treatment as the desktop row: the collapsed bar's height goes to the photo so
       the samples reach the card's base. The explicit height the portrait rule sets is replaced by
       flex growth, since the row's card width no longer comes from --st-w. */
    .st-card:not([data-filled]):not(.st-card--result) .st-photo {
        aspect-ratio: auto;
        height: auto;
        flex: 1 1 auto;
        max-height: none;
    }
}

/* Generating spinner on card 3: sits just above the status caption (.to-cap owns bottom 0.9rem), so
   it never collides with the centred effect whichever animation the admin picked. */
.st-genspin {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 2.9rem;
    z-index: 2;
    display: flex;
    justify-content: center;
    color: var(--bs-primary);
    pointer-events: none;
}
/* Sized well above Bootstrap's default so it reads as the card's "working" signal from across the
   room, not a footnote under the caption. */
.st-genspin .spinner-border {
    width: 3rem;
    height: 3rem;
    border-width: 0.3em;
}
/* Same three-part signal (beam + caption + spinner) inside a modelling-video tile, which is a fraction
   of a studio card's size - so the spinner is scaled to the tile instead of filling it. */
.mv-card .st-genspin { bottom: 2.4rem; }
.mv-card .st-genspin .spinner-border { width: 1.9rem; height: 1.9rem; border-width: 0.22em; }

/* ============================================================================================
   Looks feed (2026-07) - /looks, the home of the kept-looks gallery (moved off the studio page).
   Every look renders the SAME three-card track as the studio: below md one card at a time,
   md to lg the neighbours peeking, lg and up a static row of three - so a saved look reads exactly
   like the studio that made it, at every width. The .st-track / .st-card / .st-photo / .st-bar /
   .st-nav / .st-progress rules above are shared VERBATIM; this block only supplies the tokens and
   the feed's own trimmings.
   The tokens live on the FEED root, not on each look, so every look shares ONE photo cap - looks.js
   measures it once per page and sets --st-cap here. (The studio declares the same set on .st-studio,
   where the cap is measured against the app header as well; the feed's header scrolls away.)
   ============================================================================================ */
.lk-feed {
    --st-cap: max(12rem, calc(100svh - 14rem));
    --st-pull: 0px;
    --st-barh: 48px;
    --st-radius-card: var(--bs-border-radius-xl);
    --st-radius-inner: var(--bs-border-radius, 10px);
    --st-avail: calc(100vw - 2.5rem);
    --st-w: min(var(--st-avail), calc(var(--st-cap) * 0.75));
}
@media (min-width: 768px) and (max-width: 991.98px) {
    .lk-feed { --st-w: min(calc(var(--st-avail) * 0.72), calc(var(--st-cap) * 0.75)); }
}
/* Same reasoning as the studio's .st-ready guard: until looks.js has marked each track's card, show
   every card at full strength so the first paint does not flash all three dimmed. */
@media (max-width: 991.98px) {
    .lk-feed:not(.st-ready) .st-card { opacity: 1; transform: none; }
}

/* The History heading, under the studio's own three steps. Outside .lk-feed so it is not affected by
   the feed's tokens; the saved count sits on the baseline beside it rather than under it. */
.lk-histhead {
    display: flex;
    align-items: baseline;
    gap: 0.75rem;
    margin: 2.5rem 0 1rem;
}
.lk-histhead h2 { margin: 0; }

/* Room above a look when it is scrolled to. The History heading and the tail of the studio's own steps
   stay in frame, so the look that just arrived reads as having ARRIVED somewhere rather than as the
   page having jumped to an unlabelled photo. */
.lk-look { scroll-margin-top: 4.5rem; }

/* The just-kept look, for the couple of seconds after Keep. This is the moment the whole move exists
   for: the look the user just made arrives at the top of their history, where the share, video and
   notes buttons are. A ring plus a settle, not a flash - the eye needs to be led there, not startled.
   Reduced motion keeps the ring and drops the movement. */
@keyframes lk-kept-in {
    from { transform: translateY(-0.75rem); opacity: 0; }
    to   { transform: none; opacity: 1; }
}
.lk-look[data-kept] {
    animation: lk-kept-in 420ms ease-out both;
}
.lk-look[data-kept] .st-photo--result .to-rbox {
    outline: 2px solid var(--bs-primary);
    outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
    .lk-look[data-kept] { animation: none; }
}

/* One photo treatment for all three cards: the .to-rbox letterbox (the whole image fitted over a
   blurred blow-up of itself) fills the card's photo frame edge to edge, exactly as the studio's
   result card does - the card already clips the corners, so the box gets no radius of its own. */
.lk-look .st-photo .to-rbox {
    flex: 1;
    aspect-ratio: auto;
    min-height: 0;
    max-height: none;
    border-radius: 0;
    border-color: transparent;
}
/* Only the result keeps the brand outline - it is the card the look is about. */
.lk-look .st-photo .to-rbox--done { border-color: var(--bs-primary); }

/* The result card's three labelled buttons (video / share / notes) are wider than the studio's
   fixed-height bar can hold on a phone, so the feed's bar grows instead of clipping them off the
   card. The wrap itself lives on .st-bar-actions, one level in, which is what keeps the action menu
   on the first line rather than pushing it under the buttons. */
.lk-bar {
    height: auto;
    min-height: var(--st-barh);
    padding-block: 0.35rem;
}

/* The video-count pill riding the modelling button: smaller than a stock Bootstrap badge, which
   overwhelms a sm icon button. */
.lk-count {
    font-size: 0.6rem;
}

/* When the look was kept. The day heading above carries the date, so this is the time alone. */
.lk-time {
    margin: 0.35rem 0 0;
    text-align: center;
    font-size: 0.75rem;
    color: var(--bs-secondary-color);
}

/* Empty feed: the you + outfit = look equation in placeholder form, centred, with the headline under
   it. Flat by design - dashed neutral tiles, the result tile carrying the same hairline brand ring the
   real result cards get, no panel and no shadow. The operator chips are the studio's own .to-op. */
.lk-empty { max-width: 26rem; margin: 0 auto; padding: 2.5rem 0 3.5rem; text-align: center; }
.lk-empty-eq { display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-bottom: 1.75rem; }
.lk-empty-tile { display: flex; align-items: center; justify-content: center; width: 3.5rem;
    aspect-ratio: 3 / 4; border-radius: var(--bs-border-radius-lg); font-size: 1.05rem; color: var(--bs-secondary-color);
    border: 1px dashed var(--bs-border-color); background: var(--bs-secondary-bg); }
.lk-empty-tile--result { color: var(--bs-primary); border-style: solid;
    border-color: rgba(var(--bs-primary-rgb), 0.5); background: rgba(var(--bs-primary-rgb), 0.06); }
.lk-empty-title { margin: 0 0 0.5rem; }
.lk-empty-text { margin: 0; color: var(--bs-secondary-color); font-size: 0.9rem; line-height: 1.6; }

/* The card strip is a NAVIGATOR here, not a progress report - so it goes away in the layouts where
   all three cards are already on screen and there is nothing to navigate. (The studio keeps it at
   every width to say which card the workflow is up to; a kept look has no workflow left.) */
@media (min-width: 992px) {
    .lk-look .st-progress { display: none; }
}
@media (orientation: landscape) and (max-height: 32rem) {
    .lk-look .st-progress { display: none; }
}

/* Generating: all three cards are scanned at once, continuously - one beam per card, same timing,
   no delay and a linear curve so there is no pause at either end and they stay in sync. (The old
   equation layout staggered them into a 1-2-3 sequence.) The beam is clipped by .st-photo's
   overflow:hidden, and on card 3 it sweeps over the spinner and the generating effect. */
.st-track.is-busy .st-photo::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: -20%;
    height: 16%;
    z-index: 4;
    pointer-events: none;
    background: linear-gradient(to bottom, transparent, rgba(var(--bs-primary-rgb), 0.35), transparent);
    border-bottom: 2px solid var(--bs-primary);
    animation: to-scany 1.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
    .st-track.is-busy .st-photo::after { animation: none; opacity: 0; }
}

/* ---- /shares: the owner's view of their public galleries and polls ----------------------
   Rows deliberately reuse the PUBLIC page's .sp-tile / .sp-result / .sp-result-bar family so the owner
   sees exactly what a visitor sees; only the surrounding chrome (header, meta, actions) is .sh-*. */
.sh-share {
    padding-block: 1.25rem;
    border-bottom: 1px solid var(--bs-border-color);
}
.sh-share:last-of-type { border-bottom: 0; }

.sh-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0.75rem;
    margin-bottom: 0.75rem;
}
.sh-ident { min-width: 0; }
.sh-title { margin-bottom: 0.25rem; }

.sh-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0;
    font-size: 0.875rem;
    color: var(--bs-secondary-color);
}

/* Wraps rather than clipping: on a phone this is a status badge, a studio badge, a date, a count and an
   expiry, which is more than one line holds. */
.sh-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
}

.sh-search { max-width: 14rem; }

/* The dynamic tally width arrives as a custom property, so no rule is ever written inline. */
.sh-bar { width: var(--pct, 0%); }

/* Divides the studio / mode / status groups in the filter strip. */
.lk-filter-sep {
    width: 1px;
    align-self: stretch;
    min-height: 1.5rem;
    background: var(--bs-border-color);
}

/* ---- /looks selection: always-visible tick + selected ring + the action bar ---------------
   The tick sits TOP-LEFT of the result frame (the bottom edge is the video status strip, and top-left is
   where every photo picker puts selection). Selected state is driven by [data-selected] on .lk-look, never
   a class on the cards - looks.js owns .is-active there. */
.lk-pick {
    position: absolute;
    top: 0.5rem;
    left: 0.5rem;
    z-index: 3;
    display: inline-flex;
    padding: 0.25rem;
    border-radius: var(--bs-border-radius);
    background: rgba(var(--bs-body-bg-rgb), 0.75);
    cursor: pointer;
}
.lk-pick .form-check-input {
    margin: 0;
    cursor: pointer;
    width: 1.25rem;
    height: 1.25rem;
}

/* The ring hangs off the LOOK, so a selected look still reads as selected at card 1 or 2, where its
   checkbox has been swiped out of view. */
.lk-look[data-selected] .st-photo--result .to-rbox {
    outline: 2px solid var(--bs-primary);
    outline-offset: 2px;
}

/* Occupies the filter row's slot, so nothing shifts when a selection starts. Sticky, because the feed is
   long; top rather than bottom, because below the sidebar breakpoint the bottom is the tab bar. */
.lk-selbar {
    position: sticky;
    top: 0;
    z-index: 5;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    padding-block: 0.5rem;
    margin-bottom: 0.75rem;
    background: var(--bs-body-bg);
}
.lk-selcount { font-weight: 600; }
.lk-selnote {
    font-size: 0.875rem;
    color: var(--bs-secondary-color);
}

/* =====================================================================================
   "Create modelling video" bubble (.kfb-*) - the nudge that points at the modelling-video
   button on the just-kept row in the History.

   A kept look is the one moment offering a modelling video is worth a nudge, and a small
   glyph fifth in a row of five is otherwise invisible. Rendered by LookHistory.razor on
   the just-kept look only, held briefly, and gone for good once dismissed.

   House rules: Bootstrap tokens only (no invented colours), the Bootstrap radius scale,
   FLAT - no drop shadows anywhere - and it has to read correctly in both themes.
   ===================================================================================== */

/* The button the bubble hangs off. Plain relative/absolute rather than CSS anchor
   positioning: the two elements sit in one stacking context, so anchoring buys nothing
   here and costs Safari and Firefox support. */
.kfb-anchor { position: relative; display: inline-flex; }

.kfb {
    position: absolute;
    bottom: calc(100% + 0.7rem);
    left: 50%;
    z-index: 6;
    display: flex;
    align-items: center;
    gap: 0.6rem;
    width: max-content;
    max-width: min(17rem, 68vw);
    padding: 0.5rem 0.6rem 0.5rem 0.75rem;
    border: 1px solid transparent;
    border-radius: var(--bs-border-radius-lg);
    font-size: 0.8125rem;
    line-height: 1.3;
    text-align: left;
    animation: kfb-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* The rise is part of the message: it comes UP out of the button it is talking about. */
@keyframes kfb-in {
    from { opacity: 0; transform: translate(-50%, 0.4rem) scale(0.96); }
    to { opacity: 1; transform: translate(-50%, 0) scale(1); }
}
.kfb-text { flex: 1 1 auto; min-width: 0; }
.kfb-title { display: block; font-weight: 600; }
/* btn-close is a 1em square carrying its own padding; inside a bubble it needs neither. */
.kfb .btn-close { flex: 0 0 auto; padding: 0.25rem; margin: -0.15rem -0.1rem -0.15rem 0; font-size: 0.6rem; }

/* The tail: a rotated square inheriting the bubble's own fill and border. */
.kfb-tail {
    position: absolute;
    top: 100%;
    left: 50%;
    width: 0.62rem;
    height: 0.62rem;
    margin: -0.33rem 0 0 -0.31rem;
    transform: rotate(45deg);
    border-right: 1px solid transparent;
    border-bottom: 1px solid transparent;
    background: inherit;
    border-color: inherit;
}

/* Brand: solid primary. The loudest, and the one that reads at a glance. */
.kfb--1 { background: var(--bs-primary); border-color: var(--bs-primary); color: #fff; }

@media (prefers-reduced-motion: reduce) {
    .kfb { animation: none; }
}
