Skip to content

Stay Request Form

A booking form whose sent state is the wind: once your handler resolves, the form erodes into sand and the receipt, replaying the request, builds itself out of it.

WebGL20 deps1 file · 54 KB TSXreduced-motion aware

FreeAeolianminimal
Preview

Props

No runtime dependencies
"use client";

import { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { CSSProperties, FormEvent, ReactNode } from "react";

/**
 * STAY REQUEST FORM.
 * A booking form whose sent state is the wind: once your handler resolves, the
 * form erodes into sand and the receipt, replaying the request, builds itself
 * out of it.
 *
 * FONTS. Archivo with its width axis for display, Instrument Serif italic for
 * the accent word, Geist Mono for labels. In Next: Archivo({ subsets:
 * ["latin"], axes: ["wdth"], variable: "--font-ae" }), Instrument_Serif({
 * subsets: ["latin"], weight: "400", style: "italic", variable:
 * "--font-ae-serif" }) and Geist_Mono({ subsets: ["latin"], variable:
 * "--font-ae-mono" }) on <html>. Without them it falls back to system fonts.
 *
 * CLIENT. The file starts with "use client". onSubmit is a function, so render
 * it from a client component (a server page.tsx cannot pass functions).
 *
 * WIND. Every Aeolian entry reads one page-wide wind (aeWind on globalThis),
 * so separately pasted entries still turn together.
 *
 * DUST. The sand is drawn on a sticky canvas over the section, which no
 * ancestor with overflow: hidden may wrap; use overflow-x: clip instead.
 */

/** The deepest ground: dusk, the footer. */
const AE_NIGHT = "#150E0A";

/** Ink on the light grounds. */
const AE_UMBER = "#2B1B12";

const AE_DUNE = "#B8743F";

/** Lit sand: marks on the night ground. */
const AE_SAND = "#DDAA72";

/** The pale ground, and the colour a sandstorm turns everything. */
const AE_HAZE = "#EFE2CD";

/** The one filled accent: the low sun. */
const AE_EMBER = "#E0602C";

/**
 * The display face: Archivo, wide and light. Set its width with font-stretch
 * (112.5% or 125%), not font-variation-settings, so a canvas can paint the
 * same instance. In Next: Archivo({ subsets: ["latin"], axes: ["wdth"], variable: "--font-ae" }).
 */
const AE_DISPLAY = 'var(--font-ae, var(--font-display, "Archivo")), "Archivo", ui-sans-serif, system-ui, sans-serif';

/** Labels and readouts. */
const AE_MONO = 'var(--font-ae-mono, var(--font-geist-mono, "Geist Mono")), "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace';

/** Which ground a section sits on; the nav reads it to pick its ink. */
export type AeGround = "night" | "haze";

const aeClamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);

const aeReducedSnap = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/**
 * The scrolling box an element lives in: the nearest ancestor that actually
 * scrolls, or null for the window. Resolved on every frame, because a box only
 * becomes a scroller once its content is taller than it.
 */
function aeScrollParent(el: Element | null): HTMLElement | null {
  let n = el?.parentElement ?? null;
  while (n && n !== document.body && n !== document.documentElement) {
    const oy = getComputedStyle(n).overflowY;
    if ((oy === "auto" || oy === "scroll" || oy === "overlay") && n.scrollHeight > n.clientHeight + 1) return n;
    n = n.parentElement;
  }
  return null;
}

/**
 * The kit's scoped CSS: one focus ring (2px, offset 3px, sand on night and
 * umber on haze), tabular figures, the press, and transitions off for
 * anything a frame loop writes.
 */
const AE_CSS =
  ".ae-scope{--ae-ring:" + AE_SAND + "}" +
  ".ae-scope[data-ae-ground=haze],.ae-scope [data-ae-ground=haze]{--ae-ring:" + AE_UMBER + "}" +
  ".ae-scope :focus-visible{outline:2px solid var(--ae-ring)!important;outline-offset:3px!important}" +
  ".ae-scope .ae-num{font-variant-numeric:tabular-nums}" +
  ".ae-scope [data-ae-live]{transition:none!important}" +
  ".ae-scope .ae-press{transition:transform 180ms cubic-bezier(.2,.8,.2,1),background-color 180ms,color 180ms,border-color 180ms}" +
  ".ae-scope .ae-press:active{transform:scale(.97)}" +
  ".ae-scope .ae-arr{transition:transform 240ms cubic-bezier(.2,.8,.2,1)}.ae-scope a:hover .ae-arr,.ae-scope button:hover .ae-arr{transform:translateX(3px)}" +
  "@media (prefers-reduced-motion:reduce){.ae-scope .ae-press,.ae-scope .ae-arr{transition:none}}";

