Skip to content
LandingPagePrompts
Landing Pagestealth/space-bunny-alphaverified 20/20partial cross-check

Undertow

A self-illuminating curl-noise particle vortex draws the eye toward an off-centre data drain.

The exact prompt

WHAT IS BEING BUILT

Build a single-viewport cinematic landing page for a data-infrastructure company named “UNDERTOW”. The page is a dark, editorial product statement: an approximately 900-particle canvas vortex moves behind restrained navigation, a left-aligned hero block, a compact proof strip, and a minimal footer. The visual centre is not a conventional logo lockup. It is an off-centre black drain at 66% horizontal and 51% vertical, surrounded by teal particle acceleration. The hero copy occupies the calmer negative space from 10% to 51% of the viewport.

Set the document title to “Undertow — Data infrastructure in motion” and the root language to en. Use <meta name="viewport" content="width=device-width, initial-scale=1">. At viewport widths of 901px and above, html, body, and .page must be exactly 100dvh high and overflow:hidden. At 900px and below, use normal document flow, allow vertical scrolling, and preserve a minimum content height of 100dvh. Never allow horizontal overflow at any supported width.

The document must contain one <header>, one <main>, one <footer>, and one <nav>. It must contain exactly one <h1>. Every visible control must be a real link or button with an accessible name. Use :focus-visible to render a visible 2px accent ring with a 3px offset.

Do NOT build a dashboard, chart, table, pricing grid, account interface, modal, menu drawer, login form, multi-section marketing site, horizontal carousel, video background, image gallery, 3D scene, or secondary page. Do NOT add a second h1, decorative heading, cookie banner, chat launcher, cursor follower, scroll indicator, framework, library, external asset, or remote API call. Do NOT use image files, SVG files, base64 media, video, GIFs, web fonts other than Google Fonts, or third-party CDNs. Do NOT use React, Tailwind, GSAP, Three.js, Lottie, WebGL, or a build step. Google Fonts is the sole permitted network request; import “Manrope” at weights 400, 500, and 600, with ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, “Segoe UI”, sans-serif as the fallback stack.

THE BACKDROP

Create a fixed .backdrop element covering the viewport with z-index 0. Its base background is a near-black radial and linear gradient composite, not a flat fill. Use these exact gradient decisions:

