# Portfolio v1

> Next.js starter for a physicist and science communicator portfolio: writing, books, Saturn decoration, and a Basin contact form. New projects only (shadcn init --template next).

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

- Page, with a live preview: https://shadcn.exchange/r/uiception/portfolio-v1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uiception/portfolio-v1-light-89fde8a4e8fa6bd3.png
- Registry item: https://uiception.com/r/portfolio-v1.json
- Categories: template, portfolio
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uiception.com/r/portfolio-v1.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`, `gray-matter`, `katex`, `lucide-react`, `next-mdx-remote`, `radix-ui`, `rehype-katex`, `remark-gfm`, `remark-math`, `sonner`, `zod`

## 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-v1.json"

This scaffolds Next.js + shadcn, then installs the full site (app routes, content, components).

Then set BASIN_ENDPOINT in .env.local. Edit your name/bio/social links in app/page.tsx and app/layout.tsx. Writing posts live in content/writing/*.mdx and books live in content/books/*.mdx. MDX supports GFM tables and LaTeX math ($inline$ / $$block$$) via remark-gfm, remark-math, and rehype-katex.

## Files

### `registry/new-york/templates/portfolio-v1/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-v1/components/ui/field.tsx`

```tsx
"use client"

import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"

function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
  return (
    <fieldset
      data-slot="field-set"
      className={cn(
        "flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
        className
      )}
      {...props}
    />
  )
}

function FieldLegend({
  className,
  variant = "legend",
  ...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
  return (
    <legend
      data-slot="field-legend"
      data-variant={variant}
      className={cn(
        "mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
        className
      )}
      {...props}
    />
  )
}

function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-group"
      className={cn(
        "group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
        className
      )}
      {...props}
    />
  )
}

const fieldVariants = cva(
  "group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
  {
    variants: {
      orientation: {
        vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
        horizontal:
          "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
        responsive:
          "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
      },
    },
    defaultVariants: {
      orientation: "vertical",
    },
  }
)

function Field({
  className,
  orientation = "vertical",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
  return (
    <div
      role="group"
      data-slot="field"
      data-orientation={orientation}
      className={cn(fieldVariants({ orientation }), className)}
      {...props}
    />
  )
}

function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-content"
      className={cn(
        "group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
        className
      )}
      {...props}
    />
  )
}

function FieldLabel({
  className,
  ...props
}: React.ComponentProps<typeof Label>) {
  return (
    <Label
      data-slot="field-label"
      className={cn(
        "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
        "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
        className
      )}
      {...props}
    />
  )
}

function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-label"
      className={cn(
        "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
        className
      )}
      {...props}
    />
  )
}

function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="field-description"
      className={cn(
        "text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
        "last:mt-0 nth-last-2:-mt-1",
        "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
        className
      )}
      {...props}
    />
  )
}

function FieldSeparator({
  children,
  className,
  ...props
}: React.ComponentProps<"div"> & {
  children?: React.ReactNode
}) {
  return (
    <div
      data-slot="field-separator"
      data-content={!!children}
      className={cn(
        "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
        className
      )}
      {...props}
    >
      <Separator className="absolute inset-0 top-1/2" />
      {children && (
        <span
          className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
          data-slot="field-separator-content"
        >
          {children}
        </span>
      )}
    </div>
  )
}

function FieldError({
  className,
  children,
  errors,
  ...props
}: React.ComponentProps<"div"> & {
  errors?: Array<{ message?: string } | undefined>
}) {
  const content = useMemo(() => {
    if (children) {
      return children
    }

    if (!errors?.length) {
      return null
    }

    const uniqueErrors = [
      ...new Map(errors.map((error) => [error?.message, error])).values(),
    ]

    if (uniqueErrors?.length == 1) {
      return uniqueErrors[0]?.message
    }

    return (
      <ul className="ml-4 flex list-disc flex-col gap-1">
        {uniqueErrors.map(
          (error, index) =>
            error?.message && <li key={index}>{error.message}</li>
        )}
      </ul>
    )
  }, [children, errors])

  if (!content) {
    return null
  }

  return (
    <div
      role="alert"
      data-slot="field-error"
      className={cn("text-sm font-normal text-destructive", className)}
      {...props}
    >
      {content}
    </div>
  )
}

export {
  Field,
  FieldLabel,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldContent,
  FieldTitle,
}
```

### `registry/new-york/templates/portfolio-v1/components/ui/input.tsx`

```tsx
import * as React from "react"

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

function Input({ className, type, ...props }: React.ComponentProps<"input">) {
  return (
    <input
      type={type}
      data-slot="input"
      className={cn(
        "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
        className
      )}
      {...props}
    />
  )
}

export { Input }
```

### `registry/new-york/templates/portfolio-v1/components/ui/label.tsx`

```tsx
"use client"

import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"

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

function Label({
  className,
  ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
  return (
    <LabelPrimitive.Root
      data-slot="label"
      className={cn(
        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
        className
      )}
      {...props}
    />
  )
}

export { Label }
```

### `registry/new-york/templates/portfolio-v1/components/ui/separator.tsx`

```tsx
"use client"

import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"

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

function Separator({
  className,
  orientation = "horizontal",
  decorative = true,
  ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
  return (
    <SeparatorPrimitive.Root
      data-slot="separator"
      decorative={decorative}
      orientation={orientation}
      className={cn(
        "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
        className
      )}
      {...props}
    />
  )
}

export { Separator }
```

### `registry/new-york/templates/portfolio-v1/components/ui/textarea.tsx`

```tsx
import * as React from "react"

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

function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
  return (
    <textarea
      data-slot="textarea"
      className={cn(
        "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
        className
      )}
      {...props}
    />
  )
}

export { Textarea }
```

### `registry/new-york/templates/portfolio-v1/components/ui/sonner.tsx`

```tsx
"use client"

import { Toaster as Sonner, type ToasterProps } from "sonner"

function Toaster({ ...props }: ToasterProps) {
  return (
    <Sonner
      theme="system"
      className="toaster group"
      style={
        {
          "--normal-bg": "var(--popover)",
          "--normal-text": "var(--popover-foreground)",
          "--normal-border": "var(--border)",
          "--border-radius": "var(--radius)",
        } as React.CSSProperties
      }
      toastOptions={{
        classNames: {
          toast: "cn-toast",
        },
      }}
      {...props}
    />
  )
}

export { Toaster }
```

### `registry/new-york/templates/portfolio-v1/components/ui/spinner.tsx`

```tsx
import { Loader2Icon } from "lucide-react"

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

function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
  return (
    <Loader2Icon
      role="status"
      aria-label="Loading"
      className={cn("size-4 animate-spin", className)}
      {...props}
    />
  )
}

export { Spinner }
```

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

```tsx
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import type { ReactNode } from "react"

import { ThemeProvider } from "../components/theme-provider"
import { Toaster } from "../components/ui/sonner"

import "katex/dist/katex.min.css"
import "./globals.css"
import "./typeset.css"
import "../styles/typeset-article.css"

const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
})

export const metadata: Metadata = {
  title: "Jon Doe",
  description:
    "Physicist and science communicator. Planetary science, the solar system, and public writing about how the universe works.",
}

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${inter.className}`}
      suppressHydrationWarning
    >
      <body className="min-h-svh bg-background text-foreground antialiased">
        <ThemeProvider>
          <div className="mx-auto w-full max-w-[90ch] px-6 py-16 md:py-24">
            <main>{children}</main>
          </div>
          <Toaster />
        </ThemeProvider>
      </body>
    </html>
  )
}
```

### `registry/new-york/templates/portfolio-v1/app/typeset.css`

