/* ==========================================================================
   order.css — the /order wizard. Concept-D language ONLY: every value below is
   built from the :root tokens defined in site.css (warm-earth palette, Fraunces/
   Mulish). No new palette, no generic AI aesthetic. Loaded AFTER site.css.
   ========================================================================== */

/* The order page has no dark hero, so pull the first section under the fixed header. */
.page-order main { padding-top: 0; }

/* ==========================================================================
   INTRO BAND (dark espresso) — keeps the fixed cream header legible + frames the wizard
   ========================================================================== */
.order-intro {
  background: var(--espresso);
  color: var(--cream);
  position: relative;
  overflow: hidden;
  padding-top: clamp(7rem, 12vw, 9rem);
  padding-bottom: clamp(2.6rem, 5vw, 3.6rem);
}
.order-intro::before {
  content: "";
  position: absolute; inset: 0;
  background: radial-gradient(75% 90% at 18% 15%, rgba(179,84,46,.18), transparent 56%);
  pointer-events: none;
}
.order-intro .wrap { position: relative; }
.order-intro h1 {
  color: var(--cream);
  font-size: var(--step-4);
  font-weight: 420;
  letter-spacing: -0.02em;
  margin-top: 0.9rem;
}
.order-intro h1 em { font-style: italic; color: var(--clay-soft); }
.order-intro__sub {
  color: rgba(251,246,236,.84);
  font-size: var(--step-1);
  line-height: 1.5;
  max-width: 46ch;
  margin-top: 1.1rem;
}
.order-intro__sub strong { color: var(--cream); font-weight: 700; }

/* ---- Stepper ---- */
.stepper {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  list-style: none;
  padding: 0;
  margin: 2.2rem 0 0;
  flex-wrap: wrap;
}
.stepper__item {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  color: rgba(251,246,236,.55);
  font-size: 0.9rem;
  font-weight: 600;
}
.stepper__item:not(:last-child)::after {
  content: "";
  width: clamp(1.2rem, 4vw, 2.6rem);
  height: 1.5px;
  background: rgba(251,246,236,.22);
  margin-left: 0.6rem;
}
.stepper__dot {
  width: 30px; height: 30px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 50%;
  border: 1.5px solid rgba(251,246,236,.3);
  font-family: var(--font-display);
  font-size: 0.95rem;
  font-style: italic;
  transition: background-color .3s, border-color .3s, color .3s;
}
/* A step you can actually go to (plan item 2). The dot always LOOKED like a control — round,
   numbered, sitting in a row — and clicking it did nothing at all; owner tried it on the live run.
   Now it either responds or it is plainly not offering to: no cursor, no hover, no tab stop. */
.stepper__item.is-clickable { cursor: pointer; }
.stepper__item.is-clickable:hover { color: var(--cream); }
.stepper__item.is-clickable:hover .stepper__dot { border-color: var(--cream); }
.stepper__item.is-clickable:focus-visible { outline: 2px solid var(--clay); outline-offset: 3px; border-radius: 4px; }
.stepper__item.is-active { color: var(--cream); }
.stepper__item.is-active .stepper__dot { background: var(--clay); border-color: var(--clay); color: var(--cream); }
.stepper__item.is-done { color: rgba(251,246,236,.85); }
.stepper__item.is-done .stepper__dot { background: var(--sage); border-color: var(--sage); color: var(--cream); }

/* ==========================================================================
   WIZARD CARD
   ========================================================================== */
.order-wrap { background: var(--paper); padding-block: clamp(2.6rem, 6vw, 4.5rem) var(--section-y); }

/* "Already with the artist" — what a spent slot shows instead of the wizard. Calm, not a warning:
   nobody did anything wrong by coming back. */
.sentcard {
  background: var(--cream); border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: clamp(1.6rem, 4vw, 2.6rem); box-shadow: var(--shadow-frame);
}
.sentcard__title { margin: 0 0 .8rem; font-size: var(--step-2); }
.sentcard__body { margin: 0 0 .9rem; color: var(--ink-soft); }
.sentcard__links { margin: 1.4rem 0 0; display: flex; flex-wrap: wrap; gap: .6rem; }
/* "Tell Eugene I've made changes" + the sentence naming what that costs at this stage. */
/* "Who has the ball" — a fact, not an alarm. Settled state is quiet (it is good news and needs no
   emphasis); when the ball is with the customer the line takes the ink colour and a weight, because
   it is the one thing on the card that has changed since he last looked. No red, no badge, no
   count of days: this sits on a memorial order that is proceeding perfectly well. */
.sentcard__ball { margin: 1.2rem 0 0; font-size: .92rem; color: var(--ink-faint); max-width: 56ch; }
.sentcard__ball.is-yours { color: var(--ink-soft); font-weight: 600; }
.sentcard__tell { margin: .5rem 0 .5rem; }

/* The rule that separates navigation from the act (08.08) — see the markup note in order.php. */
.sentcard__split { margin: 1.5rem 0 0; border-top: 1px solid var(--line); }

/* "Please wait, I'm still gathering" — the quietest control on the card by design. */
/* 🔴 27.08, variant A′. The pause stopped being a checkbox: the DEFAULT is printed as a state, the
   normality sentence answers the fear underneath it, and changing the plan is a secondary link. */
.sentcard__plan { margin: .9rem 0 0; font-size: .98rem; color: var(--ink); max-width: 56ch; }
.sentcard__plannote { margin: .35rem 0 0; font-size: .9rem; color: var(--ink-soft); max-width: 56ch; }
.sentcard__gather { margin: .55rem 0 0; font-size: .9rem; color: var(--ink-faint); max-width: 56ch; }
/* The lead-in that makes this the ALTERNATIVE to the button above rather than its contradiction. */
.sentcard__gathernote { display: block; margin-top: .35rem; color: var(--ink-soft); }
.sentcard__gathernote:empty { display: none; }
.sentcard__tellnote { margin: 0; font-size: .9rem; color: var(--ink-faint); max-width: 56ch; }
.sentcard__tellnote.is-said { color: var(--ink-soft); }
/* The manual redo path (item 9): visible, quiet, and pointing at a person rather than a mechanism. */
.sentcard__redo { margin: 1.4rem 0 0; padding-top: 1.1rem; border-top: 1px solid var(--line);
  font-size: .9rem; color: var(--ink-soft); max-width: 60ch; }
/* `.sentcard__another` (the "start a portrait for another pet" block) was removed from this card on
   27.08 together with its markup — see order.php, item 9. Rules deleted with it rather than left
   behind: dead selectors are how a stylesheet stops being readable. */

/* The private link on the confirmation screen — framed, because it is the most valuable thing on
   the page: lose it and the order is unreachable. */
.donelink {
  margin: 1.6rem auto 1.4rem; max-width: 34rem; text-align: left;
  background: var(--muslin); border: 1px solid var(--line); border-radius: var(--r-sm);
  padding: 1rem 1.1rem;
}
.donelink__label { display: block; font-weight: 600; margin-bottom: .5rem; }
.donelink__row { display: flex; gap: .5rem; align-items: stretch; flex-wrap: wrap; }
.donelink__url {
  flex: 1 1 16rem; min-width: 0; font-family: var(--font-mono, ui-monospace, monospace);
  font-size: .88rem; padding: .55rem .7rem; border: 1px solid var(--line);
  border-radius: var(--r-sm); background: var(--cream); color: var(--ink);
}
.donelink__copy { flex: 0 0 auto; padding-inline: 1.1rem; }
.donelink__note { margin: .7rem 0 0; font-size: .85rem; color: var(--ink-soft); }

.donenext { margin: 1.4rem auto 0; max-width: 34rem; text-align: left; }
.donenext__h { margin: 0 0 .5rem; font-size: var(--step-0); }
.donenext__list { margin: 0; padding-left: 1.1rem; color: var(--ink-soft); }
.donenext__list li { margin-bottom: .35rem; }

/* Sticky action bar (photos step). Pinned so the length of the explanatory text stops deciding how
   hard it is to move on. Only sticks while its step is the visible one. */
