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

Tally

A full-viewport harbour LED lattice whose mint, slate, and amber pixels behave like a live departure board beneath a deliberately asymmetric navigation layer.

The exact prompt

A. WHAT IS BEING BUILT

Build one complete, dependency-free index.html for Northlight, a regional ferry and harbour operator publishing live sailings, tide windows, and berth status. The page is a single-viewport cinematic landing page whose viewport is itself the departure display. Set the document title to “Northlight — Live Harbour Departures”, set the document language to en-GB, and include a concise meta description: “Live ferry sailings, tide windows, and berth departures from Northlight Harbour.” Add a width=device-width, initial-scale=1 viewport meta tag.

The desktop canvas must occupy the entire 100dvh viewport. At viewport widths of 901px or greater, set html and body to width:100%, height:100dvh, overflow:hidden. At widths of 900px or less, allow vertical document scrolling with overflow-x:hidden. This is not a scroll piece: no section may induce a second page or horizontal scrollbar. The layout must remain usable at 390×844, 768×1024, 1440×900, and 1920×1080.

Use semantic page structure: one header, one main, one footer, and one navigation. Include exactly one h1. Every visible link, button, and table control must have an accessible name. Every keyboard-focusable control must have a visible focus-visible ring. Do not build a video, image, photograph, card, glass panel, frosted surface, decorative border, modal, cookie banner, account menu, route planner, map, ticket basket, or secondary content section. Do not use React, Tailwind, GSAP, Three.js, Lottie, external media, remote images, base64 assets, third-party scripts, or a third-party CDN. Google Fonts is the only permitted network request; load “IBM Plex Mono” at weights 400, 500, and 600 and provide ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, “Liberation Mono”, monospace as the fallback stack. If the font request fails, the page must remain fully readable.

B. THE BACKDROP

The only visual media is one full-bleed canvas element with id="tally-canvas". It contains exactly 96 columns and 40 rows, for exactly 3,840 lattice points. The coordinate-space pitch is exactly 10px, producing a 960×400 source lattice. Keep the canvas at position:fixed with inset:0, width:100vw, and height:100dvh. Scale the source coordinate space to the viewport with the browser’s canvas presentation size. This low-resolution composition is deliberate: use a 960×400 backing surface rather than multiplying it by devicePixelRatio, and let CSS scale it to the viewport. This keeps all dot circles smooth enough at the chosen radii while limiting per-frame work.

For dot x from 0 through 95 and y from 0 through 39, draw at cx=5+x*10 and cy=5+y*10. At time t in seconds, compute:

base = 1.1 + 2.4 * abs(sin(x * 0.09 + t * 0.7) * sin(y * 0.11 - t * 0.5))

Unlit dots use #5A6B72 at globalAlpha 0.12. A dot is lit when its computed normalized wave energy is at least 0.42. Normalize the sine-product contribution as clamp((abs(product)-0.18)/0.82, 0, 1). Mint-lit dots use #7DF9C6 at globalAlpha 0.52 + 0.48*energy. Three specified source columns, x equal to 21, 63, and 88, are berth columns. Within those columns, rows 0 through 39 are amber only during the live-departure state. At t below 9 seconds, columns 21 and 63 use the normal mint/slate calculation. At t from 9 through 10.2 seconds, column 88 alone receives one hard amber flash: its rows progress from row 0 at 9.00 seconds to row 39 at 10.20 seconds, each row holding #FFB020 at globalAlpha 1 for 80ms and returning to mint for the remainder of the column event. Before 9 seconds, mark the three berth states in the UI as “DEPARTING NOW” while the canvas remains mint; the canvas flash is a liveness proof, not a change in the semantic status.

Render every dot in the first pass with ctx.beginPath(), ctx.arc(cx, cy, radius, 0, Math.PI*2), and ctx.fill(). Use a reusable Path2D? No: direct arcs in one first-pass loop are acceptable because 3,840 small paths remain below budget. Reset globalCompositeOperation to source-over before this pass.

The bloom is a second full-lattice pass. For every lit mint or amber dot, draw a second circle with radius multiplied by exactly 2.6, globalAlpha multiplied by 0.34, and ctx.fillStyle equal to that dot’s live colour. Set ctx.globalCompositeOperation to "lighter". Set ctx.filter exactly once before this second pass to "blur(6px)", and reset it to "none" immediately after the pass. Never assign ctx.filter or ctx.shadowBlur inside an individual dot loop. The 6px source-space blur becomes a visible 9–18px presentation-space bloom at tested desktop aspect ratios. This physical halo is the defining trick.