```css
@layer components {
  .typeset {
    --typeset-font-body: var(--font-inter, inherit);
    --typeset-font-heading: var(--font-inter, inherit);
    --typeset-font-mono: var(--font-inter, inherit);
    --typeset-size: 1em;
    --typeset-leading: 1.75;
    --typeset-flow: 1.25em;
  }
}

@layer components {
  .typeset {
    font-family: var(--typeset-font-body);
    font-size: calc(var(--typeset-size) * 1.125);
    line-height: var(--typeset-leading);
    color: var(--color-foreground, currentColor);
    overflow-wrap: break-word;
    margin-trim: block-start;

    @media (min-width: 48rem), print {
      font-size: var(--typeset-size);
    }

    --typeset-muted: var(
      --color-muted-foreground,
      color-mix(in oklab, currentColor 60%, transparent)
    );
    --typeset-rule: var(
      --color-border,
      color-mix(in oklab, currentColor 20%, transparent)
    );
  }

  .typeset
    *:not(
      :where(
        .not-typeset,
        [data-not-typeset],
        .not-typeset *,
        [data-not-typeset] *
      )
    ) {
    &:where(p) {
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
    }

    &:where(h1, h2, h3, h4, h5, h6) {
      color: var(--color-foreground, currentColor);
      font-family: var(--typeset-font-heading);
      font-weight: 600;
      margin-block-end: 0;
    }
    &:where(h1) {
      font-size: 1.75em;
      line-height: 1.3;
      margin-block-start: var(--typeset-flow);
    }
    &:where(h2) {
      font-size: 1.25em;
      line-height: 1.4;
      margin-block-start: calc(var(--typeset-flow) * 1.4);
    }
    &:where(h3) {
      font-size: 1.125em;
      line-height: 1.45;
      margin-block-start: var(--typeset-flow);
    }
    &:where(h4) {
      font-size: 1em;
      line-height: 1.5;
      margin-block-start: var(--typeset-flow);
    }
    &:where(h5) {
      font-size: 0.875em;
      line-height: 1.5;
      font-weight: 500;
      color: var(--typeset-muted);
      margin-block-start: calc(var(--typeset-flow) / 0.875);
    }
    &:where(h6) {
      font-size: 0.8125em;
      line-height: 1.5;
      font-weight: 500;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--typeset-muted);
      margin-block-start: calc(var(--typeset-flow) / 0.8125);
    }
    &:where([id]) {
      scroll-margin-block-start: var(--typeset-flow);
    }

    &:where(h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + *) {
      margin-block-start: 1em;
    }
    &:where(:is(h1, h2, h3, h4) :is(code)) {
      font-size: 0.9em;
    }

    &:where(a) {
      color: inherit;
      font-weight: 500;
      text-decoration-line: underline;
      text-decoration-color: color-mix(in oklab, currentColor 30%, transparent);
    }
    &:where(a:hover) {
      text-decoration-color: currentColor;
    }
    &:where(a:focus-visible) {
      outline: 2px solid var(--color-ring, currentColor);
      outline-offset: 2px;
      border-radius: 0.125em;
    }
    &:where(:is(h1, h2, h3, h4, h5, h6) :is(a)) {
      font-weight: inherit;
    }

    &:where(strong, b) {
      font-weight: 600;
    }
    &:where(:is(h1, h2, h3, h4) :is(strong, b)) {
      font-weight: 600;
    }
    &:where(mark) {
      background-color: color-mix(
        in oklab,
        oklch(0.86 0.17 95) 40%,
        transparent
      );
      color: inherit;
      padding: 0.05em 0.15em;
      border-radius: 0.2em;
    }
    &:where(abbr[title]) {
      text-decoration-line: underline;
      text-decoration-style: dotted;
      text-underline-offset: 0.2em;
      cursor: help;
    }
    &:where(del, s) {
      color: var(--typeset-muted);
      text-decoration-line: line-through;
    }
    &:where(sup, sub) {
      font-size: 0.75em;
      line-height: 0;
      position: relative;
      vertical-align: baseline;
    }
    &:where(sup) {
      top: -0.5em;
    }
    &:where(sub) {
      bottom: -0.25em;
    }
    &:where(sup a) {
      text-decoration-line: none;
      font-weight: 500;
    }

    &:where(ul) {
      list-style-type: disc;
    }
    &:where(ol) {
      list-style-type: decimal;
    }
    &:where(ul ul) {
      list-style-type: circle;
    }
    &:where(ul ul ul) {
      list-style-type: square;
    }
    &:where(ol[type="a" i]) {
      list-style-type: lower-alpha;
    }
    &:where(ol[type="i" i]) {
      list-style-type: lower-roman;
    }
    &:where(ol[type="A" s]) {
      list-style-type: upper-alpha;
    }
    &:where(ol[type="I" s]) {
      list-style-type: upper-roman;
    }
    &:where(ul, ol) {
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
      padding-inline-start: 1.5em;
    }
    &:where(li) {
      margin-block-start: 0.5em;
      padding-inline-start: 0.4em;
    }
    &:where(li > p, li > ul, li > ol) {
      margin-block-start: 0.5em;
    }
    &:where(ul > li)::marker {
      color: var(--typeset-muted);
    }
    &:where(ol > li)::marker {
      color: var(--typeset-muted);
    }

    &:where(ul.contains-task-list) {
      list-style-type: none;
      padding-inline-start: 0.25em;
    }
    &:where(li.task-list-item > input[type="checkbox"]) {
      margin-inline-end: 0.5em;
      vertical-align: -0.1em;
      accent-color: var(--color-primary, currentColor);
    }

    &:where(details) {
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
    }
    &:where(summary) {
      cursor: pointer;
      font-weight: 500;
    }
    &:where(summary)::marker {
      color: var(--typeset-muted);
    }

    &:where(kbd) {
      font-family: inherit;
      font-size: 0.85em;
      font-weight: 500;
      border: 1px solid var(--typeset-rule);
      border-block-end-width: 2px;
      border-radius: min(calc(var(--radius, 0.5em) * 0.6), 0.35em);
      padding: 0.0625em 0.35em;
    }

    &:where(dl) {
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
    }
    &:where(dt) {
      font-weight: 500;
      margin-block-start: 1em;
    }
    &:where(dt + dt) {
      margin-block-start: 0.25em;
    }
    &:where(dd) {
      margin-block-start: 0.25em;
      margin-inline-start: 0;
      padding-inline-start: 1em;
      color: var(--typeset-muted);
    }

    &:where(:not(pre) > code) {
      background-color: var(
        --color-muted,
        color-mix(in oklab, currentColor 8%, transparent)
      );
      font-family: var(--typeset-font-mono);
      font-size: 0.85em;
      border-radius: min(calc(var(--radius, 0.5em) * 0.6), 0.35em);
      padding: 0.125em 0.3em;
    }

    &:where(pre) {
      background-color: var(
        --color-muted,
        color-mix(in oklab, currentColor 8%, transparent)
      );
      font-family: var(--typeset-font-mono);
      font-size: 0.875em;
      line-height: 1.5;
      tab-size: 2;
      direction: ltr;
      border-radius: var(--radius, 0.5em);
      padding: 0.75em 1em;
      overflow-x: auto;
      margin-block-start: calc(var(--typeset-flow) / 0.875);
      margin-block-end: 0;
    }
    &:where(pre code) {
      background-color: transparent;
      font-family: inherit;
      font-size: inherit;
      padding: 0;
      border-radius: 0;
    }

    &:where(blockquote) {
      border-inline-start: 2px solid var(--typeset-rule);
      padding-inline-start: 1em;
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
      margin-inline: 0;
    }

    &:where(hr) {
      border: 0;
      border-block-start: 1px solid var(--typeset-rule);
      margin-block-start: calc(var(--typeset-flow) * 2.4);
      margin-block-end: 0;
    }
    &:where(hr + h1, hr + h2, hr + h3, hr + h4) {
      margin-block-start: var(--typeset-flow);
    }

    &:where(.footnotes, [data-footnotes]) {
      margin-block-start: calc(var(--typeset-flow) * 2);
      border-block-start: 1px solid var(--typeset-rule);
      padding-block-start: var(--typeset-flow);
      font-size: 0.875em;
      color: var(--typeset-muted);
    }

    &:where(math[display="block"]) {
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
      overflow-x: auto;
      overflow-y: hidden;
      padding-block: 0.25em;
    }

    &:where(img, video) {
      border-radius: var(--radius, 0.5em);
      max-width: 100%;
      height: auto;
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
    }
    &:where(p img) {
      margin-block-start: 0;
    }
    &:where(figure) {
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
      margin-inline: 0;
    }
    &:where(figcaption, caption) {
      color: var(--typeset-muted);
      font-size: 0.875em;
      text-align: center;
      margin-block-start: calc(0.75em / 0.875);
    }
    &:where(caption) {
      caption-side: bottom;
    }

    &:where(table) {
      max-width: 100%;
      font-size: 1em;
      line-height: 1.5;
      font-variant-numeric: tabular-nums;
      border-collapse: separate;
      border-spacing: 0;
      border-block-end: 1px solid var(--typeset-rule);
      margin-block-start: var(--typeset-flow);
      margin-block-end: 0;
    }

    &:where(.typeset-scroll) {
      overflow-x: auto;
      margin-block-start: var(--typeset-flow);
    }
    &:where(.typeset-scroll > *) {
      margin-block-start: 0;
    }
    &:where(.typeset-scroll table) {
      width: max-content;
      max-width: none;
    }
    &:where(thead th) {
      font-weight: 500;
      text-align: start;
      white-space: nowrap;
      padding: 0.65em 1em;
    }
    &:where(:is(tbody, tfoot) :is(td, th)) {
      padding: 0.75em 1em;
      text-align: start;
      vertical-align: top;
      border-block-start: 1px solid var(--typeset-rule);
    }
    &:where(tbody th, tfoot th) {
      font-weight: 500;
    }
    &:where(th:first-child, td:first-child) {
      padding-inline-start: 0;
    }
    &:where(th[align="center"], td[align="center"]) {
      text-align: center;
    }
    &:where(th[align="right"], td[align="right"]) {
      text-align: end;
    }

    &:where(li > blockquote, li > table, li > figure) {
      margin-block-start: 0.5em;
    }
    &:where(li > pre) {
      margin-block-start: calc(0.5em / 0.875);
    }

    @media print {
      &:where(pre, table, blockquote, figure) {
        break-inside: avoid;
      }
      &:where(h1, h2, h3, h4) {
        break-after: avoid;
      }
    }

    @media (forced-colors: active) {
      &:where(:not(pre) > code) {
        border: 1px solid;
      }
    }
  }

  .typeset
    > :where(:first-child):not(
      :where(
        .not-typeset,
        [data-not-typeset],
        .not-typeset *,
        [data-not-typeset] *
      )
    ),
  .typeset
    > :where(:first-child)
    > :where(:first-child):not(
      :where(
        .not-typeset,
        [data-not-typeset],
        .not-typeset *,
        [data-not-typeset] *
      )
    ),
  .typeset
    :where(
      li > :first-child,
      blockquote > :first-child,
      td > :first-child,
      th > :first-child,
      dd > :first-child,
      figure > :first-child,
      figure > picture > img
    ):not(
      :where(
        .not-typeset,
        [data-not-typeset],
        .not-typeset *,
        [data-not-typeset] *
      )
    ) {
    margin-block-start: 0;
  }
}
```

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

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

