# Portfolio v2

> Static personal portfolio for a fullstack developer — notes, fictional projects, and experience. Typed TypeScript content — no CMS, no markdown. New projects only (shadcn init --template next).

A shadcn/ui block from the [@uiception](https://uiception.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/portfolio-v2).

- Page, with a live preview: https://shadcn.exchange/r/uiception/portfolio-v2
- Screenshot: https://shadcn.exchange/shot/uiception/portfolio-v2/light.png
- Registry item: https://uiception.com/r/portfolio-v2.json
- Categories: template, portfolio
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/uiception/portfolio-v2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `class-variance-authority`, `geist`, `lucide-react`, `next-themes`, `radix-ui`

## Notes from the registry

New Next.js project only (same flow as https://ui.shadcn.com/create):
  npx shadcn@latest init --template next -y "https://uiception.com/r/portfolio-v2.json"

This scaffolds Next.js + shadcn, then installs the full site.

To personalize: attach your resume and point your coding agent at AGENTS.md — it re-skins the whole site (name, bio, links, notes, projects, experience) and runs the UNSLOP.md pass on the prose it writes. Or edit app/page.tsx directly. Drop a photo at public/avatar.png to replace the JD initials. The email link copies jefferyspippitt@gmail.com (edit components/copy-email.tsx). Notes live in lib/notes.tsx as typed React bodies — no markdown or CMS. Preview at /view/portfolio-v2. Press D to toggle theme.

Canvas UI Liquid: trails work everywhere. Text distortion needs Chromium html-in-canvas. Register one Origin Trial token for your production origin at https://developer.chrome.com/origintrials (html-in-canvas), then set NEXT_PUBLIC_HTML_IN_CANVAS_OT_TOKEN in .env.local. Local-only warp without a token: chrome://flags/#canvas-draw-element.

## Files

### `registry/new-york/templates/portfolio-v2/components/ui/badge.tsx`

```tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
        secondary:
          "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
        destructive:
          "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
        outline:
          "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
        ghost:
          "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

function Badge({
  className,
  variant = "default",
  asChild = false,
  ...props
}: React.ComponentProps<"span"> &
  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
  const Comp = asChild ? Slot.Root : "span"

  return (
    <Comp
      data-slot="badge"
      data-variant={variant}
      className={cn(badgeVariants({ variant }), className)}
      {...props}
    />
  )
}

export { Badge, badgeVariants }
```

### `registry/new-york/templates/portfolio-v2/components/ui/button.tsx`

```tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
        outline:
          "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default:
          "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
        lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
        icon: "size-8",
        "icon-xs":
          "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
        "icon-lg": "size-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Button({
  className,
  variant = "default",
  size = "default",
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot.Root : "button"

  return (
    <Comp
      data-slot="button"
      data-variant={variant}
      data-size={size}
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }
```

### `registry/new-york/templates/portfolio-v2/app/layout.tsx`

```tsx
import type { Metadata } from "next"
import { GeistSans } from "geist/font/sans"
import type { ReactNode } from "react"

import { LiquidShell } from "../components/liquid-shell"
import { ThemeProvider } from "../components/theme-provider"

import "./globals.css"

export const metadata: Metadata = {
  title: "Jon Doe",
  description:
    "Fullstack developer. Building clear systems and shipping software that stays easy to maintain.",
}

// Same pattern as canvasui.dev — Origin Trial meta (no chrome://flags needed).
// One token from https://developer.chrome.com/origintrials for your site origin.
const ORIGIN_TRIAL_TOKEN =
  process.env.NEXT_PUBLIC_HTML_IN_CANVAS_OT_TOKEN ?? ""

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html
      lang="en"
      className={`h-full ${GeistSans.variable} antialiased`}
      suppressHydrationWarning
    >
      {ORIGIN_TRIAL_TOKEN ? (
        <head>
          <meta httpEquiv="origin-trial" content={ORIGIN_TRIAL_TOKEN} />
        </head>
      ) : null}
      <body
        className={`${GeistSans.className} flex min-h-svh flex-col bg-background font-sans text-foreground`}
      >
        <ThemeProvider>
          <LiquidShell>
            <div className="mx-auto flex min-h-svh w-full max-w-6xl flex-1 flex-col px-6 py-12 md:px-12 md:py-16 lg:px-16">
              <main className="flex flex-1 flex-col">{children}</main>
            </div>
          </LiquidShell>
        </ThemeProvider>
      </body>
    </html>
  )
}
```

### `registry/new-york/templates/portfolio-v2/app/page.tsx`

```tsx
import Link from "next/link"
import { ArrowUpRightIcon } from "lucide-react"

import { Button } from "@/components/ui/button"

import { ThemeToggle } from "../components/theme-toggle"
import { CopyEmail } from "../components/copy-email"
import { Avatar } from "../components/avatar"
import { getNotes } from "../lib/notes"

const projects = [
  {
    number: "01",
    name: "Panfry",
    href: "https://github.com/",
    description: "A small recipe box for the meals I actually cook on repeat",
  },
  {
    number: "02",
    name: "Longmile",
    href: "https://github.com/",
    description: "Tracks my slow, patient return to running after a bad knee",
  },
  {
    number: "03",
    name: "Spinebox",
    href: "https://github.com/",
    description: "A shelf for the books I have actually finished this year",
  },
  {
    number: "04",
    name: "Coinwell",
    href: "https://github.com/",
    description: "A simple, honest look at where my money actually goes",
  },
] as const

const experience = [
  {
    role: "Senior Full Stack Engineer",
    company: "Cortexa Labs",
    dates: "2024 to Present",
  },
  {
    role: "Full Stack Engineer",
    company: "Latentworks",
    dates: "2021 to 2024",
  },
  {
    role: "Software Engineer",
    company: "Synapse Foundry",
    dates: "2018 to 2021",
  },
  {
    role: "Junior Developer",
    company: "NeuralArc",
    dates: "2015 to 2018",
  },
] as const

export default function HomePage() {
  const notes = getNotes()

  return (
    <div className="flex flex-1 flex-col">
      {/* Top — 2 columns */}
      <header className="grid grid-cols-1 gap-10 md:grid-cols-2 md:items-start md:gap-16 lg:gap-20">
        <div className="flex items-center gap-4">
          <Avatar />
          <div className="min-w-0">
            <h1 className="text-lg font-semibold tracking-tight text-foreground">
              Jon Doe
            </h1>
            <p className="text-sm text-muted-foreground">Fullstack Developer</p>
          </div>
        </div>

        <div className="flex flex-col gap-5 text-[0.9375rem] leading-relaxed text-muted-foreground">
          <p className="font-medium text-foreground">Hey, I&apos;m Jon.</p>
          <p>
            I work as a fullstack developer, building for AI infrastructure
            startups and teams shipping real tools with real users. I keep my
            stack simple, I ship in pieces I can trust, and I leave software
            that stays easy to maintain.
          </p>
          <p>
            Find me on{" "}
            <Button
              variant="link"
              asChild
              className="h-auto p-0 text-[0.9375rem]"
            >
              <a href="https://x.com/" target="_blank" rel="noreferrer">
                X
              </a>
            </Button>
            ,{" "}
            <Button
              variant="link"
              asChild
              className="h-auto p-0 text-[0.9375rem]"
            >
              <a
                href="https://www.linkedin.com/"
                target="_blank"
                rel="noreferrer"
              >
                LinkedIn
              </a>
            </Button>
            , or{" "}
            <Button
              variant="link"
              asChild
              className="h-auto p-0 text-[0.9375rem]"
            >
              <a href="https://github.com/" target="_blank" rel="noreferrer">
                GitHub
              </a>
            </Button>
            . Or shoot me an <CopyEmail />.
          </p>
        </div>
      </header>

      <div className="min-h-12 flex-1 md:min-h-16" aria-hidden />

      <div className="grid grid-cols-1 gap-12 sm:grid-cols-2 md:grid-cols-3 md:gap-12 lg:gap-16">
        <section className="flex flex-col gap-5">
          <h2 className="text-xs font-medium tracking-tight text-muted-foreground">
            Notes
          </h2>
          <ul className="flex flex-col">
            {notes.map((note) => (
              <li
                key={note.slug}
                className="border-b border-dotted border-border/60 py-3 first:pt-0 last:border-b-0"
              >
                <Link
                  href={`/notes/${note.slug}`}
                  className="group flex flex-col gap-1"
                >
                  <span className="text-[0.9375rem] font-medium text-foreground transition-opacity group-hover:opacity-70">
                    {note.title}
                  </span>
                  <span className="font-mono text-xs text-muted-foreground tabular-nums">
                    {note.date.slice(0, 4)}
                  </span>
                </Link>
              </li>
            ))}
          </ul>
          <div className="pt-2">
            <Link
              href="/notes"
              className="group inline-flex items-center gap-1.5 text-[0.9375rem] font-medium text-foreground"
            >
              More
              <ArrowUpRightIcon
                aria-hidden
                className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
              />
            </Link>
          </div>
        </section>

        <section className="flex flex-col gap-5">
          <h2 className="text-xs font-medium tracking-tight text-muted-foreground">
            Projects
          </h2>
          <ul className="flex flex-col">
            {projects.map((project) => (
              <li
                key={project.name}
                className="border-b border-dotted border-border/60 py-3 first:pt-0 last:border-b-0"
              >
                <a
                  href={project.href}
                  target="_blank"
                  rel="noreferrer"
                  className="group grid grid-cols-[auto_1fr] items-baseline gap-x-3 gap-y-1"
                >
                  <span className="font-mono text-xs text-muted-foreground tabular-nums">
                    {project.number}
                  </span>
                  <span className="inline-flex items-center gap-1.5 text-[0.9375rem] font-medium text-foreground">
                    {project.name}
                    <ArrowUpRightIcon
                      aria-hidden
                      className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
                    />
                  </span>
                  <span className="col-start-2 text-xs text-muted-foreground">
                    {project.description}
                  </span>
                </a>
              </li>
            ))}
          </ul>
        </section>

        <section className="flex flex-col gap-5 sm:col-span-2 md:col-span-1">
          <h2 className="text-xs font-medium tracking-tight text-muted-foreground">
            Experience
          </h2>
          <ul className="flex flex-col">
            {experience.map((job) => (
              <li
                key={`${job.company}-${job.dates}`}
                className="border-b border-dotted border-border/60 py-3 first:pt-0 last:border-b-0"
              >
                <a
                  href="https://www.linkedin.com/"
                  target="_blank"
                  rel="noreferrer"
                  className="group flex flex-col gap-1"
                >
                  <span className="text-[0.9375rem] font-medium text-foreground transition-opacity group-hover:opacity-70">
                    {job.role}, {job.company}
                  </span>
                  <span className="font-mono text-xs text-muted-foreground tabular-nums">
                    {job.dates}
                  </span>
                </a>
              </li>
            ))}
          </ul>
          <div className="pt-2">
            <a
              href="https://www.linkedin.com/"
              target="_blank"
              rel="noreferrer"
              className="group inline-flex items-center gap-1.5 text-[0.9375rem] font-medium text-foreground"
            >
              LinkedIn
              <ArrowUpRightIcon
                aria-hidden
                className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
              />
            </a>
          </div>
        </section>
      </div>

      <div className="flex justify-end pt-12 md:pt-16">
        <ThemeToggle />
      </div>
    </div>
  )
}
```

### `registry/new-york/templates/portfolio-v2/app/notes/page.tsx`

```tsx
import Link from "next/link"

import { Avatar } from "../../components/avatar"
import { ThemeToggle } from "../../components/theme-toggle"
import { formatNoteDate, getNotes } from "../../lib/notes"

export default function NotesIndexPage() {
  const notes = getNotes()

  return (
    <div className="flex flex-1 flex-col">
      <div className="grid grid-cols-1 items-start gap-10 md:grid-cols-2 md:gap-16 lg:gap-20">
        <Link href="/" className="flex items-center gap-4">
          <Avatar />
          <div className="min-w-0">
            <p className="text-lg font-semibold tracking-tight text-foreground">
              Jon Doe
            </p>
            <p className="text-sm text-muted-foreground">
              Fullstack Developer
            </p>
          </div>
        </Link>

        <div className="flex flex-col">
          <h1 className="text-lg font-semibold tracking-tight text-foreground">
            Notes
          </h1>
          <p className="mt-2 text-sm text-muted-foreground">
            Short writing on fullstack development, shipping, and systems.
          </p>
          <ul className="mt-8 flex flex-col">
            {notes.map((note) => (
              <li
                key={note.slug}
                className="border-b border-dotted border-border/60 py-4 first:pt-0 last:border-b-0"
              >
                <Link
                  href={`/notes/${note.slug}`}
                  className="group flex flex-col gap-1"
                >
                  <span className="text-[0.9375rem] font-medium text-foreground transition-opacity group-hover:opacity-70">
                    {note.title}
                  </span>
                  <span className="text-sm text-muted-foreground">
                    {formatNoteDate(note.date)}
                    {note.description ? ` — ${note.description}` : null}
                  </span>
                </Link>
              </li>
            ))}
          </ul>
        </div>
      </div>

      <div className="mt-auto flex justify-end pt-12 md:pt-16">
        <ThemeToggle />
      </div>
    </div>
  )
}
```

### `registry/new-york/templates/portfolio-v2/app/notes/[slug]/page.tsx`

```tsx
import Link from "next/link"
import { notFound } from "next/navigation"

import { Avatar } from "../../../components/avatar"
import { ThemeToggle } from "../../../components/theme-toggle"
import { formatNoteDate, getNote, getNotes } from "../../../lib/notes"

type PageProps = {
  params: Promise<{ slug: string }>
}

export function generateStaticParams() {
  return getNotes().map((note) => ({ slug: note.slug }))
}

export default async function NotePage({ params }: PageProps) {
  const { slug } = await params
  const note = getNote(slug)

  if (!note) {
    notFound()
  }

  return (
    <div className="flex flex-1 flex-col">
      <div className="grid grid-cols-1 items-start gap-10 md:grid-cols-2 md:gap-16 lg:gap-20">
        <Link href="/" className="flex items-center gap-4">
          <Avatar />
          <div className="min-w-0">
            <p className="text-lg font-semibold tracking-tight text-foreground">
              Jon Doe
            </p>
            <p className="text-sm text-muted-foreground">
              Fullstack Developer
            </p>
          </div>
        </Link>

        <article className="flex flex-col">
          <h1 className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl">
            {note.title}
          </h1>
          <p className="mt-3 font-mono text-xs uppercase tabular-nums tracking-wide text-muted-foreground">
            {formatNoteDate(note.date)}
          </p>
          <div className="mt-8 flex flex-col gap-5 text-[0.9375rem] leading-relaxed text-muted-foreground">
            <note.body />
          </div>
        </article>
      </div>

      <div className="mt-auto flex justify-end pt-12 md:pt-16">
        <ThemeToggle />
      </div>
    </div>
  )
}
```

### `registry/new-york/templates/portfolio-v2/components/theme-provider.tsx`

```tsx
"use client"

import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ReactNode } from "react"

import { ThemeKeyboardShortcut } from "./theme-toggle"

export function ThemeProvider({ children }: { children: ReactNode }) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      <ThemeKeyboardShortcut />
      {children}
    </NextThemesProvider>
  )
}
```

### `registry/new-york/templates/portfolio-v2/components/theme-toggle.tsx`

```tsx
"use client"

import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"
import { flushSync } from "react-dom"
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
import { useTheme } from "next-themes"

import { cn } from "@/lib/utils"

const themes = [
  { value: "system", label: "System theme", icon: MonitorIcon },
  { value: "light", label: "Light theme", icon: SunIcon },
  { value: "dark", label: "Dark theme", icon: MoonIcon },
] as const

type ThemeValue = (typeof themes)[number]["value"]

const emptySubscribe = () => () => {}

const TOGGLE_COOLDOWN_MS = 650

/** Pure so it can be unit tested without a DOM. */
export function shouldAllowToggle(lastToggleAt: number, now: number): boolean {
  return now - lastToggleAt >= TOGGLE_COOLDOWN_MS
}

/** Returns true when the OS/browser has requested reduced motion. */
function prefersReducedMotion() {
  return (
    typeof window !== "undefined" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type VtDocument = Document & {
  startViewTransition?: (cb: () => void | Promise<void>) => {
    finished: Promise<void>
  }
}

function useIsClient() {
  return useSyncExternalStore(emptySubscribe, () => true, () => false)
}

function isEditableTarget(target: EventTarget | null) {
  return (
    (target instanceof HTMLElement && target.isContentEditable) ||
    target instanceof HTMLInputElement ||
    target instanceof HTMLTextAreaElement ||
    target instanceof HTMLSelectElement
  )
}

/** Canvas UI pattern: set theme inside flushSync (± view transition). */
function applyTheme(setTheme: (theme: string) => void, next: string) {
  const doc = document as VtDocument
  if (doc.startViewTransition && !prefersReducedMotion()) {
    doc.startViewTransition(() => {
      flushSync(() => setTheme(next))
    })
    return
  }
  setTheme(next)
}

export function ThemeToggle() {
  const { theme, setTheme } = useTheme()
  const mounted = useIsClient()

  const activeTheme = (mounted ? theme : "system") as ThemeValue | undefined

  return (
    <div
      role="group"
      aria-label="Theme"
      className="inline-flex items-center rounded-full border border-border bg-muted/60 p-0.5"
    >
      {themes.map(({ value, label, icon: Icon }) => {
        const isActive = activeTheme === value

        return (
          <button
            key={value}
            type="button"
            aria-label={label}
            aria-pressed={isActive}
            onClick={() => applyTheme(setTheme, value)}
            className={cn(
              "flex size-7 items-center justify-center rounded-full transition-colors",
              isActive
                ? "bg-background text-foreground shadow-sm"
                : "text-muted-foreground hover:text-foreground"
            )}
          >
            <Icon className="size-3.5" strokeWidth={1.75} aria-hidden />
          </button>
        )
      })}
    </div>
  )
}

export function ThemeKeyboardShortcut() {
  const { resolvedTheme, setTheme } = useTheme()
  const lastToggleAt = useRef<number>(0)

  const toggleTheme = useCallback(() => {
    const now = Date.now()
    if (!shouldAllowToggle(lastToggleAt.current, now)) return
    lastToggleAt.current = now
    applyTheme(setTheme, resolvedTheme === "dark" ? "light" : "dark")
  }, [resolvedTheme, setTheme])

  useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.metaKey || event.ctrlKey || event.altKey) return
      if (event.key !== "d" && event.key !== "D") return
      if (isEditableTarget(event.target)) return

      event.preventDefault()
      toggleTheme()
    }

    document.addEventListener("keydown", onKeyDown)
    return () => document.removeEventListener("keydown", onKeyDown)
  }, [toggleTheme])

  return null
}
```

### `registry/new-york/templates/portfolio-v2/components/copy-email.tsx`

```tsx
"use client"

import { useEffect, useRef, useState } from "react"

import { Button } from "@/components/ui/button"

const EMAIL = "jefferyspippitt@gmail.com"

export function CopyEmail() {
  const [copied, setCopied] = useState(false)
  const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  useEffect(() => {
    return () => {
      if (resetRef.current) clearTimeout(resetRef.current)
    }
  }, [])

  async function handleCopy() {
    try {
      await navigator.clipboard.writeText(EMAIL)
      setCopied(true)
      if (resetRef.current) clearTimeout(resetRef.current)
      resetRef.current = setTimeout(() => setCopied(false), 1500)
    } catch {
      setCopied(false)
    }
  }

  return (
    <Button
      type="button"
      variant="link"
      onClick={handleCopy}
      className="h-auto cursor-pointer p-0 text-[0.9375rem]"
      aria-label={copied ? "Email copied" : `Copy email ${EMAIL}`}
    >
      {copied ? "Copied" : "email"}
    </Button>
  )
}
```

### `registry/new-york/templates/portfolio-v2/components/avatar.tsx`

```tsx
import { existsSync } from "node:fs"
import { join } from "node:path"
import Image from "next/image"

const AVATAR_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"] as const

function resolveAvatarSrc(): string | null {
  // After install: drop public/avatar.png (any supported extension).
  const installDir = join(process.cwd(), "public")
  for (const ext of AVATAR_EXTENSIONS) {
    const filename = `avatar${ext}`
    if (existsSync(join(installDir, filename))) {
      return `/${filename}`
    }
  }

  // Monorepo preview / site demo (Next only serves root public/).
  // Same idea as public/images/blocks/<block-id>/.
  const demoDir = join(
    process.cwd(),
    "public",
    "images",
    "templates",
    "portfolio-v2"
  )
  for (const ext of AVATAR_EXTENSIONS) {
    const filename = `avatar${ext}`
    if (existsSync(join(demoDir, filename))) {
      return `/images/templates/portfolio-v2/${filename}`
    }
  }

  return null
}

export function Avatar() {
  const src = resolveAvatarSrc()

  if (src) {
    return (
      <Image
        src={src}
        alt=""
        width={48}
        height={48}
        unoptimized
        className="size-12 shrink-0 rounded-full object-cover"
      />
    )
  }

  return (
    <span
      aria-hidden
      className="flex size-12 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium tracking-tight text-foreground"
    >
      JD
    </span>
  )
}
```

### `registry/new-york/templates/portfolio-v2/components/liquid-shell.tsx`

```tsx
"use client"

import { useTheme } from "next-themes"
import {
  useSyncExternalStore,
  type ReactNode,
} from "react"

import { Liquid } from "@/components/canvasui/liquid"

/**
 * Trail colors as sRGB in the 0–1 range (Liquid converts to linear).
 * Light mode uses a richer blue so the overlay reads on white;
 * dark mode keeps a lighter cobalt that pops on near-black.
 */
const LIGHT_COLOR: [number, number, number] = [0.18, 0.32, 0.98]
const DARK_COLOR: [number, number, number] = [0.32, 0.45, 1]

const emptySubscribe = () => () => {}

function useIsClient() {
  return useSyncExternalStore(emptySubscribe, () => true, () => false)
}

type LiquidShellProps = {
  children: ReactNode
  className?: string
}

/**
 * Full-page Canvas UI Liquid wrapper.
 *
 * Text distortion only runs in Chromium when the html-in-canvas API is
 * available (chrome://flags/#canvas-draw-element, or a Chrome origin
 * trial token for production). Without it Liquid still paints pointer
 * trails over live HTML — matching the official package fallback.
 */
export function LiquidShell({ children, className }: LiquidShellProps) {
  const { resolvedTheme } = useTheme()
  const mounted = useIsClient()
  const isDark = !mounted || resolvedTheme !== "light"

  return (
    <Liquid
      force={0.4}
      radius={0.2}
      curl={1.5}
      pressureIterations={2}
      pressure={0.5}
      intensity={isDark ? 2 : 2.5}
      distortion={0.35}
      blend={isDark ? 5 : 7}
      densityDissipation={0.9}
      velocityDissipation={1}
      color={isDark ? DARK_COLOR : LIGHT_COLOR}
      rainbow={false}
      className={
        className ??
        "relative flex min-h-svh w-full flex-1 flex-col"
      }
      style={{ minHeight: "100svh" }}
    >
      {/*
        Solid page surface: the sim captures this subtree when html-in-canvas
        is available. Without a real background, light mode text sits on
        transparent pixels and warping looks incomplete.
      */}
      <div className="relative flex min-h-svh w-full flex-1 flex-col bg-background text-foreground">
        {children}
      </div>
    </Liquid>
  )
}
```

### `registry/new-york/templates/portfolio-v2/components/canvasui/liquid.tsx`

```tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type ReactNode,
} from "react";

export interface LiquidOptions {
  /** Resolution of the simulation grid. */
  simResolution?: number;
  /** Resolution of the fluid trail texture. */
  dyeResolution?: number;
  /** How much the trail persists each frame (closer to 1 lasts longer). */
  densityDissipation?: number;
  /** How much motion persists each frame (closer to 1 lasts longer). */
  velocityDissipation?: number;
  /** How much pressure carries over between frames. */
  pressure?: number;
  /** Pressure solver iterations. */
  pressureIterations?: number;
  /** Rotational force added back into the flow. */
  curl?: number;
  /** Radius of the pointer splat. */
  radius?: number;
  /** Force multiplier applied on pointer movement. */
  force?: number;
  /** Strength of the color tint left by the flow. */
  intensity?: number;
  /** How strongly the flow warps the content. */
  distortion?: number;
  /** How much of the fluid color blends over the content. */
  blend?: number;
  /** Trail color as [r, g, b] in 0-1 range. Ignored when rainbow is on. */
  color?: [number, number, number];
  /** Color the trail from the flow direction instead of a fixed color. */
  rainbow?: boolean;
}

export interface LiquidElements {
  /** Canvas with layoutsubtree that hosts the HTML content. */
  source: HTMLCanvasElement;
  /** The element inside the source canvas that gets captured. */
  content: HTMLElement;
  /** Canvas the WebGL effect renders to. */
  output: HTMLCanvasElement;
}

export interface LiquidInstance {
  /** Inject a splat at (x, y) in [0,1] space with velocity (dx, dy). */
  splat: (x: number, y: number, dx: number, dy: number) => void;
  /** Update simulation options live. Resolution changes are ignored. */
  setOptions: (options: LiquidOptions) => void;
  /** Re-read canvas size. Call when the element is resized. */
  resize: () => void;
  /** Stop the loop and release all GPU resources. */
  destroy: () => void;
}

const DEFAULTS: Required<LiquidOptions> = {
  simResolution: 128,
  dyeResolution: 512,
  densityDissipation: 0.96,
  velocityDissipation: 1,
  pressure: 0.8,
  pressureIterations: 4,
  curl: 1.9,
  radius: 0.3,
  force: 1.1,
  intensity: 2,
  distortion: 0.4,
  blend: 5,
  color: [0.145, 0.239, 0.867],
  rainbow: false,
};

const DT = 1 / 60;

function srgbToLinear(value: number): number {
  return value <= 0.04045
    ? value / 12.92
    : Math.pow((value + 0.055) / 1.055, 2.4);
}

type PaintableCanvas = HTMLCanvasElement & {
  onpaint?: (() => void) | null;
  requestPaint?: () => void;
};

type ElementImageContext = CanvasRenderingContext2D & {
  drawElementImage?: (element: Element, x: number, y: number) => void;
};

const VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
out vec2 vUv;
out vec2 vL;
out vec2 vR;
out vec2 vT;
out vec2 vB;
uniform vec2 texelSize;
void main () {
  vUv = aPos * 0.5 + 0.5;
  vL = vUv - vec2(texelSize.x, 0.0);
  vR = vUv + vec2(texelSize.x, 0.0);
  vT = vUv + vec2(0.0, texelSize.y);
  vB = vUv - vec2(0.0, texelSize.y);
  gl_Position = vec4(aPos, 0.0, 1.0);
}`;

const FRAG_DISPLAY = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uContent;
uniform sampler2D uFluid;
uniform vec3 uColor;
uniform float uDistortion;
uniform float uIntensity;
uniform float uBlend;
uniform float uRainbow;
uniform float uHasContent;
vec3 toLinear (vec3 c) {
  return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));
}
vec3 toSrgb (vec3 c) {
  return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
}
void main () {
  vec3 fluid = texture(uFluid, vUv).rgb;
  if (uHasContent < 0.5) {
    float mag = length(fluid);
    vec3 tint = uRainbow == 1.0
      ? clamp(fluid / max(mag, 1e-3), 0.0, 1.0)
      : uColor;
    float overlay = (1.0 - exp(-mag * uIntensity * 0.5)) * 0.82;
    outColor = vec4(toSrgb(clamp(tint, 0.0, 1.0)) * overlay, overlay);
    return;
  }
  vec2 uv = vUv - fluid.rg * uDistortion * 0.001;
  vec4 content = texture(uContent, vec2(uv.x, 1.0 - uv.y));
  content.rgb = toLinear(content.rgb);
  vec3 tint = uRainbow == 1.0 ? fluid : uColor * length(fluid);
  vec4 fluidColor = vec4(tint, 1.0);
  vec4 blended = mix(content, fluidColor, uBlend * 0.01 * clamp(length(fluid), 0.0, 1.0));
  vec4 final = mix(blended, vec4(0.0), 1.0 - content.a);
  outColor = vec4(toSrgb(clamp(final.rgb, 0.0, 1.0)), final.a);
}`;

const FRAG_SPLAT = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uTarget;
uniform float uAspect;
uniform vec3 uColor;
uniform vec2 uPoint;
uniform float uRadius;
void main () {
  vec2 p = vUv - uPoint;
  p.x *= uAspect;
  vec3 splat = exp(-dot(p, p) / uRadius) * uColor;
  vec3 base = texture(uTarget, vUv).xyz;
  outColor = vec4(base + splat, 1.0);
}`;

