/**
 * Outfit Studio — the full-screen "build a look and wear it" surface.
 *
 * Deliberately NOT themed like the rest of the dashboard. The studio is a
 * fitting-room: a bright, soft, near-white space where the only saturated
 * colours on screen are the clothes themselves. Every surface here is defined
 * against its own token set (`--os-*`) rather than the dashboard's dark
 * `--ink`/`--glass` palette, so the garment cut-outs — which are all shot on
 * white — sit on a matching ground instead of floating in a dark hole.
 *
 * Motion is load-bearing, not decoration: the studio's whole job is to make an
 * abstract two-stage pipeline (compose N items → generate one photo) feel like
 * one continuous act of getting dressed. So the sheet rises, the stack settles
 * item by item, and the wait has a shape (`Composing → Generating → Improving`)
 * rather than a spinner.
 *
 * Everything is scoped under `.os-` and mounted at the end of <body>, so none of
 * it can leak into the dashboard underneath.
 */

.os-root {
  /* Strictly monochrome. The studio is a fitting room: the garments are the only
     colour that belongs on screen, so every surface, every accent and every
     control here is white, grey or black. (The background used to be a blue
     gradient and the selection state a blue accent — both fought the clothes.) */
  --os-bg-top: #f6f6f7;
  --os-bg-bottom: #ffffff;
  --os-surface: #ffffff;
  --os-surface-soft: #f2f2f3;
  --os-surface-sunk: #e8e8ea;
  --os-ink: #101114;
  --os-ink-soft: #62646b;
  --os-ink-faint: #9b9ca3;
  --os-line: rgba(16, 17, 20, 0.1);
  --os-accent: #101114;
  --os-accent-ink: #ffffff;
  --os-accent-soft: rgba(16, 17, 20, 0.06);
  --os-shadow-sm: 0 2px 8px rgba(16, 17, 20, 0.06);
  --os-shadow-md: 0 10px 30px rgba(16, 17, 20, 0.1);
  --os-shadow-lg: 0 24px 60px rgba(16, 17, 20, 0.18);
  /* Card height, driven by the GRID rather than the card — see `.os-grid`. */
  --os-card-h: 190px;
  --os-radius: 22px;
  --os-radius-sm: 16px;
  /* One easing for everything that moves, so the surface feels like one object.
     A slight overshoot on entrances reads as physical without being bouncy. */
  --os-ease: cubic-bezier(0.22, 0.8, 0.3, 1);
  --os-ease-out: cubic-bezier(0.16, 1, 0.3, 1);

  /* Height of the dashboard's fixed nav (`.main-nav-outer`, min-height 60px,
     z-index 1050). The studio starts BELOW it rather than covering the viewport,
     so the site's branding stays on screen the whole time you're dressing a
     character. Keep in sync with that rule if the nav's height changes. */
  --os-nav-h: calc(60px + env(safe-area-inset-top, 0px));

  position: fixed;
  top: var(--os-nav-h);
  left: 0;
  right: 0;
  bottom: 0;
  /* Below the nav (1050), the offcanvas sidebar (1045) and its backdrop (1040),
     and above ordinary page content (auto). At 2000 the studio covered the
     sidebar, so opening the hamburger menu slid it out BEHIND the studio. */
  z-index: 1030;
  display: flex;
  flex-direction: column;
  background: linear-gradient(180deg, var(--os-bg-top) 0%, var(--os-bg-bottom) 42%);
  color: var(--os-ink);
  font-family: inherit;
  /* Hidden by default; `.is-open` runs the entrance. `visibility` (not
     `display`) so the children can animate in and out. */
  opacity: 0;
  visibility: hidden;
  transition: opacity .28s var(--os-ease), visibility 0s linear .28s;
}
.os-root.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity .28s var(--os-ease), visibility 0s linear 0s;
}

/* Respect a user's reduced-motion setting: keep the state changes, drop the
   travel. Everything below still works, it just cuts instead of sliding. */
