jb-website/docs/superpowers/plans/2026-06-03-particle-field-greebling.md
Josh Bairstow f4b8e1a7d8
All checks were successful
deploy / build-and-deploy (push) Successful in 31s
particle field planning
2026-07-23 23:14:04 +10:00

26 KiB
Raw Permalink Blame History

Particle Field + Greebling Restructure — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a full-viewport ambient particle field background layer and restructure existing accent marks to anchor at section edges instead of floating at random viewport positions.

Architecture: A new ParticleField.tsx React island renders a fixed, transparent <canvas> below the content stage (z-index 0, pointer-events none). Marks change from position: fixed (viewport-based) to position: absolute (section-relative) by a single CSS rule change, with each mark moved from the <slot name="marks"> into its host section.

Tech Stack: Astro, React (islands), HTML Canvas API, Perlin/fBm noise (ported from reference), CSS positioning

Spec: docs/superpowers/specs/2026-06-03-particle-field-greebling-design.md


File Map

File Action
src/components/islands/ParticleField.tsx Create — full-viewport particle canvas island
src/styles/site.css Modify.mark positioning: fixed → absolute
src/layouts/BaseLayout.astro Modify — import + render ParticleField; clear slot defaults
src/pages/index.astro Modify — remove slot Fragment; add marks inside sections
src/pages/blog/index.astro Modify — remove slot Fragment; add marks inside sections
src/pages/code/index.astro Modify — remove slot Fragment; add marks inside sections
src/pages/art/index.astro Modify — remove slot Fragment; add marks inside sections
src/pages/404.astro Modify — remove slot Fragment; add marks inside section
src/layouts/PostLayout.astro Modify — remove slot Fragment; add marks inside sections

Task 1: Create ParticleField.tsx

Files:

  • Create: src/components/islands/ParticleField.tsx

This ports the Perlin/fBm engine from reference/particle-field-demo/particleNetwork.js directly into a self-contained React component. The canvas is transparent (no background fill), full-viewport, fixed, and non-interactive. All tunable values live in PARTICLE_CONFIG.

Key differences from the reference:

  • Canvas is full window.innerWidth × window.innerHeight (no aspect-ratio constraint)

  • No background fill — clearRect instead of fillRect

  • Mouse tracking on window (not canvas, which has pointer-events: none)

  • Warm neutral colour palette replacing the reference's cool blues

  • Step 1: Create the component file

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:        130,     // max connection distance (px)
  flowSpeed:    1.8,     // field drift speed (110 scale)
  fieldScale:   4,       // noise zoom level
  dimAtMin:     0.55,    // opacity reduction at field minimum (01)

  particleSpeed: 0.28,
  particleMinR:  1.2,
  particleMaxR:  2.4,

  fieldOffscreenW: 80,
  fieldOffscreenH: 52,
  fieldUpdateMs:   50,
  fieldOctaves:    4,

  timeConstantX: 0.000014 * 60,
  timeConstantY: 0.0000085 * 37,

  // Warm neutral palette — fg-on-dark-2/3 register (#AE9B7F range)
  lineColor:      "174,140,100",
  lineColorMouse: "210,185,150",
  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,
};

// ---- Types -----------------------------------------------------------------

interface Particle { x: number; y: number; vx: number; vy: number; r: number; }
type DrawNode = { x: number; y: number; r: number; isMouse?: boolean };

