Compare commits

...

15 commits

Author SHA1 Message Date
ae60a7c856 refactor(home): finalise hero subhead copy
All checks were successful
deploy / build-and-deploy (push) Successful in 11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:43:15 +10:00
27e743bffd feat(visual): particle field background + section-anchored accent marks 2026-06-03 16:39:44 +10:00
f533e9933b chore(particle-field): visual tuning — tighter glow, dimmer field, cursor brightness boost 2026-06-03 16:38:38 +10:00
993cedf0ed chore(layout): remove orphaned marks slot 2026-06-03 16:00:40 +10:00
73170a29e5 chore(layout): remove unused Mark import from BaseLayout 2026-06-03 15:55:52 +10:00
d71a7a214d refactor(marks): anchor post layout marks to posthead and postnav 2026-06-03 15:51:28 +10:00
45e84c1856 refactor(marks): anchor 404 page marks to content section 2026-06-03 15:51:02 +10:00
867a8c1762 refactor(marks): anchor art page marks to caption column 2026-06-03 15:50:49 +10:00
cfc5983f41 refactor(marks): anchor code page marks to section edges 2026-06-03 15:50:36 +10:00
8bcd2486e3 refactor(marks): anchor blog index marks to section edges 2026-06-03 15:50:16 +10:00
91546ab393 refactor(marks): anchor home page marks to section edges 2026-06-03 15:49:51 +10:00
be6e9ea13d refactor(marks): switch Mark from fixed to absolute positioning 2026-06-03 15:49:20 +10:00
f112e26784 feat(layout): integrate ParticleField as sitewide background layer 2026-06-03 15:46:50 +10:00
f8113f7a94 refactor(islands): remove dead offscreen canvas pixel writes from updateField 2026-06-03 15:45:59 +10:00
24db26177e feat(islands): add ambient ParticleField canvas background
Self-contained React canvas island with Perlin/fBm noise-driven particle
field; warm ochre palette, transparent background, pointer-events none.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:42:11 +10:00
9 changed files with 317 additions and 50 deletions

View file