@media (prefers-reduced-motion: reduce) {
  .os-root,
  .os-root * {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
}

/* ── Top bar ──────────────────────────────────────────────────────────────── */

.os-topbar {
  display: flex;
  align-items: center;
  gap: .75rem;
  padding: .9rem 1.1rem .4rem;
  flex: 0 0 auto;
  /* Never wrap to a second line — the close button and the tabs must stay put. */
  flex-wrap: nowrap;
  min-width: 0;
}
@media (max-width: 480px) {
  .os-topbar { gap: .5rem; padding-inline: .8rem; }
}
.os-close {
  width: 40px;
  height: 40px;
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  background: var(--os-surface);
  color: var(--os-ink);
  font-size: 1.05rem;
  cursor: pointer;
  box-shadow: var(--os-shadow-sm);
  transition: transform .15s var(--os-ease), background .15s var(--os-ease);
}
.os-close:hover { transform: scale(1.06); background: var(--os-surface-soft); }
.os-close:active { transform: scale(.94); }

/* Items | Outfits — a segmented switch, the studio's only navigation. */
.os-tabs { display: flex; align-items: baseline; gap: 1.1rem; }
.os-tab {
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
  font-size: 1.55rem;
  font-weight: 800;
  letter-spacing: -0.02em;
  color: var(--os-ink-faint);
  transition: color .2s var(--os-ease), transform .2s var(--os-ease);
}
.os-tab.is-active { color: var(--os-ink); }
.os-tab:hover { color: var(--os-ink-soft); }
.os-tab:active { transform: scale(.97); }

.os-topbar-spacer { flex: 1 1 auto; }

/* NOTE: the per-try-on price used to live here as `.os-coin-note`. It now rides
   on the floating action button (`.os-tryon-cost`) — on a phone this pill
   wrapped to three lines and it stated the cost as far from the pay moment as
   the layout allowed. */

.os-starter-btn {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  flex: 0 0 auto;
  border: 0;
  border-radius: 999px;
  padding: .5rem .9rem;
  font-size: .82rem;
  font-weight: 700;
  cursor: pointer;
  background: var(--os-ink);
  color: #fff;
  box-shadow: var(--os-shadow-sm);
  transition: transform .15s var(--os-ease), filter .18s var(--os-ease);
}
.os-starter-btn:hover { transform: translateY(-1px); filter: brightness(1.5); }
.os-starter-btn:active { transform: scale(.97); }
.os-starter-btn:disabled { opacity: .6; cursor: wait; transform: none; }
/* Below the chip row there isn't space for both; the label is the useful half. */
@media (max-width: 560px) {
  .os-starter-btn span { display: none; }
  .os-starter-btn { padding: .5rem .65rem; }
}

/* ── Stage: the composed look ─────────────────────────────────────────────── */

.os-stage {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  padding: .5rem 1rem 1rem;
  position: relative;
  /* On a short viewport the stack + button are taller than the space left over,
     and a centred flex column overflows in BOTH directions — the top of the
     stack was escaping upward over the tab bar. Clip it here and let the stack
     shrink (below) rather than letting it spill. */
  overflow: hidden;
}

/* The selected pieces: a single row of identical squares.
   FIXED HEIGHT is the point. The previous scattered arrangement resized itself
   with the selection, which moved the wardrobe panel below it on every tap. A
   constant-height row means nothing under it ever shifts. */
.os-stack {
  position: relative;
  width: 100%;
  max-width: 1080px;
  height: var(--os-stack-h, 132px);
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
}
.os-stack-row {
  display: flex;
  gap: .6rem;
  align-items: center;
  max-width: 100%;
  padding: .4rem .2rem;
  /* Overflow scrolls rather than shrinking the tiles — eight pieces should still
     be legible, not eight slivers. */
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  scroll-snap-type: x proximity;
}
.os-stack-row::-webkit-scrollbar { display: none; }

/* One size for every piece, 1:1, cropped to fill. Uniformity is what turns the
   row from clutter into a readable outfit. */
.os-stack-thumb {
  position: relative;
  flex: 0 0 auto;
  width: 104px;
  height: 104px;
  padding: 0;
  border: 0;
  border-radius: 18px;
  overflow: hidden;
  cursor: pointer;
  background: var(--os-surface);
  box-shadow: 0 6px 18px rgba(23, 26, 51, .12);
  scroll-snap-align: center;
  animation: os-stack-in .42s var(--os-ease-out) backwards;
  transition: transform .18s var(--os-ease), box-shadow .2s var(--os-ease);
}
.os-stack-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.os-stack-thumb:hover { transform: translateY(-3px); box-shadow: 0 12px 26px rgba(16, 17, 20, .2); }
.os-stack-thumb:active { transform: scale(.96); }
/* Removed pieces collapse out of the row instead of vanishing, so the pieces
   either side visibly close the gap. `renderStack` drops the node afterwards. */
.os-stack-thumb.is-leaving {
  opacity: 0;
  transform: scale(.7);
  width: 0;
  margin-right: -.6rem;
  pointer-events: none;
  transition: opacity .18s linear, transform .26s var(--os-ease),
    width .26s var(--os-ease), margin .26s var(--os-ease);
}

/* Tap-to-remove affordance. */
.os-stack-remove {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: .8rem;
  background: rgba(16, 17, 20, .74);
  color: #fff;
  opacity: 0;
  transform: scale(.7);
  transition: opacity .16s, transform .18s var(--os-ease-out);
}
.os-stack-thumb:hover .os-stack-remove { opacity: 1; transform: scale(1); }
@media (hover: none) { .os-stack-remove { opacity: 1; transform: scale(1); } }

@keyframes os-stack-in {
  from { opacity: 0; transform: translateY(14px) scale(.9); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}
@media (max-width: 480px) {
  .os-stack { --os-stack-h: 108px; }
  .os-stack-thumb { width: 84px; height: 84px; border-radius: 15px; }
}

/* Nothing picked yet. */
.os-stack-empty {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: .6rem;
  text-align: center;
  color: var(--os-ink-faint);
  padding: 1rem;
}
.os-stack-empty i { font-size: 2.4rem; opacity: .5; }
.os-stack-empty p { margin: 0; font-size: .9rem; max-width: 22ch; }

/* ── The floating action: "Try it on" ─────────────────────────────────────── */

/* Sits over the wardrobe sheet at the bottom of the screen, always in the same
   place, so the commit action never scrolls away or moves as the look changes.
   Strictly black-and-white: it is the only true call to action on the surface,
   and the clothes should be the only colour competing for attention. */
/* The bottom bar owns the positioning; the buttons inside are ordinary flex
   children. "Styling" sits beside "Try it on" because it is an optional step of
   its own — buried at the bottom of the character dialog it was found by
   accident, if at all. */
.os-fab-bar {
  position: absolute;
  left: 50%;
  bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
  transform: translateX(-50%);
  z-index: 14;
  display: flex;
  align-items: center;
  gap: .5rem;
  max-width: calc(100% - 1.6rem);
  transition: opacity .25s var(--os-ease), transform .28s var(--os-ease);
}

.os-styling-btn {
  position: relative;
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: .45rem;
  border: 1.5px solid var(--os-line);
  border-radius: 999px;
  padding: .85rem 1.15rem;
  font-size: .92rem;
  font-weight: 700;
  white-space: nowrap;
  cursor: pointer;
  background: var(--os-surface);
  color: var(--os-ink);
  box-shadow: var(--os-shadow-md);
  transition: transform .16s var(--os-ease), box-shadow .18s var(--os-ease);
}
.os-styling-btn:hover { transform: translateY(-2px); box-shadow: 0 14px 32px rgba(16, 17, 20, .18); }
.os-styling-btn:active { transform: scale(.97); }
/* Set-or-not is invisible once the dialog closes, so the button says so. */
.os-styling-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--os-ink);
}
@media (max-width: 420px) {
  .os-styling-btn span:not(.os-styling-dot) { display: none; }
  .os-styling-btn { padding: .85rem .95rem; }
}