import { SaturnDecoration } from "../components/saturn-decoration"
import { formatContentDate, getWriting } from "../lib/posts"

export default async function HomePage() {
  const recentNotes = (await getWriting()).slice(0, 3)

  return (
    <div className="typeset typeset-article max-w-[42em]">
      <h1 className="flex items-center justify-between gap-4">
        <span>Jon Doe</span>
        <SaturnDecoration />
      </h1>

      <p>
        I&apos;m a physicist. I work on planetary science and the structure of
        the solar system, and I spend as much time explaining the universe as
        measuring it.
      </p>

      <p>
        Most of my public work is about why planets are the way they are, and
        what &ldquo;far&rdquo; actually measures. I write books and notes for
        people who have looked up and wanted a clearer picture of what we are
        seeing.
      </p>

      {recentNotes.length > 0 && (
        <>
          <p>What I&apos;ve been writing lately:</p>
          <ul>
            {recentNotes.map((note) => (
              <li key={note.slug}>
                {formatContentDate(note.date)}
                {". "}
                <Link href={`/writing/${note.slug}`}>{note.title}</Link>
              </li>
            ))}
          </ul>
        </>
      )}

      <p>Find your way around:</p>
      <ul>
        <li>
          <Link href="/writing" className="inline-flex items-center gap-1">
            Writing
            <ChevronRight className="size-[0.9em]" aria-hidden />
          </Link>
          <span className="text-muted-foreground">
            {" short notes and essays"}
          </span>
        </li>
        <li>
          <Link href="/books" className="inline-flex items-center gap-1">
            Books
            <ChevronRight className="size-[0.9em]" aria-hidden />
          </Link>
          <span className="text-muted-foreground">
            {" longer, book-length work"}
          </span>
        </li>
        <li>
          <Link href="/contact" className="inline-flex items-center gap-1">
            Contact Me
            <ChevronRight className="size-[0.9em]" aria-hidden />
          </Link>
          <span className="text-muted-foreground">{" say hello"}</span>
        </li>
      </ul>

      <p>
        Elsewhere, I&apos;m on{" "}
        <Link href="https://instagram.com" target="_blank" rel="noreferrer">
          Instagram
        </Link>
        ,{" "}
        <Link href="https://x.com" target="_blank" rel="noreferrer">
          X
        </Link>
        , and{" "}
        <Link href="https://github.com" target="_blank" rel="noreferrer">
          GitHub
        </Link>
        .
      </p>
    </div>
  )
}
```

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

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

import { formatContentDate, getWriting } from "../../lib/posts"

export default async function WritingIndexPage() {
  const posts = await getWriting()

  return (
    <div className="typeset typeset-article max-w-[42em]">
      <h1>Writing</h1>
      <p>
        Notes from my work.
      </p>
      {posts.length === 0 ? (
        <p>No posts yet.</p>
      ) : (
        <ul>
          {posts.map((post) => (
            <li key={post.slug}>
              {formatContentDate(post.date)}
              {". "}
              <Link href={`/writing/${post.slug}`}>{post.title}</Link>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
```

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

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

import {
  formatContentDate,
  getWriting,
  getWritingEntry,
} from "../../../lib/posts"

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

export async function generateStaticParams() {
  const posts = await getWriting()
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function WritingEntryPage({ params }: PageProps) {
  const { slug } = await params
  const post = await getWritingEntry(slug)

  if (!post) {
    notFound()
  }

  return (
    <div className="typeset typeset-article max-w-[42em]">
      <h1>{post.title}</h1>
      <p>{formatContentDate(post.date)}</p>
      <post.body />
    </div>
  )
}
```

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

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

import { getBooks } from "../../lib/posts"

function bookYear(date: string) {
  return date.slice(0, 4)
}

export default async function BooksIndexPage() {
  const books = await getBooks()

  return (
    <div className="typeset typeset-article max-w-[42em]">
      <h1>Books</h1>
      <p>
        Popular-science books from the same public work.
      </p>
      {books.length === 0 ? (
        <p>No books yet.</p>
      ) : (
        books.map((book) => (
          <div key={book.slug}>
            <h2 className="flex items-baseline justify-between gap-4">
              <Link href={`/books/${book.slug}`}>{book.title}</Link>
              <span className="shrink-0 text-base font-normal">
                {bookYear(book.date)}
              </span>
            </h2>
            {book.description ? <p>{book.description}</p> : null}
          </div>
        ))
      )}
    </div>
  )
}
```

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

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

import { formatContentDate, getBook, getBooks } from "../../../lib/posts"

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

export async function generateStaticParams() {
  const books = await getBooks()
  return books.map((book) => ({ slug: book.slug }))
}

export default async function BookPage({ params }: PageProps) {
  const { slug } = await params
  const book = await getBook(slug)

  if (!book) {
    notFound()
  }

  return (
    <div className="typeset typeset-article max-w-[42em]">
      <h1>{book.title}</h1>
      <p>{formatContentDate(book.date)}</p>
      <book.body />
    </div>
  )
}
```

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

```tsx
import { ContactForm } from "../../components/contact-form"

export default function ContactPage() {
  return (
    <div className="typeset typeset-article max-w-[42em]">
      <h1>Contact</h1>
      <p>
        If you want to reach me about speaking, media, teaching, or a question
        about the books and notes, send a message here.
      </p>
      <div className="not-typeset mt-[calc(var(--typeset-flow)*2.4)] max-w-xl">
        <ContactForm />
      </div>
    </div>
  )
}
```

### `registry/new-york/templates/portfolio-v1/components/contact-form.tsx`

```tsx
"use client"

import { useFormStatus } from "react-dom"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldGroup,
  FieldLabel,
  FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"

import { contactFormAction } from "../lib/actions"

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <Button type="submit" disabled={pending}>
      {pending ? (
        <>
          <Spinner data-icon="inline-start" />
          Sending...
        </>
      ) : (
        "Send message"
      )}
    </Button>
  )
}

function ContactFields() {
  const { pending } = useFormStatus()

  return (
    <FieldSet disabled={pending}>
      <FieldGroup>
        {/* Honeypot: real users never see or fill this field (hidden +
            off-screen). Bots that auto-fill every field will trip it. */}
        <input
          type="text"
          name="company"
          tabIndex={-1}
          autoComplete="off"
          aria-hidden="true"
          className="absolute -left-2499.75 h-px w-px opacity-0"
        />
        <Field>
          <FieldLabel htmlFor="name">Name</FieldLabel>
          <Input
            id="name"
            name="name"
            placeholder="Your name"
            required
            autoComplete="name"
          />
        </Field>
        <Field>
          <FieldLabel htmlFor="email">Email</FieldLabel>
          <Input
            id="email"
            name="email"
            type="email"
            placeholder="your.email@example.com"
            required
            autoComplete="email"
          />
        </Field>
        <Field>
          <FieldLabel htmlFor="message">Message</FieldLabel>
          <Textarea
            id="message"
            name="message"
            rows={5}
            placeholder="Speaking, a question about a book, or whatever you wanted to say."
            required
          />
        </Field>
        <Field orientation="horizontal">
          <SubmitButton />
        </Field>
      </FieldGroup>
    </FieldSet>
  )
}

