/* ============================================================
   TestYourLogo — implemented strictly from Figma (node 5605:2361)
   Font: Source Sans 3 (400 / 500 / 600 / 700)
   Tokens: border/radius/xlarge = 24px, border/radius/rnd = pill
   ============================================================ */

/* ---- Self-hosted fonts ----
   Served from our own domain instead of fonts.googleapis.com, so no visitor IP
   is ever shared with a third party (the privacy point behind the German
   LG München Google-Fonts ruling) and the CSP needs no external font hosts.
   Split into latin / latin-ext subsets with the standard unicode-ranges, so the
   ext file only downloads if a page actually uses those characters.

   Both are Google's own variable woff2 builds, taken unmodified, so nothing here
   is a Modified Version and the files carry their upstream names and licences.
   Source Sans 3: one variable file covers weights 200–900 — 400–700 for text
   and 800 for the footer wordmark, so it is now the only face on the site. */
@font-face {
  font-family: "Source Sans 3";
  font-style: normal;
  font-weight: 200 900;
  font-display: swap;
  src: url("assets/fonts/sourcesans3-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Source Sans 3";
  font-style: normal;
  font-weight: 200 900;
  font-display: swap;
  src: url("assets/fonts/sourcesans3-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  --black: #262626;
  --black-hover: #000000;        /* primary CTA hover — darkest */
  --white: #ffffff;
  --bg: #f5f5f5;                /* page background */
  --radius-xl: 24px;            /* border/radius/xlarge */
  --radius-pill: 999px;         /* border/radius/rnd */
  --track: #e5e5e5;             /* segmented control track */
  --icon-inactive: #a1a1a1;     /* inactive segment icon */
  --icon-hover: #525252;        /* inactive segment icon, hovered (darker) */
  --divider: #d4d4d4;           /* segmented control divider */
  --text-muted: #737373;        /* secondary text (subtitle, hint, filename) */
  --border-muted: #a1a1a1;      /* muted input borders / placeholders */
  --surface-hover: #ececec;     /* hover fill — a touch darker than the #f5f5f5 page so it stays visible */
  /* The only non-greyscale value in this file. Tailwind red-700, matching the
     neutral scale the rest of these tokens come from. The 600 step is the
     more obvious pick and measures 4.43:1 on the #f5f5f5 page — under the
     4.5:1 AA floor at the 12px this is set in. 700 is 5.93:1. */
  --error: #b91c1c;             /* upload error text */
  --container: 1200px;
  /* Elevation scale — two layered (ambient + key) shadows, used consistently by
     all raised surfaces so modals share one elevation language.
     --elev-1: low (transient cards). --elev-2: high (dialogs). */
  --elev-1: 0 1px 2px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.08);
  --elev-2: 0 4px 12px rgba(0, 0, 0, 0.10), 0 24px 48px rgba(0, 0, 0, 0.16);
}

* { margin: 0; padding: 0; box-sizing: border-box; }

/* Form controls don't inherit font-family by default — make them use the body
   face (otherwise button/label text falls back to Arial). */
button, input, textarea, select { font-family: inherit; }

html {
  -webkit-text-size-adjust: 100%;
  /* Kill the grey/black square iOS paints over a tapped button/link. */
  -webkit-tap-highlight-color: transparent;
  /* Without this, the html canvas is transparent → the 3D viewer's dark scrim
     renders pure BLACK in the iOS toolbar/overscroll areas (harsh black bars),
     instead of the same dark grey it shows over the light page. */
  background: var(--bg);
}

body {
  font-family: "Source Sans 3", system-ui, sans-serif;
  color: var(--black);
  background: var(--bg);
  line-height: 1.2;
  /* Grayscale (not subpixel) antialiasing → thinner, crisper text. */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* With viewport-fit=cover the page extends under the iOS notch/status bar and
     home indicator so the fixed scrim can cover edge-to-edge; this keeps normal
     content clear of those areas. Zero on non-notched/desktop devices. */
  padding: env(safe-area-inset-top) env(safe-area-inset-right)
           env(safe-area-inset-bottom) env(safe-area-inset-left);
}

/* Nicer line breaking (per the linked article): avoid orphaned last words in
   body copy; balance short, centred, heading-style text. */
p { text-wrap: pretty; }
.upload__title { text-wrap: balance; }

.container {
  /* Exactly 1200px content when there is room (matches Figma's 156px gutter at
     1512), collapsing to a 24px gutter on narrow viewports. */
  width: min(100% - 48px, var(--container));
  margin-inline: auto;
}

.icon { width: 20px; height: 20px; display: block; color: var(--black); }

/* Visually hidden but available to screen readers (e.g. the document <h1>). */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ---- Keyboard focus ring (only for keyboard users, via :focus-visible) ----
   Applied site-wide to interactive elements. Rollback: delete this block. */
:where(a, button, [tabindex], .card--3d):focus-visible {
  outline: 2px solid var(--black);
  outline-offset: 2px;
}
/* Cards round to 24px, so the offset ring should hug that radius. */
.card--3d:focus-visible { outline-offset: 3px; border-radius: var(--radius-xl); }
/* Inputs/textareas already signal focus with a darkened border; add an outline
   too so keyboard focus is unmistakable. */
.fb-textarea:focus-visible,
.fb-input:focus-visible { outline: 2px solid var(--black); outline-offset: 2px; }

/* ===================== Main content ===================== */
.content { width: 100%; }
.content__max { display: flex; flex-direction: column; }

/* ---- Upload area (5605:2676 / 5605:2178) ---- */
.upload-area { width: 100%; padding-top: 96px; }  /* 96px above the upload box */

/* Unboxed: no container at all — the hero floats on the page.
   This used to carry a drag-over fill (background + radius + overflow:hidden +
   a transition) for an `.is-hover` class. Nothing ever added that class — page-
   wide drag feedback is #drop-overlay — so the state was unreachable and the
   properties served nothing. The overflow was worse than dead: .upload__content
   has no padding, so the hint row sits flush with this box's bottom edge and
   the clip was eating the focus ring on "Try a Sample Logo". */
.upload {
  width: 100%;
}
.upload__content {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* No padding of its own: the air around the hero comes from its neighbours —
     .upload-area's 96px above, #image-grid's 48px padding-top below — so a
     second set of insets here was only ever doubling up. (The populated state
     already zeroed them, so that override went with them.) */
  padding: 0;
}

/* empty-state texts */
.upload__texts {
  display: flex;
  flex-direction: column;
  gap: 12px;
  align-items: center;
  justify-content: center;
  width: 100%;
}
.upload__title {
  font-weight: 700;
  font-size: 40px;
  /* 1.25 ratio, same as the 600px override below (24/30). */
  line-height: 50px;
  text-align: center;
  width: 100%;
}
.upload__actions {
  display: flex;
  flex-direction: column;
  /* The button and the hint are the only two children, so this gap IS the space
     above "PNG or SVG". The row variant below re-sets it for the
     populated state's side-by-side buttons. */
  gap: 24px;
  align-items: center;
  padding-top: 24px;
  width: 100%;
}
/* One line, side by side. Both sit at weight 400 so neither shouts — "Try a
   sample" still reads as interactive from its underline and darker colour,
   without needing extra weight. */
/* Its own row between the CTA and the note, so a failure never overwrites the
   copy telling you what to do about it. Sits in .upload__actions' 24px column
   gap like any other child, and :empty takes the row back when there is nothing
   to say — including the gap, which would otherwise show as 24px of drift in
   the resting layout. */
.upload__error {
  margin: 0;
  font-size: 12px;
  line-height: 16px;
  font-weight: 600;
  text-align: center;
  text-wrap: pretty;
  color: var(--error);
}
.upload__error:empty { display: none; }

/* One sentence, not two items in a row: the note and the link are set as
   running text so they read straight through, "PNG, JPG, WebP or SVG. Try a
   sample logo." That means no flex here — flex would make each a box and eat
   the space between them — so the separator is the ordinary space in the
   markup, and it must stay there.
   No nowrap: at rest the sentence measures 216px inside the 343px available at
   375px and never needs to break anyway, but sayUploadProblem() swaps a much
   longer string into the span, and nowrap sent that one 400px wide in a 388px
   viewport — clipped at BOTH ends and scrolling the page sideways, precisely
   when the copy had something to say. Wrapping costs nothing at rest and keeps
   the error readable. "Try a sample logo" is a button, so it is atomic for
   line-breaking and can only move as a whole. */
.upload__hint {
  font-size: 12px;
  line-height: 16px;
  text-align: center;
  text-wrap: pretty;
}
.upload__hint span { font-weight: 600; color: var(--text-muted); }
/* Same weight as the note it now continues, so the line reads as one voice.
   The link still declares itself by its underline and full-strength colour
   rather than by sitting apart. */
.upload__hint .link { font-weight: 600; }

/* populated-state visual */
.upload__visual {
  display: flex;
  flex-direction: column;
  gap: 24px;
  align-items: center;
  max-width: 768px; /* 640 (design Visual) + 20% — roomier for wide logos */
  width: 100%;
  padding-bottom: 24px;
}
.upload__preview {
  /* No border/box. Logo is capped in HEIGHT (240px) but free to use the full
     width of the Visual area (up to 640px) so wide logos show large & detailed
     (e.g. Discord). Matches 5614:2361/2377/2393/2408. */
  width: 100%;
  max-height: 240px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.upload__preview img {
  max-width: 100%;
  max-height: 240px;
  width: auto;
  height: auto;
  object-fit: contain;
}
.upload__preview span { font-weight: 400; font-size: 14px; line-height: 20px; }
.upload__filename {
  font-weight: 400;
  font-size: 12px;
  line-height: 16px;
  color: var(--text-muted);
  text-align: center;
  width: 100%;
}
.upload__actions--row {
  flex-direction: row;
  gap: 12px;
  justify-content: center;
  padding-top: 0;
}

/* ---- Buttons ---- */
.btn {
  height: 48px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding-inline: 32px;
  border-radius: var(--radius-pill);
  font-weight: 600;
  /* Sentence case at 14px. The 12px forced-caps label was the loudest small
     text on the page and read as a style choice rather than a button; at 14px
     the words are read as words, which is what a CTA wants. */
  font-size: 14px;
  line-height: 20px;
  cursor: pointer;
  border: 1px solid transparent;
  white-space: nowrap;
  gap: 8px; /* only applies when a button has a leading icon */
  transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* Icons inside buttons: the global .icon is 20px at stroke 1.25, which next to
   the 14px/600 label reads oversized and far too light. Scale to 16px and
   thicken the stroke so it carries the same optical weight as the text.
   (Colour must be inherited too — .icon hard-sets var(--black), invisible on
   the dark primary button.) */
.btn .icon { width: 16px; height: 16px; color: inherit; }
.btn .icon path { stroke-width: 2; }
.btn--primary { background: var(--black); color: var(--white); }

/* ---- Shimmer (hero CTA only) ----
   A soft band of light crossing the button on a slow ambient loop. Opt-in via
   .btn--shimmer so it stays on the one button that has to pull the eye on an
   empty page; Send, Done and Replace are confirmations, and a glint on those
   would read as noise. It also only exists in the empty state — once a logo is
   in, the button is replaced and the loop stops with it.

   Hover does NOT touch it. There used to be a second, eased pass on ::after
   that fired on hover; on top of the loop it read as the shimmer speeding up
   whenever the pointer arrived. The loop is a rhythm, and a rhythm that reacts
   to you isn't one — so the button's hover is the fill darkening, nothing else.

   Painted OVER the label on purpose: the label is already pure white, so a white
   overlay cannot lighten it further — only the dark fill picks the light up, the
   way a real gloss would. Hence pointer-events: none and no z-index shuffling. */
.btn--shimmer { position: relative; overflow: hidden; }
.btn--shimmer::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 55%;
  pointer-events: none;
  /* Soft-edged band, brightest at its centre. Peak 0.18 lifts the #262626 fill
     to roughly #4D4D4D at the crest; the low shoulders keep the band crisp
     rather than washing the whole button. */
  background: linear-gradient(
    100deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.07) 35%,
    rgba(255, 255, 255, 0.18) 50%,
    rgba(255, 255, 255, 0.07) 65%,
    rgba(255, 255, 255, 0) 100%
  );
  opacity: 0;
  transform: translateX(-180%) skewX(-18deg);
  /* Pin this to its own compositor layer, permanently. It only ever animates
     transform and opacity, so it belongs on the compositor anyway — but the
     promotion has to be EXPLICIT, because changing the grid layout momentarily
     promotes every card with will-change: transform and then drops them again
     (see setGridColumns in app.js), and that churn is enough for the browser to
     demote this band into the main-thread raster. It picks the worst moment to
     do it: the same handler synchronously re-renders all six 3D cards, which
     measured a 72ms main-thread stall one frame after the click against an 8.3ms
     median. A main-thread shimmer skips that whole stall in one step, which is
     the flicker. On its own layer it does not care.
     Cheap to hold: one 166x48 layer on a button that animates forever regardless. */
  will-change: transform, opacity;
  /* A glint every 4.5s. Linear rather than eased: a fixed light source sliding
     over a surface moves at a constant rate, and easing here reads as a thing
     being animated at you. The 1.6s delay keeps it out of the page's own load. */
  animation: cta-shimmer 4.5s linear 1.6s infinite;
}
/* 0% and 100% share one declaration, so the loop closes on the exact frame it
   opened on — no seam. The band lights at 2%, crosses, and is dark again by 19%
   (0.85s, of which ~0.45s is actually over the button — it lights and dims while
   still off-edge); the remaining 3.6s walks it back to the left at opacity 0, so
   the return trip is a slow invisible drift rather than a jump-cut.

   Three separate dials, easy to confuse:
     - how QUICKLY the glint crosses  = these percentages (0.85s, was 1.17s)
     - how OFTEN it comes round       = the 4.5s duration
     - when the FIRST one lands       = the 1.6s delay
   The percentages are relative to the duration, so changing the duration alone
   also changes the crossing speed. Rescale them together. */
@keyframes cta-shimmer {
  0%,
  100% { opacity: 0;    transform: translateX(-180%) skewX(-18deg); }
  2%   { opacity: 0.85; transform: translateX(-140%) skewX(-18deg); }
  17%  { opacity: 0.85; transform: translateX(260%)  skewX(-18deg); }
  19%  { opacity: 0;    transform: translateX(320%)  skewX(-18deg); }
}
@media (prefers-reduced-motion: reduce) {
  /* Off entirely rather than "the same thing but still": the band is only 55%
     of the button, so a parked version is a gradient over the left half — worse
     than none. The hover still deepens the fill, so the button isn't inert. */
  .btn--shimmer::before { display: none; animation: none; }
}
/* Subtle hovers: dark buttons lighten a touch; outlined buttons get the same
   light fill used by the cards/tabs. */
.btn--primary:not(:disabled):hover { background: var(--black-hover); }
.btn--secondary:not(:disabled):hover { background: var(--surface-hover); }
/* Populated actions (5669:2388): smaller, outlined, transparent pills. */
.btn--secondary {
  background: transparent;
  color: var(--black);
  border-color: var(--black);
  padding-inline: 24px;
}
.btn--clear { width: 99px; padding-inline: 0; }
/* Populated row: Replace + Clear sit at the full 48px CTA height (matching the
   empty-state "Upload logo"), so every button on the page is the same height. */
.upload__actions--row .btn--primary { padding-inline: 24px; }

.link {
  font-family: inherit;
  font-weight: 500;
  font-size: 12px;
  line-height: 16px;
  color: var(--black);
  background: none;
  border: none;
  text-decoration: underline;
  text-underline-offset: 1px;
  cursor: pointer;
  /* Set inline inside .upload__hint's sentence, so the UA's button padding
     would show up as a gap the copy did not ask for. */
  padding: 0;
}

/* ---- Selector (5605:2670) ---- */
:root {
  --selector-bottom-gap: 32px; /* gap from viewport bottom while floating */
}
.selector-row {
  display: flex;
  justify-content: center;
  padding-top: 96px;   /* 5605:2678 — 96px above the pill at rest */
  min-height: 144px;   /* selector slot height */
  /* Float pinned to the bottom of the viewport while the slot is still below
     the fold, then settle into the slot and scroll normally. */
  position: sticky;
  bottom: var(--selector-bottom-gap);
  z-index: 20;
  /* Let clicks pass through the empty row to the grid behind it while floating;
     the selector pill itself re-enables pointer events. */
  pointer-events: none;
}
.selector {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 48px;
  /* Inverted from the original iOS-style control: the capsule is white and the
     thumb is grey, so the whole thing reads as a white object floating over the
     grid. Separation comes from a shadow while it floats (see .is-floating
     below), not a border — a border also cost 1px off the content box, which is
     what made the thumb sit 2px from the left but only 1px from the top. */
  background: var(--white);
  border-radius: var(--radius-pill);
  overflow: hidden;
  pointer-events: auto;
  /* Eased both ways so it fades in and out with the stick, rather than popping. */
  transition: box-shadow 0.2s ease;
}
/* Only while pinned over the grid. Once it lands in its slot there is nothing
   behind it to lift away from, and the shadow would just be decoration. */
.selector-row.is-floating .selector { box-shadow: var(--elev-1); }
.selector__highlight {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 48px;
  height: 48px;
  /* Grey thumb with a 2px white border, so it reads as inset an even 2px on all
     four sides (the border blends into the capsule). */
  background: var(--surface-hover);
  border: 2px solid var(--white);
  border-radius: var(--radius-pill);
  z-index: 1;
  transition: left 0.2s ease;
}
/* Centered on each 48px button: the icon is dead-centered in the circle, and at
   the ends the circle's arc coincides with the pill's cap (same radius 24, same
   center) so they share one line — flush, no double stroke, no icon movement. */
.selector[data-selected="3"] .selector__highlight { left: 0; }
.selector[data-selected="2"] .selector__highlight { left: 48px; }
.selector[data-selected="1"] .selector__highlight { left: 96px; }

.selector__btn {
  position: relative;
  z-index: 2;
  width: 48px;
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  color: var(--black);
  cursor: pointer;
}
.selector__btn .icon { transition: color 0.15s ease; }
.selector__btn[aria-selected="false"] .icon { color: var(--icon-inactive); }
/* Hover affordance on the unselected segments (pointer devices only, so touch
   doesn't leave a sticky-hover state): darken the grey icon toward the active
   colour so it reads as interactive, without looking selected. */
@media (hover: hover) {
  .selector__btn[aria-selected="false"]:hover .icon { color: var(--icon-hover); }
}

.selector__divider {
  position: absolute;
  top: 16px;
  width: 1px;
  height: 16px;
  background: var(--divider);
  z-index: 1;
  display: none;
}
.selector[data-selected="3"] .selector__divider { display: block; left: 95.5px; }
.selector[data-selected="1"] .selector__divider { display: block; left: 47.5px; }

/* ---- Image grid (5605:2371) ---- */
.image-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-content: start;  /* pack rows to the top, even while the grid height is
                            pinned taller during a transition (no mid-row gaps) */
  /* Uniform. This was 12px row / 16px column on the theory that square cards
     read looser stacked than side by side — but at two columns on a phone the
     asymmetry is plainly visible rather than a subtle correction. */
  gap: 12px;
  padding-top: 48px;   /* 5605:2680 */
}
.image-grid[data-cols="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.image-grid[data-cols="1"] { grid-template-columns: repeat(1, minmax(0, 1fr)); }

.card {
  aspect-ratio: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--white);
  border-radius: var(--radius-xl);
  /* No padding: the 3D canvas is full-bleed (like the viewer), so the card and the
     viewer's first frame render the model IDENTICALLY — exact open/close framing parity
     (the old 8px inset was a Figma artefact and forced an approximate camera-dolly comp). */
  overflow: hidden;
  cursor: pointer;
  /* Resting shadow is the hover shadow at zero alpha — same two layers, so the
     hover transition interpolates the colour smoothly instead of snapping from
     `none` (which several browsers refuse to animate). */
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0), 0 10px 28px rgba(0, 0, 0, 0);
  transition: box-shadow 0.25s ease;
}
/* 3D model viewer cells (rotation disabled for now) */
.card__canvas {
  width: 100%;
  height: 100%;
  display: block;
  /* No border-radius needed — the card's overflow:hidden clips it to the rounded
     shape. (Avoids a per-frame repaint during the grid transition.) */
  transform-origin: center center;
  transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Subtle scale-up + soft shadow on hover (Figma 3D "Hover" state). Gated to
   hover-capable (mouse) devices: on touch, :hover sticks after a tap, which left
   the grid card scaled to 1.05 while the viewer closed to 1.0 — a visible pop /
   mismatch when the card was revealed. */
@media (hover: hover) and (pointer: fine) {
  .card--3d:hover { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 28px rgba(0, 0, 0, 0.04); }
  .card--3d:hover .card__canvas { transform: scale(1.05); }
}

/* ---- Loading state: a small, quiet spinner centered in the card while its 3D
   model loads. Rollback: delete this block + the is-loading toggles in
   model-grid.js. ---- */
.card--3d.is-loading { position: relative; }
.card--3d.is-loading::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 16px;
  height: 16px;
  margin: -8px 0 0 -8px;
  border-radius: 50%;
  border: 1.5px solid rgba(212, 212, 212, 0.5); /* faint track */
  border-top-color: var(--divider);            /* slightly darker grey arc */
  animation: card-spin 0.8s linear infinite;
}
@keyframes card-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  /* No spin for reduced-motion users — a gentle pulse instead. */
  .card--3d.is-loading::after {
    animation: card-pulse 1.4s ease-in-out infinite;
    border-top-color: var(--track);
    background: var(--track);
  }
  @keyframes card-pulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.7; } }
}