/* ⚠️ Height budget (owner, live run 07.08): the buttons in here were `btn--sm` and both of them
   ghost, so the bar had no primary action and nothing big enough to aim at. The buttons are now
   full-size (44px is the finger, not a matter of taste) and the forward one is filled — and the bar
   itself does NOT grow, because its own vertical padding gives back exactly what they took. */
.wizard__nav--sticky {
  position: sticky; bottom: 0; z-index: 20;
  margin-top: 1.6rem;
  padding: .45rem clamp(.8rem, 3vw, 1.4rem);
  background: color-mix(in srgb, var(--paper) 92%, transparent);
  backdrop-filter: blur(6px);
  border-top: 1px solid var(--line);
  border-radius: var(--r-sm) var(--r-sm) 0 0;
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
}
@supports not (backdrop-filter: blur(6px)) { .wizard__nav--sticky { background: var(--paper); } }
/* 🔴 THE COUNTER'S LENGTH MUST NOT DECIDE THE LAYOUT (27.08, fix-plan item 6). Owner's live run:
   during the upload the two buttons wrapped their labels onto two lines and "825 photos add…" was
   cut — and after F5 the same bar was fine, because the caption was short again. Diagnosis: the
   buttons were ordinary flex items, so they SHRANK as the middle column grew, and their text wrapped
   instead. Now they never shrink and never wrap; the middle is the only elastic part (it already
   ellipsises), so a four-digit count can no longer reach the buttons.
   Below 26rem even the fixed buttons stop fitting — there the step NAME inside them is dropped and
   "← 1" / "3 →" remain, which is still a working door. */
.wizard__nav--sticky .btn { min-height: 44px; flex: 0 0 auto; white-space: nowrap; }
/* D10 (owner 28.08, on his own phone): "на мобильной версии серединка получается невидная… между
   цифрой 1 и 3 снизу". Two faults in one bar, and only the first had been seen before:
     - the middle column, squeezed between two fixed-width buttons, ellipsised "STEP 2 OF 3" down to
       "STEP" — the answer to "where am I" was the thing that got cut;
     - the right-hand button reached past the card's edge, because two buttons that never shrink plus
       a gap simply do not fit 390 px.
   Widening the buttons is impossible (44 px touch height is not negotiable) and shrinking the text
   was already tried in the previous fix. So below 30rem the bar becomes TWO rows: the step line
   first, at full width, then the two doors side by side, each taking half. Nothing is cut, nothing
   overflows, and the step names come back — they fit once each button owns half the width.
   Below 20rem (an old small phone) the step name inside the buttons still drops: "← 1" / "3 →" is
   a working door even there. */
/* THE MIDDLE OF THE PINNED BAR ANSWERS "WHERE AM I" (owner, live run 07.08). Scrolled down inside
   step 2, the stepper at the top of the page is off-screen, and the bar used to say only how many
   photographs had been added — useful, but not the thing a lost person is asking. The step line goes
   first and survives the narrow-screen cut; the count is what gets dropped when there is no room,
   because "Step 2 of 3" is the answer and "358 photos added" is a detail. */
.wizard__navmid {
  flex: 1 1 auto; min-width: 0; text-align: center;
  display: flex; flex-direction: column; gap: .05rem;
}
.wizard__navstep {
  font-size: .76rem; letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-faint); white-space: nowrap;
}
/* ⚠️ THE TWO NARROW-SCREEN RULES BELOW MUST STAY AFTER the base ones above. They were written
   ABOVE them first and only `order` survived: a media query adds no specificity, so the later plain
   `.wizard__navmid { flex: 1 1 auto }` silently won and the bar came out as three stacked rows
   instead of two. The comment further down this file warns about exactly this; I walked into it
   anyway, which is why it is now stated at the point where it bites. */
@media (max-width: 30rem) {
  .wizard__nav--sticky { flex-wrap: wrap; gap: .5rem; row-gap: .55rem; }
  .wizard__navmid { order: -1; flex: 1 0 100%; }
  .wizard__nav--sticky .btn {
    flex: 1 1 0; min-width: 0; justify-content: center; text-align: center;
  }
}
@media (max-width: 20rem) {
  .wizard__nav--sticky .btn__stepname { display: none; }
}
/* Steps 1 and 3 have no pinned bar, so their position line sits straight in the nav row.
   ⚠️ THE ROW IS A GRID, NOT space-between (owner, 07.08: "на первом шаге сместите ближе к центру").
   With space-between the line was centred in the LEFTOVER space — and on step 1, where there is no
   Back button, all the leftover space is on the left, so the line sat well left of the middle.
   `1fr auto 1fr` makes the two outer columns equal by construction, so the middle is the middle
   whether or not anything is standing in the first column.
   The pinned bar keeps its own flex layout: it has a two-line block in the centre and buttons that
   must not stretch, and its rule is declared EARLIER in this file — a bare `.wizard__nav` grid here
   would silently win over it on specificity ties. */
.wizard__nav:not(.wizard__nav--sticky) {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
}
.wizard__nav:not(.wizard__nav--sticky) > :first-child { justify-self: start; }
.wizard__nav:not(.wizard__nav--sticky) > :last-child  { justify-self: end; }
.wizard__navstep--inline { justify-self: center; text-align: center; }
.wizard__navcount {
  font-size: .9rem; color: var(--ink-soft);
  min-width: 0; text-align: center;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The step NAME on a nav button collapses on a phone, the NUMBER stays: the number is the part that
   answers the question, and two long labels plus the middle block do not fit a 390px bar. */
@media (max-width: 560px) {
  .wizard__nav--sticky .btn__stepname { display: none; }
}
/* "Saved as you add — nothing to send twice." Sits just above the pinned bar on a sent order. */
.wizard__navnote { margin: 1.6rem 0 -1rem; font-size: .9rem; color: var(--ink-soft); text-align: right; }

/* The live "how many" line under the volume scale — the guidance that used to sit in a long block
   the customer had to scroll past. */
.volume__guide { margin: .35rem 0 0; font-size: .88rem; color: var(--ink-soft); }

/* Collapsed explainers: a <details> that does not look like a browser widget. */
.why-photos > summary, .albumcard > summary {
  cursor: pointer; list-style: none; display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap;
}
.why-photos > summary::-webkit-details-marker, .albumcard > summary::-webkit-details-marker { display: none; }
.why-photos > summary::after, .albumcard > summary::after {
  content: "▾"; margin-left: auto; color: var(--clay); font-size: .9em; transition: transform .2s;
}
.why-photos[open] > summary::after, .albumcard[open] > summary::after { transform: rotate(180deg); }
.why-photos__peek { font-weight: 400; font-size: .88rem; color: var(--ink-soft); }

/* "You're starting a new portrait — go back to the one you sent". A route, not a warning. */
.backstrip {
  margin: 0 0 1.4rem; padding: .8rem 1rem;
  background: var(--muslin); border: 1px solid var(--line); border-radius: var(--r-sm);
  font-size: .92rem; color: var(--ink-soft);
}
/* D17: the returning-customer variant is a footnote, not a card. It sits at the foot of step 1 and
   has to be findable by someone LOOKING for it without being the first thing read by someone who
   is not. So: no box (a box is what made it read as a notice), a hairline above it instead, small
   and faint — while the link inside keeps its underline, because the whole point is that it can be
   clicked by the one person in twenty who needs it. */
.backstrip--quiet {
  margin: 1.6rem 0 0; padding: .9rem 0 0;
  background: none; border: 0; border-top: 1px solid var(--line); border-radius: 0;
  font-size: .82rem; color: var(--ink-faint); max-width: 62ch;
}

.order-noscript {
  background: var(--flax);
  border-left: 3px solid var(--clay);
  border-radius: var(--r-md);
  padding: 1.2rem 1.4rem;
  color: var(--ink-soft);
  margin-bottom: 2rem;
}

.wizard {
  background: var(--cream);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  /* Named so the mobile rule below can cancel EXACTLY this much and no more (D12). */
  --wizard-pad: clamp(1.6rem, 4vw, 3rem);
  padding: var(--wizard-pad);
}
.wizard__step { display: none; animation: stepIn .4s cubic-bezier(.2,.8,.2,1); }
.wizard__step.is-active { display: block; }
@keyframes stepIn { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }

.wizard__eyebrow { margin: 0 0 0.8rem; }
.wizard__title { font-size: var(--step-2); margin-top: 0.3rem; }
.wizard__lede { color: var(--ink-soft); margin-top: 0.9rem; max-width: 52ch; }
/* One quiet line under the lede — used to take the "hundreds" out of "the hundreds inside it"
   before anyone decides they have too few photographs to bother. */
.wizard__note { margin: .5rem 0 0; font-size: .9rem; color: var(--ink-faint); max-width: 52ch; }

.wizard__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 2.2rem;
  padding-top: 1.6rem;
  border-top: 1px solid var(--line);
}
/* The footer of the details form carries ONLY what belongs to that form: Back and Save.
   The `.tellrow` block that lived here (a small outlined "Tell Eugene" beside them) was
   removed 08.08 with its markup — owner read one act drawn in two different weights as two
   different actions. Deleted rather than left dead: unused rules are how a removed pattern
   quietly comes back. The act now lives once, filled, in the card above, on every screen. */