export function ContactForm() {
  async function handleSubmit(formData: FormData) {
    const result = await contactFormAction(formData)

    if (result?.error) {
      toast.error(result.error)
      return
    }

    if (result?.success) {
      toast.success("Message sent successfully!")
    }
  }

  return (
    <form action={handleSubmit}>
      <ContactFields />
    </form>
  )
}
```

### `registry/new-york/templates/portfolio-v1/components/saturn-decoration.tsx`

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

import "../styles/saturn-decoration.css"

type MoonName =
  | "titan"
  | "rhea"
  | "iapetus"
  | "dione"
  | "tethys"
  | "enceladus"
  | "mimas"

type Moon = {
  name: MoonName
  size: string
  inset: string
  duration: string
  phase: number
  reverse?: boolean
}

const MOONS: Moon[] = [
  { name: "titan", size: "0.4rem", inset: "2%", duration: "20s", phase: 0.08 },
  { name: "rhea", size: "0.22rem", inset: "14%", duration: "14s", phase: 0.42 },
  { name: "iapetus", size: "0.21rem", inset: "-4%", duration: "28s", phase: 0.71, reverse: true },
  { name: "dione", size: "0.18rem", inset: "22%", duration: "11s", phase: 0.33 },
  { name: "tethys", size: "0.17rem", inset: "30%", duration: "9s", phase: 0.55 },
  { name: "enceladus", size: "0.12rem", inset: "38%", duration: "6.5s", phase: 0.88 },
  { name: "mimas", size: "0.1rem", inset: "44%", duration: "5s", phase: 0.23 },
]

function EclipseMoons() {
  return (
    <>
      {MOONS.map((moon, i) => (
        <div
          key={i}
          className={
            moon.reverse
              ? "saturn-decoration__orbit saturn-decoration__orbit--reverse"
              : "saturn-decoration__orbit"
          }
          style={
            {
              "--moon-size": moon.size,
              "--orbit-inset": moon.inset,
              "--orbit-duration": moon.duration,
              "--orbit-phase": moon.phase,
            } as CSSProperties
          }
        >
          <span className="saturn-decoration__moon-pivot">
            <span className={`saturn-decoration__moon saturn-decoration__moon--${moon.name}`}>
              <span className="saturn-decoration__glare" />
            </span>
          </span>
        </div>
      ))}
    </>
  )
}

function PlanetFace() {
  return (
    <div className="saturn-decoration__planet">
      <span className="saturn-decoration__glare" />
    </div>
  )
}

export function SaturnDecoration() {
  return (
    <div className="saturn-decoration not-typeset" aria-hidden="true">
      <div className="saturn-decoration__planet-under">
        <PlanetFace />
      </div>

      <div className="saturn-decoration__eclipse">
        <div className="saturn-decoration__plane">
          <div className="saturn-decoration__rings" />
          <EclipseMoons />
        </div>
      </div>

      <div className="saturn-decoration__planet-cap">
        <PlanetFace />
      </div>
    </div>
  )
}
```

### `registry/new-york/templates/portfolio-v1/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>
      <ThemeKeyboardShortcut />
      {children}
    </NextThemesProvider>
  )
}
```

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

```tsx
"use client"

import { useCallback, useEffect, useRef } from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"

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

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
}

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

export function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme()

  return (
    <Button
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
      variant="ghost"
      size="icon"
    >
      <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
      <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
      <span className="sr-only">Toggle theme</span>
    </Button>
  )
}

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
    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-v1/styles/saturn-decoration.css`

```css
.saturn-decoration {
  --orbit-size: 3.5rem;
  --eclipse-yaw: -27deg;
  --eclipse-scale-y: 0.3;
  /* Cassini true-color: pale gold disk, cooler icy rings. */
  --saturn-highlight: oklch(0.9 0.045 92);
  --saturn-body: oklch(0.76 0.08 85);
  --saturn-band: oklch(0.66 0.09 76);
  --saturn-shadow: oklch(0.42 0.072 68);
  --saturn-ring: oklch(0.7 0.048 82);
  --saturn-ring-dense: oklch(0.55 0.058 74);
  position: relative;
  width: var(--orbit-size);
  height: var(--orbit-size);
  flex-shrink: 0;
  overflow: visible;
}

.dark .saturn-decoration {
  --saturn-highlight: oklch(0.94 0.042 92);
  --saturn-body: oklch(0.8 0.08 85);
  --saturn-band: oklch(0.7 0.09 76);
  --saturn-shadow: oklch(0.38 0.06 65);
  --saturn-ring: oklch(0.86 0.036 88);
  --saturn-ring-dense: oklch(0.72 0.045 80);
}

.saturn-decoration__planet-under {
  position: absolute;
  inset: 33%;
  z-index: 0;
}

.saturn-decoration__eclipse {
  position: absolute;
  inset: -42%;
  z-index: 1;
  pointer-events: none;
  transform: rotate(var(--eclipse-yaw));
}

.saturn-decoration__planet-cap {
  position: absolute;
  inset: 33%;
  z-index: 2;
  overflow: hidden;
  border-radius: 9999px;
  transform: rotate(var(--eclipse-yaw));
  clip-path: inset(0 0 50% 0);
  pointer-events: none;
}

.saturn-decoration__planet-cap .saturn-decoration__planet {
  position: absolute;
  inset: 0;
  transform: rotate(calc(var(--eclipse-yaw) * -1));
}

.saturn-decoration__plane {
  position: absolute;
  inset: 16%;
  transform: scaleY(var(--eclipse-scale-y));
}

.saturn-decoration__rings {
  position: absolute;
  inset: -8%;
  border-radius: 9999px;
  background: radial-gradient(
    circle at center,
    transparent 40%,
    color-mix(in oklab, var(--saturn-ring-dense) 55%, transparent) 44%,
    color-mix(in oklab, var(--saturn-ring) 82%, var(--saturn-highlight)) 56%,
    color-mix(in oklab, var(--saturn-ring-dense) 90%, var(--saturn-highlight)) 66%,
    color-mix(in oklab, var(--saturn-ring-dense) 50%, transparent) 69%,
    transparent 71%,
    transparent 74%,
    color-mix(in oklab, var(--saturn-ring) 70%, transparent) 78%,
    color-mix(in oklab, var(--saturn-ring-dense) 42%, transparent) 88%,
    color-mix(in oklab, var(--saturn-ring) 22%, transparent) 93%,
    transparent 97%
  );
}

.saturn-decoration__orbit {
  --moon-size: 0.2rem;
  --orbit-inset: 0%;
  --orbit-duration: 12s;
  --orbit-phase: 0;
  position: absolute;
  inset: var(--orbit-inset);
  border: 1px solid transparent;
  border-radius: 9999px;
  animation: saturn-spin var(--orbit-duration) linear infinite;
  animation-delay: calc(var(--orbit-duration) * var(--orbit-phase) * -1);
}

.saturn-decoration__orbit--reverse {
  animation-direction: reverse;
}

.saturn-decoration__moon-pivot {
  position: absolute;
  top: 0;
  left: 50%;
  width: 0;
  height: 0;
  animation: saturn-spin-cancel var(--orbit-duration) linear infinite;
  animation-delay: calc(var(--orbit-duration) * var(--orbit-phase) * -1);
}

.saturn-decoration__orbit--reverse .saturn-decoration__moon-pivot {
  animation-name: saturn-spin-cancel-reverse;
}

.saturn-decoration__moon {
  --moon-highlight: oklch(0.78 0.02 85);
  --moon-body: oklch(0.62 0.025 78);
  --moon-shade: oklch(0.4 0.03 70);
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--moon-size);
  height: var(--moon-size);
  overflow: hidden;
  border-radius: 9999px;
  background:
    radial-gradient(
      circle at 30% 26%,
      var(--moon-highlight) 0%,
      var(--moon-body) 42%,
      var(--moon-shade) 100%
    );
  box-shadow:
    inset -0.08rem 0.02rem 0.12rem color-mix(in oklab, var(--moon-shade) 55%, transparent),
    inset 0.05rem -0.04rem 0.08rem color-mix(in oklab, var(--moon-highlight) 28%, transparent),
    0.04rem 0.07rem 0.12rem color-mix(in oklab, var(--moon-shade) 28%, transparent),
    0 0 0 0.5px color-mix(in oklab, var(--moon-body) 40%, transparent);
  transform:
    translate(-50%, -50%)
    scaleY(calc(1 / var(--eclipse-scale-y)))
    rotate(calc(var(--eclipse-yaw) * -1));
}

/* Titan — orange haze from the nitrogen atmosphere. */
.saturn-decoration__moon--titan {
  --moon-highlight: oklch(0.72 0.12 68);
  --moon-body: oklch(0.58 0.14 58);
  --moon-shade: oklch(0.36 0.11 50);
}

.dark .saturn-decoration__moon--titan {
  --moon-highlight: oklch(0.76 0.13 68);
  --moon-body: oklch(0.62 0.15 58);
  --moon-shade: oklch(0.34 0.1 50);
}

/* Rhea — dirty water ice, slightly tan-gray. */
.saturn-decoration__moon--rhea {
  --moon-highlight: oklch(0.8 0.022 82);
  --moon-body: oklch(0.66 0.028 75);
  --moon-shade: oklch(0.42 0.03 68);
}

.dark .saturn-decoration__moon--rhea {
  --moon-highlight: oklch(0.86 0.02 85);
  --moon-body: oklch(0.72 0.025 78);
  --moon-shade: oklch(0.4 0.028 68);
}

/* Iapetus — two-faced: bright ice vs dark reddish Cassini Regio. */
.saturn-decoration__moon--iapetus {
  --moon-highlight: oklch(0.9 0.015 95);
  --moon-body: oklch(0.32 0.055 42);
  --moon-shade: oklch(0.22 0.04 38);
  background:
    linear-gradient(
      105deg,
      var(--moon-highlight) 0 46%,
      var(--moon-body) 54% 100%
    );
}

