I figured I should incorporate the two site mascots into the image background for the vault. Meeko on the right, Mayu on the left. Clicking either should produce a speech bubble with a typewriter-animated affirmation pulled from the relevant Supabase affirmations table. Both can be visible at the same time. One cat firing has zero effect on the other.

The current implementation does all of that correctly. Getting there involved one genuinely subtle architectural mistake and several less subtle ones. This post documents how the system works now and what the wrong versions revealed.


The hook: useAffirmation

useAffirmation(theme) handles one cat's full affirmation lifecycle: fetch, random selection, typewriter animation, and cancellation.

export function useAffirmation(theme) {
  const [quote, setQuote]         = useState('')
  const [displayed, setDisplayed] = useState('')
  const [rendering, setRendering] = useState(false)

  const typewriterRef = useRef(null)
  const cancelRef     = useRef(null)

  const fetchAffirmation = useCallback(async () => {
    if (cancelRef.current) cancelRef.current()
    if (typewriterRef.current) { clearInterval(typewriterRef.current); typewriterRef.current = null }
    // ...
  }, [theme])

  return { quote, displayed, rendering, fetchAffirmation }
}

rendering is true for the full lifecycle -- fetch in-flight plus typewriter running. It only goes false when the last character is written (or on error). This is distinct from a plain loading flag, which would clear when the fetch resolved but before the typewriter finished. The click guard in Vault.jsx uses rendering, so you can't fire a new affirmation mid-typewriter.

displayed is the typewriter output -- builds character by character as the interval fires, starting from empty string. The bubble renders displayed, so it types itself into existence.


Cancellation

The hook can be called again before a previous call resolves -- a fast double-click, or clicking a different cat before the first has finished. Each call needs to safely cancel whatever was in-flight.

The pattern:

let cancelled = false
cancelRef.current = () => { cancelled = true }

// ... later, in the typewriter interval:
if (cancelled) {
  clearInterval(typewriterRef.current)
  typewriterRef.current = null
  return
}

Every call gets a closure-scoped cancelled flag. All state updates check if (cancelled) return before firing. cancelRef.current holds the cancel function for the active call -- the first thing a new call does is invoke it, which sets the previous call's cancelled flag to true and clears any running interval. Clean handoff, no ghost state.


The raw fetch

The hook uses fetch directly rather than the Supabase JS client. The client's .schema() call fails to send the Accept-Profile header in browser context, which causes PostgREST to fall back to the public schema rather than drew_portfolio. The query returns 200 with an empty array, no error.

const res = await fetch(
  `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}?select=text&active=is.true`,
  {
    headers: {
      'Accept-Profile': 'drew_portfolio',
      'apikey': import.meta.env.VITE_SUPABASE_JWT_ANON_KEY,
      'Authorization': `Bearer ${import.meta.env.VITE_SUPABASE_JWT_ANON_KEY}`,
    },
  }
)

Two things worth noting: active=is.true not active=eq.true -- PostgREST uses is for boolean columns, eq compares against the string "true" and silently returns nothing. And VITE_SUPABASE_JWT_ANON_KEY (the legacy JWT format) rather than VITE_SUPABASE_ANON_KEY -- PostgREST requires a JWT in the Authorization Bearer header, not the newer sb_publishable_... format.

Both of these are easy to get wrong and neither produces an obvious error. They're documented in patterns.md precisely because of that.


Per-cat independent state in Vault.jsx

Vault.jsx instantiates two hook instances, one per cat:

const meeko = useAffirmation('light')
const mayu  = useAffirmation('dark')

Each cat also gets its own local state for bubble visibility and the dot loading animation:

const [meekoBubbleActive, setMeekoBubbleActive] = useState(false)
const [mayuBubbleActive,  setMayuBubbleActive]  = useState(false)
const [meekoDots, setMeekoDots] = useState('')
const [mayuDots,  setMayuDots]  = useState('')
const meekoDotsRef = useRef(null)
const mayuDotsRef  = useRef(null)