Add a pointer hotspot with a 220px coordinate-space radial influence. Cache pointer client coordinates in pointerX and pointerY. Convert them from viewport coordinates to canvas coordinates using 960/innerWidth and 400/innerHeight. On pointer movement, calculate a maximum per-frame hotspot delta of 0.08. In the animation loop, hotspotX += (targetX-hotspotX)*0.08 and hotspotY += (targetY-hotspotY)*0.08. Add 1.8 multiplied by falloff to the radius sum for every dot, where falloff=max(0, 1-distance/220), then square the falloff. Initial hotspotX=480 and hotspotY=200. Pointer entry sets the target; pointer leave returns the target to 480,200. The resulting visual settle is approximately 250ms at 60fps.

The wave has a 22-second visual period. It runs continuously with t elapsed in seconds; the supplied sine coefficients create interference movement across the fixed 10-unit coordinate pitch. No dot changes x or y position. Only radius, fill colour, and opacity change.

Implement the load wipe by increasing revealY from -1 to 40 over 560ms. Each source row becomes visible when revealY is at least row-1. Interpolate the reveal boundary with a 10-unit feather. This is equivalent to top-to-bottom row activation at 14ms per row. After 560ms, animate a separate waveOpacity from 0 to 1 over exactly 400ms using an ease-out cubic. Before waveOpacity begins, rows may reach full slate opacity only. At 960ms, all rows and the mint wave are fully visible.

The per-frame cost budget is 3,840 first-pass arc fills plus no more than 1,650 second-pass lit fills. Precompute the 3,840 center coordinates, row reveal states, berth membership, and base sine factors outside the rAF loop. Calculate the shared time terms and hotspot positions once per frame. Do not call getBoundingClientRect, query selectors, read layout-affecting CSS, create gradients, create paths unrelated to the dot arcs, set canvas width, set canvas height, or resize the canvas inside the loop. Do not use ctx.shadowBlur. Do not call the 6px filter setter more than twice per frame. The main canvas dimensions and offscreen runtime resources are initialized before animation starts. The target budget is 4,000–5,500 path fills per frame and no more than 16ms of main-thread work on a 1440×900 headless browser for at least 20 frames in every 1.2-second sample.

C. THE LAYER STACK

The full-viewport stack, back to front, is:

z-index:0 — body background #04070A.
z-index:1 — fixed #tally-canvas, opacity controlled by the wave reveal only.
z-index:2 — a non-interactive top safety veil using a linear-gradient from rgba(4,7,10,0.84) at 0%, rgba(4,7,10,0.38) at 12%, rgba(4,7,10,0) at 28%, rgba(4,7,10,0) at 69%, rgba(4,7,10,0.50) at 100%. It is decorative and pointer-events:none.
z-index:3 — semantic header, position:fixed, inset:0 0 auto 0.
z-index:4 — main hero copy and status actions, position:fixed or absolute inside the viewport.
z-index:5 — footer metadata rail, anchored to the bottom.
z-index:6 — skip link, revealed only on keyboard focus.
z-index:7 — a 2px amber event line used only for the t+9s class pulse.

There are no stacked glass or card surfaces. Text shadows may use 0 2px 18px rgba(4,7,10,0.92) and 0 1px 2px rgba(4,7,10,1); these are text legibility shadows, not containers.

D. TOKEN TABLE

Define every size, spacing, colour, and timing as a CSS custom property in :root:

--void: #04070A;
--mint: #7DF9C6;
--amber: #FFB020;
--slate: #5A6B72;
--paper: #F2F7F5;
--muted: #9AA9AD;
--hairline: rgba(122,249,198,0.18);
--focus: #FFFFFF;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--page-x: 24px;
--page-y: 24px;
--header-h: 92px;
--hero-col: 320px;
--hero-bottom: 82px;
--hero-gap: 18px;
--footer-h: 58px;
--rail-label-size: 10px;
--rail-value-size: 12px;
--brand-size: 15px;
--eyebrow-size: 11px;
--h1-size: clamp(42px, 4.25vw, 72px);
--h1-line: 0.94;
--body-size: 14px;
--body-line: 1.55;
--action-h: 44px;
--action-radius: 999px;
--track: 0.12em;
--dur-fast: 160ms;
--dur-base: 260ms;
--dur-reveal: 400ms;
--ease-out: cubic-bezier(0.16,1,0.3,1);
--z-canvas: 1;
--z-veil: 2;
--z-header: 3;
--z-main: 4;
--z-footer: 5;
--z-skip: 6;
--z-event: 7;