- base color: #05080c;
- outer field: radial-gradient(circle at 66% 51%, #06131a 0%, #071017 17%, #080d13 35%, #05080c 68%);
- upper-left cast: radial-gradient(circle at 12% 4%, rgba(42, 62, 73, 0.30) 0%, rgba(14, 25, 32, 0.10) 28%, transparent 52%);
- lower-edge sink: linear-gradient(180deg, transparent 0%, rgba(4, 7, 10, 0) 62%, rgba(0, 0, 0, 0.54) 100%);
- drain: radial-gradient(circle at 66% 51%, #000306 0%, #000306 5%, rgba(0, 3, 6, 0.98) 9%, rgba(0, 5, 8, 0.68) 15%, transparent 26%).

Add a .grain element above the canvas and below the copy. It must be a CSS-generated background, 190px by 190px, repeated at 0, 0, using four inline radial-gradient dots with opacities 0.026, 0.018, 0.021, and 0.012. Apply opacity 1. Do not animate the grain. Add a vignette with inset box-shadow 0 0 190px 64px rgba(0, 0, 0, 0.50), so the left copy and the drain remain the darkest readable regions.

Render one visible <canvas class="vortex"> and one non-DOM offscreen glow canvas. Use exactly 900 particles at 901px and above, 720 from 601px through 900px, 580 from 431px through 600px, and 420 below 431px. Particle positions use CSS-pixel coordinates. Each particle carries x, y, previousX, previousY, age, life, speedBias, size, hueMix, and phase. Particle life is randomized from 5.4s to 10.8s. Initial life offsets must be distributed across the full life interval so the field is populated on the first frame.

The vortex centre is exactly 66% of viewport width and 51% of viewport height. The radial pull strength is 0.0022 per frame per CSS pixel, capped so a particle does not gain more than 2.4 CSS pixels of inward displacement in one frame. Tangential motion uses two offset sine fields as a cheap curl-noise approximation. For a particle at x, y, and time t, compute fieldA as sin(y * 0.0062 + t * 0.00037 + phase), fieldB as sin(x * 0.0047 - t * 0.00029 - phase), and the curl turn as sin((x + y) * 0.0031 + t * 0.00021 + fieldA * 0.72) + fieldB * 0.42. Apply 0.19 CSS pixels of clockwise tangential displacement, then apply the radial pull, then apply a 0.12px drag. A signed cross-clockwise bias of 0.35 multiplied by speedBias creates occasional counter-current strands without reversing the main vortex.

Each particle is drawn as a 1px line from its previous position to its current position. Use source-over for the core field. Particle stroke colors interpolate from #19333a at 70% acceleration to #5eead4 at 100% acceleration, with the final 14% of life approaching #d8fffa so respawns do not flash. Stroke alpha ranges from 0.09 to 0.72. Particle size is stored as a CSS value from 0.7px to 1.25px, but canvas strokes remain exactly 1 device-independent pixel at DPR 1.5. Do not use round particles or glow sprites.

Respawn a particle when life reaches zero, when it enters the central 3.2% radius, or when it leaves the viewport by more than 80px. Respawn on an elliptical rim between 46% and 78% of the larger viewport dimension from the drain. Bias 72% of respawns into the right and lower arcs. Reset previousX and previousY to x and y before the next update.

Every animation frame, clear and update all particles, draw all particle strokes once to the visible canvas, then composite the blurred offscreen field. Create the glow canvas at exactly 50% of the visible canvas backing resolution. Draw the visible particle field to the glow canvas through one drawImage call. Set glowCtx.filter to blur(3.2px) exactly once immediately before that drawImage call, reset it to “none” immediately afterward, and never set glowCtx.filter inside the particle loop. Draw the glow canvas into the visible canvas once with globalCompositeOperation “screen” and globalAlpha 0.34. Reset composite modes after compositing.

The visible canvas uses devicePixelRatio capped at 1.5. Backing dimensions equal rounded CSS dimensions multiplied by 1.5. Set the CSS width and height to 100%. The offscreen canvas uses a 0.5 scale multiplier relative to the visible backing dimensions, not 0.5 device pixels. Resize both canvases only when a resize event or the first measurement changes the stored width or height. The target per-frame cost on a 1440x900 headless viewport is 900 particle updates, 900 single line strokes, one full-resolution offscreen downscale, one filtered offscreen upscale, and no DOM layout reads. Target less than 7ms of JavaScript and canvas work per frame, less than 19,800 visible backing pixels processed per particle coordinate, and no allocation inside the particle loop.

THE LAYER STACK

Use these exact z-index values from back to front:

- body and .page background: 0;
- .backdrop gradients: 0;
- .vortex canvas: 1;
- .grain: 2;
- .vignette: 3;
- header.site-header: 10;
- main.hero: 20;
- footer.site-footer: 30;
- focus rings inherit their control stacking context and must remain above adjacent decoration.

The header, hero, and footer must be transparent and must not receive pointer-events:none. Decorative background layers must have pointer-events:none and aria-hidden="true". The canvas must have aria-hidden="true". The page must never put dense particle strokes behind body copy with opacity below 0.78; apply a left-side linear scrim from rgba(5, 8, 12, 0.90) 0% to rgba(5, 8, 12, 0.46) 38% to transparent 57%.

THE TOKEN TABLE

Define every visual decision in :root with these exact names and values:

- --font-sans: “Manrope”, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, “Segoe UI”, sans-serif;
- --color-bg: #05080c;
- --color-surface: #0a1117;
- --color-copy: #eef7f5;
- --color-muted: #8da19f;
- --color-faint: #536563;
- --color-line: rgba(148, 184, 180, 0.18);
- --color-accent: #5eead4;
- --color-accent-bright: #d8fffa;
- --color-dark-accent: #0f4f49;
- --header-height: 76px;
- --header-pad-x: clamp(24px, 4.4vw, 84px);
- --page-pad-x: clamp(24px, 5.2vw, 100px);
- --page-pad-y: 32px;
- --hero-max-width: 624px;
- --hero-copy-width: 570px;
- --eyebrow-size: 11px;
- --eyebrow-tracking: 0.20em;
- --eyebrow-line: 14px;
- --h1-size: clamp(52px, 5.25vw, 84px);
- --h1-line: 0.94;
- --h1-tracking: -0.055em;
- --h1-weight: 600;
- --body-size: 16px;
- --body-line: 1.65;
- --body-width: 492px;
- --nav-size: 12px;
- --nav-tracking: 0.05em;
- --button-height: 46px;
- --button-pad-x: 20px;
- --button-radius: 999px;
- --button-font-size: 12px;
- --button-tracking: 0.04em;
- --pill-height: 30px;
- --pill-pad-x: 11px;
- --pill-radius: 999px;
- --pill-font-size: 10px;
- --pill-tracking: 0.13em;
- --proof-gap: 34px;
- --proof-value-size: 19px;
- --proof-label-size: 9px;
- --proof-label-tracking: 0.14em;
- --footer-size: 10px;
- --footer-tracking: 0.12em;
- --focus-width: 2px;
- --focus-offset: 3px;
- --motion-ui: 720ms;
- --motion-fast: 360ms;
- --ease-out: cubic-bezier(0.16, 1, 0.3, 1).

THE COMPOSITION

Build the body as <div class="page"> containing .backdrop, canvas.vortex, .grain, .vignette, then the semantic page landmarks. The .page is position:relative; isolation:isolate; min-height:100dvh.

Build header.site-header as a semantic header positioned absolute at inset:0 0 auto 0, height:var(--header-height), and padding-inline:var(--header-pad-x). Its inner .header-row uses display:grid; grid-template-columns:1fr auto 1fr; align-items:center; height:100%. The left child is <a class="wordmark" href="#main" aria-label="Undertow home">UNDERTOW</a>. The center child is <nav aria-label="Primary navigation"> with display:flex; gap:34px. Its exact link labels are “Platform”, “Network”, and “Company”. The right child is <a class="header-cta" href="#contact">Talk to us <span aria-hidden="true">↗</span></a>, justified-self:end. Do not center the navigation by visual guess; preserve the 1fr auto 1fr grid.

Build main#main.hero with position:relative; z-index:20; display:grid; grid-template-columns:minmax(0, 1fr) minmax(360px, 0.86fr); grid-template-rows:auto 1fr auto; min-height:100dvh; padding:132px var(--page-pad-x) 82px. The hero content starts in column 1, row 2, aligned to center with align-self:center and transform:translateY(-16px). The drain remains visible in column 2 around 66% viewport width.

Create an .eyebrow row above the heading. It contains a 6px accent status dot and the exact text “DATA INFRASTRUCTURE / LIVE”. Do not place the status dot inside the accessible name. Add one <h1> with the exact lines “Data moves.<br>Undertow knows<br>where it leads.” The h1 uses the h1 tokens, max-width:624px, margin:22px 0 28px, and text-wrap:balance.

Place this exact paragraph below the h1: “Undertow maps live data flow across your systems, reveals pressure before it becomes failure, and routes every workload toward a clearer path.” Set its exact width to var(--body-width), color:var(--color-muted), and margin:0 0 32px.

Create .actions as display:flex; gap:12px; align-items:center. The primary link is <a class="button button-primary" href="#contact">Explore the platform <span aria-hidden="true">→</span></a>. The secondary link is <a class="button button-secondary" href="#network">View the network</a>. Do not use disabled controls.

Create .proof as a semantic <ul> display:grid; grid-template-columns:repeat(3, max-content); gap:var(--proof-gap); margin:38px 0 0; padding:0; list-style:none. The three exact items are “99.999% / UPTIME TARGET”, “38 ms / MEDIAN ROUTE TIME”, and “2.4B / EVENTS / DAY”. Each li contains a .proof-value and a .proof-label. Do not label the values as observed results; they are product claims shown as landing-page content.

Build footer.site-footer as position:absolute; inset:auto var(--page-pad-x) 28px 0; z-index:30; display:grid; grid-template-columns:1fr auto 1fr; align-items:center; min-height:24px; font-size:var(--footer-size); letter-spacing:var(--footer-tracking); color:var(--color-faint). The left text is “© 2026 UNDERTOW”. The center is a 6px teal status dot with the visually hidden text “All systems operational”; do not attach an accessible name to a non-control span. The right link is <a href="mailto:hello@undertow.systems">hello@undertow.systems</a>, justified-self:end.

THE UI LANGUAGE

Style the wordmark as color:var(--color-copy), font-size:14px, font-weight:600, letter-spacing:0.18em, and text-decoration:none. Its hover color is var(--color-accent). Nav links are color:var(--color-muted), font-size:var(--nav-size), letter-spacing:var(--nav-tracking), text-decoration:none, and transition:color 180ms ease. Hover and focus-visible color is var(--color-accent-bright).

The header CTA has height:36px, padding:0 15px, display:inline-flex, align-items:center, gap:9px, border:1px solid rgba(94, 234, 212, 0.34), border-radius:999px, color:var(--color-accent), text-decoration:none, font-size:11px, letter-spacing:0.04em, background:rgba(11, 31, 32, 0.34). Hover transforms to translateY(-1px), changes border-color to rgba(94, 234, 212, 0.72), and changes color to var(--color-accent-bright).

The primary button has exact background:linear-gradient(110deg, #d8fffa 0%, #5eead4 54%, #3ccfba 100%); color:#05201d; border:1px solid rgba(216, 255, 250, 0.72); box-shadow:0 8px 30px rgba(24, 183, 164, 0.13), inset 0 1px 0 rgba(255, 255, 255, 0.55). Its hover transform is translateY(-2px); its shadow becomes 0 12px 36px rgba(24, 183, 164, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.72). Its active transform is translateY(0). The secondary button uses background:rgba(9, 17, 22, 0.62), border:1px solid var(--color-line), and color:var(--color-copy). Hover changes background to rgba(15, 34, 38, 0.82), border-color to rgba(94, 234, 212, 0.46), and transform to translateY(-2px).

All focus selectors use outline:var(--focus-width) solid var(--color-accent); outline-offset:var(--focus-offset). Do not remove the browser outline without replacing it. Transitions animate transform, opacity, color, border-color, background, and box-shadow; canvas particle motion is rendered inside canvas and does not trigger DOM layout.

MOTION

Use named keyframes named reveal-up, signal-pulse, and drain-breathe. reveal-up animates opacity from 0 to 1 and transform from translate3d(0, 18px, 0) to translate3d(0, 0, 0) over 720ms with var(--ease-out). signal-pulse animates opacity from 0.45 to 1 and transform from scale(0.82) to scale(1) over 1800ms ease-in-out. drain-breathe animates the static drain’s opacity from 0.88 to 1 and transform from scale(0.985) to scale(1) over 5200ms ease-in-out, alternate, infinite. Because the drain is part of a CSS pseudo-element, preserve its final opacity at 1 and set transform-origin:66% 51%.

Apply this exact stagger:

- header .wordmark -> reveal-up -> 80ms;
- primary nav -> reveal-up -> 150ms;
- header CTA -> reveal-up -> 220ms;
- .eyebrow -> reveal-up -> 300ms;
- h1 -> reveal-up -> 390ms;
- hero paragraph -> reveal-up -> 500ms;
- .button-primary -> reveal-up -> 610ms;
- .button-secondary -> reveal-up -> 670ms;
- .proof li:nth-child(1) -> reveal-up -> 760ms;
- .proof li:nth-child(2) -> reveal-up -> 820ms;
- .proof li:nth-child(3) -> reveal-up -> 880ms;
- footer -> reveal-up -> 980ms;
- .status-dot -> signal-pulse -> 1100ms;
- drain pseudo-element -> drain-breathe -> 0ms.

Add these exact animations:

- .wordmark, nav, .header-cta, .eyebrow, h1, .hero-copy p, .actions > *, .proof li, footer { animation:reveal-up var(--motion-ui) var(--ease-out) both; }
- .status-dot { animation:signal-pulse 1800ms ease-in-out 1100ms 2 both; }
- .vortex-drain { animation:drain-breathe 5200ms ease-in-out 0ms infinite alternate; }

Set every animated element’s resting opacity to 1. Do not leave content dependent on JavaScript for visibility. The JavaScript adds no entrance classes; the CSS animation is declarative and must reveal to opacity 1.

The canvas rAF loop is continuous only when reduced motion is not preferred. It increments telemetry.frames exactly once per callback. It must sustain at least 20 callbacks during any 1.2-second sample at 1440x900. Cache width, height, particle count, and particle array references outside the loop. Never call getBoundingClientRect, getComputedStyle, offsetWidth, offsetHeight, clientWidth, or clientHeight inside the loop.

THE RESPONSIVE TABLE

Apply these exact overrides:

At 1440x900 and 1920x1080:
- use all root tokens;
- --page-pad-x:100px;
- --page-pad-y:32px;
- --header-height:76px;
- --hero-max-width:624px;
- --h1-size:84px at 1920px and 76px at 1440px through clamp resolution;
- particle count:900;
- hero grid columns:minmax(0, 1fr) minmax(360px, 0.86fr);
- footer bottom:28px.

At 768x1024:
- --header-height:68px;
- --header-pad-x:32px;
- --page-pad-x:42px;
- --h1-size:64px;
- --h1-line:0.96;
- --body-width:560px;
- --proof-gap:28px;
- particle count:720;
- hero padding-top:112px;
- hero padding-bottom:88px;
- hero grid columns:minmax(0, 1fr) minmax(210px, 0.55fr);
- drain remains at 66% 51%;
- allow vertical document scroll.

At 600px maximum width:
- --header-height:64px;
- --header-pad-x:22px;
- --page-pad-x:24px;
- --h1-size:52px;
- --h1-line:0.98;
- --body-size:15px;
- --body-width:100%;
- --proof-gap:20px;
- --proof-value-size:17px;
- particle count:580;
- header nav display:none;
- header grid-template-columns:1fr auto;
- hero grid-template-columns:1fr;
- hero padding:112px 24px 78px;
- hero content transform:none;
- .actions flex-wrap:wrap;
- .proof grid-template-columns:repeat(3, minmax(0, 1fr));
- footer position:relative;
- footer inset:auto;
- margin:0 24px 26px;
- footer grid-template-columns:1fr auto;
- footer center display:none;
- allow vertical document scroll.

At 430px maximum width:
- --header-pad-x:18px;
- --page-pad-x:18px;
- --h1-size:44px;
- --h1-line:1.00;
- --h1-tracking:-0.045em;
- --body-size:15px;
- --body-line:1.58;
- --button-height:46px;
- --button-pad-x:17px;
- particle count:420;
- header CTA font-size:10px;
- header CTA padding-inline:12px;
- hero padding:96px 18px 64px;
- .actions display:grid;
- grid-template-columns:1fr;
- gap:10px;
- .actions .button width:100%;
- .proof grid-template-columns:1fr;
- gap:18px;
- .proof li display:grid;
- grid-template-columns:92px 1fr;
- align-items:baseline;
- footer margin-inline:18px;
- footer font-size:9px;

At 901px minimum width and 820px maximum height:
- --header-height:64px;
- --page-pad-x:56px;
- --h1-size:62px;
- --body-size:15px;
- hero padding-top:102px;
- hero padding-bottom:64px;
- .proof margin-top:26px;
- footer bottom:18px;
- particle count:760;
- retain single-viewport clipping.

At 900px maximum width and 720px maximum height:
- --header-height:60px;
- --h1-size:48px;
- --body-line:1.5;
- hero padding-top:88px;
- hero padding-bottom:64px;
- .proof margin-top:22px;
- particle count:560;
- allow vertical document scroll.

FAILURE GUARDS

Every non-canvas content element must have opacity:1 as its base state. CSS animation “both” states must finish at opacity:1 and transform:none. If canvas support is unavailable, keep the CSS gradient backdrop, drain, grain, vignette, and copy visible. The h1 and all navigation must remain usable with JavaScript disabled.

Use a two-rAF fallback. The first rAF is scheduled after setup. The second rAF marks telemetry.ready=true and starts the continuous particle loop only after the first frame has been committed. If the main animation rAF has not advanced after 1500ms, a setTimeout watchdog may call start once, guarded by a boolean, but it must not create a second loop. This two-rAF ordering prevents ready=true before first paint while guaranteeing the animation scheduler is active.

Under prefers-reduced-motion:reduce, remove all entrance, pulse, and drain animations with animation:none; force every animated element to opacity:1 and transform:none; stop the particle loop after rendering exactly one static frame; retain the full CSS backdrop and 1px particle field; set telemetry.frames to 1; leave telemetry.ready true. Buttons must have no movement on hover under reduced motion, changing only color or border-color. Do not call cancelAnimationFrame after a loop that was never started.

Add window error and unhandledrejection listeners that push human-readable strings into telemetry.errors. Wrap setup and animation callbacks in try/catch. On a catch, push the error message, set telemetry.ready=true, and stop scheduling further frames. Never write to console. The document must remain readable if WebGL, external fonts, or canvas APIs are unavailable.

THE EXACT JS

Use this exact script at the end of body. It has no dependencies and no network requests:

<script>
(() => {
  'use strict';
  const telemetry = window.__DEMO__ = { name: 'undertow', ready: false, frames: 0, effects: [], errors: [] };
  const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const canvas = document.querySelector('.vortex');
  const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
  const glow = document.createElement('canvas');
  const glowCtx = glow.getContext('2d', { alpha: true, desynchronized: true });
  const drain = { x: 0, y: 0 };
  let width = 0;
  let height = 0;
  let ratio = 1;
  let particles = [];
  let count = 900;
  let raf = 0;
  let running = false;
  let started = false;
  let last = 0;
  let time = 0;

  const pushError = value => {
    if (telemetry.errors.length < 20) telemetry.errors.push(String(value && value.message ? value.message : value));
  };

  window.addEventListener('error', event => pushError(event.error || event.message));
  window.addEventListener('unhandledrejection', event => pushError(event.reason));

  const makeParticle = (initial, i) => {
    const life = 5.4 + Math.random() * 5.4;
    const rim = Math.max(width, height) * (0.46 + Math.random() * 0.32);
    const angle = Math.random() * Math.PI * 2;
    let x;
    let y;
    if (initial) {
      x = drain.x + Math.cos(angle) * rim * (0.78 + Math.random() * 0.22);
      y = drain.y + Math.sin(angle) * rim * (0.56 + Math.random() * 0.22);
    } else {
      const biased = Math.random() < 0.72;
      const a = biased ? (Math.random() < 0.5 ? 0 : Math.PI) + (Math.random() - 0.5) * 2.2 : Math.random() * Math.PI * 2;
      x = drain.x + Math.cos(a) * rim;
      y = drain.y + Math.sin(a) * rim * 0.72;
    }
    const p = {
      x, y, previousX: x, previousY: y,
      life, age: initial ? Math.random() * life : 0,
      speedBias: 0.65 + Math.random() * 0.7,
      size: 0.7 + Math.random() * 0.55,
      hueMix: 0.38 + Math.random() * 0.62,
      phase: (i % 97) * 0.0647 + Math.random() * 0.3
    };
    return p;
  };

  const sizeCanvas = () => {
    width = window.innerWidth;
    height = window.innerHeight;
    ratio = Math.min(window.devicePixelRatio || 1, 1.5);
    canvas.width = Math.round(width * ratio);
    canvas.height = Math.round(height * ratio);
    canvas.style.width = width + 'px';
    canvas.style.height = height + 'px';
    ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
    const glowScale = 0.5;
    glow.width = Math.max(1, Math.round(canvas.width * glowScale));
    glow.height = Math.max(1, Math.round(canvas.height * glowScale));
    glowCtx.setTransform(glowScale, 0, 0, glowScale, 0, 0);
    drain.x = width * 0.66;
    drain.y = height * 0.51;
    count = width <= 430 ? 420 : width <= 600 ? 580 : width <= 900 ? 720 : 900;
    particles = new Array(count);
    for (let i = 0; i < count; i += 1) particles[i] = makeParticle(true, i);
    ctx.clearRect(0, 0, width, height);
  };

  const resetParticle = (p, i) => {
    const old = p;
    const next = makeParticle(false, i);
    p.x = next.x;
    p.y = next.y;
    p.previousX = next.x;
    p.previousY = next.y;
    p.life = next.life;
    p.age = 0;
    p.speedBias = next.speedBias;
    p.size = next.size;
    p.hueMix = next.hueMix;
    p.phase = next.phase;
  };

  const render = delta => {
    ctx.clearRect(0, 0, width, height);
    glowCtx.clearRect(0, 0, width, height);
    time += delta;
    const frameScale = Math.min(1.6, Math.max(0.35, delta / 16.667));
    for (let i = 0; i < count; i += 1) {
      const p = particles[i];
      p.previousX = p.x;
      p.previousY = p.y;
      const dx = drain.x - p.x;
      const dy = drain.y - p.y;
      const distance = Math.sqrt(dx * dx + dy * dy) || 1;
      const fieldA = Math.sin(p.y * 0.0062 + time * 0.00037 + p.phase);
      const fieldB = Math.sin(p.x * 0.0047 - time * 0.00029 - p.phase);
      const curl = Math.sin((p.x + p.y) * 0.0031 + time * 0.00021 + fieldA * 0.72) + fieldB * 0.42;
      const inward = Math.min(2.4, distance * 0.0022 * frameScale);
      const tangent = 0.19 * frameScale;
      p.x += (-dy / distance) * tangent + (dx / distance) * inward - p.x * 0.00001;
      p.y += (dx / distance) * tangent + (dy / distance) * inward - p.y * 0.00001;
      p.x += curl * 0.055 * p.speedBias * frameScale;
      p.y -= Math.abs(curl) * 0.035 * p.speedBias * frameScale;
      p.age += delta;
      const progress = p.age / p.life;
      const acceleration = Math.min(1, Math.max(0, (distance - 28) / Math.max(width, height) * 1.7));
      if (progress >= 1 || distance < Math.max(24, Math.min(width, height) * 0.032) || p.x < -80 || p.x > width + 80 || p.y < -80 || p.y > height + 80) {
        resetParticle(p, i);
        continue;
      }
      const lifeFade = Math.min(1, progress * 10, (1 - progress) * 7);
      const mix = acceleration * p.hueMix;
      const red = Math.round(25 + mix * 69);
      const green = Math.round(51 + mix * 183);
      const blue = Math.round(58 + mix * 194);
      const alpha = (0.09 + acceleration * 0.63) * Math.max(0, lifeFade);
      ctx.beginPath();
      ctx.moveTo(p.previousX, p.previousY);
      ctx.lineTo(p.x, p.y);
      ctx.strokeStyle = 'rgba(' + red + ',' + green + ',' + blue + ',' + alpha.toFixed(3) + ')';
      ctx.lineWidth = 1;
      ctx.stroke();
    }
    glowCtx.globalCompositeOperation = 'source-over';
    glowCtx.filter = 'blur(3.2px)';
    glowCtx.drawImage(canvas, 0, 0, width, height);
    glowCtx.filter = 'none';
    ctx.save();
    ctx.globalCompositeOperation = 'screen';
    ctx.globalAlpha = 0.34;
    ctx.drawImage(glow, 0, 0, width, height);
    ctx.restore();
  };

  const frame = now => {
    if (!running) return;
    try {
      const delta = last ? Math.min(40, now - last) : 16.667;
      last = now;
      render(delta);
      telemetry.frames += 1;
      raf = window.requestAnimationFrame(frame);
    } catch (error) {
      pushError(error);
      running = false;
      telemetry.ready = true;
    }
  };

  const start = () => {
    if (started || !ctx || !glowCtx) return;
    started = true;
    running = true;
    last = 0;
    telemetry.effects.push('vortex-particle-field', 'offscreen-glow-composite', 'interface-reveal');
    raf = window.requestAnimationFrame(frame);
    window.setTimeout(() => {
      if (!started) start();
    }, 1500);
  };

  try {
    sizeCanvas();
    window.addEventListener('resize', sizeCanvas, { passive: true });
    if (!ctx || !glowCtx) {
      canvas.style.display = 'none';
      telemetry.ready = true;
    } else if (reduced) {
      render(16.667);
      telemetry.frames = 1;
      telemetry.effects.push('vortex-particle-field', 'offscreen-glow-composite', 'static-reduced-motion');
      window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { telemetry.ready = true; }); });
    } else {
      window.requestAnimationFrame(() => {
        window.requestAnimationFrame(() => {
          start();
          telemetry.ready = true;
        });
      });
    }
  } catch (error) {
    pushError(error);
    canvas.style.display = 'none';
    telemetry.ready = true;
  }
})();
</script>