const FRAG_ADVECT = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uVelocity;
uniform sampler2D uSource;
uniform vec2 texelSize;
uniform float uDt;
uniform float uDissipation;
void main () {
  vec2 coord = vUv - uDt * texture(uVelocity, vUv).xy * texelSize;
  outColor = uDissipation * texture(uSource, coord);
  outColor.a = 1.0;
}`;

const FRAG_CLEAR = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uTexture;
uniform float uValue;
void main () {
  outColor = uValue * texture(uTexture, vUv);
}`;

const FRAG_DIVERGENCE = `#version 300 es
precision highp float;
in vec2 vUv;
in vec2 vL;
in vec2 vR;
in vec2 vT;
in vec2 vB;
out vec4 outColor;
uniform sampler2D uVelocity;
void main () {
  float L = texture(uVelocity, vL).x;
  float R = texture(uVelocity, vR).x;
  float T = texture(uVelocity, vT).y;
  float B = texture(uVelocity, vB).y;
  vec2 C = texture(uVelocity, vUv).xy;
  if (vL.x < 0.0) { L = -C.x; }
  if (vR.x > 1.0) { R = -C.x; }
  if (vT.y > 1.0) { T = -C.y; }
  if (vB.y < 0.0) { B = -C.y; }
  float div = 0.5 * (R - L + T - B);
  outColor = vec4(div, 0.0, 0.0, 1.0);
}`;

