/* ============================================================
   TASAI — LANDING PAGE
   Page-specific layout. Everything visual resolves to the design
   tokens in tokens/*.css; no raw values live here that aren't
   documented in the design handoff.
   ============================================================ */

html, body, #root {
  min-height: 100%;
  margin: 0;
  /* The gradient field covers the viewport; ink prevents a white
     flash on first paint before the field renders. */
  background: var(--ink);
}
html { color-scheme: dark; }

/* Focus ring: the token default (iris, 55% alpha) disappears against the
   colour field. Frost is legible over every stop in the gradient. */
:focus-visible { outline-color: var(--frost); }

/* Author `display` rules outrank the user-agent `[hidden] { display: none }`,
   so anything marked hidden here needs the attribute to win explicitly. */
[hidden] { display: none !important; }

/* ---------- Root shell ---------- */
/* The handoff specifies `height: 100%; overflow: hidden` — one screen, no
   scroll. That is exactly what this produces on every viewport where the
   content fits: min-height pins the page to the viewport, the hero still
   bottom-anchors, and no scrollbar appears. The difference is the failure
   mode. With a fixed height, a viewport too short for the content (landscape
   phones, small screens) silently clips the bottom of the signup form off the
   page. With min-height it scrolls instead, so the form is always reachable.
   dvh keeps this honest against mobile browser chrome. */