This is deliberately not DRYed into a single parameterised slot. An early version used one catAffirmation state slot that stored { cat, hook }. Clicking cat A while cat B was mid-typewriter would overwrite the slot, switching the active hook reference to A's and effectively making cat B's bubble disappear. Independent state per cat means both bubbles can exist simultaneously with zero interference.


The stale snapshot trap

The original { cat, hook } approach had a subtler problem than just the single-slot collision. This is the mistake worth understanding properly.

When you write:

setCatAffirmation({ cat, hook })
hook.fetchAffirmation()

You're storing a snapshot of hook in React state at the moment of the click. The displayed property on that snapshot is '' and stays '' forever -- because setDisplayed inside the hook updates the hook's own state, not the copy you stored. React re-renders don't update objects already stored in state; they create new state values.

So catAffirmation.hook.displayed was perpetually empty. The typewriter was running correctly, updating meeko.displayed or mayu.displayed in real time -- but the bubble was reading from a dead copy stored four renders ago.

The fix: never store the hook in state. Store only the identifier (the cat object), and derive the live hook at render time:

const activeCatHook = catAffirmation?.affirmationTheme === 'dark' ? mayu : catAffirmation ? meeko : null

This always reads from the live instance. displayed now reflects actual current state because you're looking at the hook directly, not a snapshot of it.

The bug was diagnosed by injecting a window.fetch intercept directly into the running page via browser tools -- this confirmed the fetch was firing and the typewriter was completing, which pointed to the display layer rather than the data layer. Sometimes you have to go around HMR.


The dot animation

The loading dots ('.' → '..' → '...') cycle while the fetch is in-flight and displayed is still empty. These live as local state in Vault.jsx driven by useEffect, not inside the hook.

useEffect(() => {
  if (meekoDotsRef.current) { clearInterval(meekoDotsRef.current); meekoDotsRef.current = null }
  if (!meekoBubbleActive || meeko.displayed) { setMeekoDots(''); return }
  let count = 1; setMeekoDots('.')
  meekoDotsRef.current = setInterval(() => {
    count = count >= 3 ? 1 : count + 1
    setMeekoDots('.'.repeat(count))
  }, 300)
  return () => { clearInterval(meekoDotsRef.current); meekoDotsRef.current = null }
}, [meekoBubbleActive, meeko.displayed])

When meeko.displayed has content, the effect clears the interval and resets dots to ''. The bubble then switches from showing dots to showing the typewriter output. The dot animation belongs in the component rather than the hook because it's display logic -- the hook doesn't need to know whether dots are showing.


The click guard

The click guard uses rendering || bubbleActive rather than just rendering:

const busy = isMayu
  ? (mayu.rendering || mayuBubbleActive)
  : (meeko.rendering || meekoBubbleActive)
if (busy) return

rendering goes false when the last character is written, typically 2-3 seconds into a 5 second display window. Without bubbleActive in the guard, you could click again mid-display, reset displayed to '', and jump the bubble back to dots. bubbleActive stays true for the full 5 seconds, so the guard covers the entire display window not just the typewriter phase.

The CatHotspot component receives disabled from the same busy check, keeping the visual state in sync -- no hover glow while a quote is showing.


Improvement notes

A few things in the current implementation are worth flagging for a future pass.

The VITE_SUPABASE_JWT_ANON_KEY / VITE_SUPABASE_ANON_KEY split is a footgun that will catch someone out again. The right long-term fix is a shared supabaseFetch utility in src/lib/ that handles the headers correctly, rather than repeating the raw fetch pattern everywhere.

The parallel state blocks for Meeko and Mayu (meekoBubbleActive, mayuBubbleActive etc.) are correct but verbose. A useCatAffirmation wrapper hook that encapsulates bubble visibility, dot animation, and the guard logic would clean up Vault.jsx meaningfully without changing how anything works. The per-cat independent state stays -- the wrapper just co-locates it.

Neither of these is urgent. The current code works correctly and is readable. But the vault is going to grow -- more content, more interaction states -- and having cleaner hook boundaries will matter when that happens.