// ---- 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;

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const cfg = PARTICLE_CONFIG;

    const off = document.createElement("canvas");
    off.width  = cfg.fieldOffscreenW;
    off.height = cfg.fieldOffscreenH;
    const oCtx = off.getContext("2d")!;
    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 = canvas.width  = window.innerWidth;
      H = canvas.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;
      const imgData = oCtx.createImageData(oW, oH);
      const d = imgData.data;
      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);
          const v   = Math.max(0, Math.min(1, raw * 1.8 + 0.5));
          fieldCache[py * oW + px] = v;
          const idx = (py * oW + px) * 4;
          d[idx]     = Math.round(v * 30  + (1 - v) * 5);
          d[idx + 1] = Math.round(v * 110 + (1 - v) * 18);
          d[idx + 2] = Math.round(v * 210 + (1 - v) * 55);
          d[idx + 3] = 255;
        }
      }
      oCtx.putImageData(imgData, 0, 0);
    }

    function draw() {
      const { reach, lineColor, lineColorMouse, glowColorInner, glowColorMid,
              dotBaseR, dotBaseG, dotBaseB, dotBoostR, dotBoostG, dotBoostB } = cfg;
      const reach2 = reach * reach;

      ctx.clearRect(0, 0, W, H);

      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 all: DrawNode[] = [
        ...particles,
        { x: mouse.x, y: mouse.y, r: 0, isMouse: true },
      ];
      const n  = all.length;
      const fv = all.map(p => sampleField(p.x, p.y));
      const fo = fv.map(v => fieldToOpacity(v));

      for (let i = 0; i < n; i++) {
        for (let j = i + 1; j < n; j++) {
          const a = all[i], b = all[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;
          const isMc = a.isMouse || b.isMouse;
          ctx.beginPath();
          ctx.moveTo(a.x, a.y);
          ctx.lineTo(b.x, b.y);
          ctx.strokeStyle = isMc
            ? `rgba(${lineColorMouse},${alpha * 0.9})`
            : `rgba(${lineColor},${alpha * 0.7})`;
          ctx.lineWidth = isMc ? 0.9 : 0.5;
          ctx.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 mb = md < reach ? 0.4 * (1 - md / reach) : 0;
        const gA    = Math.max(0.02, opMult * 0.85 + mb);
        const glowR = p.r * (2.5 + f * 1.5);

        const grd = ctx.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)");
        ctx.beginPath();
        ctx.arc(p.x, p.y, glowR * 3, 0, Math.PI * 2);
        ctx.fillStyle = grd;
        ctx.fill();

        const dR = Math.round(dotBaseR + f * dotBoostR);
        const dG = Math.round(dotBaseG + f * dotBoostG);
        const dB = Math.round(dotBaseB + f * dotBoostB);
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r * (0.8 + f * 0.5), 0, Math.PI * 2);
        ctx.fillStyle = `rgba(${dR},${dG},${dB},${gA})`;
        ctx.fill();
      }
    }

    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",
      }}
    />
  );
}
  • Step 2: Run type-check to verify no TypeScript errors
npx tsc --noEmit

Expected: no errors referencing ParticleField.tsx.

  • Step 3: Commit
git add src/components/islands/ParticleField.tsx
git commit -m "feat(islands): add ambient ParticleField canvas background"

Task 2: Wire ParticleField into BaseLayout and clear mark slot defaults

Files:

  • Modify: src/layouts/BaseLayout.astro

  • Step 1: Add the ParticleField import and render it

In src/layouts/BaseLayout.astro, add the import at the top of the frontmatter:

---
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";
// ... rest of frontmatter unchanged
---

In the <body>, add <ParticleField client:idle /> before .stage and clear the slot defaults:

<body class="dark">
  <ParticleField client:idle />
  <slot name="marks" />
  <div class="stage">
    <TopBar current={current} showCoord={showCoordInTopbar} />
    <slot />
    <SiteFoot />
  </div>
</body>

Note: <slot name="marks" /> stays (with no defaults) so individual pages can still optionally inject fixed marks if needed in future. The default marks are removed — they will be placed inline in each page's sections instead.

  • Step 2: Start the dev server and verify the particle field renders
npm run dev

Open http://localhost:4321. Confirm:

  • Particles and connecting lines are visible as a warm, dim background layer

  • The particle canvas is entirely behind all page content (nav, text, hero zone)

  • Clicking links and buttons still works (pointer events pass through)

  • No console errors

  • Step 3: Commit

git add src/layouts/BaseLayout.astro
git commit -m "feat(layout): integrate ParticleField as sitewide background layer"

Task 3: Change Mark positioning from fixed to absolute

Files:

  • Modify: src/styles/site.css (line 42)

  • Step 1: Change the CSS rule

In src/styles/site.css, find the .mark rule (around line 41) and change position: fixed to position: absolute:

.mark {
  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);
}
  • Step 2: Verify marks are no longer floating

With the dev server still running, check that existing marks on any page have collapsed to their section (they will be mispositioned until Task 49 move them into sections, but they should no longer be floating over the viewport at incorrect positions).

  • Step 3: Commit
git add src/styles/site.css
git commit -m "refactor(marks): switch Mark from fixed to absolute positioning"

Task 4: Migrate marks — Home page (index.astro)

Files:

  • Modify: src/pages/index.astro

Marks move from the <Fragment slot="marks"> into the content sections. Four sections host one mark each. The fifth ringdots (bottom:20% left:44%) is dropped — four marks is enough.

  • Step 1: Remove the slot Fragment and move marks into sections