/* A disabled Save is quiet, not broken-looking: it is waiting for a change, not refusing one. */
.btn[disabled] { opacity: .45; cursor: default; box-shadow: none; }

/* --------------------------------------------------------------------------
   "This one becomes the whole thing" — the same photograph twice, plain and
   then built out of hundreds. Quiet: it explains, it does not sell.
   -------------------------------------------------------------------------- */
.becomes {
  margin: 1.6rem 0 0; padding: 0;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: .8rem;
}
.becomes__img {
  width: 100%; height: auto; display: block;
  border-radius: var(--r-sm); border: 1px solid var(--line);
}
.becomes__arrow { color: var(--clay); display: block; }
/* D18: the step-2 version of this figure has ONE frame, not two with an arrow between them. */
.becomes--one { grid-template-columns: 1fr; }
.becomes--one .becomes__img { max-width: 34rem; margin-inline: auto; }
.becomes__arrow svg { width: clamp(22px, 5vw, 40px); height: auto; display: block; }
.becomes__cap {
  grid-column: 1 / -1; margin-top: .5rem;
  font-size: .88rem; color: var(--ink-faint); text-align: center;
}
@media (max-width: 480px) {
  /* Stacked, with the arrow turned to point down — side by side the two frames get too small
     for the tiles in the second one to be visible at all, which is the entire point of it. */
  .becomes { grid-template-columns: 1fr; justify-items: center; gap: .5rem; }
  /* Rotate the SPAN, not the <svg>: a CSS transform on an SVG root does not compute reliably
     across engines, and the wrapper is a plain element that always does. */
  .becomes__arrow { transform: rotate(90deg); }
  .becomes__arrow svg { width: 28px; }
}

/* ==========================================================================
   UPLOADER
   ========================================================================== */
.uploader { margin-top: 1.6rem; }
.uploader__input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.uploader__drop {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 0.7rem;
  padding: clamp(1.8rem, 5vw, 2.8rem);
  border: 2px dashed var(--line);
  border-radius: var(--r-md);
  background: var(--muslin);
  cursor: pointer;
  transition: border-color .25s, background-color .25s, transform .25s;
}
.uploader__drop:hover { border-color: var(--clay-soft); background: var(--flax); }
.uploader.is-drag .uploader__drop { border-color: var(--clay); background: var(--flax); transform: scale(1.005); }
.uploader__icon { width: 46px; height: 46px; color: var(--clay); }
.uploader__icon svg { width: 100%; height: 100%; }
.uploader__prompt { font-size: var(--step-1); color: var(--ink); }
.uploader__prompt strong { color: var(--clay); font-weight: 700; }
.uploader__hint { font-size: 0.9rem; color: var(--ink-faint); }

/* Две строки под кнопкой: сколько надо и как не мучиться с отбором. Не внутри метки —
   иначе они растягивают саму цель касания и отодвигают её низ за край экрана. */
.uploader__how {
  margin: .7rem 0 0; font-size: .9rem; line-height: 1.55; color: var(--ink-soft); text-align: center;
}
.uploader__how em { font-style: normal; font-weight: 600; color: var(--ink); }
.uploader__how-tip { display: block; margin-top: .3rem; color: var(--ink-faint); }

/* НА ТЕЛЕФОНЕ ЭТО КНОПКА, А НЕ ЗОНА ПЕРЕТАСКИВАНИЯ (30.08.2026).
   Пунктир — общепринятый знак «сюда можно бросить файл»; жеста нет — знак лжёт и вдобавок
   читается хуже кнопки. Порог 700px — тот же, по которому handoff.js решает, показывать ли
   «поделиться» вместо QR: одна и та же граница «это телефон» в двух местах должна быть одна. */
@media (max-width: 700px) {
  .uploader__dropword { display: none; }
  .uploader__drop {
    border: none;
    background: var(--clay);
    color: var(--cream);
    min-height: 56px;                 /* палец, а не вкус */
    padding: 1.1rem 1rem;
    gap: .45rem;
    box-shadow: 0 2px 10px rgba(42,33,25,.18);
  }
  .uploader__drop:hover, .uploader.is-drag .uploader__drop {
    border: none; background: var(--clay-deep); transform: none;
  }
  .uploader__icon { width: 32px; height: 32px; color: var(--cream); }
  .uploader__prompt { color: var(--cream); }
  .uploader__prompt strong { color: var(--cream); }
  .uploader__hint { color: rgba(247,242,234,.82); }
}

/* The privacy promise under the upload zone (GAP-33). Quiet, but not faint: it is the sentence
   that lets a cautious person press the button, so it must be legible without being a banner. */
.uploader__promise { margin: .8rem 0 0; font-size: .88rem; line-height: 1.55; color: var(--ink-soft);
                     text-align: center; max-width: 62ch; margin-inline: auto; }
.uploader__promise a { color: inherit; text-decoration: underline; }
.uploader__count { margin-top: 1rem; font-weight: 700; color: var(--ink); font-size: 0.98rem; }
.uploader__count.is-low { color: var(--clay); }

/* --------------------------------------------------------------------------
   BATCH PROGRESS + WATCHDOG (Stage 2 of the upload rework)
   The platform shows this in a modal that owns the screen; here it sits inline
   under the drop zone, because the customer must stay able to read the page,
   scroll the grid and fill the email card while several hundred photos travel.
   -------------------------------------------------------------------------- */
.upprogress {
  margin-top: 1.1rem;
  padding: 0.9rem 1rem 1rem;
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
}
.upprogress__head {
  display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
  margin-bottom: 0.55rem;
}
.upprogress__count { font-weight: 700; color: var(--ink); font-size: 0.98rem; white-space: nowrap; }
.upprogress__file {
  flex: 1 1 auto; min-width: 0; text-align: right;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--ink-faint); font-size: 0.85rem;
}
.upprogress__bar { height: 6px; border-radius: var(--r-pill); background: var(--line); overflow: hidden; }
.upprogress__bar i { display: block; height: 100%; width: 0; background: var(--clay); transition: width .25s ease; }
/* ETA + the calm line that takes the bar's place when the batch lands (27.08, item 5). */
.upprogress__eta { margin: .45rem 0 0; font-size: .86rem; color: var(--ink-soft); }
.upprogress__done { margin: .5rem 0 0; font-size: .95rem; font-weight: 600; color: var(--ink); }
.upprogress.is-done .upprogress__bar,
.upprogress.is-done .upprogress__head { display: none; }
.upprogress__stall { margin-top: 0.9rem; padding-top: 0.8rem; border-top: 1px dashed var(--line); }
.upprogress__stall p { margin: 0 0 0.6rem; font-size: 0.92rem; color: var(--ink-soft); }
.upnote {
  margin: 0.8rem 0 0; padding: 0.7rem 0.9rem;
  background: var(--muslin); border-left: 3px solid var(--clay-soft); border-radius: var(--r-sm);
  font-size: 0.92rem; color: var(--ink-soft);
}
.btn--sm { padding: 0.45rem 0.9rem; font-size: 0.86rem; }