---

TECHNICAL CONTRACT (non-negotiable):

1. Deliver ONE single file: a complete, valid `index.html`. No build step, no bundler,
   no framework, no package install. It must open correctly by double-clicking the file
   and also over http://.
2. Vanilla only. No React, no Tailwind CDN, no GSAP, no Three.js, no jQuery, no Lottie.
   Plain HTML + CSS + modern JavaScript in <style> and <script> tags.
3. NO external media. No .mp4, no .webm, no .gif, no remote images, no base64 blobs of
   photos. Every visual must be generated procedurally with CSS gradients, SVG, or
   <canvas>. This keeps the page free to host and impossible to break with a dead link.
4. Google Fonts is the ONLY permitted network request, and it must be declared with a
   <link rel="preconnect"> plus a system-font fallback stack so the page still looks
   intentional offline. If you prefer, skip it and use a pure system stack.
5. Responsive and correct at 390x844 (phone), 768x1024 (tablet) and 1440x900 (desktop).
   No horizontal scrollbar at any of those widths. Nothing may overflow its container.
6. Accessibility: exactly one <h1>; real <header>/<main>/<footer>/<nav> landmarks; every
   interactive element is a <button> or <a> with an accessible name; visible :focus-visible
   ring; body text contrast ratio >= 4.5:1 against its background.