.tsf-root {
  position: relative;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

/* ---------- Gradient field ----------
   PERFORMANCE, and why this is built the way it is.

   The handoff animates `background-position` on the conic gradient. That is a
   paint property, not a compositor property: every frame the browser
   re-rasterises a full-viewport conic gradient on the main thread and re-blends
   the grain over it. On a large display that is enough sustained CPU to heat
   the machine and spin the fans.

   Only `transform` and `opacity` are handed to the GPU. So the gradient is now
   painted ONCE onto an oversized inner layer, and that layer is moved with
   `transform`. Identical look, no per-frame repainting.

   The motion itself is described at .tsf-field--live below. */

.tsf-field {
  position: absolute;
  inset: 0;
  overflow: hidden;
  /* Static field. The 404 and no-JS pages use this on its own. */
  background: var(--field-spectrum-veiled);
  background-size: 160% 160%;
  background-position: 30% 30%;
}

/* Animated variant — the art moves to an inner layer.

   MOTION. Two nested circular orbits, opposite directions, co-prime periods,
   so the combined path is a slowly-precessing spirograph rather than a loop.

   Each circle is generated the correct way: two axis animations at the SAME
   period, a quarter-period out of phase, eased with a sine approximation.
   x = -R·cos(wt) and y = -R·cos(w(t + P/4)) = R·sin(wt) is exactly a circle,
   traversed at constant speed, from two keyframes per axis and no corners.

   Two earlier attempts failed, both instructive:

   1. Separate X and Y `alternate` animations at DIFFERENT periods. Each axis
      decelerates to a dead stop at its turning point, so whenever one axis was
      parked the motion read as flat horizontal sliding. Different periods is
      what breaks it — same period with a quarter-phase offset is a circle, and
      that is what is used below.
   2. Circles approximated with twelve `linear` keyframes. That is a dodecagon:
      the direction snaps 30 degrees at every vertex, roughly once a second
      across both orbits, which reads as twitchy. Never approximate a curve with
      linear keyframe segments — generate it from eased sinusoids instead. */
.tsf-field--live { background: #46606b; }  /* fallback only; art covers it */

/* Approximates sin() to within ~0.5%, and its velocity reaches zero smoothly at
   each turnaround — which is exactly what a sinusoid does — so the alternate
   boundary introduces no kink. */
.tsf-field--live { --sine: cubic-bezier(0.37, 0, 0.63, 1); }

/* The veil is a fixed vignette, so it stays put while the colour moves
   beneath it. Baking it into the moving layer (as the token does) made the
   whole field pulse brighter and darker as the art drifted vertically. */
.tsf-field--live::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(180deg, rgba(24, 24, 28, 0.12), rgba(24, 24, 28, 0.34));
}

.tsf-ax, .tsf-ay,
.tsf-bx, .tsf-by { position: absolute; inset: 0; }

.tsf-field-art,
.tsf-field-art.tasai-grain {  /* beat .tasai-grain{position:relative} */
  position: absolute;
  /* Oversized and centred so the orbits can never expose an edge.
     Max combined displacement is 17% + 8% = 25%, inside this 45% margin. */
  inset: -45%;
  /* Focal point re-placed. The token puts it at 36% 32% of this layer, which —
     because the layer is 170% of the viewport — lands at roughly 26% / 19% of
     the screen, so the orbits circled a point stuck in the upper-left quadrant
     and never reached the bottom or right. At 46.5% / 44% of the layer it rests
     near 44% / 40% of the screen, so the orbit sweeps through the centre and
     out to every side, while staying clear of the headline. */
  background: conic-gradient(from 205deg at 46.5% 44%,
    #3d7079, #5a5aa0, #a85472, #cf9152, #4f7c6b, #3d7079);
  /* Contain the grain's overlay blend to this subtree, so it composites once
     into this layer instead of re-blending against a moving backdrop. */
  isolation: isolate;
  /* No `will-change` here on purpose. This layer is far larger than the viewport, and
     Safari sizes the backing store of a permanently-promoted layer to roughly
     the visible area — so translating it reveals region that was never painted,
     showing the flat fallback as a band down one edge. Transform animations are
     promoted automatically; the hint is redundant and actively harmful at this
     size. Do not add it back. */
  backface-visibility: hidden;
}

/* Orbit A — radius 17%, orbital period 37s. Each axis animation runs at half
   the orbital period because `alternate` makes a full oscillation take two
   durations. Y leads X by a quarter period; the negative delay starts it
   mid-phase immediately, rather than freezing it at its start value. */
.tsf-field--live .tsf-ax { animation: tasaiX17 18.5s var(--sine) infinite alternate; }
.tsf-field--live .tsf-ay { animation: tasaiY17 18.5s var(--sine) infinite alternate;
                           animation-delay: -9.25s; }

/* Orbit B — radius 8%, orbital period 41s. The three-quarter phase offset
   (rather than one-quarter) reverses the direction of travel, so this circle
   counter-rotates against orbit A.

   The radius and period are chosen so this orbit's TANGENTIAL SPEED cannot
   cancel orbit A's. Combined speed ranges over |R_a*w_a - R_b*w_b| to their
   sum; if the two are close, the point periodically stops dead and flicks off
   in a new direction — a cusp, which reads as twitchy even though the path is
   mathematically smooth. Here 17%*2pi/37 = 2.89 against 8%*2pi/41 = 1.23, a
   ratio of ~2.4, so speed varies gently and never collapses. An earlier pair
   (10% at 23s = 2.73) was within 6% of orbit A and cusped ~90 times a minute.
   If you retune these, keep the two tangential speeds well apart. */
.tsf-field--live .tsf-bx { animation: tasaiX8 20.5s var(--sine) infinite alternate; }
.tsf-field--live .tsf-by { animation: tasaiY8 20.5s var(--sine) infinite alternate;
                           animation-delay: -30.75s; }

.tsf-field--live .tsf-field-art { animation: tasaiBreathe 41s var(--ease-in-out) infinite alternate; }

/* Percentages resolve against each layer's own box, which is the viewport. */
@keyframes tasaiX17 { from { transform: translate(-17%, 0); } to { transform: translate(17%, 0); } }
@keyframes tasaiY17 { from { transform: translate(0, -17%); } to { transform: translate(0, 17%); } }
@keyframes tasaiX8  { from { transform: translate(-8%, 0); } to { transform: translate(8%, 0); } }
@keyframes tasaiY8  { from { transform: translate(0, -8%); } to { transform: translate(0, 8%); } }
@keyframes tasaiBreathe {
  from { transform: scale(1); }
  to   { transform: scale(1.14); }
}

/* ---------- Header ---------- */
.tsf-nav {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: var(--container);
  margin: 0 auto;
  display: flex;
  align-items: center;
  padding: 28px var(--gutter-lg);
}

/* The wordmark is "tasai 多彩" — the CJK is part of the mark, not decoration.
   The handoff README's wordmark spec lists the text as just "tasai"; the actual
   Wordmark component (in the _ds_bundle.js that wasn't shipped with the handoff)
   sets both, which is what the design screens show. Sized relative to the latin
   so the pair scales together. */
.tsf-wordmark {
  font-family: var(--font-serif);
  font-weight: var(--w-medium);
  font-size: 52px;
  line-height: 1;
  letter-spacing: var(--track-display);
  color: var(--frost);
  white-space: nowrap;
}
.tsf-wordmark-cjk {
  font-family: var(--font-serif-cjk);
  font-size: 0.46em;
  letter-spacing: 0.06em;
  margin-left: 0.95em;   /* em here is the CJK size, not the latin size */
}

/* ---------- Hero ---------- */
.tsf-hero {
  position: relative;
  z-index: 1;
  flex: 1;
  display: flex;
  align-items: flex-end;   /* bottom-anchored */
  width: 100%;
  max-width: var(--container);
  margin: 0 auto;
  padding: 0 var(--gutter-lg) 80px;
}

.tsf-headline {
  font-family: var(--font-serif);
  font-weight: var(--w-medium);
  font-size: clamp(44px, 6vw, 88px);
  line-height: 1.04;
  letter-spacing: 0.02em;
  color: var(--frost);
  margin: 0;
  max-width: 800px;
}

.tsf-sub {
  max-width: 440px;
  margin-top: 22px;
  font-size: var(--t-body-lg);
  line-height: 1.65;
  color: rgba(255, 255, 255, 0.86);
}

.tsf-form-slot { margin-top: 36px; }

/* ---------- Signup form ---------- */
/* CRITICAL: the hero is bottom-anchored, so any height change in this block
   pushes the headline. All three states share one grid cell, which makes the
   block permanently as tall as its tallest state — currently step 2, whose
   SMS disclosure wraps to two lines.
   The handoff specified a fixed `min-height: 104px` instead; that is 12px short
   of the real step-2 height and visibly jogs the headline on submit. A larger
   fixed number would only relocate the bug — it would break again on a copy
   edit, a font fallback, or a different viewport. This reserves the true
   maximum instead, so it cannot drift. */
.tsf-form {
  display: grid;
  max-width: 520px;
  min-height: 104px;   /* floor from the handoff; the grid governs above it */
}
.tsf-form > .tsf-state {
  grid-area: 1 / 1;    /* stack every state in the same cell */
  align-self: start;   /* states keep their natural height, no stretching */
}
/* Inactive states stay laid out — visibility, never display — so they keep
   contributing their height. `inert` (set in markup) removes them from the
   tab order and the accessibility tree. */
.tsf-form > .tsf-state:not([data-active="true"]) {
  visibility: hidden;
  pointer-events: none;
}

.tsf-row {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.tsf-input {
  flex: 1 1 240px;
  min-width: 0;
  padding: 14px 16px;
  font-family: var(--font-sans);
  font-size: var(--t-body);
  letter-spacing: var(--track-body);
  color: var(--frost);
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.34);
  border-radius: var(--r-sm);
  outline: none;
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}
.tsf-input::placeholder { color: rgba(255, 255, 255, 0.5); }

/* Browser autofill must not repaint the field yellow. */
.tsf-input:-webkit-autofill,
.tsf-input:-webkit-autofill:hover,
.tsf-input:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--frost);
  caret-color: var(--frost);
  transition: background-color 9999s ease-in-out 0s;
  box-shadow: 0 0 0 1000px rgba(60, 62, 66, 0.55) inset !important;
}

