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 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>
|
||||
|
||||
Reference in New Issue
Block a user