Use --mint, --slate, --amber, and --paper exactly as semantic colours. Do not introduce blue, purple, red, or card backgrounds.

E. THE COMPOSITION

Build the document in this source order: skip link, header, main, footer, canvas, decorative veil, and event line. The canvas may appear first in paint order through z-index even if it follows semantics in source order.

Header is fixed at the top with left:24px, right:24px, top:0, height:92px, display:grid, grid-template-columns:320px minmax(0,1fr) auto; column-gap:24px; align-items:start; padding-top:20px. The first cell is a nav with aria-label="Primary navigation". It contains an anchor named “Northlight home” and the exact text “NORTHLIGHT / HARBOUR”. Set it to --brand-size, weight 600, letter-spacing:0.10em, line-height:1.2, color:var(--paper), text-decoration:none. Under it place a 10px slate label at 6px top margin reading “LIVE CONTROL / NTH 04”.

The second header cell is the right-aligned sailing table, aligned to the top rail at the 24px page inset. Use a semantic table with class sailings and caption “Sailings from Northlight Harbour”. The table uses display:inline-grid; grid-template-columns:132px 92px 92px; gap:0; margin-left:auto; border-collapse:collapse; color:var(--slate); background:transparent. The caption is position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%). Use th and td with height:24px, padding:0, border:0, font-size:10px, font-weight:500, line-height:24px, letter-spacing:0.08em, text-align:left. The first row contains the exact headers “SAILING”, “STATUS”, “BERTH”. The second row contains “06:40 KIRKGATE”, “BOARDING”, “02”. The third contains “07:15 ISLAY”, “ON TIME”, “04”. The fourth contains “08:05 SKERRY”, “DEPARTING NOW”, “06”. Only the status text in the final row is --amber. The second cell and the other values remain slate or paper. Do not add row backgrounds, cell borders, glass, or card padding. At widths below 901px, hide the caption visually while preserving semantics and reflow the table to a width-safe two-line arrangement.

The third header cell is a 44px-wide, 44px-high anchor with aria-label="Open live departures". It displays the exact text “↗” at 20px, color:var(--mint). It is not enclosed by a box.

Main is positioned left:var(--page-x), bottom:var(--hero-bottom), width:var(--hero-col), z-index:var(--z-main), and contains one hero column. Use display:flex; flex-direction:column; align-items:flex-start; gap:var(--hero-gap). Place the eyebrow first: exact text “NORTHLIGHT / LIVE HARBOUR”, 11px, weight 500, color:var(--mint), letter-spacing:var(--track). Place the h1 next with exact text “Know when<br>the water<br>opens.” Set margin:0, max-width:320px, color:var(--paper), font-size:var(--h1-size), line-height:var(--h1-line), font-weight:500, letter-spacing:-0.055em. Place the body paragraph next with exact text: “Three departures are moving now. Read the tide window, watch the berth, and leave with the next crossing.” Set max-width:310px, margin:0, color:var(--muted), font-size:var(--body-size), line-height:var(--body-line). Place the actions after a 2px gap. Use a div with display:flex; gap:10px. The primary anchor has exact visible text “VIEW LIVE SAILINGS” and accessible name “View live sailings”. It points to #live-sailings and has height:var(--action-h), padding:0 18px, border-radius:var(--action-radius), color:var(--void), background:linear-gradient(135deg, #B0FFE0 0%, var(--mint) 100%), font-size:11px, font-weight:600, letter-spacing:0.08em, display:inline-flex, align-items:center, text-decoration:none. The secondary anchor has exact visible text “TIDE WINDOWS” and accessible name “Tide windows”, points to #tide-windows, and uses transparent background, color:var(--paper), padding:0 4px, display:inline-flex, align-items:center, font-size:11px, font-weight:500, letter-spacing:0.08em.

Below the main text, include a visually compact status row with exact text “HARBOUR SIGNAL  /  09:42:18 UTC” and a 7px mint status dot. The dot uses background:var(--mint) and animation:none at rest. This row is not a card and has no border.