.os-tryon-btn {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: .6rem;
  border: 1.5px solid var(--os-line);
  border-radius: 999px;
  padding: .85rem 1.5rem .85rem 1.3rem;
  font-size: 1.02rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  white-space: nowrap;
  cursor: pointer;
  background: var(--os-surface);
  color: var(--os-ink-faint);
  box-shadow: var(--os-shadow-md);
  transition: background .25s var(--os-ease), color .25s var(--os-ease),
    box-shadow .25s var(--os-ease), transform .18s var(--os-ease),
    border-color .25s var(--os-ease);
}
.os-tryon-btn i { font-size: 1.1em; }
/* Enabled the moment there is something to wear — inverts to solid black. */
.os-tryon-btn.is-ready {
  background: #101114;
  color: #fff;
  border-color: #101114;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.os-tryon-btn.is-ready:hover { transform: translateY(-2px); box-shadow: 0 14px 36px rgba(0, 0, 0, 0.36); }
.os-tryon-btn.is-ready:active { transform: scale(.98); }
.os-tryon-btn:disabled { cursor: not-allowed; }

/* Drops out of the way whenever it would be meaningless: while a try-on renders,
   while the result is up, and on the Outfits tab (there you wear a saved look by
   tapping its card, so a global "try it on" has nothing to act on). */
.os-root.is-working .os-fab-bar,
.os-root.is-result .os-fab-bar,
.os-root.is-picking .os-fab-bar,
.os-root.is-outfits-tab .os-fab-bar {
  opacity: 0;
  pointer-events: none;
  transform: translateX(-50%) translateY(26px);
}

/* The price rides on the action itself. It used to be a separate pill in the
   top bar, which on a phone wrapped to three lines ("50 / per / try-on") and
   put the cost as far as possible from the moment you agree to pay it. */
.os-tryon-cost {
  display: inline-flex;
  align-items: center;
  gap: .28rem;
  padding: .2rem .55rem;
  border-radius: 999px;
  font-size: .82rem;
  font-weight: 700;
  background: var(--os-surface-sunk);
  color: var(--os-ink-soft);
  transition: background .25s var(--os-ease), color .25s var(--os-ease);
}
.os-tryon-btn.is-ready .os-tryon-cost { background: rgba(255, 255, 255, .16); color: #fff; }

.os-stage-hint {
  font-size: .82rem;
  color: var(--os-ink-faint);
  margin: 0;
  text-align: center;
  min-height: 1.2em;
}

/* ── Bottom sheet: the wardrobe ───────────────────────────────────────────── */

/* The wardrobe panel FLOATS over the background rather than being welded to the
   bottom edge: inset on all sides, rounded all round, with a deep shadow that
   separates it from the stage behind. Docked edge-to-edge it read as a second
   page rather than a drawer laid over the fitting room. */
.os-sheet {
  flex: 0 0 auto;
  background: var(--os-surface);
  border-radius: var(--os-radius);
  box-shadow: var(--os-shadow-lg);
  display: flex;
  flex-direction: column;
  width: min(100% - 1.6rem, 1080px);
  margin: 0 auto .8rem;
  /* A FIXED height, not a min/max range. With a range the panel resized as the
     stage above it changed, so picking an item made the whole wardrobe jump.
     One height means the panel is a stable surface you can scroll and tap
     without the ground moving. The stage takes whatever is left. */
  height: clamp(300px, 58vh, 560px);
  /* Rises from below on open — the gesture that makes the studio feel like a
     drawer of clothes rather than a dialog. */
  transform: translateY(110%);
  transition: transform .42s var(--os-ease-out);
}
.os-root.is-open .os-sheet { transform: translateY(0); }
/* Collapsed while a try-on renders, so the result owns the screen. */
.os-root.is-working .os-sheet { transform: translateY(100%); }

/* Match the floating panel's width so the tabs line up with the cards below.
   (The sheet carries its own `width`/`margin` — see `.os-sheet`.) */
@media (min-width: 900px) {
  .os-topbar { width: min(100% - 1.6rem, 1080px); margin-inline: auto; }
}

.os-sheet-handle {
  width: 44px;
  height: 5px;
  border-radius: 999px;
  background: var(--os-surface-sunk);
  margin: .65rem auto .35rem;
  flex: 0 0 auto;
}

/* Category chips — horizontally scrollable, no visible scrollbar. */
.os-chips {
  display: flex;
  gap: .5rem;
  padding: .35rem 1rem .6rem;
  overflow-x: auto;
  scrollbar-width: none;
  flex: 0 0 auto;
  scroll-padding-inline: 1rem;
}
.os-chips::-webkit-scrollbar { display: none; }
.os-chip {
  flex: 0 0 auto;
  border: 0;
  border-radius: 14px;
  padding: .7rem 1.15rem;
  font-size: .98rem;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  background: var(--os-surface-soft);
  color: var(--os-ink-soft);
  transition: background .2s var(--os-ease), color .2s var(--os-ease), transform .15s var(--os-ease);
}
.os-chip:hover { background: var(--os-surface-sunk); }
.os-chip:active { transform: scale(.96); }
.os-chip.is-active { background: var(--os-ink); color: #fff; }
/* An empty category is shown, but dimmed — it reads as a gap to fill rather
   than as a category with things in it. */
.os-chip.is-empty { color: var(--os-ink-faint); background: transparent; box-shadow: inset 0 0 0 1.5px var(--os-line); }
.os-chip.is-empty.is-active { background: var(--os-ink); color: #fff; box-shadow: none; }
.os-chip-count {
  margin-left: .4rem;
  font-size: .8em;
  opacity: .6;
  font-variant-numeric: tabular-nums;
}

/* The item grid.
   Card sizes are deliberately generous: these are garments, and at ~96px a
   patterned top and a plain one are indistinguishable, which makes the picker
   useless for the one decision it exists to support. The bottom padding clears
   the floating "Try it on" button so the last row is never trapped under it. */
.os-grid {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  /* THE ROW HEIGHT IS FIXED HERE, ON THE GRID — not derived from the card.
     Every attempt to let the card size itself (aspect-ratio, then a padding-top
     ::before box) lost to something in the dashboard's global stylesheet stack,
     and the cards collapsed to the height of their name label: the wardrobe
     looked empty on "All" and like a list of tiny pills everywhere else. A grid
     track height is not negotiable by anything the cards inherit, and grid items
     stretch to it by default. If cards ever look wrong again, change this. */
  grid-auto-rows: var(--os-card-h);
  gap: .75rem;
  padding: 0 1rem 5.5rem;
  align-content: start;
}
@media (min-width: 640px) {
  .os-grid {
    grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
    --os-card-h: 210px;
    gap: .9rem;
  }
}
/* Two big cards beat three cramped ones on a phone — you are judging a garment,
   which needs the pixels. */
@media (max-width: 560px) {
  .os-grid {
    grid-template-columns: repeat(2, 1fr);
    --os-card-h: 200px;
  }
}

.os-tile {
  position: relative;
  border: 0;
  padding: 0;
  cursor: pointer;
  border-radius: var(--os-radius-sm);
  background: var(--os-surface-soft);
  overflow: hidden;
  /* A grid item's automatic minimum is its content's intrinsic size, so without
     this a tall product shot stretches its row and the grid loses its rhythm
     (the same trap as the collection mosaics — see dashboard-artifacts.css). */
  min-width: 0;
  min-height: 0;
  transition: transform .18s var(--os-ease), box-shadow .2s var(--os-ease), background .2s;
  animation: os-tile-in .32s var(--os-ease-out) backwards;
}
/* The card fills its grid track (grid items stretch by default), so its height
   comes from `--os-card-h` on `.os-grid` and nothing the card inherits can
   collapse it. An earlier `::before { padding-top: 100% }` box tried to do this
   from the inside and lost. */
.os-tile { height: 100%; }
@keyframes os-tile-in {
  from { opacity: 0; transform: translateY(10px) scale(.96); }
  to   { opacity: 1; transform: none; }
}
.os-tile:hover { transform: translateY(-2px); box-shadow: var(--os-shadow-md); }
.os-tile:active { transform: scale(.97); }
.os-tile img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: .4rem;
  display: block;
}
.os-tile.is-selected { background: var(--os-accent-soft); box-shadow: inset 0 0 0 2px var(--os-accent); }

/* The check bubble: an empty ring that fills in. */
.os-tile-check {
  position: absolute;
  top: 7px;
  left: 7px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.86);
  box-shadow: inset 0 0 0 1.6px rgba(20, 22, 40, 0.28);
  color: transparent;
  font-size: .8rem;
  transition: background .2s var(--os-ease), box-shadow .2s var(--os-ease), color .15s;
}
.os-tile.is-selected .os-tile-check {
  background: var(--os-accent);
  box-shadow: inset 0 0 0 1.6px var(--os-accent);
  color: #fff;
  animation: os-check-pop .3s var(--os-ease-out);
}
@keyframes os-check-pop {
  0%   { transform: scale(.6); }
  55%  { transform: scale(1.18); }
  100% { transform: scale(1); }
}

.os-tile-name {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: .9rem .5rem .35rem;
  font-size: .68rem;
  font-weight: 600;
  color: var(--os-ink-soft);
  text-align: center;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .92) 55%);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;
}