/** The kit's arrow: slides on hover. */
function AeArrow({ size = 14 }: { size?: number }) {
  return (
    <svg className="ae-arr" width={size} height={size} viewBox="0 0 14 14" aria-hidden>
      <path d="M2 7h9.5M7.8 3.4L11.5 7l-3.7 3.6" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

interface AeProgramSpec {
  vert: string;
  frag: string;
  /** Transform-feedback outputs, in buffer order (interleaved off: one buffer each). */
  feedback?: string[];
}

interface AeProgram {
  prog: WebGLProgram;
  loc: (name: string) => WebGLUniformLocation | null;
}

/**
 * Compile and link every program, then hand them to `done` (null, and a logged
 * reason, when any fails or the context is lost). Nothing asks whether a
 * compile worked until the driver says it has finished
 * (KHR_parallel_shader_compile, polled once a frame), so a heavy shader never
 * blocks the page. Without the extension it links at once. Returns a cancel.
 */
function aeLink(gl: WebGL2RenderingContext, specs: AeProgramSpec[], done: (progs: AeProgram[] | null) => void, label = "Aeolian"): () => void {
  const built: { prog: WebGLProgram; vs: WebGLShader; fs: WebGLShader }[] = [];
  for (const s of specs) {
    const prog = gl.createProgram();
    const vs = gl.createShader(gl.VERTEX_SHADER);
    const fs = gl.createShader(gl.FRAGMENT_SHADER);
    if (!prog || !vs || !fs) {
      done(null);
      return () => {};
    }
    gl.shaderSource(vs, s.vert);
    gl.shaderSource(fs, s.frag);
    gl.compileShader(vs);
    gl.compileShader(fs);
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    if (s.feedback) gl.transformFeedbackVaryings(prog, s.feedback, gl.SEPARATE_ATTRIBS);
    gl.linkProgram(prog);
    built.push({ prog, vs, fs });
  }
  const finish = () => {
    if (gl.isContextLost()) return done(null);
    for (const b of built) {
      if (!gl.getProgramParameter(b.prog, gl.LINK_STATUS)) {
        console.warn(label + ": a WebGL2 program failed, showing the CSS ground instead.", gl.getShaderInfoLog(b.fs) || gl.getShaderInfoLog(b.vs) || gl.getProgramInfoLog(b.prog));
        return done(null);
      }
    }
    done(
      built.map((b) => {
        const cache = new Map<string, WebGLUniformLocation | null>();
        return {
          prog: b.prog,
          loc: (n: string) => {
            if (!cache.has(n)) cache.set(n, gl.getUniformLocation(b.prog, n));
            return cache.get(n) ?? null;
          },
        };
      }),
    );
  };
  const ext = gl.getExtension("KHR_parallel_shader_compile") as { COMPLETION_STATUS_KHR: number } | null;
  if (!ext) {
    finish();
    return () => {};
  }
  let raf = 0;
  const poll = () => {
    raf = 0;
    if (gl.isContextLost() || built.every((b) => gl.getProgramParameter(b.prog, ext.COMPLETION_STATUS_KHR))) finish();
    else raf = requestAnimationFrame(poll);
  };
  raf = requestAnimationFrame(poll);
  return () => cancelAnimationFrame(raf);
}

/** Shared GLSL: hashes and value noise. */
const AE_NOISE_GLSL = [
  "float aeHash(vec2 p){ p = fract(p * vec2(443.897, 441.423)); p += dot(p, p.yx + 19.19); return fract((p.x + p.y) * p.x); }",
  "float aeHash3(vec3 p){ p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); }",
  "float aeNoise2(vec2 x){",
  "  vec2 i = floor(x), f = fract(x);",
  "  f = f * f * (3.0 - 2.0 * f);",
  "  return mix(mix(aeHash(i), aeHash(i + vec2(1, 0)), f.x), mix(aeHash(i + vec2(0, 1)), aeHash(i + vec2(1, 1)), f.x), f.y);",
  "}",
  "float aeNoise3(vec3 x){",
  "  vec3 i = floor(x), f = fract(x);",
  "  f = f * f * (3.0 - 2.0 * f);",
  "  return mix(mix(mix(aeHash3(i), aeHash3(i + vec3(1, 0, 0)), f.x), mix(aeHash3(i + vec3(0, 1, 0)), aeHash3(i + vec3(1, 1, 0)), f.x), f.y),",
  "             mix(mix(aeHash3(i + vec3(0, 0, 1)), aeHash3(i + vec3(1, 0, 1)), f.x), mix(aeHash3(i + vec3(0, 1, 1)), aeHash3(i + vec3(1, 1, 1)), f.x), f.y), f.z);",
  "}",
  "float aeFbm2(vec2 p){ float s = 0.0, a = 0.5; for (int i = 0; i < 4; i++){ s += a * aeNoise2(p); p = p * 2.03 + 17.1; a *= 0.5; } return s; }",
  "vec3 aeAces(vec3 x){ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }",
].join("\n");

/**
 * The page's wind. `angle` is the way it blows TOWARD, in degrees on the
 * ground: 0 = to the right (east), 90 = away from you (north), 180 = left,
 * 270 = toward you. `speed` is in km/h. The dune stage, the grains, the dust
 * of every dissolve, the nav's vane and the footer all read this one store,
 * so turning the rose in the hero turns every grain on the page. It lives on
 * globalThis, so entries pasted as separate files still share one wind.
 */
const aeWind: { angle: number; speed: number; subs: Set<() => void> } = ((globalThis as unknown as { __aeolianWind?: { angle: number; speed: number; subs: Set<() => void> } }).__aeolianWind ??= { angle: 55, speed: 30, subs: new Set() });

/**
 * The wind as a direction on a flat screen: x right, y down. Wind blowing
 * away from you reads as rising, toward you as falling, a little flattened
 * (the ground is seen at a low angle). Unit length.
 */
function aeWindScreen(angle = aeWind.angle): [number, number] {
  const a = (angle * Math.PI) / 180;
  const x = Math.cos(a);
  const y = -Math.sin(a) * 0.55;
  const l = Math.hypot(x, y) || 1;
  return [x / l, y / l];
}

interface AeRaster {
  canvas: HTMLCanvasElement;
  /** The element's layout size in CSS pixels (unscaled by any transform above it). */
  w: number;
  h: number;
  /** Resolves once inline SVGs (decoded as images) are painted in too. */
  ready: Promise<void>;
}

const AE_STRETCH: Record<string, string> = {
  "50%": "ultra-condensed",
  "62.5%": "extra-condensed",
  "75%": "condensed",
  "87.5%": "semi-condensed",
  "100%": "normal",
  "112.5%": "semi-expanded",
  "125%": "expanded",
  "150%": "extra-expanded",
  "200%": "ultra-expanded",
};

function aeAlpha(color: string): number {
  if (!color || color === "transparent") return 0;
  const m = color.match(/rgba?\(([^)]+)\)/);
  if (!m || !m[1]) return 1;
  const parts = m[1].split(/[\s,/]+/).filter(Boolean);
  return parts.length > 3 ? parseFloat(parts[3] ?? "1") : 1;
}

function aeRadius(v: string, w: number, h: number): number {
  const n = parseFloat(v) || 0;
  return v.trim().endsWith("%") ? (n / 100) * Math.min(w, h) : n;
}

function aeRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: [number, number, number, number]) {
  const m = Math.min(w, h) / 2;
  const [a, b, c, d] = r.map((v) => Math.max(0, Math.min(v, m))) as [number, number, number, number];
  ctx.beginPath();
  ctx.moveTo(x + a, y);
  ctx.lineTo(x + w - b, y);
  ctx.arcTo(x + w, y, x + w, y + b, b);
  ctx.lineTo(x + w, y + h - c);
  ctx.arcTo(x + w, y + h, x + w - c, y + h, c);
  ctx.lineTo(x + d, y + h);
  ctx.arcTo(x, y + h, x, y + h - d, d);
  ctx.lineTo(x, y + a);
  ctx.arcTo(x, y, x + a, y, a);
  ctx.closePath();
}

/**
 * Paint `el` into a canvas at `res` pixels per CSS pixel: background colours,
 * borders and radii, overflow clips, child canvases, inline SVGs, input
 * values, and text, one character at a time at the exact place the browser
 * laid it out (read with Range rects), in the element's own font. That is
 * enough for anything built from flat fills and type, which is what every
 * Aeolian surface is. Everything is read synchronously at the call, so the
 * snapshot is of the element as it is now even if React redraws it a moment
 * later. The element's own opacity is ignored (a hidden element can still be
 * painted, ready to be built out of sand); nodes marked data-ae-skip are left out.
 */
