:root {
  /* Fallbacks only. canvas-pool.js republishes --tile-w and --tile-h onto each
     layer element at init and on every breakpoint change, so the JS cell pitch
     and the CSS box are guaranteed to agree. */
  --tile-w: 210px;
  --tile-h: 250px;
  --tile-pad: 10px;
  --tile-radius: 10px;
  --tile-gap: 8px;

  /* Kept in step with public/styles/tokens.css. --serif is for headings only;
     everything else, card text included, is --sans. */
  --serif: "Newsreader", Georgia, serif;
  --sans: "Inter", -apple-system, "Segoe UI", sans-serif;

  /* The two-colour pair the whole UI is drawn from. The submit button applies
     it one way (ink on ivory, since it sits on a light fill) and the header the
     other (ivory on dark). Inverted on purpose — but from these values, not
     from a second set of near-identical hexes. */
  --ivory: #e9e6e1;
  --ivory-lift: #f2efea;
  --ink: #1a1310;

  /* Every interactive control that has a fill or a focus ring of its own uses
     this. The field row's own 8px is this plus its 2px inset, so the two stay
     concentric if either moves. */
  --control-radius: 6px;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  height: 100%;
  background: #000;
  font-family: var(--sans);
  /* Nothing on the page scrolls, so suppress the rubber-band bounce that iOS
     would otherwise apply to the whole document during a pan. */
  overscroll-behavior: none;
}

/* ---------------------------------------------------------------- canvas */

.canvas-root {
  position: fixed;
  inset: 0;
  overflow: hidden;
  background: #000;
  /* Required for Pointer Events on touch: without it the browser claims the
     gesture for native scrolling and stops sending pointermove mid-drag. */
  touch-action: none;
  /* Stops a drag turning into a text selection across the cards. */
  user-select: none;
  -webkit-user-select: none;
  /* Suppresses the grey flash Safari paints over the tile a touch lands on. */
  -webkit-tap-highlight-color: transparent;
  cursor: grab;
}

.canvas-root.is-dragging {
  cursor: grabbing;
}

/* One per depth plane. Opacity and stacking come from canvas.js, since both are
   derived from the layer's depth and there is no fixed set of layer names to
   write rules against. This is the element that moves: pan is applied here once
   per frame, and the tiles inside hold static per-cell transforms. That is what
   makes will-change worth stating — before the depth layers existed the pan
   lived on every tile and this hint described nothing.

   No filter. Distance does need to read as lost contrast and colour, but the
   field sits on black, where reducing opacity already scales luminance and
   chroma together. A saturate()/brightness() pass on top bought nothing and
   forced each plane into its own filtered buffer. */
.canvas-layer {
  position: absolute;
  top: 0;
  left: 0;
  opacity: var(--layer-opacity, 1);
  will-change: transform;
  /* Dragging is handled on .canvas-root, and no tile has a handler of its own,
     so taking several hundred nodes out of hit testing costs nothing. */
  pointer-events: none;
}

/* Height is fixed rather than content-driven. Uniform boxes are what make the
   field tile seamlessly, since varying text length would otherwise break the
   alignment the JS cell pitch assumes. */
.tile {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--tile-w);
  height: var(--tile-h);
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0;
  overflow: hidden;
  background: #fff;
  border-radius: var(--tile-radius);
  color: #111;
  /* Load-bearing, not cosmetic: the rect maths in canvas-pool.js assumes a tile
     scales about its centre, and the content safe-zone test is built on that
     rect. Changing this origin moves every tile without moving the box the test
     checks, which lets a tile drift over the headline. */
  transform-origin: 50% 50%;
}

/* Surplus pool nodes that no visible cell currently needs. They stay in the
   DOM and only their visibility is toggled, so recycling never inserts or
   removes anything. visibility rather than display, so an idle tile keeps its
   place in its layer's rasterisation instead of forcing a reflow on return. */
.tile.is-idle {
  visibility: hidden;
}