@media (prefers-reduced-motion: reduce) {
  .card__canvas { transition: none; }
}

/* ---- Spacer (5605:2687) ---- */
.spacer { height: 96px; }

/* ===================== Footer (5605:2688 / mobile 5605:2465) ===================== */
.site-footer {
  width: 100%;
  /* Footer shares the page background and only has bottom padding; its content
     uses the same .container as the rest of the page (no extra side padding), so
     it lines up with the grid above instead of being inset further. */
  background: var(--bg);
  padding: 0 0 96px;
}
.site-footer__row {
  display: flex;
  flex-direction: column;
  gap: 64px;
  align-items: center;
  padding-inline: 0;
}
.site-footer__wordmark {
  /* Source Sans 3 at 800. No font-variation-settings: Source Sans 3 has only a
     wght axis (no opsz/wdth), and leaving a "wght" there would silently outrank
     font-weight. */
  font-family: "Source Sans 3", system-ui, sans-serif;
  font-weight: 800;
  /* Optical sizing, by hand. Source Sans 3 is a TEXT face — its spacing is drawn
     for ~16px. The per-pair KERNING here is already right and needs nothing: every
     pair in "TestYourLogo" measures at the font's own normal gap for its shapes
     ('ur' 122 units = 'un' 122; 'ou' 91 = 'no' 91; 'og' 67 = 'oo' 68), and the six
     pairs that do need help (Te, st, tY, Yo, Lo, go) already carry kern values.
     What is wrong at 200px is the uniform TRACKING: spacing that reads well small
     reads airy huge. -0.02em closes it without cramping.
     The negative right margin cancels letter-spacing's trailing space, which would
     otherwise push the centred wordmark half a step right. */
  letter-spacing: -0.02em;
  margin-right: -0.02em;
  /* Fill the width of the grid/container above. --wm-gutter is the TOTAL side
     gutter, kept in lockstep with .container (48px desktop, 32px ≤600px), so the
     wordmark tracks the grid width — except ≤400px where it stays 24px each side
     (48px) so it isn't cramped at the very edges. 5.74 ≈ rendered-width /
     font-size, rounded up a hair so nowrap never has to save it. Measured at
     5.775 with the tracking above AND the s exemption below; it was 5.735 with
     tracking alone, 5.975 untracked, and 6.399 back when the name carried
     spaces — the number is specific to the string, the face, the tracking and
     the exemption. The 207px cap is the other half of the same sum: it stops
     the wordmark growing past the 1200px container, so it is 1200 / 5.78, not a
     round number that happens to look tidy. Re-measure if ANY of those change. */
  --wm-gutter: 48px;
  font-size: min(207px, calc((100vw - var(--wm-gutter)) / 5.78));
  white-space: nowrap;
  line-height: 1.1;
  text-align: center;
  width: 100%;
  max-width: 100%;
}
/* Uniform tracking is a blunt instrument: it takes the same 20/1000 em off a
   pair with 122 units of room and one with 22. "st" is the tightest lowercase
   pair in this word — the font already kerns it -20 — so the -0.02em left it at
   2 units, effectively touching, where every other s-pair in the face sits at
   26-68. Exempting the s puts both of its sides back to the font's own values
   (measured: e|s 36 -> 56, s|t 2 -> 22), which is the type designer's spacing
   rather than a number I invented. Everything else keeps the display tracking.

   Note it frees BOTH sides, not just the one after: with letter-spacing normal
   on the span, the parent's tracking is dropped after the preceding character
   too. That is why the wordmark's width ratio moved by 0.04em, not 0.02 — if
   you change this, re-measure the divisor below. */
