feat(marketing): ambient "shift" canvas hero (replaces the desk illustration)
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

Port of crnacura/AmbientCanvasBackgrounds' shift effect: large circles drift
at random with hue driven by 3D simplex noise, drawn to an offscreen buffer
then blurred and composited onto the visible canvas. Re-tuned to a light brand
palette (pale periwinkle base; blue→violet→coral blooms) so the dark hero copy
stays readable. Self-contained (vendored simplex noise), scoped to the hero,
respects prefers-reduced-motion, and pauses when scrolled out of view / tab
hidden.

- Replace claws-at-desk.png with <HeroAmbient/>; add a soft fade into the
  section below.
- Mask the animated hero region in the marketing @visual snapshot (keeps the
  baseline deterministic) and refresh the baseline.
- Fix a latent AA contrast bug the old illustration was hiding: the CrewStrip
  caption (#77879e on white, 3.65:1) → #647289 (4.87:1).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-15 14:02:35 -07:00
co-authored by Claude Opus 4.8
parent 90b1fe61e6
commit c7c1ff5d23
4 changed files with 330 additions and 15 deletions
@@ -0,0 +1,317 @@
"use client";
import { useEffect, useRef } from "react";
/* Ambient "shift" background — a faithful port of crnacura/AmbientCanvasBackgrounds
* (js/shift.js): a few dozen large circles drift at random, their hue driven by
* 3D simplex noise at their position + time; everything is drawn to an offscreen
* buffer, then blurred and composited onto the visible canvas over a flat base.
* Re-tuned to a light, brand palette (pale periwinkle base; soft blue→violet→coral
* blooms) so the dark hero copy stays readable. Scoped to its container, not the
* window. Respects prefers-reduced-motion and pauses when scrolled out of view. */
// --- tunables (all easy to tweak) ---------------------------------------
const CIRCLE_COUNT = 120;
const BASE_SPEED = 0.06;
const RANGE_SPEED = 0.7;
const BASE_TTL = 180;
const RANGE_TTL = 260;
const BASE_RADIUS = 120;
const RANGE_RADIUS = 240;
const NOISE_OFF = 0.0015; // x/y/z noise sampling step
const BLUR_PX = 50;
const BASE_COLOR = "#edf3fc"; // pale periwinkle base (matches the hero fill)
const HUE_MIN = 205; // blue
const HUE_MAX = 360; // → indigo / violet / magenta / coral
const SAT = 70;
const LIGHT = 74;
const ALPHA = 0.5; // peak blob opacity (scaled by fade-in/out)
const TAU = Math.PI * 2;
const rand = (n: number) => n * Math.random();
const fadeInOut = (t: number, m: number) => {
const hm = 0.5 * m;
return Math.abs(((t + hm) % m) - hm) / hm;
};
// --- minimal 3D simplex noise (public domain, after S. Gustavson / J. Wagner) ---
const GRAD3 = new Float32Array([
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0,
-1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
]);
class SimplexNoise {
private perm = new Uint8Array(512);
private permMod12 = new Uint8Array(512);
constructor(random: () => number = Math.random) {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const n = Math.floor(random() * (i + 1));
const tmp = p[i];
p[i] = p[n];
p[n] = tmp;
}
for (let i = 0; i < 512; i++) {
this.perm[i] = p[i & 255];
this.permMod12[i] = this.perm[i] % 12;
}
}
noise3D(xin: number, yin: number, zin: number): number {
const { perm, permMod12 } = this;
const F3 = 1 / 3;
const G3 = 1 / 6;
let n0 = 0;
let n1 = 0;
let n2 = 0;
let n3 = 0;
const s = (xin + yin + zin) * F3;
const i = Math.floor(xin + s);
const j = Math.floor(yin + s);
const k = Math.floor(zin + s);
const t = (i + j + k) * G3;
const x0 = xin - (i - t);
const y0 = yin - (j - t);
const z0 = zin - (k - t);
let i1, j1, k1, i2, j2, k2;
if (x0 >= y0) {
if (y0 >= z0) {
i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 1; k2 = 0;
} else if (x0 >= z0) {
i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 0; k2 = 1;
} else {
i1 = 0; j1 = 0; k1 = 1; i2 = 1; j2 = 0; k2 = 1;
}
} else {
if (y0 < z0) {
i1 = 0; j1 = 0; k1 = 1; i2 = 0; j2 = 1; k2 = 1;
} else if (x0 < z0) {
i1 = 0; j1 = 1; k1 = 0; i2 = 0; j2 = 1; k2 = 1;
} else {
i1 = 0; j1 = 1; k1 = 0; i2 = 1; j2 = 1; k2 = 0;
}
}
const x1 = x0 - i1 + G3;
const y1 = y0 - j1 + G3;
const z1 = z0 - k1 + G3;
const x2 = x0 - i2 + 2 * G3;
const y2 = y0 - j2 + 2 * G3;
const z2 = z0 - k2 + 2 * G3;
const x3 = x0 - 1 + 3 * G3;
const y3 = y0 - 1 + 3 * G3;
const z3 = z0 - 1 + 3 * G3;
const ii = i & 255;
const jj = j & 255;
const kk = k & 255;
let t0 = 0.6 - x0 * x0 - y0 * y0 - z0 * z0;
if (t0 >= 0) {
const g = permMod12[ii + perm[jj + perm[kk]]] * 3;
t0 *= t0;
n0 = t0 * t0 * (GRAD3[g] * x0 + GRAD3[g + 1] * y0 + GRAD3[g + 2] * z0);
}
let t1 = 0.6 - x1 * x1 - y1 * y1 - z1 * z1;
if (t1 >= 0) {
const g = permMod12[ii + i1 + perm[jj + j1 + perm[kk + k1]]] * 3;
t1 *= t1;
n1 = t1 * t1 * (GRAD3[g] * x1 + GRAD3[g + 1] * y1 + GRAD3[g + 2] * z1);
}
let t2 = 0.6 - x2 * x2 - y2 * y2 - z2 * z2;
if (t2 >= 0) {
const g = permMod12[ii + i2 + perm[jj + j2 + perm[kk + k2]]] * 3;
t2 *= t2;
n2 = t2 * t2 * (GRAD3[g] * x2 + GRAD3[g + 1] * y2 + GRAD3[g + 2] * z2);
}
let t3 = 0.6 - x3 * x3 - y3 * y3 - z3 * z3;
if (t3 >= 0) {
const g = permMod12[ii + 1 + perm[jj + 1 + perm[kk + 1]]] * 3;
t3 *= t3;
n3 = t3 * t3 * (GRAD3[g] * x3 + GRAD3[g + 1] * y3 + GRAD3[g + 2] * z3);
}
return 32 * (n0 + n1 + n2 + n3);
}
}
const PROPS = 8; // x, y, vx, vy, life, ttl, radius, hue
export function HeroAmbient() {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
// Offscreen buffer (a) + visible canvas (b), mirroring shift.js.
const a = document.createElement("canvas");
const b = document.createElement("canvas");
b.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
host.appendChild(b);
const actx = a.getContext("2d")!;
const bctx = b.getContext("2d")!;
const simplex = new SimplexNoise();
const circles = new Float32Array(CIRCLE_COUNT * PROPS);
let baseHue = HUE_MIN;
let raf = 0;
let running = true;
function initCircle(i: number) {
const x = rand(a.width);
const y = rand(a.height);
const n = simplex.noise3D(x * NOISE_OFF, y * NOISE_OFF, baseHue * NOISE_OFF);
const ang = rand(TAU);
const speed = BASE_SPEED + rand(RANGE_SPEED);
const hue = HUE_MIN + ((n + 1) / 2) * (HUE_MAX - HUE_MIN);
circles.set(
[
x,
y,
speed * Math.cos(ang),
speed * Math.sin(ang),
0,
BASE_TTL + rand(RANGE_TTL),
BASE_RADIUS + rand(RANGE_RADIUS),
hue,
],
i,
);
}
function drawCircle(
x: number,
y: number,
life: number,
ttl: number,
radius: number,
hue: number,
) {
actx.save();
actx.fillStyle = `hsla(${hue},${SAT}%,${LIGHT}%,${fadeInOut(life, ttl) * ALPHA})`;
actx.beginPath();
actx.arc(x, y, radius, 0, TAU);
actx.fill();
actx.restore();
}
function updateCircle(i: number) {
const x = circles[i] + circles[i + 2];
const y = circles[i + 1] + circles[i + 3];
const life = circles[i + 4] + 1;
const ttl = circles[i + 5];
const radius = circles[i + 6];
drawCircle(circles[i], circles[i + 1], life, ttl, radius, circles[i + 7]);
circles[i] = x;
circles[i + 1] = y;
circles[i + 4] = life;
const out =
x < -radius ||
x > a.width + radius ||
y < -radius ||
y > a.height + radius;
if (out || life > ttl) initCircle(i);
}
function frame() {
actx.clearRect(0, 0, a.width, a.height);
bctx.fillStyle = BASE_COLOR;
bctx.fillRect(0, 0, b.width, b.height);
baseHue += 0.05; // slow drift keeps the band gently shifting
if (baseHue > HUE_MAX) baseHue = HUE_MIN;
for (let i = 0; i < circles.length; i += PROPS) updateCircle(i);
bctx.save();
bctx.filter = `blur(${BLUR_PX}px)`;
bctx.drawImage(a, 0, 0);
bctx.restore();
if (running) raf = window.requestAnimationFrame(frame);
}
function resize() {
const w = Math.max(1, Math.floor(host!.clientWidth));
const h = Math.max(1, Math.floor(host!.clientHeight));
a.width = w;
a.height = h;
b.width = w;
b.height = h;
}
function start() {
if (raf) return;
running = true;
raf = window.requestAnimationFrame(frame);
}
function stop() {
running = false;
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
resize();
for (let i = 0; i < circles.length; i += PROPS) initCircle(i);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
// Static single frame — no animation.
for (let i = 0; i < circles.length; i += PROPS) updateCircle(i);
bctx.fillStyle = BASE_COLOR;
bctx.fillRect(0, 0, b.width, b.height);
bctx.save();
bctx.filter = `blur(${BLUR_PX}px)`;
bctx.drawImage(a, 0, 0);
bctx.restore();
}
const ro = new ResizeObserver(() => {
resize();
if (reduce) {
actx.clearRect(0, 0, a.width, a.height);
for (let i = 0; i < circles.length; i += PROPS) updateCircle(i);
bctx.fillStyle = BASE_COLOR;
bctx.fillRect(0, 0, b.width, b.height);
bctx.save();
bctx.filter = `blur(${BLUR_PX}px)`;
bctx.drawImage(a, 0, 0);
bctx.restore();
}
});
ro.observe(host);
// Pause when the hero is scrolled out of view (saves CPU/battery).
const io = reduce
? null
: new IntersectionObserver(
([e]) => (e.isIntersecting ? start() : stop()),
{ threshold: 0 },
);
if (io) io.observe(host);
else if (!reduce) start();
const onVisibility = () => {
if (reduce) return;
if (document.hidden) stop();
else start();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io?.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
b.remove();
};
}, []);
return (
<div
ref={hostRef}
aria-hidden
data-hero-ambient
// Solid base (the same color the canvas paints) so the hero copy has a
// determinate, readable background — both for real contrast and so axe
// can resolve it through the overlapping <canvas>.
className="absolute inset-0 -z-0"
style={{ backgroundColor: BASE_COLOR }}
/>
);
}
+10 -15
View File
@@ -1,6 +1,8 @@
import Image from "next/image";
import Link from "next/link";
import { HeroAmbient } from "./HeroAmbient";
/* Marketing landing — WorkClaw's full section layout/styling reproduced 1:1,
Clawmates-branded with honest claims (no borrowed customer logos, no
unverified SOC 2). Measurements/colours lifted from the live source.
@@ -19,6 +21,8 @@ export function Hero() {
className="relative -mt-[98px] h-[760px] w-full overflow-hidden bg-[#ebf4fd] sm:h-[720px] md:h-[760px] lg:h-[820px] xl:h-[900px] 2xl:h-[963px]"
style={{ zIndex: 2 }}
>
{/* Animated ambient backdrop (replaces the hero illustration). */}
<HeroAmbient />
<div
className="absolute left-1/2 flex w-full max-w-[720px] -translate-x-1/2 flex-col items-center px-6 text-center"
style={{ top: "clamp(160px, 12.0139vw, 173px)", gap: 48, zIndex: 2 }}
@@ -96,21 +100,12 @@ export function Hero() {
))}
</ul>
</div>
{/* Soft fade into the white section below. */}
<div
className="absolute left-1/2 flex w-[600px] -translate-x-1/2 justify-center sm:w-[740px] md:w-[820px] lg:w-[920px] xl:w-[1020px] 2xl:w-[1098px]"
style={{ bottom: "-9%", zIndex: 1 }}
>
<Image
src="/images/marketing/claws-at-desk.png"
alt="The clawmate crew working at a shared desk"
width={1672}
height={919}
priority
unoptimized
className="h-auto w-full"
style={{ filter: "drop-shadow(0 24px 40px rgba(22,29,39,0.08))" }}
/>
</div>
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-gradient-to-b from-transparent to-white"
style={{ zIndex: 1 }}
/>
</section>
);
}
@@ -120,7 +115,7 @@ export function CrewStrip() {
return (
<div className="relative w-full" style={{ zIndex: 1 }}>
<div className="relative z-[1] flex flex-col items-center gap-6 pt-8 pb-14 lg:gap-8">
<p className="text-center text-[16px] font-semibold text-[#77879e]">
<p className="text-center text-[16px] font-semibold text-[#647289]">
A whole crew, built on the open{" "}
<span className="text-marketing-coral-strong">Claw</span> runtime
</p>