.dark .saturn-decoration__moon--iapetus {
  --moon-highlight: oklch(0.93 0.014 95);
  --moon-body: oklch(0.3 0.055 42);
  --moon-shade: oklch(0.2 0.04 38);
}

/* Dione — icy, a bit less bright than Tethys. */
.saturn-decoration__moon--dione {
  --moon-highlight: oklch(0.88 0.016 95);
  --moon-body: oklch(0.74 0.02 88);
  --moon-shade: oklch(0.48 0.025 75);
}

.dark .saturn-decoration__moon--dione {
  --moon-highlight: oklch(0.92 0.014 95);
  --moon-body: oklch(0.8 0.018 90);
  --moon-shade: oklch(0.46 0.022 75);
}

/* Tethys — very high albedo, near-white ice. */
.saturn-decoration__moon--tethys {
  --moon-highlight: oklch(0.94 0.012 95);
  --moon-body: oklch(0.84 0.016 92);
  --moon-shade: oklch(0.56 0.02 80);
}

.dark .saturn-decoration__moon--tethys {
  --moon-highlight: oklch(0.96 0.01 95);
  --moon-body: oklch(0.88 0.014 92);
  --moon-shade: oklch(0.54 0.018 80);
}

/* Enceladus — brightest, fresh ice with a faint blue. */
.saturn-decoration__moon--enceladus {
  --moon-highlight: oklch(0.97 0.012 230);
  --moon-body: oklch(0.9 0.018 220);
  --moon-shade: oklch(0.62 0.02 230);
}

.dark .saturn-decoration__moon--enceladus {
  --moon-highlight: oklch(0.98 0.012 230);
  --moon-body: oklch(0.93 0.016 220);
  --moon-shade: oklch(0.6 0.02 230);
}

/* Mimas — small gray ice. */
.saturn-decoration__moon--mimas {
  --moon-highlight: oklch(0.72 0.018 80);
  --moon-body: oklch(0.56 0.022 72);
  --moon-shade: oklch(0.36 0.025 65);
  opacity: 0.9;
}

.dark .saturn-decoration__moon--mimas {
  --moon-highlight: oklch(0.78 0.016 82);
  --moon-body: oklch(0.62 0.02 75);
  --moon-shade: oklch(0.34 0.022 65);
}

.saturn-decoration__planet {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 9999px;
  background:
    radial-gradient(
      circle at 30% 26%,
      var(--saturn-highlight) 0%,
      color-mix(in oklab, var(--saturn-body) 55%, var(--saturn-highlight)) 22%,
      var(--saturn-body) 48%,
      color-mix(in oklab, var(--saturn-body) 45%, var(--saturn-shadow)) 72%,
      var(--saturn-shadow) 100%
    );
  box-shadow:
    inset -0.22rem 0.04rem 0.34rem color-mix(in oklab, var(--saturn-shadow) 70%, transparent),
    inset 0.1rem -0.08rem 0.18rem color-mix(in oklab, var(--saturn-highlight) 35%, transparent),
    0.05rem 0.1rem 0.18rem color-mix(in oklab, var(--saturn-shadow) 40%, transparent),
    0 0 0 1px color-mix(in oklab, var(--saturn-band) 55%, var(--saturn-shadow));
}

.saturn-decoration__planet::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: repeating-linear-gradient(
    180deg,
    transparent 0 16%,
    color-mix(in oklab, var(--saturn-band) 42%, transparent) 16% 22%,
    transparent 22% 36%,
    color-mix(in oklab, var(--saturn-band) 28%, transparent) 36% 44%,
    transparent 44% 58%,
    color-mix(in oklab, var(--saturn-shadow) 22%, transparent) 58% 66%
  );
  pointer-events: none;
}

.saturn-decoration__planet-under .saturn-decoration__planet {
  inset: 0;
}

.saturn-decoration__glare {
  position: absolute;
  top: 12%;
  left: 16%;
  width: 36%;
  height: 28%;
  border-radius: 9999px;
  background: color-mix(in oklab, var(--saturn-highlight) 55%, white);
  filter: blur(0.09rem);
  opacity: 0.45;
}

.saturn-decoration__planet > .saturn-decoration__glare {
  opacity: 0.5;
}

.saturn-decoration__moon .saturn-decoration__glare {
  background: color-mix(in oklab, var(--moon-highlight) 70%, white);
  filter: blur(0.04rem);
  opacity: 0.55;
}

.saturn-decoration__moon--titan .saturn-decoration__glare {
  opacity: 0.28;
}

.saturn-decoration__moon--enceladus .saturn-decoration__glare,
.saturn-decoration__moon--tethys .saturn-decoration__glare {
  opacity: 0.7;
}

@keyframes saturn-spin {
  to {
    transform: rotate(360deg);
  }
}

@keyframes saturn-spin-cancel {
  from {
    transform: translate(-50%, -50%) rotate(0deg);
  }
  to {
    transform: translate(-50%, -50%) rotate(-360deg);
  }
}

@keyframes saturn-spin-cancel-reverse {
  from {
    transform: translate(-50%, -50%) rotate(0deg);
  }
  to {
    transform: translate(-50%, -50%) rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .saturn-decoration__orbit,
  .saturn-decoration__moon-pivot {
    animation: none;
  }

  .saturn-decoration__moon-pivot {
    transform: translate(-50%, -50%);
  }
}
```

### `registry/new-york/templates/portfolio-v1/lib/posts.ts`

```ts
import fs from "node:fs/promises"
import path from "node:path"

import matter from "gray-matter"
import { compileMDX } from "next-mdx-remote/rsc"
import type { ComponentType } from "react"
import rehypeKatex from "rehype-katex"
import remarkGfm from "remark-gfm"
import remarkMath from "remark-math"
import { z } from "zod"

export type ContentFrontmatter = {
  title: string
  description?: string
  date: string
  published?: boolean
}

export type ContentEntry = {
  slug: string
  title: string
  description?: string
  date: string
  body: ComponentType
}

const TEMPLATE_CONTENT_FALLBACK = path.join(
  process.cwd(),
  "registry/new-york/templates/portfolio-v1/content"
)

async function resolveContentDir(kind: "writing" | "books"): Promise<string | null> {
  const candidates = [
    path.join(process.cwd(), "content", kind),
    path.join(TEMPLATE_CONTENT_FALLBACK, kind),
  ]

  for (const dir of candidates) {
    try {
      // Build-time content reads are scoped to the known content dir; the
      // ignore comment stops Turbopack from tracing the whole project into
      // the server bundle just because the path is computed at runtime.
      await fs.access(/*turbopackIgnore: true*/ dir)
      return dir
    } catch {
      // try next
    }
  }
  return null
}

function normalizeDate(date: unknown): string {
  // gray-matter parses unquoted YAML dates (e.g. `date: 2025-09-20`) into Date objects.
  if (date instanceof Date) {
    return date.toISOString().slice(0, 10)
  }
  return String(date).slice(0, 10)
}

const ContentFrontmatterSchema = z.object({
  title: z.string().min(1, { message: "title is required" }),
  description: z.string().optional(),
  date: z.union([z.string(), z.date()]),
  published: z.boolean().optional(),
})

export type FrontmatterParseResult =
  | { ok: true; data: ContentFrontmatter }
  | { ok: false; error: string }

/**
 * Pure — takes raw frontmatter data (already parsed by gray-matter) and
 * validates it. Kept separate from file I/O so it can be unit tested
 * without touching the filesystem.
 */
export function parseFrontmatter(raw: unknown): FrontmatterParseResult {
  const result = ContentFrontmatterSchema.safeParse(raw)
  if (!result.success) {
    return { ok: false, error: result.error.issues[0]?.message ?? "Invalid frontmatter." }
  }
  return {
    ok: true,
    data: {
      title: result.data.title,
      description: result.data.description,
      date: normalizeDate(result.data.date),
      published: result.data.published,
    },
  }
}

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

export function formatContentDate(dateStr: string): string {
  return formatDate(dateStr)
}

async function loadEntries(kind: "writing" | "books"): Promise<ContentEntry[]> {
  const directory = await resolveContentDir(kind)
  if (!directory) return []

  let filenames: string[] = []
  try {
    filenames = await fs.readdir(/*turbopackIgnore: true*/ directory)
  } catch {
    return []
  }

  const mdxFiles = filenames.filter((filename) => filename.endsWith(".mdx"))

  const entries = await Promise.all(
    mdxFiles.map(async (filename) => {
      try {
        const fullPath = path.join(/*turbopackIgnore: true*/ directory, filename)
        const source = await fs.readFile(/*turbopackIgnore: true*/ fullPath, "utf8")
        const { data, content } = matter(source)

        const parsed = parseFrontmatter(data)
        if (!parsed.ok) {
          console.warn(`[content] skipping ${kind}/${filename}: ${parsed.error}`)
          return null
        }
        const frontmatter = parsed.data

        if (frontmatter.published === false) {
          return null
        }

        const compiled = await compileMDX<ContentFrontmatter>({
          source: content,
          options: {
            parseFrontmatter: false,
            mdxOptions: {
              remarkPlugins: [remarkGfm, remarkMath],
              rehypePlugins: [rehypeKatex],
            },
          },
        })
        const Body = () => compiled.content

        const entry: ContentEntry = {
          slug: filename.replace(/\.mdx$/, ""),
          title: frontmatter.title,
          description: frontmatter.description,
          date: frontmatter.date,
          body: Body,
        }
        return entry
      } catch (error) {
        console.warn(`[content] skipping ${kind}/${filename}: ${error instanceof Error ? error.message : String(error)}`)
        return null
      }
    })
  )

  return entries
    .filter((entry): entry is ContentEntry => entry !== null)
    .sort((a, b) => b.date.localeCompare(a.date))
}

async function loadEntry(kind: "writing" | "books", slug: string): Promise<ContentEntry | null> {
  const directory = await resolveContentDir(kind)
  if (!directory) return null

  const filename = `${slug}.mdx`
  const fullPath = path.join(/*turbopackIgnore: true*/ directory, filename)

  let source: string
  try {
    source = await fs.readFile(/*turbopackIgnore: true*/ fullPath, "utf8")
  } catch {
    return null
  }

  const { data, content } = matter(source)
  const parsed = parseFrontmatter(data)
  if (!parsed.ok) {
    console.warn(`[content] ${kind}/${filename}: ${parsed.error}`)
    return null
  }
  const frontmatter = parsed.data
  if (frontmatter.published === false) return null

  try {
    const compiled = await compileMDX<ContentFrontmatter>({
      source: content,
      options: {
        parseFrontmatter: false,
        mdxOptions: {
          remarkPlugins: [remarkGfm, remarkMath],
          rehypePlugins: [rehypeKatex],
        },
      },
    })
    const Body = () => compiled.content

    return {
      slug,
      title: frontmatter.title,
      description: frontmatter.description,
      date: frontmatter.date,
      body: Body,
    }
  } catch (error) {
    console.warn(`[content] ${kind}/${filename}: ${error instanceof Error ? error.message : String(error)}`)
    return null
  }
}

export async function getWriting(): Promise<ContentEntry[]> {
  return loadEntries("writing")
}

export async function getWritingEntry(slug: string): Promise<ContentEntry | null> {
  return loadEntry("writing", slug)
}

export async function getBooks(): Promise<ContentEntry[]> {
  return loadEntries("books")
}

export async function getBook(slug: string): Promise<ContentEntry | null> {
  return loadEntry("books", slug)
}
```

### `registry/new-york/templates/portfolio-v1/lib/actions.ts`

```ts
"use server"

import { ContactFormSchema } from "./schemas"

export async function contactFormAction(formData: FormData) {
  // Honeypot: real users never see or fill this field (hidden + off-screen
  // in the form). Bots that auto-fill every field will trip it.
  const honeypot = String(formData.get("company") ?? "").trim()
  if (honeypot !== "") {
    return { success: true as const }
  }

  const endpoint = process.env.BASIN_ENDPOINT

  if (!endpoint) {
    return {
      error: "Contact form is not configured. Set BASIN_ENDPOINT in .env.local.",
    }
  }

  const parsed = ContactFormSchema.safeParse({
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  })

  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid form data." }
  }

  const { name, email, message } = parsed.data

  const body = new URLSearchParams()
  body.set("name", name)
  body.set("email", email)
  body.set("message", message)

  try {
    const response = await fetch(endpoint, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body,
    })

    if (!response.ok) {
      return { error: "Failed to send message. Please try again." }
    }

    return { success: true as const }
  } catch {
    return { error: "Failed to send message. Please try again." }
  }
}
```

### `registry/new-york/templates/portfolio-v1/lib/schemas.ts`

```ts
import { z } from "zod"