.site-footer__wordmark .wm-untracked { letter-spacing: normal; }

.site-footer__tagline {
  font-weight: 500;
  font-size: 24px;
  line-height: 32px;
  text-align: center;
  width: 100%;
  /* Even out the wrapped lines (esp. on mobile where the <br> is dropped). */
  text-wrap: balance;
}
.site-footer__links {
  display: flex;
  flex-direction: column;
  gap: 16px;
  align-items: center;
  justify-content: center;
  width: 100%;
}
.footer-link {
  display: flex;
  gap: 8px;
  align-items: center;
  justify-content: center;
  color: var(--black);
  text-decoration: none;
  transition: opacity 0.15s ease;
}
/* Icons off. This has gone back and forth twice, so it is a rule rather than a
   markup change: the SVGs stay in both footers and flipping this one line
   brings them back. `gap` above is inert while there's a single child, so
   nothing is left holding space. */
.footer-link .icon { display: none; }
.footer-link span {
  font-weight: 400;
  font-size: 16px;
  line-height: 24px;
  text-decoration: underline;
  /* Tie the rule to the letterforms. Source Sans 3's own underlineThickness is
     a fixed 50/1000 em at every weight, and the browser's `auto` ignores weight
     too — so the rule drifts away from the type as soon as the weight moves.
     The stem of a 400 'I' is 84/1000 em; 0.08 sits a hair under it, which is
     where an underline traditionally wants to be. Set in em so it holds at any
     size. Re-measure if these links change weight. */
  text-decoration-thickness: 0.08em;
  text-underline-offset: 1px;
  white-space: nowrap;
}
.site-footer__copy {
  font-weight: 400;
  font-size: 12px;
  line-height: 16px;
  text-align: center;
  width: 100%;
}

