Working method

How I work with AI

I direct Claude Code like a design partner — framing the brief, critiquing the output, refining until each interaction earns its place. Every page of this site is the evidence.

This is how I work, not a list of what I made.

Prompt direction

Briefs that read like design specs — constraints, hierarchy, restraint — not feature tickets.

Critical eye

First passes get rewritten. I read AI output for hierarchy, taste, and what’s missing.

UI engineering

Tailwind, Framer Motion, Next.js. I read the diff and tune the spring.

Accessibility & motion

Contrast math, hit targets, reduced-motion. Senior-level finish, not “good enough.”

The loop

How each build was made.

Same loop on every entry. The interesting part isn't the tool. It's where the decisions sit inside it.

  1. Define intent.

    Translate the product idea into a problem the build can answer. Constraints first: what must be true for this to read as deliberate.

  2. Direct the build.

    Write the prompt as a design brief: behavior, hierarchy, restraint. Not a feature ticket. The way you frame the ask is the first design decision.

  3. Critique the output.

    Read the code the way you read a mockup: for hierarchy, taste, and what's missing. The first pass is rarely the right pass.

  4. Refine and ship.

    Tune the interaction. Hold the line on craft. Ship the version that reads as deliberate, not just functional.

May 5, 2026Layout

Work section: card hierarchy & wider layout

Editorial spacing is what makes the strongest work read as primary.

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.

Problem

All three case studies sat at equal weight. The strongest piece wasn't reading as the primary one — the layout was making a tie I never intended.

Prompt

Rework the work section so the strongest project gets full-width placement; secondary projects sit in a denser layout below. Make it feel editorial without losing density.

Critique

Initial proposal split the second row 50/50. Pushed back: hierarchy has to hold inside the row, not just between rows. Settled on 7/5. Widened the content shell from max-w-3xl to max-w-5xl so the editorial quality could live in negative space.

Result

Reading order is set before the visitor scans: Remitly as one unbroken composition; UoP and MSCI in deliberate asymmetry below. The layout makes the hierarchy claim before the copy has to.

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">
SkillsVisual hierarchy12-col gridEditorial pacingReading order
May 5, 2026Interaction

Magnetic "View case studies" button

Tactility is a tuning problem. 0.35 is the answer.

Hover the button

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

Problem

The hero CTA needs to read as crafted — tactile, precise — without becoming a gimmick that distracts from intent.

Prompt

Build a magnetic 'View case studies' button that feels tactile and precise. The interaction should support intent, not perform craft. Keep it readable, accessible, performant.

Critique

First pass pulled at 0.5× and the button felt like it was chasing the cursor — the label drifted off its visual anchor. Cut to 0.35× so the focus ring and hit target stay put. Spring retuned for precise on entry, calm on exit.

Result

The cursor expands to wrap the button at the same moment the button pulls toward the cursor — they meet partway. Responsiveness reads as a property of the interaction, not a performance of it.

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>
  )
}
SkillsSpring tuningGPU transformsCursor morphingHit-target discipline
May 5, 2026Accessibility

Work cards: WCAG contrast pass

Passing AA isn't the finish line. The worst-case image is.

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.

Problem

Work cards overlay text on unpredictable image backgrounds. WCAG AA is the constraint; a bright, high-detail photo underneath is the real test.

Prompt

Design the work cards so overlaid text stays legible across any image. Use WCAG AA as the standard, but optimize for the hardest real-world case — a bright, high-detail photo underneath.

Critique

First pass passed AA on paper, but the company-name text sat at 4.8:1 — basically the 4.5 floor. Marginal isn't done. Pushed the gradient from black/80 to black/90, lifted category to white/75 and company to white/65. Comfortable margin, not the legal minimum.

Result

Worst-case image stops dictating the design. Hierarchy holds over any project photo without re-testing, and the AA contract is honored with room to spare.

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>
SkillsWCAG AAContrast mathGradient tuningWorst-case design
May 5, 2026Motion

Hero chatbot: expanding conversation reveals image

Reveal, don't morph. The frame opens; the photograph holds.

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.

Problem

Chat hero needs to expand as the conversation grows without layout shift. The photograph has to feel anchored — depth, not a moving image.

Prompt

Design the hero so the background image progressively reveals downward as the conversation expands. The image stays anchored — depth and continuity, not layout shift. Cinematic, restrained, deliberate.

Critique

Percentage-based backgroundPosition broke the anchor: as the container grew, the image drifted upward. Pushed for a fixed-pixel solution — preload the image, compute the exact pixel equivalent of 40% for the initial height, lock it in. The image stops moving the moment it's measured.

Result

A fixed photographic plate behind a growing aperture. The container grows; the image doesn't. Restraint over animation — 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"+ }, [])
SkillsLayout-shift disciplineAnchored revealImage preload + measureSpatial continuity
May 18, 2026Navigation

Navigation: clarity over novelty

An active state is a wayfinding contract, not a styling choice.

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>
SkillsWayfindingScrollspyIntersectionObserverActive state systems
May 18, 2026Information architecture

Build log: designing the page itself

Co-equal placement of prompt and result reframes the artifact.

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>
  )
}
SkillsIACase-study framingContent hierarchyPin-and-scroll
May 4, 2026Layout

Projects bento grid

Asymmetry as hierarchy: the layout reads before you do.

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>
Skills12-col gridlayoutId morphModal patternAsymmetric composition

Index

Every build.

Filter by theme. Each row opens the full card above — demo on one side, prompt and critique on the other.

Next

Same craft as the case studies. Smaller scope, tighter loop, every decision visible.