Landing Pageglm-5.3verified 20/20confirmed cross-check
Data Tide
A flowing ribbon field that reacts to a simulated signal, with a floating glass panel
The exact prompt
DESIGN BRIEF — "Data Tide"
A. WHAT IS BEING BUILT
Build ONE complete, valid, self-contained 'index.html': a single-viewport dark landing page for
a fictional observability product called **Tide**. Document title (exact):
'Tide — See the whole signal'. '<html lang="en">'.
The page is: a deep blue-black ground (#05080d); a full-bleed PROCEDURAL ribbon field rendered
on a <canvas> as the cinematic backdrop (40 horizontal sine-summed 1px polylines that surge when
a travelling Gaussian envelope passes every 6 s); a fixed header with a liquid-metal pill nav; a
vertically centred hero with a two-line masked <h1>, a lede, two CTAs, and ONE floating glass
panel below the copy showing a fake live metric readout in monospace (ticks every 1.2 s) plus a
sparkline drawn in inline SVG; and a three-item stats footer. That is the whole page. One frame.
No scroll on desktop.
DO NOT BUILD — every one of these is a failure:
- No video, no WebGL, no Three.js, no Lottie, no GSAP, no jQuery, no React, no Tailwind CDN.
- No remote images, no .mp4/.webm/.gif, no base64 photo blobs, no third-party CDN of any kind.
Google Fonts (preconnect + one stylesheet) is the ONLY network request.
- No extra sections, no feature cards, no logo strip, no pricing table, no testimonial, no FAQ,
no form, no newsletter input, no cookie banner, no second panel, no chart other than the one
sparkline, no footer beyond the three stats.
- No scroll indicator, no cursor blob, no custom scrollbar, no marquee, no typing effect.
- No vertical scrolling on desktop: at >=901px 'html, body { height:100%; overflow:hidden }'
and '.page { height:100dvh; overflow:hidden }'. Scroll is permitted ONLY at <=900px.
- No text-shadow glows, no neon outlines on type, no drop shadows under the headline.
- No console.log / console.warn / console.error anywhere in the happy path; no uncaught
exceptions; no network request other than Google Fonts.
B. THE BACKDROP — PROCEDURAL RIBBON FIELD
B.1 The canvas element
<canvas id="tide" class="tide-canvas" aria-hidden="true"></canvas>
CSS: position:fixed; inset:0; width:100%; height:100%; display:block; z-index:0;
pointer-events:none; (transparent — the body ground shows through; never paint the ground
inside the canvas). It is fixed, not absolute, so it never moves during layout.
B.2 Resolution policy (implement exactly)
const DPR = Math.min(window.devicePixelRatio || 1, 2); // cap DPR at 2
plus a backing-store clamp: if (iw * ih * DPR * DPR > 3,400,000) lower DPR to
DPR = Math.sqrt(3400000 / (iw * ih)). Then:
canvas.width = Math.max(1, Math.round(innerWidth * DPR));
canvas.height = Math.max(1, Math.round(innerHeight * DPR));
ctx.setTransform(DPR, 0, 0, DPR, 0, 0); // ALL drawing math stays in CSS pixels
Lines are 1px and must stay crisp, so render at full clamped DPR (no half-resolution trick).
Recompute on resize with a 150 ms debounce, and on orientationchange.
B.3 Determinism — a seeded PRNG, not Math.random
The field must be identical on every load and across implementations. Use mulberry32 seeded
with 20260114:
function mulberry32(a) { return function () { a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
var rnd = mulberry32(20260114);
Build exactly 40 ribbon records. For EACH ribbon i = 0..39, with u = i / 39, consume rnd()
IN THIS EXACT ORDER (11 calls per ribbon; never reorder, never skip):
1 mix = rnd() // colour mix along the blue->teal ramp
2 alpha = rnd() // base stroke alpha
3 amp = rnd() // wave amplitude fraction
4 p1 = rnd() * 6.28318
5 p2 = rnd() * 6.28318
6 p3 = rnd() * 6.28318
7 w1 = 0.9 + 0.5 * rnd() // rad/s
8 w2 = 0.5 + 0.35 * rnd()
9 w3 = 1.4 + 0.8 * rnd()
10 bAmp = rnd() // vertical bob fraction
11 bPh = rnd() * 6.28318; bSp = 0.12 + 0.1 * rnd() // bob speed from a 12th call
Derived per ribbon (compute once, at build time, and PRE-BUILD BOTH STROKE STRINGS so the
rAF loop performs ZERO string allocation):
frac = 0.07 + 0.86 * u // vertical anchor, even spread
ampF = 0.018 + 0.052 * amp // fraction of viewport height
alphaF= 0.10 + 0.34 * alpha
r = Math.round(88 + 42 * mix); g = Math.round(138 + 62 * mix); b = Math.round(178 + 18 * mix)
base = 'rgba(r,g,b,alphaF)' // cool steel-blue -> dim teal
hot = 'rgba(58,224,198, min(0.72, alphaF * 1.7))' // surge highlight colour
The 'hot' string is built once with the numeric value baked in (toFixed(3)).
B.4 The wave shape (all in CSS px; W, H = innerWidth, innerHeight)
Wavelengths scale with width: k1 = 2π / (0.30·W), k2 = 2π / (0.13·W), k3 = 2π / (0.055·W).
Per ribbon, per sample x:
base = frac·H + bAmp'·H · sin(bSp·t + bPh) where bAmp' = 0.008 + 0.008·bAmp
wave = 0.54·sin(k1·x − w1·t + p1)
+ 0.30·sin(k2·x + w2·t + p2)
+ 0.16·sin(k3·x − w3·t + p3)
y = base + ampF·H · env(x) · wave
env(x) is the surge envelope (B.5). The three components travel in mixed directions (two
rightward, one leftward) at incommensurate speeds, so the field never visibly repeats.
B.5 THE SURGE — a travelling Gaussian envelope, period exactly 6.0 s
p = (t mod 6.0) / 6.0
cx = (−0.15 + 1.30·p) · W // fully off-left at p=0, off-right at p=1
σ = 0.085 · W
g(x)= exp(−0.5·((x−cx)/σ)²)
env(x) = 0.30 + 2.30·g(x)
At rest ribbons wobble at 30% amplitude; inside the envelope they lift to 260% — a visible
surge sweeping left to right every 6 seconds, lifting exactly the ribbons it passes.
B.6 Two-pass stroke, per ribbon, per frame
ctx.lineWidth = 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round' (set once per frame).
Pass 1 (full width): x from −STEP to W+STEP inclusive, step STEP = 10 CSS px at >900px width,
STEP = 14 at <=900px. strokeStyle = ribbon.base. One beginPath + moveTo + lineTo… + stroke.
Pass 2 (surge highlight, only when the window intersects the canvas):
window = [cx − 2.6σ, cx + 2.6σ] clamped to [−STEP, W+STEP]; same sampling step; same y
formula; strokeStyle = ribbon.hot (teal #3ae0c6, brighter alpha). This makes the surge read
as a bright band of tide moving through the field.
Draw count: DRAWN = 26 ribbons when innerWidth <= 900, else all 40 (the array still holds 40;
only the drawn count changes, so resizing never re-seeds).
Budget: 40 ribbons × ~148 samples × 2 passes ≈ 11,800 lineTo per frame at 1440px — trivial.
Cap the frame rate: skip the draw when (ts − lastDraw) < 15.5 ms. Clamp dt to 0.05 s so a tab
switch cannot teleport the field. If document.hidden, cancel the rAF and restart on
visibilitychange (reset the last-timestamp so dt starts clean).
Each rAF tick increments window.__DEMO__.frames — including ticks whose draw is skipped.
B.7 Scrim (CSS, above canvas, below content)
.tide-scrim is a fixed div at z-index:1 with pointer-events:none and:
background:
radial-gradient(72% 60% at 50% 46%, rgba(5,8,13,0.62) 0%, rgba(5,8,13,0.38) 46%,
rgba(5,8,13,0) 100%),
linear-gradient(180deg, rgba(5,8,13,0.60) 0%, rgba(5,8,13,0.10) 16%, rgba(5,8,13,0) 34%,
rgba(5,8,13,0) 66%, rgba(5,8,13,0.14) 84%, rgba(5,8,13,0.64) 100%);
The centre ellipse is load-bearing: the hero copy and the glass panel sit inside it, so text
contrast wins no matter how bright the surge is. The top ramp protects the header; the bottom
ramp protects the stats. Values are not adjustable upward.
B.8 Grain: SVG feTurbulence at 5%, static (zero per-frame cost)
Inline (NOT a data URI, NOT remote):
<svg class="grain-svg" aria-hidden="true" focusable="false">
<filter id="tideGrain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3"
stitchTiles="stitch" seed="11" result="n"/>
<feColorMatrix type="saturate" values="0"/>
</filter>
<rect width="100%" height="100%" filter="url(#tideGrain)"/>
</svg>
CSS: .grain-svg { position:fixed; inset:-4%; width:108%; height:108%; z-index:100;
opacity:0.05; mix-blend-mode:soft-light; pointer-events:none;
animation:grain-drift 7s steps(7, end) infinite; will-change:transform; }
@keyframes grain-drift {
0% { transform: translate3d(0,0,0) } 14.3% { transform: translate3d(-1.1%, 0.9%, 0) }
28.6%{ transform: translate3d(0.8%, -1.2%, 0) } 42.9% { transform: translate3d(-0.6%, -0.7%, 0) }
57.1%{ transform: translate3d(1.0%, 0.6%, 0) } 71.4% { transform: translate3d(-0.9%, 1.1%, 0) }
85.7%{ transform: translate3d(0.5%, -0.5%, 0) } 100% { transform: translate3d(0,0,0) } }
C. THE LAYER STACK (back -> front), with z-index
z 0 html, body background:#05080d (see failure guards)
z 0 #tide fixed canvas, the ribbon field (transparent)
z 1 .tide-scrim fixed vignette/scrim gradients
z 2 .page position:relative; display:grid; grid-template-rows:auto 1fr auto;
min-height:100dvh; (desktop lock: height:100dvh; overflow:hidden)
auto header / main / footer children of .page, no z-index of their own
z 100 .grain-svg feTurbulence grain, 5%, soft-light
Nothing else may create a stacking context above z 2 inside .page. The mobile menu overlay,
when open, sits at z-index:90 (below grain, above .page). The glass panel's
backdrop-filter samples the canvas and scrim beneath it — this is intended.
D. TOKEN TABLE (declare all of these on :root; default values are the ~1440px design)
Colour
--bg: #05080d
--text: #ffffff
--muted: #9aa7b2 (8.1:1 on #05080d — safe for the lede)
--stat: #d3dae2
--accent: #3ae0c6
--warn: #f5b04a (latency-up delta chip)
--border: rgba(255,255,255,0.16)
--border-soft: rgba(255,255,255,0.12)
--pill-line: rgba(198,208,220,0.55)
--pill-line-hover: rgba(235,242,248,0.9)
--glass-hi: rgba(16,26,38,0.55)
--glass-lo: rgba(8,13,20,0.34)
--grain-opacity: 0.05
--focus: rgba(94,230,210,0.9)
Type
--logo: 15.5px
--logo-mark: 22px
--nav: 14px
--nav-h: 40px
--btn: 13.5px
--btn-h: 40px
--h1: 46px
--lede: 15.5px
--badge: 12.5px
--stat-size: 13.5px
--panel-value: 40px (big monospace readout)
--panel-label: 12.5px
--panel-mono: 12px
--spark-h: 56px
Space / geometry
--header-y: 22px
--header-x: 40px
--stats-x: 72px
--stats-y: 36px
--badge-mb: 24px
--h1-mb: 20px
--lede-mb: 28px
--actions-mb: 34px
--copy-max: 860px
--lede-max: 500px
--panel-w: 560px
--panel-pad: 22px
--panel-gap: 12px
--panel-radius: 18px
--nav-gap: 10px
--cta-gap: 14px
--hero-btn-gap: 12px
--stat-gap: 36px
Fonts
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif
--font-serif: "Instrument Serif", "Iowan Old Style", "Palatino Linotype", Palatino,
Georgia, serif
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
Load with <link rel="preconnect" href="https://fonts.googleapis.com">,
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>, and ONE
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Instrument+Serif:ital@1&family=IBM+Plex+Mono:wght@400;500&display=swap">.
If the request fails the system fallbacks above must still look intentional.
E. THE COMPOSITION — build order
E.1 <body style="background:#05080d;color:#fff"> (inline attribute, belt and braces).
Also body { overflow-x:hidden } at every breakpoint — horizontal overflow is forbidden.
E.2 <header class="site-header">
padding: var(--header-y) var(--header-x); display:flex; align-items:center; gap:16px;
.brand { display:inline-flex; align-items:center; gap:10px; color:#fff; text-decoration:none }
.brand-mark { width:var(--logo-mark); height:var(--logo-mark); flex:0 0 auto }
An INLINE SVG, viewBox="0 0 22 22": three horizontal wave paths, stroke
stroke="url(#tideGrad)" stroke-width="1.5" fill="none" stroke-linecap="round":
M3 6.5 C6 4.5, 9.5 8.5, 12.5 6.5 S18 4.5, 19.5 6.5 (opacity 1)
M3 11 C6 9, 9.5 13, 12.5 11 S18 9, 19.5 11 (opacity 0.66)
M3 15.5 C6 13.5, 9.5 17.5, 12.5 15.5 S18 13.5, 19.5 15.5 (opacity 0.38)
where #tideGrad is a linearGradient x1=0 y1=0 x2=1 y2=0 with stops
#3ae0c6 -> #6aa8ff. aria-hidden="true" (the wordmark carries the name).
.brand-word { font-size:var(--logo); letter-spacing:0.14em; text-transform:uppercase;
font-weight:500; white-space:nowrap } -> text "Tide"
nav.nav-desktop { margin-left:auto; display:flex; align-items:center; gap:var(--nav-gap) }
ul { display:flex; align-items:center; gap:var(--nav-gap); list-style:none; margin:0; padding:0 }
Four <a class="pill appear"> links, in this order and with these EXACT strings:
"Product" "Integrations" "Pricing" "Docs"
Then <a class="btn btn-ghost btn-sm appear"> with the string "Start free", separated
from the last pill by var(--cta-gap).
button.burger (hidden at >=901px): 40x40, aria-label="Open menu", aria-expanded="false",
aria-controls="mobile-menu", border:1px solid var(--border), border-radius:7px,
background:transparent; three 1px x 16px white bars (spans) at 6px vertical spacing.
E.3 <main class="hero">
CSS: .hero { display:flex; flex-direction:column; align-items:center; justify-content:center;
padding:8px 24px 20px; min-height:0 }
.hero-inner { width:100%; max-width:var(--copy-max); margin:0 auto; text-align:center;
display:flex; flex-direction:column; align-items:center }
At >=901px only, .hero-inner { transform:translateY(-1.5vh) } (optical centring).
Children, in order:
1. p.badge.appear
inline-flex; align-items:center; gap:8px; margin:0 0 var(--badge-mb);
font-size:var(--badge); letter-spacing:0.08em; text-transform:uppercase;
color:var(--muted);
.badge-dot { width:5px; height:5px; border-radius:50%; background:var(--accent);
box-shadow:0 0 10px rgba(58,224,198,0.75);
animation:badge-pulse 2.4s ease-in-out infinite }
Copy (exact): "New — Tide Signal Engine v2"
2. h1.hero-title (the ONLY h1 on the page)
font-size:var(--h1); font-weight:500; letter-spacing:-0.045em; line-height:1.12;
margin:0 0 var(--h1-mb); color:var(--text);
Exactly TWO <span class="headline-line"> children, each display:block;
overflow:hidden; padding:0.06em 0.15em 0.14em; (the padding stops the italic serif
and the descenders from being clipped by the mask):
Line 1: All your signals,
Line 2: one <em>living</em> tide.
em { font-family:var(--font-serif); font-style:italic; font-weight:400;
font-size:1.08em; letter-spacing:-0.03em; color:var(--muted) } <-- NOT white
3. p.lede.appear
max-width:var(--lede-max); margin:0 0 var(--lede-mb); font-size:var(--lede);
line-height:1.62; color:var(--muted);
Copy (exact): "Tide streams millions of datapoints a second into one living ribbon
field — so latency, errors and saturation show up as motion you can read before
they become incidents."
4. div.hero-actions { display:flex; gap:var(--hero-btn-gap); justify-content:center;
align-items:center; flex-wrap:wrap; margin-bottom:var(--actions-mb) }
a.btn.btn-solid.appear -> "Start streaming"
a.btn.btn-line.appear -> "Book a live demo"
5. THE GLASS PANEL — three nested elements (entrance / float / tilt must never share a
transform, so each gets its own node):
div.panel-wrap.appear.appear--panel (entrance animation + perspective)
div.panel-float (infinite idle float)
div.glass (JS-driven tilt via custom props)
.panel-wrap { width:100%; max-width:var(--panel-w); perspective:1200px }
.panel-float { animation:panel-float 9s ease-in-out infinite;
@keyframes panel-float { 0%,100% { transform:translateY(0) }
50% { transform:translateY(-5px) } } }
.glass { position:relative; border-radius:var(--panel-radius);
padding:var(--panel-pad); display:flex; flex-direction:column;
gap:var(--panel-gap);
background:linear-gradient(160deg, var(--glass-hi) 0%, var(--glass-lo) 100%);
backdrop-filter:blur(22px) saturate(1.4);
-webkit-backdrop-filter:blur(22px) saturate(1.4);
box-shadow:0 30px 80px rgba(0,0,0,0.55), 0 4px 18px rgba(0,0,0,0.32);
transform:rotateX(var(--tilt-x,0deg)) rotateY(var(--tilt-y,0deg));
will-change:transform; }
THE 1PX GRADIENT BORDER — the mask trick, on .glass::before:
content:""; position:absolute; inset:0; border-radius:inherit; padding:1px;
pointer-events:none;
background:linear-gradient(135deg, rgba(255,255,255,0.42) 0%,
rgba(255,255,255,0.10) 28%, rgba(58,224,198,0.38) 58%,
rgba(255,255,255,0.16) 100%);
-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite:xor; mask-composite:exclude;
Inner sheen on .glass::after: content:""; position:absolute; inset:0;
border-radius:inherit; pointer-events:none;
background:linear-gradient(180deg, rgba(255,255,255,0.09) 0%, transparent 34%);
Panel content, in order (all rows width:100%):
a) .glass-head { display:flex; justify-content:space-between; align-items:center }
span.metric-label { font-family:var(--font-mono); font-size:var(--panel-label);
color:var(--muted); letter-spacing:0.02em } -> "ingest.pipeline.latency"
span.live { display:inline-flex; align-items:center; gap:6px;
font-family:var(--font-mono); font-size:10.5px; letter-spacing:0.14em;
color:var(--accent) } -> "LIVE" with span.live-dot (5px circle,
background:var(--accent), box-shadow:0 0 8px rgba(58,224,198,0.8),
animation:badge-pulse 2.4s ease-in-out infinite)
b) .metric-row { display:flex; align-items:baseline; gap:10px }
span#metricValue.metric-value { font-family:var(--font-mono);
font-size:var(--panel-value); font-weight:500; letter-spacing:-0.02em;
color:#ffffff; font-variant-numeric:tabular-nums; line-height:1.05 }
-> initial text "238.6"
span.metric-unit { font-family:var(--font-mono); font-size:14px;
color:var(--muted) } -> "ms"
span#metricDelta.metric-delta { margin-left:auto; font-family:var(--font-mono);
font-size:12px; padding:3px 8px; border-radius:6px; white-space:nowrap }
-> initial text "▾ 1.2%"
.metric-delta.down { color:var(--accent); background:rgba(58,224,198,0.10) }
.metric-delta.up { color:var(--warn); background:rgba(245,176,74,0.12) }
c) the sparkline — INLINE SVG, exact markup:
<svg class="spark" viewBox="0 0 480 56" preserveAspectRatio="none" role="img"
aria-label="Latency sparkline over the last 48 samples">
<defs><linearGradient id="sparkFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3ae0c6" stop-opacity="0.20"/>
<stop offset="100%" stop-color="#3ae0c6" stop-opacity="0"/>
</linearGradient></defs>
<path id="sparkArea" fill="url(#sparkFill)"/>
<polyline id="sparkLine" fill="none" stroke="#3ae0c6" stroke-width="1.5"
vector-effect="non-scaling-stroke" stroke-linejoin="round"
stroke-linecap="round"/>
<circle id="sparkDot" r="2.5" fill="#3ae0c6"/>
</svg>
.spark { display:block; width:100%; height:var(--spark-h); min-width:0 }
d) .glass-foot { display:flex; justify-content:space-between; gap:8px 14px;
flex-wrap:wrap; font-family:var(--font-mono); font-size:var(--panel-mono);
color:var(--muted) }
Exactly three spans, each .sub with a <b> child (color:#d3dae2; font-weight:500;
font-variant-numeric:tabular-nums):
<b id="subP50">184</b> p50 ms
<b id="subP95">410</b> p95 ms
<b id="subErr">0.03</b>% err
The panel must be keyboard-independent (no focusable controls inside it) and its
ticking numbers must NOT be in any aria-live region.
E.4 <footer class="stats">
padding:0 var(--stats-x) var(--stats-y);
ul { display:flex; align-items:center; justify-content:center; gap:var(--stat-gap);
flex-wrap:wrap; list-style:none; margin:0; padding:0 }
Exactly THREE <li class="stat appear"> items:
<b>2.4M</b> datapoints per second
<b>140+</b> native integrations
<b>38ms</b> p50 query latency
.stat { font-size:var(--stat-size); color:var(--stat); letter-spacing:0.01em;
display:inline-flex; gap:7px; align-items:baseline; white-space:nowrap }
.stat b { color:#ffffff; font-weight:500; font-variant-numeric:tabular-nums }
E.5 Mobile menu (<=900px only)
div#mobile-menu.mobile-menu[hidden] — position:fixed; inset:0; z-index:90;
background:rgba(4,8,13,0.72); backdrop-filter:blur(18px) saturate(1.2);
-webkit-backdrop-filter:blur(18px) saturate(1.2);
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px;
Contains the same four links at font-size:22px plus "Start free" as a solid button.
Toggled by .burger; closed by: clicking any link, pressing Escape, or a resize to >=901px.
When open: body gets .menu-open { overflow:hidden } and aria-expanded flips to "true".
F. THE UI LANGUAGE
F.1 Liquid-metal pill (.pill) — the nav links
display:inline-flex; align-items:center; justify-content:center;
height:var(--nav-h); padding:0 18px; border-radius:7px;
overflow:hidden; position:relative; isolation:isolate;
border:1px solid var(--pill-line);
background:linear-gradient(105deg, #05070a 0%, #252c36 48%, #414b58 100%);
color:#e9edf2; font-size:var(--nav); font-weight:500; letter-spacing:0.005em;
text-decoration:none; transition:border-color .35s ease, background .35s ease,
box-shadow .35s ease, color .35s ease;
.pill::before { content:""; position:absolute; inset:-1px; z-index:-1;
background:linear-gradient(115deg, transparent 30%, rgba(255,255,255,0.16) 50%,
transparent 70%);
transform:translateX(-120%); transition:transform .6s ease }
.pill:hover::before { transform:translateX(120%) }
.pill:hover { border-color:var(--pill-line-hover); color:#ffffff;
background:linear-gradient(105deg, #0e1319 0%, #333e4c 45%, #5b6a7a 100%);
box-shadow:0 0 18px rgba(160,210,230,0.16) }
.pill:focus-visible { outline:2px solid var(--focus); outline-offset:3px }
F.2 Buttons (.btn)
display:inline-flex; align-items:center; justify-content:center;
height:var(--btn-h); padding:0 20px; border-radius:7px;
font-size:var(--btn); font-weight:500; letter-spacing:0.01em;
text-decoration:none; cursor:pointer; border:1px solid transparent;
transition:transform .3s ease, background .3s ease, border-color .3s ease,
box-shadow .3s ease, color .3s ease;
.btn-solid { background:linear-gradient(105deg, #e8eef2 0%, #ffffff 52%, #d3dce2 100%);
color:#06090d; border-color:rgba(255,255,255,0.5) }
.btn-solid:hover { transform:translateY(-1px);
box-shadow:0 8px 26px rgba(160,200,225,0.20) }
.btn-line { background:rgba(255,255,255,0.02); color:#ffffff;
border-color:var(--border) }
.btn-line:hover { border-color:rgba(255,255,255,0.42);
background:rgba(255,255,255,0.06) }
.btn-ghost { background:linear-gradient(105deg, rgba(255,255,255,0.06) 0%,
rgba(255,255,255,0.02) 55%, rgba(255,255,255,0.10) 100%);
color:#ffffff; border-color:var(--pill-line) } // header CTA, matches pills
.btn-sm { height:var(--btn-h); padding:0 16px; font-size:var(--btn) }
Hover lift is translateY(-1px) ONLY — nothing bounces, nothing scales on hover.
F.3 Focus ring, global
:focus-visible { outline:2px solid var(--focus); outline-offset:3px; border-radius:inherit }
Never remove outlines; never set outline:0 without a replacement.
F.4 Anti-flash rule (first three rules of the stylesheet, in this order)
1) html, body { background:#05080d !important; color:#ffffff; }
2) (the inline body attribute from E.1)
3) html, body { background:#05080d; background:var(--bg, #05080d); margin:0; padding:0;
font-family:var(--font-sans); -webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility; }
G. MOTION
G.1 Entrance system
.appear { opacity:1 } // RESTING STATE IS VISIBLE — see I
.appear { animation-duration:1.05s; animation-fill-mode:both;
animation-timing-function:cubic-bezier(0.16,1,0.3,1);
animation-delay:var(--d, 0.08s); }
.is-in { animation:none !important; opacity:1 !important; transform:none !important;
clip-path:none !important; filter:none !important; }
G.2 Stagger table (element -> animation -> --d)
brand in-scale 0.08s
nav pill 1 in-scale 0.16s
nav pill 2 in-soft 0.28s
nav pill 3 in-scale 0.40s
nav pill 4 in-soft 0.52s
header CTA in-scale 0.34s
badge in-pop 0.22s
h1 line 1 in-mask 0.42s
h1 line 2 in-mask 0.62s
lede in-soft 0.82s (animation-duration:1.25s)
solid CTA in-btn 0.96s
line CTA in-side 1.10s
glass panel in-panel 1.20s (animation-duration:1.15s, on .panel-wrap only)
stat 1 in-stat 1.32s
stat 2 in-stat 1.44s
stat 3 in-stat 1.56s
(the canvas has no entrance animation; it fades in once via .tide-canvas
{ animation:in-fade 2.2s ease both } with --d 0s)
G.3 Keyframes — every one ends at opacity 1 and the identity transform
@keyframes in-fade { from { opacity:0 } to { opacity:1 } }
@keyframes in-scale { from { opacity:0; transform:scale(0.84) }
to { opacity:1; transform:scale(1) } }
@keyframes in-soft { from { opacity:0; transform:translateY(14px) }
to { opacity:1; transform:translateY(0) } }
@keyframes in-mask { from { opacity:0; transform:translateY(40%) }
to { opacity:1; transform:translateY(0) } } // clipped by .headline-line
@keyframes in-pop { 0% { opacity:0; transform:scale(0.90) }
70% { opacity:1; transform:scale(1.03) }
100% { opacity:1; transform:scale(1) } }
@keyframes in-btn { from { opacity:0; transform:translateY(18px) scale(0.94) }
to { opacity:1; transform:translateY(0) scale(1) } }
@keyframes in-side { from { opacity:0; transform:translateX(22px) }
to { opacity:1; transform:translateX(0) } }
@keyframes in-stat { from { opacity:0; transform:translateY(20px) }
to { opacity:1; transform:translateY(0) } }
@keyframes in-panel { from { opacity:0; transform:translateY(26px) scale(0.97) }
to { opacity:1; transform:translateY(0) scale(1) } }
@keyframes badge-pulse { 0%,100% { opacity:1; transform:scale(1) }
50% { opacity:0.55; transform:scale(0.82) } }
G.4 Runtime motion (not entrance)
panel-float: .panel-float translateY 0 -> -5px -> 0 over 9s ease-in-out, infinite.
panel tilt: JS-driven, max 2deg, damped (see J). Never a CSS transition — the damping IS
the animation.
metric ticker: text swaps every 1200 ms with no transition (tabular numerics prevent
layout jitter).
The ribbon field runs continuously at <= 60 fps (see B.6).
H. RESPONSIVE TABLE — every breakpoint is a token override table
>=1600px
--logo:17px; --logo-mark:24px; --nav:15px; --nav-h:44px; --btn:15px; --btn-h:44px;
--h1:56px; --lede:18px; --badge:13.5px; --stat-size:15px; --panel-value:46px;
--panel-label:13.5px; --panel-mono:13px; --spark-h:64px; --header-y:28px; --header-x:64px;
--stats-x:96px; --stats-y:44px; --copy-max:980px; --lede-max:560px; --panel-w:620px;
--panel-pad:26px
>=1920px
--h1:64px; --lede:19px; --badge:14px; --stat-size:15.5px; --panel-value:52px; --panel-w:660px;
--panel-pad:28px; --spark-h:68px; --header-y:30px; --header-x:72px; --stats-x:120px;
--stats-y:52px; --copy-max:1060px; --lede-max:600px; --badge-mb:28px; --h1-mb:24px;
--lede-mb:32px; --actions-mb:38px
1280px – 1599px
--h1:50px; --header-x:48px; --copy-max:900px; --panel-w:580px
901px – 1279px
--h1:40px; --nav-h:36px; --copy-max:760px; --lede-max:440px; --panel-w:520px;
--panel-value:34px; --spark-h:48px; --stats-x:48px; --stat-gap:28px; --header-y:18px;
--badge-mb:18px; --h1-mb:16px; --lede-mb:22px; --actions-mb:26px
>=901px AND (max-height:850px)
--h1:38px; --lede:14.5px; --panel-w:480px; --panel-value:32px; --spark-h:44px;
--panel-pad:18px; --panel-gap:10px; --header-y:18px; --stats-y:26px; --badge-mb:16px;
--h1-mb:14px; --lede-mb:18px; --actions-mb:22px
>=901px AND (max-height:720px)
--h1:32px; --lede:13.5px; --badge:11.5px; --panel-w:440px; --panel-value:26px;
--panel-mono:11px; --panel-label:11.5px; --spark-h:32px; --panel-pad:14px; --panel-gap:8px;
--panel-radius:14px; --header-y:14px; --stats-y:20px; --badge-mb:10px; --h1-mb:8px;
--lede-mb:12px; --actions-mb:14px; --stat-gap:24px
>=901px DESKTOP LOCK (apply in the same block)
html, body { height:100%; overflow:hidden }
.page { height:100dvh; overflow:hidden }
ONE FRAME, NO SCROLL.
<=900px (phone + small tablet — scrolling IS allowed here)
Remove the vh lock: html, body { height:auto; overflow-x:hidden; overflow-y:auto }
.page { height:auto; min-height:100dvh }
.nav-desktop { display:none } .burger { display:inline-flex }
.hero { justify-content:flex-start; padding:16px 20px 56px } .hero-inner { transform:none }
--h1:40px; --header-y:18px; --header-x:24px; --stats-x:24px; --stats-y:32px; --stat-gap:18px;
--copy-max:640px; --lede-max:420px; --panel-w:100%; --panel-pad:18px; --panel-value:34px;
--spark-h:48px; --badge-mb:18px; --h1-mb:16px; --lede-mb:22px; --actions-mb:26px
Menu overlay per E.5.
<=560px
--h1:34px; --lede:15px; --badge:12px; --stat-size:12.5px; --header-x:18px; --stats-x:18px;
--panel-pad:16px; --panel-value:30px; --spark-h:44px; --hero-btn-gap:10px
.hero-actions { flex-direction:column; align-items:stretch; width:100% }
.hero-actions .btn { width:100% }
.headline-line { padding:0.06em 0.10em 0.14em }
.stats ul { flex-direction:column; align-items:center; gap:10px } // three stacked lines
No horizontal overflow at 390px, 768px, 1440px or 1920px: never set a width wider than 100vw,
keep .panel-wrap and .lede inside a 20px gutter, and let .headline-line shrink (it is a block,
so it wraps rather than overflows). body keeps overflow-x:hidden everywhere as the backstop.
I. FAILURE GUARDS
I.1 Resting opacity 1. '.appear { opacity:1 }' is the base rule; the animation only ever
animates FROM 0. If CSS animations are unsupported, disabled, or interrupted, every
element is already in its final state. The page is NEVER blank.
I.2 animationend settlement. On each element's own 'animationend', add '.is-in' so the
element drops the animation and keeps its end state (prevents any post-animation
repaint from re-running the keyframe, and frees compositor layers).
I.3 Two-rAF fallback. After two nested requestAnimationFrames from DOMContentLoaded, for
every '.appear': if 'el.getAnimations().some(a => a.playState === "running" ||
a.playState === "finished")' is false, add '.is-in'. Guard with
'typeof el.getAnimations === "function"' — if absent, add '.is-in' to all.
I.4 Canvas fallback. If 'canvas.getContext("2d")' returns null, hide #tide, keep the ground
+ scrim, set '__DEMO__.ready = true', push 'tide-canvas-fallback' into effects, and
push nothing into errors. The rAF loop must STILL run (frames keeps counting), and the
metric ticker, sparkline, entrance fallback and menu must still work — the page must
still look finished (a dark page with a glass readout panel).
I.5 Panel guards. Every getElementById for the panel is null-checked; a missing sparkline
must not break the ticker. The tilt code only runs when
matchMedia('(hover: hover) and (pointer: fine)').matches — never on touch.
I.6 prefers-reduced-motion: reduce
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition:none !important; animation:none !important; }
.appear, .headline-line, .badge-dot, .live-dot, h1 em { opacity:1 !important;
transform:none !important; clip-path:none !important; filter:none !important; }
.grain-svg, .panel-float { animation:none !important }
}
Canvas under reduced motion: draw ONE static frame with t = 0 and the surge envelope
frozen at centre (cx = 0.5·W), then keep the rAF loop running ONLY to increment
__DEMO__.frames (no dt advance, no draw, no tilt). The metric ticker and sparkline DO
keep updating — they are a data readout, not motion. The visual result is a completely
static, fully readable page; telemetry still advances.
I.7 Contrast. --muted #9aa7b2 on #05080d is 8.1:1; --stat #d3dae2 and #fff are higher; the
delta colours #3ae0c6 and #f5b04a are >= 9:1 on the glass. Never lower text opacity
below these values.
J. THE JAVASCRIPT — exactly this much, nothing more
One IIFE at the end of <body>, 'use strict'. Order: (1) telemetry first —
window.__DEMO__ = { name:'data-tide', ready:false, frames:0, effects:[], errors:[] }, then a
window 'error' listener pushing String(e.message || 'error'); (2) entrance settlement and the
two-rAF fallback exactly per I.2/I.3; (3) mobile menu wiring exactly per E.5; (4) the seeded
PRNG and the 40 ribbon records exactly per B.3 (12 rnd() calls per ribbon, exact order);
(5) canvas sizing, draw, and the rAF loop per B.4-B.6 and subsection 8 below; (6) the metric
ticker and sparkline; (7) the pointer tilt; (8) boot — size the canvas, draw once, set
__DEMO__.ready = true after the first paint of the field, start the rAF loop. Use straight
quotes in real JS, never the typographic quotes used in this prose.
6. METRIC TICKER + SPARKLINE (ticks every 1.2 s)
Cache by id: metricValue, metricDelta, subP50, subP95, subErr, sparkLine, sparkArea,
sparkDot. Keep a ring buffer buf of NFULL = 48 samples. PREFILL it once at boot with the
SAME rnd() stream (do not re-seed; the remaining stream after the ribbons is deterministic)
using the recurrence v = min(322, max(178, prev + (rnd() - 0.5) * 22)), starting from
v0 = 238.6 — so the chart renders FULL on first paint, never empty. renderPanel() writes
everything (below). After prefill, call renderPanel() once, then setInterval(push, 1200).
A push takes the last value, computes nv with the same clamp formula using
(Math.random() - 0.5) * 22 (live randomness is fine here — the field's determinism is what
matters), appends, drops the head past 48, and calls renderPanel().
renderPanel():
- metricValue.textContent = cur.toFixed(1)
- delta: d = ((cur - prev) / prev) * 100; textContent = (d <= 0 ? '▾ ' : '▴ ') +
Math.abs(d).toFixed(1) + '%'; classList.toggle('down', d <= 0) and ('up', d > 0).
- subP50 = String(Math.round(cur * 0.77)); subP95 = String(Math.round(cur * 1.72));
subErr = (0.02 + cur / 9000).toFixed(2).
- sparkline: for each sample j, x = (j * 480) / 47; y = 50 - clamp((v - 170) / 160,
0, 1) * 44; build the points string 'x,y' joined by spaces for #sparkLine; build
#sparkArea d = 'M0,56' + 'Lx,y' for each sample + 'L480,56 Z'; set #sparkDot
cx=480 and cy to the last y. All numbers toFixed(1).
Every id lookup is null-checked; a missing sparkline must not break the ticker. The
ticking numbers are NOT in any aria-live region.
7. PANEL TILT — max 2deg, damped (I.5)
Only when matchMedia('(hover: hover) and (pointer: fine)').matches AND !reduce.
State: tX, tY (targets), cX, cY (current). On pointermove { passive:true } over window:
nx = e.clientX / innerWidth; ny = e.clientY / innerHeight;
tY = clamp((0.5 - nx) * 4, -2, 2); // rotateY, leans toward pointer-x
tX = clamp((ny - 0.5) * 4, -2, 2); // rotateX, leans toward pointer-y
window 'blur' resets tX = tY = 0. Inside the rAF loop each frame:
cX += (tX - cX) * 0.08; cY += (tY - cY) * 0.08; // 8% of the gap per frame = damped
glass.style.setProperty('--tilt-x', cX.toFixed(3) + 'deg');
glass.style.setProperty('--tilt-y', cY.toFixed(3) + 'deg');
The tilt is clamped hard at ±2deg and driven by the damped custom properties only —
never a CSS transition, the damping IS the animation. The glass element is cached once;
if missing, tilt silently does nothing.
8. CANVAS + rAF LOOP (parameters already pinned in B.2, B.4 - B.6 — do not deviate)
resize(): apply B.2 (DPR cap + 3.4M pixel clamp, setTransform so drawing math stays in
CSS px; DRAWN = iw <= 900 ? 26 : 40; STEP = iw <= 900 ? 14 : 10), then draw(reduce) once.
Debounced 150 ms on resize; immediate on orientationchange.
draw(still): ctx.clearRect (the canvas is transparent — the body ground shows through;
never paint the ground inside the canvas), then the exact k1/k2/k3, σ, cx, envelope,
two-pass strokes and surge-window clamp from B.4-B.6.
frame(ts): rAF itself first; D.frames++ every tick; first tick initialises last/lastDraw
and returns; dt = min((ts − last)/1000, 0.05); if reduce return AFTER incrementing frames;
else t += dt, run the tilt damping step (7), skip the draw when (ts − lastDraw) < 15.5 ms,
otherwise lastDraw = ts and draw(false).
visibilitychange: hidden -> cancelAnimationFrame; visible -> reset last = 0 and restart.
8b. Canvas failure (I.4): if getContext('2d') returns null, hide #tide, still boot the rAF
loop (frames keep counting), the ticker/sparkline/menu/entrance must still work, and
push 'tide-canvas-fallback' into effects. Push nothing into errors.
9. Effects list — push each implemented effect name exactly once, in this order:
'ribbon-field', 'surge-envelope', 'glass-panel', 'metric-ticker', 'sparkline-svg',
'entrance-stagger', 'pill-shine', 'grain', then 'panel-tilt' only if tilt is active.
(If the canvas failed, replace 'ribbon-field'/'surge-envelope' with
'tide-canvas-fallback' in that position.)
No console.log anywhere. No unhandled promise. No network request other than Google Fonts.
No localStorage, no fetch, no timers other than the single 1200 ms ticker interval and the
150 ms resize debounce. File size target: under 90 KB (this page should land around 24-32 KB).
---
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 page contains exactly one <h1>, split into exactly 2 .headline-line elements each with overflow:hidden, and the footer contains exactly 3 .stat items
- a #tide canvas exists with non-zero width and paints non-blank pixels, and window.__DEMO__.frames advances by at least 20 in 1.2s
- the #metricValue textContent changes within 2.6s of load, and the #sparkLine points attribute contains exactly 48 coordinate pairs
- no element overflows the viewport horizontally at 390, 768, 1440 and 1920 widths, and exactly one .glass panel exists
- window.__DEMO__.effects includes 'ribbon-field', 'surge-envelope', 'metric-ticker' and 'glass-panel', and window.__DEMO__.errors is empty
- 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