/* ===================== About modal (5623:2450) ===================== */
.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;                       /* keeps the card off the edges */
  background: rgba(0, 0, 0, 0.4);      /* scrim */
  /* Fade — same duration & easing both directions */
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.25s ease, visibility 0s linear 0.25s;
}
.modal-overlay.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.25s ease; /* visibility flips instantly on open */
}
.modal {
  width: 400px;
  max-width: 100%;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 24px;
  align-items: center;
  padding: 32px;
  background: var(--white);
  border-radius: var(--radius-xl);
  box-shadow: var(--elev-2); /* About + feedback dialogs */
  text-align: center;
  /* Subtle fade + scale — identical easing/duration to the overlay so open and
     close feel exactly the same. */
  transform: scale(0.94);
  opacity: 0;
  transition: opacity 0.25s ease, transform 0.25s ease;
}
.modal-overlay.is-open .modal {
  transform: scale(1);
  opacity: 1;
}
.modal:focus { outline: none; }

/* Hover affordance for the dark footer links. They're already the darkest ink,
   so they dim slightly on hover rather than darken — same intent as the
   segmented control's hover (clear "this is interactive" feedback). Pointer
   devices only, so touch doesn't leave a sticky dimmed state. */
@media (hover: hover) {
  .footer-link:hover { opacity: 0.6; }
}

@media (prefers-reduced-motion: reduce) {
  .modal-overlay,
  .modal { transition: opacity 0.01ms linear; }
  .modal { transform: none; }
}

/* ===================== Give feedback modal (5669:2160 / 2185 / 5681:97527 / 97559) ===================== */
.feedback-modal {
  /* Step 1 (categories) is wide; step 2 (details) is narrow. These are the
     natural/target widths — the width+height morph between them is animated in
     JS (FLIP) so there are no jumps. Sections own their padding, so the modal
     itself has none. */
  width: min(880px, 100%);
  padding: 0;
  gap: 0;
  max-height: calc(100dvh - 48px);
  overflow-y: auto;
}
.feedback-modal[data-step="details"],
.feedback-modal[data-step="success"] { width: min(528px, 100%); }

/* Shared modal close (X) — used by both the feedback and 3D-viewer modals.
   A 16px icon centred in a 32px hover hitbox (8px gap each side). It's positioned
   so the ICON sits 24px from the edges (matching the content inset); the hover
   circle overhangs the extra 8px and is not meant to count toward the spacing. */
.modal-close {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: var(--radius-pill);
  /* White fill (not transparent) so the X stays legible even when it sits over
     the 3D model in the full-bleed viewer; hover deepens it to surface-hover. */
  background: var(--white);
  color: var(--black);
  cursor: pointer;
  transition: background-color 0.15s ease;
}
.modal-close:hover { background: var(--surface-hover); }
.modal-close svg { width: 16px; height: 16px; display: block; }

/* Header reserves the top strip; flex-start + 16px padding lands the 32px button
   16px from the top-right corner, so its icon sits 24px in. */
.fb-header {
  width: 100%;
  height: 56px;
  flex-shrink: 0;
  display: flex;
  align-items: flex-start;
  justify-content: flex-end;
  padding: 16px 16px 0;
}
/* The confirmation step has no close (X) — only Done or clicking outside closes.
   With the X gone, the 56px header was just dead space up top, leaving the whole
   step top-heavy. Collapse it and balance the spacing (a touch more above the icon
   than below the heavier full-width button reads as centred). */
.feedback-modal[data-step="success"] .fb-close { display: none; }
.feedback-modal[data-step="success"] .fb-header { height: 0; padding: 0; }
.feedback-modal[data-step="success"] .fb-success { padding: 32px 24px 0; gap: 8px; }

.fb-step { width: 100%; display: none; flex-direction: column; align-items: center; transition: opacity 0.25s ease; }
.fb-step--categories { text-align: center; }
.fb-step--details { text-align: left; }
/* Only the form parts fade in during the morph; the icon row is revealed
   instantly as the ghosts land on it (no fade, so no flicker). */
.fb-fields, .fb-actions { transition: opacity 0.15s ease; }
/* Show only the active step. */
.feedback-modal[data-step="categories"] .fb-step--categories,
.feedback-modal[data-step="details"] .fb-step--details,
.feedback-modal[data-step="success"] .fb-step--success { display: flex; }

