feat(marketing): ambient "shift" canvas hero (replaces the desk illustration)
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:
co-authored by
Claude Opus 4.8
parent
90b1fe61e6
commit
c7c1ff5d23
@@ -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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { HeroAmbient } from "./HeroAmbient";
|
||||||
|
|
||||||
/* Marketing landing — WorkClaw's full section layout/styling reproduced 1:1,
|
/* Marketing landing — WorkClaw's full section layout/styling reproduced 1:1,
|
||||||
Clawmates-branded with honest claims (no borrowed customer logos, no
|
Clawmates-branded with honest claims (no borrowed customer logos, no
|
||||||
unverified SOC 2). Measurements/colours lifted from the live source.
|
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]"
|
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 }}
|
style={{ zIndex: 2 }}
|
||||||
>
|
>
|
||||||
|
{/* Animated ambient backdrop (replaces the hero illustration). */}
|
||||||
|
<HeroAmbient />
|
||||||
<div
|
<div
|
||||||
className="absolute left-1/2 flex w-full max-w-[720px] -translate-x-1/2 flex-col items-center px-6 text-center"
|
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 }}
|
style={{ top: "clamp(160px, 12.0139vw, 173px)", gap: 48, zIndex: 2 }}
|
||||||
@@ -96,21 +100,12 @@ export function Hero() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Soft fade into the white section below. */}
|
||||||
<div
|
<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]"
|
aria-hidden
|
||||||
style={{ bottom: "-9%", zIndex: 1 }}
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-gradient-to-b from-transparent to-white"
|
||||||
>
|
style={{ 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>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,7 +115,7 @@ export function CrewStrip() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative w-full" style={{ zIndex: 1 }}>
|
<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">
|
<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{" "}
|
A whole crew, built on the open{" "}
|
||||||
<span className="text-marketing-coral-strong">Claw</span> runtime
|
<span className="text-marketing-coral-strong">Claw</span> runtime
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ test("marketing landing looks right @visual", async ({ page, context }) => {
|
|||||||
await expect(page).toHaveScreenshot("marketing.png", {
|
await expect(page).toHaveScreenshot("marketing.png", {
|
||||||
...SCREENSHOT,
|
...SCREENSHOT,
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
|
// The hero backdrop is an animated, randomized canvas — mask it so the
|
||||||
|
// baseline stays deterministic (the heading is asserted separately above).
|
||||||
|
mask: [page.locator("[data-hero-ambient]")],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
Reference in New Issue
Block a user