/* Ornament Generator — runtime UI styles.
   Injected into the actual runtime mount root (Shadow-DOM safe). */

/* ══════════════════════════════════════════════════════════════════════
 * Atomm generator layout spec — Figma "生成器布局规范" v1.2, section
 * "4ad · 生成悬浮350 · Tab顶部居中 · 无右栏"
 * (file b2y2t6TeE2vTSuta75kS1Z, section 150:17947, frame Main 150:17948).
 *
 * The design tokens below are taken from that file. Do not hand-tune:
 * change the design, re-read it, then change this.
 * ══════════════════════════════════════════════════════════════════════ */
.ornament-app {
  /* ---- design tokens (Figma) ---- */
  /* color/text */
  --t-primary: #171719;
  --t-secondary: #3d3e42;
  --t-tertiary: #929499;
  --t-body: #1c1c21;
  --t-label: #8c8c94;        /* "View all" */
  --t-inverse: #ffffff;
  /* color/stroke */
  --stroke: #d9dbde;
  --divider: rgba(61, 62, 66, 0.08);
  /* color/bg */
  --bg-default: #ffffff;
  --bg-subtlest: #fcfcfd;
  --bg-surface: #f8f9fa;
  --bg-control: #f1f2f4;
  --bg-hover: rgba(61, 62, 66, 0.06);
  --bg-segmented-active: #ffffff;
  /* color/brand + warning */
  --brand: #292a2d;
  --warning: #ff6500;
  --warning-disabled: rgba(240, 106, 0, 0.4);
  /* radius / space */
  --r-sm: 4px; --r-md: 6px; --r-lg: 8px; --r-xl: 12px; --r-circle: 100px;
  --sp-50: 4px; --sp-75: 6px; --sp-100: 8px; --sp-150: 12px; --sp-200: 16px;
  /* effect/shadow-100 and the footer's shadow-200(-) */
  --shadow-100: 0 0 4px rgba(11, 11, 13, 0.06), 0 4px 12px rgba(11, 11, 13, 0.06);
  /* The footer's shadow-200(-) reads as `0 -2px 8px rgba(0,0,0,.1),
     0 -16px 32px rgba(0,0,0,.1)` on the spec node, but that frame has NO
     fill — Figma casts a drop shadow from an unfilled frame's content
     alpha, so in the design it is only a ~1% hint against the divider.
     Our bar has a white background, where the same numbers paint a 10%
     band 40px up over the scrolling list. Matched to the rendered design
     instead: the 1px divider does the separating, the shadow only hints
     that content runs underneath. */
  --shadow-200-up: 0 -1px 4px rgba(0, 0, 0, 0.04);

  /* ---- legacy aliases ----
     The generator predates the spec and its modal / detail / 3D chrome still
     reads these. Remapping here retones the whole app in one place; the
     components with explicit spec geometry are restyled individually below. */
  --bg: var(--bg-control);
  --surface: var(--bg-default);
  --border: var(--divider);
  --text: var(--t-primary);
  --muted: var(--t-tertiary);
  --accent: var(--t-primary);
  --accent-deep: var(--brand);
  --accent-soft: var(--bg-hover);
  --danger: #dc2626;
  --radius: var(--r-lg);
  --shadow: var(--shadow-100);

  position: relative;
  width: 100%;
  height: 100%;
  min-height: 560px;
  overflow: hidden;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  font-size: 14px;
}

.ornament-app *, .ornament-app *::before, .ornament-app *::after { box-sizing: border-box; }
.ornament-app button { font: inherit; cursor: pointer; border: none; padding: 0; background: none; color: inherit; }
.ornament-app img { display: block; max-width: 100%; }

/* ---------- stage ---------- */
.stage {
  position: absolute;
  inset: 0;
  overflow: hidden;
}
.stage-star {
  position: absolute;
  font-size: 14px;
  opacity: 0.55;
  pointer-events: none;
  user-select: none;
}

/* ---------- left panel ---------- */
/* Floating Sidebar (spec node 150:17952): 360 wide, inset 16, r12, shadow-100. */
.side-panel {
  position: absolute;
  left: var(--sp-200);
  top: var(--sp-200);
  bottom: var(--sp-200);
  width: 360px;
  background: var(--bg-default);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-100);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  z-index: 5;
}
.side-views {
  position: relative;
  flex: 1;
  min-height: 0;
}
.side-view {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  transition: transform 0.28s ease;
}
/* overlay views (Type detail, history) slide in over the primary view */
.side-view.is-overlay { transform: translateX(100%); }
.side-panel.show-detail .side-view.is-primary,
.side-panel.show-history .side-view.is-primary { transform: translateX(-24%); }
.side-panel.show-detail .side-view.is-detail,
.side-panel.show-history .side-view.is-history { transform: translateX(0); }

.side-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  /* Content block of the Floating Sidebar: p16. */
  padding: var(--sp-200);
  /* native scrollbar fully hidden — a JS overlay thumb (.scroll-ghost) shows
     only while scrolling, so no gutter is reserved */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* legacy Edge */
}
.side-scroll::-webkit-scrollbar { display: none; }
/* overlay scrollbar thumb, appended next to each .side-scroll */
.scroll-ghost {
  position: absolute;
  top: 0;
  right: 4px;
  width: 5px;
  border-radius: 999px;
  background: rgba(24, 24, 27, 0.26);
  opacity: 0;
  transition: opacity 0.25s ease;
  pointer-events: none;
  z-index: 3;
}
.scroll-ghost.is-visible { opacity: 1; }