.tile img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  /* The mock is already a full card, so it fills the tile instead of sitting
     in a padded photo well with a second title underneath. */
  border-radius: inherit;
  background: #fff;
  flex: none;
  -webkit-user-drag: none;
}

.tile-user,
.tile-title,
.tile-meta {
  display: none;
}

/* --------------------------------------------------------------- overlay */

.content-layer {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  z-index: 10;
  pointer-events: none;
  color: #fff;
  text-align: left;
  padding: 6vw;
}

/* Centred on screen by the grid above, but ranged left within itself: the label
   sits at one edge and the submit button at the other, so the row needs a
   settled width rather than one the headline happens to imply.
   
   width, not max-width. The input is flex: 1 with min-width: 0, so under a max
   the row would collapse to the button's own width and never reach 620.
   
   Also load-bearing for the canvas: content-rect.js measures this element and
   canvas-pool.js keeps tiles out of the result, so pinning the width pins the
   hole in the tile field too. */
.content-block {
  width: min(620px, 100%);
}

/* The overlay sits on a field of white cards, so it needs a scrim to stay
   legible. Provisional and self-contained: delete this rule to drop it. */
.content-layer::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: radial-gradient(
    ellipse 55% 45% at 50% 50%,
    rgba(0, 0, 0, 0.82) 0%,
    rgba(0, 0, 0, 0.62) 45%,
    rgba(0, 0, 0, 0) 100%
  );
}

/* Only interactive children take pointer events; everything else lets drags
   pass through to the canvas underneath. */
.content-layer input,
.content-layer button,
.content-layer a {
  pointer-events: auto;
}

.content-layer h1 {
  margin: 0 0 16px;
  font-family: var(--serif);
  font-size: clamp(1.8rem, 4.4vw, 3.2rem);
  font-weight: 400;
  line-height: 1.14;
}

.content-block > p {
  margin: 0 0 32px;
  font-size: 1rem;
  color: rgba(255, 255, 255, 0.76);
}

.signup {
  margin: 0;
}

.field-label {
  display: block;
  margin-bottom: 18px;
  font-size: 0.95rem;
  color: #fff;
}

/* One box around both controls. The border lives here rather than on the input
   so the button can sit inside it, and the 1px padding plus the 1px border give
   the button its 2px inset on every side. */
.field-row {
  display: flex;
  /* Not centre: the button takes its height from the row, which is in turn set
     by the input's padding, so the two always agree without a fixed height. */
  align-items: stretch;
  padding: 1px;
  border: 1px solid rgba(255, 255, 255, 0.6);
  border-radius: 8px;
  transition: border-color 0.2s ease;
}

/* The input has no border of its own, so the row carries the focus cue for it.
   Without this the field would give no indication of focus at all. */
.field-row:focus-within {
  border-color: #fff;
}

.signup input {
  flex: 1;
  /* Flex items floor at their content width otherwise, which for an input is
     wide enough to push the button off the row. */
  min-width: 0;
  padding: 14px 15px;
  border: none;
  background: transparent;
  color: #fff;
  font: inherit;
  font-size: 0.95rem;
  /* The row's own focus-within ring stands in for this one. */
  outline: none;
}

.signup input::placeholder {
  color: rgba(255, 255, 255, 0.6);
}

.signup button {
  /* Sized by its label, and never squeezed by the input beside it. */
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 0 20px;
  border: none;
  /* Flush against the input on the left; only the outer (right) corners follow
     the row. */
  border-radius: 0 var(--control-radius) var(--control-radius) 0;
  background: var(--ivory);
  color: var(--ink);
  font: inherit;
  font-size: 0.95rem;
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.18s ease;
}

.signup button:hover {
  background: var(--ivory-lift);
}

/* Sits on a light fill, where the row's border change is invisible, so the
   button keeps a focus ring of its own. */
.signup button:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

.signup .arrow {
  transition: transform 0.18s ease;
}

.signup button:hover .arrow {
  transform: translateX(2px);
}