export const ContactFormSchema = z.object({
  name: z.string().min(1, { message: "Name is required." }),
  email: z
    .string()
    .min(1, { message: "Email is required." })
    .email("Invalid email."),
  message: z
    .string()
    .min(10, { message: "Message must be at least 10 characters." })
    .max(500, { message: "Message must be less than 500 characters." }),
})
```

### `registry/new-york/templates/portfolio-v1/styles/typeset-article.css`

```css
.typeset-article {
  --typeset-font-body: var(--font-inter);
  --typeset-font-heading: var(--font-inter);
  --typeset-font-mono: var(--font-inter);
  --typeset-size: 15px;
  --typeset-leading: 1.75;
  --typeset-flow: 1.25em;
}

/* KaTeX inherits theme text; display math follows typeset rhythm. */
.typeset-article .katex {
  color: inherit;
}

.typeset-article .katex-display {
  margin-block: var(--typeset-flow) 0;
  overflow-x: auto;
  overflow-y: hidden;
  padding-block: 0.25em;
}
```

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

```example
BASIN_ENDPOINT=https://usebasin.com/f/your-basin-form-id
```

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

````md
# Personalizing this portfolio

This site ships with filler content for a fictional physicist and science
communicator ("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: 1–2 paragraphs synthesized from their summary, experience,
  and notable work — written in first person, in a voice that matches their
  actual field. Do not reuse the physicist framing ("planetary science",
  "the solar system") 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, publications, or focus areas worth referencing in
  writing/book content (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, and the two bio `<p>` paragraphs. They
  are not interchangeable — each one is doing a specific job, and copying
  the pattern matters as much as matching the length:
  - **Paragraph 1 — Identity: who they are.** Role, domain, and a
    defining stance or tension in how they work — the original is "I'm a
    physicist. I work on planetary science and the structure of the solar
    system, and I spend as much time explaining the universe as measuring
    it." (role → domain → a duality that says something about *how* they
    approach the work, not just what it is).
  - **Paragraph 2 — Purpose: what their public output is about, and for
    whom.** Not a list of named projects — the original is "Most of my
    public work is about why planets are the way they are... I write
    books and notes for people who have looked up and wanted a clearer
    picture of what we are seeing." It's the *theme* of what they
    write/build in public, plus who it serves, and it bridges into the
    Writing/Books sections below it on the page. Named projects belong in
    those
    sections (Step 4) and in the "what I've been writing lately" list,
    which already exists — don't front-load them into the bio too.
    Most resumes have no publishing history at all — a nurse, an
    accountant, a retail manager almost never have a "public output" to
    theme around, and that's the common case, not an exception. When
    there's nothing to draw on, frame this paragraph around what they'd
    plausibly write about *given their actual field and role* — the kind
    of thing worth noting from work they've actually done — not invented
    accomplishments. It's honest framing of intent, not a summary of a
    body of work that doesn't exist yet.
- `app/layout.tsx` — `metadata.title` and `metadata.description`
- `app/contact/page.tsx` — the intro paragraph (currently mentions "books
  and notes" — reframe to match what this person actually does)
- `app/writing/page.tsx` and `app/books/page.tsx` — the one-line intro
  under each `<h1>` (currently physicist-flavored, e.g. "Popular-science
  books from the same public work")
- `app/page.tsx` — the "Find your way around" list: the two muted
  descriptions ("short notes and essays", "longer, book-length work") next
  to the Writing and Books links. See "Section labels" below — the label
  words themselves may need to change too, not just these descriptions.

**Length budget:** before you rewrite the two bio paragraphs, count the
words in the two you're replacing. Match that count within about 20%, and
keep it two paragraphs, not three or four. The demo bio is short on
purpose — two or three sentences each. A longer bio isn't more thorough,
it's just a worse read. Same rule for the one-line intros: one line stays
one line. Same for the two "Find your way around" descriptions: they're
four words each — stay in that range, don't turn them into full sentences.

### Section labels (Writing / Books)

"Books" barely fits anyone who isn't an author, and it's the common case,
not an edge case — most people personalizing this (a full-stack developer
is the expected default) have no literal books. Don't just reinterpret the
content under the label (Step 4) and leave the word "Books" sitting there
if it reads wrong. Relabel it. This doesn't touch routing — `/books` stays
`/books` as a URL; only the *visible* text changes, in every place it
appears:

1. `app/page.tsx` — the nav link text ("Writing" / "Books" in the "Find
   your way around" list) and its muted description
2. `app/writing/page.tsx` / `app/books/page.tsx` — the `<h1>` and the
   one-line intro under it

Pick a label that actually fits — "Projects", "Work", "Case Studies" for
Books; "Writing" itself is a reasonable fit for almost anyone and usually
doesn't need to change. Do this automatically as part of the personalization,
the same way you rewrite the bio — you don't need to ask first unless the
right replacement word genuinely isn't obvious. Keep every label to a
single word or short phrase, matching the length of what it replaces —
this is a nav item, not a sentence.

## Step 3 — Links

Do not shorten or remove the "Elsewhere, I'm on Instagram, X, and
GitHub" section in `app/page.tsx` — keep all three labels and the sentence
structure exactly as they are. This section is a fixed set of redirect
slots, 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 `href` rule is the same for every platform,
every time — resolved or not:

- Resume has a real link/handle for the platform (most dev resumes have a
  GitHub) → replace that one `href` with the real URL. Keep
  `target="_blank" rel="noreferrer"` as-is — it already opens in a new tab.
- Resume doesn't mention the platform (Instagram usually won't; a resume
  link labeled "Twitter" is the same platform as X — fill the X slot
  with it, don't treat it as a new one), **or you can't recover a real URL
  for it** (see the PDF caveat below) → set `href="#"`. Don't leave the
  original bare-domain placeholder (`https://instagram.com`, etc.) —
  that's a working link to the wrong page, which is worse than an obvious
  placeholder. `href="#"` reads unambiguously as "not filled in yet," for
  a developer and non-developer alike.

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
so you can fill it in for real.

