/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.
Magnetic "View case studies" button
Hover the button
Magnetic pull at 35% of cursor offset · global cursor expands via data-cursor-morph
Conversation
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.
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.
the global pointer cursor should also expand to match the button shape when hovering
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.
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>
)
}Navigation — clarity over novelty
Click a label
Underline morphs between active labels via framer-motion layoutId
Conversation
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.
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.
Build log — designing the page itself
Click a dot
Pin-and-scroll strip with carousel dots — each card holds the demo and transcript at equal hierarchy.
Conversation
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.
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.
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>
)
}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
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.
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.
Work cards — WCAG contrast pass
Toggle the WCAG pass

Money Transfer on ChatGPT
Strengthened gradient (black/80→/90) and bumped text opacities — company line clears the 4.5:1 AA floor comfortably.
Conversation
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.
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.
Work section — card hierarchy & wider layout
Compare the layouts
Strongest project goes full-width to lead reading order — secondary projects compress to a 7/5 row, container widens to feel more editorial.
Conversation
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.
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.
Projects bento grid
Click any card
Each card shares a layoutId with its expanded view — framer-motion morphs between states.
Conversation
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.
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.
<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>Magnetic "View case studies" button
Hover the button
Magnetic pull at 35% of cursor offset · global cursor expands via data-cursor-morph
Conversation
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.
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.
the global pointer cursor should also expand to match the button shape when hovering
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.
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>
)
}Navigation — clarity over novelty
Click a label
Underline morphs between active labels via framer-motion layoutId
Conversation
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.
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.
Build log — designing the page itself
Click a dot
Pin-and-scroll strip with carousel dots — each card holds the demo and transcript at equal hierarchy.
Conversation
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.
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.
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>
)
}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
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.
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.
Work cards — WCAG contrast pass
Toggle the WCAG pass

Money Transfer on ChatGPT
Strengthened gradient (black/80→/90) and bumped text opacities — company line clears the 4.5:1 AA floor comfortably.
Conversation
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.
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.
Work section — card hierarchy & wider layout
Compare the layouts
Strongest project goes full-width to lead reading order — secondary projects compress to a 7/5 row, container widens to feel more editorial.
Conversation
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.
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.
Projects bento grid
Click any card
Each card shares a layoutId with its expanded view — framer-motion morphs between states.
Conversation
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.
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.
<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>