/* --------------------------------------------------------------------------
   TILE GRID — his photos appearing one by one.
   Square cards on a fluid grid: 3 across on a phone, up to 7 on a desktop, so a
   few hundred photos read as a wall of him rather than a list of filenames.
   -------------------------------------------------------------------------- */
.tilegrid {
  list-style: none; padding: 0; margin: 1.2rem 0 0;
  display: grid;
  /* Bigger than a contact sheet on purpose. The job on this screen is to RECOGNISE his own dog in
     a photo — at 88 px a 45+ customer starts opening pictures one by one instead of scanning, and
     three controls do not fit on the card without touching each other. 110 phone / 150 desktop. */
  grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  gap: 10px;
}
@media (min-width: 700px) { .tilegrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; } }

.tile {
  position: relative;
  aspect-ratio: 1 / 1;
  border-radius: var(--r-sm);
  overflow: hidden;
  background: var(--flax);
  border: 1px solid var(--line);
}
.tile__thumb { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.tile__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Quiet pulse while the photo is on its way / being prepared — a spinner would
   shout "loading" 300 times over; this reads as film developing. */
/* "Still being made" — a ring over the picture, not instead of it.
   It sits ON TOP of the instant local preview: the photo is there so the customer can see his
   batch arriving, and the ring is there so he knows the framing he is looking at is not the final
   one and the buttons are not missing, just not ready. It leaves when the server preview lands. */
/* 🔴 КРУТИЛКА ЗАГРУЗКИ — 12 ЛУЧЕЙ И ПРИВОД ОТ АБСОЛЮТНЫХ ЧАСОВ (01.09.2026)

   ТРИ НЕВЕРНЫХ ДИАГНОЗА — чтобы четвёртый не начинался с нуля:
   1) «голодание главного потока» — замер нашёл настоящую беду (toDataURL, 117 мс на фото;
      её лечение живёт в upload.js и его не трогать), но ДРУГУЮ: нехватка кадров даёт
      ЗАМИРАНИЕ и рывок ВПЕРЁД, а владелец видел движение НАЗАД.
   2) «четырёхкратная симметрия бордюра» — симметрию убрали (r2373), симптом остался.
   3) замер по видеозаписи (126 кадров, 30 к/с): дуга попадает ТОЧНО в 0° около 10 раз в
      секунду, тогда как свободная анимация проходит 0° раз за оборот (1,3 с) — в 13 раз реже.
      Ни одна постоянная скорость ряд углов не описывает (ошибки до ±75°).
      Значит СБРАСЫВАЕТСЯ САМ ТАЙМЛАЙН АНИМАЦИИ, примерно 10 раз в секунду.

   ЛЕЧЕНИЕ В ДВА СЛОЯ — и второй важнее первого:

   СЛОЙ 1, ФИГУРА. Двенадцать одинаковых лучей, шаг 30°. Любой сброс попадает в ЗАКОННОЕ
   состояние фигуры, а не в видимый рывок назад: глазу нечем измерить направление. Именно
   поэтому такая крутилка стоит в каждой ОС.

   СЛОЙ 2, ПРИВОД. Одной фигуры МАЛО: CSS-анимация с шагом 100 мс при сбросе таймлайна
   10 раз в секунду никогда не уйдёт дальше первого шага — крутилка будет дрожать между
   двумя положениями вместо вращения. Поэтому угол считается от performance.now() в ОДНОМ
   общем requestAnimationFrame (petSpinDriver в upload.js) и приезжает сюда через --pet-spin.
   АБСОЛЮТНЫЕ ЧАСЫ СБРОСИТЬ НЕЛЬЗЯ — чем бы ни была первопричина, угол всегда правильный и
   всегда вперёд. CSS-анимация остаётся запасным вариантом без JS; привод гасит её классом
   .pet-spin-js на <html>.

   ⚙ Символ — встроенный SVG (data-URI): ни одного лишнего запроса; класс .tile__spin и все
   три места его создания в upload.js остались как были.
   ⚠ Проверять ТОЛЬКО на живом телефоне: на десктопе эффекта не видно ни до, ни после. */
.tile__spin {
  position: absolute; top: 50%; left: 50%; margin: -15px 0 0 -15px;
  width: 30px; height: 30px;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23F7F2EA'%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.14' transform='rotate(0 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.22' transform='rotate(30 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.30' transform='rotate(60 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.38' transform='rotate(90 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.46' transform='rotate(120 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.54' transform='rotate(150 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.62' transform='rotate(180 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.70' transform='rotate(210 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.78' transform='rotate(240 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.86' transform='rotate(270 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='0.93' transform='rotate(300 12 12)'/%3E%3Crect x='11.05' y='1.4' width='1.9' height='6.2' rx='.95' opacity='1' transform='rotate(330 12 12)'/%3E%3C/svg%3E") center / contain no-repeat;
  filter: drop-shadow(0 1px 2px rgba(42,33,25,.55));
  animation: tilespin 1.2s steps(12, end) infinite;
  will-change: transform;
  pointer-events: none;
}
@keyframes tilespin { to { transform: rotate(360deg); } }
/* Привод жив — считаем угол сами, анимацию гасим совсем. */
.pet-spin-js .tile__spin { animation: none; transform: rotate(var(--pet-spin, 0deg)); }
/* Before any picture exists the card is empty muslin — then the bare ring would float on nothing,
   so a soft disc sits under it. */
.tile__thumb .tile__spin::after {
  content: ""; position: absolute; inset: -9px; border-radius: 50%;
  background: rgba(42,33,25,.28); z-index: -1;
}
/* Dim the local preview slightly while it is still provisional; full colour once it is real. */
.tile.is-waiting .tile__img.is-local, .tile.is-uploading .tile__img.is-local { filter: saturate(.8) brightness(.9); }

.tile__bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(43,32,24,.12); }
.tile__bar i { display: block; height: 100%; width: 0; background: var(--clay); transition: width .2s ease; }
.tile.is-waiting .tile__bar, .tile.is-ready .tile__bar { display: none; }

/* Filename caption only while there is no picture yet: once the photo shows, the
   picture IS the label. */
.tile__name {
  position: absolute; left: 0; right: 0; bottom: 3px;
  padding: 0 4px;
  font-size: 0.62rem; line-height: 1.5;
  color: var(--ink-faint);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  text-align: center;
}
.tile.is-ready .tile__name { display: none; }

.tile.is-failed { border-color: var(--clay-deep); background: var(--muslin); }
.tile__retry {
  position: absolute; inset: auto 4px 4px 4px;
  padding: 3px 0; border: 0; border-radius: var(--r-sm);
  background: var(--clay); color: var(--cream);
  font-family: var(--font-body); font-size: 0.7rem; cursor: pointer;
}

/* File list (legacy uploader path — retired in Stage 9, kept until then) */
.filelist { list-style: none; padding: 0; margin: 1rem 0 0; display: flex; flex-direction: column; gap: 0.5rem; }
.file {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  padding: 0.7rem 0.9rem;
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  font-size: 0.92rem;
}
.file__name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-soft); }
.file__state { flex: none; font-size: 0.82rem; font-weight: 700; letter-spacing: 0.02em; }
.file--up   .file__state { color: var(--ink-faint); }
.file--ok   .file__state { color: var(--sage); }
.file--err  .file__state { color: var(--clay-deep); }
.file__bar { flex: none; width: 66px; height: 4px; border-radius: var(--r-pill); background: var(--line); overflow: hidden; }
.file__bar i { display: block; height: 100%; width: 0; background: var(--clay); transition: width .2s ease; }
.file--ok .file__bar i { background: var(--sage); }

/* ==========================================================================
   WHY-PHOTOS explainer + privacy line
   ========================================================================== */
/* D9v (owner 28.08): "два блока по высоте немножко отличаются… и по цвету тоже. У одного слева
   есть зелёная полосочка, а у другого нет". He is right, and the difference meant NOTHING — the
   sage stripe and the flax fill were left over from an earlier design, not a state. Two controls
   that do the same thing (expand optional reading) now look the same; the one that carries a peek
   line is still distinguishable by that line, which is a real difference.
   Checked before flattening: nothing sets or reads the stripe, it is decoration only. */