function aeRasterize(el: HTMLElement, res: number): AeRaster | null {
  const R = el.getBoundingClientRect();
  const w = el.offsetWidth;
  const h = el.offsetHeight;
  if (!w || !h || !R.width) return null;
  const s = R.width / w;
  const canvas = document.createElement("canvas");
  canvas.width = Math.max(1, Math.ceil(w * res));
  canvas.height = Math.max(1, Math.ceil(h * res));
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;
  ctx.scale(res, res);
  const X = (x: number) => (x - R.left) / s;
  const Y = (y: number) => (y - R.top) / s;
  const range = document.createRange();
  const svgs: Promise<void>[] = [];

  const text = (t: Text, cs: CSSStyleDeclaration, alpha: number) => {
    const data = t.data;
    if (!data.trim()) return;
    ctx.globalAlpha = alpha * aeAlpha(cs.color);
    ctx.fillStyle = cs.color;
    ctx.font = cs.fontStyle + " " + cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
    const c2 = ctx as unknown as { fontStretch?: string; letterSpacing?: string };
    if ("fontStretch" in c2) c2.fontStretch = AE_STRETCH[cs.fontStretch] ?? "normal";
    if ("letterSpacing" in c2) c2.letterSpacing = "0px";
    ctx.textBaseline = "alphabetic";
    const m = ctx.measureText("Hg");
    const asc = m.fontBoundingBoxAscent || parseFloat(cs.fontSize) * 0.8;
    const desc = m.fontBoundingBoxDescent || parseFloat(cs.fontSize) * 0.2;
    const upper = cs.textTransform === "uppercase";
    for (let i = 0; i < data.length; i++) {
      const code = data.charCodeAt(i);
      const n = code >= 0xd800 && code <= 0xdbff ? 2 : 1;
      let ch = data.slice(i, i + n);
      if (/\s/.test(ch)) continue;
      range.setStart(t, i);
      range.setEnd(t, i + n);
      i += n - 1;
      const rect = range.getClientRects()[0];
      if (!rect || !rect.width) continue;
      if (upper) ch = ch.toUpperCase();
      const rh = rect.height / s;
      ctx.fillText(ch, X(rect.left), Y(rect.top) + (rh - (asc + desc)) / 2 + asc);
    }
  };

  const paint = (node: Element, alpha: number, root: boolean) => {
    if (!root && node.hasAttribute("data-ae-skip")) return;
    const cs = getComputedStyle(node);
    if (cs.display === "none" || (!root && cs.visibility === "hidden")) return;
    const a = root ? alpha : alpha * (parseFloat(cs.opacity) || 0);
    if (a < 0.01) return;
    const r = node.getBoundingClientRect();
    const x = X(r.left);
    const y = Y(r.top);
    const bw = r.width / s;
    const bh = r.height / s;
    const radii: [number, number, number, number] = [
      aeRadius(cs.borderTopLeftRadius, bw, bh),
      aeRadius(cs.borderTopRightRadius, bw, bh),
      aeRadius(cs.borderBottomRightRadius, bw, bh),
      aeRadius(cs.borderBottomLeftRadius, bw, bh),
    ];
    if (node instanceof SVGSVGElement) {
      const clone = node.cloneNode(true) as SVGSVGElement;
      clone.setAttribute("width", String(bw));
      clone.setAttribute("height", String(bh));
      clone.setAttribute("color", cs.color);
      clone.style.color = cs.color;
      const img = new Image();
      const src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      svgs.push(
        new Promise<void>((done) => {
          img.onload = () => {
            ctx.save();
            ctx.globalAlpha = a;
            ctx.drawImage(img, x, y, bw, bh);
            ctx.restore();
            done();
          };
          img.onerror = () => done();
          img.src = src;
        }),
      );
      return;
    }
    ctx.save();
    if (aeAlpha(cs.backgroundColor) > 0) {
      ctx.globalAlpha = a * aeAlpha(cs.backgroundColor);
      ctx.fillStyle = cs.backgroundColor;
      aeRoundRect(ctx, x, y, bw, bh, radii);
      ctx.fill();
    }
    const bt = parseFloat(cs.borderTopWidth) || 0;
    const br = parseFloat(cs.borderRightWidth) || 0;
    const bb = parseFloat(cs.borderBottomWidth) || 0;
    const bl = parseFloat(cs.borderLeftWidth) || 0;
    if (bt || br || bb || bl) {
      const uniform = bt === br && br === bb && bb === bl && cs.borderTopColor === cs.borderBottomColor && cs.borderLeftColor === cs.borderRightColor && cs.borderTopColor === cs.borderLeftColor;
      if (uniform && cs.borderTopStyle !== "none") {
        ctx.globalAlpha = a * aeAlpha(cs.borderTopColor);
        ctx.strokeStyle = cs.borderTopColor;
        ctx.lineWidth = bt;
        aeRoundRect(ctx, x + bt / 2, y + bt / 2, bw - bt, bh - bt, radii.map((v) => Math.max(0, v - bt / 2)) as [number, number, number, number]);
        ctx.stroke();
      } else {
        const side = (on: number, color: string, style: string, sx: number, sy: number, sw: number, sh: number) => {
          if (!on || style === "none" || aeAlpha(color) === 0) return;
          ctx.globalAlpha = a * aeAlpha(color);
          ctx.fillStyle = color;
          ctx.fillRect(sx, sy, sw, sh);
        };
        side(bt, cs.borderTopColor, cs.borderTopStyle, x, y, bw, bt);
        side(bb, cs.borderBottomColor, cs.borderBottomStyle, x, y + bh - bb, bw, bb);
        side(bl, cs.borderLeftColor, cs.borderLeftStyle, x, y, bl, bh);
        side(br, cs.borderRightColor, cs.borderRightStyle, x + bw - br, y, br, bh);
      }
    }
    if (node instanceof HTMLCanvasElement) {
      try {
        ctx.globalAlpha = a;
        ctx.drawImage(node, x, y, bw, bh);
      } catch {
        // A canvas we may not read (tainted): leave its box empty.
      }
    }
    if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) {
      const isSelect = node instanceof HTMLSelectElement;
      // Secrets never reach the sand: passwords, one-time codes and card fields paint as dots.
      const secret = node instanceof HTMLInputElement && (node.type === "password" || /^(one-time-code|cc-)/.test(node.autocomplete));
      const value = isSelect ? (node.selectedOptions[0]?.text ?? "") : secret ? "•".repeat(node.value.length) : node.value;
      const shown = value || (isSelect ? "" : node.placeholder);
      if (shown && !(node instanceof HTMLInputElement && /^(checkbox|radio|range|color|file|hidden)$/.test(node.type))) {
        ctx.globalAlpha = a * (value ? 1 : 0.5);
        ctx.fillStyle = cs.color;
        ctx.font = cs.fontStyle + " " + cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
        ctx.textBaseline = "middle";
        const pl = (parseFloat(cs.paddingLeft) || 0) + bl;
        const lineY = node instanceof HTMLTextAreaElement ? y + (parseFloat(cs.paddingTop) || 0) + bt + (parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.3) / 2 : y + bh / 2;
        ctx.fillText(shown, x + pl, lineY, Math.max(1, bw - pl * 2));
      }
    }
    if (cs.overflow !== "visible" && cs.overflow !== "") {
      aeRoundRect(ctx, x, y, bw, bh, radii);
      ctx.clip();
    }
    for (const c of Array.from(node.childNodes)) {
      if (c.nodeType === 3) text(c as Text, cs, a);
      else if (c.nodeType === 1) paint(c as Element, a, false);
    }
    ctx.restore();
  };

  paint(el, 1, true);
  range.detach();
  return { canvas, w, h, ready: Promise.all(svgs).then(() => undefined) };
}

interface AeBurstOptions {
  /** The whole burst, in ms (the element is gone a little past half way). */
  duration?: number;
  /** Blow this way instead of the page's wind (degrees, same compass as the wind store). */
  angle?: number;
  /** How far the grains fly, 0.3..2. */
  reach?: number;
}

/** A burst you drive yourself (with the scroll): `set("scatter", 0..1)` blows it away, `set("gather", 0..1)` builds it. */
interface AeScrub {
  set: (mode: "scatter" | "gather", c: number) => void;
  /** Throw the snapshot away (the element changed); the next set() paints it again. */
  refresh: () => void;
  drop: () => void;
}

interface AeDust {
  /** Paint the element, hide it, and blow it away grain by grain. Resolves once it has fully let go (the grains fly on a little longer). */
  scatter: (el: HTMLElement, o?: AeBurstOptions) => Promise<void>;
  /** Build an element (laid out, typically hidden with opacity 0) out of sand blown in on the wind, then show it. */
  gather: (el: HTMLElement, o?: AeBurstOptions) => Promise<void>;
  scrub: (el: HTMLElement, o?: Omit<AeBurstOptions, "duration">) => AeScrub;
}

/** Release window and flight time, as fractions of a burst. */
const AE_RELEASE = 0.55;