/* Empty category → offer the starter items rather than a dead end. */
.os-grid-empty {
  grid-column: 1 / -1;
  text-align: center;
  padding: 2rem 1rem;
  color: var(--os-ink-soft);
}
.os-grid-empty i { font-size: 2rem; opacity: .4; display: block; margin-bottom: .5rem; }
.os-grid-empty p { margin: 0 0 .9rem; font-size: .88rem; }

/* ── Saved outfits tab ────────────────────────────────────────────────────── */

.os-outfits {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
  gap: .9rem;
  /* Same floating-button clearance as the item grid. */
  padding: 0 1rem 5.5rem;
  overflow-y: auto;
  flex: 1 1 auto;
  min-height: 0;
  align-content: start;
}
.os-outfit-card {
  border: 0;
  padding: 0;
  cursor: pointer;
  border-radius: var(--os-radius-sm);
  background: var(--os-surface-soft);
  overflow: hidden;
  text-align: left;
  min-width: 0;
  transition: transform .18s var(--os-ease), box-shadow .2s var(--os-ease);
  animation: os-tile-in .32s var(--os-ease-out) backwards;
}
.os-outfit-card:hover { transform: translateY(-2px); box-shadow: var(--os-shadow-md); }
/* Square preview via a reserved box + out-of-flow image — same reasoning as
   `.os-tile::before`: an intrinsic-size fallback here makes ragged cards. */