Replace the entire <Fragment slot="marks">...</Fragment> block with nothing (delete it).

Inside .intro, add a mark as the first child:

<section class="intro">
  <Mark kind="ringdots" width={84} top="-10px" left="-24px" rotate={-5} />
  <Eyebrow>Engineer · Sydney</Eyebrow>
  ...
</section>

Inside .hero, add two marks as children before <HeroZone client:load />:

<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>

Inside .index, add a mark as the first child:

<section class="index">
  <Mark kind="concentric" width={42} bottom="-14px" right="-10px" />
  <Eyebrow>Index</Eyebrow>
  ...
</section>
  • Step 2: Add position: relative to the host sections in the page styles

In the <style> block, add position: relative to .intro, .hero, and .index:

.intro {
  grid-area: intro;
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.hero {
  grid-area: hero;
  position: relative;
  display: flex;
}
.index {
  grid-area: index;
  position: relative;
  text-align: left;
}
  • Step 3: Visual check

In the browser at /, confirm marks appear at the edges of their sections and scroll with the page (not fixed to viewport). Adjust offset values in the markup if positioning looks off — the values above are a starting point for visual iteration.

  • Step 4: Commit
git add src/pages/index.astro
git commit -m "refactor(marks): anchor home page marks to section edges"

Task 5: Migrate marks — Blog index (blog/index.astro)

Files:

  • Modify: src/pages/blog/index.astro

Current marks: dots 54px top-right, concentric 40px mid-left, pipes 26px bottom-right, ringdots 72px bottom-center.

Mapping to sections: .masthead, .feature (or the archive section).

  • Step 1: Remove slot Fragment and move marks into sections

Delete the <Fragment slot="marks">...</Fragment> block.

In the <header class="masthead">, add a mark as the first child:

<header class="masthead">
  <Mark kind="ringdots" width={72} top="-12px" right="-14px" rotate={-4} />
  <div>
    <Eyebrow>Journal · field notes</Eyebrow>
    ...
  </div>
  ...
</header>

In the <section class="archive">, add marks:

<section class="archive">
  <Mark kind="dots"       width={54} top="-14px" right="-10px" />
  <Mark kind="concentric" width={40} bottom="-12px" left="-12px" rotate={6} />
  ...
</section>

Drop the pipes 26px mark (three marks is enough for this page).

  • Step 2: Add position: relative to host sections in the styles
.masthead {
  position: relative;
  display: grid;
  /* ... rest unchanged */
}
.archive {
  position: relative;
  padding: clamp(34px, 6vh, 72px) 0 0;
}
  • Step 3: Visual check at /blog/

Marks should appear at masthead and archive section edges, not floating across the viewport.

  • Step 4: Commit
git add src/pages/blog/index.astro
git commit -m "refactor(marks): anchor blog index marks to section edges"

Task 6: Migrate marks — Code page (code/index.astro)

Files:

  • Modify: src/pages/code/index.astro

Current marks: pipes 30px top-left, dots 48px mid-right, concentric 40px bottom-center.

  • Step 1: Remove slot Fragment and move marks into sections

Delete the <Fragment slot="marks">...</Fragment> block.

In <header class="masthead">:

<header class="masthead">
  <Mark kind="pipes" width={30} top="-10px" left="-10px" rotate={-4} />
  <div>
    <Eyebrow>Workshop · in progress</Eyebrow>
    ...
  </div>
  ...
</header>

In <section class="placeholder">:

<section class="placeholder">
  <Mark kind="dots"       width={48} top="-14px" right="-8px" />
  <Mark kind="concentric" width={40} bottom="-12px" left="-8px" />
  ...
</section>
  • Step 2: Add position: relative in styles
.masthead {
  position: relative;
  display: grid;
  /* ... rest unchanged */
}
.placeholder {
  position: relative;
  padding: clamp(34px, 6vh, 72px) 0 0;
  border-top: 1px solid var(--hairline-on-dark);
  /* ... rest unchanged */
}
  • Step 3: Visual check at /code/

  • Step 4: Commit

git add src/pages/code/index.astro
git commit -m "refactor(marks): anchor code page marks to section edges"

Task 7: Migrate marks — Art page (art/index.astro)

Files:

  • Modify: src/pages/art/index.astro

Current marks: dots 50px top-right, pipes 26px bottom-left, concentric 38px mid-right. The art page has a two-column .gallery layout with a .work section and a .caption aside.

  • Step 1: Remove slot Fragment and move marks into sections

Delete the <Fragment slot="marks">...</Fragment> block.

In <main class="gallery">, add marks targeting the caption column:

<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>
    ...
  </aside>
</main>
  • Step 2: Add position: relative to .caption in styles
.caption {
  position: relative;
  display: flex;
  flex-direction: column;
}
  • Step 3: Visual check at /art/

Confirm the TideStudyCanvas renders normally — the ParticleField is below .stage and TideStudyCanvas is inside .work inside .stage, so they do not conflict.

  • Step 4: Commit
git add src/pages/art/index.astro
git commit -m "refactor(marks): anchor art page marks to caption column"

Task 8: Migrate marks — 404 page (404.astro)

Files:

  • Modify: src/pages/404.astro

Current marks: ringdots 92px top-left, dots 50px mid-right, pipes 28px bottom-center, concentric 44px bottom-right. The 404 page has a single .missing section (flex column, centered).

  • Step 1: Remove slot Fragment and move marks into the section

Delete the <Fragment slot="marks">...</Fragment> block.

In <main class="missing">:

<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>
  ...
</main>

Drop the pipes 28px mark (three marks is enough).

  • Step 2: Add position: relative to .missing in styles
.missing {
  position: relative;
  flex: 1;
  display: flex;
  /* ... rest unchanged */
}
  • Step 3: Visual check at /404 (navigate to any non-existent URL)

  • Step 4: Commit

git add src/pages/404.astro
git commit -m "refactor(marks): anchor 404 page marks to content section"

Task 9: Migrate marks — Post layout (PostLayout.astro)

Files:

  • Modify: src/layouts/PostLayout.astro

Current marks: ringdots 70px top-left, dots 48px mid-right, pipes 26px bottom-left. The post layout has <article> containing .posthead and .prose.

  • Step 1: Remove slot Fragment and move marks into article sections

Delete the <Fragment slot="marks">...</Fragment> block.

In <article>, wrap the marks around .posthead and .postnav:

<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" />
    ...existing content...
  </header>

  <div class="prose col">
    <slot />
  </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>
  </nav>
</article>
  • Step 2: Add position: relative to .posthead and .postnav
.posthead {
  position: relative;
  padding: clamp(40px, 8vh, 104px) 0 clamp(26px, 4vh, 44px);
  text-align: center;
}
.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);
}
  • Step 3: Visual check on a blog post