const FRAG_CURL = `#version 300 es
precision highp float;
in vec2 vUv;
in vec2 vL;
in vec2 vR;
in vec2 vT;
in vec2 vB;
out vec4 outColor;
uniform sampler2D uVelocity;
void main () {
  float L = texture(uVelocity, vL).y;
  float R = texture(uVelocity, vR).y;
  float T = texture(uVelocity, vT).x;
  float B = texture(uVelocity, vB).x;
  float vorticity = R - L - T + B;
  outColor = vec4(vorticity, 0.0, 0.0, 1.0);
}`;

const FRAG_VORTICITY = `#version 300 es
precision highp float;
in vec2 vUv;
in vec2 vL;
in vec2 vR;
in vec2 vT;
in vec2 vB;
out vec4 outColor;
uniform sampler2D uVelocity;
uniform sampler2D uCurl;
uniform float uCurlStrength;
uniform float uDt;
void main () {
  float L = texture(uCurl, vL).x;
  float R = texture(uCurl, vR).x;
  float T = texture(uCurl, vT).x;
  float B = texture(uCurl, vB).x;
  float C = texture(uCurl, vUv).x;
  vec2 force = vec2(abs(T) - abs(B), abs(R) - abs(L)) * 0.5;
  force /= length(force) + 1.0;
  force *= uCurlStrength * C;
  force.y *= -1.0;
  vec2 velocity = texture(uVelocity, vUv).xy;
  outColor = vec4(velocity + force * uDt, 0.0, 1.0);
}`;