.os-outfit-thumb { position: relative; width: 100%; height: 150px; background: #fff; }
.os-outfit-thumb img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}
.os-outfit-meta { padding: .5rem .6rem .65rem; }
.os-outfit-name {
  font-size: .82rem;
  font-weight: 700;
  color: var(--os-ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.os-outfit-count { font-size: .72rem; color: var(--os-ink-faint); }

/* ── Working overlay: Composing → Generating → Improving ──────────────────── */

/* Shaped like the app's own modals (`.uim-overlay` / `.uim-dialog` in
   ui-modal.css): a blurred veil with ONE centred glass dialog. The first version
   was a bespoke full-bleed screen with a giant blob, which read as a different
   product interrupting this one. */
.os-working {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: rgba(18, 20, 34, 0.28);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  text-align: center;
}
.os-root.is-working .os-working { display: flex; animation: os-fade-in .25s var(--os-ease); }
@keyframes os-fade-in { from { opacity: 0; } to { opacity: 1; } }

.os-working-dialog {
  width: 100%;
  max-width: 420px;
  background: var(--os-surface);
  border: 1px solid var(--os-line);
  border-radius: 18px;
  box-shadow: 0 24px 70px rgba(0, 0, 0, 0.18);
  padding: 26px 26px 22px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .55rem;
  animation: os-modal-in .28s var(--os-ease-out);
}
/* The dialog's icon well — same 46px rounded square the app's modals use. */
.os-working-icon {
  width: 46px;
  height: 46px;
  border-radius: 14px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--os-surface-soft);
  margin-bottom: 6px;
}

/* A soft morphing blob standing in for the garment being rendered. It changes
   colour and silhouette per stage, which is what makes a 60-second wait read as
   progress instead of a hang. Now sized for the icon well, not the screen. */
.os-blob {
  width: 26px;
  height: 26px;
  display: block;
  background: #b9c8f7;
  animation: os-blob-morph 5.5s var(--os-ease) infinite, os-blob-float 3.4s ease-in-out infinite;
  border-radius: 50%;
}
.os-working[data-stage="compose"] .os-blob { background: #b9c8f7; }
.os-working[data-stage="generate"] .os-blob { background: #f3cfd0; }
.os-working[data-stage="improve"] .os-blob { background: #e6b3f0; }
@keyframes os-blob-morph {
  0%   { border-radius: 50% 50% 50% 50% / 50% 50% 50% 50%; transform: rotate(0deg); }
  33%  { border-radius: 62% 38% 46% 54% / 58% 43% 57% 42%; transform: rotate(6deg); }
  66%  { border-radius: 38% 62% 58% 42% / 42% 57% 43% 58%; transform: rotate(-5deg); }
  100% { border-radius: 50% 50% 50% 50% / 50% 50% 50% 50%; transform: rotate(0deg); }
}
@keyframes os-blob-float {
  0%, 100% { translate: 0 0; }
  50%      { translate: 0 -3px; }
}

/* The thin sweeping line under the blob — a horizon the blob floats above. */
.os-working-line {
  width: 100%;
  height: 3px;
  border-radius: 999px;
  background: linear-gradient(90deg, transparent, var(--os-surface-sunk) 20%, var(--os-surface-sunk) 80%, transparent);
  overflow: hidden;
  position: relative;
}
.os-working-line::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, rgba(16, 17, 20, .45), transparent);
  animation: os-sweep 1.8s var(--os-ease) infinite;
}
@keyframes os-sweep {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}

.os-working-title {
  font-size: 1.15rem;
  font-weight: 800;
  letter-spacing: -0.01em;
  margin: 0;
  color: var(--os-ink);
}
.os-working-sub { margin: 0 0 .5rem; font-size: .88rem; color: var(--os-ink-soft); }

/* ── Result ───────────────────────────────────────────────────────────────── */

.os-result {
  position: absolute;
  inset: 0;
  z-index: 6;
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: .9rem;
  background: linear-gradient(180deg, var(--os-bg-top) 0%, var(--os-bg-bottom) 45%);
  padding: 1rem;
  overflow-y: auto;
}
.os-root.is-result .os-result { display: flex; }
/* Photo + "what went in" sit side by side where there's room, stacked on a
   phone. Seeing the inputs next to the output is the only way to tell what the
   model actually rendered from what it quietly dropped. */
.os-result-body {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  min-height: 0;
  max-width: 100%;
}
@media (max-width: 780px) {
  .os-result-body { flex-direction: column; gap: .7rem; }
}

.os-result-frame {
  position: relative;
  border-radius: var(--os-radius);
  overflow: hidden;
  background: var(--os-surface-soft);
  box-shadow: var(--os-shadow-md);
  max-height: 62vh;
  flex: 0 1 auto;
  animation: os-result-in .55s var(--os-ease-out);
}
@keyframes os-result-in {
  from { opacity: 0; transform: translateY(20px) scale(.96); }
  to   { opacity: 1; transform: none; }
}
.os-result-frame img { display: block; max-height: 62vh; max-width: min(88vw, 420px); object-fit: contain; }


/* The inputs: character first, then every piece. A column beside the photo on
   desktop, a horizontally scrolling strip under it on a phone. */
.os-result-used {
  display: flex;
  flex-direction: column;
  gap: .5rem;
  max-height: 62vh;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 2px;
  scrollbar-width: none;
}
.os-result-used::-webkit-scrollbar { display: none; }
@media (max-width: 780px) {
  .os-result-used {
    flex-direction: row;
    max-height: none;
    max-width: min(92vw, 460px);
    overflow-x: auto;
    overflow-y: hidden;
    padding-bottom: 4px;
  }
}

.os-used-tile {
  flex: 0 0 auto;
  width: 62px;
  height: 62px;
  border-radius: 14px;
  overflow: hidden;
  background: var(--os-surface);
  box-shadow: var(--os-shadow-sm);
}
.os-used-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The character reads as the source of the look, so it gets a ring. */
.os-used-tile.is-character { box-shadow: 0 0 0 2px var(--os-ink), var(--os-shadow-sm); }

/* Actions: compact, monochrome, and ONE row on a phone (they scroll sideways
   rather than wrapping into a second and third row). */
.os-result-actions {
  display: flex;
  gap: .5rem;
  flex-wrap: nowrap;
  justify-content: center;
  max-width: 100%;
  overflow-x: auto;
  padding: 2px 4px;
  scrollbar-width: none;
}
.os-result-actions::-webkit-scrollbar { display: none; }
.os-action {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  flex: 0 0 auto;
  border: 1.5px solid var(--os-line);
  border-radius: 999px;
  padding: .6rem 1.05rem;
  font-size: .88rem;
  font-weight: 700;
  white-space: nowrap;
  cursor: pointer;
  background: var(--os-surface);
  color: var(--os-ink);
  box-shadow: var(--os-shadow-sm);
  transition: transform .15s var(--os-ease), box-shadow .18s var(--os-ease), background .18s;
}
.os-action:hover { transform: translateY(-2px); box-shadow: var(--os-shadow-md); }
.os-action:active { transform: translateY(0) scale(.98); }
/* Monochrome, matching the floating "Try it on" — the studio has no blue. */
.os-action-primary {
  background: #101114;
  color: #fff;
  border-color: #101114;
  box-shadow: 0 8px 22px rgba(0, 0, 0, .26);
}
.os-action-primary:hover { box-shadow: 0 12px 28px rgba(0, 0, 0, .32); }
.os-action:disabled { opacity: .5; cursor: not-allowed; transform: none; }
@media (max-width: 480px) {
  .os-action { padding: .55rem .85rem; font-size: .82rem; }
}

/* ── Model (character) picker ─────────────────────────────────────────────── */

.os-modal-backdrop {
  position: absolute;
  inset: 0;
  z-index: 8;
  background: rgba(18, 20, 34, 0.42);
  backdrop-filter: blur(2px);
  display: none;
  align-items: center;
  justify-content: center;
  padding: 1.2rem;
}
.os-modal-backdrop.is-open { display: flex; animation: os-fade-in .22s var(--os-ease); }

.os-modal {
  background: var(--os-surface);
  border-radius: var(--os-radius);
  box-shadow: var(--os-shadow-lg);
  width: min(100%, 460px);
  max-height: 84vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  animation: os-modal-in .34s var(--os-ease-out);
}
@keyframes os-modal-in {
  from { opacity: 0; transform: translateY(24px) scale(.96); }
  to   { opacity: 1; transform: none; }
}
/* A close icon in the corner, rather than a Cancel button competing with the
   dialog's real action at the bottom. */
.os-modal-head { padding: 1.2rem 1.3rem .5rem; position: relative; }
.os-modal-close {
  position: absolute;
  top: .85rem;
  right: .85rem;
  width: 34px;
  height: 34px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  background: var(--os-surface-soft);
  color: var(--os-ink-soft);
  font-size: .9rem;
  cursor: pointer;
  transition: background .16s var(--os-ease), transform .16s var(--os-ease), color .16s;
}
.os-modal-close:hover { background: var(--os-surface-sunk); color: var(--os-ink); transform: scale(1.06); }
.os-modal-close:active { transform: scale(.94); }
/* Keep the title clear of the icon. */
.os-modal-title, .os-modal-sub { padding-right: 2.4rem; }
.os-modal-title { margin: 0; font-size: 1.32rem; font-weight: 800; letter-spacing: -0.02em; }
.os-modal-sub { margin: .3rem 0 0; font-size: .85rem; color: var(--os-ink-soft); }
/* The look, echoed in the dialog that covers it. */
.os-modal-look {
  display: flex;
  gap: .4rem;
  margin-top: .7rem;
  overflow-x: auto;
  padding-bottom: 2px;
  scrollbar-width: none;
}
.os-modal-look::-webkit-scrollbar { display: none; }
.os-look-tile {
  flex: 0 0 auto;
  width: 46px;
  height: 46px;
  border-radius: 12px;
  overflow: hidden;
  background: var(--os-surface-soft);
  box-shadow: var(--os-shadow-sm);
}
.os-look-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }

.os-modal-body { padding: .8rem 1.3rem; overflow-y: auto; flex: 1 1 auto; min-height: 0; }
.os-modal-foot {
  display: flex;
  gap: .6rem;
  padding: .8rem 1.3rem 1.2rem;
  justify-content: flex-end;
  flex: 0 0 auto;
}

/* The character cards. Portrait-first: you are choosing a face, so the face is
   the card — name and status sit over it rather than beside it. */
/* `#os-models` is now a stack of labelled groups ("Your characters", then the
   ready-made public models, then the create-your-own row), so the grid moved
   down one level onto `.os-model-grid`. */
.os-models {
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
}
.os-model-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  grid-auto-rows: 148px;
  gap: .65rem;
}
.os-model-group-title {
  margin: 0 0 .15rem;
  font-size: .78rem;
  font-weight: 800;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--os-ink-soft);
}
.os-model-group-sub {
  margin: 0 0 .6rem;
  font-size: .74rem;
  color: var(--os-ink-soft);
  opacity: .85;
}
.os-model-note {
  padding: .6rem .75rem;
  border-radius: var(--os-radius-sm);
  background: var(--os-surface-soft);
  font-size: .78rem;
  color: var(--os-ink-soft);
}
/* The cell wraps the character button so the "see her try-ons" link can sit on
   top of it without becoming part of the button's own click target. */