const AE_FLIGHT = 0.45;

const AE_DUST_COMMON = [
  "uniform vec2 uView;",
  "uniform vec2 uOrigin;",
  "uniform vec2 uSize;",
  "uniform float uCell;",
  "uniform float uC;",
  "uniform float uGather;",
  "uniform vec2 uWind;",
  "uniform float uReach;",
  "uniform float uSeed;",
  "uniform float uFlight;",
  AE_NOISE_GLSL,
  /** When the grain at `pos` (element px) lets go, 0..1: windward first, along a ragged, patchy front. */
  "float aeRelease(vec2 pos){",
  "  vec2 cell = floor(pos / uCell);",
  "  float half_ = 0.5 * (abs(uWind.x) * uSize.x + abs(uWind.y) * uSize.y) + 1.0;",
  "  float lin = 0.5 + 0.5 * dot(pos - 0.5 * uSize, uWind) / half_;",
  "  float n = aeFbm2(pos / 46.0 + uSeed * 13.0);",
  "  float g = aeHash(cell + uSeed * 71.0);",
  "  return clamp(lin * 0.74 + (n - 0.5) * 0.42 + (g - 0.5) * 0.07 + 0.08, 0.0, 1.0);",
  "}",
  "vec2 aeClip(vec2 px){ return vec2(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0); }",
].join("\n");

const AE_GRAIN_VERT = [
  "#version 300 es",
  "precision highp float;",
  "uniform sampler2D uTex;",
  "uniform int uCols;",
  "uniform float uRes;",
  "uniform float uDpr;",
  AE_DUST_COMMON,
  "out vec4 vCol;",
  "void main(){",
  "  vCol = vec4(0.0);",
  "  gl_Position = vec4(2.0, 2.0, 0.0, 1.0);",
  "  gl_PointSize = 1.0;",
  "  vec2 cell = vec2(float(gl_VertexID % uCols), float(gl_VertexID / uCols));",
  "  vec2 pos = (cell + 0.5) * uCell;",
  "  if (pos.y > uSize.y || pos.x > uSize.x) return;",
  "  vec4 tex = texelFetch(uTex, ivec2(pos * uRes), 0);",
  "  if (tex.a < 0.04) return;",
  "  float tr = aeRelease(pos) * " + AE_RELEASE.toFixed(3) + ";",
  "  float tau = (uC - tr) / " + AE_FLIGHT.toFixed(3) + ";",
  "  if (tau <= 0.0 || tau >= 1.0) return;",
  "  if (uGather > 0.5) tau = 1.0 - tau;",
  "  vec4 r = vec4(aeHash(cell + 1.7 + uSeed), aeHash(cell * 1.31 + 9.2), aeHash(cell * 0.77 + 4.1), aeHash(cell * 2.13 + 7.7));",
  "  vec2 d = uWind;",
  "  vec2 nrm = vec2(-d.y, d.x);",
  "  float t = tau * uFlight;",
  "  float tp = mix(0.08, 0.5, r.x * r.x);",
  "  vec2 U = d * uReach * (720.0 + 0.6 * max(uSize.x, uSize.y));",
  "  vec2 v0 = d * 90.0 * r.y + vec2(0.0, -(110.0 + 220.0 * r.z) * uReach);",
  "  const float G = 640.0;",
  "  float e = 1.0 - exp(-t / tp);",
  "  vec2 off = U * t + (v0 - U) * tp * e + vec2(0.0, G * tp * t - G * tp * tp * e);",
  // Eddies: a slow wander across the wind that grows as the grain travels.
  "  off += nrm * (aeNoise2(vec2(t * 1.6 + r.w * 7.0, r.y * 13.0)) - 0.5) * 120.0 * uReach * tau;",
  // Built from sand: the same flight run backward, blown in from upwind and settling from above.
  "  if (uGather > 0.5) off = vec2(-off.x, -abs(off.y) * 0.6);",
  "  vec2 px = uOrigin + pos + off;",
  "  gl_Position = vec4(aeClip(px), 0.0, 1.0);",
  "  gl_PointSize = max(1.0, uCell * uDpr * (1.18 - 0.55 * tau));",
  "  vec3 sand = mix(vec3(0.867, 0.667, 0.447), vec3(0.722, 0.455, 0.247), r.z);",
  "  vec3 base = tex.rgb / max(tex.a, 0.001);",
  "  vec3 col = mix(base, sand, smoothstep(0.04, 0.55, tau) * 0.78);",
  "  float a = tex.a * (1.0 - smoothstep(0.55, 1.0, tau)) * (0.75 + 0.25 * r.w);",
  "  vCol = vec4(col * a, a);",
  "}",
].join("\n");

const AE_GRAIN_FRAG = [
  "#version 300 es",
  "precision mediump float;",
  "in vec4 vCol;",
  "out vec4 o;",
  "void main(){",
  "  float d = length(gl_PointCoord - 0.5);",
  "  o = vCol * smoothstep(0.52, 0.28, d);",
  "}",
].join("\n");

const AE_SLAB_VERT = [
  "#version 300 es",
  "precision highp float;",
  "uniform vec2 uView;",
  "uniform vec2 uOrigin;",
  "uniform vec2 uSize;",
  "out vec2 vUv;",
  "void main(){",
  "  int i = gl_VertexID;",
  "  vec2 uv = vec2(float(i == 1 || i == 4 || i == 5), float(i == 2 || i == 3 || i == 5));",
  "  vUv = uv;",
  "  vec2 px = uOrigin + uv * uSize;",
  "  gl_Position = vec4(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0, 0.0, 1.0);",
  "}",
].join("\n");

const AE_SLAB_FRAG = [
  "#version 300 es",
  "precision highp float;",
  "uniform sampler2D uTex;",
  AE_DUST_COMMON,
  "in vec2 vUv;",
  "out vec4 o;",
  "void main(){",
  "  vec2 pos = vUv * uSize;",
  "  vec4 tex = texture(uTex, vUv);",
  "  float tr = aeRelease(pos) * " + AE_RELEASE.toFixed(3) + ";",
  // Scatter: still here until its grain lets go. Gather: here once its grain has landed.
  "  float k = uGather > 0.5 ? step(tr + " + AE_FLIGHT.toFixed(3) + ", uC) : step(uC, tr);",
  "  if (k < 0.5) discard;",
  // A thin rim of loose sand along the front.
  "  float front = uGather > 0.5 ? uC - tr - " + AE_FLIGHT.toFixed(3) + " : tr - uC;",
  "  float rim = 1.0 - smoothstep(0.0, 0.035, front);",
  "  vec3 base = tex.rgb / max(tex.a, 0.001);",
  "  vec3 col = mix(base, vec3(0.867, 0.667, 0.447), rim * 0.55);",
  "  o = vec4(col * tex.a, tex.a);",
  "}",
].join("\n");

interface AeBurst {
  el: HTMLElement;
  tex: WebGLTexture | null;
  raster: AeRaster | null;
  mode: "scatter" | "gather";
  c: number;
  t0: number;
  dur: number;
  angle: number | undefined;
  reach: number;
  seed: number;
  /** Driven by its caller (scroll), not by the clock. */
  scrubbed: boolean;
  /** Superseded by a newer burst on the same element: only its grains still fly. */
  ghost: boolean;
  /** Where the element last was on the layer (CSS px), kept for when it leaves the DOM mid-flight. */
  ox: number;
  oy: number;
  stale: boolean;
  released: boolean;
  settle: (() => void) | null;
  finish: (() => void) | null;
}