**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.
Linking out to it would point the person's own site at itself (or at an
old one they're replacing). Skip it entirely; don't add it to the list, no
matter what domain it resolves to.

**Beyond the fixed three, actively check the resume for these and add each
one you find as a new item in the same list** — never remove or replace
any of the original three to make room for them, only append:

- **LinkedIn** — check for this every time. It's the single most common
  link on a resume that isn't already one of the three slots. If found, it
  goes right after X and before GitHub, so the order reads
  Instagram, X, LinkedIn, and GitHub.
- Anything else the resume links to (Dribbble, a Mastodon handle, etc.)
  also goes at the end, in the order you found it. This isn't a closed
  list — LinkedIn is just the one to never skip checking for.

Exact placement for LinkedIn, so it's unambiguous — this is the only line
in `app/page.tsx` that changes shape:

```jsx
<p>
  Elsewhere, I&apos;m on{" "}
  <Link href="#" target="_blank" rel="noreferrer">
    Instagram
  </Link>
  ,{" "}
  <Link href="#" target="_blank" rel="noreferrer">
    X
  </Link>
  ,{" "}
  <Link href="https://linkedin.com/in/their-real-handle" target="_blank" rel="noreferrer">
    LinkedIn
  </Link>
  , and{" "}
  <Link href="https://github.com/their-real-handle" target="_blank" rel="noreferrer">
    GitHub
  </Link>
  .
</p>
```

This example shows a resume with LinkedIn and GitHub but no Instagram or
X, which is the common case — Instagram and X fall back to
`#`, LinkedIn gets appended, GitHub gets its real handle. If LinkedIn
isn't on the resume, don't add that block at all; just apply the `#`-or-real
rule to the original three.

## Step 4 — Writing & Books content