/* The cell is the grid item now, so it — not the button — takes the track
   height; `.os-model { height: 100% }` then fills it as before. */
.os-model-cell { position: relative; min-width: 0; height: 100%; }
.os-model-browse {
  position: absolute;
  top: 6px;
  left: 6px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, .92);
  color: var(--os-ink-soft);
  font-size: .72rem;
  text-decoration: none;
  opacity: 0;
  transition: opacity .18s;
}
.os-model-cell:hover .os-model-browse,
.os-model-browse:focus-visible { opacity: 1; }
/* No hover on touch, so the affordance has to be permanent there. */
@media (hover: none) {
  .os-model-browse { opacity: 1; }
}
/* Search — sits in the dialog head, above the grid it filters. */
.os-model-search {
  position: relative;
  margin: .7rem 0 0;
}
.os-model-search i {
  position: absolute;
  left: .7rem;
  top: 50%;
  transform: translateY(-50%);
  font-size: .82rem;
  color: var(--os-ink-soft);
  pointer-events: none;
}
.os-model-search input {
  width: 100%;
  padding: .55rem .8rem .55rem 2rem;
  border: 1.5px solid var(--os-line);
  border-radius: 999px;
  background: var(--os-surface);
  color: inherit;
  font: inherit;
  font-size: .85rem;
  outline: none;
  transition: border-color .18s;
}
.os-model-search input:focus { border-color: var(--os-accent); }
/* Safari draws its own clear affordance; it collides with the pill radius. */
.os-model-search input::-webkit-search-decoration,
.os-model-search input::-webkit-search-cancel-button { -webkit-appearance: none; }