7. Motion safety: wrap all continuous motion in
   `@media (prefers-reduced-motion: reduce)` rules that reduce it to a static or
   near-static state. The page must remain fully readable and usable with motion off.
8. Performance: no layout thrash. Cache DOM reads, batch writes inside a single
   requestAnimationFrame loop, use transform/opacity for animation, and never allocate
   inside the hot loop. Target 60fps on a 2019 laptop. Total file under 90 KB.
9. Runtime telemetry hook (used by the automated verifier — implement it exactly):
   at the top of your script define
     window.__DEMO__ = { name: '<slug>', ready: false, frames: 0, effects: [], errors: [] };
   then set `ready = true` after the first paint of your main effect, increment
   `frames` once per animation frame inside your rAF loop, and push the name of each
   implemented effect into `effects`. Add a `window.addEventListener('error', ...)`
   handler that pushes messages into `errors`. Do not rename these fields.
10. Zero console output in the happy path. No console.log left behind. No uncaught
    exceptions, no failed network requests.

RESPONSE FORMAT:
Return ONLY the raw HTML inside a single ```html fenced code block. No explanation, no
preamble, no commentary before or after the fence. The file must be complete and
immediately runnable — never truncate, never write "// rest of code here".

Acceptance criteria (15)

  1. A headless browser reports document.title exactly as “Undertow — Data infrastructure in motion”, document.documentElement.lang as “en”, exactly one h1, and semantic header, nav, main, and footer elements.
  2. At 1440x900 and 1920x1080 the page has no vertical document scrollbar and no horizontal overflow, while at 390x844, 768x1024, and 600x900 all content remains inside the viewport width and the page remains readable with vertical scrolling.
  3. window.__DEMO__ has name “undertow”, ready is true after first paint, frames increases by at least 20 over a 1200ms interval at 1440x900, effects includes “vortex-particle-field” and “offscreen-glow-composite”, and errors remains empty.
  4. The visible canvas has 900 particle line segments on the initial desktop frame, the offscreen glow is composited once per frame, and no getBoundingClientRect or getComputedStyle call occurs inside the animation loop.
  5. With prefers-reduced-motion set to reduce, telemetry.frames equals 1, all content has opacity 1, no CSS entrance, pulse, or drain animation is active, and every visible link retains a non-transparent :focus-visible outline.
  6. window.__DEMO__.ready becomes true within 5s
  7. window.__DEMO__.frames advances (>= 20 frames in 1.2s)
  8. window.__DEMO__.errors is empty
  9. zero console errors and zero page errors
  10. zero failed network requests
  11. exactly one <h1> with non-empty text
  12. header, main, footer landmarks present
  13. no horizontal overflow at 390 / 768 / 1440 widths
  14. no horizontal overflow after 2s of animation
  15. prefers-reduced-motion: reduce leaves the page usable and errors empty

More landing page prompts