const FRAG_PRESSURE = `#version 300 es
precision highp float;
in vec2 vUv;
in vec2 vL;
in vec2 vR;
in vec2 vT;
in vec2 vB;
out vec4 outColor;
uniform sampler2D uPressure;
uniform sampler2D uDivergence;
void main () {
  float L = texture(uPressure, vL).x;
  float R = texture(uPressure, vR).x;
  float T = texture(uPressure, vT).x;
  float B = texture(uPressure, vB).x;
  float divergence = texture(uDivergence, vUv).x;
  float pressure = (L + R + B + T - divergence) * 0.25;
  outColor = vec4(pressure, 0.0, 0.0, 1.0);
}`;

const FRAG_GRADIENT = `#version 300 es
precision highp float;
in vec2 vUv;
in vec2 vL;
in vec2 vR;
in vec2 vT;
in vec2 vB;
out vec4 outColor;
uniform sampler2D uPressure;
uniform sampler2D uVelocity;
void main () {
  float L = texture(uPressure, vL).x;
  float R = texture(uPressure, vR).x;
  float T = texture(uPressure, vT).x;
  float B = texture(uPressure, vB).x;
  vec2 velocity = texture(uVelocity, vUv).xy;
  velocity.xy -= vec2(R - L, T - B);
  outColor = vec4(velocity, 0.0, 1.0);
}`;

interface Target {
  fbo: WebGLFramebuffer;
  texture: WebGLTexture;
  width: number;
  height: number;
}

interface DoubleTarget {
  read: Target;
  write: Target;
  swap: () => void;
}

export function supportsHtmlInCanvas(): boolean {
  if (typeof document === "undefined") return false;
  const probe = document.createElement("canvas") as PaintableCanvas;
  const ctx = probe.getContext("2d") as ElementImageContext | null;
  return Boolean(
    ctx &&
    typeof ctx.drawElementImage === "function" &&
    typeof probe.requestPaint === "function",
  );
}