Navigate to any post. Confirm marks appear at the head and footer of the post, not floating across the viewport.

  • Step 4: Commit
git add src/layouts/PostLayout.astro
git commit -m "refactor(marks): anchor post layout marks to posthead and postnav"

Task 10: Final cross-page verification

  • Step 1: Check all pages

Visit each route and confirm:

  • / — particle field visible, 4 marks at section edges
  • /blog/ — particle field, 3 marks
  • /blog/posts/<any-post>/ — particle field, 3 marks
  • /code/ — particle field, 3 marks
  • /art/ — particle field, 3 marks; TideStudyCanvas unaffected
  • /404 — particle field, 3 marks

For each page:

  • Marks scroll with page content (not fixed to viewport)

  • Particle canvas is always behind all content

  • No visual regressions in typography, nav, interactive zones, or layout

  • Step 2: Check reduced-motion

In browser devtools → Rendering → "Emulate CSS media feature prefers-reduced-motion: reduce". Particle canvas should show a single static frame, not animate.

  • Step 3: Check light theme

Toggle the light theme (if the site has a toggle). Marks should use --mark-filter-light (reduced contrast) and the particle field should remain visible against the lighter ground.

  • Step 4: Final commit if any tweaks were made
git add -p
git commit -m "fix(visual): post-review mark offset tweaks"

Visual Tuning Reference

After the initial pass, iterate on PARTICLE_CONFIG in ParticleField.tsx:

Want more... Adjust
Particles count: 40 → higher
Denser mesh reach: 130 → higher
Faster drift flowSpeed: 1.8 → higher
More field contrast dimAtMin: 0.55 → higher (more dimming at troughs)
Slower/larger blobs fieldScale: 4 → lower
Brighter particles dotBaseR/G/B → higher

For mark placement, edit the top/left/right/bottom values inline in each page file. Values are CSS strings ("px" or "%" both work).