/* Primary button — inverted for the dark field. */
.tsf-btn {
  padding: 16px 34px;
  font-family: var(--font-mono);
  font-size: var(--t-body-lg);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  background: var(--frost);
  color: var(--ink);
  border: 1px solid var(--frost);
  border-radius: var(--r-sm);
  cursor: pointer;
  transition: background var(--dur) var(--ease-out),
              color var(--dur) var(--ease-out),
              border-color var(--dur) var(--ease-out),
              transform var(--dur-fast) var(--ease-out),
              box-shadow var(--dur) var(--ease-out);
}
/* Hover/active values were not specified in the handoff; the transition
   list implies them. Kept to a brighten + lift, nothing invented beyond that. */
.tsf-btn:hover:not(:disabled) {
  background: var(--white);
  border-color: var(--white);
  box-shadow: var(--shadow-md);
}
.tsf-btn:active:not(:disabled) { transform: translateY(1px); box-shadow: none; }
.tsf-btn:disabled { opacity: 0.4; cursor: not-allowed; }

.tsf-skip {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.72);
  text-decoration: underline;
  text-underline-offset: 4px;
}
.tsf-skip:hover { color: var(--frost); }

/* Byline / error row. The disclosure and the error share one grid cell, so the
   row is always as tall as the disclosure — swapping in a shorter error message
   cannot shrink it. min-height keeps the empty step-1 row reserved. */
