/build-log

Built with Claude Code

Every feature on this site was built in conversation with Claude Code. Each card is the live result, paired with the prompt and code behind it.

May 5, 2026Portfolio

Magnetic "View case studies" button

Hover the button

Magnetic pull at 35% of cursor offset · global cursor expands via data-cursor-morph

Conversation

You

Build a magnetic 'View case studies' button that feels tactile and precise. The cursor interaction should suggest responsiveness and craft, but the button still needs to remain readable, accessible, and performant. The effect should feel like it supports the user's intent to explore, not like a gimmick.

Claude Code

Spring-magnetism is the right primitive, but the brief sharpens it into one tuning question: how much pull is 'tactile' without becoming a gimmick? Two useMotionValue refs (x, y) wrapped in useSpring for smooth easing, then translate the button by an offset from cursor-to-center. The 0.35 multiplier on the cursor delta is the answer. Lower and the pull is invisible — no signal of responsiveness. Higher and the button drifts far enough to feel like it's chasing the cursor rather than meeting it. At 0.35 the button stays inside its visual anchor, so the label stays readable and the focus ring/hit target never wander off. Spring config { stiffness: 200, damping: 12, mass: 0.1 } lands the 'precise on entry, calm on exit' feel. Transform-only animation keeps it GPU-cheap so it stays smooth on lower-end hardware.

You

the global pointer cursor should also expand to match the button shape when hovering

Claude Code

Adding data-cursor-morph to the anchor. PointerCursor already watches for that attribute — when the cursor enters the element, it reads the bounding rect and sizes itself to match, then snaps back on leave. Layering the two effects: the button magnetically pulls toward the cursor by 35%, while the cursor itself expands to wrap the button. Both happen simultaneously — the result feels like the cursor and button are meeting partway. The 0.35 multiplier was deliberately chosen so they meet in the middle visually, not at either end.

components/Hero.tsx
function MagneticButton({ href, children, className }) {
  const x = useMotionValue(0)
  const y = useMotionValue(0)
  const sx = useSpring(x, { stiffness: 200, damping: 12, mass: 0.1 })
  const sy = useSpring(y, { stiffness: 200, damping: 12, mass: 0.1 })

  function onMove(e: React.MouseEvent<HTMLDivElement>) {
    const r = e.currentTarget.getBoundingClientRect()
    x.set((e.clientX - (r.left + r.width / 2)) * 0.35)
    y.set((e.clientY - (r.top + r.height / 2)) * 0.35)
  }
  function onLeave() {
    x.set(0)
    y.set(0)
  }

  return (
    <motion.div
      style={{ x: sx, y: sy, display: "inline-flex" }}
      onMouseMove={onMove}
      onMouseLeave={onLeave}
    >
      <a
        href={href}
        data-cursor-morph
        className={`inline-flex items-center pl-[2px] pr-8 py-3
                    rounded-full ${className ?? ""}`}
      >
        {children}
      </a>
    </motion.div>
  )
}
May 18, 2026Portfolio

Navigation — clarity over novelty

Click a label

ann.

Underline morphs between active labels via framer-motion layoutId

Conversation

You

Refine the navigation for my portfolio so it feels simple, calm, and obvious. The user should always know where they are and what to explore next. Prioritize clarity over novelty, and make the nav feel like part of the brand system rather than a generic header.

Claude Code

The biggest miss against the brief was that nothing told you where you were — every label looked identical. So the first change is an active state: pathname match for the build-log route, and an IntersectionObserver scrollspy for the home anchors (Work, About, Contact). The active line sits ~30% down the viewport so the label flips at the moment a section feels 'in view' rather than the instant its top touches the fold. For the brand layer, swapped the wordmark from 'Ann' to 'ann.' — the lowercase plus terminal period gives the nav a voice instead of reading as a generic header. The active-link treatment is a 1.5px underline at 6px offset, using decoration-current so it inherits whatever color the bar is in (white over case-study hero, dark over the rest). No badges, no boxes, no novelty — just a quiet line under whichever label is current.

components/Navbar.tsxdiff
@@ wordmark @@- Ann+ ann.@@ scrollspy for home anchors @@+ useEffect(() => {+ if (pathname !== "/") { setActiveSection(null); return }+ const ids = ["projects", "about", "contact"]+ const nodes = ids+ .map((id) => document.getElementById(id))+ .filter((n): n is HTMLElement => n !== null)++ const observer = new IntersectionObserver(+ (entries) => {+ const visible = entries+ .filter((e) => e.isIntersecting)+ .sort((a, b) => b.intersectionRatio - a.intersectionRatio)+ if (visible[0]) setActiveSection(visible[0].target.id)+ },+ { rootMargin: "-30% 0px -55% 0px",+ threshold: [0, 0.25, 0.5, 0.75, 1] },+ )+ nodes.forEach((n) => observer.observe(n))+ return () => observer.disconnect()+ }, [pathname])@@ active link styling @@- <a href={href} className="hover:text-zinc-900 ...">- {label}- </a>+ <a href={href}+ aria-current={active ? "page" : undefined}+ className={`hover:text-zinc-900 ... ${active+ ? "underline underline-offset-[6px] decoration-[1.5px] decoration-current"+ : ""}`}>+ {label}+ </a>
May 18, 2026Portfolio