/* "Create your own" as a TILE in the grid, sharing `.os-model` for size and
   shape so it lines up with the faces instead of sitting under them. */
.os-model-new {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: .4rem;
  text-decoration: none;
  color: var(--os-ink-soft);
  border: 1.5px dashed var(--os-line);
  background: var(--os-surface-soft);
}
.os-model-new:hover { border-color: var(--os-accent); color: var(--os-accent); }
.os-model-new-icon {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--os-accent);
  color: #fff;
  font-size: .9rem;
}
/* The face tiles caption over a photo; this one has no photo to sit on. */
.os-model-new .os-model-name {
  position: static;
  padding: 0 .4rem;
  background: none;
  color: inherit;
  font-size: .74rem;
}
.os-model {
  position: relative;
  border: 0;
  padding: 0;
  cursor: pointer;
  border-radius: var(--os-radius-sm);
  overflow: hidden;
  background: var(--os-surface-soft);
  min-width: 0;
  transition: transform .18s var(--os-ease), box-shadow .2s var(--os-ease);
}
/* Height comes from the grid track, for the same reason as the item cards:
   nothing the card inherits can then collapse it.
   WIDTH has to be explicit for the same reason. The button used to BE the grid
   item, so it stretched to the column on its own; once it was wrapped in
   `.os-model-cell` (to hang the "see her try-ons" link off), it became an
   ordinary in-flow child — and a <button> shrink-wraps its content. Both the
   portrait and the name caption are `position: absolute`, so they contribute no
   layout width, and every character tile collapsed to 0px: a picker that
   reported N models and rendered an empty box. */
.os-model { height: 100%; width: 100%; }
.os-model img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.os-model:hover { transform: translateY(-2px); box-shadow: var(--os-shadow-md); }
.os-model.is-selected { box-shadow: inset 0 0 0 3px var(--os-accent), var(--os-shadow-md); }
.os-model-name {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 1.4rem .5rem .45rem;
  font-size: .76rem;
  font-weight: 700;
  color: #fff;
  text-align: center;
  background: linear-gradient(180deg, rgba(10, 12, 24, 0), rgba(10, 12, 24, .78));
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.os-model-tick {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--os-accent);
  color: #fff;
  font-size: .72rem;
  opacity: 0;
  transform: scale(.6);
  transition: opacity .18s, transform .22s var(--os-ease-out);
}
.os-model.is-selected .os-model-tick { opacity: 1; transform: scale(1); }
/* A character with no face reference can't be dressed — say so on the card
   instead of failing after the user has already paid attention to it. */
.os-model.is-disabled { opacity: .45; cursor: not-allowed; }
.os-model.is-disabled:hover { transform: none; box-shadow: none; }
.os-model-warn {
  position: absolute;
  top: 6px;
  left: 6px;
  padding: .18rem .45rem;
  border-radius: 999px;
  font-size: .6rem;
  font-weight: 700;
  background: rgba(255, 255, 255, .92);
  color: var(--os-ink-soft);
}

/* Styling direction — the user's own prompt. */
.os-field { margin-top: 1rem; }
.os-field-label {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .5rem;
  font-size: .82rem;
  font-weight: 700;
  color: var(--os-ink-soft);
  margin-bottom: .35rem;
}
.os-input,
.os-textarea {
  width: 100%;
  border: 1.5px solid var(--os-line);
  border-radius: 14px;
  padding: .7rem .85rem;
  font-size: .92rem;
  font-family: inherit;
  color: var(--os-ink);
  background: var(--os-surface);
  resize: vertical;
  transition: border-color .18s var(--os-ease), box-shadow .18s var(--os-ease);
}
.os-input:focus,
.os-textarea:focus {
  outline: none;
  border-color: var(--os-accent);
  box-shadow: 0 0 0 3px var(--os-accent-soft);
}
.os-input::placeholder,
.os-textarea::placeholder { color: var(--os-ink-faint); }