export function createLiquid(
  elements: LiquidElements,
  options: LiquidOptions = {},
): LiquidInstance | null {
  const config = { ...DEFAULTS, ...options };
  const { source, content, output } = elements;

  const gl = output.getContext("webgl2", {
    alpha: true,
    depth: false,
    stencil: false,
    antialias: false,
    premultipliedAlpha: true,
  });
  if (!gl || gl.isContextLost()) return null;

  const sourceCtx = source.getContext("2d") as ElementImageContext | null;
  const paintable = source as PaintableCanvas;
  const htmlInCanvas = Boolean(
    sourceCtx &&
    typeof sourceCtx.drawElementImage === "function" &&
    typeof paintable.requestPaint === "function",
  );

  let contentDirty = false;
  let wake = () => {};

  if (htmlInCanvas) {
    paintable.onpaint = () => {
      try {
        sourceCtx!.reset();
        sourceCtx!.drawElementImage!(content, 0, 0);
        contentDirty = true;
        wake();
      } catch {}
    };
  }

  gl.getExtension("EXT_color_buffer_float");
  const supportsLinear = Boolean(gl.getExtension("OES_texture_float_linear"));
  const filtering = supportsLinear ? gl.LINEAR : gl.NEAREST;

  const shaders: WebGLShader[] = [];

  function compile(type: number, source: string): WebGLShader {
    const shader = gl!.createShader(type)!;
    gl!.shaderSource(shader, source);
    gl!.compileShader(shader);
    if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) {
      console.error("Liquid shader error:", gl!.getShaderInfoLog(shader));
    }
    shaders.push(shader);
    return shader;
  }

  const vertexShader = compile(gl.VERTEX_SHADER, VERT);

  interface Program {
    program: WebGLProgram;
    uniforms: Record<string, WebGLUniformLocation>;
  }

  const programs: WebGLProgram[] = [];

  function createProgram(fragSource: string): Program {
    const program = gl!.createProgram()!;
    gl!.attachShader(program, vertexShader);
    gl!.attachShader(program, compile(gl!.FRAGMENT_SHADER, fragSource));
    gl!.linkProgram(program);
    programs.push(program);
    const uniforms: Record<string, WebGLUniformLocation> = {};
    const count = gl!.getProgramParameter(program, gl!.ACTIVE_UNIFORMS);
    for (let i = 0; i < count; i++) {
      const info = gl!.getActiveUniform(program, i)!;
      uniforms[info.name] = gl!.getUniformLocation(program, info.name)!;
    }
    return { program, uniforms };
  }

  const displayProgram = createProgram(FRAG_DISPLAY);
  const splatProgram = createProgram(FRAG_SPLAT);
  const advectProgram = createProgram(FRAG_ADVECT);
  const clearProgram = createProgram(FRAG_CLEAR);
  const divergenceProgram = createProgram(FRAG_DIVERGENCE);
  const curlProgram = createProgram(FRAG_CURL);
  const vorticityProgram = createProgram(FRAG_VORTICITY);
  const pressureProgram = createProgram(FRAG_PRESSURE);
  const gradientProgram = createProgram(FRAG_GRADIENT);

  const quad = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, quad);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
    gl.STATIC_DRAW,
  );
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

  function createTarget(
    size: number,
    internalFormat: number,
    format: number,
    filter: number,
  ): Target {
    const texture = gl!.createTexture()!;
    gl!.bindTexture(gl!.TEXTURE_2D, texture);
    gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_MIN_FILTER, filter);
    gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_MAG_FILTER, filter);
    gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_WRAP_S, gl!.CLAMP_TO_EDGE);
    gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_WRAP_T, gl!.CLAMP_TO_EDGE);
    gl!.texImage2D(
      gl!.TEXTURE_2D,
      0,
      internalFormat,
      size,
      size,
      0,
      format,
      gl!.HALF_FLOAT,
      null,
    );
    const fbo = gl!.createFramebuffer()!;
    gl!.bindFramebuffer(gl!.FRAMEBUFFER, fbo);
    gl!.framebufferTexture2D(
      gl!.FRAMEBUFFER,
      gl!.COLOR_ATTACHMENT0,
      gl!.TEXTURE_2D,
      texture,
      0,
    );
    gl!.viewport(0, 0, size, size);
    gl!.clearColor(0, 0, 0, 1);
    gl!.clear(gl!.COLOR_BUFFER_BIT);
    return { fbo, texture, width: size, height: size };
  }

  function createDoubleTarget(
    size: number,
    internalFormat: number,
    format: number,
    filter: number,
  ): DoubleTarget {
    let read = createTarget(size, internalFormat, format, filter);
    let write = createTarget(size, internalFormat, format, filter);
    return {
      get read() {
        return read;
      },
      get write() {
        return write;
      },
      swap() {
        const t = read;
        read = write;
        write = t;
      },
    };
  }

  const velocity = createDoubleTarget(
    config.simResolution,
    gl.RG16F,
    gl.RG,
    filtering,
  );
  const dye = createDoubleTarget(
    config.dyeResolution,
    gl.RGBA16F,
    gl.RGBA,
    filtering,
  );
  const divergence = createTarget(
    config.simResolution,
    gl.R16F,
    gl.RED,
    gl.NEAREST,
  );
  const curl = createTarget(config.simResolution, gl.R16F, gl.RED, gl.NEAREST);
  const pressure = createDoubleTarget(
    config.simResolution,
    gl.R16F,
    gl.RED,
    gl.NEAREST,
  );

  function releaseAll() {
    [
      velocity.read,
      velocity.write,
      dye.read,
      dye.write,
      pressure.read,
      pressure.write,
      divergence,
      curl,
    ].forEach((t) => {
      gl!.deleteFramebuffer(t.fbo);
      gl!.deleteTexture(t.texture);
    });
  }

  let texelX = 0;
  let texelY = 0;

  function updateTexelSize() {
    const width = Math.max(output.clientWidth, 1);
    const height = Math.max(output.clientHeight, 1);
    texelX = 1 / (config.simResolution * (width / (height + 400)));
    texelY = 1 / config.simResolution;
  }

  function syncCanvasSize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const width = Math.max(1, Math.round(output.clientWidth * dpr));
    const height = Math.max(1, Math.round(output.clientHeight * dpr));
    if (output.width !== width || output.height !== height) {
      output.width = width;
      output.height = height;
    }
    if (htmlInCanvas) {
      const cssWidth = Math.max(1, Math.round(source.clientWidth));
      const cssHeight = Math.max(1, Math.round(source.clientHeight));
      if (source.width !== cssWidth * dpr || source.height !== cssHeight * dpr) {
        source.width = cssWidth * dpr;
        source.height = cssHeight * dpr;
      }
      paintable.requestPaint!();
    }
    updateTexelSize();
  }

  syncCanvasSize();

  const contentTexture = gl.createTexture()!;
  gl.bindTexture(gl.TEXTURE_2D, contentTexture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA,
    1,
    1,
    0,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    new Uint8Array([0, 0, 0, 0]),
  );

  function uploadContent() {
    if (!htmlInCanvas || !contentDirty) return;
    contentDirty = false;
    gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
    gl!.texImage2D(
      gl!.TEXTURE_2D,
      0,
      gl!.RGBA,
      gl!.RGBA,
      gl!.UNSIGNED_BYTE,
      source,
    );
  }

  function blit(target: Target | null) {
    if (target) {
      gl!.bindFramebuffer(gl!.FRAMEBUFFER, target.fbo);
      gl!.viewport(0, 0, target.width, target.height);
    } else {
      gl!.bindFramebuffer(gl!.FRAMEBUFFER, null);
      gl!.viewport(0, 0, output.width, output.height);
    }
    gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
  }

  function bindTexture(texture: WebGLTexture, unit: number): number {
    gl!.activeTexture(gl!.TEXTURE0 + unit);
    gl!.bindTexture(gl!.TEXTURE_2D, texture);
    return unit;
  }

  function applySplat(x: number, y: number, dx: number, dy: number) {
    const aspect = output.clientWidth / Math.max(output.clientHeight, 1);
    const radius = config.radius / 100;

    gl!.useProgram(splatProgram.program);
    gl!.uniform1i(
      splatProgram.uniforms.uTarget,
      bindTexture(velocity.read.texture, 0),
    );
    gl!.uniform1f(splatProgram.uniforms.uAspect, aspect);
    gl!.uniform2f(splatProgram.uniforms.uPoint, x, y);
    gl!.uniform3f(splatProgram.uniforms.uColor, dx, dy, 10);
    gl!.uniform1f(splatProgram.uniforms.uRadius, radius);
    blit(velocity.write);
    velocity.swap();

    gl!.uniform1i(
      splatProgram.uniforms.uTarget,
      bindTexture(dye.read.texture, 0),
    );
    blit(dye.write);
    dye.swap();
  }

  function step(delta: number) {
    gl!.disable(gl!.BLEND);

    gl!.useProgram(curlProgram.program);
    gl!.uniform2f(curlProgram.uniforms.texelSize, texelX, texelY);
    gl!.uniform1i(
      curlProgram.uniforms.uVelocity,
      bindTexture(velocity.read.texture, 0),
    );
    blit(curl);

    gl!.useProgram(vorticityProgram.program);
    gl!.uniform2f(vorticityProgram.uniforms.texelSize, texelX, texelY);
    gl!.uniform1i(
      vorticityProgram.uniforms.uVelocity,
      bindTexture(velocity.read.texture, 0),
    );
    gl!.uniform1i(
      vorticityProgram.uniforms.uCurl,
      bindTexture(curl.texture, 1),
    );
    gl!.uniform1f(vorticityProgram.uniforms.uCurlStrength, config.curl);
    gl!.uniform1f(vorticityProgram.uniforms.uDt, DT);
    blit(velocity.write);
    velocity.swap();

    gl!.useProgram(divergenceProgram.program);
    gl!.uniform2f(divergenceProgram.uniforms.texelSize, texelX, texelY);
    gl!.uniform1i(
      divergenceProgram.uniforms.uVelocity,
      bindTexture(velocity.read.texture, 0),
    );
    blit(divergence);

    gl!.useProgram(clearProgram.program);
    gl!.uniform1i(
      clearProgram.uniforms.uTexture,
      bindTexture(pressure.read.texture, 0),
    );
    gl!.uniform1f(
      clearProgram.uniforms.uValue,
      Math.pow(config.pressure, delta * 60),
    );
    blit(pressure.write);
    pressure.swap();

    gl!.useProgram(pressureProgram.program);
    gl!.uniform2f(pressureProgram.uniforms.texelSize, texelX, texelY);
    gl!.uniform1i(
      pressureProgram.uniforms.uDivergence,
      bindTexture(divergence.texture, 0),
    );
    for (let i = 0; i < config.pressureIterations; i++) {
      gl!.uniform1i(
        pressureProgram.uniforms.uPressure,
        bindTexture(pressure.read.texture, 1),
      );
      blit(pressure.write);
      pressure.swap();
    }

    gl!.useProgram(gradientProgram.program);
    gl!.uniform2f(gradientProgram.uniforms.texelSize, texelX, texelY);
    gl!.uniform1i(
      gradientProgram.uniforms.uPressure,
      bindTexture(pressure.read.texture, 0),
    );
    gl!.uniform1i(
      gradientProgram.uniforms.uVelocity,
      bindTexture(velocity.read.texture, 1),
    );
    blit(velocity.write);
    velocity.swap();

    gl!.useProgram(advectProgram.program);
    gl!.uniform2f(advectProgram.uniforms.texelSize, texelX, texelY);
    gl!.uniform1i(
      advectProgram.uniforms.uVelocity,
      bindTexture(velocity.read.texture, 0),
    );
    gl!.uniform1i(
      advectProgram.uniforms.uSource,
      bindTexture(velocity.read.texture, 0),
    );
    gl!.uniform1f(advectProgram.uniforms.uDt, DT);
    gl!.uniform1f(
      advectProgram.uniforms.uDissipation,
      Math.pow(config.velocityDissipation, delta * 60),
    );
    blit(velocity.write);
    velocity.swap();

    gl!.uniform1i(
      advectProgram.uniforms.uVelocity,
      bindTexture(velocity.read.texture, 0),
    );
    gl!.uniform1i(
      advectProgram.uniforms.uSource,
      bindTexture(dye.read.texture, 1),
    );
    gl!.uniform1f(
      advectProgram.uniforms.uDissipation,
      Math.pow(config.densityDissipation, delta * 60),
    );
    blit(dye.write);
    dye.swap();
  }

  function render() {
    uploadContent();
    gl!.useProgram(displayProgram.program);
    gl!.uniform1i(
      displayProgram.uniforms.uContent,
      bindTexture(contentTexture, 0),
    );
    gl!.uniform1i(
      displayProgram.uniforms.uFluid,
      bindTexture(dye.read.texture, 1),
    );
    gl!.uniform3f(
      displayProgram.uniforms.uColor,
      srgbToLinear(config.color[0]),
      srgbToLinear(config.color[1]),
      srgbToLinear(config.color[2]),
    );
    gl!.uniform1f(displayProgram.uniforms.uDistortion, config.distortion);
    gl!.uniform1f(displayProgram.uniforms.uIntensity, config.intensity);
    gl!.uniform1f(displayProgram.uniforms.uBlend, config.blend);
    gl!.uniform1f(displayProgram.uniforms.uRainbow, config.rainbow ? 1 : 0);
    gl!.uniform1f(displayProgram.uniforms.uHasContent, htmlInCanvas ? 1 : 0);
    blit(null);
  }

  const queued: Array<[number, number, number, number]> = [];

  let raf = 0;
  let lastTime = performance.now();
  let destroyed = false;
  let running = false;
  let visible = true;
  let idleAt = 0;

  function idleDelayMs() {
    const dissipation = Math.min(config.densityDissipation, 0.999);
    const frames = Math.log(1e-7) / Math.log(dissipation);
    return (frames / 60) * 1000;
  }

  function frame(now: number) {
    if (destroyed) return;
    if (!visible) {
      running = false;
      return;
    }
    const delta = Math.min((now - lastTime) / 1000, 1 / 30);
    lastTime = now;
    if (queued.length > 0) {
      idleAt = now + idleDelayMs();
      while (queued.length > 0) {
        const [x, y, dx, dy] = queued.pop()!;
        applySplat(x, y, dx, dy);
      }
    }
    step(delta);
    render();
    if (now >= idleAt && !contentDirty) {
      running = false;
      return;
    }
    raf = requestAnimationFrame(frame);
  }

  function start() {
    if (destroyed || running || !visible) return;
    running = true;
    lastTime = performance.now();
    raf = requestAnimationFrame(frame);
  }

  wake = start;
  start();

  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reducedMotion = motionQuery.matches;

  function onMotionChange() {
    reducedMotion = motionQuery.matches;
    if (!reducedMotion) start();
  }
  motionQuery.addEventListener("change", onMotionChange);

  const pointers = new Map<number, { x: number; y: number }>();

  function onPointerMove(event: PointerEvent) {
    if (reducedMotion) return;
    const rect = output.getBoundingClientRect();
    const px = event.clientX - rect.left;
    const py = event.clientY - rect.top;
    const previous = pointers.get(event.pointerId);
    pointers.set(event.pointerId, { x: px, y: py });
    if (!previous) return;
    const dx = (px - previous.x) * config.force;
    const dy = -(py - previous.y) * config.force;
    queued.push([px / rect.width, 1 - py / rect.height, dx, dy]);
    start();
  }

  function onPointerLeave(event: PointerEvent) {
    pointers.delete(event.pointerId);
  }

  const listenTarget = output.parentElement ?? output;
  listenTarget.addEventListener("pointermove", onPointerMove as EventListener);
  listenTarget.addEventListener(
    "pointerleave",
    onPointerLeave as EventListener,
  );
  listenTarget.addEventListener(
    "pointercancel",
    onPointerLeave as EventListener,
  );

  const observer = new ResizeObserver(() => {
    syncCanvasSize();
    start();
  });
  observer.observe(output);

  const intersection = new IntersectionObserver((entries) => {
    visible = entries[entries.length - 1]?.isIntersecting ?? true;
    if (visible) start();
  });
  intersection.observe(output);

  return {
    splat(x, y, dx, dy) {
      if (reducedMotion) return;
      queued.push([x, y, dx, dy]);
      start();
    },
    setOptions(next) {
      const { simResolution, dyeResolution, ...rest } = next;
      void simResolution;
      void dyeResolution;
      Object.assign(config, rest);
      start();
    },
    resize() {
      syncCanvasSize();
      start();
    },
    destroy() {
      destroyed = true;
      cancelAnimationFrame(raf);
      observer.disconnect();
      intersection.disconnect();
      motionQuery.removeEventListener("change", onMotionChange);
      releaseAll();
      gl!.deleteTexture(contentTexture);
      programs.forEach((program) => gl!.deleteProgram(program));
      shaders.forEach((shader) => gl!.deleteShader(shader));
      gl!.deleteBuffer(quad);
      if (htmlInCanvas) paintable.onpaint = null;
      listenTarget.removeEventListener(
        "pointermove",
        onPointerMove as EventListener,
      );
      listenTarget.removeEventListener(
        "pointerleave",
        onPointerLeave as EventListener,
      );
      listenTarget.removeEventListener(
        "pointercancel",
        onPointerLeave as EventListener,
      );
    },
  };
}