.why-photos,
.albumcard {
  margin-top: 1.8rem;
  padding: 1.2rem 1.3rem;
  background: var(--muslin);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
}

/* D9b: an ℹ before the heading, so an expandable EXPLANATION is not mistaken for a form field to
   fill in. Same glyph in both, same colour as the chevron that opens them. */
.why-photos > summary::before,
.albumcard > summary::before {
  /* 🔴 ЗДЕСЬ БЫЛА АВАРИЯ, ВИДНАЯ КЛИЕНТАМ (поймана owner'ом 29.08 на бою).
     Было: content: "\24D8" — но патч-скрипт пропустил эту строку через НЕ-raw строку Python,
     где \24 — восьмеричный escape (0x14, невидимый управляющий символ). В файл легло
     chr(0x14) + "D8", и каждый клиент видел вместо иконки красное «D8» перед двумя заголовками
     шага 2 — выглядит как внутренний номер задачи, потому что это он и есть.
     Лечение — сам символ, а не escape: файл и так UTF-8, и так его нечем испортить. */
  content: "ⓘ";
  color: var(--clay);
  font-size: 1.05em;
  line-height: 1;
  flex: 0 0 auto;
}

/* D12: on a narrow screen the indents used to stack — page, then card, then this block — and the
   text ended up ~44 px from the edge on a 390 px screen, 11% of the width spent on nothing. The fix
   is NOT a smaller margin (18–20 px from the glass is readability, not decoration) but removing the
   NESTING: inside the card these blocks run edge to edge, so their own padding is the only indent. */
@media (max-width: 640px) {
  .why-photos,
  .albumcard {
    margin-left: calc(-1 * var(--wizard-pad, 1.6rem));
    margin-right: calc(-1 * var(--wizard-pad, 1.6rem));
    border-left: 0;
    border-right: 0;
    border-radius: 0;
  }
}
.why-photos__h { font-size: var(--step-1); margin-bottom: 0.6rem; }
.why-photos p { color: var(--ink-soft); }
.why-photos p + p { margin-top: 0.7rem; }
.why-photos strong { color: var(--ink); }
.why-photos__soft { font-size: 0.95rem; }
.why-photos__guide { margin-top: 0.9rem !important; }

.privacy-line {
  display: flex;
  align-items: center;
  gap: 0.6em;
  margin-top: 1.5rem;
  color: var(--ink-soft);
  font-size: 0.95rem;
}
.privacy-line svg { width: 18px; height: 18px; flex: none; stroke: var(--sage); }

/* ==========================================================================
   FIELDS (light variant of the concept-D form inputs)
   ========================================================================== */
.field { display: block; margin-top: 1.5rem; }
.field--radio { border: 0; padding: 0; margin-top: 1.6rem; }
.field__label {
  display: block;
  font-size: 0.78rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--ink-faint);
  margin-bottom: 0.55rem;
}
.field__opt { text-transform: none; letter-spacing: 0; font-weight: 600; color: var(--ink-faint); opacity: 0.8; }
.field__input {
  font-family: var(--font-body);
  font-size: var(--step-0);
  width: 100%;
  padding: 0.85em 1.05em;
  border: 1.5px solid var(--line);
  border-radius: var(--r-md);
  background: var(--paper);
  color: var(--ink);
  transition: border-color .2s, box-shadow .2s, background-color .2s;
}
textarea.field__input { resize: vertical; line-height: 1.5; }
.field__input::placeholder { color: var(--ink-faint); opacity: 0.7; }
.field__input:focus {
  outline: none;
  border-color: var(--clay-soft);
  background: var(--cream);
  box-shadow: 0 0 0 3px rgba(179,84,46,.14);
}
.field__note { display: block; margin-top: 0.5rem; font-size: 0.86rem; color: var(--ink-faint); }
.field__error { display: none; margin-top: 0.5rem; font-size: 0.88rem; font-weight: 600; color: var(--clay-deep); }
.field.has-error .field__input { border-color: var(--clay); background: #fbeee6; }
.field.has-error .field__error { display: block; }

.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; }
.field-row .field { margin-top: 1.5rem; }

/* Radios */
.radio-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
.radio {
  display: flex;
  align-items: flex-start;
  gap: 0.7rem;
  padding: 1rem 1.1rem;
  border: 1.5px solid var(--line);
  border-radius: var(--r-md);
  background: var(--paper);
  cursor: pointer;
  transition: border-color .2s, background-color .2s;
}
.radio:hover { border-color: var(--clay-soft); }
.radio input { position: absolute; opacity: 0; width: 0; height: 0; }
.radio__mark {
  flex: none;
  width: 20px; height: 20px;
  margin-top: 0.15em;
  border: 2px solid var(--line);
  border-radius: 50%;
  position: relative;
  transition: border-color .2s;
}
.radio__mark::after {
  content: ""; position: absolute; inset: 3px; border-radius: 50%;
  background: var(--clay); transform: scale(0); transition: transform .2s;
}
.radio input:checked ~ .radio__mark { border-color: var(--clay); }
.radio input:checked ~ .radio__mark::after { transform: scale(1); }
.radio:has(input:checked) { border-color: var(--clay); background: var(--flax); }
.radio input:focus-visible ~ .radio__mark { box-shadow: 0 0 0 3px rgba(179,84,46,.2); }
.radio__text { display: flex; flex-direction: column; gap: 0.15rem; }
.radio__text strong { color: var(--ink); font-weight: 700; font-size: 0.98rem; }
.radio__text span { color: var(--ink-faint); font-size: 0.86rem; }

/* Agreement + reassurance */
.agree-line { margin-top: 1.6rem; font-size: 0.92rem; color: var(--ink-soft); }
.agree-line a { color: var(--clay); font-weight: 700; text-decoration: none; border-bottom: 1.5px solid var(--clay-soft); }
.agree-line a:hover { color: var(--clay-deep); }
.reassure-line { margin-top: 1.3rem; font-size: 0.9rem; color: var(--ink-faint); text-align: center; }

/* The one place a failed send speaks (GAP-30). Weighted like a field error — this is the same kind
   of news — but centred under the button, because it is about the act, not about a field. */
.sendnote { margin-top: 1.2rem; font-size: .92rem; font-weight: 600; line-height: 1.5;
            color: var(--clay-deep); text-align: center; }
.sendnote a { color: inherit; text-decoration: underline; }
.sendnote[hidden] { display: none; }

/* Submit button busy state */
.btn.is-busy { opacity: 0.7; pointer-events: none; }
.btn.is-busy .btn__label::after {
  content: "\2026"; /* animated ellipsis handled by JS text swap; keep static fallback */
}

/* ==========================================================================
   DONE — confirmation
   ========================================================================== */
.wizard__done { display: none; text-align: center; padding: clamp(1rem, 3vw, 2rem) 0; }
.wizard__done.is-active { display: block; animation: stepIn .5s cubic-bezier(.2,.8,.2,1); }
.done__mark {
  width: 64px; height: 64px;
  margin: 0 auto 1.4rem;
  border-radius: 50%;
  background: var(--sage);
  color: var(--cream);
  display: flex; align-items: center; justify-content: center;
}
.done__mark svg { width: 34px; height: 34px; }
.done__title { font-size: var(--step-3); }
.done__body { color: var(--ink-soft); max-width: 46ch; margin: 1.1rem auto 0; }
.done__sig { margin-top: 1.6rem; }
.done__links { margin-top: 1.6rem; }

/* ==========================================================================
   LOW-COUNT soft warning modal (non-blocking)
   ========================================================================== */
.lowcount {
  position: fixed; inset: 0; z-index: 60;
  display: flex; align-items: center; justify-content: center;
  padding: var(--gutter);
  background: rgba(28,20,13,.55);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  animation: fadeIn .25s ease;
}
.lowcount[hidden] { display: none; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.lowcount__panel {
  background: var(--cream);
  border-radius: var(--r-lg);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-frame);
  padding: clamp(1.6rem, 4vw, 2.4rem);
  max-width: 30rem;
  width: 100%;
  animation: stepIn .3s cubic-bezier(.2,.8,.2,1);
}
.lowcount__title { font-size: var(--step-1); }
.lowcount__body { color: var(--ink-soft); margin-top: 0.8rem; }
.lowcount__body strong { color: var(--ink); }
.lowcount__actions { display: flex; gap: 0.9rem; margin-top: 1.6rem; flex-wrap: wrap; }
.lowcount__actions .btn { flex: 1 1 auto; justify-content: center; }