Build log — designing the page itself

Click a dot

May 5Portfolio
May 18Portfolio
May 4Portfolio

Pin-and-scroll strip with carousel dots — each card holds the demo and transcript at equal hierarchy.

Conversation

You

Help me design a build log page that documents how I built my portfolio with AI tools. Each entry should feel like a design artifact, not just a changelog. I want every card to show the feature, the reasoning behind it, and the prompt/code that made it possible. The experience should reinforce that I use AI thoughtfully as part of my design process, not as a shortcut.

Claude Code

The structural decision is splitting each card into two equal halves: the live demo on the left, the conversation + code on the right. Putting them at the same hierarchy level is what makes the card read as a design artifact rather than a list of commits — the prompt sits co-equal to the output, not as a footnote. For page rhythm, a pin-and-scroll horizontal strip (sticky section, x-translate driven by scroll progress) lets each card hold the full viewport. One artifact at a time, no parallel scanning, with carousel dots as a wayfinding aid. Mobile falls back to a vertical stack — same content hierarchy, just restacked for narrow widths.

app/build-log/page.tsx
function FullCard({ session }) {
  return (
    <div className="h-screen flex items-start justify-center">
      <div className="rounded-3xl border bg-white dark:bg-zinc-950
                      flex flex-col overflow-hidden shadow-sm">
        <CardHeader session={session} />

        {/* Body: demo + transcript, equal hierarchy */}
        <div className="grid grid-cols-2 flex-1 min-h-0">
          {/* Demo half — the result */}
          <div className="p-6 lg:p-8 2xl:p-12 flex items-center
                          justify-center border-r">
            <DemoComponent />
          </div>

          {/* Transcript half — the reasoning */}
          <div className="overflow-y-auto bg-zinc-950">
            <ConversationPanel messages={session.messages} />
            <OutputPanel output={session.output} />
          </div>
        </div>
      </div>
    </div>
  )
}
May 5, 2026Portfolio

Hero chatbot — expanding conversation reveals image

Watch the photo reveal

You: What do you design?

Image is anchored in pixels — as chat grows the container, more of the photo reveals from the bottom.

Conversation

You

Design a hero chatbot experience in which the background image progressively reveals downward as the conversation expands. The image should remain visually anchored so the user experiences depth and continuity, not layout shift. As the chatbot grows taller with each response, more of the image should become visible from the bottom, creating a calm, cinematic sense of unfolding. Prioritize precision, restraint, and spatial consistency so the interaction feels like a deliberate product decision, not a visual trick.

Claude Code

The brief is anti-illusion: nothing should appear to morph or trick the eye, only to progressively reveal. So the mental model is a fixed photographic plate behind a growing aperture — the image doesn't move, the container does. The technical challenge is that backgroundPosition: '100% 40%' uses a percentage Y — as the container grows, the image shifts, which breaks the anchor. The fix: on mount, load the image, compute the exact pixel equivalent of 40% for the initial section height, and lock it in as a fixed pixel value. Then switch the background div to inset-0 so it grows with the section. The image stays anchored at the same X/Y, and as chat messages push the section taller, more of the photo is revealed at the bottom — the camera doesn't move, the frame opens.

components/Hero.tsxdiff
@@ background image div @@- className="absolute inset-x-0 top-0 h-screen ..."- style={{ backgroundPosition: "100% 40%" }}+ ref={bgRef}+ className="absolute inset-0 ..."+ style={{ backgroundPosition: `100% ${bgPosY}` }}@@ on mount — lock Y in pixels @@+ useEffect(() => {+ const img = new window.Image()+ img.onload = () => {+ const containerW = el.offsetWidth+ const containerH = el.offsetHeight+ const mult = window.innerWidth >= 1024 ? 1.36+ : window.innerWidth >= 640 ? 1.48 : 1.60+ const bgW = containerW * mult+ const bgH = (img.naturalHeight / img.naturalWidth) * bgW+ setBgPosY(`${Math.round((containerH - bgH) * 0.4)}px`)+ }+ img.src = "/headshot.jpg"+ }, [])
May 5, 2026Portfolio

Work cards — WCAG contrast pass

Toggle the WCAG pass

RemitlyConversational AI