.field-row.has-error {
  border-color: var(--error);
}

.error-msg {
  margin: 10px 0 0;
  min-height: 1.1em;
  font-size: 0.86rem;
  color: var(--error);
}

form.is-hidden {
  display: none;
}

form.is-pending input {
  color: rgba(255, 255, 255, 0.5);
}

form.is-pending .arrow {
  opacity: 0;
}

.signup button:disabled {
  cursor: progress;
  opacity: 0.7;
}

.signup button:disabled:hover {
  background: var(--ivory);
}

.success-panel {
  display: none;
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: var(--control-radius);
  padding: 20px 22px;
}

.success-panel.is-visible {
  display: block;
}

.success-panel p {
  margin: 0 0 10px;
  font-size: 0.98rem;
  line-height: 1.5;
  color: #fff;
}

.success-panel p:last-child {
  margin-bottom: 0;
}

.success-panel .success-email {
  color: rgba(255, 255, 255, 0.72);
}

/* Sibling of the overlay rather than inside it, so the canvas still receives
   pans in the rest of the viewport. Visible from the start: there is no video
   reveal to wait on. */
.privacy-link {
  position: fixed;
  right: 6vw;
  bottom: 5vh;
  z-index: 15;
  padding: 0;
  border: none;
  background: none;
  color: rgba(255, 255, 255, 0.72);
  font-family: var(--sans);
  font-size: 0.9rem;
  text-decoration: none;
  cursor: pointer;
}

.privacy-link:hover {
  color: #fff;
}

.privacy-link:focus-visible {
  outline: 2px solid var(--ivory);
  outline-offset: 2px;
  border-radius: var(--control-radius);
}

/* ----------------------------------------------------------------- header */

/* Above .content-layer, so the bar covers the overlay's scrim as well as the
   card field. Fixed rather than sticky: nothing on this page scrolls, so there
   is no flow for sticky to detach from.

   The bar is the entire contrast fix for the logo and the links. Cards pass
   under it at full white, and 60% black over white is what keeps the ivory
   readable — so if legibility ever fails here, this alpha is the thing to
   change. Adding a gradient or a shadow behind the text instead would leave
   two overlapping answers to one problem. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  /* 6vw is .content-layer's own inset, so the brand and the nav land on the
     same margins as the headline below them. The 12px, with the 44px minimum
     on the links, is what gives the bar its height. */
  padding: 12px 6vw;
  background: rgba(0, 0, 0, 0.6);
  color: var(--ivory);
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: inherit;
  text-decoration: none;
}

.brand-mark {
  width: 24px;
  height: 24px;
  /* The mark is the one thing here that must not be squeezed by a long
     wordmark on a narrow viewport. */
  flex: none;
}

.brand-name {
  font-family: var(--serif);
  font-style: italic;
  font-weight: 500;
  font-size: 1.15rem;
}

.site-nav {
  display: flex;
  align-items: center;
  gap: 24px;
}

/* The 44px minimum is on the link rather than the bar, so the whole target
   belongs to the anchor and not to dead space around it. */
.site-nav a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 4px;
  color: inherit;
  /* Same family, size and weight as the submit button's label: both are UI
     text at the same level, so neither gets its own type treatment. */
  font-size: 0.95rem;
  font-weight: 500;
  /* The underline is always there and only its colour moves. text-decoration
     is not animatable, so fading the colour is what buys the transition — and
     declaring the line up front means hover cannot shift the text by adding
     one. */
  text-decoration: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 1px;
  text-underline-offset: 4px;
  transition: text-decoration-color 0.18s ease;
}

.site-nav a:hover {
  text-decoration-color: currentColor;
}

.site-nav a[aria-current="page"] {
  text-decoration-color: currentColor;
}

.brand:focus-visible,
.site-nav a:focus-visible {
  outline: 2px solid var(--ivory);
  outline-offset: 2px;
  border-radius: var(--control-radius);
}