/* ==========================================================================
   RESPONSIVE
   ========================================================================== */
@media (max-width: 640px) {
  .field-row { grid-template-columns: 1fr; }
  .radio-row { grid-template-columns: 1fr; }
  /* Back to a stacked flex column on a phone. ⚠️ The selector has to carry the SAME `:not()` as the
     grid rule above, or it loses on specificity (0,2,0 beats 0,1,0) and `flex-direction` is ignored
     while `display: grid` is still in force — the stack would silently not happen. */
  .wizard__nav,
  .wizard__nav:not(.wizard__nav--sticky) {
    display: flex; flex-direction: column-reverse; align-items: stretch; gap: 0.8rem;
  }
  .wizard__nav .btn { width: 100%; justify-content: center; }
  /* The empty span that holds step 1's left grid column open is pure layout — stacked, it is just
     an extra gap. It cannot be display:none in the grid, or the position line slides into its
     column and lands on the left again. */
  .wizard__nav > span:empty { display: none; }
  /* The inline position line goes away on a phone: the row is stacked here, so it would land
     UNDER the buttons (column-reverse), and reading "Step 3 of 3" after the controls it describes
     is worse than not reading it. The numbers on the buttons themselves stay, and they carry it. */
  .wizard__navstep--inline { display: none; }
  /* …except the PINNED bar, which stays one row. Stacked into three full-width rows it would
     cover a third of a phone screen permanently — and the thing it floats over is the grid of
     photographs the person is working with. */
  .wizard__nav--sticky { flex-direction: row; align-items: center; gap: .5rem; }
  .wizard__nav--sticky .btn { width: auto; flex: 0 0 auto; }
  .wizard__nav--sticky .wizard__navcount { font-size: .82rem; }
  /* Below ~480 the two buttons leave the counter about 40 px, i.e. an ellipsis and nothing else.
     A stub that says "89 pho…" is worse than no stub: the same number is on screen twice already,
     under the uploader and on the volume scale. */
  @media (max-width: 480px) { .wizard__nav--sticky .wizard__navcount { display: none; } }
  .wizard__navnote { text-align: center; }
  .stepper__item .stepper__label { display: none; }
  .stepper__item.is-active .stepper__label { display: inline; }
}

@media (prefers-reduced-motion: reduce) {
  .wizard__step, .wizard__done.is-active, .lowcount, .lowcount__panel { animation: none; }
}

/* Photo inside a card (local canvas preview first, server preview after the poller swaps it). */
.tile__img { width: 100%; height: 100%; object-fit: cover; display: block; }
.tile__img.is-local { filter: saturate(.92); }          /* a touch quieter until the real one lands */
.tile.is-ready .tile__img.is-local { filter: none; }
.tile.is-broken { border-color: var(--clay-deep); background: repeating-linear-gradient(45deg, var(--muslin), var(--muslin) 6px, var(--flax) 6px, var(--flax) 12px); }
/* Не display:none: проход через display перезапускает CSS-анимацию с нуля,
   а это ровно тот класс отказа, который замер нашёл выше. Гасим прозрачностью. */
.tile.is-broken .tile__spin { opacity: 0; }
/* A2 — the refusal, said IN the tile. On a phone there is no hover, so `title` says nothing at all;
   this is the only way the customer learns that one of his photographs did not arrive. The local
   preview is pushed back so the words sit on something readable rather than on fur. */
.tile.is-broken .tile__thumb { opacity: .28; filter: grayscale(1); }
.tile.is-broken .tile__name { display: none; }
.tile__broken {
  position: absolute; inset: auto 3px 3px 3px;
  padding: 3px 4px; border-radius: var(--r-sm);
  background: var(--clay-deep); color: var(--cream);
  font-size: 0.62rem; line-height: 1.25; text-align: center;
  overflow: hidden;
}

/* --------------------------------------------------------------------------
   PORTRAIT CANDIDATES (Stage 4) — up to three, shown large: this is the photo
   the whole mosaic will become, so it gets room, not a thumbnail.
   -------------------------------------------------------------------------- */