/* One-tap prompt presets — the "well-designed custom prompt" made pickable. */
.os-presets { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .5rem; }
.os-preset {
  border: 0;
  border-radius: 999px;
  padding: .4rem .8rem;
  font-size: .78rem;
  font-weight: 600;
  cursor: pointer;
  background: var(--os-surface-soft);
  color: var(--os-ink-soft);
  transition: background .18s, color .18s, transform .14s var(--os-ease);
}
.os-preset:hover { background: var(--os-surface-sunk); }
.os-preset:active { transform: scale(.95); }
.os-preset.is-active { background: var(--os-ink); color: #fff; }

/* ── Name-this-outfit dialog ──────────────────────────────────────────────── */

.os-name-dialog .os-modal { width: min(100%, 400px); }
.os-name-dialog .os-modal-body { padding-top: .3rem; }
.os-name-input {
  width: 100%;
  border: 0;
  border-bottom: 2px solid var(--os-line);
  border-radius: 0;
  padding: .5rem 0;
  font-size: 1.15rem;
  font-family: inherit;
  color: var(--os-ink);
  background: transparent;
  transition: border-color .2s var(--os-ease);
}
.os-name-input:focus { outline: none; border-bottom-color: var(--os-accent); }
.os-name-input::placeholder { color: var(--os-ink-faint); }

.os-btn-ghost {
  border: 1.5px solid var(--os-line);
  background: transparent;
  color: var(--os-ink);
  border-radius: 999px;
  padding: .65rem 1.5rem;
  font-size: .92rem;
  font-weight: 600;
  cursor: pointer;
  transition: background .18s, transform .14s var(--os-ease);
}
.os-btn-ghost:hover { background: var(--os-surface-soft); }
.os-btn-ghost:active { transform: scale(.97); }
.os-btn-solid {
  border: 0;
  background: var(--os-surface-sunk);
  color: var(--os-ink-faint);
  border-radius: 999px;
  padding: .65rem 1.5rem;
  font-size: .92rem;
  font-weight: 700;
  cursor: pointer;
  transition: background .2s, color .2s, transform .14s var(--os-ease), box-shadow .2s;
}
/* Only lights up once the field has something worth saving. */
.os-btn-solid.is-ready { background: var(--os-accent); color: #fff; box-shadow: 0 6px 18px rgba(16, 17, 20, .28); }
.os-btn-solid:active { transform: scale(.97); }

/* ── Toast ────────────────────────────────────────────────────────────────── */

.os-toast {
  position: absolute;
  left: 50%;
  bottom: 26px;
  z-index: 20;
  transform: translate(-50%, 130%);
  background: #24262f;
  color: #fff;
  border-radius: 14px;
  padding: .85rem 1.2rem;
  font-size: .92rem;
  font-weight: 600;
  box-shadow: var(--os-shadow-lg);
  max-width: min(88vw, 420px);
  transition: transform .38s var(--os-ease-out), opacity .25s;
  opacity: 0;
  pointer-events: none;
}
.os-toast.is-visible { transform: translate(-50%, 0); opacity: 1; }

/* ── Entry point on the dashboard header ──────────────────────────────────── */

/* The "Try On" action lives top-right with the other header buttons and is the
   headline action of the page — so it is the ONE solid black button in the
   header. Everything else there (Upload, Generate, Bulk Import, Create
   Collection) is a light secondary, which is what makes this one read as the
   primary at a glance; a second black button next to it destroys that. The
   subtle idle shimmer pulls the eye once without adding another colour. */
.arf-btn-tryon {
  background: linear-gradient(135deg, #26272c, #0d0e11);
  color: #fff;
  border-color: transparent;
  position: relative;
  overflow: hidden;
  box-shadow: 0 4px 16px rgba(0, 0, 0, .28);
}
.arf-btn-tryon:hover { filter: brightness(1.35); transform: translateY(-1px); box-shadow: 0 8px 22px rgba(0, 0, 0, .34); }
.arf-btn-tryon::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: 45%;
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .3), transparent);
  transform: translateX(-160%);
  animation: os-shimmer 4.5s ease-in-out infinite;
  pointer-events: none;
}
@keyframes os-shimmer {
  0%, 62% { transform: translateX(-160%); }
  84%     { transform: translateX(260%); }
  100%    { transform: translateX(260%); }
}

/* Per-card quick try-on button on the artifacts grid. */
.arf-card-btn.arf-quick-tryon { color: #fff; background: rgba(16, 17, 20, .82); }
.arf-card-btn.arf-quick-tryon:hover { background: rgba(16, 17, 20, 1); }

/* ── Item search + infinite scroll (catalogue browsing) ─────────────────── */

.os-search {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: .55rem;
  margin: .5rem 1rem .1rem;
  padding: .55rem .9rem;
  background: var(--os-surface-soft);
  border: 1px solid var(--os-line);
  border-radius: 999px;
  color: var(--os-ink-faint);
}
.os-search:focus-within {
  background: var(--os-surface);
  border-color: var(--os-ink-soft);
  color: var(--os-ink-soft);
}
.os-search input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: transparent;
  font: inherit;
  font-size: .92rem;
  color: var(--os-ink);
  outline: none;
}
.os-search input::placeholder { color: var(--os-ink-faint); }
.os-search input::-webkit-search-cancel-button { -webkit-appearance: none; }

/* Infinite-scroll trigger: invisible on purpose — it only exists to be
   scrolled into view. Kept 1px tall so it never paints. */
.os-sentinel { height: 1px; flex: 0 0 auto; }

/* First page in flight: shape-holding placeholders instead of a flash of
   "nothing here". Same silhouette as a real tile. */
.os-tile.os-tile-skeleton {
  aspect-ratio: 1 / 1;
  min-height: var(--os-card-h);
  border-radius: var(--os-radius-sm);
  background: var(--os-surface-soft);
  animation: os-skeleton-pulse 1.2s ease-in-out infinite;
}
@keyframes os-skeleton-pulse {
  0%, 100% { opacity: .55; }
  50% { opacity: 1; }
}