.cat-section { margin-bottom: 20px; }
.cat-section:last-child { margin-bottom: 0; }

/* Sidebar/Section Header (spec node 71:7307) */
.cat-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-100);
  margin-bottom: var(--sp-150);
}
.cat-icon {
  width: 24px;
  height: 24px;
  border-radius: var(--r-sm);
  background: var(--bg-control);
  display: grid;
  place-items: center;
  color: var(--t-secondary);
  flex: 0 0 auto;
}
.cat-icon svg { width: 14px; height: 14px; display: block; }
.icon-slot, .cat-icon, .my-cards-icon {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
}
.cat-title {
  flex: 1;
  min-width: 0;
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: var(--t-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ornament-app .cat-viewall {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-50);
  flex: none;
  padding: 0;
  font-size: 11px;
  font-weight: 400;
  line-height: 16px;
  color: var(--t-label);
}
.ornament-app .cat-viewall:hover { color: var(--t-secondary); }
.cat-viewall .chev { display: inline-flex; width: 16px; height: 16px; }
.cat-viewall .chev svg { width: 16px; height: 16px; display: block; }

/* Items Row (spec node 150:18661): three equal columns, gap 8. The old design
   was a drag-scrollable rail with a half-cropped 4th card; the spec shows a
   fixed 3-up preview with "View all" as the way to see the rest. */
.style-rail {
  display: grid;
  /* minmax(0, 1fr) because a bare 1fr floors at min-content, and a long
     nowrap label would widen its whole column */
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--sp-100);
  align-items: start;
}

/* "View all" detail view: every style in the Type, two per row */
.detail-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-100);
  align-items: start;
  padding-bottom: var(--sp-150);
}
.ornament-app .style-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-75);
  text-align: center;
  padding: 0;
}
/* Image Frame (spec node 150:18649): r8 + a 1px divider hairline, selection is
   a 2px ink border. The spec sizes the frame to the collection's own content
   aspect (its banknote row is 99:64, its coin row 1:1) — card covers are
   authored at 2:3 (341×512), so that is the aspect here and nothing is cropped. */
.style-thumb {
  aspect-ratio: 1 / 1;
  border-radius: var(--r-lg);
  background: var(--bg-control);
  border: 1px solid var(--border);
  display: grid;
  place-items: center;
  overflow: hidden;
  transition: border-color 0.15s ease;
}
.style-card:hover .style-thumb { border-color: var(--stroke); }
.style-card.is-active .style-thumb { border: 2px solid var(--t-primary); }
.ornament-app .style-name {
  font-size: 11px;
  font-weight: 400;
  line-height: 16px;
  color: var(--t-secondary);
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* CSS-rendered ornament thumbnail (fallback art): a round disc with a hole */
.card-thumb {
  width: 72%;
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  position: relative;
  display: grid;
  place-items: center;
}
.card-thumb .card-hole {
  position: absolute;
  top: 7%;
  left: 50%;
  width: 11%;
  height: 11%;
  margin-left: -5.5%;
  border-radius: 50%;
  background: var(--bg-control);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.35);
}
.card-thumb .card-glyph {
  font-size: 26px;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
}

/* pre-generated cover image inside a card thumb */
/* No radius on the cover: the frame clips it with overflow:hidden, and that clip
   follows the border's INNER curve (border-radius − border-width). Any radius
   here rounds harder than that curve — 8px against the 6px inner edge of the
   2px selected border — and notches all four corners. */
.style-cover { width: 100%; height: 100%; display: grid; place-items: center; }
.style-cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* my cards entry (bottom of the panel) */
/* `.ornament-app` prefix needed: the generic `.ornament-app button` reset would
   otherwise win over a single-class selector and strip the background */
/* Footer + Sidebar/Footer Item (spec nodes 150:18683 / 71:7314): a 54px bar
   pinned to the bottom of the card, lifted off the list with a divider and
   shadow-200(-) so the catalog scrolls under it. */
.ornament-app .my-cards-bar {
  flex: none;
  width: 100%;
  height: 54px;
  padding: var(--sp-100) 18px;
  display: flex;
  align-items: center;
  gap: var(--sp-100);
  background: var(--bg-default);
  border-top: 1px solid var(--divider);
  box-shadow: var(--shadow-200-up);
  border-radius: 0;
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  color: var(--t-primary);
  transition: background 0.15s ease;
}
.my-cards-bar:hover { background: var(--bg-hover); }
.my-cards-bar .grow { flex: 1; text-align: left; }
.my-cards-icon {
  width: 24px;
  height: 24px;
  border-radius: var(--r-sm);
  background: var(--bg-control);
  color: var(--t-secondary);
}
.my-cards-icon svg { width: 14px; height: 14px; display: block; }
.my-cards-bar .chev { display: inline-flex; width: 16px; height: 16px; color: var(--t-label); }
.my-cards-bar .chev svg { width: 16px; height: 16px; display: block; }