`content/writing/*.mdx` and `content/books/*.mdx` are demo posts about
planetary science. Replace them with new entries that fit the person's real
title/field from Step 1 — e.g. a full-stack developer gets short technical
notes under `content/writing/` and longer project write-ups or guides under
`content/books/` (reinterpret "Books" as their long-form work — case
studies, whitepapers, guides — if they don't literally have books). If
that reinterpretation feels like a stretch once you've drafted it, relabel
the section per "Section labels" in Step 2 instead of forcing the content
to justify a word that doesn't fit — do this automatically, don't wait to
be asked.

- Keep the same frontmatter schema: `title`, `description`, `date`
  (`YYYY-MM-DD`), `published` (optional)
- **Keep the same number of entries** currently in each folder — count the
  files in `content/writing/` and `content/books/` before you delete
  anything, and replace with that many, not fewer. If the resume genuinely
  doesn't support that many distinct topics, say so and ask before
  under-filling a section.
- **Length budget, per entry:** before replacing a file, count its words
  (frontmatter excluded). Target the new entry within about 20% of that —
  neither a two-line stub nor a doubled-length essay. Do this per file, not
  as a folder average, since the demo entries aren't all the same length
  to begin with.
- Match the existing structure of the current demo entries — prose
  paragraphs, a `<figure>` image with `<figcaption>` (see below), a pull
  quote via `>`, a table or two if relevant to the field
- Images: every `content/writing/*.mdx` entry gets a `<figure>` unless the
  topic is genuinely non-visual — see "Sourcing images" below for how to
  find a real URL (a required step, not a nice-to-have) and how exact the
  match has to be. Video is never an option in this template.
- Titles and topics should read as genuinely written by this person about
  their real field, not a generic reskin — use the specifics from their
  resume (technologies, industries, notable work) as material

### Sourcing images

Images are expected. Personalization should not produce a set of text-only
posts when the demo had a photo on nearly every one. Every
`content/writing/*.mdx` entry gets one `<figure>` unless the topic is
genuinely non-visual (a pure-abstract argument, a math-heavy note).
Books / long-form entries: one where it fits, optional otherwise. "I
couldn't find one" is a per-figure outcome after a real search — never the
reason the whole set ships bare.

Images only — this template has no video component or pattern anywhere in
it, so never add a `<video>` tag or embed.

Images are plain hotlinked URLs in the MDX itself — no upload, no local
file, no attachment. Use exactly the pattern the demo content already
uses:

```
<figure>
  <img
    className="grayscale dark:brightness-50"
    src="https://images.unsplash.com/photo-<id>?q=80&w=1200&h=675&fit=crop"
    alt="..."
    width={1200}
    height={675}
  />
  <figcaption>...</figcaption>
</figure>
```

**Finding the URL is a required step, not optional.** Unsplash photo IDs
are not guessable, and a plausible-looking one is almost always a dead
link. Never write a `src` from memory. For each figure:

1. Web-search or fetch for a real Unsplash photo matching the topic — the
   `firecrawl` skill, or `WebSearch` / `WebFetch`, all work.
2. Take the actual photo ID from the real page URL
   (`unsplash.com/photos/<slug>-<id>`) and build the `src` with the query
   string shown above.
3. Only if that specific search turns up nothing that is an *exact* match
   (see below) do you skip that one `<figure>` — and say so in your
   summary: which entries you left imageless and why.

If you have no web access at all in this environment, stop and tell the
user before finishing: either they supply the image URLs, or the
personalization pass runs somewhere with web access. Do not ship the
content silently image-free.

**The match has to be exact, not thematic.** `rip-pluto.mdx` is the bar:
the post is specifically about Pluto, and the image is an actual photo of
Pluto, not a generic nebula or star field. A post about a specific
product, place, or piece of hardware needs a photo of that thing (or
something concretely evocative of it), not a generic "coding" or "office"
stock photo standing in for the topic.

What *kind* of photo fits depends on the field, but the answer is rarely
"none":

- **Developer / technical field** — skip the laptop-on-a-desk cliché. When
  the work has no photographable subject of its own, a clean editorial
  shot of the real-world domain it serves (a warehouse floor, a hospital
  ward, a trading desk, transit signage) carries the post. Never pass off
  a screenshot, chart, or diagram as a photo.
- **Writer / creative field** — closest to the current demo. An exact,
  specific photo on essentially every entry.
- **Other / general personal site** — one exact-match image per entry;
  favor a precise photo of the real subject over an evocative-but-vague
  one.

## 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 pull quotes,
"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

- `BASIN_ENDPOINT` / `.env.local` — separate manual setup, not derived from
  the resume (see `.env.example`)
- `components/contact-form.tsx`, `components/saturn-decoration.tsx`, **and
  the `<SaturnDecoration />` usage in `app/page.tsx`.** Leave the
  decoration on the page exactly as it is, whatever the person's field. It
  ships on by default; the owner removes it themselves if they don't want
  it. Don't drop it, don't gate it on whether the field is
  space-adjacent, don't move it.
- Theme, CSS, and routing (URLs stay `/writing`, `/books`, `/contact` —
  only the visible labels change, per "Section labels" in Step 2, 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-v1/UNSLOP.md`

```md
# Unslop

A pass to run against everything written during personalization — the
`about` bio, the contact intro, and every `content/writing/*.mdx` /
`content/books/*.mdx` entry. 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 pull-quote or 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 up a plain idea as a
   formula or diagram to look rigorous — `$$\text{time to first working
   page} \to \text{one command}$$` for "install is one command" is exactly
   this. Only use math/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 headings, straight quotes, and no bolding 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.

### 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 entry, measured against what you're replacing — if you're
    over it, the fix is almost always to cut a sentence, not tighten the
    wording of all of them.
18. **One idea per section, not three angles on it.** If two paragraphs are
    making the same point from different sides, keep the sharper one and
    delete the other.

### 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.

## 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.
```

### `registry/new-york/templates/portfolio-v1/content/writing/rip-pluto.mdx`

```mdx
---
title: RIP Pluto
description: My take on the 2006 redefinition, the Kuiper Belt, and why my childhood poster of nine planets still stings a little.
date: 2026-05-18
published: true
---

People still ask me, half joking, whether Pluto is a planet. I understand the loyalty. I grew up with nine names in a row, taped to a bedroom wall, and childhood lists feel like contracts. When the International Astronomical Union changed the rules in 2006, a lot of people heard a demotion handed down by humorless bureaucrats. It was astronomy catching up with a crowded neighborhood — and I still flinch at the empty ninth slot.

<figure>
  <img
    className="grayscale dark:brightness-50"
    src="https://images.unsplash.com/photo-1614314107768-6018061b5b72?q=80&w=1200&h=675&fit=crop"
    alt="Pluto on a black background, as seen by New Horizons."
    width={1200}
    height={675}
  />
  <figcaption>Still remarkable. Just not alone out there.</figcaption>
</figure>

The IAU settled on three tests for a planet in our solar system. It must orbit the Sun. It must be round enough that gravity has pulled it into hydrostatic equilibrium — roughly spherical. And it must have cleared the neighborhood around its orbit.

Pluto passes the first two. It fails the third.

Clearing the neighborhood does not mean an empty sky. It means a body is gravitationally dominant along its path: it has swept up, ejected, or otherwise controlled the debris that shares its orbital zone. Earth has done this. Jupiter has done this with authority. Pluto has not. It travels through the Kuiper Belt, a broad ring of icy leftovers beyond Neptune, among peers of similar scale.

That is the heart of it. Once we found Eris, and kept finding other large Kuiper Belt objects, the old nine-planet list stopped describing the solar system we actually live in. Keep Pluto as a planet under the old habits, and you soon have a dozen or more — not a tidy classroom poster, but an honest census of small worlds at the edge.

> Pluto did not get smaller. Our map got larger.

New Horizons showed mountains of water ice, a heart-shaped plain, a thin blue haze. Dwarf planet is the category that matches where it lives: among the cold, crowded outskirts, not among the eight bodies that own their lanes.

I still miss the poster. I just stopped arguing with the belt.
```

### `registry/new-york/templates/portfolio-v1/content/writing/how-big-is-far.mdx`

```mdx
---
title: How big is far
description: How I talk about light-minutes, light-hours, and why the solar system is mostly empty room with a few bright furniture pieces.
date: 2026-03-12
published: true
---

Ask someone how far Mars is and you will usually get a number that sounds large and means almost nothing. Millions of kilometers are hard to feel. When I talk about distance in public, I count *time for light* instead of kilometers.

Light leaves the Sun and reaches Earth in a little over eight minutes. That is already a useful unit: one astronomical unit is eight light-minutes of emptiness with a warm rock at one end.

<figure>
  <img
    className="grayscale dark:brightness-50"
    src="https://images.unsplash.com/photo-1446776811953-b23d57bd21aa?q=80&w=1200&h=675&fit=crop"
    alt="Earth from orbit under a thin blue atmosphere."
    width={1200}
    height={675}
  />
  <figcaption>Eight light-minutes from a star. That is “near.”</figcaption>
</figure>

Saturn is more than an hour away at light speed when the geometry is cooperative — longer when it is not. Voyager 1, still outbound, is measured in light-*days*. The nearest other star is measured in years. The galaxy is a structure you cannot cross in a human lifetime at any speed we know how to build.

I want the solar system to feel correctly sparse. Planets are lonely objects separated by volumes of almost nothing, held in place by gravity that does not care how empty the gaps look from here.

> Far is a clock.

Once you have that clock, conjunctions and spacecraft delay make more sense. The night sky is a set of delayed messages from objects that are still out there, doing physics, whether or not anyone is watching.
```

### `registry/new-york/templates/portfolio-v1/content/writing/what-the-sky-is-made-of.mdx`

```mdx
---
title: What the sky is made of
description: My note on vacuum, photons, and why empty space is still full of rules.
date: 2026-02-04
published: true
---

On a clear night the sky looks like a black sheet with lights stuck to it. That is a useful lie, and I still catch myself believing it for a second. The black is a long look through vacuum — and vacuum is not empty the way a drawer is empty. It is a place where light can travel for years without hitting much of anything.

What you see is almost entirely photons. Some left a nearby star minutes or years ago. Some bounced off the Moon. A few are the washed-out glow of our own atmosphere scattering sunlight long after sunset. The points are sources. The darkness between them is the path.

<figure>
  <img
    className="grayscale dark:brightness-50"
    src="https://images.unsplash.com/photo-1419242902214-272b3f66ee7a?q=80&w=1200&h=675&fit=crop"
    alt="A dense field of stars against a dark night sky."
    width={1200}
    height={675}
  />
  <figcaption>Most of the picture is travel time.</figcaption>
</figure>

There is more in that darkness than eyes report: magnetic fields, sparse gas, dust, neutrinos streaming through everything including you. None of that paints the sky blue at noon or black at midnight. The color of night is too little scattered light to overwhelm the retina. The physics is still there.

I keep wonder attached to mechanism. The sky is radiation and geometry. Constellations are mnemonic overlays. And “empty” is a volume so thin that a photon can travel for years and still arrive.
```

### `registry/new-york/templates/portfolio-v1/content/writing/the-book-that-started-it.mdx`

```mdx
---
title: The book that started it
description: How one popular-science book turned my habit of looking up into a decision to study physics.
date: 2025-11-22
published: true
---

I became a physicist because a book made the night sky feel like a place you could *understand*, not only admire.

I no longer remember the edition. I remember the voice: patient, concrete, unwilling to replace a hard idea with a metaphor and walk away. It treated the universe as something that could be explained to a curious person without insulting them, and without pretending that explanation is the same as poetry.

<figure>
  <img
    className="grayscale dark:brightness-50"
    src="https://images.unsplash.com/photo-1481627834876-b7833e8f5570?q=80&w=1200&h=675&fit=crop"
    alt="An open book on a wooden table in soft light."
    width={1200}
    height={675}
  />
  <figcaption>The book did not replace wonder. It made the next sentence usable.</figcaption>
</figure>

After that, classroom physics stopped feeling like a closed club. Vectors were a way to talk about motion without waving your hands. Spectra were a way to ask a star what it was made of. Orbits stopped looking like decoration.

I still write for that reader: someone who looked up, got hooked, and wanted the next sentence to be true. Public science is the same subject, spoken in a register that does not require a degree to enter the room.

If you want a place to start among my notes, begin with [RIP Pluto](/writing/rip-pluto).
```

### `registry/new-york/templates/portfolio-v1/content/books/wonders-of-the-outer-system.mdx`

```mdx
---
title: Wonders of the Outer System
description: My tour of the cold worlds beyond the asteroid belt — Saturn’s rings, ice giants, and the crowded dark of the Kuiper Belt.
date: 2026-04-01
published: true
---

Most popular accounts of the solar system linger near the Sun: Mercury’s scorched rock, Earth’s blue luck, Mars as the nearest unfinished story. I wrote *Wonders of the Outer System* to start where the warmth thins out — past the asteroid belt, into gas giants, ice, and distances measured in light-hours.

The book is my guided walk through Jupiter’s violence, Saturn’s geometry, Uranus and Neptune’s sideways weather, and the swarm of small worlds that refuse to behave like a neat ninth planet. Saturn sits at the emotional center for me: a planet whose rings make orbital mechanics visible to anyone willing to look carefully.

<figure>
  <img
    src="https://images.unsplash.com/photo-1614732414444-096e5f1122d5?q=80&w=1200&h=675&fit=crop"
    alt="Saturn and its rings against a dark background."
    width={1200}
    height={675}
  />
  <figcaption>Rings are orbits you can see.</figcaption>
</figure>

Each chapter pairs a place with a rule. Why rings stay thin. Why moons shepherd ice. Why a dwarf planet can be geologically alive and still fail a classroom definition. I wanted a coherent picture of a neighborhood that is mostly empty space and a few spectacular exceptions, not a catalog of trivia.

If you have only ever met the outer solar system as a list of names after Mars, this is the book I wrote to turn that list into a map. It is colder and sparser than the posters suggested.
```

### `registry/new-york/templates/portfolio-v1/content/books/why-anything-happens.mdx`

```mdx
---
title: Why Anything Happens
description: My book on forces, chance, and the small set of rules that turn empty space into stars, planets, and a night sky worth explaining.
date: 2025-09-20
published: true
---

Physics can sound like a museum of special cases: gravity here, electromagnetism there, quantum rules in a locked room at the back. I wrote *Why Anything Happens* to put the cases back into one story — how a handful of interactions, plus time, produce a universe that looks complicated from the inside.

I begin with motion and force in plain language, then widen to fields, probability, and the uncomfortable fact that “empty” is still governed. Not every reader wants the full mathematical machinery. The machinery still has a shape you can feel without a blackboard.

When the machinery does show up, it can stay small. Newton’s second law is just:

$$
F = ma
$$

And a few familiar scales help keep the story honest:

| Quantity | Rough scale | Why it matters |
| --- | --- | --- |
| Earth–Sun light time | ~8 minutes | “Near” in solar-system talk |
| Earth surface $g$ | $9.8\,\mathrm{m/s^2}$ | Everyday falling |
| Solar escape (Earth orbit) | ~42 km/s | Leaving the neighborhood |

The chapters move from falling apples to orbiting moons to the statistical quiet of atoms that refuse classical certainty. The through-line is cause: what makes one outcome more likely than another, and why the sky we inherit is the residue of lawful processes.

Read it as a companion to the outer-system book if you want the *where*; read it alone if you want the *why*.
```