export interface LiquidProps extends LiquidOptions {
  children: ReactNode;
  className?: string;
  style?: React.CSSProperties;
}

const emptySubscribe = () => () => {};

export function Liquid({
  children,
  className,
  style,
  ...options
}: LiquidProps) {
  const sourceRef = useRef<HTMLCanvasElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const outputRef = useRef<HTMLCanvasElement>(null);
  const instanceRef = useRef<LiquidInstance | null>(null);
  const [initialOptions] = useState(options);
  const [failed, setFailed] = useState(false);

  const supported = useSyncExternalStore(
    emptySubscribe,
    supportsHtmlInCanvas,
    () => false,
  );
  const native = supported && !failed;

  useEffect(() => {
    const source = sourceRef.current;
    const content = contentRef.current;
    const output = outputRef.current;
    if (!source || !content || !output) return;
    instanceRef.current = createLiquid(
      { source, content, output },
      initialOptions,
    );
    // WebGL init can fail even when html-in-canvas is advertised — remount children
    // on the non-canvas fallback path (createLiquid returned null).
    // eslint-disable-next-line react-hooks/set-state-in-effect -- recover from GPU init failure
    if (native && !instanceRef.current) setFailed(true);
    return () => {
      instanceRef.current?.destroy();
      instanceRef.current = null;
    };
  }, [initialOptions, native]);

  useEffect(() => {
    instanceRef.current?.setOptions(options);
  });

  return (
    <div className={className} style={{ position: "relative", ...style }}>
      <canvas
        ref={sourceRef}
        // @ts-expect-error experimental html-in-canvas attribute
        layoutsubtree="true"
        suppressHydrationWarning
        style={
          native
            ? {
                position: "absolute",
                inset: 0,
                width: "100%",
                height: "100%",
                // Keep painted for first frames; WebGL covers fully once
                // content has an opaque bg-background capture.
              }
            : { display: "none" }
        }
      >
        {native ? (
          <div
            ref={contentRef}
            data-liquid-content
            style={{
              position: "relative",
              width: "100%",
              minHeight: "100%",
              height: "100%",
              overflow: "auto",
              scrollbarWidth: "none",
              msOverflowStyle: "none",
            }}
          >
            {children}
          </div>
        ) : null}
      </canvas>
      {/* html-in-canvas keeps this subtree in the real layout tree so it can
          be captured — its scrollbar would otherwise render on top of the
          WebGL output. Hidden here since it ships to consumer repos without
          our host-only preview CSS. */}
      <style>{`[data-liquid-content]::-webkit-scrollbar{display:none}`}</style>
      {!native ? (
        <div
          ref={contentRef}
          style={{
            position: "relative",
            width: "100%",
            minHeight: "100%",
            height: "100%",
          }}
        >
          {children}
        </div>
      ) : null}
      <canvas
        ref={outputRef}
        aria-hidden
        style={{
          position: "absolute",
          inset: 0,
          width: "100%",
          height: "100%",
          pointerEvents: "none",
        }}
      />
    </div>
  );
}


export default Liquid;
```

### `registry/new-york/templates/portfolio-v2/public/avatar.png.gitkeep`

```gitkeep
Place your avatar here as avatar.png (or avatar.jpg / avatar.webp). Delete this .gitkeep file after adding your photo.
```

### `registry/new-york/templates/portfolio-v2/lib/notes.tsx`

```tsx
import type { ReactNode } from "react"

export type Note = {
  slug: string
  title: string
  date: string
  description?: string
  body: () => ReactNode
}

