/* ---------------------------------------------------------------------------
   Theme tokens.

   The names are ROLES, not literal colours: `ink` is the page surface and
   `bone` is the text on it, so in light mode `ink` is near-white and `bone` is
   near-black. That's deliberate — it means every existing utility class
   (bg-ink, text-bone, border-ink3, and opacity variants like bg-ink2/40)
   switches with the theme without touching a single template.

   Values are space-separated RGB channels so Tailwind's `<alpha-value>`
   placeholder can compose them into rgb(... / opacity).

   `swatch-edge` is the hairline drawn ON TOP of a user's palette colour — it
   has to contrast with an arbitrary hue, so it flips white→black rather than
   following the surface token.
--------------------------------------------------------------------------- */
/* Light is the base state: `:root` carries it, so a page that never gets a
   data-theme attribute (JS disabled, localStorage throwing) still paints light
   rather than falling back to the alternate theme. The dark block must stay
   BELOW this one — both selectors have equal specificity, so source order is
   what lets [data-theme='dark'] win. */
:root,
[data-theme='light'] {
  --c-ink: 251 250 247; /* #FBFAF7 soft warm white, not stark */
  --c-ink2: 255 255 255; /* cards */
  --c-ink3: 226 221 213; /* borders */
  --c-bone: 26 23 32; /* #1A1720 text */
  --c-muted: 110 104 96; /* 5.3:1 on the surface */
  --c-mauve: 141 92 96; /* deepened: #A97F82 is only ~3:1 on white */

  --swatch-edge: 0 0 0;
  --swatch-edge-a: 0.16;
  --scrim: 26 23 32;
  --scrim-a: 0.45;
  --shadow-a: 0.1;
  --thumb-ring: 255 255 255;
}

[data-theme='dark'] {
  --c-ink: 21 20 26;
  --c-ink2: 31 29 36;
  --c-ink3: 50 47 56;
  --c-bone: 241 238 233;
  --c-muted: 156 151 143;
  --c-mauve: 169 127 130;

  --swatch-edge: 255 255 255;
  --swatch-edge-a: 0.12;
  --scrim: 0 0 0;
  --scrim-a: 0.7;
  --shadow-a: 0.35;
  --thumb-ring: 21 20 26;
}

html {
  background: rgb(var(--c-ink));
  color-scheme: light;
}
[data-theme='dark'] {
  color-scheme: dark;
}

* {
  font-family: 'Work Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}

/* Hairline over a palette swatch — must read against any hue underneath. */
.swatch-edge {
  border: 1px solid rgb(var(--swatch-edge) / var(--swatch-edge-a));
}
.shadow-soft {
  box-shadow: 0 4px 14px rgb(0 0 0 / var(--shadow-a));
}
.scrim {
  background: rgb(var(--scrim) / var(--scrim-a));
}

body {
  -webkit-tap-highlight-color: transparent;
}

.swatch-chip {
  display: block;
  width: 44px;
  height: 44px;
  border-radius: 999px;
  border: 1px solid rgb(var(--swatch-edge) / var(--swatch-edge-a));
  box-shadow: 0 2px 6px rgb(0 0 0 / var(--shadow-a));
  transition: transform 0.12s ease;
}

.swatch-btn:hover .swatch-chip {
  transform: scale(1.06);
}

.swatch-btn:active .swatch-chip {
  transform: scale(0.92);
}

.swatch-btn:focus-visible .swatch-chip {
  outline: 2px solid rgb(var(--c-mauve));
  outline-offset: 2px;
}

.preview-chip {
  width: 32px;
  height: 32px;
  border-radius: 8px;
  border: 1px solid rgb(var(--swatch-edge) / var(--swatch-edge-a));
  background: rgb(var(--c-bone) / 0.05);
}

.toggle-checkbox {
  width: 18px;
  height: 18px;
  border-radius: 5px;
  accent-color: rgb(var(--c-mauve));
}

a,
button {
  outline-color: rgb(var(--c-mauve));
}

::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}
::-webkit-scrollbar-thumb {
  background: rgb(var(--c-mauve) / 0.35);
  border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
  background: rgb(var(--c-mauve) / 0.55);
}
::-webkit-scrollbar-track {
  background: transparent;
}
/* Firefox. Only the two standard properties — no vendor hacks — so anything
   that doesn't understand them simply keeps its native scrollbar. */
html {
  scrollbar-width: thin;
  scrollbar-color: rgb(var(--c-mauve) / 0.35) transparent;
}

/* Legal pages. Long-form reading measure and rhythm — these are documents,
   not app screens, so they get a wider column and looser leading. */
.legal h2 {
  font-family: 'Fraunces', serif;
  font-size: 1.25rem;
  font-weight: 600;
  color: rgb(var(--c-bone));
  margin-top: 2.5rem;
  margin-bottom: 0.6rem;
}
.legal p,
.legal li {
  color: rgb(var(--c-muted));
  line-height: 1.65;
  margin-bottom: 0.9rem;
}
.legal strong {
  color: rgb(var(--c-bone));
  font-weight: 600;
}
.legal ul {
  list-style: none;
  padding-left: 0;
  margin-bottom: 1rem;
}
.legal li {
  padding-left: 1rem;
  border-left: 2px solid rgb(var(--c-ink3));
  margin-bottom: 0.5rem;
}
.legal a {
  color: rgb(var(--c-bone));
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-color: rgb(var(--c-mauve));
}
.legal a:hover {
  text-decoration-color: rgb(var(--c-bone));
}

/* Shade sliders (skin / eyes / hair). The track carries that category's own
   gradient via --track, so the control shows the range it spans. Height is
   28px rather than the visual 10px so there is a finger-sized hit area. */
.shade-slider {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 28px;
  background: transparent;
  cursor: pointer;
}

.shade-slider::-webkit-slider-runnable-track {
  height: 10px;
  border-radius: 999px;
  background: var(--track);
  border: 1px solid rgb(var(--c-bone) / 0.16);
}
.shade-slider::-moz-range-track {
  height: 10px;
  border-radius: 999px;
  background: var(--track);
  border: 1px solid rgb(var(--c-bone) / 0.16);
}

.shade-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 22px;
  height: 22px;
  border-radius: 999px;
  background: rgb(var(--c-bone));
  border: 2px solid rgb(var(--thumb-ring));
  box-shadow: 0 0 0 1.5px rgb(var(--c-mauve)), 0 2px 6px rgb(0 0 0 / var(--shadow-a));
  margin-top: -7px;
  transition: transform 0.12s ease;
}
.shade-slider::-moz-range-thumb {
  width: 22px;
  height: 22px;
  border-radius: 999px;
  background: rgb(var(--c-bone));
  border: 2px solid rgb(var(--thumb-ring));
  box-shadow: 0 0 0 1.5px rgb(var(--c-mauve)), 0 2px 6px rgb(0 0 0 / var(--shadow-a));
}