@ -0,0 +1,283 @@
import { useEffect, useRef } from "react";
// ---- Perlin noise helpers ---------------------------------------------------
function fade(t: number) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a: number, b: number, t: number) { return a + t * (b - a); }
function grad(h: number, x: number, y: number) {
h &= 7;
const u = h < 4 ? x : y, v = h < 4 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
function buildPermutation(): number[] {
const a = Array.from({ length: 256 }, (_, i) => i);
for (let i = 255; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return [...a, ...a];
}
function noise2(P: number[], x: number, y: number): number {
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
x -= Math.floor(x); y -= Math.floor(y);
const u = fade(x), v = fade(y);
const aa = P[X] + Y, bb = P[X + 1] + Y;
return lerp(
lerp(grad(P[aa], x, y), grad(P[bb], x - 1, y), u),
lerp(grad(P[aa + 1], x, y - 1), grad(P[bb + 1], x - 1, y - 1), u),
v,
);
}
function fbm(P: number[], x: number, y: number, octaves = 4): number {
let value = 0, amp = 0.5, freq = 1, max = 0;
for (let i = 0; i < octaves; i++) {
value += noise2(P, x * freq, y * freq) * amp;
max += amp;
amp *= 0.5;
freq *= 2;
}
return value / max;
}
// ---- Config — all tunable values in one place ------------------------------
const PARTICLE_CONFIG = {
count: 40, // number of particles
reach: 600, // max connection distance (px)
flowSpeed: 1.0, // field drift speed (110 scale)
fieldScale: 1, // noise zoom level
dimAtMin: 0.3, // opacity reduction at field minimum (01)
particleSpeed: 0.14,
particleMinR: 0.8,
particleMaxR: 1.6,
fieldOffscreenW: 80,
fieldOffscreenH: 52,
fieldUpdateMs: 50,
fieldOctaves: 4,
timeConstantX: 0.000014 * 30,
timeConstantY: 0.0000085 * 20,
// Warm neutral palette — fg-on-dark-2/3 register (#AE9B7F range)
lineColor: "174,140,100",
glowColorInner: "200,170,130",
glowColorMid: "155,120,82",
// Dot colour at fv=0 (dim) and per-unit boost at fv=1 (bright)
dotBaseR: 174, dotBaseG: 145, dotBaseB: 110,
dotBoostR: 30, dotBoostG: 35, dotBoostB: 40,
// Glow: base radius multiplier (relative to particle r); field peaks expand by +1.5×
glowScale: 1.2,
// Master opacity applied to the entire field (01); 0.5 = half brightness
masterOpacity: 0.4,
// Max brightness boost on particles near the cursor (01); 0.30 = 30% increase
cursorBoostMax: 1.0,
};
// ---- Types -----------------------------------------------------------------
interface Particle { x: number; y: number; vx: number; vy: number; r: number; }
// ---- Component -------------------------------------------------------------
export default function ParticleField() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Re-bind as non-nullable so nested functions can reference them safely
const cvs: HTMLCanvasElement = canvas;
const c: CanvasRenderingContext2D = ctx;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const cfg = PARTICLE_CONFIG;
const fieldCache = new Float32Array(cfg.fieldOffscreenW * cfg.fieldOffscreenH);
const P = buildPermutation();
let particles: Particle[] = [];
const mouse = { x: -9999, y: -9999 };
let elapsed = 0;
let lastTs: number | null = null;
let fieldAge = 9999;
let rafId = 0;
let W = 0, H = 0;
function resize() {
W = cvs.width = window.innerWidth;
H = cvs.height = window.innerHeight;
syncCount();
}
function makeParticle(): Particle {
return {
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * cfg.particleSpeed,
vy: (Math.random() - 0.5) * cfg.particleSpeed,
r: cfg.particleMinR + Math.random() * (cfg.particleMaxR - cfg.particleMinR),
};
}
function syncCount() {
while (particles.length < cfg.count) particles.push(makeParticle());
while (particles.length > cfg.count) particles.pop();
}
function sampleField(x: number, y: number): number {
const oW = cfg.fieldOffscreenW, oH = cfg.fieldOffscreenH;
const cx = Math.max(0, Math.min(oW - 1, Math.round((x / W) * (oW - 1))));
const cy = Math.max(0, Math.min(oH - 1, Math.round((y / H) * (oH - 1))));
return fieldCache[cy * oW + cx];
}
function fieldToOpacity(fv: number): number {
const minMult = 1 - cfg.dimAtMin;
return minMult + (1 - minMult) * fv;
}
function updateField() {
const { fieldOffscreenW: oW, fieldOffscreenH: oH, fieldScale,
fieldOctaves, timeConstantX, timeConstantY } = cfg;
const scale = fieldScale * 0.0018;
const ox = elapsed * timeConstantX;
const oy = elapsed * timeConstantY;
for (let py = 0; py < oH; py++) {
for (let px = 0; px < oW; px++) {
const raw = fbm(P, px * scale + ox, py * scale + oy, fieldOctaves);
fieldCache[py * oW + px] = Math.max(0, Math.min(1, raw * 1.8 + 0.5));
}
}
}
function draw() {
const { reach, lineColor, glowColorInner, glowColorMid,
dotBaseR, dotBaseG, dotBaseB, dotBoostR, dotBoostG, dotBoostB,
glowScale, masterOpacity, cursorBoostMax } = cfg;
const reach2 = reach * reach;
c.clearRect(0, 0, W, H);
c.globalAlpha = masterOpacity;
for (const p of particles) {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > W) p.vx *= -1;
if (p.y < 0 || p.y > H) p.vy *= -1;
}
const fv = particles.map(p => sampleField(p.x, p.y));
const fo = fv.map(v => fieldToOpacity(v));
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const a = particles[i], b = particles[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 >= reach2) continue;
const dist = Math.sqrt(d2);
const t01 = dist / reach;
const expFade = (1 - t01) ** 3;
const lineMult = Math.sqrt(fo[i] * fo[j]);
const alpha = expFade * lineMult * 0.85;
if (alpha < 0.005) continue;
c.beginPath();
c.moveTo(a.x, a.y);
c.lineTo(b.x, b.y);
c.strokeStyle = `rgba(${lineColor},${alpha * 0.7})`;
c.lineWidth = 0.5;
c.stroke();
}
}
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const f = fv[i];
const opMult = fo[i];
const dx = p.x - mouse.x, dy = p.y - mouse.y;
const md = Math.sqrt(dx * dx + dy * dy);
const proximity = md < reach ? 1 - md / reach : 0;
const cursorBoost = cursorBoostMax * proximity ** 3;
const gA = Math.max(0.02, opMult * 0.85 * (1 + cursorBoost));
const glowR = p.r * (glowScale + f * 1.5);
const grd = c.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR * 3);
grd.addColorStop(0, `rgba(${glowColorInner},${gA * 0.55})`);
grd.addColorStop(0.35, `rgba(${glowColorMid},${gA * 0.22})`);
grd.addColorStop(1, "rgba(0,0,0,0)");
c.beginPath();
c.arc(p.x, p.y, glowR * 3, 0, Math.PI * 2);
c.fillStyle = grd;
c.fill();
const dR = Math.round(dotBaseR + f * dotBoostR);
const dG = Math.round(dotBaseG + f * dotBoostG);
const dB = Math.round(dotBaseB + f * dotBoostB);
c.beginPath();
c.arc(p.x, p.y, p.r * (0.8 + f * 0.5), 0, Math.PI * 2);
c.fillStyle = `rgba(${dR},${dG},${dB},${gA})`;
c.fill();
}
c.globalAlpha = 1;
}
function loop(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
elapsed += dt * (cfg.flowSpeed / 3);
fieldAge += dt;
if (fieldAge >= cfg.fieldUpdateMs) {
updateField();
fieldAge = 0;
}
draw();
rafId = requestAnimationFrame(loop);
}
const onMouseMove = (e: MouseEvent) => { mouse.x = e.clientX; mouse.y = e.clientY; };
const onMouseLeave = () => { mouse.x = -9999; mouse.y = -9999; };
const onResize = () => resize();
resize();
window.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseleave", onMouseLeave);
window.addEventListener("resize", onResize);
if (reduce) {
updateField();
draw();
} else {
rafId = requestAnimationFrame(loop);
}
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseleave", onMouseLeave);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: "fixed",
inset: 0,
width: "100vw",
height: "100vh",
pointerEvents: "none",
zIndex: 0,
display: "block",
}}
/>
);
}