.mains { margin-top: 1.6rem; }
.mainsgrid { list-style: none; padding: 0; margin: 0 0 1rem; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.mainsgrid:empty { display: none; }
.main-card { position: relative; aspect-ratio: 1 / 1; border-radius: var(--r-md); overflow: hidden; background: var(--flax); border: 1px solid var(--line); box-shadow: var(--shadow-card); }
.main-card .tile__thumb { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.main-card__name { position: absolute; left: 0; right: 0; bottom: 0; padding: 3px 6px; font-size: 0.68rem; color: var(--cream); background: rgba(42,33,25,.62); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.main-card.is-ready .main-card__name { display: none; }
.main-card__x { position: absolute; top: 6px; right: 6px; width: 30px; height: 30px; border: 0; border-radius: 50%; background: rgba(42,33,25,.62); color: var(--cream); font-size: 1.15rem; line-height: 1; cursor: pointer; transition: background-color .2s; }
.main-card__x:hover { background: var(--clay-deep); }
.uploader__drop--main { padding: clamp(1.2rem, 3.5vw, 1.9rem); }
.uploader__drop--main.is-full { opacity: .5; pointer-events: none; }
.mains__promise { margin-top: 1rem; font-size: 0.95rem; color: var(--ink-soft); font-style: italic; }

/* --------------------------------------------------------------------------
   TILE ACTIONS (Stage 5) — remove (a toggle, never a deletion) and 1:1 crop.
   Buttons stay hidden until the card is ready: before that there is nothing to
   crop, and a control that does nothing teaches distrust.
   -------------------------------------------------------------------------- */
/* 32 px, and never hidden behind hover. The audience is 45+ and a good share of them are on an
   iPad, where hover does not exist at all — a control that only appears on mouse-over is a control
   they never find. */
.tile__x, .tile__crop {
  position: absolute; width: 32px; height: 32px;
  border: 0; border-radius: 50%; cursor: pointer;
  background: rgba(42,33,25,.6); color: var(--cream);
  display: none; align-items: center; justify-content: center;
  padding: 0; line-height: 1; transition: background-color .2s, transform .15s;
}
/* Positions match the studio page exactly (collage_photos.php box3/box5/box4): delete top-LEFT,
   crop top-RIGHT, heart bottom-CENTRE. Not for consistency's sake — for the thumb: the right edge
   is where a hand sweeps while scrolling, so the destructive control belongs in the far corner,
   and the heart belongs where the thumb already is. Owner caught the first version, which had
   delete and crop mirrored and the heart pushed into the corner. */
.tile__x { top: 5px; left: 5px; font-size: 1.2rem; }
.tile__crop { top: 5px; right: 5px; }
.tile__crop svg { width: 16px; height: 16px; }
/* × appears as soon as the row exists; crop waits for the derivative it needs. */
.tile.is-ready .tile__x, .tile.is-waiting .tile__x, .tile.is-broken .tile__x { display: flex; }
.tile.is-ready .tile__crop { display: flex; }
.tile__x:hover { background: var(--clay-deep); transform: scale(1.08); }
.tile__crop:hover { background: var(--clay); transform: scale(1.08); }
/* Touch targets: on a phone these must be thumb-sized, not mouse-sized. */
@media (pointer: coarse) { .tile__x, .tile__crop { width: 44px; height: 44px; } }

/* Removed-by-you: dimmed, not gone — the same reassurance the studio page gives. */
.tile.is-removed { opacity: .28; }
.tile.is-removed .tile__crop { display: none; }
.tile.is-removed::after {
  content: "Deleted — press × to undo";
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 3px 4px; font-size: 0.58rem; text-align: center;
  background: rgba(42,33,25,.72); color: var(--cream);
}

/* ---- Crop dialog ---- */
.cropmodal { position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(42,33,25,.72); }
.cropmodal[hidden] { display: none; }
.cropmodal__panel { background: var(--cream); border-radius: var(--r-lg); box-shadow: var(--shadow-frame); padding: clamp(1.1rem, 3vw, 1.8rem); width: min(560px, 100%); max-height: 92vh; overflow: auto; }
.cropmodal__title { margin: 0; font-size: var(--step-1); }
.cropmodal__hint { margin: .4rem 0 1rem; color: var(--ink-soft); font-size: .92rem; }
.cropmodal__stage { background: var(--muslin); border-radius: var(--r-sm); overflow: hidden; }
.cropmodal__stage img { display: block; max-width: 100%; }
/* Turn / Reset row. Deliberately quiet and text-labelled: for a 45+ customer a bare circular arrow
   is a guess, and a wrong guess here re-renders the tile through the whole pipeline. */
.cropmodal__tools { display: flex; gap: .5rem; margin-top: .9rem; flex-wrap: wrap; }
.cropmodal__tool {
  display: inline-flex; align-items: center; gap: .4rem;
  min-height: 40px; padding: .45rem .8rem;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--muslin); color: var(--ink-soft);
  font: inherit; font-size: .88rem; cursor: pointer;
  transition: background .15s, color .15s, border-color .15s;
}
.cropmodal__tool svg { width: 17px; height: 17px; flex: none; }
.cropmodal__tool:hover:not(:disabled) { background: var(--flax); color: var(--ink); border-color: var(--clay); }
.cropmodal__tool:disabled { opacity: .38; cursor: default; }
@media (pointer: coarse) { .cropmodal__tool { min-height: 44px; } }

.cropmodal__actions { display: flex; justify-content: flex-end; gap: .7rem; margin-top: 1.1rem; }
.cropmodal__actions .btn:disabled { opacity: .5; cursor: default; }
body.is-modal { overflow: hidden; }

/* --------------------------------------------------------------------------
   THE HEART (Stage 6) — variant A from the contact sheet, chosen by the owner.
   One level, no quotas, no progress bars: this is a mark of love, not a target.
   Marked = filled terracotta on ivory; unmarked = outline on a dark disc, so it
   stays visible over both a bright sky and a black dog.
   -------------------------------------------------------------------------- */
.tilegrid__hint { margin: 1.2rem 0 0; font-size: 0.92rem; color: var(--ink-soft); }
.tilegrid__marked { margin: .35rem 0 0; font-size: 0.92rem; color: var(--clay); font-weight: 600; }
.tile__heart {
  position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%);
  width: 34px; height: 34px; padding: 0; border: 0; background: none;
  cursor: pointer; display: none; transition: transform .15s;
}
.tile.is-ready .tile__heart { display: block; }
.tile__heart svg { width: 100%; height: 100%; display: block; }
.tile__heart-disc { fill: rgba(46,36,29,.38); }
.tile__heart-path { fill: none; stroke: #F7F2EA; stroke-width: 1.8; stroke-linejoin: round; }
.tile__heart.is-on .tile__heart-disc { fill: rgba(247,242,234,.92); }
.tile__heart.is-on .tile__heart-path { fill: var(--clay); stroke: none; }
.tile__heart:hover { transform: translateX(-50%) scale(1.1); }
.tile__heart.is-on { animation: heartpop .28s ease-out; }
@keyframes heartpop { 0% { transform: translateX(-50%) scale(1); } 45% { transform: translateX(-50%) scale(1.28); } 100% { transform: translateX(-50%) scale(1); } }
/* Smaller cards get a smaller badge; the tap target stays finger-sized on touch. */
@media (max-width: 480px) { .tile__heart { width: 24px; height: 24px; } }
@media (pointer: coarse) { .tile__heart { padding: 7px; width: 44px; height: 44px; bottom: 0; } }

/* --------------------------------------------------------------------------
   EMAIL CARD (Stage 7) — sits UNDER the progress, never over it. Quiet, part of
   the page: no overlay, no close button, nothing to dismiss by accident.
   -------------------------------------------------------------------------- */
.emailcard {
  margin-top: 1rem; padding: 1rem 1.1rem;
  background: var(--muslin); border: 1px solid var(--line); border-left: 3px solid var(--clay);
  border-radius: var(--r-md);
  animation: stepIn .45s cubic-bezier(.2,.8,.2,1);
}
.emailcard__q { margin: 0 0 .7rem; font-size: 1rem; color: var(--ink); }
.emailcard__row { display: flex; gap: .6rem; align-items: stretch; flex-wrap: wrap; }
.emailcard__row .field__input { flex: 1 1 220px; min-width: 0; }
.emailcard__row .field__input.has-error { border-color: var(--clay-deep); }
.emailcard__note { margin: .6rem 0 0; font-size: .85rem; color: var(--ink-faint); }
.emailcard__done { margin: .6rem 0 0; font-size: .92rem; font-weight: 600; color: var(--sage); }

/* --------------------------------------------------------------------------
   VOLUME SCALE (Stage 8) — four warm zones, no threshold, no failing state.
   Colour walks sage (a beginning) to amber/clay (his whole life): growth, not
   a score. Ticks mark where the zones change, without labelling them as goals.
   -------------------------------------------------------------------------- */
.volume { margin-top: .8rem; }

/* 🔴 27.08 — MILESTONES REPLACED THE TRACK, and 28.08 (D7) ONE LINE REPLACED THE MILESTONES.
   The rule outlived both shapes and is the reason this file keeps the history: a percentage implies
   a maximum, and a maximum is the threshold this scale exists to avoid. The milestones fixed that
   and introduced their own fault — bordered cells read as buttons and invited a click that does
   nothing (owner, 28.08).
   The line keeps the no-ceiling property by geometry rather than by the words: the head is placed
   by volumePos() on a compressed scale that only ever approaches the right edge, so there is no
   state in which it is "full". No knob — a knob is a drag affordance, which is the same mistake
   the boxes made in a different costume. */
.volume__scale { margin: .15rem 0 0; }
.volume__track {
  position: relative; height: 7px; border-radius: 999px;
  background: color-mix(in srgb, var(--line) 55%, transparent);
}
.volume__fill {
  position: absolute; inset: 0; display: block;
  /* The gradient is painted across the WHOLE track and then clipped, so a given number of photos
     always has the same colour — if the gradient lived on a growing element it would re-stretch on
     every upload and the colour would slide backwards as the line grew. */
  /* No --gold here on purpose: the palette declares it "WARM GOLD HAIRLINE ONLY - <=5%, never
     fill/gradient" (site.css:45). The milestone version could use it because it coloured a 1px
     border; this line is a fill, so the warming step is the muted amber #a09154 that already
     served the same role in this file. */
  background: linear-gradient(90deg, var(--sage) 0%, #a09154 55%, var(--clay) 100%);
  clip-path: inset(0 100% 0 0 round 999px);
  transition: clip-path .55s cubic-bezier(.2,.8,.2,1);
}
.volume__ends {
  display: flex; justify-content: space-between; gap: 1rem;
  margin: .3rem 0 0; font-size: .72rem; color: var(--ink-faint);
}
.volume__label { margin: .45rem 0 0; font-size: .9rem; color: var(--ink-soft); font-style: italic; }
@media (prefers-reduced-motion: reduce) { .volume__fill { transition: none; } }

/* PHASE: while bytes are moving, the volume scale is a sentence and nothing more. Two things
   growing side by side compete for the same attention and only one of them is urgent. */
.volume.is-collapsed .volume__scale { display: none; }
.volume.is-collapsed .volume__label { font-style: normal; }


/* ==========================================================================
   ПРОДОЛЖИТЬ НА ДРУГОМ УСТРОЙСТВЕ — D8 (ссылка в момент загрузки) и D20 (QR на шаге 2).
   Оба места намеренно ТИХИЕ: это второй способ сделать то, что экран и так просит, а не
   призыв. Отсюда «карточка без карточки» — фон бумаги, тонкая линия, никакого акцентного
   цвета: кнопка «выбрать фотографии» обязана оставаться единственной яркой вещью здесь.
   ========================================================================== */
.handoff {
  margin: 1.5rem 0 0; padding: 1.1rem 1.2rem;
  background: var(--muslin); border: 1px solid var(--line); border-radius: var(--r-sm);
}
.handoff__q { margin: 0 0 .25rem; font-size: 1rem; font-weight: 600; color: var(--ink); }
.handoff__p { margin: 0 0 .9rem; font-size: .92rem; color: var(--ink-soft); max-width: 56ch; }
.handoff__body { display: flex; gap: 1.1rem; align-items: flex-start; flex-wrap: wrap; }
/* Белое поле вокруг кода — не оформление, а требование стандарта: без светлой рамки в четыре
   модуля телефон код не поймает. Она внутри самого SVG, здесь только подложка под неё. */
.handoff__qr {
  flex: 0 0 auto; line-height: 0; background: #fff; padding: .4rem;
  border: 1px solid var(--line); border-radius: 6px;
}
.handoff__qr svg { display: block; width: 168px; height: 168px; }
.handoff__side { flex: 1 1 260px; min-width: 0; }
.handoff__hint { margin: 0 0 .7rem; font-size: .92rem; color: var(--ink-soft); }
.handoff__acts { display: flex; gap: .5rem; flex-wrap: wrap; }
.handoff__said { margin: .7rem 0 0; font-size: .88rem; font-weight: 600; color: var(--sage);
  overflow-wrap: anywhere; }
.handoff__warn { margin: .7rem 0 0; font-size: .8rem; color: var(--ink-faint); max-width: 46ch; }

/* D8: строка живёт внутри блока прогресса, поэтому набрана мельче него и не спорит с цифрами. */
.upkeep { margin: .6rem 0 0; font-size: .86rem; color: var(--ink-soft); }
.upkeep__said { margin: .3rem 0 0; font-size: .86rem; font-weight: 600; color: var(--sage);
  overflow-wrap: anywhere; }
/* Кнопка, которая обязана выглядеть ссылкой: действие текстовое и стоит внутри предложения,
   а <button> нужен потому, что это действие, а не переход (иначе Enter на клавиатуре и
   screen-reader обещают человеку страницу, которой не будет). */
.linklike {
  appearance: none; background: none; border: 0; padding: 0; font: inherit; cursor: pointer;
  color: var(--clay); text-decoration: underline; text-underline-offset: 2px;
}
.linklike:hover { color: var(--clay-deep); }

/* ---- Album alternative, standing next to the uploader as an equal ---- */
/* (the shared look lives above with .why-photos — D9v) */
.albumcard__h { margin: 0 0 .4rem; font-size: var(--step-1); font-weight: 500; }
.albumcard__p { margin: 0 0 .9rem; color: var(--ink-soft); }
.albumcard .field { margin-top: 0; }

/* ==========================================================================
   PORTRAIT ROWS (§B15) — switching between several portraits in one browser.

   Deliberately quiet: this is a way-finder, not a dashboard. A customer with one
   portrait never sees it at all, and the one who has three must be able to tell in a
   glance WHICH set of photographs he is standing in — the pet's name is the only
   thing he can verify himself. Rows sit above the card and read as a list, not as
   tabs: tabs promise an app, and this is not one.
   Touch targets are generous on purpose (audience is 45+, often on a phone).
   ========================================================================== */
/* The portrait strip lives in assets/site.css now: it is rendered on BOTH /order and /p
   (owner asked for it on the preview page 07.08), and a component used by two pages cannot
   keep its styles inside one page's stylesheet. */


/* ==========================================================================
   IN-PAGE CONFIRM (replaces window.confirm for "start another portrait").
   The system dialog was grey, shouted the domain name, and appeared at exactly the
   moment the page most needs to be believed. Same words, our frame.
   ========================================================================== */
.pmodal { position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; padding: 1.2rem; }
.pmodal[hidden] { display: none; }
.pmodal__veil { position: absolute; inset: 0; background: rgba(42,33,25,.55); backdrop-filter: blur(2px); }
.pmodal__box {
  position: relative; z-index: 1;
  width: min(30rem, 100%);
  background: var(--cream);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-frame);
  padding: clamp(1.4rem, 4vw, 2rem);
}
.pmodal__title { font-family: var(--font-display); font-size: 1.35rem; line-height: 1.2; margin: 0 0 .7rem; color: var(--ink); }
.pmodal__body  { margin: 0 0 1.4rem; color: var(--ink-soft); font-size: .95rem; line-height: 1.6; }
.pmodal__actions { display: flex; flex-wrap: wrap; gap: .6rem; justify-content: flex-end; }
@media (max-width: 420px) {
  .pmodal__actions { flex-direction: column-reverse; }
  .pmodal__actions .btn { width: 100%; text-align: center; }
}

/* Hint under a read-only field (the email on an order that already exists). Quiet: it explains a
   deliberate limit, it is not an error. */
.field__hint { display: block; margin-top: .35rem; font-size: .82rem; color: var(--ink-faint); line-height: 1.45; }
.field__input[readonly] { background: var(--muslin); color: var(--ink-soft); cursor: default; }

/* The portrait you are standing on gets a link to its OWN page (27.08, item 8): the row is where the
   eye already is, and the ghost button lower down the card was the only door before. */
.portraits__open--here { margin-left: auto; font-size: .85rem; color: var(--clay);
  text-decoration: underline; text-underline-offset: 2px; white-space: nowrap; }

/* A5 — the bot trap. Not `display:none`: some fillers skip hidden inputs, and a field moved off the
   canvas is still "there" to them while being unreachable for a person (aria-hidden + tabindex=-1
   in the markup do the accessibility half). */
.hp-field { position: absolute; left: -9999px; top: auto; width: 1px; height: 1px; overflow: hidden; }

/* D23: sized for the four-line placeholder, not for the median answer — the hidden part of that
   hint is the sentence that takes the pressure off ("leave it empty and I'll choose"). */
.field__input--tall { min-height: 6.5em; }

/* ==========================================================================
   ERASURE ON REQUEST (owner's decision C, 01.09.2026) — two states, deliberately
   different in weight.

   THE INVITATION is the quietest thing on the page: no button chrome, no colour,
   the smallest type here. It is not shy for shyness's sake — the visual weight of
   a control is a promise about how casually it may be pressed, and this one may
   not be pressed casually at all.

   THE STANDING NOTICE is the opposite, and for the symmetrical reason: once an
   erasure is scheduled, the person who most needs to see it is the one who did
   NOT ask for it. So it sits above the portraits, on paper the eye cannot skim
   past, and it keeps a real button — undoing must be as easy as this is loud.
   ========================================================================== */
.erase { margin: 2.6rem 0 .4rem; text-align: center; font-size: .82rem;
         line-height: 1.6; color: var(--ink-faint); }
.erase a { color: var(--ink-faint); }
.erase__link {
  background: none; border: 0; padding: 0; cursor: pointer;
  font: inherit; color: var(--ink-faint);
  text-decoration: underline; text-underline-offset: 3px;
  text-decoration-color: color-mix(in srgb, var(--ink-faint) 45%, transparent);
}
.erase__link:hover { color: var(--ink-soft); text-decoration-color: currentColor; }

.deletion-notice {
  margin: 0 0 1.6rem; padding: 1rem 1.2rem;
  background: var(--muslin);
  border: 1px solid var(--line);
  border-left: 3px solid var(--clay);
  border-radius: var(--r-md, 8px);
}
.deletion-notice__h { margin: 0 0 .5rem; font-size: 1rem; line-height: 1.5; color: var(--ink); }
.deletion-notice__b { margin: 0 0 .9rem; font-size: .9rem; line-height: 1.6; color: var(--ink-soft); }
.deletion-notice__a { margin: 0; }

/* Error line inside a dialog. Red is used NOWHERE else on this site on purpose; here it is the
   only correct colour, because the only thing that can appear in it is "your click did not do
   what you think it did". */
.pmodal__note { margin: 1rem 0 0; font-size: .88rem; line-height: 1.5; color: #9c3a2c; }
.pmodal__note[hidden] { display: none; }