.shade-slider:active::-webkit-slider-thumb {
  transform: scale(1.12);
}
.shade-slider:focus-visible::-webkit-slider-thumb {
  box-shadow: 0 0 0 3px rgb(var(--c-mauve)), 0 2px 6px rgb(0 0 0 / var(--shadow-a));
}
.shade-slider:focus {
  outline: none;
}

.shade-preview {
  width: 44px;
  height: 44px;
  border-radius: 999px;
  border: 1px solid rgb(var(--c-bone) / 0.2);
  box-shadow: 0 2px 8px rgb(0 0 0 / var(--shadow-a));
  flex-shrink: 0;
}

/* Hide the native disclosure triangle — the chevron is drawn in markup so it
   can be coloured and rotated with the rest of the system. */
summary {
  list-style: none;
}
summary::-webkit-details-marker {
  display: none;
}

/* One focus treatment across every control, replacing the browser default. */
:where(button, a, summary, input, [tabindex]):focus-visible {
  outline: 2px solid rgb(var(--c-mauve));
  outline-offset: 3px;
  border-radius: 9px;
}

/* Signature moment: staggered reveal on the results screen */
@keyframes drape-in {
  from {
    opacity: 0;
    transform: translateY(10px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

.drape-reveal {
  opacity: 0;
  animation: drape-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

/* ---------------------------------------------------------------------------
   Homepage hero — desktop only.

   `display: none` is the base state and the grid is opted into at 1024px, so
   the hero can never appear on a phone: there is no room for two columns, and
   mobile is meant to land straight in step one of the flow.

   Written here rather than as `lg:grid` utilities for the same reason as
   `.canvas-split` below — app.js toggles `.is-hidden` on this element to keep
   the hero to the instructions step, and a `lg:` display utility would
   out-rank that. `:not(.is-hidden)` keeps the JS toggle authoritative.
--------------------------------------------------------------------------- */
.hero {
  display: none;
}

@media (min-width: 1024px) {
  .hero:not(.is-hidden) {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    column-gap: 4rem;
    align-items: center;
    padding-top: 3.5rem;
    padding-bottom: 4.5rem;
  }
}

.hero-eyebrow {
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  font-weight: 500;
  color: rgb(var(--c-mauve));
  margin-bottom: 1.35rem;
}

.hero-title {
  font-family: 'Fraunces', serif;
  font-size: clamp(2.75rem, 4.2vw, 3.75rem);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -0.025em;
  color: rgb(var(--c-bone));
  margin-bottom: 1.5rem;
}

.hero-title span {
  display: block;
}

.hero-sub {
  font-size: 1.0625rem;
  line-height: 1.65;
  color: rgb(var(--c-muted));
  max-width: 33rem;
  margin-bottom: 2.25rem;
}

/* Same primary-button treatment as the flow's fixed action bar, so the CTA
   reads as the identical control the user meets on the next screen. */
.hero-cta {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0.95rem 2rem;
  border-radius: 0.75rem;
  background: rgb(var(--c-bone));
  color: rgb(var(--c-ink));
  font-weight: 500;
  font-size: 1rem;
  transition: filter 0.15s ease, transform 0.12s ease;
}
.hero-cta:hover {
  filter: brightness(0.95);
}
.hero-cta:active {
  transform: scale(0.99);
}

.hero-note {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 1.1rem;
  font-size: 0.8125rem;
  color: rgb(var(--c-muted));
}
.hero-note-icon {
  color: rgb(var(--c-mauve));
  flex-shrink: 0;
}

.hero-figure {
  margin: 0;
  position: relative; /* containing block for the drift circles */
}

/* The mockup sits above the circles, so they peek out at its edges rather
   than floating in front of the phone. */
.hero-figure picture {
  position: relative;
  z-index: 1;
  display: block;
}

/* ---------------------------------------------------------------------------
   Hero drift circles.

   Colours are the brand mark's own four (icon SVG) plus the mauve accent —
   no new palette invented for decoration.

   Each circle pairs a `drift` translate/scale with a slower `breathe` opacity
   pulse. The two run at different, deliberately non-harmonic durations with
   negative delays, so the pair never re-syncs and the whole group never lands
   in a repeating formation. That mismatch is the entire trick: equal or
   harmonic durations read as mechanical within a few seconds.

   Blurred and semi-transparent so they read as soft light behind the phone
   rather than as flat discs competing with it.
--------------------------------------------------------------------------- */
.hero-orb {
  position: absolute;
  border-radius: 999px;
  pointer-events: none;
  z-index: 0;
  filter: blur(14px);
  /* Promoted once, up front: without it each keyframe step can re-rasterise
     the blur, which is the expensive part. */
  will-change: transform, opacity;
}

.hero-orb-1 {
  width: 190px; height: 190px;
  top: 4%; left: -6%;
  background: rgb(241 104 94 / 0.34);
  animation: drift1 15.6s ease-in-out -3s infinite, breathe 9.2s ease-in-out -1.5s infinite;
}
.hero-orb-2 {
  width: 140px; height: 140px;
  top: 28%; right: -8%;
  background: rgb(217 164 65 / 0.38);
  animation: drift2 12.5s ease-in-out -1s infinite, breathe 11s ease-in-out -4s infinite;
}
.hero-orb-3 {
  width: 168px; height: 168px;
  bottom: 10%; left: -9%;
  background: rgb(64 196 170 / 0.30);
  animation: drift3 18s ease-in-out -7s infinite, breathe 8.5s ease-in-out -2.4s infinite;
}
.hero-orb-4 {
  width: 118px; height: 118px;
  bottom: 20%; right: -5%;
  background: rgb(123 45 142 / 0.26);
  animation: drift4 14.2s ease-in-out -5s infinite, breathe 10.4s ease-in-out -3s infinite;
}
.hero-orb-5 {
  width: 96px; height: 96px;
  top: 62%; left: 12%;
  background: rgb(169 127 130 / 0.30);
  animation: drift5 16.8s ease-in-out -2.4s infinite, breathe 7.6s ease-in-out -5s infinite;
}

@keyframes drift1 {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  33% { transform: translate3d(-14px, -22px, 0) scale(1.035); }
  66% { transform: translate3d(10px, 16px, 0) scale(0.975); }
}
@keyframes drift2 {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  40% { transform: translate3d(18px, 14px, 0) scale(1.05); }
  70% { transform: translate3d(-8px, -18px, 0) scale(0.96); }
}
@keyframes drift3 {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  30% { transform: translate3d(16px, -16px, 0) scale(0.97); }
  62% { transform: translate3d(-12px, 20px, 0) scale(1.04); }
}
@keyframes drift4 {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  45% { transform: translate3d(-20px, 12px, 0) scale(1.06); }
  75% { transform: translate3d(6px, -14px, 0) scale(0.98); }
}
@keyframes drift5 {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  36% { transform: translate3d(12px, 18px, 0) scale(1.07); }
  68% { transform: translate3d(-16px, -10px, 0) scale(0.95); }
}
@keyframes breathe {
  0%, 100% { opacity: 0.85; }
  50% { opacity: 1; }
}

.hero-img {
  display: block;
  /* 0.845 is the asset's own 760∶900 ratio, so the viewport cap bounds the
     mockup's HEIGHT on short laptop screens without distorting it or
     letterboxing it inside a taller box. */
  width: min(100%, 560px, calc(76vh * 0.845));
  height: auto;
  margin-inline: auto;
}

/* The mockup carries its own drop shadow, which does its job on the light
   surface and disappears on the dark one — leaving a near-black device on a
   near-black page. A hairline halo hugging the alpha silhouette gives the
   edge back without restyling the asset. */
[data-theme='dark'] .hero-img {
  filter: drop-shadow(0 0 1px rgb(var(--c-bone) / 0.45));
}

/* ---------------------------------------------------------------------------
   Share row.

   Same chrome as the checklist cards on the instructions screen (ink2 fill,
   ink3 hairline, mauve on hover) so the buttons read as part of the system
   rather than as embedded platform widgets. Icons inherit `currentColor`,
   which is what keeps them monochrome across both themes.
--------------------------------------------------------------------------- */
.share-heading {
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: rgb(var(--c-mauve));
  margin-bottom: 0.7rem;
}

.share-btns {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.share-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  height: 40px;
  width: 40px;
  justify-content: center;
  border-radius: 0.75rem;
  background: rgb(var(--c-ink2));
  border: 1px solid rgb(var(--c-ink3));
  color: rgb(var(--c-bone));
  transition: border-color 0.15s ease, color 0.15s ease, transform 0.12s ease;
}

.share-btn:hover {
  border-color: rgb(var(--c-mauve) / 0.45);
  color: rgb(var(--c-mauve));
}

.share-btn:active {
  transform: scale(0.96);
}

/* The copy button carries a label as well as an icon, so it is the one item
   that is not a fixed square. */
.share-copy {
  width: auto;
  padding: 0 0.9rem;
}

.share-copy-label {
  font-size: 0.8125rem;
  white-space: nowrap;
}

/* Confirmation holds the mauve accent rather than flashing a new colour in. */
.share-copy.is-copied {
  border-color: rgb(var(--c-mauve) / 0.45);
  color: rgb(var(--c-mauve));
}

.share-copy-icon {
  display: inline-flex;
}

.share-copy-icon[hidden] {
  display: none;
}

/* ---------------------------------------------------------------------------
   FAQ accordion (learn.html).

   Native <details>/<summary> rather than JS: the answers stay in the DOM and
   readable with scripting off, which also matters for the FAQPage JSON-LD in
   the head — Google requires the marked-up answer text to be present on the
   page, and content injected by script is a weaker guarantee than markup that
   ships in the HTML.

   The chevron is a markup character, not the native disclosure triangle (that
   is suppressed globally by the `summary` rule near the top of this file), so
   it can carry the mauve accent and rotate with the rest of the system.
--------------------------------------------------------------------------- */
.faq {
  margin: 1.75rem 0 2.75rem;
  border-top: 1px solid rgb(var(--c-ink3));
}

.faq details {
  border-bottom: 1px solid rgb(var(--c-ink3));
}

.faq summary {
  display: flex;
  align-items: flex-start;
  gap: 0.7rem;
  padding: 1rem 0;
  cursor: pointer;
  color: rgb(var(--c-bone));
  font-weight: 500;
  font-size: 1rem;
  line-height: 1.45;
  transition: color 0.15s ease;
}

.faq summary:hover {
  color: rgb(var(--c-mauve));
}

.faq-chevron {
  flex-shrink: 0;
  margin-top: 0.05em;
  color: rgb(var(--c-mauve));
  transition: transform 0.18s ease;
}

.faq details[open] .faq-chevron {
  transform: rotate(90deg);
}

.faq-answer {
  margin: 0;
  padding: 0 0 1.2rem 1.7rem;
  max-width: 64ch;
  color: rgb(var(--c-muted));
  font-size: 0.9375rem;
  line-height: 1.7;
}

/* Sits below the final divider, inside .faq so it inherits the block's rhythm.
   Underline treatment matches the article's inline links. */
.faq-contact {
  margin: 0;
  padding-top: 1.15rem;
  color: rgb(var(--c-muted));
  font-size: 0.9375rem;
}

.faq-contact a {
  color: rgb(var(--c-bone));
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-color: rgb(var(--c-mauve) / 0.6);
}

.faq-contact a:hover {
  text-decoration-color: rgb(var(--c-bone));
}

/* Footer links across the site point at learn.html#faq, so the heading needs
   breathing room when it is jumped to rather than sitting flush to the top. */
#faq {
  scroll-margin-top: 1.5rem;
}

/* ---------------------------------------------------------------------------
   Long-form article (learn.html). Distinct from `.legal`: that is a reference
   document people scan, this is prose people read start to finish, so it gets
   a tighter measure (~66ch ≈ 680px), looser leading, and larger headings.

   The measure is set on the text elements rather than the container, so
   full-width elements — the trait-card grid, the closing CTA — can still span
   the column while paragraphs stay readable.
--------------------------------------------------------------------------- */
/* A fixed measure, not `ch`: ch scales with each element's own font-size, so a
   shared ch value silently gives the lede, body and headings different widths. */
.article > p,
.article > ul,
.article > h2,
.article > h3,
.article > .article-lede {
  max-width: 42rem; /* 672px — inside the 650–700px reading target */
}

.article > p {
  color: rgb(var(--c-muted));
  line-height: 1.75;
  margin-bottom: 1.4rem;
}

.article .article-lede {
  font-size: 1.125rem;
  line-height: 1.7;
  color: rgb(var(--c-bone) / 0.85);
  margin-bottom: 1.6rem;
}

.article > h2 {
  font-family: 'Fraunces', serif;
  font-size: 1.65rem;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: rgb(var(--c-bone));
  margin-top: 3.25rem;
  margin-bottom: 0.85rem;
}

.article > h3 {
  font-family: 'Fraunces', serif;
  font-size: 1.15rem;
  font-weight: 600;
  color: rgb(var(--c-bone));
  margin-top: 2rem;
  margin-bottom: 0.5rem;
}

.article strong {
  color: rgb(var(--c-bone));
  font-weight: 600;
}

.article > ul {
  list-style: none;
  padding-left: 0;
  margin-bottom: 1.6rem;
}

.article > ul > li {
  position: relative;
  padding-left: 1.15rem;
  color: rgb(var(--c-muted));
  line-height: 1.7;
  margin-bottom: 0.9rem;
}

/* Mauve tick rather than a bullet glyph, so the list reads as part of the
   same system as the icon set instead of a browser default. */
.article > ul > li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.72em;
  width: 6px;
  height: 6px;
  border-radius: 999px;
  background: rgb(var(--c-mauve));
}

/* ---------------------------------------------------------------------------
   learn.html visuals.

   The article is a single 48rem column, and `.article > p/ul/h2` cap at 42rem
   for readability — but these blocks are figures, not prose, so they are
   direct children that take the full column and break the text rhythm on
   purpose. House rule for this page: no major section without something to
   look at.

   Illustration colours are hard-coded rather than themed: they are colour
   SAMPLES making a point about hue, so flipping them with the UI theme would
   destroy the thing being demonstrated. Only chrome (cards, labels, rules)
   follows the tokens.
--------------------------------------------------------------------------- */

/* Opening figure: the phone mockup beside a plain-language gloss, so the page
   shows what the tool looks like before asking anyone to read the theory. */
.lede-figure {
  display: grid;
  gap: 1.5rem;
  align-items: center;
  margin: 2.25rem 0 3rem;
  padding: 1.75rem;
  background: rgb(var(--c-ink2) / 0.55);
  border: 1px solid rgb(var(--c-ink3));
  border-radius: 1.25rem;
}

@media (min-width: 640px) {
  .lede-figure {
    grid-template-columns: 210px minmax(0, 1fr);
    padding: 2rem 2.25rem;
  }
}

.lede-figure img {
  display: block;
  width: 100%;
  max-width: 210px;
  height: auto;
  margin-inline: auto;
}

/* The mockup's baked-in shadow vanishes on the dark surface — same hairline
   halo the homepage hero uses to give the device its edge back. */
[data-theme='dark'] .lede-figure img {
  filter: drop-shadow(0 0 1px rgb(var(--c-bone) / 0.45));
}

.lede-figure figcaption {
  color: rgb(var(--c-muted));
  font-size: 0.9375rem;
  line-height: 1.6;
}

.lede-figure figcaption strong {
  color: rgb(var(--c-bone));
  font-weight: 600;
}

.lede-pills {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.9rem;
}

.lede-pills span {
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: rgb(var(--c-mauve));
  border: 1px solid rgb(var(--c-ink3));
  background: rgb(var(--c-ink));
  border-radius: 999px;
  padding: 0.3rem 0.7rem;
}

/* The swatch strip that turns each four-factor card from a paragraph in a box
   into a picture of the axis it describes. */
.trait-strip {
  display: flex;
  gap: 4px;
  margin-bottom: 0.9rem;
}

.trait-strip span {
  flex: 1;
  height: 28px;
  border-radius: 5px;
  border: 1px solid rgb(var(--swatch-edge) / var(--swatch-edge-a));
}

/* Common-mistakes rows: every point sits beside the comparison it is making.
   Overrides the plain-list treatment above — these are figure/text rows, not
   bullets, so they drop the mauve dot and take the full column. */
.article > ul.mistakes {
  max-width: none;
  margin-bottom: 2.5rem;
}

/* Written long-hand as `.article > ul.mistakes > li` rather than
   `.mistakes > li`: the generic `.article > ul > li` rule above carries an
   extra element in its specificity, and would otherwise keep re-applying the
   mauve bullet and its indent to these rows. */
.article > ul.mistakes > li {
  display: grid;
  gap: 1rem 1.75rem;
  align-items: center;
  padding-left: 0;
  padding-bottom: 1.6rem;
  margin-bottom: 1.6rem;
  border-bottom: 1px solid rgb(var(--c-ink3));
}

.article > ul.mistakes > li::before {
  content: none;
}

.article > ul.mistakes > li:last-child {
  border-bottom: 0;
  padding-bottom: 0;
  margin-bottom: 0;
}

.mistakes p {
  margin: 0;
}

@media (min-width: 640px) {
  .article > ul.mistakes > li {
    grid-template-columns: minmax(0, 1fr) 232px;
  }
}

/* Shared shell for every comparison graphic: two or three labelled samples in
   a row. What sits inside varies (drape tiles, tone bars, flat swatches) —
   the frame is what keeps them reading as one family. */
.cmp {
  display: flex;
  gap: 0.55rem;
  padding: 0.7rem;
  background: rgb(var(--c-ink2));
  border: 1px solid rgb(var(--c-ink3));
  border-radius: 0.9rem;
}

.cmp-half {
  flex: 1;
  min-width: 0;
}

.cmp-label {
  margin-top: 0.5rem;
  font-size: 0.625rem;
  line-height: 1.35;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  text-align: center;
  color: rgb(var(--c-muted));
}

.cmp-tile {
  display: block;
  width: 100%;
  height: auto;
}

.cmp-swatch {
  display: block;
  width: 100%;
  height: 62px;
  border-radius: 0.55rem;
  border: 1px solid rgb(var(--swatch-edge) / var(--swatch-edge-a));
}

.cmp-bars {
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.cmp-bars span {
  height: 14px;
  border-radius: 3px;
}

/* Wider variant for the section-level figures, which get the full column
   rather than a 232px rail. */
.cmp-wide {
  margin: 1.75rem 0 2.5rem;
  padding: 1rem;
  gap: 1rem;
}

.cmp-wide .cmp-swatch {
  height: 96px;
}

.cmp-wide .cmp-label {
  font-size: 0.6875rem;
  margin-top: 0.7rem;
}

/* Old way vs new way. A two-column card rather than two more paragraphs — the
   comparison is the content, so it should be shaped like one. */
.vs {
  display: grid;
  gap: 1rem;
  margin: 1.75rem 0 2.5rem;
}

@media (min-width: 640px) {
  .vs {
    grid-template-columns: 1fr 1fr;
  }
}

.vs-col {
  background: rgb(var(--c-ink2));
  border: 1px solid rgb(var(--c-ink3));
  border-radius: 1rem;
  padding: 1.25rem;
}

/* The Skintonic side gets the mauve edge — the same accent the app uses for
   the currently-active choice. */
.vs-col--accent {
  border-color: rgb(var(--c-mauve) / 0.5);
}

.vs-kicker {
  font-size: 0.625rem;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: rgb(var(--c-mauve));
  margin-bottom: 0.4rem;
}

.vs-title {
  font-family: 'Fraunces', serif;
  font-size: 1.0625rem;
  font-weight: 600;
  line-height: 1.25;
  color: rgb(var(--c-bone));
  margin-bottom: 0.85rem;
}

.vs-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.vs-list li {
  position: relative;
  padding-left: 1.15rem;
  font-size: 0.875rem;
  line-height: 1.5;
  color: rgb(var(--c-muted));
  margin-bottom: 0.5rem;
}

.vs-list li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.55em;
  width: 5px;
  height: 5px;
  border-radius: 999px;
  background: rgb(var(--c-mauve));
}

/* ---------------------------------------------------------------------------
   Supporting copy beside the "The difference it makes" tiles.

   A real rule rather than Tailwind size utilities, because the exact values
   matter here and utilities make them easy to nudge by accident: this text is
   pinned at 18px/1.6, one step ABOVE the 16px body default, so it holds its
   own against the two illustrations it sits next to.

   Only the size and leading live here. Colour, the lg-and-up visibility and
   the measure cap stay as utilities on the element.
--------------------------------------------------------------------------- */
.compare-note {
  font-size: 1.125rem; /* 18px */
  line-height: 1.6;    /* 28.8px */
}

/* ---------------------------------------------------------------------------
   Signature statement colour. One swatch in the statement row is the closest
   match to the wearer's own chroma, contrast and undertone, so it gets a mauve
   halo and a slight size bump — enough to read as chosen without turning the
   row into a set of competing highlights.
--------------------------------------------------------------------------- */
.signature-dot {
  box-shadow: 0 0 0 2px rgb(var(--c-ink)), 0 0 0 4px rgb(var(--c-mauve)),
    0 4px 18px rgb(var(--c-mauve) / 0.35);
  transform: scale(1.08);
}

.signature-badge {
  display: inline-block;
  font-size: 9px;
  line-height: 1.5;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgb(var(--c-ink));
  background: rgb(var(--c-mauve));
  border-radius: 999px;
  padding: 1px 7px;
}

/* ---------------------------------------------------------------------------
   Locked palette teaser.

   The scrim is a radial gradient rather than a flat wash: it only needs to be
   opaque where the headline and button actually sit, so the swatches around
   the edges keep their full strength. A flat `bg-ink/70` dimmed the colours
   everywhere to solve a problem that exists only in the middle.

   Written here, not as `bg-[radial-gradient(...)]`, because the Tailwind CDN's
   runtime JIT does not reliably compile arbitrary values containing commas.
--------------------------------------------------------------------------- */
.teaser-scrim {
  /* Deliberately NOT `--c-ink`: that token is near-white in light theme, which
     turned this into a white haze that bleached the swatches and the CTA. The
     scrim has to be dark in both themes, because its whole job is to carry
     light text and a mauve button over vivid colour. */
  background: radial-gradient(
    ellipse 56% 66% at 50% 50%,
    rgb(20 18 24 / 0.86) 0%,
    rgb(20 18 24 / 0.76) 42%,
    rgb(20 18 24 / 0.38) 66%,
    rgb(20 18 24 / 0.08) 86%,
    rgb(20 18 24 / 0) 100%
  );
}

/* Fixed light values, not theme tokens, for the same reason as the scrim. */
.teaser-title {
  color: #f6f2ed;
}
.teaser-sub {
  color: rgb(246 242 237 / 0.76);
}
.teaser-price {
  color: #f6f2ed;
}

/* The unlock CTA: brand mauve rather than the generic bone pill, so it does
   not blend into the pale copy above it. Text is the deep ink rather than
   white — on #A97F82 that is the pairing that clears AA. */
.btn-unlock {
  background: #a97f82;
  color: #17151b;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.25);
  transition: background-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
  animation: unlock-glow 3s ease-in-out infinite;
}
.btn-unlock:hover {
  background: #b98d90;
  transform: translateY(-1px);
  animation: none; /* pointer is already here — stop asking for attention */
}
.btn-unlock:active {
  transform: translateY(0) scale(0.99);
}

/* A slow halo that breathes outward and fades. Deliberately low-contrast and
   3s long: enough to catch the eye in peripheral vision, not enough to nag. */
@keyframes unlock-glow {
  0%,
  100% {
    box-shadow: 0 1px 2px rgb(0 0 0 / 0.25), 0 0 0 0 rgb(169 127 130 / 0.55);
  }
  50% {
    box-shadow: 0 1px 2px rgb(0 0 0 / 0.25), 0 0 0 12px rgb(169 127 130 / 0);
  }
}

/* ---------------------------------------------------------------------------
   Unlock confirmation. Fires only after PayPal verification succeeds — paying
   and then watching content appear with no acknowledgement reads as though
   something went wrong.

   Deliberately restrained: one toast that leaves on its own, and a single
   settling motion on the revealed content. No confetti — the moment should
   feel like a receipt, not a slot machine.
--------------------------------------------------------------------------- */
@keyframes unlock-toast-in {
  from {
    opacity: 0;
    transform: translate(-50%, 16px) scale(0.96);
  }
  to {
    opacity: 1;
    transform: translate(-50%, 0) scale(1);
  }
}

@keyframes unlock-toast-out {
  to {
    opacity: 0;
    transform: translate(-50%, -8px);
  }
}

.unlock-toast {
  position: fixed;
  left: 50%;
  bottom: 28px;
  z-index: 60;
  display: flex;
  align-items: center;
  gap: 0.6rem;
  max-width: calc(100vw - 32px);
  padding: 0.85rem 1.15rem;
  border-radius: 999px;
  background: rgb(var(--c-ink2));
  border: 1px solid rgb(var(--c-mauve) / 0.5);
  box-shadow: 0 10px 34px rgb(0 0 0 / calc(var(--shadow-a) + 0.18));
  color: rgb(var(--c-bone));
  font-size: 0.9rem;
  animation: unlock-toast-in 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

.unlock-toast.is-leaving {
  animation: unlock-toast-out 0.35s ease forwards;
}

/* The tick draws itself, so the confirmation reads as an event rather than a
   static glyph that was always there. */
.unlock-toast svg path {
  stroke-dasharray: 26;
  stroke-dashoffset: 26;
  animation: unlock-tick 0.42s 0.16s ease forwards;
}

@keyframes unlock-tick {
  to {
    stroke-dashoffset: 0;
  }
}

/* One settle on the content that just appeared. */
@keyframes unlock-settle {
  from {
    opacity: 0;
    transform: translateY(12px);
    filter: blur(6px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
    filter: blur(0);
  }
}

.unlock-settle {
  animation: unlock-settle 0.72s cubic-bezier(0.22, 1, 0.36, 1) both;
}

/* Drape, desktop: the framed photo sits left at a fixed sane width with the
   palette that drives it alongside. Written as CSS rather than an arbitrary
   `lg:grid-cols-[minmax(0,380px)_...]` utility because the Tailwind CDN's
   runtime JIT does not reliably compile arbitrary values containing commas. */
@media (min-width: 1024px) {
  .drape-split {
    display: grid;
    grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
    column-gap: 2rem;
    align-items: start;
  }
}

/* ---------------------------------------------------------------------------
   Photo screen, desktop: the canvas takes the left column and the controls
   (target tabs, prompt, warning, sampled chips) stack down a right-hand rail,
   instead of the whole thing running as one narrow strip.

   Written as CSS rather than `lg:grid` utilities on purpose: JS shows this
   block by removing `.hidden`, and a `lg:grid` utility would out-rank
   `display:none` at desktop widths, revealing the canvas before any photo has
   been uploaded. `:not(.hidden)` keeps the toggle authoritative.

   DOM order is the mobile order; only the desktop placement is reassigned.
--------------------------------------------------------------------------- */
@media (min-width: 1024px) {
  .canvas-split:not(.hidden) {
    display: grid;
    grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
    column-gap: 2rem;
    align-items: start;
  }
  .canvas-split > .canvas-stage-cell {
    grid-column: 1;
    grid-row: 1 / span 4;
  }
  .canvas-split > .canvas-rail-1 {
    grid-column: 2;
    grid-row: 1;
  }
  .canvas-split > .canvas-rail-2 {
    grid-column: 2;
    grid-row: 2;
  }
  .canvas-split > .canvas-rail-3 {
    grid-column: 2;
    grid-row: 3;
  }
  .canvas-split > .canvas-rail-4 {
    grid-column: 2;
    grid-row: 4;
    align-self: start;
  }
}

/* ---------------------------------------------------------------------------
   First-load cue for the drape swatches. A row of coloured circles doesn't
   announce that it's interactive, so they breathe three times and then stop
   for good — a hint, not an attention loop. The class is also dropped on the
   first interaction, so anyone who already understood never sees it finish.

   Only inactive swatches pulse: the active one already carries a bone ring,
   and a glow underneath it would read as a rendering artefact.
--------------------------------------------------------------------------- */
@keyframes swatch-hint {
  0%,
  100% {
    transform: scale(1);
    box-shadow: 0 0 0 0 rgb(var(--c-mauve) / 0);
  }
  50% {
    transform: scale(1.07);
    box-shadow: 0 0 0 5px rgb(var(--c-mauve) / 0.28);
  }
}

/* aria-pressed lives on the cell button; the circle it animates is inside. */
.drape-grid.is-hinting .drape-swatch-cell[aria-pressed='false'] .drape-swatch {
  animation: swatch-hint 1.15s ease-in-out var(--hint-delay, 0ms) 3;
}

/* Hover intent always wins over the hint. */
.drape-grid.is-hinting .drape-swatch-cell:hover .drape-swatch {
  animation: none;
}

/* ---------------------------------------------------------------------------
   Season headline (results screen).

   The name is the dominant element on the page, so the two things that could
   undermine it are handled deliberately:

   1. TEXT COLOUR IS ALWAYS `bone`, never the season colour. Season colours
      span pastels (#B9D3E4) to deep statements, so painting the words in one
      would give some users a headline at ~1.5:1 on the light surface. The
      season colour appears only as accent — a chip and a diffuse glow — where
      its contrast doesn't decide whether the text is readable.
   2. The glow sits BEHIND the text at low opacity and heavy blur, so it tints
      the surface by a few percent rather than competing with the glyphs.

   `--season` is set inline per result; the mauve fallback covers a season with
   no usable colour in its palette.
--------------------------------------------------------------------------- */
.season-headline {
  --season: rgb(var(--c-mauve));
  position: relative;
  margin-bottom: 0.85rem;
}

.season-headline::before {
  content: '';
  position: absolute;
  left: -6%;
  top: -40%;
  width: 78%;
  height: 180%;
  background: radial-gradient(closest-side, var(--season), transparent 72%);
  opacity: 0.22;
  filter: blur(34px);
  pointer-events: none;
  z-index: 0;
}

[data-theme='dark'] .season-headline::before {
  opacity: 0.3;
}

.season-name {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 0.7rem;
  font-family: 'Fraunces', serif;
  font-size: clamp(2.9rem, 7.4vw, 4.75rem);
  font-weight: 600;
  line-height: 1.02;
  letter-spacing: -0.032em;
  color: rgb(var(--c-bone));
}

/* The hairline is what keeps a pale season's chip visible on the light
   surface — same treatment every other swatch on the site uses. */
.season-chip {
  flex-shrink: 0;
  width: 0.62em;
  height: 0.62em;
  border-radius: 999px;
  background: var(--season);
  border: 1px solid rgb(var(--swatch-edge) / var(--swatch-edge-a));
  box-shadow: 0 2px 10px rgb(0 0 0 / var(--shadow-a));
}

/* Traits: below the headline in size, but NOT dimmed. All four are peers and
   are read as one result, so the line takes a single colour and weight
   throughout — singling out one term made it look like the others mattered
   less. Separation from the headline is carried by size alone (76px vs 19px),
   which is what keeps this readable without turning it grey. */
.season-traits {
  position: relative;
  z-index: 1;
  font-size: 1.0625rem;
  font-weight: 500;
  line-height: 1.5;
  color: rgb(var(--c-bone));
  margin-bottom: 2.25rem;
  max-width: 40ch;
}

@media (min-width: 640px) {
  .season-traits {
    font-size: 1.1875rem;
  }
}

/* ---------------------------------------------------------------------------
   Motion layer.

   Everything here is decorative: each effect has a no-motion resting state
   that is the finished, visible state, so if an animation never runs (reduced
   motion, JS off, an unsupported browser) the page is still correct rather
   than blank. That is the rule to keep when adding to this section.
--------------------------------------------------------------------------- */

/* Step-to-step transition, applied by app.js on each render. Short enough to
   read as a settle rather than a wait. */
@keyframes step-in {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* Same entrance, minus the transform, for anything that must not become a
   containing block. */
@keyframes step-fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* The transform is applied to the step's CHILDREN, never to the step wrapper
   itself, and the pinned action bar is exempted from it entirely.
   A transformed element is a containing block for its `position: fixed`
   descendants. With the animation on the wrapper, the bar spent every step
   transition anchored to the bottom of the wrapper rather than the viewport —
   and with a tall photo mounted that wrapper is over 1200px tall, so the
   Continue button was parked below the fold. Animating the children instead
   is visually identical (same keyframes, same duration, same easing, and the
   children don't overlap) while leaving the bar's ancestor chain transform-free
   at every point in the animation, not just after it finishes.

   `backwards`, NOT `both`: a forwards fill would keep the animation's resolved
   end value — the matrix `matrix(1, 0, 0, 1, 0, 0)`, not the keyword `none`
   the keyframe is written with — on the element forever, which re-creates the
   containing block permanently. `backwards` covers the gap before the
   animation starts, which is the only fill this ever needed. */
.step-enter > *:not(.flow-action-bar) {
  animation: step-in 300ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}

.step-enter > .flow-action-bar {
  animation: step-fade 300ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}

/* ---------------------------------------------------------------------------
   The sampling photo.

   The canvas is sized to FIT, never to fill. Before this, it was `w-full` with
   an auto height, so a 900x2600 full-body shot rendered ~964px tall on a 812px
   phone: the photo alone was taller than the screen, the page became a long
   scroll, and reaching anything below the photo meant dragging across the one
   element that treats a drag as a colour sample.

   `svh`, not `vh` or `dvh`, for the cap:
     - `vh` is the LARGE viewport (browser toolbar hidden). Sizing to it means
       the photo overflows by the toolbar's height whenever the toolbar is
       actually showing — which is exactly when the user first sees the screen.
     - `dvh` tracks the toolbar live, so the photo would resize mid-scroll and
       reflow everything under it. That is a wiggle generator, not a fix.
     - `svh` is the SMALL viewport (toolbar showing): the worst case, and a
       constant. The photo fits on arrival and never changes size afterwards.
   The plain `vh` line above it is the fallback for browsers that predate the
   new units; they simply get the older behaviour.

   The subtraction is what keeps the Continue button clear: the step's chrome
   (target tabs, prompt line, sample chips) plus the pinned bar comes to about
   300px, and the floor stops the photo collapsing to nothing on a short screen
   or a landscape phone.
--------------------------------------------------------------------------- */
/* Sampling is a press-and-drag on an image, and every native gesture that
   competes with it starts the same way. `user-select: none` stops a drag from
   starting a text selection that then runs away into the copy around the
   photo; `-webkit-touch-callout: none` stops iOS answering a long press with
   its own selection callout AND its own text magnifier — which is the one that
   showed up on top of ours, since the press-and-hold that claims a sampling
   gesture lands on exactly the same timing.
   This used to be handled implicitly: the old pointerdown handler called
   preventDefault() on every pointer, which suppresses both. The touch path
   can't do that any more — preventDefault there also cancels the scroll the
   page needs — so what was a side effect now has to be stated. */
.photo-stage,
.photo-canvas {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

/* The stage deliberately keeps the column's full width and does NOT shrink to
   the photo. `width: fit-content` here reads as the tidier option, but it makes
   the canvas's own `min(100%, …)` width circular — the percentage resolves
   against a containing block that is itself being sized by its content — and
   the canvas silently falls back to its intrinsic bitmap width, which is the
   one size it must never use. The overlays that need to hug the photo rather
   than the column (the loupe, the colour readout) are positioned against the
   photo's measured box in photo.js instead. */
.photo-stage {
  width: 100%;
}

.photo-canvas {
  display: block;
  margin-inline: auto;

  /* Fallback first, then the real thing — browsers without the new units keep
     the older behaviour instead of dropping the whole declaration. */
  --photo-max-h: max(240px, calc(100vh - 300px));
  --photo-max-h: max(240px, calc(100svh - 300px));

  /* The width is what the height cap is expressed through. `max-height` alone
     can't do this job: with a specified width, the replaced-element rules clamp
     the height and leave the width where it was, so a tall photo ends up in a
     full-width box with the image letterboxed inside a border drawn around
     empty space. Deriving the width from the cap and the photo's own ratio
     (--photo-ar, set by mountImage) makes the box hug the image at every
     shape — and `min(100%, …)` means a small or wide photo still fills the
     column instead of sitting tiny in the middle of it.
     `max-height` and `object-fit` below are the safety net, not the mechanism:
     if --photo-ar is ever missing, the cap still holds and the image scales
     down inside the box rather than stretching. canvasContentRect() in
     photo.js measures that same letterboxing back out, so a sample stays on
     the pixel under the finger either way. */
  width: min(100%, calc(var(--photo-max-h) * var(--photo-ar, 1)));
  height: auto;
  max-width: 100%;
  max-height: var(--photo-max-h);
  object-fit: contain;
}

/* From lg up the photo has its own column beside the rail, so the chrome it
   has to share the screen with is only the header and the pinned bar. */
@media (min-width: 1024px) {
  .photo-canvas {
    --photo-max-h: max(320px, calc(100vh - 220px));
    --photo-max-h: max(320px, calc(100svh - 220px));
  }
}

/* ---------------------------------------------------------------------------
   "Not yet" shake.

   Used when a control is pressed before its precondition is met. A refusal
   needs to be FELT at the point of the press — a message alone, in a bar the
   user may not be looking at, reads as nothing happening at all.

   Small and quick on purpose: 5px each way over a fifth of a second. A bigger
   or slower wobble reads as breakage rather than as "not yet".
--------------------------------------------------------------------------- */
@keyframes nudge-shake {
  0%, 100% { transform: translateX(0); }
  15% { transform: translateX(-5px); }
  30% { transform: translateX(5px); }
  45% { transform: translateX(-4px); }
  60% { transform: translateX(3px); }
  75% { transform: translateX(-2px); }
}

.is-shaking {
  animation: nudge-shake 220ms ease-in-out;
}

/* ---------------------------------------------------------------------------
   The pinned action bar shared by the flow steps.

   Padding, not margin, for the home-indicator inset: the bar's background has
   to run all the way to the physical bottom edge of the screen, with only the
   button pushed up clear of the indicator. `viewport-fit=cover` in the
   <meta name="viewport"> is what makes env() resolve to a real value here.

   The background is fully opaque rather than a translucent blur. The bar sits
   over the sampling canvas, so a photo scrolling underneath a 95%-alpha panel
   shows through just enough to fight the button label.
--------------------------------------------------------------------------- */
/* The class is doubled to raise specificity to (0,2,0). The Tailwind CDN
   injects its utilities into a <style> element that lands AFTER this file, so
   a single-class rule here loses to the `p-4` on the same element and the
   inset padding silently does nothing. Ordering, not weight, is the problem —
   hence the doubled selector rather than !important. */
.flow-action-bar.flow-action-bar {
  /* max(), not calc(1rem + ...). On iOS Safari the bottom inset is 0 while the
     browser's own toolbar is showing and jumps to the home-indicator height
     the moment that toolbar minimises mid-scroll. Adding the two makes the bar
     grow by the full inset at that moment, which shifts the button under the
     user's thumb — the wiggle. max() keeps the bar at 1rem until the inset
     actually exceeds it, so the step is smaller and only ever happens once. */
  padding-bottom: max(1rem, env(safe-area-inset-bottom, 0px));
}

/* The instructions step drops out of the pinned treatment from lg up (the
   desktop hero owns the primary CTA there), so it drops the inset with it. */
@media (min-width: 1024px) {
  .flow-action-bar.is-inline-lg.is-inline-lg {
    padding-bottom: 0;
  }
}

/* Click feedback on a palette swatch: overshoot then settle. Runs on the inner
   dot so it composites on its own layer and never reflows the grid. */
@keyframes swatch-pop {
  0% {
    transform: scale(1);
  }
  45% {
    transform: scale(1.16);
  }
  100% {
    transform: scale(1);
  }
}

.swatch-dot.is-popping {
  animation: swatch-pop 320ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* Confirmation ring pulsed over a committed sample point on the photo. */
@keyframes sample-pulse {
  from {
    transform: translate(-50%, -50%) scale(0.5);
    opacity: 0.9;
  }
  to {
    transform: translate(-50%, -50%) scale(2.1);
    opacity: 0;
  }
}

.sample-pulse {
  position: absolute;
  width: 46px;
  height: 46px;
  margin: 0;
  border-radius: 999px;
  border: 2px solid rgb(var(--c-bone));
  pointer-events: none;
  z-index: 25;
  animation: sample-pulse 520ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

/* ---------------------------------------------------------------------------
   "There is more below" nudge on the results screen.

   A hint, not an attention loop — the same rule the drape grid's cue follows.
   The arrow bounces a fixed number of times and then rests, so a page left
   open doesn't pulse at someone indefinitely; the pill itself stays until they
   scroll, because that is the moment it has served its purpose.

   pointer-events:none throughout: it floats over the palette, and a nudge that
   can eat a swatch tap is worse than no nudge. It is also aria-hidden in the
   markup — everything it points at is already in the document and reachable by
   keyboard or screen reader, so announcing it would be noise.
--------------------------------------------------------------------------- */
@keyframes scroll-hint-nudge {
  0%,
  100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(3px);
  }
}

.scroll-hint {
  position: fixed;
  left: 50%;
  /* Clears the home indicator on a phone without floating oddly high on a
     desktop, where the inset resolves to zero. */
  bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));
  z-index: 50;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.9rem;
  border-radius: 999px;
  background: rgb(var(--c-ink));
  border: 1px solid rgb(var(--c-mauve) / 0.45);
  box-shadow: 0 6px 20px rgb(0 0 0 / var(--shadow-a));
  color: rgb(var(--c-mauve));
  font-size: 0.78rem;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  pointer-events: none;
  /* The -50% has to be carried by every transform below, or the pill jumps
     half its own width sideways as it leaves. */
  transform: translateX(-50%);
  transition: opacity 260ms ease, transform 260ms ease;
}

.scroll-hint.is-gone {
  opacity: 0;
  transform: translateX(-50%) translateY(6px);
}

.scroll-hint-arrow {
  display: block;
  animation: scroll-hint-nudge 1.4s ease-in-out 6;
}

/* Scroll-triggered reveals.

   The hidden state is scoped to `.reveal-armed`, which JS puts on <html> only
   after confirming IntersectionObserver exists and reduced motion is off. No
   JS, old browser, or reduced-motion preference means the rule never matches
   and the content is simply visible — content is never hidden by a stylesheet
   that something else has to come along and un-hide. */
.reveal-armed [data-reveal] {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}

.reveal-armed [data-reveal].is-revealed {
  opacity: 1;
  transform: none;
}

/* Analysing state: a deliberate beat before results. */
@keyframes analyse-sweep {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(100%);
  }
}

.analyse-bar {
  position: relative;
  overflow: hidden;
  height: 3px;
  border-radius: 999px;
  background: rgb(var(--c-ink3));
}

.analyse-bar::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: linear-gradient(90deg, transparent, rgb(var(--c-mauve)), transparent);
  animation: analyse-sweep 900ms ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .drape-reveal {
    animation: none;
    opacity: 1;
    transform: none;
  }
  .btn-unlock {
    animation: none;
  }
  /* Hero drift circles hold still. Scoped rather than the `* { animation:
     none !important }` blanket the reference used: this file rescues each
     animated element individually (.drape-reveal starts at opacity 0 and
     would be invisible forever if its animation were simply killed), and a
     global !important reset would silently pre-empt those fixes for anything
     added later. The circles keep their position and colour — only the
     motion stops. */
  .hero-orb {
    animation: none;
  }
  /* The toast still appears and still leaves — only the motion is dropped. */
  .unlock-toast,
  .unlock-toast.is-leaving,
  .unlock-toast svg path,
  .unlock-settle {
    animation: none;
    opacity: 1;
    transform: translate(-50%, 0);
    filter: none;
    stroke-dashoffset: 0;
  }
  .unlock-settle {
    transform: none;
  }
  .drape-grid.is-hinting .drape-swatch-cell[aria-pressed='false'] .drape-swatch {
    animation: none;
  }
  /* Each of these resolves to the finished state, not to nothing. */
  .step-enter > *,
  .swatch-dot.is-popping,
  .sample-pulse,
  .scroll-hint-arrow,
  .is-shaking,
  .analyse-bar::after {
    animation: none;
  }
  .step-enter > * {
    opacity: 1;
    transform: none;
  }
  .sample-pulse {
    display: none;
  }
  .reveal-armed [data-reveal] {
    opacity: 1;
    transform: none;
    transition: none;
  }
}