/* ---- Step 1: title + category cards ---- */
.fb-title {
  width: 100%;
  padding-inline: 24px;
  font-weight: 600;            /* SemiBold */
  font-size: 28px;
  line-height: 36px;
  text-align: center;
  color: var(--black);
  transition: opacity 0.14s ease;
}
/* Floating clone of the title used to cross-fade it during the morph. */
.fb-title-ghost {
  position: fixed;
  font-weight: 500;
  font-size: 28px;
  line-height: 36px;
  text-align: center;
  color: var(--black);
  z-index: 1050;
  pointer-events: none;
  transition: opacity 0.14s ease;
}
.fb-cards {
  display: grid;
  /* Three categories, three columns, at every width. They fit across a 375px
     phone now that the longest label is "Other" — the 2x2 fallback existed for
     four cards and would leave a lone orphan on the second row. */
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 12px;
  width: 100%;
  padding: 24px;
}
.fb-card {
  aspect-ratio: 177.6 / 252;
  display: flex;
  flex-direction: column;
  gap: 8px;
  align-items: center;
  justify-content: center;
  padding: 16px 8px; /* slim sides: the labels are nowrap (see span below) */
  background: var(--white);
  border: 1.5px solid var(--track);
  border-radius: var(--radius-xl);
  color: var(--black);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}
/* Hover fill for both shapes of this control (card and tab) is #fafafa, not
   the shared --surface-hover: that token is #ececec, which after the selected
   tab dropped to #f5f5f5 was DARKER than the selected state, so hovering an
   inactive tab read as more chosen than the chosen one. #fafafa keeps the
   progression honest — white at rest, a hint on hover, --bg when selected — and
   leaves the dark border as the thing that actually signals both.
   Not the global token: --surface-hover is shared with the close button, the
   viewer reset, the back button and the secondary button, none of which have a
   selected state to sit under. Keep these two in step with each other. */