.tsf-byline {
  display: grid;
  margin-top: 12px;
  min-height: 22px;
  font-family: var(--font-sans);
  font-size: var(--t-small);
  line-height: var(--lh-body);
  color: rgba(255, 255, 255, 0.66);
}
.tsf-byline > * { grid-area: 1 / 1; }
.tsf-byline-err { color: #F0B8B8; }
.tsf-byline[data-error="true"] .tsf-byline-note { visibility: hidden; }
.tsf-byline:not([data-error="true"]) .tsf-byline-err { visibility: hidden; }

/* Honeypot — off-screen, never focusable, invisible to assistive tech. */
.tsf-hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

/* ---------- Confirmation ---------- */
.tsf-done {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 18px 20px;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: var(--r-md);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}
.tsf-seal { flex: none; width: 44px; height: 44px; display: block; }
/* The seal is inlined rather than <img src="seal.svg"> so the page's web fonts
   apply — an <img>-referenced SVG cannot load them, and the CJK would fall back
   to a system sans face. The handoff's seal.svg specifies Hanken Grotesk here;
   the handoff README requires a *serif* CJK face, which is what we follow. */
.tsf-seal-cjk {
  font-family: var(--font-serif-cjk);
  font-weight: var(--w-medium);
  font-size: 62px;
  letter-spacing: 2px;
}
.tsf-seal-mark {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 4px;
}
.tsf-done-title {
  font-family: var(--font-serif);
  font-size: 1.6rem;
  font-weight: var(--w-medium);
  color: var(--frost);
  line-height: 1.2;
}
.tsf-done-sub {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.72);
  margin-top: 6px;
}

/* ---------- Responsive ---------- */
@media (max-width: 720px) {
  .tsf-nav  { padding-left: 20px; padding-right: 20px; }
  .tsf-hero { padding-left: 20px; padding-right: 20px; }
}

/* Short viewports — landscape phones, and portrait phones once the browser
   chrome is showing. The handoff specifies one width breakpoint and no height
   one; without this the page does not scroll (by design) and the bottom of the
   form is clipped off the viewport. Spacing only — type and layout are untouched. */
@media (max-height: 620px) {
  .tsf-nav  { padding-top: 16px; padding-bottom: 16px; }
  .tsf-hero { padding-bottom: 32px; }
}
@media (max-height: 460px) {
  .tsf-nav  { padding-top: 12px; padding-bottom: 12px; }
  .tsf-hero { padding-bottom: 20px; }
  .tsf-sub  { margin-top: 14px; }
  .tsf-form-slot { margin-top: 22px; }
}

@media (prefers-reduced-motion: reduce) {
  .tsf-field--live .tsf-ax,
  .tsf-field--live .tsf-ay,
  .tsf-field--live .tsf-bx,
  .tsf-field--live .tsf-by,
  .tsf-field--live .tsf-field-art { animation: none; }
  .tsf-btn { transition: none; }
}