/**
 * The dust layer's engine: one WebGL2 canvas laid over a whole page (or one
 * asset), drawing only while something is turning to sand. Each burst is a
 * snapshot texture drawn twice: a crisp slab that erodes along the wind, and
 * one grain per cell flown on a stateless path (so a burst can be played
 * forward, backward, or scrubbed by the scroll). Without WebGL2, or with
 * reduced motion, every burst is a 220ms fade.
 */
function aeDustEngine() {
  let canvas: HTMLCanvasElement | null = null;
  let gl: WebGL2RenderingContext | null = null;
  let progs: AeProgram[] | null = null;
  let failed = false;
  let raf = 0;
  let cancelLink: (() => void) | null = null;
  const bursts: AeBurst[] = [];
  const waiting: (() => void)[] = [];
  let seed = 1;

  const fade = (el: HTMLElement, show: boolean) =>
    new Promise<void>((done) => {
      el.style.transition = "opacity 220ms ease";
      el.style.opacity = show ? "" : "0";
      window.setTimeout(() => {
        el.style.transition = "";
        done();
      }, 230);
    });

  const ready = () => !!(gl && progs && !failed && !aeReducedSnap());

  const upload = (b: AeBurst) => {
    if (!gl) return;
    const res = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
    b.raster = aeRasterize(b.el, res);
    b.stale = false;
    if (!b.raster) return;
    if (!b.tex) b.tex = gl.createTexture();
    const put = () => {
      if (!gl || !b.tex || !b.raster) return;
      gl.bindTexture(gl.TEXTURE_2D, b.tex);
      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
      try {
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, b.raster.canvas);
      } catch {
        // A tainted snapshot (some browsers taint SVG with foreignObject): draw nothing for it.
        gl.deleteTexture(b.tex);
        b.tex = null;
        return;
      }
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    };
    put();
    // Inline SVGs land a moment later; upload again when they have.
    const r = b.raster;
    void r.ready.then(() => {
      if (b.raster === r) put();
    });
  };

  const free = (b: AeBurst) => {
    const i = bursts.indexOf(b);
    if (i >= 0) bursts.splice(i, 1);
    if (gl && b.tex) gl.deleteTexture(b.tex);
    b.tex = null;
  };

  const frame = (now: number) => {
    raf = 0;
    if (!canvas || !gl || !progs) return;
    const [grain, slab] = progs as [AeProgram, AeProgram];
    // A bleed box is display:none while idle, so its overhang never adds page scroll.
    const bleedBox = canvas.dataset.aeBox ? canvas.parentElement : null;
    if (bleedBox && bleedBox.style.display) bleedBox.style.display = "";
    let cssH = canvas.offsetHeight;
    if (!canvas.dataset.aeBox) {
      const host = canvas.parentElement?.parentElement;
      const sp = aeScrollParent(canvas);
      const viewH = sp ? sp.clientHeight : window.innerHeight;
      const hostH = host ? host.offsetHeight : viewH;
      cssH = Math.max(1, Math.min(viewH, hostH));
      if (canvas.style.height !== cssH + "px") canvas.style.height = cssH + "px";
    }
    cssH = Math.max(1, cssH);
    const C = canvas.getBoundingClientRect();
    const cssW = canvas.offsetWidth || 1;
    const sc = C.width / cssW || 1;
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    const W = Math.max(1, Math.round(cssW * dpr));
    const H = Math.max(1, Math.round(cssH * dpr));
    if (canvas.width !== W || canvas.height !== H) {
      canvas.width = W;
      canvas.height = H;
    }
    gl.viewport(0, 0, W, H);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    let live = false;
    for (const b of bursts.slice()) {
      if (!b.scrubbed) b.c = aeClamp((now - b.t0) / b.dur);
      if (!b.scrubbed && !b.released && b.c >= (b.mode === "scatter" ? AE_RELEASE : 1)) {
        b.released = true;
        b.settle?.();
      }
      const shown = b.mode === "scatter" ? b.c <= 0 : b.c >= 1;
      if (b.scrubbed) {
        b.el.style.opacity = shown ? "" : "0";
        if (shown) continue;
        if (b.stale || !b.raster) upload(b);
      }
      if (!b.scrubbed && b.c >= 1) {
        if (b.mode === "gather" && !b.ghost) b.el.style.opacity = "";
        b.finish?.();
        free(b);
        continue;
      }
      live = live || !b.scrubbed;
      if (!b.raster || !b.tex) continue;
      // An element replaced mid-flight (a route opening, a form sent) keeps its grains where it was.
      if (b.el.isConnected) {
        const R = b.el.getBoundingClientRect();
        b.ox = (R.left - C.left) / sc;
        b.oy = (R.top - C.top) / sc;
      }
      const { ox, oy } = b;
      const area = b.raster.w * b.raster.h;
      const cell = Math.max(1.5, Math.sqrt(area / 80000));
      const cols = Math.ceil(b.raster.w / cell);
      const rows = Math.ceil(b.raster.h / cell);
      const wind = aeWindScreen(b.angle ?? aeWind.angle);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, b.tex);
      for (const p of [slab, grain]) {
        gl.useProgram(p.prog);
        gl.uniform1i(p.loc("uTex"), 0);
        gl.uniform2f(p.loc("uView"), cssW, cssH);
        gl.uniform2f(p.loc("uOrigin"), ox, oy);
        gl.uniform2f(p.loc("uSize"), b.raster.w, b.raster.h);
        gl.uniform1f(p.loc("uCell"), cell);
        gl.uniform1f(p.loc("uC"), b.c);
        gl.uniform1f(p.loc("uGather"), b.mode === "gather" ? 1 : 0);
        gl.uniform2f(p.loc("uWind"), wind[0], wind[1]);
        gl.uniform1f(p.loc("uReach"), b.reach);
        gl.uniform1f(p.loc("uSeed"), b.seed);
        // Seconds of flight a burst gives its grains (a scrubbed one takes a nominal 1.5 s burst).
        gl.uniform1f(p.loc("uFlight"), ((b.dur || 1500) / 1000) * AE_FLIGHT);
      }
      if (!b.ghost) {
        gl.useProgram(slab.prog);
        gl.drawArrays(gl.TRIANGLES, 0, 6);
      }
      gl.useProgram(grain.prog);
      gl.uniform1i(grain.loc("uCols"), cols);
      gl.uniform1f(grain.loc("uRes"), b.raster.canvas.width / b.raster.w);
      gl.uniform1f(grain.loc("uDpr"), dpr);
      gl.drawArrays(gl.POINTS, 0, cols * rows);
    }
    if (live) raf = requestAnimationFrame(frame);
    else if (bleedBox && !bursts.length) bleedBox.style.display = "none";
  };

  const kick = () => {
    if (!raf && canvas && gl && progs) raf = requestAnimationFrame(frame);
  };

  const begin = (el: HTMLElement, mode: "scatter" | "gather", o: AeBurstOptions, scrubbed: boolean): AeBurst => {
    for (const old of bursts.filter((x) => x.el === el && !x.ghost)) {
      old.settle?.();
      // A scatter whose grains are still in the air keeps flying while the element builds again.
      if (old.mode === "scatter" && !old.scrubbed && !scrubbed && mode === "gather") {
        old.ghost = true;
        continue;
      }
      if (old.mode === "gather") el.style.opacity = "";
      old.finish?.();
      free(old);
    }
    const b: AeBurst = {
      el,
      tex: null,
      raster: null,
      mode,
      c: 0,
      t0: performance.now(),
      dur: o.duration ?? 1500,
      angle: o.angle,
      reach: aeClamp(o.reach ?? 1, 0.3, 2),
      seed: (seed = (seed * 1.618) % 7.3) + 0.37,
      scrubbed,
      ghost: false,
      ox: 0,
      oy: 0,
      stale: true,
      released: false,
      settle: null,
      finish: null,
    };
    bursts.push(b);
    return b;
  };

  const timed = (el: HTMLElement, mode: "scatter" | "gather", o: AeBurstOptions = {}) =>
    new Promise<void>((resolve) => {
      const go = () => {
        if (!ready()) {
          if (mode === "gather") el.style.opacity = "0";
          void fade(el, mode === "gather").then(resolve);
          return;
        }
        const b = begin(el, mode, o, false);
        upload(b);
        if (!b.raster) {
          free(b);
          el.style.opacity = mode === "gather" ? "" : "0";
          resolve();
          return;
        }
        el.style.opacity = "0";
        let said = false;
        const say = () => {
          if (!said) {
            said = true;
            resolve();
          }
        };
        if (mode === "scatter") b.settle = say;
        else b.finish = say;
        kick();
      };
      // The layer may still be compiling: wait for it (briefly) rather than fading.
      if (!ready() && !failed && !aeReducedSnap() && canvas) {
        let ran = false;
        const once = () => {
          if (ran) return;
          ran = true;
          go();
        };
        waiting.push(once);
        window.setTimeout(once, 600);
      } else go();
    });

  return {
    attach(c: HTMLCanvasElement) {
      canvas = c;
      const g = c.getContext("webgl2", { alpha: true, premultipliedAlpha: true, antialias: false });
      if (!g) {
        failed = true;
        waiting.splice(0).forEach((f) => f());
        return;
      }
      gl = g;
      cancelLink = aeLink(
        g,
        [
          { vert: AE_GRAIN_VERT, frag: AE_GRAIN_FRAG },
          { vert: AE_SLAB_VERT, frag: AE_SLAB_FRAG },
        ],
        (p) => {
          cancelLink = null;
          progs = p;
          failed = !p;
          waiting.splice(0).forEach((f) => f());
          kick();
        },
        "Aeolian dust",
      );
      // Never keep a click waiting on a slow compile.
      window.setTimeout(() => waiting.splice(0).forEach((f) => f()), 700);
    },
    lost() {
      failed = true;
      progs = null;
      for (const b of bursts.slice()) {
        b.el.style.opacity = b.mode === "scatter" && !b.scrubbed ? "0" : "";
        b.settle?.();
        b.finish?.();
        b.tex = null;
        bursts.splice(bursts.indexOf(b), 1);
      }
    },
    detach() {
      cancelLink?.();
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      for (const b of bursts.slice()) {
        if (b.mode === "gather" || b.scrubbed) b.el.style.opacity = "";
        b.settle?.();
        b.finish?.();
        free(b);
      }
      if (gl && progs) progs.forEach((p) => gl?.deleteProgram(p.prog));
      gl = null;
      progs = null;
      canvas = null;
    },
    api: {
      scatter: (el, o) => timed(el, "scatter", o),
      gather: (el, o) => timed(el, "gather", o),
      scrub: (el, o = {}) => {
        let b: AeBurst | null = null;
        let ro: ResizeObserver | null = null;
        return {
          set(mode, c) {
            const k = aeClamp(c);
            if (b && bursts.includes(b) && b.mode === mode && b.c === k) return;
            if ((!b || !bursts.includes(b)) && ((mode === "scatter" && k <= 0) || (mode === "gather" && k >= 1))) {
              el.style.opacity = "";
              return;
            }
            if (!ready()) {
              el.style.opacity = (mode === "scatter" ? k < 0.5 : k > 0.5) ? "" : "0";
              return;
            }
            if (!b || !bursts.includes(b)) {
              b = begin(el, mode, o, true);
              if (!ro && typeof ResizeObserver !== "undefined") {
                ro = new ResizeObserver(() => {
                  if (b) b.stale = true;
                });
                ro.observe(el);
              }
            }
            b.mode = mode;
            b.c = k;
            if (raf) return;
            raf = requestAnimationFrame(frame);
          },
          refresh() {
            if (b) b.stale = true;
          },
          drop() {
            ro?.disconnect();
            if (b) {
              el.style.opacity = "";
              free(b);
              b = null;
              kick();
            }
          },
        };
      },
    } satisfies AeDust,
  };
}