View file

@ -3,7 +3,7 @@ import "~/styles/colors_and_type.css";
import "~/styles/site.css";
import TopBar from "~/components/organisms/TopBar.astro";
import SiteFoot from "~/components/organisms/SiteFoot.astro";
import Mark from "~/components/atoms/Mark.astro";
import ParticleField from "~/components/islands/ParticleField";
type CurrentSection = "home" | "blog" | "art" | "code";
@ -50,13 +50,7 @@ const canonicalHref = canonical ?? new URL(Astro.url.pathname, Astro.site).toStr
<meta property="og:url" content={canonicalHref} />
</head>
<body class="dark">
<!-- sprinkled accent marks — loose, non-rigid placement, per page-of-page overrides via slot -->
<slot name="marks">
<Mark kind="ringdots" width={84} top="14%" left="3%" rotate={-5} />
<Mark kind="pipes" width={28} top="73%" left="6%" rotate={8} />
<Mark kind="dots" width={52} top="26%" right="3.5%" />
<Mark kind="concentric" width={42} bottom="9%" right="7%" />
</slot>
<ParticleField client:idle />
<div class="stage">
<TopBar current={current} showCoord={showCoordInTopbar} />

View file

@ -23,14 +23,10 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric
description={description ?? standfirst}
current="blog"
>
<Fragment slot="marks">
<Mark kind="ringdots" width={70} top="16%" left="4%" rotate={-6} />
<Mark kind="dots" width={48} top="58%" right="3.5%" />
<Mark kind="pipes" width={26} bottom="14%" left="6%" rotate={5} />
</Fragment>
<article>
<header class="posthead col">
<Mark kind="ringdots" width={70} top="-12px" left="-16px" rotate={-6} />
<Mark kind="dots" width={48} top="-12px" right="-12px" />
{(tag || series) && (
<span class="kicker">
{tag && <span class="tag">{tag}</span>}
@ -53,6 +49,7 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric
</div>
<nav class="postnav col-wide">
<Mark kind="pipes" width={26} bottom="-8px" left="-8px" rotate={5} />
<a class="back" href={blog("/")}>
<span class="lbl">← Back to writing</span>
</a>
@ -66,6 +63,7 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric
.col-wide { max-width: 940px; margin-left: auto; margin-right: auto; }
.posthead {
position: relative;
padding: clamp(40px, 8vh, 104px) 0 clamp(26px, 4vh, 44px);
text-align: center;
}
@ -203,6 +201,7 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric
:global(body:not(.dark)) .prose :global(blockquote) { color: var(--fg-1); }
.postnav {
position: relative;
padding: clamp(40px, 6vh, 72px) 0 clamp(20px, 3vh, 32px);
margin-top: clamp(30px, 5vh, 56px);
border-top: 1px solid var(--hairline-on-dark);

View file

@ -6,14 +6,10 @@ import { home } from "~/lib/urls";
---
<BaseLayout title="Not yet here — Josh Bairstow" description="The page you were looking for doesn't exist yet.">
<Fragment slot="marks">
<Mark kind="ringdots" width={92} top="18%" left="6%" rotate={-8} />
<Mark kind="dots" width={50} top="64%" right="6%" />
<Mark kind="pipes" width={28} bottom="18%" left="40%" rotate={10} />
<Mark kind="concentric" width={44} bottom="22%" right="14%" />
</Fragment>
<main class="missing">
<Mark kind="ringdots" width={92} top="-14px" left="-16px" rotate={-8} />
<Mark kind="dots" width={50} top="-12px" right="-12px" />
<Mark kind="concentric" width={44} bottom="-14px" right="-12px" />
<p class="num">404</p>
<h1>Not yet here<span class="accent">.</span></h1>
<p class="lead">
@ -29,6 +25,7 @@ import { home } from "~/lib/urls";
<style>
.missing {
position: relative;
flex: 1;
display: flex;
flex-direction: column;

View file

@ -10,18 +10,15 @@ import TideStudyCanvas from "~/components/islands/TideStudyCanvas";
description="A real-time field of warm rings, seeded each morning by the tide chart off Manly."
current="art"
>
<Fragment slot="marks">
<Mark kind="dots" width={50} top="20%" right="4%" />
<Mark kind="pipes" width={26} bottom="16%" left="4%" rotate={6} />
<Mark kind="concentric" width={38} top="70%" right="8%" />
</Fragment>
<main class="gallery">
<section class="work">
<TideStudyCanvas client:load />
</section>
<aside class="caption">
<Mark kind="dots" width={50} top="-14px" right="-12px" />
<Mark kind="pipes" width={26} bottom="-8px" left="-8px" rotate={6} />
<Mark kind="concentric" width={38} top="40%" right="-14px" />
<span class="idx">Work 07 / 12</span>
<p class="eyebrow series-line">Tide studies · generative</p>
<h1>Tide Study <em>No. 7</em></h1>
@ -125,6 +122,7 @@ import TideStudyCanvas from "~/components/islands/TideStudyCanvas";
}
.caption {
position: relative;
display: flex;
flex-direction: column;
}

View file

@ -59,14 +59,8 @@ for (const post of posts) {
description="Short pieces on craft, the ocean, and the quiet discipline of making small software."
current="blog"
>
<Fragment slot="marks">
<Mark kind="dots" width={54} top="18%" right="4%" />
<Mark kind="concentric" width={40} top="64%" left="3%" rotate={6} />
<Mark kind="pipes" width={26} bottom="12%" right="9%" />
<Mark kind="ringdots" width={72} bottom="26%" left="46%" rotate={-4} />
</Fragment>
<header class="masthead">
<Mark kind="ringdots" width={72} top="-12px" right="-14px" rotate={-4} />
<div>
<Eyebrow>Journal · field notes</Eyebrow>
<h1 class="title">Writing<span style="color:var(--accent-deep)">.</span></h1>
@ -109,6 +103,8 @@ for (const post of posts) {
}
<section class="archive">
<Mark kind="dots" width={54} top="-14px" right="-10px" />
<Mark kind="concentric" width={40} bottom="-12px" left="-12px" rotate={6} />
{
yearGroups.map(([year, items]) => (
<>
@ -135,6 +131,7 @@ for (const post of posts) {
<style>
.masthead {
position: relative;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: clamp(30px, 6vw, 80px);
@ -256,6 +253,7 @@ for (const post of posts) {
}
.archive {
position: relative;
padding: clamp(34px, 6vh, 72px) 0 0;
}

View file

@ -11,13 +11,8 @@ import { blog, home } from "~/lib/urls";
description="Experiments and small tools. Coming online piece by piece."
current="code"
>
<Fragment slot="marks">
<Mark kind="pipes" width={30} top="22%" left="6%" rotate={-4} />
<Mark kind="dots" width={48} top="68%" right="6%" />
<Mark kind="concentric" width={40} bottom="12%" left="44%" />
</Fragment>
<header class="masthead">
<Mark kind="pipes" width={30} top="-10px" left="-10px" rotate={-4} />
<div>
<Eyebrow>Workshop · in progress</Eyebrow>
<h1 class="title">Code<span class="accent">.</span></h1>
@ -30,6 +25,8 @@ import { blog, home } from "~/lib/urls";
</header>
<section class="placeholder">
<Mark kind="dots" width={48} top="-14px" right="-8px" />
<Mark kind="concentric" width={40} bottom="-12px" left="-8px" />
<p>
In the meantime, the writing index is the closest thing to a working log:
</p>
@ -45,6 +42,7 @@ import { blog, home } from "~/lib/urls";
<style>
.masthead {
position: relative;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: clamp(30px, 6vw, 80px);
@ -82,6 +80,7 @@ import { blog, home } from "~/lib/urls";
:global(body:not(.dark)) .masthead .blurb { color: var(--fg-2); }
.placeholder {
position: relative;
padding: clamp(34px, 6vh, 72px) 0 0;
border-top: 1px solid var(--hairline-on-dark);
display: flex;

View file

@ -31,26 +31,21 @@ const indexItems = [
---
<BaseLayout title="Josh Bairstow" current="home" showCoordInTopbar={true}>
<Fragment slot="marks">
<Mark kind="ringdots" width={84} top="14%" left="3%" rotate={-5} />
<Mark kind="pipes" width={28} top="73%" left="6%" rotate={8} />
<Mark kind="dots" width={52} top="26%" right="3.5%" />
<Mark kind="concentric" width={42} bottom="9%" right="7%" />
<Mark kind="ringdots" width={66} bottom="20%" left="44%" rotate={-3} />
</Fragment>
<main class="home" data-layout="stack">
<section class="intro">
<Mark kind="ringdots" width={84} top="-10px" left="-24px" rotate={-5} />
<Eyebrow>engineer errant · Sydney</Eyebrow>
<h2 class="statement">
Deliberate work.<br /><em>Restless inquiry</em><span class="accent">.</span>
</h2>
<p class="lead">
Software, pondering, and the occasional ink mark in my quiet corner of the internet.
Software, pondering, and the occasional ink mark in a quiet corner of the internet.
</p>
</section>
<section class="hero">
<Mark kind="dots" width={52} top="-16px" right="-12px" />
<Mark kind="pipes" width={28} bottom="-8px" left="-6px" rotate={8} />
<HeroZone client:load />
</section>
@ -68,6 +63,7 @@ const indexItems = [
}
<section class="index">
<Mark kind="concentric" width={42} bottom="-14px" right="-10px" />
<Eyebrow>Index</Eyebrow>
<nav>
{indexItems.map((item) => (
@ -100,6 +96,7 @@ const indexItems = [
}
.intro {
grid-area: intro;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
@ -143,6 +140,7 @@ const indexItems = [
.hero {
grid-area: hero;
position: relative;
display: flex;
}
/* Styles for .hzframe + its children come from the React island's class names */
@ -215,6 +213,7 @@ const indexItems = [
.index {
grid-area: index;
position: relative;
text-align: left;
}
.index :global(.eyebrow) {

View file

@ -39,7 +39,7 @@ body:not(.dark)::before { opacity: 0.045; mix-blend-mode: normal; }
--mark-rest: 0.16;
}
.mark {
position: fixed; pointer-events: none; z-index: 0;
position: absolute; pointer-events: none; z-index: 0;
filter: var(--mark-filter-dark);
opacity: var(--mark-rest);
transition: opacity var(--dur-slow) var(--ease-out);