Landing Pagestealth/space-bunny-alphaverified 20/20partial cross-check
Headlight
A procedural night-city long-exposure canvas that evolves continuously behind a bottom-anchored Nocturne ride-service interface.
The exact prompt
A. WHAT IS BEING BUILT
Build one complete single-file index.html for a cinematic landing page titled “Headlight”. Set the document language to lang="en", use the document title “Headlight — Nocturne”, and include the viewport meta tag with width=device-width, initial-scale=1, viewport-fit=cover. The page is a night-city ride-service brand presentation for Nocturne, operating only from 23:00 through 05:00.
At viewport widths of 901px and wider, html, body, and the app shell must be exactly 100dvh high, with overflow:hidden. There is no page scroll at desktop sizes. At widths of 900px and narrower, the document may scroll vertically, but it must never scroll horizontally. The canvas is a visual background, not a video. All visible content remains readable when animation is reduced.
Use semantic real page structure: one header, one main, one footer, and one navigation region. There must be exactly one visible h1. Every visible interactive element must have an accessible name through visible text or aria-label. Every focusable control must have a visible :focus-visible ring.
The design direction is editorial, restrained, and cinematic: a black road surface, accumulated sodium-amber traffic trails, sparse red brake-light interruptions, and cool white-blue headlight accents. The interface is left-heavy and typographic, with the copy anchored in the lower-left quadrant and a single CTA pill anchored in the lower-right. The visual should feel like a long-exposure photograph taken from a moving car, not a gradient or neon particle demo.
Do NOT build a video element, image element, remote asset, base64 asset, third-party CDN, React, Tailwind, GSAP, Three.js, Lottie, WebGL renderer, audio player, cookie banner, modal, authentication flow, map, pricing calculator, route planner, or multi-page navigation. Do NOT use an external font request. Use only CSS gradients, inline SVG, or canvas. Do NOT add a second h1. Do NOT make the lower third visually noisy. Do not use horizontal scrolling. Do not add decorative controls without an accessible name. Do not use ctx.filter or ctx.shadowBlur inside any per-particle loop.
B. THE BACKDROP
Create one black canvas with id="traffic-canvas", positioned as a fixed full-viewport layer. Use a black background matching #050507. The canvas is decorative with aria-hidden="true", but it must remain the principal visual backdrop.
The procedural particle system contains exactly 140 particles. Seed the particle data once at startup with a deterministic seeded pseudo-random generator using seed 27041986. Each particle has a normalized x position, normalized y position, velocity, length, width, alpha, hue, and phase. No particle is recreated after startup. Seed the positions so 104 particles use sodium amber, 39 particles use brake red, and 17 particles use headlight blue-white, producing 74.3% amber, 27.9% red, and 12.1% blue-white within rounding tolerance.
The advection field is:
a = sin(x * 0.004 + t * 0.2) * cos(y * 0.003)
Treat x and y as canvas pixel coordinates. Convert the scalar angle-like value into a cheap velocity rotation. Each frame, calculate vx = cos(a) * speed and vy = sin(a) * speed, where speed varies by particle from 0.42 to 1.18 pixels per frame. Add a small forward velocity of 0.24 to 0.62 pixels per frame in the x direction. Keep particles in an infinite conceptual field by wrapping x from -40 to width + 40 and y from -40 to height + 40.
Each particle must leave a 6px to 34px trail by stamping points into an offscreen buffer. Create one offscreen canvas with the same backing dimensions as the visible canvas. Every frame, fade the offscreen buffer to 6% opacity rather than clearing it. Implement the fade by filling the buffer with rgba(5,5,7,0.06) using source-over. Then draw all particle stamps with globalCompositeOperation = "lighter". Each particle contributes a 3px vertical smear: draw its previous and current positions with a 3px-wide rounded line or three adjacent alpha-weighted stamps. Trail alpha is 0.12 to 0.42. Hues are restricted to 18deg through 42deg for sodium trails, 358deg through 8deg for brake red, and 204deg through 222deg for headlight blue. Do not use a rainbow palette.
The visible canvas composites the offscreen buffer onto a CSS-filtered canvas with filter: blur(0.4px). The canvas element is not allowed to use a larger blur. The offscreen buffer is not allowed to use ctx.filter or ctx.shadowBlur. Composite the buffer onto the visible canvas with globalCompositeOperation = "lighter" and globalAlpha controlled by the exposure ramp.
Place exactly two oversized radial-gradient glow fields behind the canvas. Use one 500px circle with center rgba(42,22,8,0.42), transparent edge rgba(42,22,8,0), placed left:18%, top:24%, and one 500px circle with center rgba(10,20,32,0.34), transparent edge rgba(10,20,32,0), placed right:12%, bottom:8%. Each uses filter: blur(70px). These are static CSS layers at z-index 0, not animated particles. They create road-surface value without competing with the type.
At 60fps, apply a 6% per-frame buffer fade, equivalent to a visible tail of approximately 16 frames. Do not use a hard reset, timed scene loop, or particle respawn sequence. The field is continuously evolving and must not visibly repeat. Apply cursor parallax to the entire canvas container: translate it by a maximum of 10px in x and 10px in y toward the pointer. Use a 0.04 lerp per frame. Pointer values are normalized from the viewport center and clamped to [-1,1].
For the canvas backing resolution, use a maximum device pixel ratio of 1.5. Set backing width to Math.round(clientWidth * dpr), backing height to Math.round(clientHeight * dpr), cap the total backing pixel count at 2,500,000, and reduce dpr to 1 if the cap would be exceeded. The offscreen buffer uses the same dimensions. Set canvas.width and canvas.height only on resize, never inside the animation loop.
The per-frame cost budget is 140 particle updates, 140 current-position stamps, 140 previous-position stamps, one buffer fade fill, and one visible-buffer composite. Use no more than 420 draw operations per frame. Cache the canvas contexts, dimensions, particle objects, color strings, and alpha values before the loop. Never call getBoundingClientRect inside rAF. The rAF loop must sustain at least 20 frames per 1.2 seconds at 1440x900 headless desktop resolution. Use transform and opacity for DOM motion. The canvas drawing itself may update pixels, but all DOM animation must be transform/opacity only.
C. THE LAYER STACK
Use these exact z-index values from back to front:
z-index 0: the two static glow fields inside .road-glows.
z-index 1: the black canvas layer .canvas-wrap, containing the traffic canvas and its exposure overlay.
z-index 2: a full-viewport vignette .vignette with transparent center and rgba(0,0,0,0.52) edges.
z-index 3: a fine grain layer .grain using a CSS repeating radial-gradient pattern at opacity 0.045, pointer-events:none.
z-index 4: the semantic page shell .site-shell, containing header, main, and footer.
z-index 5: focus rings and dialogs if any, although no dialog is required.
z-index 6: the bottom-right CTA cluster .cta-cluster.
The canvas-wrap must have inset:-12px so its ±10px parallax never exposes an unpainted edge. The canvas itself must be width:100%, height:100%, display:block. The page shell must use position:relative and pointer-events:none for decorative regions only; all text and controls must receive pointer-events:auto.
D. TOKEN TABLE
Define every visual size and color as CSS custom properties on :root:
--bg: #050507;
--ink: #F4F0E8;
--muted: #A39D92;
--dim: #6F6A63;
--amber: #FFB347;
--red: #FF5E3A;
--blue: #7FB2FF;
--line: rgba(244,240,232,0.18);
--line-strong: rgba(244,240,232,0.34);
--panel: rgba(5,5,7,0.34);
--focus: #F4F0E8;
--max-width: 1440px;
--content-x: 72px;
--header-height: 82px;
--footer-height: 34px;
--hero-top: 26vh;
--hero-bottom: 0px;
--hero-gap: 34px;
--type-display: clamp(54px, 7.2vw, 128px);
--type-body: 16px;
--type-small: 12px;
--type-label: 11px;
--button-height: 52px;
--button-padding-x: 24px;
--radius-pill: 999px;
--radius-small: 8px;
--canvas-blur: 0.4px;
--glow-blur: 70px;
--glow-size: 500px;
--parallax-max: 10px;
--exposure-duration: 1400ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-smooth: cubic-bezier(0.22, 1, 0.36, 1);
Use the system font stack: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, “Segoe UI”, sans-serif. Inter may be used only if already installed; no Google Fonts request is needed.
E. THE COMPOSITION
Build the page in this order.
First, add the static backdrop elements inside a div.road-backdrop. Add two .glow elements with exact 500px dimensions and blur(70px), then the canvas-wrap and vignette. The canvas-wrap receives the canvas and an absolutely positioned .exposure-wash with background:#050507 and opacity:0.
Second, create header.site-header with position:absolute, top:0, left:var(--content-x), right:var(--content-x), height:var(--header-height), display:flex, align-items:center, justify-content:space-between, z-index:4. The left brand is a link or span with the exact visible text “NOCTURNE” and aria-label “Nocturne home”. Set it to font-size:13px, font-weight:700, letter-spacing:0.24em, color:var(--ink). The right nav contains exactly two visible links: “The service” and “Safety”. Both must have href="#service” and href="#safety” respectively, with accessible names, color:var(--muted), font-size:var(--type-small), and a 22px horizontal gap.
Third, create main#service.hero. Position it as a full-height flex container using display:grid, grid-template-columns:minmax(0, 1fr) auto, grid-template-rows:1fr auto, align-items:end, column-gap:var(--hero-gap), row-gap:24px, padding:0 var(--content-x) 62px. The hero must sit above the footer and leave the lower third visually readable. The copy block is grid-column:1, grid-row:2, max-width:700px. The CTA cluster is grid-column:2, grid-row:2, align-self:end, padding-bottom:4px.
The copy block contains a small eyebrow with exact text “NIGHT SERVICE / 23:00—05:00”, followed by exactly one h1 with the exact text “Take the long way home.” Set h1 margin:16px 0 22px, max-width:700px, color:var(--ink), font-size:var(--type-display), line-height:0.91, font-weight:650, letter-spacing:-0.065em. Add a paragraph with exact text “Nocturne moves you through the sleeping city with a driver, a quiet cabin, and the road already awake.” Set max-width:410px, margin:0, color:var(--muted), font-size:var(--type-body), line-height:1.55.
Fourth, add a service-status strip above the CTA or directly below the paragraph with exact text “LIVE WINDOW · NEXT DEPARTURE 23:18” and a small 7px amber status dot. The strip must be inline-flex, gap:10px, align-items:center, height:30px, margin-top:26px, border-top:1px solid var(--line), border-bottom:1px solid var(--line), color:var(--muted), font-size:var(--type-label), letter-spacing:0.14em.
The CTA cluster contains one primary pill button with exact visible text “Book a Nocturne” and aria-label “Book a Nocturne ride”. It is 52px high, padding 0 24px, border-radius:999px, and uses a horizontal gradient from #FFB347 to #FFD27A. Text is #050507, font-size:13px, font-weight:750. It must be a real button or link. Beside it, place one quiet text link with exact text “See how it works”, href="#how-it-works”, color:var(--ink), font-size:13px, and underline offset:5px. The CTA cluster is display:flex, align-items:center, gap:20px.
Sixth, create footer.site-footer with position:absolute, left:var(--content-x), right:var(--content-x), bottom:0, height:var(--footer-height), display:flex, align-items:center, justify-content:space-between, color:var(--dim), font-size:10px, letter-spacing:0.1em. Left text: “NOCTURNE / CITY TRANSIT AFTER DARK”. Right text: “23:00—05:00 / 365 NIGHTS”. Do not add another navigation control.
The darkest quadrant is the lower-left. The particle field must be biased toward y positions between 4% and 62% of the canvas height. Exactly 24 particles may enter the lower 38% at reduced alpha, but no particle may be rendered over the h1 at opacity above 0.18. Apply a CSS linear-gradient from rgba(5,5,7,0.04) at 42% to rgba(5,5,7,0.84) at 78% as a readability scrim inside the page shell.
F. THE UI LANGUAGE
The primary button uses background:linear-gradient(100deg, #FFB347 0%, #FFC76B 52%, #FFD98C 100%), border:1px solid rgba(255,235,190,0.72), box-shadow:0 8px 26px rgba(255,179,71,0.18). Its resting transform is translate3d(0,0,0). On hover, change transform to translate3d(0,-2px,0), increase box-shadow to 0 12px 30px rgba(255,179,71,0.28), and change the gradient endpoint to #FFE1A2. On active, translate to translate3d(0,1px,0) and use box-shadow:none. The transition is transform 180ms var(--ease-out), box-shadow 180ms var(--ease-out), background 180ms linear.
The quiet link rests with color:#F4F0E8 and opacity:0.78. On hover, opacity:1 and color:#FFB347. The transition is opacity 160ms linear, color 160ms linear. All links have text-decoration:none except the quiet link, which uses text-decoration:underline.
Buttons and links use border-radius:999px or the defined small radius. The visible focus state is outline:2px solid var(--focus), outline-offset:4px, with a 1px dark inner contrast ring. Do not remove native focus styles without replacement. The cursor is default except for links and buttons, where cursor:pointer is used.
G. MOTION
Use CSS keyframes named exposure-ramp, copy-rise, eyebrow-rise, cta-rise, and status-pulse. The canvas exposure begins at opacity:0 and reaches opacity:1 over 1400ms using exposure-ramp from opacity:0 to opacity:1. The canvas-wrap itself translates from translate3d(-10px, 6px, 0) to translate3d(0,0,0), but pointer parallax writes inline transform on the same element after the initial animation. To prevent conflict, put the entrance animation on .canvas-inner and the parallax transform on .canvas-wrap.
The DOM entrance sequence is:
- header: opacity 0→1, transform translate3d(0,-10px,0)→translate3d(0,0,0), duration 700ms, delay 80ms, easing var(--ease-out).
- eyebrow: opacity 0→1, transform translate3d(0,12px,0)→translate3d(0,0,0), duration 620ms, delay 260ms.
- h1: opacity 0→1, transform translate3d(0,24px,0)→translate3d(0,0,0), duration 900ms, delay 340ms.
- paragraph: opacity 0→1, transform translate3d(0,18px,0)→translate3d(0,0,0), duration 720ms, delay 520ms.
- status strip: opacity 0→1, transform translate3d(0,10px,0)→translate3d(0,0,0), duration 600ms, delay 720ms.
- CTA cluster: opacity 0→1, transform translate3d(14px,0,0)→translate3d(0,0,0), duration 720ms, delay 620ms.
- footer: opacity 0→1, transform translate3d(0,8px,0)→translate3d(0,0,0), duration 600ms, delay 900ms.
- canvas: exposure-ramp 1400ms, delay 0ms.
- status dot: status-pulse 1800ms ease-in-out infinite, scaling 0.82 to 1.18 and opacity 0.55 to 1.
Use the following named keyframes exactly:
@keyframes exposure-ramp { from { opacity: 0; } to { opacity: 1; } }
@keyframes copy-rise { from { opacity: 0; transform: translate3d(0,24px,0); } to { opacity: 1; transform: translate3d(0,0,0); } }
@keyframes eyebrow-rise { from { opacity: 0; transform: translate3d(0,12px,0); } to { opacity: 1; transform: translate3d(0,0,0); } }
@keyframes cta-rise { from { opacity: 0; transform: translate3d(14px,0,0); } to { opacity: 1; transform: translate3d(0,0,0); } }
@keyframes status-pulse { 0%,100% { opacity: 0.55; transform: scale(0.82); } 50% { opacity: 1; transform: scale(1.18); } }
The JavaScript animation loop continuously updates the particle buffer, exposure, and pointer parallax. Effects pushed into telemetry must be exactly “field-advection”, “trail-decay”, “cursor-parallax”, and “exposure-ramp”. Increment window.__DEMO__.frames once for every rAF callback.
H. THE RESPONSIVE TABLE
At 1920x1080, set --content-x:96px, --type-display:128px, --hero-bottom:0px, --button-height:52px. Keep the desktop grid as minmax(0,1fr) auto.
At 1440x900, set --content-x:72px, --type-display:104px, --header-height:82px, --hero-bottom:0px, and hero padding-bottom:62px.
At 768x1024, set --content-x:36px, --header-height:70px, --type-display:76px, --hero-bottom:0px, and --button-height:50px. Change .hero to grid-template-columns:1fr, grid-template-rows:1fr auto auto, padding-bottom:48px. Copy occupies grid-column:1 and grid-row:2. The CTA cluster occupies grid-column:1, grid-row:3, justify-self:start, padding-bottom:0. The canvas glow positions become left:8%, top:18% and right:2%, bottom:18%. Allow vertical document scrolling because 768 is below 901px.
At 390x844, set --content-x:22px, --header-height:62px, --type-display:58px, --type-body:15px, --type-small:11px, --button-height:50px, --button-padding-x:20px, and --hero-gap:22px. Hide the footer’s left text while keeping the right operating-hours text. Header navigation gap is 14px. The CTA cluster is width:100%, flex-direction:column, align-items:flex-start, gap:14px. The h1 max-width:350px, line-height:0.94, margin-bottom:18px. The paragraph max-width:335px. The status strip width:100%, font-size:9px, letter-spacing:0.1em. Canvas glow size is 360px, blur is 58px, and the glow positions are left:-90px, top:18% and right:-120px, bottom:12%. Keep all content inside the viewport width and allow vertical scrolling.
Add a max-height:760px desktop override for widths 901px and above. Reduce --header-height to 64px, --type-display to 72px, --hero-bottom to 0px, hero padding-bottom to 42px, and h1 margin-top to 12px. Reduce paragraph line-height to 1.4 and button height to 46px. Keep the bottom third clear.
Add a max-height:620px desktop override for widths 901px and above. Reduce --header-height to 54px, --type-display to 58px, --hero-bottom to 0px, hero padding-bottom to 30px, h1 margin:10px 0 14px, and footer height to 24px. Hide the paragraph only if its measured content would extend below 30px from the viewport bottom; otherwise preserve it. No CSS overflow-x is allowed at any breakpoint.
I. FAILURE GUARDS
Every animated DOM element must have a resting opacity:1 rule after its entrance animation, so a failed CSS animation cannot leave invisible copy. The canvas wrapper must default to opacity:1, and the exposure wash must default to opacity:1 after the ramp. Do not use an animation-fill-mode that permanently leaves an element hidden.
Implement a two-rAF fallback. After DOMContentLoaded, schedule the first animation frame with requestAnimationFrame(begin), and schedule a second with requestAnimationFrame(begin) only if the first callback did not start the loop. The begin function must be idempotent, guarded by a boolean named loopStarted. If requestAnimationFrame is unavailable, start a static seeded frame once, set exposure to 1, set window.__DEMO__.ready = true after first paint, and do not schedule further frames.
If ResizeObserver is unavailable, use a cached window resize listener. Resize handling may read client dimensions and update canvas dimensions outside the rAF loop. During resize, preserve the previous offscreen image by copying it into a temporary canvas at the old size and drawing it into the new buffer with globalAlpha 0.55. Debounce resize work by 120ms.
Under prefers-reduced-motion: reduce, remove all entrance animations, stop cursor parallax, and render one static seeded canvas frame with exposure 1. Keep the page fully readable. Do not stop semantic controls or hover focus states. Set the animation loop to a static mode and do not increment frames after the first static frame.
J. THE EXACT JS
The script must be plain JavaScript placed at the end of body. It must use this structure and behavior:
const demo = window.__DEMO__ = { name: "headlight", ready: false, frames: 0, effects: [], errors: [] };
window.addEventListener("error", event => { demo.errors.push(String(event.message || "Unknown error")); });
const canvas = document.getElementById("traffic-canvas");
const context = canvas.getContext("2d", { alpha: true });
const buffer = document.createElement("canvas");
const bufferContext = buffer.getContext("2d", { alpha: true });
const wrap = document.querySelector(".canvas-wrap");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
let width = 0, height = 0, dpr = 1, bufferWidth = 0, bufferHeight = 0;
let pointerX = 0, pointerY = 0, targetX = 0, targetY = 0, currentX = 0, currentY = 0;
let loopStarted = false, running = true, lastTime = 0, elapsed = 0, exposure = 0;
let seed = 27041986;
const random = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const particles = Array.from({ length: 140 }, (_, index) => {
const group = index < 39 ? "red" : index < 56 ? "blue" : "amber";
const color = group === "red" ? "255,94,58" : group === "blue" ? "127,178,255" : "255,179,71";
return { x: random() * Math.max(window.innerWidth, 1), y: random() * Math.max(window.innerHeight * 0.62, 1), px: 0, py: 0, speed: 0.42 + random() * 0.76, forward: 0.24 + random() * 0.38, length: 6 + random() * 28, alpha: 0.12 + random() * 0.30, hue: group === "red" ? 358 + random() * 10 : group === "blue" ? 204 + random() * 18 : 18 + random() * 24, color, group };
});
particles.forEach(particle => { particle.px = particle.x; particle.py = particle.y; });
function resize() {
width = window.innerWidth;
height = window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 1.5);
if (width * height * dpr * dpr > 2500000) dpr = 1;
bufferWidth = canvas.width = Math.round(width * dpr);
bufferHeight = canvas.height = Math.round(height * dpr);
buffer.width = bufferWidth;
buffer.height = bufferHeight;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
bufferContext.setTransform(dpr, 0, 0, dpr, 0, 0);
bufferContext.fillStyle = "#050507";
bufferContext.fillRect(0, 0, width, height);
}
function frame(time) {
if (!running) return;
if (!lastTime) lastTime = time;
const delta = Math.min(32, time - lastTime);
lastTime = time;
elapsed += delta * 0.0166667;
exposure = Math.min(1, exposure + delta / 1400);
currentX += (targetX - currentX) * 0.04;
currentY += (targetY - currentY) * 0.04;
wrap.style.transform = "translate3d(" + (currentX * 10).toFixed(2) + "px," + (currentY * 10).toFixed(2) + "px,0)";
bufferContext.globalCompositeOperation = "source-over";
bufferContext.fillStyle = "rgba(5,5,7,0.06)";
bufferContext.fillRect(0, 0, width, height);
bufferContext.globalCompositeOperation = "lighter";
for (const particle of particles) {
const angle = Math.sin(particle.x * 0.004 + elapsed * 0.2) * Math.cos(particle.y * 0.003);
const vx = Math.cos(angle) * particle.speed + particle.forward;
const vy = Math.sin(angle) * particle.speed;
particle.px = particle.x;
particle.py = particle.y;
particle.x += vx;
particle.y += vy;
if (particle.x < -40) particle.x = width + 40;
if (particle.x > width + 40) particle.x = -40;
if (particle.y < -40) particle.y = height + 40;
if (particle.y > height + 40) particle.y = -40;
const lowerFade = particle.y > height * 0.62 ? Math.max(0.18, 1 - (particle.y - height * 0.62) / (height * 0.38)) : 1;
const alpha = particle.alpha * lowerFade;
bufferContext.strokeStyle = "hsla(" + particle.hue + ",100%,68%," + alpha.toFixed(3) + ")";
bufferContext.lineWidth = 3;
bufferContext.lineCap = "round";
bufferContext.beginPath();
bufferContext.moveTo(particle.px, particle.py);
bufferContext.lineTo(particle.x, particle.y);
bufferContext.stroke();
}
context.globalCompositeOperation = "source-over";
context.clearRect(0, 0, width, height);
context.globalCompositeOperation = "lighter";
context.globalAlpha = exposure;
context.drawImage(buffer, 0, 0, width, height);
context.globalAlpha = 1;
context.globalCompositeOperation = "source-over";
demo.frames += 1;
if (exposure >= 1 && demo.effects.indexOf("exposure-ramp") === -1) demo.effects.push("exposure-ramp");
if (demo.effects.indexOf("field-advection") === -1) demo.effects.push("field-advection");
if (demo.effects.indexOf("trail-decay") === -1) demo.effects.push("trail-decay");
if (demo.effects.indexOf("cursor-parallax") === -1) demo.effects.push("cursor-parallax");
if (demo.frames === 1) requestAnimationFrame(() => { demo.ready = true; });
requestAnimationFrame(frame);
}
function begin() {
if (loopStarted) return;
loopStarted = true;
if (reduceMotion.matches) {
elapsed = 0;
exposure = 1;
bufferContext.fillStyle = "#050507";
bufferContext.fillRect(0, 0, width, height);
particles.forEach(particle => {
const angle = Math.sin(particle.x * 0.004) * Math.cos(particle.y * 0.003);
particle.px = particle.x - Math.cos(angle) * particle.length;
particle.py = particle.y - Math.sin(angle) * particle.length;
bufferContext.strokeStyle = "hsla(" + particle.hue + ",100%,68%," + particle.alpha.toFixed(3) + ")";
bufferContext.lineWidth = 3;
bufferContext.lineCap = "round";
bufferContext.beginPath();
bufferContext.moveTo(particle.px, particle.py);
bufferContext.lineTo(particle.x, particle.y);
bufferContext.stroke();
});
context.globalCompositeOperation = "lighter";
context.drawImage(buffer, 0, 0, width, height);
demo.effects.push("field-advection", "trail-decay", "cursor-parallax", "exposure-ramp");
demo.frames = 1;
demo.ready = true;
return;
}
requestAnimationFrame(frame);
}
window.addEventListener("pointermove", event => {
targetX = Math.max(-1, Math.min(1, event.clientX / width * 2 - 1));
targetY = Math.max(-1, Math.min(1, event.clientY / height * 2 - 1));
}, { passive: true });
window.addEventListener("resize", resize, { passive: true });
resize();
requestAnimationFrame(begin);
requestAnimationFrame(begin);
The script must not log to the console. It must not throw when pointer events, ResizeObserver, rAF, or canvas contexts are unavailable. If a context is unavailable, leave the CSS backdrop and readable content visible, set ready to true after first paint, and record no uncaught exception.
---
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)
- The output is one valid index.html with no external media or framework dependencies, contains exactly one h1, and has real header, main, footer, and nav landmarks.
- At 1440x900, html, body, and the app shell are 100dvh high with overflow:hidden; at 390x844, 768x1024, 1440x900, and 1920x1080, document scrollWidth never exceeds clientWidth.
- The runtime object exists as window.__DEMO__ with name headlight, ready becomes true after first paint, frames increments once per rAF callback, effects contains the four named effects, and errors captures window errors.
- The canvas starts with exactly 140 seeded particles, uses an offscreen 6-percent fading buffer, lighter compositing, 3px vertical stamps, hue values between 18 and 42 degrees for sodium particles, and never calls ctx.filter or ctx.shadowBlur in the particle loop.
- With prefers-reduced-motion: reduce, the page remains readable with all text at opacity 1, the canvas renders a static seeded frame, cursor parallax is disabled, and no repeating rAF loop is scheduled.
- window.__DEMO__.ready becomes true within 5s
- window.__DEMO__.frames advances (>= 20 frames in 1.2s)
- window.__DEMO__.errors is empty
- zero console errors and zero page errors
- zero failed network requests
- exactly one <h1> with non-empty text
- header, main, footer landmarks present
- no horizontal overflow at 390 / 768 / 1440 widths
- no horizontal overflow after 2s of animation
- prefers-reduced-motion: reduce leaves the page usable and errors empty