const AeDustContext = createContext<AeDust | null>(null);

/**
 * The layer's canvas: stuck to the top of the view inside its host, as tall as
 * the view (or the host, if shorter). With `bleed`, a box that reaches that
 * many px past the host instead (twice that sideways), for small hosts such
 * as a lone button, so the grains have room to fly.
 */
function AeDustCanvas({ engine, bleed }: { engine: ReturnType<typeof aeDustEngine>; bleed?: number }) {
  const ref = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const c = ref.current;
    if (!c) return;
    engine.attach(c);
    const lost = (e: Event) => {
      e.preventDefault();
      engine.lost();
    };
    c.addEventListener("webglcontextlost", lost);
    return () => {
      c.removeEventListener("webglcontextlost", lost);
      engine.detach();
    };
  }, [engine]);
  if (bleed)
    return (
      <span aria-hidden data-ae-skip="" className="pointer-events-none absolute z-[60] block" style={{ inset: -bleed + "px " + -bleed * 2 + "px", display: "none" }}>
        <canvas ref={ref} data-ae-box="1" className="absolute inset-0 block h-full w-full" />
      </span>
    );
  return (
    <div aria-hidden data-ae-skip="" className="pointer-events-none sticky top-0 z-[60] h-0 w-full">
      <canvas ref={ref} className="absolute left-0 top-0 w-full" style={{ height: 0 }} />
    </div>
  );
}

/**
 * The dust for this part of the page: the page's layer when there is one
 * (an <AeDustContext> above), otherwise a layer of this asset's own. Render
 * `layer` first inside your (relatively positioned) root and wrap the rest in
 * `provide`, so everything inside shares it.
 */
function useAeDust(bleed?: number): { dust: AeDust; layer: ReactNode; provide: (children: ReactNode) => ReactNode } {
  const outer = useContext(AeDustContext);
  const [own] = useState(() => (outer ? null : aeDustEngine()));
  const dust = outer ?? (own as ReturnType<typeof aeDustEngine>).api;
  return {
    dust,
    layer: own ? <AeDustCanvas engine={own} bleed={bleed} /> : null,
    provide: (children) => (own ? <AeDustContext.Provider value={dust}>{children}</AeDustContext.Provider> : children),
  };
}

export type AeStayId = "lee" | "crest" | "traverse";

export interface AeStay {
  id: AeStayId;
  name: string;
  /** Per night, in dollars. */
  night: number;
  line: string;
  perks: string[];
}

const AE_STAYS: AeStay[] = [
  { id: "lee", name: "Lee tent", night: 180, line: "Sheltered in the horn of the dune, out of the wind.", perks: ["Canvas bell tent, two beds", "Breakfast and supper at the long table", "Hot water at dusk"] },
  { id: "crest", name: "Crest tent", night: 260, line: "Pitched on the brink, so the sunrise comes in the door.", perks: ["Everything in the Lee tent", "A private deck on the crest", "A telescope for the night sky"] },
  { id: "traverse", name: "Erg traverse", night: 420, line: "A guide, two camels and a different camp every night.", perks: ["A guided crossing on any route", "Every meal on the trail", "A fly camp under the stars"] },
];