const notes: Note[] = [
  {
    slug: "agents-that-survive-a-restart",
    title: "Agents that survive a restart",
    date: "2025-11-12",
    description: "Why I stopped treating an agent like a function that either works or vanishes.",
    body: () => (
      <>
        <p>
          I used to think of an agent as one long function call. You send a
          message, something happens, you get an answer back. That mental
          model works fine in a demo and falls apart the moment a real task
          takes longer than a few seconds, a server restarts, or a person
          steps away and comes back an hour later expecting the conversation
          to still make sense.
        </p>
        <p>
          What actually holds up is treating every step the agent takes as
          something worth saving. Not just the final answer, but where it
          was in the middle of a task, what it had already decided, what it
          was still waiting on. When it can pick up from that saved point
          instead of starting over, a crash stops being a disaster and
          becomes something closer to a hiccup.
        </p>
        <p>
          The word for this took me a while to appreciate. Durable. It
          sounds like a small engineering detail until the day your agent
          survives a deploy in the middle of a task and nobody even notices.
        </p>
      </>
    ),
  },
  {
    slug: "a-chatbot-is-a-codebase",
    title: "A chatbot is a codebase",
    date: "2025-09-03",
    description: "The day I stopped writing one giant prompt and started building an actual app.",
    body: () => (
      <>
        <p>
          For a long time my agents were one enormous prompt with everything
          crammed into it. The personality, the rules, the tool
          descriptions, the edge cases somebody hit three weeks ago that I
          patched with one more sentence. It worked, in the way a single
          file with four thousand lines of code works. Which is to say,
          barely, and only for me.
        </p>
        <p>
          The shift that actually helped was treating the agent like a real
          project instead of a clever paragraph. A short file that says who
          it is. A folder of skills it can read when a task calls for them.
          Small tools it can reach for instead of pretending it can do
          everything from memory. Once I gave it that shape, I could open
          the project six months later and still understand why it behaves
          the way it does.
        </p>
        <p>
          A chatbot people trust is rarely one clever prompt. It is a small,
          honest codebase that happens to talk.
        </p>
      </>
    ),
  },
  {
    slug: "the-human-in-the-loop-is-not-optional",
    title: "The human in the loop is not optional",
    date: "2025-06-18",
    description: "Some actions deserve a person's eyes before they happen, not after.",
    body: () => (
      <>
        <p>
          Early on I let an agent send an email on its own, and it was fine,
          right up until it was not. Nothing catastrophic, just a message
          that went out a little too early with a tone nobody had approved.
          A small mistake, but it taught me something about where autonomy
          earns trust and where it has not yet.
        </p>
        <p>
          Now anything that touches money, sends something a person cannot
          unsend, or changes a record we cannot easily reverse gets a pause
          built in. The agent still does the thinking. It drafts the plan,
          it explains what it wants to do and why. A person just has to
          actually look at it before it becomes real.
        </p>
        <p>
          That pause is not a lack of confidence in the model. It is respect
          for the fact that some moments deserve a witness.
        </p>
      </>
    ),
  },
  {
    slug: "one-agent-many-rooms",
    title: "One agent, many rooms",
    date: "2025-03-01",
    description: "The same agent shows up in Slack, on the web, and in a terminal, and has to feel like itself everywhere.",
    body: () => (
      <>
        <p>
          I built an agent for our team and the first version only lived in
          one place, a chat window on a webpage. Then someone asked if it
          could live in Slack too. Then someone else wanted it in a script
          that ran from the terminal at two in the morning. Suddenly the
          same brain needed to show up in three very different rooms and
          behave like it belonged in each one.
        </p>
        <p>
          The trick was keeping the thinking in one place and letting only
          the surface change. The part that decides what to do stayed the
          same no matter where the message came from. What changed was how
          a response got formatted, how long a message could be, whether a
          button made sense or a plain line of text was all the room could
          offer.
        </p>
        <p>
          It is a strangely fullstack problem. The model barely changes.
          Everything else, the delivery, the formatting, the little manners
          of each platform, is where most of the real work quietly lives.
        </p>
      </>
    ),
  },
]

export function getNotes() {
  return [...notes].sort((a, b) => b.date.localeCompare(a.date))
}

export function getNote(slug: string) {
  return notes.find((note) => note.slug === slug)
}

export function formatNoteDate(dateStr: string) {
  const [year, month, day] = dateStr.split("-").map(Number)
  const date = new Date(Date.UTC(year, (month ?? 1) - 1, day ?? 1))
  return date.toLocaleDateString("en-US", {
    month: "long",
    day: "numeric",
    year: "numeric",
    timeZone: "UTC",
  })
}
```

### `registry/new-york/templates/portfolio-v2/.env.example`

```example
# Optional: Chrome Origin Trial so Liquid warps HTML without chrome://flags.
# Register once for your production origin (check "match all subdomains" if needed):
# https://developer.chrome.com/origintrials → html-in-canvas
# Example: Web Origin = https://yoursite.com
NEXT_PUBLIC_HTML_IN_CANVAS_OT_TOKEN=
```

### `registry/new-york/templates/portfolio-v2/AGENTS.md`

````md
# Personalizing this portfolio

This site ships with filler content for a fictional fullstack developer
("Jon Doe"). It's a demo persona, not a template you fill in
field-by-field — your job is to fully re-skin the site as the real person
who owns this project.

**Trigger:** the user attaches their resume (a PDF) directly to you and
asks you to personalize the site — e.g. by mentioning `@AGENTS.md`. When
that happens, follow the steps below.

The resume is personal data and has no reason to live in this repo. Work
from the attachment. Do **not** copy it into the project directory at any
point — not to read it, not to run the extraction in Step 1, not
"just temporarily while I work." As a safety net in case someone saves it
here by hand, add a line to `.gitignore` before you start (skip if it's
already listed):

```
grep -qxF 'resume.pdf' .gitignore || echo 'resume.pdf' >> .gitignore
```

## Step 1 — Read the resume

Read the attached resume. Pull out:

- Full name
- Real profession / field / title (e.g. "Full-Stack Developer", "Product
  Designer", "Marine Biologist") — this drives the tone of everything below
- A narrative bio: one first-person paragraph synthesized from their
  summary, experience, and notable work, in a voice that matches their
  actual field. Do not reuse the demo's "AI infrastructure startups /
  ship in pieces" framing with the name swapped in — the whole voice
  changes.
- Contact email, if present
- Social / portfolio links actually present in the resume (LinkedIn, GitHub,
  personal site, X, Dribbble, etc.)
- Notable projects, work, or focus areas worth listing under Projects and
  writing about under Notes (Step 4)
- Employment history — role, company, dates — for the Experience list
  (Step 4)

### Recovering the real URL behind a link

Reading the PDF only gives you visible text — "GitHub" as a clickable
label doesn't tell you what it links to. Before treating any link as
unresolved, try recovering the actual target from the raw bytes of the
file.

This needs a real filesystem path, and the resume is an attachment, not a
file in the repo. Copy it to a scratch location **outside the project** —
your OS temp directory — run the search there, and delete it when done:

```
# adjust the source to wherever the attachment actually is on disk
cp <attached-resume-path> "${TMPDIR:-/tmp}/resume.pdf"
grep -a -o "/URI[^)]*)" "${TMPDIR:-/tmp}/resume.pdf"
rm "${TMPDIR:-/tmp}/resume.pdf"
```

On Windows use the temp dir (`"$TEMP/resume.pdf"` in Git Bash,
`$env:TEMP` in PowerShell). Never copy it under the project directory,
even for a moment. If you can't get a filesystem path to the attachment at
all, ask the user for the path to the file on their machine and grep it in
place — still don't bring it into the repo.

Most resumes (exported from Google Docs, Word, or a resume builder) store
their hyperlink targets as plain-text `/URI (...)` entries even when the
rest of the PDF is compressed, so this works more often than not. Match
each URL back to the label near it in the resume text — a `mailto:` is the
email, a `github.com/...` URL is GitHub, `linkedin.com/in/...` is LinkedIn,
and so on. Do this for every link-looking label in the resume, not just
the obvious ones — it's what makes Step 3 actually work instead of falling
back to asking every time.

Only fall back to asking the user for the real link if this comes up empty
and the resume text still shows an unresolved label — some PDFs compress
the annotation objects too, which a plain-text search can't see through.

## Step 2 — Identity & tone

Rewrite, in the new voice from Step 1:

- `app/page.tsx`:
  - the `<h1>` name ("Jon Doe")
  - the role line directly under it (`Fullstack Developer`) — their real
    title from Step 1
  - the greeting line ("Hey, I'm Jon.") — "Hey, I'm &lt;first name&gt;."
  - the single bio `<p>` below the greeting. One paragraph, two or three
    sentences: role → what they work on and who for → a concrete stance on
    how they work. The original is "I work as a fullstack developer,
    building for AI infrastructure startups and teams shipping real tools
    with real users. I keep my stack simple, I ship in pieces I can trust,
    and I leave software that stays easy to maintain." Keep that length —
    one paragraph, not three. A nurse or an accountant doesn't get the
    "ship in pieces" developer framing; match the stance to their field.
- `app/layout.tsx` — `metadata.title` (the name) and `metadata.description`
- `app/notes/page.tsx` — the name and role in the top-left home link, the
  `<h1>` ("Notes" — usually fine, see "Section labels"), and the one-line
  intro under it ("Short writing on fullstack development, shipping, and
  systems.")
- `app/notes/[slug]/page.tsx` — the name and role in the same top-left link
- `components/avatar.tsx` — the `JD` fallback initials (their initials).
  The photo path and resolution logic stay, see "Do not touch".
- `components/copy-email.tsx` — the `EMAIL` constant (currently a real
  address) → their contact email from the resume. If the resume has no
  email, keep the component but ask the user which address to use.

**Length budget:** the demo bio is one short paragraph on purpose. Count
its words before you rewrite and stay within about 20%. One paragraph, not
two or three. The one-line intro under the Notes heading stays one line.

### Section labels

"Notes", "Projects", and "Experience" fit most people as-is. If one reads
wrong for the field, relabel it — this doesn't touch routing, `/notes`
stays `/notes`, only the visible text changes: the `<h2>` in `app/page.tsx`
and, for Notes, also the `<h1>` and intro in `app/notes/page.tsx`. Keep
each label to a single word.

## Step 3 — Links

The bio paragraph in `app/page.tsx` ends with a fixed set of social slots —
**X, LinkedIn, GitHub** — followed by a copy-email button. Keep all three
labels and the sentence shape. This is a redirect slot set, not a list you
edit down to what the resume happens to mention. ("X" is the platform
formerly called Twitter — a resume may still label it either way.)

For each of the three, the rule is the same every time:

- Resume has a real link/handle for it → replace that one `href` with the
  real URL. Keep `target="_blank" rel="noreferrer"` as-is.
- Resume doesn't mention it, **or you can't recover a real URL for it**
  (see the PDF caveat in Step 1) → set `href="#"`. Don't leave the
  original bare-domain placeholder (`https://x.com/`, `https://github.com/`,
  `https://www.linkedin.com/`) — that's a working link to the wrong page,
  which is worse than an obvious placeholder. `href="#"` reads
  unambiguously as "not filled in yet."