Money Transfer on ChatGPT

Company line:4.8:1 marginal

Strengthened gradient (black/80→/90) and bumped text opacities — company line clears the 4.5:1 AA floor comfortably.

Conversation

You

Design the work cards so the overlaid text remains fully legible across unpredictable image backgrounds. Use the WCAG contrast standard as the constraint, but optimize for the hardest real-world case: a bright or high-detail image underneath.

Claude Code

Ran contrast calculations against worst-case (bright image underneath). Title at text-white clears AAA at 12.6:1. Category at text-white/60 passes AA at 6.1:1. Company name at text-white/50 was marginal at 4.8:1 — technically passing but too close to the 4.5:1 floor. Strengthened gradient from from-black/80 to from-black/90 and bumped category to text-white/75, company to text-white/65. All text now comfortably clears WCAG AA.

components/Projects.tsxdiff
@@ card interior @@- <div className="absolute inset-0 bg-gradient-to-t- from-black/80 via-black/30 to-transparent" />+ <div className="absolute inset-0 bg-gradient-to-t+ from-black/90 via-black/40 to-transparent" /> <div className="absolute inset-x-0 bottom-0 p-6">- <span className="... text-white/60">{project.category}</span>- <p className="... text-white/50">{project.company}</p>+ <span className="... text-white/75">{project.category}</span>+ <p className="... text-white/65">{project.company}</p> <h3 className="... text-white">{project.title}</h3> </div>
May 5, 2026Portfolio

Work section — card hierarchy & wider layout

Compare the layouts

Remitly
UoP
MSCI
MS

Strongest project goes full-width to lead reading order — secondary projects compress to a 7/5 row, container widens to feel more editorial.

Conversation

You

Rework the work section hierarchy so the strongest project gets full-width placement at the top, while secondary projects sit in a denser two-column layout below. The goal is to create a clearer visual hierarchy, give the best work more presence, and make the overall portfolio feel more spacious and editorial.

Claude Code

Strongest project at full width sets the reading order before the visitor even scans — they encounter your best work as one unbroken composition rather than as one of three equivalent tiles. The second row uses a 7/5 split (not 50/50) so hierarchy is preserved inside the row too: there's still a primary and a secondary, not a tie. Final layout: Remitly full-width on top, University of Phoenix (7/12) and MSCI (5/12) side by side below. Content shell goes from max-w-3xl to max-w-5xl across all sections — the 'editorial' quality lives in the negative space around each piece, so the work needs room before it can read as spacious.

components/Projects.tsxdiff
@@ grid layout @@- const colClass = [- "col-span-12 sm:col-span-7",- "col-span-12 sm:col-span-5",- "col-span-12 sm:col-span-5",- "col-span-12 sm:col-span-7",- ][i]+ const colClass = [+ "col-span-12",+ "col-span-12 sm:col-span-7",+ "col-span-12 sm:col-span-5",+ ][i]@@ section container @@- <div className="max-w-3xl mx-auto px-6">+ <div className="max-w-5xl mx-auto px-6">
May 4, 2026Portfolio

Projects bento grid

Click any card

Remitly
UoP
MSCI
MS

Each card shares a layoutId with its expanded view — framer-motion morphs between states.

Conversation

You

Build the projects section. I want a bento grid feel — different card sizes. Click a card to expand it into a full overlay with project details.

Claude Code

I'll use a 12-column CSS grid with alternating 7/5 column splits for the asymmetric bento layout. Framer-motion's layoutId is the right tool for the expand animation — the card and modal share an ID so the element smoothly morphs between states. The backdrop blurs on open; Escape and click-outside both dismiss. Creating components/Projects.tsx.

components/Projects.tsx
<ul className="grid grid-cols-12 gap-4">
  {projects.map((project, i) => {
    const colClass = [
      "col-span-12 sm:col-span-7",
      "col-span-12 sm:col-span-5",
      "col-span-12 sm:col-span-5",
      "col-span-12 sm:col-span-7",
    ][i]

    return (
      <motion.li key={project.id} className={colClass}
        initial={{ opacity: 0, y: 32 }}
        whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: true }}
      >
        <motion.div
          layoutId={project.id}
          onClick={() => setSelectedId(project.id)}
          className="relative rounded-3xl overflow-hidden
                     cursor-pointer border border-zinc-200
                     dark:border-zinc-800"
          whileHover={{ scale: 1.025 }}
          whileTap={{ scale: 0.975 }}
        />
      </motion.li>
    )
  })}
</ul>

<AnimatePresence>
  {selected && (
    <motion.div
      layoutId={selected.id}
      className="fixed inset-0 z-50 flex items-center
                 justify-center p-4"
    >
      {/* expanded project detail */}
    </motion.div>
  )}
</AnimatePresence>