.fb-card:hover { border-color: var(--black); background: #fafafa; }
.fb-card svg { width: 20px; height: 20px; display: block; }
/* Labels never wrap — cards keep their side padding slim enough for the longest
   label ("Other") to sit on one line at every breakpoint. */
.fb-card span { font-weight: 600; font-size: 14px; line-height: 20px; text-align: center; white-space: nowrap; }

/* ---- Step 2: category icon row ---- */
.fb-icons {
  /* Flex, not a 3x56px grid: the selected tab is wider than the others now, and
     fixed track widths would clip it. */
  display: flex;
  gap: 12px;
  justify-content: center;
  align-items: center;
}
.fb-icon {
  /* Square while unselected; widens to fit its name when chosen.
     The content is LEFT-anchored, not centred: anchored, the icon cannot drift
     or clip while the box is mid-animation, and the label simply spills out of
     the overflow until the box has grown to hold it.
     The padding is 16.5px, not 18: box-sizing is border-box, so the 1.5px
     border is inside the 56px and the icon's true inset is border + padding.
     16.5 + 1.5 = 18 = (56 - 20) / 2, which is where centring used to put it.
     This is derived from the icon size — resize the icon and this moves with it,
     or the unselected square goes visibly off-centre. */
  width: 56px;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 8px;
  overflow: hidden;
  padding: 0 16.5px;
  background: var(--white);
  border: 1.5px solid var(--track);
  border-radius: 16px;
  color: var(--black);
  cursor: pointer;
  transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.fb-icon svg { width: 20px; height: 20px; display: block; flex: none; }
/* The name, revealed on the selected tab. It stays in flow at full width even
   when unselected — the square's overflow hides it — so the button always
   reports its true selected width the instant the class changes. That matters:
   the FLIP in feedback.js measures the end state immediately, and a label that
   was itself mid-transition would report a too-small target and then snap. */
.fb-icon__label {
  font-weight: 600;
  font-size: 14px;
  /* Same 14/20 as .fb-card span and .fb-ghost__label: the name flies between
     card and tab, so all three must render it identically or it jumps size. */
  line-height: 20px;
  white-space: nowrap;
  opacity: 0;
  /* Leaving: the word goes fast, before the box starts closing on it — otherwise
     the shrinking box slices through live text. */
  transition: opacity 0.1s ease;
}
/* Arriving: hold until the box has opened far enough to hold the word, then
   fade it in. Delay + duration land just inside the 0.28s width animation. */
.fb-icon[aria-selected="true"] .fb-icon__label {
  opacity: 1;
  transition: opacity 0.16s ease 0.1s;
}
/* Sizes to its content; the FLIP animates between this and the 56px square. */
.fb-icon[aria-selected="true"] { width: auto; }
/* Same hover as the overview cards (dark border + subtle fill); the active
   (dark) tab keeps its filled style. */
.fb-icon:not([aria-selected="true"]):hover { border-color: var(--black); background: #fafafa; }
/* Selected reads as grey-with-a-dark-outline, not a dark fill: a black pill
   here competed with the black Send button directly below it. --bg rather than
   --surface-hover, so the fill is a whisper and the dark border does the work of
   saying which one is on. The hover fill on the other tabs is lighter still
   (#fafafa, see .fb-card:hover), so the three states step white -> hover ->
   selected rather than doubling back.
   .fb-ghost--selected must carry the same three values — it is what you actually
   watch land during the morph. */
.fb-icon[aria-selected="true"] { background: var(--bg); border-color: var(--black); color: var(--black); }

/* ---- Step 2: fields ---- */
.fb-fields { display: flex; flex-direction: column; gap: 24px; width: 100%; padding: 24px 24px 0; }
.fb-field { display: flex; flex-direction: column; width: 100%; }
/* [hidden] must win over .fb-field's display:flex (email is revealed on opt-in). */
.fb-email[hidden] { display: none; }
.fb-email { transition: opacity 0.25s ease; }
.fb-label {
  /* Small caps-style field labels: 12px uppercase, 600 to hold their own
     against the 14px sentence-case text they sit above.
     Leading stays 20px even though the type dropped to 12: these are always a
     single line, so line-height only sets the box height, and holding it keeps
     the label-to-field gap exactly where it was. */
  font-weight: 650;
  font-size: 12px;
  line-height: 20px;
  text-transform: uppercase;
  padding-bottom: 6px;
  /* A step back from --black: these label the fields rather than competing with
     what you type into them. Tailwind neutral-700, same ramp the rest of the
     greys come from. */
  color: #404040;
}
.fb-textarea,
.fb-input {
  width: 100%;
  background: var(--white);
  border: 1px solid var(--border-muted);
  border-radius: 12px;
  padding: 12px;
  font-family: inherit;
  font-weight: 400;
  /* 14px on pointer devices. Touch is pinned back to 16px in the
     (pointer: coarse) block below, which is what actually keeps iOS Safari from
     zooming the page on focus — do not let that override drift away from here. */
  font-size: 14px;
  line-height: 20px;
  color: var(--black);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.fb-textarea {
  min-height: 152px;
  max-height: 280px;
  resize: vertical;
  /* Custom grip tucked inside the rounded corner (the native one pokes out). */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Cpath d='M9 3 3 9M9 6.5 6.5 9' stroke='%23a1a1a1' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 7px bottom 7px;
}
.fb-textarea::-webkit-resizer { display: none; }
.fb-textarea::placeholder,
.fb-input::placeholder { color: var(--border-muted); }
.fb-textarea:focus,
.fb-input:focus {
  outline: none;
  border-color: var(--black);
  /* Active field reads ~0.5px heavier without a reflow (inset shadow, not a wider
     border that would nudge the text). */
  box-shadow: inset 0 0 0 0.5px var(--black);
}
/* Touch devices (iOS Safari): 16px input text avoids the auto-zoom on focus that
   otherwise leaves the page zoomed in and shifts hit-targets (mis-firing taps).
   Also drop the resize grip — touch can't drag-resize a textarea, so the box
   auto-grows with its content instead (see app.js). */
@media (pointer: coarse) {
  .fb-textarea,
  .fb-input { font-size: 16px; }
  .fb-textarea { resize: none; background-image: none; }
}

/* ---- Step 2: contact checkbox ---- */
.fb-check { display: flex; gap: 8px; align-items: center; cursor: pointer; }
.fb-check__input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.fb-check__box {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  border-radius: 6px;
  /* Same resting border as the text inputs (.fb-input) so the form reads as one set. */
  border: 1px solid var(--border-muted);
  background: var(--white);
  color: var(--white);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.12s ease, border-color 0.12s ease;
}
.fb-check__box svg { width: 12px; height: 12px; opacity: 0; transition: opacity 0.12s ease; }
.fb-check__input:checked + .fb-check__box { background: var(--black); border-color: var(--black); }
.fb-check__input:checked + .fb-check__box svg { opacity: 1; }
.fb-check__input:focus-visible + .fb-check__box { outline: 2px solid var(--black); outline-offset: 2px; }
.fb-check__label { font-weight: 400; font-size: 14px; line-height: 20px; color: var(--black); }

/* ---- Step 2: full-width SEND ---- */
/* Both buttons here size to their own label and centre. .btn already brings
   height 48 and padding-inline 32, so there is nothing to restate. */
.fb-actions { width: 100%; padding: 24px; display: flex; justify-content: center; }
/* DONE keeps .fb-actions' even 24px on all four sides, so the gap above it is
   the same as the inset below and beside it. */
/* Disabled SEND is a flat grey pill (Figma), not a faded dark one. */
.fb-send:disabled { background: var(--track); color: var(--icon-inactive); cursor: default; pointer-events: none; }
/* A sending SEND is disabled too (that is what blocks a double submit), so it
   keeps the grey pill above and reads as unavailable while it works — which is
   the intent: there is nothing to press until it lands.
   The word goes with it and the spinner alone carries the state. That leaves a
   button whose only child is a decorative span, so the accessible name is put
   back with aria-label in feedback.js. */
/* The label is hidden with visibility, NOT display, and the spinner is taken out
   of flow on top of it. Now that the button hugs its label, display:none would
   collapse the pill to spinner width the moment you press it — a 480px button
   snapping to 80px. This way the word still holds the box open at exactly its
   resting width and only the ink swaps. */
.fb-send { position: relative; }
.fb-send.is-sending .fb-send__label { visibility: hidden; }
.fb-send__spinner {
  display: none;
  position: absolute;
  left: 50%;
  top: 50%;
  margin: -8px 0 0 -8px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  /* A ring with one quarter picked out: the gap is what reads as motion.
     currentColor so it tracks the button's own disabled ink instead of being
     pinned to a value that would vanish if the pill ever changes. */
  border: 2px solid rgba(38, 38, 38, 0.12);
  border-top-color: currentColor;
  animation: fb-spin 0.7s linear infinite;
}
.fb-send.is-sending .fb-send__spinner { display: block; }
@keyframes fb-spin { to { transform: rotate(360deg); } }
/* Nothing to spin, so drop the ring — frozen at one angle it just looks like a
   broken image — and put the word back, because it is the only thing left to
   carry the state. Without this second line the button sits completely blank for
   the whole two seconds: the spinner is gone and .fb-send__label above is still
   visibility: hidden. */
@media (prefers-reduced-motion: reduce) {
  .fb-send.is-sending .fb-send__spinner { display: none; }
  .fb-send.is-sending .fb-send__label { visibility: visible; }
}

/* ---- Step 3: sent confirmation ---- */
.fb-success {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  width: 100%;
  padding: 16px 24px 0;
  text-align: center;
}
.fb-success__icon {
  width: 56px;
  height: 56px;
  border-radius: var(--radius-pill);
  /* 8px on top of the 8px flex gap below = 16px down to the title, while
     title -> text keeps the bare 8. One value each, no per-child gap juggling. */
  margin-bottom: 8px;
  /* Pale plate with a dark tick, not a black disc: at 56px a solid black circle
     was the heaviest thing in the modal and pulled focus off the message. */
  background: var(--surface-hover);
  color: var(--black);
  display: flex;
  align-items: center;
  justify-content: center;
}
.fb-success__icon svg { width: 24px; height: 24px; display: block; }
.fb-success__title { font-weight: 650; font-size: 24px; line-height: 32px; color: var(--black); }
.fb-success__text {
  font-weight: 400;
  font-size: 14px;
  line-height: 20px;
  color: var(--text-muted);
  max-width: 280px;
}

/* ---- Cards → icon-row morph: floating "ghosts" that fly between the two
   layouts in viewport space (so the modal resize doesn't drag them around). ---- */
.fb-ghost {
  position: fixed;
  /* Contents are absolutely placed from measured coordinates (see place() in
     feedback.js), not laid out — a flex box would re-centre them on every frame
     as the ghost resizes. */
  display: block;
  background: var(--white);
  border: 1.5px solid var(--track);
  color: var(--black);
  overflow: hidden;
  z-index: 1050;
  pointer-events: none;
  transition: top 0.42s cubic-bezier(0.22, 1, 0.36, 1),
              left 0.42s cubic-bezier(0.22, 1, 0.36, 1),
              width 0.42s cubic-bezier(0.22, 1, 0.36, 1),
              height 0.42s cubic-bezier(0.22, 1, 0.36, 1),
              border-radius 0.42s cubic-bezier(0.22, 1, 0.36, 1),
              background-color 0.42s ease, border-color 0.42s ease;
}
.fb-ghost svg {
  position: absolute;
  /* 20x20, the same as .fb-card svg and .fb-icon svg. This is the SAME icon
     flying between those two, so a mismatch here changes size in mid-air.
     Change one, change all three. */
  width: 20px;
  height: 20px;
  display: block;
  transition: left 0.42s cubic-bezier(0.22, 1, 0.36, 1),
              top 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
/* The label collapses its height + margin (not just opacity) so the icon glides
   from above-centre (card) to dead-centre (icon button) with no end snap. */
.fb-ghost__label {
  position: absolute;
  /* 600/14/20, identical to .fb-card span and .fb-icon__label. This is the SAME
     word flying between those two, so any difference in weight, size or leading
     shows up as the text changing shape in mid-air. Change one, change all three. */
  font-weight: 600;
  font-size: 14px;
  line-height: 20px;
  white-space: nowrap;
  /* left/top carry the name from under the icon to beside it; opacity only
     matters for the two that are losing their label on the way. */
  transition: left 0.42s cubic-bezier(0.22, 1, 0.36, 1),
              top 0.42s cubic-bezier(0.22, 1, 0.36, 1),
              opacity 0.3s ease;
}
/* Must track .fb-icon[aria-selected="true"] exactly. The ghost is what you
   actually watch during the card->icon morph; if it wears a different skin
   from the real button it lands on, the swap ends in a colour snap. */
.fb-ghost--selected { background: var(--bg); border-color: var(--black); color: var(--black); }

/* Below the modal's full 880px, the cards drop the desktop portrait aspect and
   hug the icon + label with 48px of vertical padding instead (sides stay slim so
   the nowrap labels fit on one line in the narrow columns).
   The breakpoint is 880, not 600, because the aspect ratio is what makes these
   tall and it keeps stretching them the whole way up: at 700px wide the card was
   274px tall for 52px of content. Only at 880 and over is the modal at the width
   that proportion was drawn for. */
@media (max-width: 879px) {
  .fb-card { aspect-ratio: auto; padding: 48px 10px; }
}

/* ===================== Drag-and-drop overlay ===================== */
.drop-overlay {
  position: fixed;
  inset: 0;
  z-index: 1300;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.45);   /* light scrim, no blur */
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.2s ease, visibility 0s linear 0.2s;
  pointer-events: none; /* visual only — drag/drop is handled on window */
}
.drop-overlay.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.2s ease;
}
/* One clean card: icon + a single line. */
.drop-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  padding: 32px 44px;
  background: var(--white);
  border-radius: var(--radius-xl);
  box-shadow: var(--elev-1);
  transform: scale(0.96);
  transition: transform 0.2s ease;
}
.drop-overlay.is-open .drop-card { transform: scale(1); }
.drop-card__icon { width: 30px; height: 30px; color: var(--black); display: block; }
.drop-card__title { font-weight: 600; font-size: 17px; line-height: 22px; color: var(--black); }

/* ===================== 3D model viewer modal (5623:2469) ===================== */
.viewer-overlay {
  position: fixed;
  inset: 0;
  z-index: 1100;
  display: none; /* toggled to block while open */
}
.viewer-overlay.is-active { display: block; }
.viewer-scrim {
  position: absolute;
  inset: 0;
  /* Same light scrim as the About/Feedback modals (rgba(0,0,0,.4)) so the viewer
     blends with the default light browser chrome instead of fighting it with a dark
     backdrop + theme-color flip (which iOS only half-honours, esp. the bottom bar). */
  background: rgba(0, 0, 0, 0.4); /* fades independently */
  opacity: 0;
  transition: opacity 0.32s ease;
}
.viewer-overlay.is-shown .viewer-scrim { opacity: 1; }
.viewer-modal {
  /* Geometry (left/top/width/height) is driven by JS so the box can morph from
     the card to the centred modal without any distorting transform. */
  position: fixed;
  background: var(--white);
  border-radius: var(--radius-xl);
  box-shadow: var(--elev-2); /* same dialog elevation (subtle over the dark scrim) */
  overflow: hidden;
  will-change: left, top, width, height;
}
.viewer-canvas {
  position: absolute;
  /* Full-bleed: fill the modal so a zoomed-in model clips flush at the modal's
     rounded edge (overflow:hidden + border-radius) instead of at a square,
     8px-inset canvas edge that left a white frame. The grid card's canvas is
     still inset 8px (.card--3d padding); framing parity for a pop-free morph is
     restored in JS by pulling the viewer camera back ~W/(W-16) (see insetComp in
     model-grid.js). NB: <canvas> is a replaced element, so it needs an explicit
     (definite) size — width/height 100% keeps it definite while inset:0 anchors it. */
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: block;
  touch-action: none; /* let drag rotate instead of scrolling the page */
  cursor: grab;
}
.viewer-canvas:active { cursor: grabbing; }
.viewer-hint {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  padding: 24px;
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0),
    rgba(255, 255, 255, 0.75)
  );
  pointer-events: none;
}
.viewer-hint p {
  font-weight: 400;
  font-size: 12px;
  line-height: 16px;
  color: rgba(38, 38, 38, 0.33);
  text-align: center;
}
/* Visual (size, transparent fill, hover circle, 16px X) comes from .modal-close.
   Positioned so the icon sits 24px from the corner (the 32px button at 16px). */