A link's visible text alone ("Portfolio", "GitHub") doesn't tell you the
`href` — use the extraction command from Step 1 to recover it before
concluding it's unresolved. Only after that comes up empty do you treat it
like "not on the resume": don't guess a URL, don't leave a URL that looks
real but isn't. Set it to `href="#"` and ask the user for the actual link.

**Never add a "Portfolio" link found on the resume.** This site *is* their
portfolio — that resume link points at wherever their portfolio currently
lives, and once they've personalized this template, this is that site.
Skip it entirely, no matter what domain it resolves to.

Beyond the fixed three, if the resume links somewhere else worth keeping
(Dribbble, a Mastodon handle, a personal site that isn't their portfolio),
add it as one more `<Button variant="link" asChild>` slot in the same
sentence, in the order you found it. Never remove one of the three to make
room.

The **Experience** section links — both the per-role list items and the
"LinkedIn" link under the list in `app/page.tsx` — all point at
`https://www.linkedin.com/`. Point them at the person's real LinkedIn if
the resume has it, or `#` if not. Same rule.

## Step 4 — Notes, projects, and experience

### Notes — `lib/notes.tsx`

Four demo notes about building AI agents. Replace them with entries that
fit the person's real field from Step 1.

- Keep the same shape per entry: `slug`, `title`, `date` (`YYYY-MM-DD`),
  `description`, and a `body` that returns JSX — **plain `<p>` paragraphs
  only**, the way the demo entries are built. No images, no headings, no
  tables: this template renders a note body as a simple prose column and
  has no styling for anything else.
- Keep four entries. If the resume genuinely doesn't support four distinct
  topics, say so and ask before shipping fewer.
- Length budget per note: count the words in the entry you're replacing
  (two or three short paragraphs) and stay within about 20%.
- Keep `description` to one sentence, like the originals.
- Titles and topics should read as genuinely written by this person about
  their real work — use specifics from the resume, not a generic reskin.

### Projects — the `projects` array in `app/page.tsx`

Four demo side-projects. Replace with the person's real projects, portfolio
pieces, or notable work from the resume.

- Keep the shape: `number` (`"01"`–`"04"`), `name`, `href`, `description`
  (one line, roughly the length of the originals).
- Keep four. Same "ask before shipping fewer" rule.
- `href`: a real repo or live URL if the resume has one, otherwise `"#"` —
  not the bare `https://github.com/` placeholder.

### Experience — the `experience` array in `app/page.tsx`

A demo career ladder with invented companies. Replace with the person's
real roles from the resume.

- Keep the shape: `role`, `company`, `dates` (match the `"2024 to Present"`
  format).
- Use their actual roles, most recent first. Three to five entries is the
  right range — use what the resume actually has rather than padding to a
  fixed count.

## Step 5 — Unslop pass

Before you're done, read `UNSLOP.md` and check everything you drafted in
Steps 2 and 4 against it. AI-generated bios and blog posts have a
recognizable smell — stacked em dashes, fortune-cookie closing lines,
"not just X but Y," decorative jargon — and this is exactly the content
most likely to have it. Rewrite what trips those patterns before finishing.

## Do not touch

- `components/liquid-shell.tsx`, `components/canvasui/liquid.tsx`, and the
  `NEXT_PUBLIC_HTML_IN_CANVAS_OT_TOKEN` / Origin Trial wiring in
  `app/layout.tsx` and `.env.example` — the Liquid background is separate
  manual setup, not derived from the resume. Leave it on by default; the
  owner removes it themselves if they don't want it.
- `components/avatar.tsx` beyond the fallback initials — the
  photo-resolution logic and the `public/avatar.png` path stay. The owner
  drops their own `public/avatar.png` after install.
- `components/theme-provider.tsx`, `components/theme-toggle.tsx`, and the
  logic in `components/copy-email.tsx` (only the `EMAIL` value changes,
  never the component).
- Theme, CSS, and routing — `/notes` stays `/notes` as a URL; only the
  visible labels change, per "Section labels", never the folder names or
  routes themselves.

## When you're done

The resume was never copied into the repo, so there's nothing to clean up
there. If you made a scratch copy in a temp directory for the Step 1
extraction, confirm it's deleted. If the user has a `resume.pdf` sitting in
the project root anyway (a manual drop, or a previous run), point it out
and offer to remove it — `.gitignore` now keeps it from being committed
either way.
````

### `registry/new-york/templates/portfolio-v2/UNSLOP.md`

```md
# Unslop

A pass to run against everything written during personalization — the
bio in `app/page.tsx`, the project descriptions, and every note body in
`lib/notes.tsx`. This applies whenever `AGENTS.md` has you drafting prose
for this site, not just once.

## Process

1. Draft the content per `AGENTS.md`.
2. Before you're done, read it back against every pattern below.
3. Rewrite what trips a pattern. Preserve the meaning and the person's real
   voice — don't flatten it, just strip the tells.
4. Self-audit once more: "would this specific sentence survive if a human
   who does this work for real wrote it?" If not, cut or rewrite it.

## Patterns to detect and fix

### Style

1. **Em dash overuse.** One sentence with two em dashes is the single most
   common tell in generated bios. Use a period or comma instead.
   - Bad: "I build products end to end — frontend, backend, and
     infrastructure — and I'd rather have something live than perfect."
   - Better: "I build products end to end: frontend, backend,
     infrastructure. I'd rather have something live than perfect."
2. **Colon as a mid-sentence connector.** "The mechanism is simple once you
   see it:" or "The honest version:" add nothing — they're a wind-up
   before the actual sentence. Cut the wind-up, state the point.
3. **Manufactured profundity.** A closing line that sounds like a fortune
   cookie is a tell, not a flourish.
   - Bad: "The best infrastructure is the kind you stop noticing. Nobody
     should think about the deck. They should think about the talk."
   - Just say what happened: "I built it because I got tired of tethering
     a laptop to a projector just to glance at my own notes."
4. **Decorative technical notation.** Don't dress a plain idea up as a
   formula or fake diagram to look rigorous. Only use real numbers or
   tables for content that's actually quantitative.
5. **Rhetorical essay openers.** "X reads like Y until you realize Z" and
   similar setup-then-reveal openings are a generated-essay pattern. Open
   with the actual fact or event instead.
6. **Title case headings, curly quotes, decorative emphasis.** Sentence
   case, straight quotes, and don't bold every proper noun.

### Language

7. **AI vocabulary.** Additionally, crucial, delve, enduring, fostering,
   garner, interplay, intricate, landscape (abstract), pivotal, showcase,
   tapestry, testament, underscore, vibrant, "at the intersection of."
   Replace with the plain word or cut.
8. **Fancy ways to say "is."** "serves as", "stands as", "boasts". Just say
   "is" or "has."
9. **"Not just X, but Y."** State the point directly.
10. **Rule of three.** Don't force two real examples into three by padding
    a filler item. Use the number that's actually true.
11. **Vague attribution.** "People say", "it's widely known" — if there's
    no source, cut the claim or make it a fact you can stand behind.

### Voice

12. **Say what happened, not how it felt.** "made the workflow seamless"
    names a feeling. "cut the review step from three tools to one" names a
    fact. If a sentence could sit unchanged in anyone else's portfolio,
    it says nothing about this one — cut it or make it specific.
13. **Active voice, real actor.** "the score was improved by streaming the
    response" becomes "streaming the response improved the score."
14. **Cut adverbs propping up a weak verb.** "runs significantly faster"
    becomes the actual number, or a stronger verb.
15. **Plain word over the fancier synonym.** "utilize" → "use", "leverage"
    → "use", "facilitate" → "help", "numerous" → "many."
16. **Chatbot artifacts.** No "I hope this helps," no apologizing, no
    address to a reader who isn't there — this is a bio and a blog post,
    not a chat reply.

### Length

17. **Padding past the point.** AI drafts tend to run long — an extra
    sentence restating what the previous one said, a closing paragraph that
    summarizes instead of ending, an implication spelled out that the
    reader already got. `AGENTS.md` gives you a word-count budget for the
    bio and each note, measured against what you're replacing — if you're
    over it, the fix is almost always to cut a sentence, not to tighten
    the wording of all of them.
18. **One idea per note, not three angles on it.** If two paragraphs are
    making the same point from different sides, keep the sharper one and
    delete the other.

## Sound test

Read the final paragraph out loud. If it sounds like something written
*about* the person rather than *by* them, it's still slopped.
```