Footer is fixed at left:24px, right:24px, bottom:0, height:var(--footer-h), display:grid; grid-template-columns:320px 1fr auto; gap:24px; align-items:center; border-top:1px solid var(--hairline); background:linear-gradient(to top, rgba(4,7,10,0.86), rgba(4,7,10,0)); z-index:var(--z-footer). The first metadata item is exact text “TIDE 2.4M / RISING” in 10px slate. The middle item is exact text “WIND NW 11KT / VISIBILITY 8.2MI” in 10px slate, centered. The last item is a link named “Harbour assistance” with exact text “HARBOUR ASSISTANCE  ↗”, 10px mint, right aligned. This is a thin metadata rail, not a card.

The semantic destinations #live-sailings and #tide-windows must be visually represented by the table caption and a visually hidden status note. Add one visually hidden p id="tide-windows": “Northlight tide windows: 2.4 metres and rising; next safe crossing window 09:50 UTC.” Add id="live-sailings" to the sailing table. Do not create visible extra sections.

F. THE UI LANGUAGE

Buttons and pills use no glass and no border. The primary action is a solid mint-to-pale-mint diagonal pill. On hover, translate it by translateY(-2px), change its background to linear-gradient(135deg, #D5FFF0 0%, #8EFBCB 100%), and set box-shadow:0 8px 24px rgba(125,249,198,0.18). On active, translateY(0) and use the base background. Transition transform and box-shadow over 160ms using --ease-out; do not animate layout properties.

The secondary action is text-only with a 1px transparent underline. On hover, color:var(--mint) and border-bottom-color:var(--mint). On active, color:var(--paper). The top-right arrow anchor is text-only; hover color:var(--amber), transform:translateY(-2px). Every focus-visible target receives outline:2px solid var(--focus), outline-offset:4px. No focus ring may be clipped by overflow.

Status colors are semantic: “BOARDING” and “ON TIME” use slate, “DEPARTING NOW” uses amber, live signal text uses mint. Use uppercase IBM Plex Mono at 10–12px with letter spacing between 0.08em and 0.12em. Do not use rounded rectangular containers around table rows or metadata.

G. MOTION

The load sequence begins at document script execution. Rows reveal top-to-bottom over 560ms at 14ms per row. Dot opacity is controlled by row reveal and wave opacity. The mint wave begins after the row reveal at 560ms and reaches opacity 1 at 960ms. The exact row-reveal easing is linear for the 14ms row progression; the wave fade uses cubic ease-out. The UI copy uses these delays:

The brand anchor: opacity 0 to 1 and translateY(8px) to translateY(0), duration 420ms, delay 120ms, ease-out.
The sailing table: opacity 0 to 1 and translateX(12px) to translateX(0), duration 460ms, delay 260ms, ease-out.
The eyebrow: opacity 0 to 1 and translateY(10px) to translateY(0), duration 420ms, delay 180ms, ease-out.
The h1: opacity 0 to 1 and translateY(16px) to translateY(0), duration 560ms, delay 220ms, ease-out.
The body paragraph: opacity 0 to 1 and translateY(10px) to translateY(0), duration 440ms, delay 360ms, ease-out.
The primary action: opacity 0 to 1 and translateY(8px) to translateY(0), duration 400ms, delay 470ms, ease-out.
The secondary action: opacity 0 to 1 and translateY(8px) to translateY(0), duration 400ms, delay 520ms, ease-out.
The status row: opacity 0 to 1 and translateY(8px) to translateY(0), duration 400ms, delay 600ms, ease-out.
The footer: opacity 0 to 1, duration 360ms, delay 680ms, ease-out.

Name the CSS keyframes exactly: ui-rise, ui-slide, and amber-column. ui-rise animates opacity and transform only. ui-slide animates opacity and transform only. amber-column animates opacity from 0 to 1 to 0 over 1200ms with steps timing. Add class “amber-flash” to the event line at 9000ms and remove it at 10200ms. The canvas independently executes the t+9s column event; the event line is a 2px horizontal signal indicator positioned at left:0, right:0, top:50%, opacity:0, pointer-events:none. Do not use perpetual CSS animation for the canvas.

The pointer hotspot is frame-driven, not CSS-driven. Its target follows pointermove and pointerleave with a per-frame lerp of exactly 0.08. The wave is always running after the 960ms reveal. The timestamp display must update only once per second by caching the previous displayed second, not once per animation frame.

H. THE RESPONSIVE TABLE

At 1440×900 and 1920×1080, use the base tokens. Keep the hero at 320px and the table at the top-right. At 1920×1080, set --page-x:40px, --header-h:104px, --hero-bottom:96px, --footer-h:64px, and --hero-col:360px; set the h1 maximum to 80px. The source lattice remains 960×400 and is presented across the full viewport; do not increase dot count.

At 768×1024, set --page-x:20px, --page-y:20px, --header-h:156px, --hero-col:360px, --hero-bottom:108px, --footer-h:86px, --h1-size:52px. Header grid-template-columns:1fr auto; grid-template-rows:44px 96px; column-gap:16px. Put the navigation in row 1 column 1, the arrow anchor in row 1 column 2, and the sailing table in row 2 spanning both columns with justify-self:end. Reduce the table grid-template-columns to 126px 88px 76px. The table remains right-aligned and has no background. Main moves to left:20px and bottom:108px. Footer grid-template-columns:1fr auto; grid-template-rows:34px 28px; row-gap:0; height:86px. Put tide in row 1 column 1, wind in row 1 column 2, and assistance in row 2 column 1 / 3. No element may exceed the viewport width.

At 390×844, set --page-x:16px, --page-y:16px, --header-h:176px, --hero-col:calc(100vw - 32px), --hero-bottom:120px, --footer-h:108px, --h1-size:44px, --body-size:13px, --action-h:42px. Header grid-template-columns:1fr 36px; grid-template-rows:42px 118px; column-gap:8px. Place navigation in row 1 column 1 and arrow in row 1 column 2. The sailing table spans row 2, uses display:grid, grid-template-columns:1fr 1fr 1fr, width:100%, justify-self:stretch, font-size:9px. Header labels must wrap safely with min-width:0. Main left:16px, right:16px, bottom:120px, width:auto. Set h1 max-width:350px. The action row uses flex-wrap:wrap and gap:8px. The footer left:16px, right:16px, height:108px, grid-template-columns:1fr 1fr, grid-template-rows:30px 30px 30px, gap:4px 12px. Put tide in row 1 column 1 / 3, wind in row 2 column 1 / 3, and assistance in row 3 column 1 / 3. At 390px the page may scroll vertically, but the hero must not cover the footer or create horizontal overflow.

Add max-height breakpoints. At min-width:901px and max-height:760px, set --header-h:72px, --hero-bottom:58px, --footer-h:46px, --h1-size:42px, --body-size:12px, --action-h:38px, and --page-y:14px. Reduce header padding-top to 12px, table row height to 20px, and the gap between h1 and body to 12px. At min-width:901px and max-height:620px, set --header-h:60px, --hero-bottom:46px, --footer-h:38px, --h1-size:34px, --body-size:11px, --action-h:34px, --page-x:18px, --page-y:10px. Hide the body paragraph only in this exact short desktop condition, reduce the h1 to three lines at 34px, set table row height to 18px, and retain both accessible actions. Do not hide the h1, header, table, status row, or footer.

I. FAILURE GUARDS

All animated DOM content must have resting opacity 1. Define its final opacity and transform in the base rule, then animate from opacity 0 only inside a .is-loading wrapper or an animation that fill-mode is both. Remove .is-loading after 1100ms. If JavaScript is unavailable, the body must not retain opacity 0: use a no-js base state with opacity 1 and add .js to the document element before applying load classes.

Use the two-rAF fallback for ready telemetry: after initialization, call requestAnimationFrame(() => requestAnimationFrame(() => { window.__DEMO__.ready = true; })). This marks ready after the first painted frame and a second frame opportunity. Increment frames exactly once inside the rAF loop by incrementing window.__DEMO__.frames. Do not increment frames in the ready callback.

The animation loop must have a visibility-safe guard. If document.hidden is true, cancel the current rAF, do not increment frames, and restart on visibilitychange. On reduced motion, do not run the wave or load wipe. Render one static complete lattice at 960×400 with revealY=40, waveOpacity=1, hotspot at center, and t=0. Set ready using the same two-rAF fallback and keep the page readable. Do not update the clock. Do not add .is-loading to any UI element. Do not schedule the amber flash in reduced-motion mode. Do not animate pointer movement, UI entrance, or the status signal.

The complete prefers-reduced-motion: reduce block must set animation:none!important, transition:none!important, scroll-behavior:auto!important, and force all animated elements to opacity:1 and transform:none. It must also prevent the canvas script from entering the rAF loop while preserving the static canvas. Reduced-motion users must retain access to the live sailings table, tide information, actions, and footer.

J. THE EXACT JS

Use this exact script structure and values. Adapt only the required DOM ids to the specified markup:

const root = document.documentElement;
root.classList.add("js");
window.__DEMO__ = { name: "tally", ready: false, frames: 0, effects: [], errors: [] };
window.addEventListener("error", event => {
  window.__DEMO__.errors.push(String(event.message || "window error"));
});

const canvas = document.getElementById("tally-canvas");
const ctx = canvas.getContext("2d", { alpha: false });
const COLS = 96;
const ROWS = 40;
const PITCH = 10;
const W = COLS * PITCH;
const H = ROWS * PITCH;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
canvas.width = W;
canvas.height = H;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "low";

const dots = new Array(COLS * ROWS);
for (let y = 0; y < ROWS; y++) {
  for (let x = 0; x < COLS; x++) {
    dots[y * COLS + x] = {
      x,
      y,
      cx: 5 + x * PITCH,
      cy: 5 + y * PITCH,
      sx: Math.sin(x * 0.09),
      cxPhase: Math.cos(x * 0.09),
      sy: Math.sin(y * 0.11),
      cyPhase: Math.cos(y * 0.11),
      berth: x === 21 || x === 63 || x === 88
    };
  }
}

const pointer = { x: W / 2, y: H / 2, targetX: W / 2, targetY: H / 2 };
function setPointer(clientX, clientY) {
  pointer.targetX = Math.max(0, Math.min(W, clientX * W / window.innerWidth));
  pointer.targetY = Math.max(0, Math.min(H, clientY * H / window.innerHeight));
}
window.addEventListener("pointermove", event => setPointer(event.clientX, event.clientY), { passive: true });
window.addEventListener("pointerleave", () => {
  pointer.targetX = W / 2;
  pointer.targetY = H / 2;
});

let raf = 0;
let start = performance.now();
let previousSecond = -1;
let clock = document.getElementById("clock");
let amberSent = false;
let amberEnded = false;

function drawStatic() {
  ctx.globalCompositeOperation = "source-over";
  ctx.filter = "none";
  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = "#04070A";
  ctx.fillRect(0, 0, W, H);
  ctx.fillStyle = "#5A6B72";
  ctx.globalAlpha = 0.12;
  for (const dot of dots) {
    ctx.beginPath();
    ctx.arc(dot.cx, dot.cy, 1.1, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.globalAlpha = 1;
}

function drawFrame(now) {
  const elapsed = (now - start) / 1000;
  const revealY = Math.min(ROWS, elapsed * 1000 / 14);
  const waveOpacity = Math.max(0, Math.min(1, (elapsed * 1000 - 560) / 400));
  const waveEase = 1 - Math.pow(1 - waveOpacity, 3);
  pointer.x += (pointer.targetX - pointer.x) * 0.08;
  pointer.y += (pointer.targetY - pointer.y) * 0.08;

  if (elapsed >= 9 && !amberSent) {
    document.body.classList.add("amber-flash");
    window.__DEMO__.effects.push("amber-column");
    amberSent = true;
  }
  if (elapsed >= 10.2 && !amberEnded) {
    document.body.classList.remove("amber-flash");
    amberEnded = true;
  }

  ctx.globalCompositeOperation = "source-over";
  ctx.filter = "none";
  ctx.globalAlpha = 1;
  ctx.fillStyle = "#04070A";
  ctx.fillRect(0, 0, W, H);

  for (const dot of dots) {
    const rowAlpha = Math.max(0, Math.min(1, revealY - dot.y));
    if (rowAlpha <= 0) continue;
    const product = Math.abs(
      (dot.sx * Math.cos(elapsed * 0.7) + dot.cxPhase * Math.sin(elapsed * 0.7)) *
      (dot.sy * Math.cos(elapsed * 0.5) - dot.cyPhase * Math.sin(elapsed * 0.5))
    );
    const energy = Math.max(0, Math.min(1, (product - 0.18) / 0.82));
    const distance = Math.hypot(dot.cx - pointer.x, dot.cy - pointer.y);
    const falloff = Math.max(0, 1 - distance / 220);
    const radius = 1.1 + 2.4 * product + 1.8 * falloff * falloff;
    const lit = energy >= 0.42;
    const amber = dot.x === 88 && elapsed >= 9 + dot.y * 0.03076923 && elapsed < 9.08 + dot.y * 0.03076923;
    ctx.fillStyle = amber ? "#FFB020" : (lit ? "#7DF9C6" : "#5A6B72");
    ctx.globalAlpha = amber ? rowAlpha : (lit ? (0.52 + 0.48 * energy) * waveEase * rowAlpha : 0.12 * rowAlpha);
    ctx.beginPath();
    ctx.arc(dot.cx, dot.cy, radius, 0, Math.PI * 2);
    ctx.fill();
  }

  ctx.globalCompositeOperation = "lighter";
  ctx.filter = "blur(6px)";
  for (const dot of dots) {
    const rowAlpha = Math.max(0, Math.min(1, revealY - dot.y));
    if (rowAlpha <= 0) continue;
    const product = Math.abs(
      (dot.sx * Math.cos(elapsed * 0.7) + dot.cxPhase * Math.sin(elapsed * 0.7)) *
      (dot.sy * Math.cos(elapsed * 0.5) - dot.cyPhase * Math.sin(elapsed * 0.5))
    );
    const energy = Math.max(0, Math.min(1, (product - 0.18) / 0.82));
    const distance = Math.hypot(dot.cx - pointer.x, dot.cy - pointer.y);
    const falloff = Math.max(0, 1 - distance / 220);
    if (energy < 0.42 && !(dot.x === 88 && elapsed >= 9 + dot.y * 0.03076923 && elapsed < 9.08 + dot.y * 0.03076923)) continue;
    const radius = (1.1 + 2.4 * product + 1.8 * falloff * falloff) * 2.6;
    ctx.fillStyle = dot.x === 88 && elapsed >= 9 + dot.y * 0.03076923 && elapsed < 9.08 + dot.y * 0.03076923 ? "#FFB020" : "#7DF9C6";
    ctx.globalAlpha = rowAlpha * waveEase * 0.34;
    ctx.beginPath();
    ctx.arc(dot.cx, dot.cy, radius, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.filter = "none";
  ctx.globalCompositeOperation = "source-over";
  ctx.globalAlpha = 1;

  const second = Math.floor(elapsed);
  if (clock && second !== previousSecond) {
    previousSecond = second;
    const total = 9 * 3600 + 42 * 60 + 18 + second;
    const hh = String(Math.floor(total / 3600) % 24).padStart(2, "0");
    const mm = String(Math.floor(total / 60) % 60).padStart(2, "0");
    const ss = String(total % 60).padStart(2, "0");
    clock.textContent = hh + ":" + mm + ":" + ss + " UTC";
  }

  window.__DEMO__.frames += 1;
  raf = requestAnimationFrame(drawFrame);
}

function startAnimation() {
  if (reduceMotion.matches) {
    drawStatic();
    requestAnimationFrame(() => requestAnimationFrame(() => {
      window.__DEMO__.ready = true;
    }));
    return;
  }
  start = performance.now();
  window.__DEMO__.effects.push("wave-sweep");
  window.__DEMO__.effects.push("pointer-hotspot");
  window.__DEMO__.effects.push("load-wipe");
  raf = requestAnimationFrame(drawFrame);
  requestAnimationFrame(() => requestAnimationFrame(() => {
    window.__DEMO__.ready = true;
  }));
}

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    cancelAnimationFrame(raf);
  } else if (!reduceMotion.matches) {
    start = performance.now();
    raf = requestAnimationFrame(drawFrame);
  }
});

startAnimation();

---

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. The page loads as a single index.html with exactly one h1, semantic header/main/footer/nav elements, no external media, and no horizontal overflow at 390×844, 768×1024, 1440×900, and 1920×1080.
  2. At 1440×900, document.documentElement.scrollHeight equals the viewport height, body overflow is hidden, and the canvas presents a 96×40 lattice of 3,840 fixed-pitch dots using the specified slate, mint, and amber colours.
  3. After two animation frames, window.__DEMO__.name is "tally", window.__DEMO__.ready is true, window.__DEMO__.frames is greater than 0, and the effects array contains "wave-sweep", "pointer-hotspot", and "load-wipe" unless prefers-reduced-motion is active.
  4. During a normal 1.2-second sample at 1440×900, the rAF loop increments window.__DEMO__.frames by at least 20, makes no console output, records no uncaught errors, and does not call getBoundingClientRect inside the animation loop.
  5. With prefers-reduced-motion: reduce, the page remains readable and static, the rAF animation loop is not started, no amber flash is scheduled, and the canvas still contains a complete static 3,840-dot lattice.
  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