.viewer-close,
.viewer-download {
  position: absolute;
  top: 16px;
  opacity: 0; /* fades in once the grow finishes (.is-settled) */
  transition: opacity 0.2s ease, background-color 0.15s ease;
}
.viewer-close { right: 16px; }
.viewer-download { left: 16px; } /* opposite corner from the close X */
.viewer-modal.is-settled .viewer-close,
.viewer-modal.is-settled .viewer-download { opacity: 1; }

/* "Reset view" — a quiet pill at the bottom centre, shown only once the model has been
   moved (.is-moved, toggled by the viewer loop). Sits where the first-open hint does, but
   they're mutually exclusive (moving dismisses the hint). */
.viewer-reset {
  position: absolute;
  left: 50%;
  bottom: 20px;
  transform: translateX(-50%) translateY(6px);
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 38px;
  padding: 0 16px;
  background: var(--white);
  color: var(--black);
  border: 1px solid var(--track);
  border-radius: var(--radius-pill);
  font-family: inherit;
  font-weight: 500;
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease, transform 0.2s ease, background-color 0.15s ease;
}
.viewer-reset svg { width: 15px; height: 15px; display: block; }
.viewer-reset:hover { background: var(--surface-hover); }
.viewer-modal.is-moved .viewer-reset {
  opacity: 1;
  pointer-events: auto;
  transform: translateX(-50%) translateY(0);
}
/* The "drag to rotate" hint is shown by JS only on the first object opened this
   session (.is-hint), then fades out on its own. */
.viewer-hint { opacity: 0; transition: opacity 0.45s ease; }
.viewer-modal.is-hint .viewer-hint { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .viewer-scrim { transition: opacity 0.01ms linear; }
  .viewer-close,
  .viewer-hint { transition: opacity 0.01ms linear; }
}

/* ===================== Responsive ===================== */

/* Tablet & below — selector offers only 2- and 1-column (3-column hidden, no
   divider). The grid follows the selector and defaults to 2 columns (app.js).
   Mobile selector matches Figma "Device=Mobile" (5605:2225). */
@media (max-width: 1024px) {
  .selector__btn[data-cols="3"] { display: none; }
  .selector[data-selected] .selector__divider { display: none; }
  /* Two buttons: centered on each 48px button — circle shares the pill cap's arc
     at the ends (flush, single line) with the icon centered. */
  .selector[data-selected="2"] .selector__highlight { left: 0; }
  .selector[data-selected="1"] .selector__highlight { left: 48px; }

  /* Footer content already uses .container (24px gutter), which matches the
     rest of the page — so the footer needs no extra horizontal padding here,
     otherwise the two insets stack (48px) and the footer looks narrower. */
  .site-footer { --footer-pad-x: 0px; }
}

/* Phones — tighten spacing. */
@media (max-width: 600px) {
  .upload__title { font-size: 28px; line-height: 35px; }
  /* Wordmark, tagline, links and copyright. 64px is generous on a desktop
     footer that is 1200px wide; on a phone it just pushes the copyright off
     the fold. Not the 16px gap BETWEEN the two links — that is
     .site-footer__links above, and it holds at every width. */
  .site-footer__row { gap: 40px; }
  /* Tagline, on phones: a size down, and its forced <br> dropped so it wraps
     naturally and balances. 20/28 keeps roughly the 24/32 ratio with a touch
     more leading, which small text wants. */
  .site-footer__tagline { font-size: 20px; line-height: 28px; }
  .site-footer__tagline br { display: none; }
  /* Wordmark tracks the grid's 16px gutters here (container = 100% - 32px). */
  .site-footer__wordmark { --wm-gutter: 32px; }
}

/* Very small phones — keep the big wordmark at 24px gutters (48px total) so it
   isn't cramped at the edges, even though the grid stays at 16px. */
@media (max-width: 400px) {
  .site-footer__wordmark { --wm-gutter: 48px; }
}

/* Phones — tighten the page gutter to 16px each side. */
@media (max-width: 600px) {
  .container { width: min(100% - 32px, var(--container)); }
}


/* ===================== Standalone text pages (about) ===================== */
/* Editorial layout: a boxed label, a centred title lockup, then LEFT-aligned
   sections with large display headings. Widths are in px, not ch — ch scales with
   the font size, so raising the type while holding a ch measure leaves the block
   exactly as wide. Everything here is unused by index.html; it's a handful of
   rules, and one stylesheet is simpler than two. */
.page {
  /* Matches .upload-area's 96px, so both pages start their content at the same
     height — and, like that one, it holds at every width (no mobile override). */
  padding: 96px 0 0;
  /* One source of truth for the measure and the button, so the two can't drift
     apart: the button positions itself off --page-col, and the column reserves
     exactly --back-size for it. */
  --page-col: 680px;
  --back-size: 44px;
  --back-gap: 32px;   /* clear space between the button and the text */
}
.page__col {
  max-width: var(--page-col);
  margin-inline: auto;
}
/* Back to the tool. Sticky rather than fixed so <main> bounds it: it rides down
   the page and comes to rest level with the last line of copy, instead of
   floating on past the text and over the divider. It is centred like the column
   and then pushed out into the left gutter, so it tracks the measure. */