/* ---------------------------------------------------------------- compact */

/* Must stay in step with COMPACT_MAX_WIDTH in canvas-pool.js. The tile box
   itself comes from JS via --tile-w / --tile-h; only the interior scales here,
   since padding and type do not affect the cell pitch. */
@media (max-width: 640px) {
  :root {
    --tile-pad: 8px;
    --tile-gap: 6px;
    --tile-radius: 8px;
  }

  /* Nudged up slightly from the strict-grid values, since a tile can now render
     at 0.86 scale and the text scales with it. */
  .tile-user {
    font-size: 0.7rem;
  }

  .tile-title {
    font-size: 0.8rem;
  }

  .tile-meta {
    font-size: 0.66rem;
  }

  .content-block > p {
    margin-bottom: 24px;
  }

  /* Gaps and the mark tighten on a phone; the nav's type does not. Stepping the
     links down to 0.9rem would put them on a different size from the submit
     button, which is the one thing they have to keep agreeing on — and the
     measured row clears 390px with room to spare at full size anyway. */
  .site-header {
    gap: 16px;
  }

  .brand-mark {
    width: 22px;
    height: 22px;
  }

  .brand-name {
    font-size: 1.05rem;
  }

  .site-nav {
    gap: 16px;
  }

  /* Side by side, the field plus the button overflows a phone. Stacking is
     predictable instead, which means the shared box has to give up its border
     and hand it back to the input: a single outline around two stacked controls
     reads as one very tall field. */
  .field-row {
    flex-direction: column;
    gap: 8px;
    padding: 0;
    border: none;
  }

  .field-row:focus-within {
    border-color: transparent;
  }

  .signup input {
    border: 1px solid rgba(255, 255, 255, 0.6);
    border-radius: 8px;
  }

  /* The row no longer sets the height, so the button needs its own. Stacked,
     it is a full-width control again and takes the radius on every corner. */
  .signup button {
    justify-content: center;
    padding: 14px 20px;
    border-radius: var(--control-radius);
  }

  /* Back on the input, since it is the one with a border again. */
  .signup input:focus-visible {
    outline: 2px solid #fff;
    outline-offset: 2px;
  }

  .field-row.has-error input {
    border-color: var(--error);
  }

  .privacy-link {
    right: 7vw;
    bottom: 4vh;
  }
}

/* ----------------------------------------------------------- interior pages */

/* The landing page locks the document to the viewport so a pan cannot bounce
   the whole window. Interior pages have to scroll, so those locks come off
   when the body says so. */
html:has(body.page-about),
html:has(body.page-blog),
body.page-about,
body.page-blog {
  height: auto;
  min-height: 100%;
  overscroll-behavior: auto;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.about {
  padding: 120px 6vw 24vh;
  color: #fff;
  font-size: 1.05rem;
  line-height: 1.65;
}

.about-copy {
  max-width: 620px;
}

.about p {
  margin: 0 0 1.35em;
}

.about p:last-child {
  margin-bottom: 0;
}

.about strong {
  font-weight: 500;
}

.about a {
  color: inherit;
  text-underline-offset: 3px;
}

.about a:focus-visible {
  outline: 2px solid var(--ivory);
  outline-offset: 2px;
  border-radius: var(--control-radius);
}

.about-copy h1 {
  font-family: var(--serif);
  font-weight: 400;
  font-size: 1.7rem;
  line-height: 1.3;
  margin: 0 0 0.5em;
}

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

.blog-list li {
  margin: 0;
}

/* Same underline fade as the header nav: the line is always there and only
   its colour appears on hover, so the text does not shift. */
.blog-list a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 1px;
  text-underline-offset: 4px;
  transition: text-decoration-color 0.18s ease;
}

.blog-list a:hover {
  text-decoration-color: currentColor;
}

.blog-list a:focus-visible {
  outline: 2px solid var(--ivory);
  outline-offset: 2px;
  border-radius: var(--control-radius);
}