/* ---------- secondary panels (Type detail, My history) ---------- */
.panel-head {
  flex: none;
  display: flex;
  align-items: center;
}
/* Back-to-primary row (spec: Footer / Footer Content / Sidebar-Footer Item).
   Same component as the "My history" footer row, so the whole 54px row is the
   control; the spec's Separator is display:none, hence no rule and no shadow. */
.ornament-app .panel-back-row {
  width: 100%;
  height: 54px;
  padding: var(--sp-100) 18px;
  display: flex;
  align-items: center;
  gap: var(--sp-100);
  background: var(--bg-default);
  border-radius: 0;
  transition: background 0.15s ease;
}
.panel-back-row:hover { background: var(--bg-hover); }
.panel-back-icon {
  flex: 0 0 auto;
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  border-radius: var(--r-sm);
  background: var(--bg-control);
  color: var(--t-label);
}
.panel-back-icon svg { width: 16px; height: 16px; display: block; }
.panel-back-label {
  flex: 1;
  text-align: left;
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  color: var(--t-primary);
}

.history-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
  padding: 4px 2px 12px;
}
.ornament-app .history-card {
  border-radius: 12px;
  overflow: hidden;
  border: 1px solid var(--border);
  background: #f4f4f5;
  aspect-ratio: 1 / 1;
  position: relative;
  padding: 0;
}
.history-card img { width: 100%; height: 100%; object-fit: cover; }
.history-card:hover { box-shadow: 0 6px 16px rgba(24, 24, 27, 0.14); }
.history-card.is-loading { opacity: 0.55; cursor: progress; }
.history-card .badge {
  position: absolute;
  right: 6px;
  bottom: 6px;
  background: rgba(31, 36, 48, 0.72);
  color: #fff;
  font-size: 11px;
  border-radius: 8px;
  padding: 2px 7px;
}
.history-card .center-note {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--muted);
  font-size: 12px;
  padding: 8px;
  text-align: center;
}
.history-empty, .history-login {
  color: var(--muted);
  text-align: center;
  padding: 42px 18px;
  font-size: 13px;
  line-height: 1.6;
}
.ornament-app .history-more {
  display: block;
  width: 100%;
  padding: 10px;
  margin: 2px 0 12px;
  border-radius: 12px;
  background: #f4f4f5;
  color: var(--text);
  font-weight: 600;
}
.history-more:hover { background: #e9e9ec; }

/* ---------- canvas area (infinite canvas) ---------- */
.canvas-area {
  position: absolute;
  /* panel inset + panel width + panel inset (16 + 360 + 16); also how far the
     3D stage extends back under the panel */
  --canvas-left: 392px;
  left: var(--canvas-left);
  right: 0;
  top: 0;
  bottom: 0;
  overflow: hidden;
}
.canvas-area.is-solo { --canvas-left: 0px; left: 0; }
.canvas-area.has-content { cursor: grab; }
.canvas-area.is-panning { cursor: grabbing; }

/* transformed world layer — translate/scale via the view state */
.world-layer {
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 0 0;
  will-change: transform;
}
.world-cell {
  position: absolute;
  display: block;
  padding: 0;
  /* no card: the result is a keyed-out PIECE floating on the stage, so the
     cell itself paints nothing — no fill, no radius, no clipping, no shadow */
  background: transparent;
  border: none;
  border-radius: 0;
  overflow: visible;
  box-shadow: none;
}
/* the raster — the original transparent PNG as an <img>, or a keyed canvas when
   the service handed back an opaque one; the shadow follows the silhouette,
   which is what sells it as a physical cut piece */
.world-cell .result-art {
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.world-cell .result-art { filter: drop-shadow(0 18px 30px rgba(15, 23, 42, 0.26)); }
/* grow into place instead of popping — the loading card is much smaller than
   the fitted result, so an instant swap reads as a jolt. Only on NEW results;
   re-renders (multi-select) don't replay it. */
.world-cell.is-new { animation: cell-reveal 0.55s cubic-bezier(0.22, 0.9, 0.3, 1); }
@keyframes cell-reveal {
  from { opacity: 0; transform: scale(0.86) translateY(14px); }
}
/* the cut outline (src/outline.js) laid over the raster: red = cut, the same
   paths the SVG export ships; a constant 1.5px on screen at every zoom */
.world-cell .cut-overlay {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
}
.world-cell .cut-overlay path { stroke-width: 1.5px; stroke-linejoin: round; }
/* selection affordance only for legacy multi-image tasks */
.world-cell.is-selected {
  outline: 3px solid var(--accent);
  outline-offset: 6px;
}
.world-cell .check {
  position: absolute;
  top: -4px;
  right: -4px;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  display: grid;
  place-items: center;
  font-size: 14px;
  box-shadow: 0 2px 6px rgba(24, 24, 27, 0.28);
}

/* centered overlay for empty / generating / failed states */
.canvas-overlay {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 32px;
  pointer-events: none;
}
.canvas-overlay .btn-again { pointer-events: auto; }

/* floating top bar: the 2D/3D stage switch, top-right of the canvas */
.canvas-topbar {
  position: absolute;
  top: var(--sp-200);
  left: var(--sp-200);
  right: var(--sp-200);
  display: flex;
  /* the spec centres the segmented control over the canvas area, which is what
     this bar spans (canvas-area starts after the floating panel) */
  justify-content: center;
  align-items: flex-start;
  gap: 8px;
  z-index: 4;
  pointer-events: none;
}
.canvas-topbar > * { pointer-events: auto; }
/* Segmented control, top centre (spec node 71:6424): 32 high, p2, r6, active
   segment is white with a drop shadow. */
.stage-mode {
  display: inline-flex;
  align-items: center;
  height: 32px;
  padding: 2px;
  background: var(--bg-hover);
  border-radius: var(--r-md);
}
.stage-mode:empty { display: none; }
.ornament-app .view-tab {
  height: 100%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 80px;
  padding: var(--sp-75) var(--sp-100);
  border-radius: 5px;
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: var(--t-tertiary);
  white-space: nowrap;
  transition: color 0.14s ease;
}
.ornament-app .view-tab:hover { color: var(--t-secondary); }
.ornament-app .view-tab.is-active {
  background: var(--bg-segmented-active);
  color: var(--t-primary);
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}

/* 3D stage */
.three-stage {
  position: absolute;
  inset: 0;
  display: none;
  user-select: none;
}
.three-stage.is-active { display: block; }
.three-stage canvas { display: block; width: 100%; height: 100%; }
.canvas-area.is-3d { cursor: default; }
/* The 3D scene paints its own backdrop and reflective floor. Confined to the
   canvas area it would stop dead at the panel's edge, leaving a slab of flat
   page background on the left instead of the panel floating over a whole
   scene. Extend it back under the panel; safe to unclip here because the 2D
   world layer and zoom controls are hidden in 3D anyway. The renderer
   compensates for the covered strip so the card stays centred in what you
   can see. */
.canvas-area.is-3d { overflow: visible; }
.canvas-area.is-3d .three-stage {
  left: calc(-1 * var(--canvas-left));
  z-index: 0;
}
.canvas-area.is-3d .world-layer,
.canvas-area.is-3d .zoom-controls,
.canvas-area.is-3d .stage-star { display: none; }

/* Zoom Controls, bottom-left (spec node 71:6425). Bottom-right belongs to the
   shell's Buy Materials + Export block. 32×32 white tiles, r8, gap 4, no
   border or shadow — they sit on the flat color/bg/control ground. */
.zoom-controls {
  position: absolute;
  left: var(--sp-200);
  bottom: var(--sp-200);
  display: flex;
  gap: var(--sp-50);
  z-index: 4;
}
.canvas-area:not(.has-content) .zoom-controls { display: none; }
.ornament-app .zoom-btn {
  width: 32px;
  height: 32px;
  padding: 0;
  border-radius: var(--r-lg);
  background: var(--bg-default);
  border: none;
  font-size: 14px;
  font-weight: 400;
  color: var(--t-secondary);
  display: grid;
  place-items: center;
  transition: background 0.14s ease;
}
.zoom-btn:hover { background: #e9eaec; }
.zoom-btn:active { transform: scale(0.94); }
.ornament-app .zoom-pct { width: 51px; font-size: 12px; }

.empty-state { text-align: center; max-width: 440px; }
/* a dealt hand of three REAL covers (one per Type) — the empty state
   advertises the product itself, not a placeholder drawing */
.empty-fan {
  position: relative;
  width: 296px;
  height: 236px;
  margin: 0 auto 22px;
  animation: placeholder-float 6s ease-in-out infinite;
}
.fan-card {
  position: absolute;
  width: 132px;
  aspect-ratio: 1 / 1;
  border-radius: 12px;
  overflow: hidden;
  background: #e9e9ec;
  box-shadow: 0 16px 36px rgba(24, 24, 27, 0.22), 0 0 0 4px #fff;
}
.fan-card img { width: 100%; height: 100%; object-fit: cover; }
.fan-left { left: 4px; top: 28px; transform: rotate(-11deg); z-index: 1; }
.fan-center { left: 82px; top: 4px; z-index: 2; box-shadow: 0 22px 44px rgba(24, 24, 27, 0.26), 0 0 0 4px #fff; }
.fan-right { left: 160px; top: 28px; transform: rotate(11deg); z-index: 1; }
@keyframes placeholder-float {
  0%, 100% { transform: translateY(0) rotate(-0.6deg); }
  50% { transform: translateY(-12px) rotate(0.6deg); }
}
@media (prefers-reduced-motion: reduce) {
  .empty-fan { animation: none; }
}
.empty-title { font-size: 21px; font-weight: 700; margin: 0 0 8px; }
.empty-sub { color: var(--muted); margin: 0; line-height: 1.6; }

/* generating */
.gen-state { text-align: center; }

/* ---------- generating cutscene ----------
   An ornament being made: a round piece hangs from a thread and sways while a
   laser dot travels once around the rim, drawing the red cut line behind it
   (the same red the export ships); then an engraving beam sweeps the face.
   Real artwork on the face (the user's photo, or a blurred style-cover ghost),
   few elements, slow soft motion. */
.gen-stage {
  position: relative;
  width: 280px;
  height: 320px;
  margin: 0 auto 20px;
}
/* soft breathing light behind the piece — warm, like light through wood */
.gen-glow {
  position: absolute;
  left: 50%;
  top: 176px;
  width: 300px;
  height: 300px;
  margin: -150px 0 0 -150px;
  background: radial-gradient(circle, rgba(255, 196, 120, 0.30), rgba(255, 196, 120, 0.10) 44%, transparent 68%);
  filter: blur(6px);
  animation: glow-breathe 3.4s ease-in-out infinite;
}
@keyframes glow-breathe {
  0%, 100% { opacity: 0.85; transform: scale(1); }
  50% { opacity: 0.55; transform: scale(0.94); }
}
/* everything that hangs swings together from the pivot at the top: a slow
   pendulum with a tiny out-of-phase twist so the light moves on the face */
.gen-hang {
  position: absolute;
  inset: 0;
  transform-origin: 50% 0;
  animation: gen-swing 4.6s ease-in-out infinite alternate;
}
@keyframes gen-swing {
  from { transform: rotate(-4.5deg); }
  to { transform: rotate(4.5deg); }
}
.gen-thread {
  position: absolute;
  left: 50%;
  top: 0;
  width: 2px;
  height: calc(var(--cy, 176px) - var(--r, 92px) + 10px);
  margin-left: -1px;
  background: linear-gradient(180deg, rgba(120, 118, 112, 0), #8d8a84 18%, #8d8a84);
}
/* the piece: a round disc with the hanging hole at 12 o'clock. The face
   (photo / ghost) is clipped to the disc; the wood fallback fill shows until
   it decodes, and stays as the material under a ghosted cover */
.gen-disc {
  --r: 92px;
  --cy: 176px;
  position: absolute;
  left: 50%;
  top: var(--cy);
  width: calc(var(--r) * 2);
  height: calc(var(--r) * 2);
  margin: calc(var(--r) * -1) 0 0 calc(var(--r) * -1);
  border-radius: 50%;
  overflow: hidden;
  background:
    radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.18), transparent 55%),
    linear-gradient(160deg, #d9b98f, #c9a679 55%, #b8946a);
  box-shadow:
    0 22px 40px rgba(24, 24, 27, 0.26),
    inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.gen-hole {
  position: absolute;
  left: 50%;
  top: 12px;
  width: 15px;
  height: 15px;
  margin-left: -7.5px;
  border-radius: 50%;
  background: var(--bg-control);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.5);
  z-index: 2;
}
/* the face fades in once its image has decoded */
.gen-face {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.45s ease;
}
.gen-face.is-loaded { opacity: 1; }
/* idea-only runs: the style cover is only a tone-setting ghost — blurred past
   recognition so it never reads as someone else's finished ornament */
.gen-disc.is-ghost .gen-face {
  filter: blur(10px) saturate(0.8) brightness(1.05);
  transform: scale(1.12); /* hide the blur's soft edges */
}
.gen-disc.is-ghost .gen-face.is-loaded { opacity: 0.6; }
/* engraving beam sweeping down the face, after the cut line has closed */
.gen-scan {
  position: absolute;
  left: -10%;
  right: -10%;
  top: 0;
  height: 30%;
  background: linear-gradient(180deg,
    transparent,
    rgba(255, 255, 255, 0.30) 42%,
    rgba(255, 244, 214, 0.72) 50%,
    rgba(255, 255, 255, 0.30) 58%,
    transparent);
  filter: blur(1px);
  mix-blend-mode: screen;
  opacity: 0;
  animation: scan-sweep 6s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
@keyframes scan-sweep {
  0%, 52% { transform: translateY(-110%); opacity: 0; }
  58% { opacity: 1; }
  92% { transform: translateY(340%); opacity: 1; }
  96%, 100% { transform: translateY(340%); opacity: 0; }
}
/* the cut line: a faint track around the rim, and the red line drawing itself
   along it once per cycle (dashoffset from full length to 0), holding while the
   engraving beam runs, then resetting for the next lap */
.gen-cut {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}
.gen-cut-track {
  fill: none;
  stroke: rgba(24, 24, 27, 0.10);
  stroke-width: 1;
  stroke-dasharray: 3 5;
}
.gen-cut-line {
  fill: none;
  stroke: #ff0000;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: var(--len);
  stroke-dashoffset: var(--len);
  filter: drop-shadow(0 0 3px rgba(255, 40, 40, 0.55));
  animation: cut-draw 6s linear infinite;
}
@keyframes cut-draw {
  0% { stroke-dashoffset: var(--len); opacity: 1; }
  50% { stroke-dashoffset: 0; opacity: 1; }
  94% { stroke-dashoffset: 0; opacity: 1; }
  100% { stroke-dashoffset: 0; opacity: 0; }
}
/* the laser head riding the cut path, hot white core + red halo, hidden while
   the engraving beam has the stage */
.gen-laser {
  position: absolute;
  left: 0;
  top: 0;
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  border-radius: 50%;
  background: #fff8f0;
  box-shadow:
    0 0 6px 2px rgba(255, 120, 80, 0.95),
    0 0 18px 6px rgba(255, 40, 40, 0.45);
  offset-rotate: 0deg;
  animation: laser-ride 6s linear infinite, laser-flicker 0.18s steps(2) infinite;
}
@keyframes laser-ride {
  0% { offset-distance: 0%; opacity: 1; }
  49.5% { offset-distance: 100%; opacity: 1; }
  50%, 100% { offset-distance: 100%; opacity: 0; }
}
@keyframes laser-flicker {
  from { transform: scale(1); }
  to { transform: scale(1.25); }
}
/* a few quiet twinkles around the piece */
.gen-spark2 {
  position: absolute;
  color: #dcae62;
  opacity: 0;
  font-size: var(--s);
  animation: twinkle var(--t) ease-in-out infinite;
  animation-delay: var(--d);
  pointer-events: none;
}
@keyframes twinkle {
  0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
  50% { opacity: 0.85; transform: scale(1) rotate(24deg); }
}

@media (prefers-reduced-motion: reduce) {
  .gen-glow, .gen-hang, .gen-scan, .gen-cut-line, .gen-laser, .gen-spark2 { animation: none; }
  .gen-scan, .gen-laser { display: none; }
  .gen-cut-line { stroke-dashoffset: 0; } /* the finished cut line, static */
}
.gen-title { font-size: 18px; font-weight: 700; margin: 0 0 6px; }
.gen-sub {
  color: var(--muted);
  margin: 0;
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.gen-sub.is-swapping { opacity: 0; transform: translateY(5px); }

.btn-again {
  background: var(--text);
  color: #fff;
  border-radius: 999px;
  padding: 8px 16px;
  font-weight: 600;
  font-size: 13px;
}
.btn-again:hover { background: #343b4d; }

/* failed */
.fail-state { text-align: center; max-width: 420px; }
.fail-icon { font-size: 40px; margin-bottom: 14px; }
.fail-title { font-size: 17px; font-weight: 700; margin: 0 0 8px; }
.fail-sub { color: var(--muted); margin: 0 0 18px; line-height: 1.6; }

/* ---------- modal ---------- */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(31, 36, 48, 0.45);
  display: grid;
  place-items: center;
  z-index: 60;
  animation: fade-in 0.18s ease;
}
@keyframes fade-in { from { opacity: 0; } }

/* ══════════════════════════════════════════════════════════════════════
 * Generate Modal — Figma "生成器布局规范原型", node 226:656
 * (file Zn2c5eGQP7jHjd4DW6hriV). Same modal appears in the spec file
 * b2y2t6TeE2vTSuta75kS1Z at 150:18033.
 * ══════════════════════════════════════════════════════════════════════ */
.modal-card {
  width: min(480px, calc(100vw - 48px));
  max-height: calc(100vh - 64px);
  background: var(--bg-default);
  border-radius: var(--r-xl);
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28);
  animation: pop-in 0.2s ease;
  /* Head, divider and foot stay pinned; only .modal-body scrolls, so the
     scrollbar never runs behind the close or Generate button. */
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
@keyframes pop-in { from { transform: scale(0.96); opacity: 0; } }

/* Header (226:657). The dark fill is one radial gradient, authored in the
   design as r=10 scaled by matrix(48, 40.517) at (86.4, 18.638) over the
   480×94 header — i.e. a 480×405px ellipse centred at 18%/19.8%, which is
   why the glow sits behind the thumbnail and falls off to the right. */
.modal-head {
  flex: none;
  display: flex;
  align-items: flex-start;
  gap: 14px;
  padding: var(--sp-200) var(--sp-150) var(--sp-150) var(--sp-200);
  background: radial-gradient(100% 431% at 18% 19.8%, #45454e 0%, #303038 36%, #1c1c21 72%);
}
.head-thumb {
  width: 66px;
  aspect-ratio: 1 / 1;
  flex: none;
  /* 226:658 sets a loose 10px here — no radius token matches it */
  border-radius: 10px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.08);
}
.head-thumb .style-cover,
.head-thumb .style-cover img { width: 100%; height: 100%; }
/* contain, not cover: a cover that ever ships at another ratio letterboxes
   instead of losing the edges of the card */
.head-thumb .style-cover img { border-radius: 0; object-fit: contain; }
.head-thumb .card-thumb { width: 100%; }
.head-text {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--sp-100);
}
.modal-title {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  line-height: 22px;
  color: var(--t-inverse);
}
/* Tags (226:662): output ratio + what the style asks of the user */
.head-tags { display: flex; flex-wrap: wrap; gap: var(--sp-75); }
.head-tag {
  height: 20px;
  display: inline-flex;
  align-items: center;
  padding: 2px var(--sp-75);
  border-radius: var(--r-sm);
  background: rgba(231, 233, 237, 0.5);
  font-size: 10px;
  font-weight: 500;
  line-height: 14px;
  color: var(--t-inverse);
  white-space: nowrap;
}
/* GhostButton (226:665): 24px hit area, 18px glyph */
.ornament-app .modal-close {
  flex: none;
  width: 24px;
  height: 24px;
  border-radius: var(--r-sm);
  display: grid;
  place-items: center;
  background: none;
  color: var(--t-inverse);
  transition: background 0.15s ease;
}
.ornament-app .modal-close:hover { background: rgba(255, 255, 255, 0.14); }
.modal-close svg { display: block; }

/* Content (226:666) — the only scrolling region. A slim inset pill scrollbar
   (visible when an external mouse forces classic scrollbars); the 3px
   transparent border makes the thumb read as a 6px pill floating inside the
   body rather than a bar glued to the edge. */
.modal-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--sp-150);
  padding: var(--sp-150) var(--sp-200) var(--sp-200);
  scrollbar-width: thin; /* Firefox */
  scrollbar-color: #d4d4d9 transparent;
}
.modal-body::-webkit-scrollbar { width: 12px; }
.modal-body::-webkit-scrollbar-track { background: transparent; }
.modal-body::-webkit-scrollbar-thumb {
  background: #d4d4d9;
  border-radius: 999px;
  border: 3px solid transparent;
  background-clip: padding-box;
}
.modal-body::-webkit-scrollbar-thumb:hover { background-color: #bcbcc4; background-clip: padding-box; }

/* Upload Area (226:669). `.ornament-app` prefix needed: the dropzone is a
   <button>, so the generic `.ornament-app button` reset would strip the dashed
   border and the fill. */
.ornament-app .dropzone {
  width: 100%;
  height: 115px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-50);
  border: 1px dashed var(--divider);
  border-radius: var(--r-lg);
  background: var(--bg-surface);
  padding: var(--sp-150);
  text-align: center;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.ornament-app .dropzone:hover,
.ornament-app .dropzone.is-drag {
  border-color: var(--t-tertiary);
  background: var(--bg-control);
}
.dropzone .dz-icon {
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  color: var(--t-primary);
}
.dropzone .dz-icon svg { display: block; transition: transform 0.15s ease; }
.dropzone:hover .dz-icon svg, .dropzone.is-drag .dz-icon svg { transform: translateY(-2px); }
.dropzone .dz-main {
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: var(--t-primary);
}
.dropzone .dz-sub {
  font-size: 11px;
  font-weight: 400;
  line-height: 16px;
  color: var(--t-tertiary);
}

/* Filled state of the Upload Area: not drawn in the spec, but it takes the
   same slot, so it keeps the Area's box (115px, r8, surface fill) and its
   buttons match the footer's Cancel. */
.upload-preview {
  display: flex;
  gap: var(--sp-150);
  align-items: center;
  height: 115px;
  border: 1px solid var(--divider);
  border-radius: var(--r-lg);
  padding: var(--sp-150);
  background: var(--bg-surface);
}
.upload-frame {
  width: 91px;
  height: 91px;
  flex: none;
  border-radius: var(--r-md);
  overflow: hidden;
  border: 1px solid var(--divider);
  background:
    conic-gradient(#eee 0 25%, #fff 0 50%, #eee 0 75%, #fff 0) 0 0 / 16px 16px;
}
.upload-frame img { width: 100%; height: 100%; object-fit: cover; }
.upload-actions { display: flex; flex-direction: column; gap: var(--sp-150); min-width: 0; }
.upload-filename {
  font-size: 11px;
  font-weight: 400;
  line-height: 16px;
  color: var(--t-tertiary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 260px;
}
.upload-btns { display: flex; gap: var(--sp-100); }
.ornament-app .btn-ghost {
  height: 32px;
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--stroke);
  border-radius: var(--r-md);
  padding: var(--sp-75) var(--sp-150);
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  color: var(--t-secondary);
  background: var(--bg-default);
}
.btn-ghost:hover { background: var(--bg-hover); }
.btn-ghost.is-danger:hover { background: #fdeeee; border-color: #efc7c5; color: var(--danger); }

/* label + control pair; Content spaces the pairs 12px apart */
.field-group { display: flex; flex-direction: column; gap: var(--sp-100); }
/* Label (226:668 / 226:674): 12-medium, quiet. The design uses a loose
   #8a8a93 here; mapped to the token it rounds to, color/text/label. */
.modal-label {
  display: flex;
  align-items: center;
  gap: var(--sp-50);
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: var(--t-label);
}
.modal-label .opt { font-weight: 400; }
.prompt-input {
  width: 100%;
  height: 80px;
  border: none;
  border-radius: var(--r-lg);
  padding: 10px var(--sp-150);
  font: inherit;
  font-size: 12px;
  line-height: 16px;
  resize: vertical;
  background: var(--bg-control);
  color: var(--t-primary);
}
.prompt-input::placeholder { color: var(--t-tertiary); }
.prompt-input:focus { outline: 2px solid var(--stroke); outline-offset: -1px; }

/* magic wand inside the description textarea: taps in a ready-made example */
.prompt-wrap { position: relative; }
.prompt-wrap .prompt-input { padding-right: 42px; }
.ornament-app .wand-btn {
  position: absolute;
  right: 8px;
  top: 8px;
  width: 24px;
  height: 24px;
  border-radius: var(--r-sm);
  display: grid;
  place-items: center;
  font-size: 13px;
  background: var(--bg-default);
  transition: transform 0.12s ease, background 0.12s ease;
}
.ornament-app .wand-btn:hover { background: var(--bg-hover); transform: rotate(-8deg) scale(1.08); }
.ornament-app .wand-btn:active { transform: scale(0.92); }

/* guided slot fields: labeled inputs with example placeholders */
.slot-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px 12px;
}
.slot-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.slot-label { font-size: 12px; font-weight: 500; line-height: 16px; color: var(--t-label); }
/* quieter "(optional)" suffix on every optional field label */
.slot-opt { font-weight: 400; }
.req-star { color: var(--accent-deep); font-weight: 700; }
.ornament-app .slot-input {
  width: 100%;
  height: 32px;
  border: none;
  border-radius: var(--r-lg);
  padding: 0 var(--sp-150);
  font: inherit;
  font-size: 12px;
  line-height: 16px;
  background: var(--bg-control);
  color: var(--t-primary);
}
.ornament-app .slot-input::placeholder { color: var(--t-tertiary); }
.ornament-app .slot-input:focus { outline: 2px solid var(--stroke); outline-offset: -1px; }
/* a lone primary field spans the full width instead of sitting half-empty */
.slot-grid .slot-field:only-child { grid-column: 1 / -1; }
/* …same for a field left alone on its row after a full-row chips field */
.slot-grid .slot-field.slot-chips + .slot-field:last-child { grid-column: 1 / -1; }
/* chip fields (e.g. Tarot theme) always take the full row */
.slot-field.slot-chips { grid-column: 1 / -1; }
.chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
.ornament-app .chip {
  border: none;
  border-radius: var(--r-circle);
  padding: var(--sp-75) var(--sp-150);
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: var(--t-primary);
  background: var(--bg-control);
  transition: background 0.12s ease, color 0.12s ease;
}
/* two-part chip labels ("Love · The Lovers"): meaning leads, card name recedes */
.chip .chip-sub { font-weight: 400; color: var(--t-tertiary); }
.ornament-app .chip.is-on .chip-sub { color: rgba(255, 255, 255, 0.85); }
.ornament-app .chip:hover { background: var(--bg-hover); }
.ornament-app .chip.is-on {
  background: var(--brand);
  color: var(--t-inverse);
}
/* inline input revealed by the "Custom ✏️" chip */
.ornament-app .chip-custom-input { margin-top: 8px; }

/* Frame shape (外框形状): preset chips FILL the text box below on tap (回填).
   The chips sit above the box so the eye reads "pick one, or write your own". */
.shape-group .shape-chips { margin-bottom: 2px; }
.ornament-app .shape-input { height: 36px; }

/* "More details" disclosure keeps the few advanced slot fields out of the way */
.ornament-app .adv-toggle {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-50);
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: var(--t-label);
}
.ornament-app .adv-toggle:hover { color: var(--t-primary); }
.adv-toggle .adv-caret { font-size: 10px; }
.adv-section[hidden] { display: none; }

/* Divider (226:678) + Footer (226:679): note on the left, actions right */
.modal-divider { flex: none; height: 1px; background: #e3e3e8; }
.modal-foot {
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--sp-100);
  padding: 13px 18px;
}
.foot-note {
  flex: 1;
  min-width: 0;
  font-size: 11px;
  font-weight: 400;
  line-height: 16px;
  color: var(--t-tertiary);
}
.foot-actions { flex: none; display: flex; gap: var(--sp-100); }
/* `.ornament-app` prefix needed on these buttons: the generic `.ornament-app button`
   reset (higher specificity) would otherwise strip their backgrounds */
.ornament-app .btn-cancel {
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-75) var(--sp-150);
  border: 1px solid var(--stroke);
  border-radius: var(--r-md);
  background: var(--bg-default);
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  color: var(--t-secondary);
  transition: background 0.15s ease;
}
.ornament-app .btn-cancel:hover { background: var(--bg-hover); }
.ornament-app .btn-generate {
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-100);
  padding: 0 var(--sp-200);
  border-radius: var(--r-lg);
  background: var(--brand);
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  color: var(--t-inverse);
  transition: filter 0.15s ease;
}
/* the price rides inside the button (226:686) */
.btn-credits {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-50);
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
}
.btn-credits:empty { display: none; }
.credits-icon { display: block; flex: 0 0 auto; }
.btn-generate:hover:not(:disabled) { filter: brightness(1.25); }
.btn-generate:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
.btn-generate:active:not(:disabled) { transform: scale(0.98); }

/* ---------- embed panel ---------- */
.embed-panel { padding: 16px; background: var(--surface); }
.embed-panel .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.embed-panel label { font-size: 12.5px; color: var(--muted); font-weight: 600; }
.embed-panel select, .embed-panel input {
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px 10px;
  font: inherit;
  font-size: 13px;
  background: #fafafa;
  color: var(--text);
}

/* ---------- toast ---------- */
.toast-root {
  position: fixed;
  bottom: 28px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 80;
  display: flex;
  flex-direction: column;
  gap: 8px;
  align-items: center;
  pointer-events: none;
}
.toast {
  background: rgba(31, 36, 48, 0.92);
  color: #fff;
  border-radius: 12px;
  padding: 10px 18px;
  font-size: 13px;
  max-width: 420px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
  animation: toast-in 0.22s ease;
}
@keyframes toast-in { from { transform: translateY(8px); opacity: 0; } }

.world-cell.is-previewable { cursor: zoom-in; }

/* spinner */
.spinner {
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: 2.5px solid #e4e4e7;
  border-top-color: var(--accent);
  animation: spin 0.8s linear infinite;
  margin: 0 auto;
}
@keyframes spin { to { transform: rotate(360deg); } }

@media (max-width: 900px) {
  .side-panel { width: 280px; }
  .canvas-area { --canvas-left: 310px; padding: 18px; }
}

@media (prefers-reduced-motion: reduce) {
  .ornament-app *, .ornament-app *::before, .ornament-app *::after { animation: none !important; transition: none !important; }
}