.page__back {
  display: none;
  position: sticky;
  top: 40px;
  z-index: 2;
  width: var(--back-size);
  height: var(--back-size);
  margin-inline: auto;
  transform: translateX(calc(
    var(--page-col) / -2 - var(--back-gap) - var(--back-size) / 2
  ));
  align-items: center;
  justify-content: center;
  /* Same treatment as the grid selector: a white capsule separated by a shadow,
     not a border. Both are the site's two floating controls, so they should read
     as the same object. --elev-1 is unconditional here where the selector only
     takes it while pinned — this one is always floating over the copy. */
  border: none;
  border-radius: var(--radius-pill);
  background: var(--white);
  box-shadow: var(--elev-1);
  color: var(--black);
  transition: background-color 0.15s ease;
}
.page__back:hover { background: var(--surface-hover); }
/* No optical nudge: unlike the bare chevron this was, an arrow spans its box
   symmetrically (shaft one way, head the other), so geometric centre is also
   the optical one. */
.page__back svg { width: 20px; height: 20px; display: block; }
/* Only once the gutter is genuinely wide enough to hold it: at 960 the column
   starts 128px in, so the button clears the viewport edge by 52px. */
@media (min-width: 960px) {
  .page__back { display: flex; }
  /* The button takes a row of flow at the top; pull the column back up over it
     so the header still starts at the padding edge. Deliberately NOT a negative
     margin-bottom on the button itself: sticky clamps an element by its MARGIN
     box, so a -44px there bought it 44px of travel past the end of <main> and
     it slid below the last line of copy. Moving the pull onto the column leaves
     the button's box honest, and it comes to rest level with the text. */
  .page__col { margin-top: calc(var(--back-size) * -1); }
}
/* The in-flow way back, for widths where the floating chevron has nowhere to
   live. Shown by default and hidden at 960px — the exact complement of
   .page__back's rule above, so there is no width where both or neither appear.
   Styled like a footer link (20px icon, underlined 16px label) rather than like
   the chevron, because in the flow it is a link, not a control. */
.page__back-inline {
  display: inline-flex;
  align-items: center;
  /* 4px, not 8: a chevron's ink stops at 12.5 of its 20-unit box, so it carries
     7.5px of its own empty space on the right where the arrow carried 4. The
     smaller gap lands the same ~12px optical distance to the B. */
  gap: 4px;
  color: var(--text-muted);
  text-decoration: none;
  margin-bottom: 24px;
  transition: color 0.15s ease;
}
/* Grey, so it sits under the title rather than competing with it — but it has
   to still read as a link, so hovering takes it to full ink. Dimming (what the
   dark links do) would only make a muted grey fainter. */
@media (hover: hover) {
  .page__back-inline:hover { color: var(--black); }
}
.page__back-inline svg { width: 20px; height: 20px; display: block; }
.page__back-inline span {
  font-weight: 400;
  font-size: 16px;
  line-height: 24px;
  text-decoration: underline;
  text-decoration-thickness: 0.08em;
  text-underline-offset: 1px;
}
@media (min-width: 960px) {
  .page__back-inline { display: none; }
}

.page__head {
  /* Same gap the sections use, so the page keeps one vertical rhythm from the
     title all the way down. */
  margin: 0 0 48px;
}
/* 700 — the top of the range we use for text; the footer wordmark is the one
   place that goes heavier. */
.page__title {
  font-weight: 700;
  font-size: 72px;
  line-height: 1.04;
  letter-spacing: -0.02em;
  margin-bottom: 28px;
}
/* Plain wrap at desktop widths. `balance` and the global `pretty` are both off
   deliberately: the column IS the measure, and both of them re-break lines per
   block to even them out — which pulls each block in by a different amount and
   loses the shared right edge. (Balance was tried here: it put the paragraphs
   at ~78% of the column, floating in from the edge.) This holds at every width:
   phones carried a `pretty` override for a while, but the copy has been
   rewritten since and it was measured to change nothing — same line count, same
   line widths, same block heights at 375px — so it was dropped. */
.page :is(h1, h2, p) {
  text-wrap: wrap;
}

/* Centred throughout, like the home page. The reference runs its sections left,
   but this site is centred end to end and the page should read as part of it. */
.page__section {
  margin-top: 48px;
}
.page__section h2 {
  font-weight: 600;
  font-size: 32px;
  line-height: 1.15;
  letter-spacing: -0.01em;
  margin-bottom: 16px;
}
/* Thinner and lighter than the words either side, so the pipe separates rather
   than competing with the name. */
.page__section p {
  font-weight: 400;
  font-size: 18px;
  line-height: 26px;
}
.page__section p + p { margin-top: 20px; }
/* Credit marks: each source's own favicon, leading its name. Fetched once and
   served from /assets/credits — NOT through a favicon service (s2/favicons,
   icons.duckduckgo.com and friends). Those are a third-party request per icon,
   which would hand a visitor's IP to Google on a page that exists partly to say
   we don't do that, and `img-src 'self' data: blob:` in _headers would block
   them anyway.
   Sized in em so they track the copy, and wrapped with the name in a nowrap
   span so an icon never ends a line with its word on the next one. */
.page__credit { white-space: nowrap; }
/* The three people don't have a brand mark to borrow, so they get a face
   instead: src/avatars.js deals one at random per name on every visit, out of
   eleven PNGs in /assets/credits/avatars. Only the corners are set here — each
   is already a tile with its own pale ground, so it needs no fill, and its
   colour lives in the files rather than the stylesheet, which is why there are
   still no non-greyscale values below.
   The grounds are DiceBear's "Pastel Wall" set, all five of them, one per file
   in rotation. They sit at 1.03:1 to 1.11:1 against this page, so they read as
   a hint of tint rather than a tile. The dark strokes carry these at 18px, at
   14:1. Nothing here competes with a mark already in the line (Lucide's
   #F56565, Heroicons' #8b5cf6, the Google Fonts mark's #0D652D green and
   #F29900 orange).
   No placeholder fill while the src is empty either: avatars.js is a blocking
   script at the end of the body, so it runs before the first paint and the
   slot is never seen unfilled.
   The corners: 6px on the 18px these render at is 0.33, near enough the ratio
   the rest of the site rounds to (the feedback checkbox is 6 on 20, the
   category tabs 16 on 56). 4px would read squarer than anything else here. */
.page__mark--avatar { border-radius: 6px; }
.page__mark {
  width: 1em;
  height: 1em;
  display: inline-block;
  /* Sits the mark on the text's optical centre rather than the baseline. */
  vertical-align: -0.15em;
  margin-right: 0.35em;
  object-fit: contain;
}

.page__link {
  color: inherit;
  text-decoration: underline;
  /* Matched to the stem, same reasoning as .footer-link span above. */
  text-decoration-thickness: 0.08em;
  text-underline-offset: 3px;
  transition: opacity 0.15s ease;
}
.page__link:hover { opacity: 0.6; }
/* Rule before the footer, with matching space above and below. Edge to edge:
   it sits outside .container, so it spans the viewport rather than the 1200px
   content width, and reads as a division of the page rather than of the
   copy. */
.page__rule {
  border: 0;
  /* --divider, not --black: full-strength black is a lot of line for something
     that only separates the page from its footer, and it landed heavier than
     anything else on the page. This is the same hairline the rest of the site
     uses to separate things. */
  border-top: 1px solid var(--divider);
  /* 96px, matching .site-footer's bottom padding, which has no responsive
     override — so this doesn't get one either. */
  margin: 96px 0;
}

/* Both pages open with the same top space (96px), and they step down together
   here — keep these two lines adjacent so they can't drift apart. */
@media (max-width: 700px) {
  .upload-area { padding-top: 72px; }
  .page { padding-top: 72px; }
}

@media (max-width: 700px) {
  /* Type steps down here; the SPACING does not — the 48px section rhythm and
     the 16px heading-to-body gap hold at every width. (Earlier overrides in
     this block were scaled off larger desktop values, which made a phone's
     gaps BIGGER than a desktop's. Don't reintroduce that.)
     Line-heights move with their sizes: 26 x 1.15 = 30 holds the heading's
     ratio. */
  .page__title { font-size: 40px; }
  .page__section h2 { font-size: 26px; line-height: 30px; }
}
