The blog index used to be flat editorial rows. Title, subtitle, date, tags, border. Clean but anonymous. Every post looked the same regardless of whether it was a portfolio build log, a vault deep dive, or a project_void devlog. With the project column already in Supabase and flowing through posts.json, the data was there. It just wasn't doing anything visual.

This session changed that.


The idea was straightforward: each project gets a background image on its blog card, and the card styling shifts to match the project's identity. Portfolio posts get a watercolour wash with Meeko's pink ink splatters. Vault posts get the actual vault scene background. project_void posts get a dark, grimy texture that looks like it crawled out of a Soulsborne concept art folder.

The portfolio background needed generating. I fed ChatGPT a prompt for an abstract watercolour wash in the Meeko colour palette, cool greys and warm whites, then iterated to add scattered ink splatters in the site's accent pink. The first pass was too smooth and digital. Adding the splatters gave it that hand-made texture that ties back to the illustration style without literally putting a cat on every card.

For the card structure, each PostPreview now carries a data-project attribute from the post data. The background lives in a real DOM element rather than a ::before pseudo, because you can't apply JavaScript transforms to pseudo-elements, and we needed that for the parallax.


The parallax was a "while we're here" addition that turned out to be the thing that really sells the whole feature. A useParallax hook reads scroll position, maps each card's viewport position to a vertical offset, and shifts the background div by up to 25 pixels in either direction. The background div is deliberately taller than the card by 25px on each side so the shift never exposes empty edges.

function useParallax(ref, amount = 25) {
  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (prefersReduced) return;

    let ticking = false;

    function onScroll() {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(() => {
        const rect = el.getBoundingClientRect();
        const viewH = window.innerHeight;
        const progress = 1 - (rect.top + rect.height) / (viewH + rect.height);
        const offset = (progress - 0.5) * 2 * amount;
        const bg = el.querySelector(".post-preview-bg");
        if (bg) bg.style.transform = `translateY(${offset}px)`;
        ticking = false;
      });
    }

    window.addEventListener("scroll", onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener("scroll", onScroll);
  }, [ref, amount]);
}

It respects prefers-reduced-motion, throttles via requestAnimationFrame, and uses a passive scroll listener. The will-change: transform on the background div hints to the browser to GPU-composite it. The effect is subtle enough that you might not consciously notice it, but it gives the cards a sense of depth that flat backgrounds don't have.


The vault and project_void cards needed special treatment. Both use dark backgrounds with busy textures, so plain white text would get lost. Two things fix readability: a text-shadow on the content wrapper that creates a soft dark halo behind every character, and deliberately lighter/warmer text colours pulled from the vault page's own palette.

Tags on these dark cards also needed overriding. The base .tag style uses background: var(--surface) which is white, creating these jarring bright pills floating over a moody background. Swapping to dark semi-transparent pills with muted text made them feel native to the card.

The vault card colours were matched directly to vault.css, using the same hsl(35 90% 65%) accent for hover states and hsl(35 20% 92%) for headings. project_void gets a more desaturated, cooler treatment. Same structural approach, different personality.


The blog post page got the same treatment. Each post header now has the project background behind it, wrapped in a rounded card with the same dark island styling for vault and project_void. Portfolio posts get the pink watercolour. The background sits behind a .post-header-content wrapper so the text always layers above it.

One silly bug during this work: a stray backslash at the end of a JSX map callback in BlogPost.jsx that I introduced when rewriting the file. Rendered the whole component unparseable. That kind of thing is why you always check the dev server console after a full file rewrite.

The portfolio background image also gets filter: saturate(1.6) applied via CSS. The generated watercolour was too washed out at the opacity levels needed for readability, so rather than regenerating the image, bumping saturation in CSS brought the pink through without touching the source file. Cheaper than another round of prompt engineering.


Next up for the vault: the black hole, a CSS-animated interactive element in the vault scene that routes to project_void's technical docs. After that, vault sidebar nav for projects with multiple entries. The blog index is no longer the plainest page on the site, and now each project carries its visual identity all the way from the index card through to the post header. That consistency matters more than any individual effect.