export interface StayRequest {
  name: string;
  email: string;
  stay: AeStayId;
  nights: number;
  month: string;
  note: string;
}

const RF_MONTHS = ["November", "December", "January", "February", "March", "April"];

const RF_CSS =
  ".ae-rf-field{transition:border-color 200ms,box-shadow 200ms}" +
  ".ae-rf-field:focus{outline:none!important;border-color:" + AE_DUNE + "!important;box-shadow:0 1px 0 0 " + AE_DUNE + "}" +
  ".ae-rf-chip{transition:background-color 200ms,color 200ms,border-color 200ms}" +
  ".ae-rf-chip:has(input:checked){background:" + AE_UMBER + ";color:" + AE_HAZE + ";border-color:" + AE_UMBER + "}" +
  ".ae-rf-chip:has(input:focus-visible){outline:2px solid " + AE_UMBER + ";outline-offset:3px}" +
  "@media (prefers-reduced-motion:reduce){.ae-rf-field,.ae-rf-chip{transition:none}}";

/** A plain, linear-time email check: something@something.something, no spaces. */
const rfEmail = (v: string) => /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(v);

export interface StayRequestFormProps {
  eyebrow?: string;
  headline?: string;
  sub?: string;
  /**
   * Where requests go. Resolve when received; throw to keep the form and show its error line. The form never says "sent" before this resolves.
   * A request that answers with an error status still resolves, so check the response and throw on !res.ok yourself. The checks here are for the visitor: validate, length-limit and rate-limit again on the server.
   */
  onSubmit: (request: StayRequest) => Promise<void> | void;
  /** Fill the stay and nights from elsewhere (the pricing): change `key` to apply it again. */
  preset?: { stay?: AeStayId; nights?: number; key: number } | null;
  ground?: AeGround;
  className?: string;
  style?: CSSProperties;
}

/**
 * StayRequestForm — a request for nights at the camp. Fields lift their
 * rule on focus, chips are real radios, errors say what to fix and take
 * focus. Once your handler resolves, the form erodes into sand and blows
 * away; the receipt (replaying what was asked for) builds itself out of
 * sand in its place. "Send another" runs it the other way.
 */
export function StayRequestForm({
  eyebrow = "Request",
  headline = "Ask for a night on the dune.",
  sub = "Tell us when and how long. We write back from the camp with the dates we can hold.",
  onSubmit,
  preset = null,
  ground = "haze",
  className = "",
  style,
}: StayRequestFormProps) {
  const { dust, layer, provide } = useAeDust();
  const [stay, setStay] = useState<AeStayId>("crest");
  const [nights, setNights] = useState(4);
  const [errors, setErrors] = useState<Partial<Record<"name" | "email", string>>>({});
  const [status, setStatus] = useState<"idle" | "sending" | "error">("idle");
  const [sent, setSent] = useState<StayRequest | null>(null);
  const formRef = useRef<HTMLFormElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
  const receiptRef = useRef<HTMLDivElement>(null);
  const arrive = useRef(false);
  const ids = useId();
  const presetKey = preset?.key;

  // A choice made elsewhere on the page fills the stay and the nights.
  useEffect(() => {
    if (presetKey === undefined || !preset) return;
    const t = window.setTimeout(() => {
      if (preset.stay) setStay(preset.stay);
      if (preset.nights) setNights(preset.nights);
    }, 0);
    return () => window.clearTimeout(t);
    // Applied once per key.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [presetKey]);

  useLayoutEffect(() => {
    if (!arrive.current) return;
    arrive.current = false;
    const el = sent ? receiptRef.current : cardRef.current;
    if (!el) return;
    el.style.opacity = "0";
    void dust.gather(el, { duration: 1500 }).then(() => {
      if (sent) receiptRef.current?.querySelector<HTMLElement>("[data-rf-title]")?.focus({ preventScroll: true });
      else cardRef.current?.querySelector<HTMLElement>("[name=name]")?.focus({ preventScroll: true });
    });
  }, [sent, dust]);

  const submit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const f = new FormData(e.currentTarget);
    const req: StayRequest = {
      name: String(f.get("name") ?? "").trim(),
      email: String(f.get("email") ?? "").trim(),
      stay,
      nights,
      month: String(f.get("month") ?? RF_MONTHS[0]),
      note: String(f.get("note") ?? "").trim(),
    };
    const next: Partial<Record<"name" | "email", string>> = {};
    if (!req.name) next.name = "Add the name the booking is under.";
    if (!rfEmail(req.email)) next.email = "Add an email we can write back to, like name@example.com.";
    setErrors(next);
    const first = next.name ? "name" : next.email ? "email" : null;
    if (first) {
      formRef.current?.querySelector<HTMLInputElement>('[name="' + first + '"]')?.focus();
      return;
    }
    setStatus("sending");
    try {
      await onSubmit(req);
    } catch {
      setStatus("error");
      return;
    }
    const card = cardRef.current;
    if (card) await dust.scatter(card, { duration: 1500 });
    setStatus("idle");
    arrive.current = true;
    setSent(req);
  };

  const again = async () => {
    const r = receiptRef.current;
    if (r) await dust.scatter(r, { duration: 1300 });
    arrive.current = true;
    setSent(null);
  };

  const night = ground === "night";
  const ink = night ? AE_HAZE : AE_UMBER;
  const line = night ? "rgba(239,226,205,.2)" : "rgba(43,27,18,.2)";
  const field = "ae-rf-field mt-2 block h-11 w-full rounded-none border-0 bg-transparent px-0 text-[16px]";
  const stayName = (id: AeStayId) => AE_STAYS.find((s) => s.id === id)?.name ?? id;
  return (
    <section data-ae-ground={ground} className={"ae-scope @container relative w-full " + className} style={{ background: night ? AE_NIGHT : AE_HAZE, color: ink, fontFamily: AE_DISPLAY, ...style }}>
      <style>{AE_CSS + RF_CSS}</style>
      {layer}
      {provide(
        <div className="grid grid-cols-1 gap-12 px-5 py-20 @3xl:grid-cols-[1fr_minmax(420px,560px)] @3xl:gap-[6cqw] @3xl:px-[5.5cqw] @3xl:py-[9cqw]">
          <div>
            <p className="mb-5 flex items-center gap-3 text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, opacity: 0.72 }}>
              <span aria-hidden className="inline-block h-px w-8" style={{ background: "currentColor" }} />
              {eyebrow}
            </p>
            <h2 className="m-0 max-w-[14ch] leading-[1.02] tracking-[-0.03em]" style={{ fontSize: "clamp(36px, 5.2cqw, 84px)", fontStretch: "112.5%", fontWeight: 320 }}>
              {headline}
            </h2>
            <p className="mt-6 max-w-[26rem] text-[16px] leading-[1.6]" style={{ opacity: 0.75, fontStretch: "100%" }}>
              {sub}
            </p>
          </div>
          <div className="relative">
            {sent ? (
              <div ref={receiptRef} role="status" className="rounded-[28px] p-7 @3xl:p-9" style={{ background: AE_UMBER, color: AE_HAZE }}>
                <p className="m-0 text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, color: AE_SAND }}>
                  Request received
                </p>
                <p data-rf-title="" tabIndex={-1} className="m-0 mt-4 text-[34px] leading-[1.05] tracking-[-0.02em] outline-none" style={{ fontStretch: "112.5%", fontWeight: 330 }}>
                  Thank you, {sent.name.split(" ")[0]}.
                </p>
                <dl className="m-0 mt-7 text-[15px]" style={{ fontStretch: "100%" }}>
                  {(
                    [
                      ["Stay", stayName(sent.stay)],
                      ["Nights", String(sent.nights)],
                      ["Month", sent.month],
                      ["Reply to", sent.email],
                    ] as const
                  ).map(([k, v]) => (
                    <div key={k} className="flex justify-between gap-6 py-3" style={{ borderTop: "1px solid rgba(239,226,205,.16)" }}>
                      <dt style={{ opacity: 0.65 }}>{k}</dt>
                      <dd className="m-0 truncate text-right">{v}</dd>
                    </div>
                  ))}
                </dl>
                <button type="button" onClick={() => void again()} className="ae-press mt-7 inline-flex h-11 items-center gap-2 rounded-full px-5 text-[14px]" style={{ background: "transparent", color: AE_HAZE, border: "1px solid rgba(239,226,205,.35)", fontFamily: "inherit", fontStretch: "100%" }}>
                  Send another
                </button>
              </div>
            ) : (
              <div ref={cardRef} className="rounded-[28px] p-7 @3xl:p-9" style={{ background: night ? "rgba(239,226,205,.05)" : "rgba(255,250,242,.6)", border: "1px solid " + (night ? "rgba(239,226,205,.12)" : "rgba(43,27,18,.1)") }}>
                <form ref={formRef} noValidate onSubmit={(e) => void submit(e)} aria-describedby={ids + "-status"}>
                  <div className="grid grid-cols-1 gap-6 @xl:grid-cols-2">
                    {(
                      [
                        ["name", "Name", "text", "name"],
                        ["email", "Email", "email", "email"],
                      ] as const
                    ).map(([key, label, type, auto]) => (
                      <div key={key}>
                        <label htmlFor={ids + key} className="text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, opacity: 0.72 }}>
                          {label}
                        </label>
                        <input
                          id={ids + key}
                          name={key}
                          type={type}
                          maxLength={key === "email" ? 254 : 120}
                          autoComplete={auto}
                          aria-invalid={!!errors[key]}
                          aria-describedby={errors[key] ? ids + key + "-err" : undefined}
                          className={field}
                          style={{ color: "inherit", borderBottom: "1px solid " + (errors[key] ? AE_EMBER : line), fontFamily: "inherit", fontStretch: "100%" }}
                        />
                        {errors[key] ? (
                          <p id={ids + key + "-err"} className="m-0 mt-2 text-[13px]" style={{ color: night ? AE_SAND : "#A3401A" }}>
                            {errors[key]}
                          </p>
                        ) : null}
                      </div>
                    ))}
                  </div>
                  <fieldset className="m-0 mt-7 min-w-0 border-0 p-0">
                    <legend className="mb-3 p-0 text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, opacity: 0.72 }}>
                      Stay
                    </legend>
                    <div className="flex flex-wrap gap-2">
                      {AE_STAYS.map((s) => (
                        <label key={s.id} className="ae-rf-chip inline-flex h-10 cursor-pointer items-center rounded-full px-4 text-[14px]" style={{ border: "1px solid " + line, fontStretch: "100%" }}>
                          <input type="radio" name="stay" value={s.id} checked={stay === s.id} onChange={() => setStay(s.id)} className="sr-only" />
                          {s.name}
                        </label>
                      ))}
                    </div>
                  </fieldset>
                  <div className="mt-7 grid grid-cols-2 gap-6">
                    <div>
                      <label htmlFor={ids + "nights"} className="text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, opacity: 0.72 }}>
                        Nights
                      </label>
                      <select id={ids + "nights"} name="nights" value={nights} onChange={(e) => setNights(Number(e.currentTarget.value))} className={field} style={{ color: "inherit", borderBottom: "1px solid " + line, fontFamily: "inherit", fontStretch: "100%" }}>
                        {Array.from({ length: 14 }, (_, i) => i + 1).map((n) => (
                          <option key={n} value={n} style={{ color: AE_UMBER }}>
                            {n}
                          </option>
                        ))}
                      </select>
                    </div>
                    <div>
                      <label htmlFor={ids + "month"} className="text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, opacity: 0.72 }}>
                        Month
                      </label>
                      <select id={ids + "month"} name="month" defaultValue={RF_MONTHS[1]} className={field} style={{ color: "inherit", borderBottom: "1px solid " + line, fontFamily: "inherit", fontStretch: "100%" }}>
                        {RF_MONTHS.map((m) => (
                          <option key={m} value={m} style={{ color: AE_UMBER }}>
                            {m}
                          </option>
                        ))}
                      </select>
                    </div>
                  </div>
                  <div className="mt-7">
                    <label htmlFor={ids + "note"} className="text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, opacity: 0.72 }}>
                      Anything else <span style={{ opacity: 0.6 }}>(optional)</span>
                    </label>
                    <textarea id={ids + "note"} name="note" rows={2} maxLength={1200} className="ae-rf-field mt-2 block w-full resize-none rounded-none border-0 bg-transparent px-0 py-2 text-[16px]" style={{ color: "inherit", borderBottom: "1px solid " + line, fontFamily: "inherit", fontStretch: "100%" }} />
                  </div>
                  <div className="mt-8 flex flex-wrap items-center justify-between gap-4">
                    <p id={ids + "-status"} aria-live="polite" className="m-0 text-[13px]" style={{ color: status === "error" ? (night ? AE_SAND : "#A3401A") : "inherit", opacity: status === "error" ? 1 : 0.6, fontStretch: "100%" }}>
                      {status === "sending" ? "Sending your request…" : status === "error" ? "That did not send. Your answers are still here; try again." : "We only use your email to reply."}
                    </p>
                    <button type="submit" disabled={status === "sending"} className="ae-press inline-flex h-12 items-center gap-2.5 rounded-full px-6 text-[14.5px] font-medium disabled:cursor-wait disabled:opacity-70" style={{ background: AE_EMBER, color: "#1A0E07", fontFamily: "inherit", fontStretch: "100%" }}>
                      {status === "sending" ? "Sending…" : "Send request"}
                      <AeArrow />
                    </button>
                  </div>
                </form>
              </div>
            )}
          </div>
        </div>,
      )}
    </section>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this section

The one form on the site: a request for nights at the camp. Name and email on underlined fields, the stay as chips, nights and month as selects, and an optional note. Errors say what to fix and take focus. When you send it, the button says so while your handler runs; once it resolves, the whole form card erodes from its windward edge and blows away, and in its place the receipt (who, which stay, how many nights, which month, where we will reply) builds itself out of sand. Send another runs the wind the other way.

Curator’s note

Free. onSubmit is required: the form only blows away after your handler resolves; if it throws, the form stays filled and says so. Chips are native radios styled through :has, the email check is linear-time, the first bad field takes focus. A preset prop lets another part of the page fill the stay and nights (the pricing does in the Barchan site). Every Aeolian entry is cut byte for byte from one site (Barchan, a made-up camp that moves with the dunes) and shares its light and its wind: sand, umber and haze with one ember accent, Archivo set wide and light, Instrument Serif italic for one word, and a single page-wide wind that every engine reads.

Pairs well with

NewPro
NewPro