{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "portfolio-v1",
  "title": "Portfolio v1",
  "description": "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).",
  "dependencies": [
    "next-mdx-remote",
    "gray-matter",
    "remark-gfm",
    "remark-math",
    "rehype-katex",
    "katex",
    "class-variance-authority",
    "radix-ui",
    "lucide-react",
    "sonner",
    "zod"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/new-york/templates/portfolio-v1/components/ui/button.tsx",
      "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"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\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground [a]:hover:bg-primary/80\",\n        outline:\n          \"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\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground\",\n        ghost:\n          \"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50\",\n        destructive:\n          \"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\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default:\n          \"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2\",\n        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\",\n        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\",\n        lg: \"h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3\",\n        icon: \"size-8\",\n        \"icon-xs\":\n          \"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3\",\n        \"icon-sm\":\n          \"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg\",\n        \"icon-lg\": \"size-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Button({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean\n  }) {\n  const Comp = asChild ? Slot.Root : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Button, buttonVariants }\n",
      "type": "registry:ui",
      "target": "components/ui/button.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/ui/field.tsx",
      "content": "\"use client\"\n\nimport { useMemo } from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { Label } from \"@/components/ui/label\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { cn } from \"@/lib/utils\"\n\nfunction FieldSet({ className, ...props }: React.ComponentProps<\"fieldset\">) {\n  return (\n    <fieldset\n      data-slot=\"field-set\"\n      className={cn(\n        \"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldLegend({\n  className,\n  variant = \"legend\",\n  ...props\n}: React.ComponentProps<\"legend\"> & { variant?: \"legend\" | \"label\" }) {\n  return (\n    <legend\n      data-slot=\"field-legend\"\n      data-variant={variant}\n      className={cn(\n        \"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-group\"\n      className={cn(\n        \"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\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst fieldVariants = cva(\n  \"group/field flex w-full gap-2 data-[invalid=true]:text-destructive\",\n  {\n    variants: {\n      orientation: {\n        vertical: \"flex-col *:w-full [&>.sr-only]:w-auto\",\n        horizontal:\n          \"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\",\n        responsive:\n          \"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\",\n      },\n    },\n    defaultVariants: {\n      orientation: \"vertical\",\n    },\n  }\n)\n\nfunction Field({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof fieldVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"field\"\n      data-orientation={orientation}\n      className={cn(fieldVariants({ orientation }), className)}\n      {...props}\n    />\n  )\n}\n\nfunction FieldContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-content\"\n      className={cn(\n        \"group/field-content flex flex-1 flex-col gap-0.5 leading-snug\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof Label>) {\n  return (\n    <Label\n      data-slot=\"field-label\"\n      className={cn(\n        \"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\",\n        \"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-label\"\n      className={cn(\n        \"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"field-description\"\n      className={cn(\n        \"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5\",\n        \"last:mt-0 nth-last-2:-mt-1\",\n        \"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldSeparator({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  children?: React.ReactNode\n}) {\n  return (\n    <div\n      data-slot=\"field-separator\"\n      data-content={!!children}\n      className={cn(\n        \"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2\",\n        className\n      )}\n      {...props}\n    >\n      <Separator className=\"absolute inset-0 top-1/2\" />\n      {children && (\n        <span\n          className=\"relative mx-auto block w-fit bg-background px-2 text-muted-foreground\"\n          data-slot=\"field-separator-content\"\n        >\n          {children}\n        </span>\n      )}\n    </div>\n  )\n}\n\nfunction FieldError({\n  className,\n  children,\n  errors,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  errors?: Array<{ message?: string } | undefined>\n}) {\n  const content = useMemo(() => {\n    if (children) {\n      return children\n    }\n\n    if (!errors?.length) {\n      return null\n    }\n\n    const uniqueErrors = [\n      ...new Map(errors.map((error) => [error?.message, error])).values(),\n    ]\n\n    if (uniqueErrors?.length == 1) {\n      return uniqueErrors[0]?.message\n    }\n\n    return (\n      <ul className=\"ml-4 flex list-disc flex-col gap-1\">\n        {uniqueErrors.map(\n          (error, index) =>\n            error?.message && <li key={index}>{error.message}</li>\n        )}\n      </ul>\n    )\n  }, [children, errors])\n\n  if (!content) {\n    return null\n  }\n\n  return (\n    <div\n      role=\"alert\"\n      data-slot=\"field-error\"\n      className={cn(\"text-sm font-normal text-destructive\", className)}\n      {...props}\n    >\n      {content}\n    </div>\n  )\n}\n\nexport {\n  Field,\n  FieldLabel,\n  FieldDescription,\n  FieldError,\n  FieldGroup,\n  FieldLegend,\n  FieldSeparator,\n  FieldSet,\n  FieldContent,\n  FieldTitle,\n}\n",
      "type": "registry:ui",
      "target": "components/ui/field.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/ui/input.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        \"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\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Input }\n",
      "type": "registry:ui",
      "target": "components/ui/input.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/ui/label.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Label as LabelPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Label({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        \"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\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Label }\n",
      "type": "registry:ui",
      "target": "components/ui/label.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/ui/separator.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Separator as SeparatorPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Separator }\n",
      "type": "registry:ui",
      "target": "components/ui/separator.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/ui/textarea.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        \"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\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Textarea }\n",
      "type": "registry:ui",
      "target": "components/ui/textarea.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/ui/sonner.tsx",
      "content": "\"use client\"\n\nimport { Toaster as Sonner, type ToasterProps } from \"sonner\"\n\nfunction Toaster({ ...props }: ToasterProps) {\n  return (\n    <Sonner\n      theme=\"system\"\n      className=\"toaster group\"\n      style={\n        {\n          \"--normal-bg\": \"var(--popover)\",\n          \"--normal-text\": \"var(--popover-foreground)\",\n          \"--normal-border\": \"var(--border)\",\n          \"--border-radius\": \"var(--radius)\",\n        } as React.CSSProperties\n      }\n      toastOptions={{\n        classNames: {\n          toast: \"cn-toast\",\n        },\n      }}\n      {...props}\n    />\n  )\n}\n\nexport { Toaster }\n",
      "type": "registry:ui",
      "target": "components/ui/sonner.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/ui/spinner.tsx",
      "content": "import { Loader2Icon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Spinner({ className, ...props }: React.ComponentProps<\"svg\">) {\n  return (\n    <Loader2Icon\n      role=\"status\"\n      aria-label=\"Loading\"\n      className={cn(\"size-4 animate-spin\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Spinner }\n",
      "type": "registry:ui",
      "target": "components/ui/spinner.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/app/layout.tsx",
      "content": "import type { Metadata } from \"next\"\nimport { Inter } from \"next/font/google\"\nimport type { ReactNode } from \"react\"\n\nimport { ThemeProvider } from \"../components/theme-provider\"\nimport { Toaster } from \"../components/ui/sonner\"\n\nimport \"katex/dist/katex.min.css\"\nimport \"./globals.css\"\nimport \"./typeset.css\"\nimport \"../styles/typeset-article.css\"\n\nconst inter = Inter({\n  subsets: [\"latin\"],\n  variable: \"--font-inter\",\n})\n\nexport const metadata: Metadata = {\n  title: \"Jon Doe\",\n  description:\n    \"Physicist and science communicator. Planetary science, the solar system, and public writing about how the universe works.\",\n}\n\nexport default function RootLayout({ children }: { children: ReactNode }) {\n  return (\n    <html\n      lang=\"en\"\n      className={`${inter.variable} ${inter.className}`}\n      suppressHydrationWarning\n    >\n      <body className=\"min-h-svh bg-background text-foreground antialiased\">\n        <ThemeProvider>\n          <div className=\"mx-auto w-full max-w-[90ch] px-6 py-16 md:py-24\">\n            <main>{children}</main>\n          </div>\n          <Toaster />\n        </ThemeProvider>\n      </body>\n    </html>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/layout.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/app/typeset.css",
      "content": "@layer components {\n  .typeset {\n    --typeset-font-body: var(--font-inter, inherit);\n    --typeset-font-heading: var(--font-inter, inherit);\n    --typeset-font-mono: var(--font-inter, inherit);\n    --typeset-size: 1em;\n    --typeset-leading: 1.75;\n    --typeset-flow: 1.25em;\n  }\n}\n\n@layer components {\n  .typeset {\n    font-family: var(--typeset-font-body);\n    font-size: calc(var(--typeset-size) * 1.125);\n    line-height: var(--typeset-leading);\n    color: var(--color-foreground, currentColor);\n    overflow-wrap: break-word;\n    margin-trim: block-start;\n\n    @media (min-width: 48rem), print {\n      font-size: var(--typeset-size);\n    }\n\n    --typeset-muted: var(\n      --color-muted-foreground,\n      color-mix(in oklab, currentColor 60%, transparent)\n    );\n    --typeset-rule: var(\n      --color-border,\n      color-mix(in oklab, currentColor 20%, transparent)\n    );\n  }\n\n  .typeset\n    *:not(\n      :where(\n        .not-typeset,\n        [data-not-typeset],\n        .not-typeset *,\n        [data-not-typeset] *\n      )\n    ) {\n    &:where(p) {\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n    }\n\n    &:where(h1, h2, h3, h4, h5, h6) {\n      color: var(--color-foreground, currentColor);\n      font-family: var(--typeset-font-heading);\n      font-weight: 600;\n      margin-block-end: 0;\n    }\n    &:where(h1) {\n      font-size: 1.75em;\n      line-height: 1.3;\n      margin-block-start: var(--typeset-flow);\n    }\n    &:where(h2) {\n      font-size: 1.25em;\n      line-height: 1.4;\n      margin-block-start: calc(var(--typeset-flow) * 1.4);\n    }\n    &:where(h3) {\n      font-size: 1.125em;\n      line-height: 1.45;\n      margin-block-start: var(--typeset-flow);\n    }\n    &:where(h4) {\n      font-size: 1em;\n      line-height: 1.5;\n      margin-block-start: var(--typeset-flow);\n    }\n    &:where(h5) {\n      font-size: 0.875em;\n      line-height: 1.5;\n      font-weight: 500;\n      color: var(--typeset-muted);\n      margin-block-start: calc(var(--typeset-flow) / 0.875);\n    }\n    &:where(h6) {\n      font-size: 0.8125em;\n      line-height: 1.5;\n      font-weight: 500;\n      letter-spacing: 0.08em;\n      text-transform: uppercase;\n      color: var(--typeset-muted);\n      margin-block-start: calc(var(--typeset-flow) / 0.8125);\n    }\n    &:where([id]) {\n      scroll-margin-block-start: var(--typeset-flow);\n    }\n\n    &:where(h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + *) {\n      margin-block-start: 1em;\n    }\n    &:where(:is(h1, h2, h3, h4) :is(code)) {\n      font-size: 0.9em;\n    }\n\n    &:where(a) {\n      color: inherit;\n      font-weight: 500;\n      text-decoration-line: underline;\n      text-decoration-color: color-mix(in oklab, currentColor 30%, transparent);\n    }\n    &:where(a:hover) {\n      text-decoration-color: currentColor;\n    }\n    &:where(a:focus-visible) {\n      outline: 2px solid var(--color-ring, currentColor);\n      outline-offset: 2px;\n      border-radius: 0.125em;\n    }\n    &:where(:is(h1, h2, h3, h4, h5, h6) :is(a)) {\n      font-weight: inherit;\n    }\n\n    &:where(strong, b) {\n      font-weight: 600;\n    }\n    &:where(:is(h1, h2, h3, h4) :is(strong, b)) {\n      font-weight: 600;\n    }\n    &:where(mark) {\n      background-color: color-mix(\n        in oklab,\n        oklch(0.86 0.17 95) 40%,\n        transparent\n      );\n      color: inherit;\n      padding: 0.05em 0.15em;\n      border-radius: 0.2em;\n    }\n    &:where(abbr[title]) {\n      text-decoration-line: underline;\n      text-decoration-style: dotted;\n      text-underline-offset: 0.2em;\n      cursor: help;\n    }\n    &:where(del, s) {\n      color: var(--typeset-muted);\n      text-decoration-line: line-through;\n    }\n    &:where(sup, sub) {\n      font-size: 0.75em;\n      line-height: 0;\n      position: relative;\n      vertical-align: baseline;\n    }\n    &:where(sup) {\n      top: -0.5em;\n    }\n    &:where(sub) {\n      bottom: -0.25em;\n    }\n    &:where(sup a) {\n      text-decoration-line: none;\n      font-weight: 500;\n    }\n\n    &:where(ul) {\n      list-style-type: disc;\n    }\n    &:where(ol) {\n      list-style-type: decimal;\n    }\n    &:where(ul ul) {\n      list-style-type: circle;\n    }\n    &:where(ul ul ul) {\n      list-style-type: square;\n    }\n    &:where(ol[type=\"a\" i]) {\n      list-style-type: lower-alpha;\n    }\n    &:where(ol[type=\"i\" i]) {\n      list-style-type: lower-roman;\n    }\n    &:where(ol[type=\"A\" s]) {\n      list-style-type: upper-alpha;\n    }\n    &:where(ol[type=\"I\" s]) {\n      list-style-type: upper-roman;\n    }\n    &:where(ul, ol) {\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n      padding-inline-start: 1.5em;\n    }\n    &:where(li) {\n      margin-block-start: 0.5em;\n      padding-inline-start: 0.4em;\n    }\n    &:where(li > p, li > ul, li > ol) {\n      margin-block-start: 0.5em;\n    }\n    &:where(ul > li)::marker {\n      color: var(--typeset-muted);\n    }\n    &:where(ol > li)::marker {\n      color: var(--typeset-muted);\n    }\n\n    &:where(ul.contains-task-list) {\n      list-style-type: none;\n      padding-inline-start: 0.25em;\n    }\n    &:where(li.task-list-item > input[type=\"checkbox\"]) {\n      margin-inline-end: 0.5em;\n      vertical-align: -0.1em;\n      accent-color: var(--color-primary, currentColor);\n    }\n\n    &:where(details) {\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n    }\n    &:where(summary) {\n      cursor: pointer;\n      font-weight: 500;\n    }\n    &:where(summary)::marker {\n      color: var(--typeset-muted);\n    }\n\n    &:where(kbd) {\n      font-family: inherit;\n      font-size: 0.85em;\n      font-weight: 500;\n      border: 1px solid var(--typeset-rule);\n      border-block-end-width: 2px;\n      border-radius: min(calc(var(--radius, 0.5em) * 0.6), 0.35em);\n      padding: 0.0625em 0.35em;\n    }\n\n    &:where(dl) {\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n    }\n    &:where(dt) {\n      font-weight: 500;\n      margin-block-start: 1em;\n    }\n    &:where(dt + dt) {\n      margin-block-start: 0.25em;\n    }\n    &:where(dd) {\n      margin-block-start: 0.25em;\n      margin-inline-start: 0;\n      padding-inline-start: 1em;\n      color: var(--typeset-muted);\n    }\n\n    &:where(:not(pre) > code) {\n      background-color: var(\n        --color-muted,\n        color-mix(in oklab, currentColor 8%, transparent)\n      );\n      font-family: var(--typeset-font-mono);\n      font-size: 0.85em;\n      border-radius: min(calc(var(--radius, 0.5em) * 0.6), 0.35em);\n      padding: 0.125em 0.3em;\n    }\n\n    &:where(pre) {\n      background-color: var(\n        --color-muted,\n        color-mix(in oklab, currentColor 8%, transparent)\n      );\n      font-family: var(--typeset-font-mono);\n      font-size: 0.875em;\n      line-height: 1.5;\n      tab-size: 2;\n      direction: ltr;\n      border-radius: var(--radius, 0.5em);\n      padding: 0.75em 1em;\n      overflow-x: auto;\n      margin-block-start: calc(var(--typeset-flow) / 0.875);\n      margin-block-end: 0;\n    }\n    &:where(pre code) {\n      background-color: transparent;\n      font-family: inherit;\n      font-size: inherit;\n      padding: 0;\n      border-radius: 0;\n    }\n\n    &:where(blockquote) {\n      border-inline-start: 2px solid var(--typeset-rule);\n      padding-inline-start: 1em;\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n      margin-inline: 0;\n    }\n\n    &:where(hr) {\n      border: 0;\n      border-block-start: 1px solid var(--typeset-rule);\n      margin-block-start: calc(var(--typeset-flow) * 2.4);\n      margin-block-end: 0;\n    }\n    &:where(hr + h1, hr + h2, hr + h3, hr + h4) {\n      margin-block-start: var(--typeset-flow);\n    }\n\n    &:where(.footnotes, [data-footnotes]) {\n      margin-block-start: calc(var(--typeset-flow) * 2);\n      border-block-start: 1px solid var(--typeset-rule);\n      padding-block-start: var(--typeset-flow);\n      font-size: 0.875em;\n      color: var(--typeset-muted);\n    }\n\n    &:where(math[display=\"block\"]) {\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n      overflow-x: auto;\n      overflow-y: hidden;\n      padding-block: 0.25em;\n    }\n\n    &:where(img, video) {\n      border-radius: var(--radius, 0.5em);\n      max-width: 100%;\n      height: auto;\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n    }\n    &:where(p img) {\n      margin-block-start: 0;\n    }\n    &:where(figure) {\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n      margin-inline: 0;\n    }\n    &:where(figcaption, caption) {\n      color: var(--typeset-muted);\n      font-size: 0.875em;\n      text-align: center;\n      margin-block-start: calc(0.75em / 0.875);\n    }\n    &:where(caption) {\n      caption-side: bottom;\n    }\n\n    &:where(table) {\n      max-width: 100%;\n      font-size: 1em;\n      line-height: 1.5;\n      font-variant-numeric: tabular-nums;\n      border-collapse: separate;\n      border-spacing: 0;\n      border-block-end: 1px solid var(--typeset-rule);\n      margin-block-start: var(--typeset-flow);\n      margin-block-end: 0;\n    }\n\n    &:where(.typeset-scroll) {\n      overflow-x: auto;\n      margin-block-start: var(--typeset-flow);\n    }\n    &:where(.typeset-scroll > *) {\n      margin-block-start: 0;\n    }\n    &:where(.typeset-scroll table) {\n      width: max-content;\n      max-width: none;\n    }\n    &:where(thead th) {\n      font-weight: 500;\n      text-align: start;\n      white-space: nowrap;\n      padding: 0.65em 1em;\n    }\n    &:where(:is(tbody, tfoot) :is(td, th)) {\n      padding: 0.75em 1em;\n      text-align: start;\n      vertical-align: top;\n      border-block-start: 1px solid var(--typeset-rule);\n    }\n    &:where(tbody th, tfoot th) {\n      font-weight: 500;\n    }\n    &:where(th:first-child, td:first-child) {\n      padding-inline-start: 0;\n    }\n    &:where(th[align=\"center\"], td[align=\"center\"]) {\n      text-align: center;\n    }\n    &:where(th[align=\"right\"], td[align=\"right\"]) {\n      text-align: end;\n    }\n\n    &:where(li > blockquote, li > table, li > figure) {\n      margin-block-start: 0.5em;\n    }\n    &:where(li > pre) {\n      margin-block-start: calc(0.5em / 0.875);\n    }\n\n    @media print {\n      &:where(pre, table, blockquote, figure) {\n        break-inside: avoid;\n      }\n      &:where(h1, h2, h3, h4) {\n        break-after: avoid;\n      }\n    }\n\n    @media (forced-colors: active) {\n      &:where(:not(pre) > code) {\n        border: 1px solid;\n      }\n    }\n  }\n\n  .typeset\n    > :where(:first-child):not(\n      :where(\n        .not-typeset,\n        [data-not-typeset],\n        .not-typeset *,\n        [data-not-typeset] *\n      )\n    ),\n  .typeset\n    > :where(:first-child)\n    > :where(:first-child):not(\n      :where(\n        .not-typeset,\n        [data-not-typeset],\n        .not-typeset *,\n        [data-not-typeset] *\n      )\n    ),\n  .typeset\n    :where(\n      li > :first-child,\n      blockquote > :first-child,\n      td > :first-child,\n      th > :first-child,\n      dd > :first-child,\n      figure > :first-child,\n      figure > picture > img\n    ):not(\n      :where(\n        .not-typeset,\n        [data-not-typeset],\n        .not-typeset *,\n        [data-not-typeset] *\n      )\n    ) {\n    margin-block-start: 0;\n  }\n}\n",
      "type": "registry:file",
      "target": "app/typeset.css"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/app/page.tsx",
      "content": "import Link from \"next/link\"\nimport { ChevronRight } from \"lucide-react\"\n\nimport { SaturnDecoration } from \"../components/saturn-decoration\"\nimport { formatContentDate, getWriting } from \"../lib/posts\"\n\nexport default async function HomePage() {\n  const recentNotes = (await getWriting()).slice(0, 3)\n\n  return (\n    <div className=\"typeset typeset-article max-w-[42em]\">\n      <h1 className=\"flex items-center justify-between gap-4\">\n        <span>Jon Doe</span>\n        <SaturnDecoration />\n      </h1>\n\n      <p>\n        I&apos;m a physicist. I work on planetary science and the structure of\n        the solar system, and I spend as much time explaining the universe as\n        measuring it.\n      </p>\n\n      <p>\n        Most of my public work is about why planets are the way they are, and\n        what &ldquo;far&rdquo; actually measures. I write books and notes for\n        people who have looked up and wanted a clearer picture of what we are\n        seeing.\n      </p>\n\n      {recentNotes.length > 0 && (\n        <>\n          <p>What I&apos;ve been writing lately:</p>\n          <ul>\n            {recentNotes.map((note) => (\n              <li key={note.slug}>\n                {formatContentDate(note.date)}\n                {\". \"}\n                <Link href={`/writing/${note.slug}`}>{note.title}</Link>\n              </li>\n            ))}\n          </ul>\n        </>\n      )}\n\n      <p>Find your way around:</p>\n      <ul>\n        <li>\n          <Link href=\"/writing\" className=\"inline-flex items-center gap-1\">\n            Writing\n            <ChevronRight className=\"size-[0.9em]\" aria-hidden />\n          </Link>\n          <span className=\"text-muted-foreground\">\n            {\" short notes and essays\"}\n          </span>\n        </li>\n        <li>\n          <Link href=\"/books\" className=\"inline-flex items-center gap-1\">\n            Books\n            <ChevronRight className=\"size-[0.9em]\" aria-hidden />\n          </Link>\n          <span className=\"text-muted-foreground\">\n            {\" longer, book-length work\"}\n          </span>\n        </li>\n        <li>\n          <Link href=\"/contact\" className=\"inline-flex items-center gap-1\">\n            Contact Me\n            <ChevronRight className=\"size-[0.9em]\" aria-hidden />\n          </Link>\n          <span className=\"text-muted-foreground\">{\" say hello\"}</span>\n        </li>\n      </ul>\n\n      <p>\n        Elsewhere, I&apos;m on{\" \"}\n        <Link href=\"https://instagram.com\" target=\"_blank\" rel=\"noreferrer\">\n          Instagram\n        </Link>\n        ,{\" \"}\n        <Link href=\"https://x.com\" target=\"_blank\" rel=\"noreferrer\">\n          X\n        </Link>\n        , and{\" \"}\n        <Link href=\"https://github.com\" target=\"_blank\" rel=\"noreferrer\">\n          GitHub\n        </Link>\n        .\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/page.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/app/writing/page.tsx",
      "content": "import Link from \"next/link\"\n\nimport { formatContentDate, getWriting } from \"../../lib/posts\"\n\nexport default async function WritingIndexPage() {\n  const posts = await getWriting()\n\n  return (\n    <div className=\"typeset typeset-article max-w-[42em]\">\n      <h1>Writing</h1>\n      <p>\n        Notes from my work.\n      </p>\n      {posts.length === 0 ? (\n        <p>No posts yet.</p>\n      ) : (\n        <ul>\n          {posts.map((post) => (\n            <li key={post.slug}>\n              {formatContentDate(post.date)}\n              {\". \"}\n              <Link href={`/writing/${post.slug}`}>{post.title}</Link>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/writing/page.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/app/writing/[slug]/page.tsx",
      "content": "import { notFound } from \"next/navigation\"\n\nimport {\n  formatContentDate,\n  getWriting,\n  getWritingEntry,\n} from \"../../../lib/posts\"\n\ntype PageProps = {\n  params: Promise<{ slug: string }>\n}\n\nexport async function generateStaticParams() {\n  const posts = await getWriting()\n  return posts.map((post) => ({ slug: post.slug }))\n}\n\nexport default async function WritingEntryPage({ params }: PageProps) {\n  const { slug } = await params\n  const post = await getWritingEntry(slug)\n\n  if (!post) {\n    notFound()\n  }\n\n  return (\n    <div className=\"typeset typeset-article max-w-[42em]\">\n      <h1>{post.title}</h1>\n      <p>{formatContentDate(post.date)}</p>\n      <post.body />\n    </div>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/writing/[slug]/page.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/app/books/page.tsx",
      "content": "import Link from \"next/link\"\n\nimport { getBooks } from \"../../lib/posts\"\n\nfunction bookYear(date: string) {\n  return date.slice(0, 4)\n}\n\nexport default async function BooksIndexPage() {\n  const books = await getBooks()\n\n  return (\n    <div className=\"typeset typeset-article max-w-[42em]\">\n      <h1>Books</h1>\n      <p>\n        Popular-science books from the same public work.\n      </p>\n      {books.length === 0 ? (\n        <p>No books yet.</p>\n      ) : (\n        books.map((book) => (\n          <div key={book.slug}>\n            <h2 className=\"flex items-baseline justify-between gap-4\">\n              <Link href={`/books/${book.slug}`}>{book.title}</Link>\n              <span className=\"shrink-0 text-base font-normal\">\n                {bookYear(book.date)}\n              </span>\n            </h2>\n            {book.description ? <p>{book.description}</p> : null}\n          </div>\n        ))\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/books/page.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/app/books/[slug]/page.tsx",
      "content": "import { notFound } from \"next/navigation\"\n\nimport { formatContentDate, getBook, getBooks } from \"../../../lib/posts\"\n\ntype PageProps = {\n  params: Promise<{ slug: string }>\n}\n\nexport async function generateStaticParams() {\n  const books = await getBooks()\n  return books.map((book) => ({ slug: book.slug }))\n}\n\nexport default async function BookPage({ params }: PageProps) {\n  const { slug } = await params\n  const book = await getBook(slug)\n\n  if (!book) {\n    notFound()\n  }\n\n  return (\n    <div className=\"typeset typeset-article max-w-[42em]\">\n      <h1>{book.title}</h1>\n      <p>{formatContentDate(book.date)}</p>\n      <book.body />\n    </div>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/books/[slug]/page.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/app/contact/page.tsx",
      "content": "import { ContactForm } from \"../../components/contact-form\"\n\nexport default function ContactPage() {\n  return (\n    <div className=\"typeset typeset-article max-w-[42em]\">\n      <h1>Contact</h1>\n      <p>\n        If you want to reach me about speaking, media, teaching, or a question\n        about the books and notes, send a message here.\n      </p>\n      <div className=\"not-typeset mt-[calc(var(--typeset-flow)*2.4)] max-w-xl\">\n        <ContactForm />\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/contact/page.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/contact-form.tsx",
      "content": "\"use client\"\n\nimport { useFormStatus } from \"react-dom\"\nimport { toast } from \"sonner\"\n\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Field,\n  FieldGroup,\n  FieldLabel,\n  FieldSet,\n} from \"@/components/ui/field\"\nimport { Input } from \"@/components/ui/input\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\nimport { contactFormAction } from \"../lib/actions\"\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus()\n\n  return (\n    <Button type=\"submit\" disabled={pending}>\n      {pending ? (\n        <>\n          <Spinner data-icon=\"inline-start\" />\n          Sending...\n        </>\n      ) : (\n        \"Send message\"\n      )}\n    </Button>\n  )\n}\n\nfunction ContactFields() {\n  const { pending } = useFormStatus()\n\n  return (\n    <FieldSet disabled={pending}>\n      <FieldGroup>\n        {/* Honeypot: real users never see or fill this field (hidden +\n            off-screen). Bots that auto-fill every field will trip it. */}\n        <input\n          type=\"text\"\n          name=\"company\"\n          tabIndex={-1}\n          autoComplete=\"off\"\n          aria-hidden=\"true\"\n          className=\"absolute -left-2499.75 h-px w-px opacity-0\"\n        />\n        <Field>\n          <FieldLabel htmlFor=\"name\">Name</FieldLabel>\n          <Input\n            id=\"name\"\n            name=\"name\"\n            placeholder=\"Your name\"\n            required\n            autoComplete=\"name\"\n          />\n        </Field>\n        <Field>\n          <FieldLabel htmlFor=\"email\">Email</FieldLabel>\n          <Input\n            id=\"email\"\n            name=\"email\"\n            type=\"email\"\n            placeholder=\"your.email@example.com\"\n            required\n            autoComplete=\"email\"\n          />\n        </Field>\n        <Field>\n          <FieldLabel htmlFor=\"message\">Message</FieldLabel>\n          <Textarea\n            id=\"message\"\n            name=\"message\"\n            rows={5}\n            placeholder=\"Speaking, a question about a book, or whatever you wanted to say.\"\n            required\n          />\n        </Field>\n        <Field orientation=\"horizontal\">\n          <SubmitButton />\n        </Field>\n      </FieldGroup>\n    </FieldSet>\n  )\n}\n\nexport function ContactForm() {\n  async function handleSubmit(formData: FormData) {\n    const result = await contactFormAction(formData)\n\n    if (result?.error) {\n      toast.error(result.error)\n      return\n    }\n\n    if (result?.success) {\n      toast.success(\"Message sent successfully!\")\n    }\n  }\n\n  return (\n    <form action={handleSubmit}>\n      <ContactFields />\n    </form>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/contact-form.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/saturn-decoration.tsx",
      "content": "import type { CSSProperties } from \"react\"\n\nimport \"../styles/saturn-decoration.css\"\n\ntype MoonName =\n  | \"titan\"\n  | \"rhea\"\n  | \"iapetus\"\n  | \"dione\"\n  | \"tethys\"\n  | \"enceladus\"\n  | \"mimas\"\n\ntype Moon = {\n  name: MoonName\n  size: string\n  inset: string\n  duration: string\n  phase: number\n  reverse?: boolean\n}\n\nconst MOONS: Moon[] = [\n  { name: \"titan\", size: \"0.4rem\", inset: \"2%\", duration: \"20s\", phase: 0.08 },\n  { name: \"rhea\", size: \"0.22rem\", inset: \"14%\", duration: \"14s\", phase: 0.42 },\n  { name: \"iapetus\", size: \"0.21rem\", inset: \"-4%\", duration: \"28s\", phase: 0.71, reverse: true },\n  { name: \"dione\", size: \"0.18rem\", inset: \"22%\", duration: \"11s\", phase: 0.33 },\n  { name: \"tethys\", size: \"0.17rem\", inset: \"30%\", duration: \"9s\", phase: 0.55 },\n  { name: \"enceladus\", size: \"0.12rem\", inset: \"38%\", duration: \"6.5s\", phase: 0.88 },\n  { name: \"mimas\", size: \"0.1rem\", inset: \"44%\", duration: \"5s\", phase: 0.23 },\n]\n\nfunction EclipseMoons() {\n  return (\n    <>\n      {MOONS.map((moon, i) => (\n        <div\n          key={i}\n          className={\n            moon.reverse\n              ? \"saturn-decoration__orbit saturn-decoration__orbit--reverse\"\n              : \"saturn-decoration__orbit\"\n          }\n          style={\n            {\n              \"--moon-size\": moon.size,\n              \"--orbit-inset\": moon.inset,\n              \"--orbit-duration\": moon.duration,\n              \"--orbit-phase\": moon.phase,\n            } as CSSProperties\n          }\n        >\n          <span className=\"saturn-decoration__moon-pivot\">\n            <span className={`saturn-decoration__moon saturn-decoration__moon--${moon.name}`}>\n              <span className=\"saturn-decoration__glare\" />\n            </span>\n          </span>\n        </div>\n      ))}\n    </>\n  )\n}\n\nfunction PlanetFace() {\n  return (\n    <div className=\"saturn-decoration__planet\">\n      <span className=\"saturn-decoration__glare\" />\n    </div>\n  )\n}\n\nexport function SaturnDecoration() {\n  return (\n    <div className=\"saturn-decoration not-typeset\" aria-hidden=\"true\">\n      <div className=\"saturn-decoration__planet-under\">\n        <PlanetFace />\n      </div>\n\n      <div className=\"saturn-decoration__eclipse\">\n        <div className=\"saturn-decoration__plane\">\n          <div className=\"saturn-decoration__rings\" />\n          <EclipseMoons />\n        </div>\n      </div>\n\n      <div className=\"saturn-decoration__planet-cap\">\n        <PlanetFace />\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/saturn-decoration.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/theme-provider.tsx",
      "content": "\"use client\"\n\nimport { ThemeProvider as NextThemesProvider } from \"next-themes\"\nimport type { ReactNode } from \"react\"\n\nimport { ThemeKeyboardShortcut } from \"./theme-toggle\"\n\nexport function ThemeProvider({ children }: { children: ReactNode }) {\n  return (\n    <NextThemesProvider attribute=\"class\" defaultTheme=\"system\" enableSystem>\n      <ThemeKeyboardShortcut />\n      {children}\n    </NextThemesProvider>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/theme-provider.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/components/theme-toggle.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef } from \"react\"\nimport { Moon, Sun } from \"lucide-react\"\nimport { useTheme } from \"next-themes\"\n\nimport { Button } from \"@/components/ui/button\"\n\nconst TOGGLE_COOLDOWN_MS = 650\n\n/** Pure so it can be unit tested without a DOM. */\nexport function shouldAllowToggle(lastToggleAt: number, now: number): boolean {\n  return now - lastToggleAt >= TOGGLE_COOLDOWN_MS\n}\n\nfunction isEditableTarget(target: EventTarget | null) {\n  return (\n    (target instanceof HTMLElement && target.isContentEditable) ||\n    target instanceof HTMLInputElement ||\n    target instanceof HTMLTextAreaElement ||\n    target instanceof HTMLSelectElement\n  )\n}\n\nexport function ThemeToggle() {\n  const { resolvedTheme, setTheme } = useTheme()\n\n  return (\n    <Button\n      onClick={() => setTheme(resolvedTheme === \"dark\" ? \"light\" : \"dark\")}\n      variant=\"ghost\"\n      size=\"icon\"\n    >\n      <Sun className=\"h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90\" />\n      <Moon className=\"absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0\" />\n      <span className=\"sr-only\">Toggle theme</span>\n    </Button>\n  )\n}\n\nexport function ThemeKeyboardShortcut() {\n  const { resolvedTheme, setTheme } = useTheme()\n  const lastToggleAt = useRef<number>(0)\n\n  const toggleTheme = useCallback(() => {\n    const now = Date.now()\n    if (!shouldAllowToggle(lastToggleAt.current, now)) return\n    lastToggleAt.current = now\n    setTheme(resolvedTheme === \"dark\" ? \"light\" : \"dark\")\n  }, [resolvedTheme, setTheme])\n\n  useEffect(() => {\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.metaKey || event.ctrlKey || event.altKey) return\n      if (event.key !== \"d\" && event.key !== \"D\") return\n      if (isEditableTarget(event.target)) return\n\n      event.preventDefault()\n      toggleTheme()\n    }\n\n    document.addEventListener(\"keydown\", onKeyDown)\n    return () => document.removeEventListener(\"keydown\", onKeyDown)\n  }, [toggleTheme])\n\n  return null\n}\n",
      "type": "registry:component",
      "target": "components/theme-toggle.tsx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/styles/saturn-decoration.css",
      "content": ".saturn-decoration {\n  --orbit-size: 3.5rem;\n  --eclipse-yaw: -27deg;\n  --eclipse-scale-y: 0.3;\n  /* Cassini true-color: pale gold disk, cooler icy rings. */\n  --saturn-highlight: oklch(0.9 0.045 92);\n  --saturn-body: oklch(0.76 0.08 85);\n  --saturn-band: oklch(0.66 0.09 76);\n  --saturn-shadow: oklch(0.42 0.072 68);\n  --saturn-ring: oklch(0.7 0.048 82);\n  --saturn-ring-dense: oklch(0.55 0.058 74);\n  position: relative;\n  width: var(--orbit-size);\n  height: var(--orbit-size);\n  flex-shrink: 0;\n  overflow: visible;\n}\n\n.dark .saturn-decoration {\n  --saturn-highlight: oklch(0.94 0.042 92);\n  --saturn-body: oklch(0.8 0.08 85);\n  --saturn-band: oklch(0.7 0.09 76);\n  --saturn-shadow: oklch(0.38 0.06 65);\n  --saturn-ring: oklch(0.86 0.036 88);\n  --saturn-ring-dense: oklch(0.72 0.045 80);\n}\n\n.saturn-decoration__planet-under {\n  position: absolute;\n  inset: 33%;\n  z-index: 0;\n}\n\n.saturn-decoration__eclipse {\n  position: absolute;\n  inset: -42%;\n  z-index: 1;\n  pointer-events: none;\n  transform: rotate(var(--eclipse-yaw));\n}\n\n.saturn-decoration__planet-cap {\n  position: absolute;\n  inset: 33%;\n  z-index: 2;\n  overflow: hidden;\n  border-radius: 9999px;\n  transform: rotate(var(--eclipse-yaw));\n  clip-path: inset(0 0 50% 0);\n  pointer-events: none;\n}\n\n.saturn-decoration__planet-cap .saturn-decoration__planet {\n  position: absolute;\n  inset: 0;\n  transform: rotate(calc(var(--eclipse-yaw) * -1));\n}\n\n.saturn-decoration__plane {\n  position: absolute;\n  inset: 16%;\n  transform: scaleY(var(--eclipse-scale-y));\n}\n\n.saturn-decoration__rings {\n  position: absolute;\n  inset: -8%;\n  border-radius: 9999px;\n  background: radial-gradient(\n    circle at center,\n    transparent 40%,\n    color-mix(in oklab, var(--saturn-ring-dense) 55%, transparent) 44%,\n    color-mix(in oklab, var(--saturn-ring) 82%, var(--saturn-highlight)) 56%,\n    color-mix(in oklab, var(--saturn-ring-dense) 90%, var(--saturn-highlight)) 66%,\n    color-mix(in oklab, var(--saturn-ring-dense) 50%, transparent) 69%,\n    transparent 71%,\n    transparent 74%,\n    color-mix(in oklab, var(--saturn-ring) 70%, transparent) 78%,\n    color-mix(in oklab, var(--saturn-ring-dense) 42%, transparent) 88%,\n    color-mix(in oklab, var(--saturn-ring) 22%, transparent) 93%,\n    transparent 97%\n  );\n}\n\n.saturn-decoration__orbit {\n  --moon-size: 0.2rem;\n  --orbit-inset: 0%;\n  --orbit-duration: 12s;\n  --orbit-phase: 0;\n  position: absolute;\n  inset: var(--orbit-inset);\n  border: 1px solid transparent;\n  border-radius: 9999px;\n  animation: saturn-spin var(--orbit-duration) linear infinite;\n  animation-delay: calc(var(--orbit-duration) * var(--orbit-phase) * -1);\n}\n\n.saturn-decoration__orbit--reverse {\n  animation-direction: reverse;\n}\n\n.saturn-decoration__moon-pivot {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  width: 0;\n  height: 0;\n  animation: saturn-spin-cancel var(--orbit-duration) linear infinite;\n  animation-delay: calc(var(--orbit-duration) * var(--orbit-phase) * -1);\n}\n\n.saturn-decoration__orbit--reverse .saturn-decoration__moon-pivot {\n  animation-name: saturn-spin-cancel-reverse;\n}\n\n.saturn-decoration__moon {\n  --moon-highlight: oklch(0.78 0.02 85);\n  --moon-body: oklch(0.62 0.025 78);\n  --moon-shade: oklch(0.4 0.03 70);\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: var(--moon-size);\n  height: var(--moon-size);\n  overflow: hidden;\n  border-radius: 9999px;\n  background:\n    radial-gradient(\n      circle at 30% 26%,\n      var(--moon-highlight) 0%,\n      var(--moon-body) 42%,\n      var(--moon-shade) 100%\n    );\n  box-shadow:\n    inset -0.08rem 0.02rem 0.12rem color-mix(in oklab, var(--moon-shade) 55%, transparent),\n    inset 0.05rem -0.04rem 0.08rem color-mix(in oklab, var(--moon-highlight) 28%, transparent),\n    0.04rem 0.07rem 0.12rem color-mix(in oklab, var(--moon-shade) 28%, transparent),\n    0 0 0 0.5px color-mix(in oklab, var(--moon-body) 40%, transparent);\n  transform:\n    translate(-50%, -50%)\n    scaleY(calc(1 / var(--eclipse-scale-y)))\n    rotate(calc(var(--eclipse-yaw) * -1));\n}\n\n/* Titan — orange haze from the nitrogen atmosphere. */\n.saturn-decoration__moon--titan {\n  --moon-highlight: oklch(0.72 0.12 68);\n  --moon-body: oklch(0.58 0.14 58);\n  --moon-shade: oklch(0.36 0.11 50);\n}\n\n.dark .saturn-decoration__moon--titan {\n  --moon-highlight: oklch(0.76 0.13 68);\n  --moon-body: oklch(0.62 0.15 58);\n  --moon-shade: oklch(0.34 0.1 50);\n}\n\n/* Rhea — dirty water ice, slightly tan-gray. */\n.saturn-decoration__moon--rhea {\n  --moon-highlight: oklch(0.8 0.022 82);\n  --moon-body: oklch(0.66 0.028 75);\n  --moon-shade: oklch(0.42 0.03 68);\n}\n\n.dark .saturn-decoration__moon--rhea {\n  --moon-highlight: oklch(0.86 0.02 85);\n  --moon-body: oklch(0.72 0.025 78);\n  --moon-shade: oklch(0.4 0.028 68);\n}\n\n/* Iapetus — two-faced: bright ice vs dark reddish Cassini Regio. */\n.saturn-decoration__moon--iapetus {\n  --moon-highlight: oklch(0.9 0.015 95);\n  --moon-body: oklch(0.32 0.055 42);\n  --moon-shade: oklch(0.22 0.04 38);\n  background:\n    linear-gradient(\n      105deg,\n      var(--moon-highlight) 0 46%,\n      var(--moon-body) 54% 100%\n    );\n}\n\n.dark .saturn-decoration__moon--iapetus {\n  --moon-highlight: oklch(0.93 0.014 95);\n  --moon-body: oklch(0.3 0.055 42);\n  --moon-shade: oklch(0.2 0.04 38);\n}\n\n/* Dione — icy, a bit less bright than Tethys. */\n.saturn-decoration__moon--dione {\n  --moon-highlight: oklch(0.88 0.016 95);\n  --moon-body: oklch(0.74 0.02 88);\n  --moon-shade: oklch(0.48 0.025 75);\n}\n\n.dark .saturn-decoration__moon--dione {\n  --moon-highlight: oklch(0.92 0.014 95);\n  --moon-body: oklch(0.8 0.018 90);\n  --moon-shade: oklch(0.46 0.022 75);\n}\n\n/* Tethys — very high albedo, near-white ice. */\n.saturn-decoration__moon--tethys {\n  --moon-highlight: oklch(0.94 0.012 95);\n  --moon-body: oklch(0.84 0.016 92);\n  --moon-shade: oklch(0.56 0.02 80);\n}\n\n.dark .saturn-decoration__moon--tethys {\n  --moon-highlight: oklch(0.96 0.01 95);\n  --moon-body: oklch(0.88 0.014 92);\n  --moon-shade: oklch(0.54 0.018 80);\n}\n\n/* Enceladus — brightest, fresh ice with a faint blue. */\n.saturn-decoration__moon--enceladus {\n  --moon-highlight: oklch(0.97 0.012 230);\n  --moon-body: oklch(0.9 0.018 220);\n  --moon-shade: oklch(0.62 0.02 230);\n}\n\n.dark .saturn-decoration__moon--enceladus {\n  --moon-highlight: oklch(0.98 0.012 230);\n  --moon-body: oklch(0.93 0.016 220);\n  --moon-shade: oklch(0.6 0.02 230);\n}\n\n/* Mimas — small gray ice. */\n.saturn-decoration__moon--mimas {\n  --moon-highlight: oklch(0.72 0.018 80);\n  --moon-body: oklch(0.56 0.022 72);\n  --moon-shade: oklch(0.36 0.025 65);\n  opacity: 0.9;\n}\n\n.dark .saturn-decoration__moon--mimas {\n  --moon-highlight: oklch(0.78 0.016 82);\n  --moon-body: oklch(0.62 0.02 75);\n  --moon-shade: oklch(0.34 0.022 65);\n}\n\n.saturn-decoration__planet {\n  position: absolute;\n  inset: 0;\n  overflow: hidden;\n  border-radius: 9999px;\n  background:\n    radial-gradient(\n      circle at 30% 26%,\n      var(--saturn-highlight) 0%,\n      color-mix(in oklab, var(--saturn-body) 55%, var(--saturn-highlight)) 22%,\n      var(--saturn-body) 48%,\n      color-mix(in oklab, var(--saturn-body) 45%, var(--saturn-shadow)) 72%,\n      var(--saturn-shadow) 100%\n    );\n  box-shadow:\n    inset -0.22rem 0.04rem 0.34rem color-mix(in oklab, var(--saturn-shadow) 70%, transparent),\n    inset 0.1rem -0.08rem 0.18rem color-mix(in oklab, var(--saturn-highlight) 35%, transparent),\n    0.05rem 0.1rem 0.18rem color-mix(in oklab, var(--saturn-shadow) 40%, transparent),\n    0 0 0 1px color-mix(in oklab, var(--saturn-band) 55%, var(--saturn-shadow));\n}\n\n.saturn-decoration__planet::before {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: repeating-linear-gradient(\n    180deg,\n    transparent 0 16%,\n    color-mix(in oklab, var(--saturn-band) 42%, transparent) 16% 22%,\n    transparent 22% 36%,\n    color-mix(in oklab, var(--saturn-band) 28%, transparent) 36% 44%,\n    transparent 44% 58%,\n    color-mix(in oklab, var(--saturn-shadow) 22%, transparent) 58% 66%\n  );\n  pointer-events: none;\n}\n\n.saturn-decoration__planet-under .saturn-decoration__planet {\n  inset: 0;\n}\n\n.saturn-decoration__glare {\n  position: absolute;\n  top: 12%;\n  left: 16%;\n  width: 36%;\n  height: 28%;\n  border-radius: 9999px;\n  background: color-mix(in oklab, var(--saturn-highlight) 55%, white);\n  filter: blur(0.09rem);\n  opacity: 0.45;\n}\n\n.saturn-decoration__planet > .saturn-decoration__glare {\n  opacity: 0.5;\n}\n\n.saturn-decoration__moon .saturn-decoration__glare {\n  background: color-mix(in oklab, var(--moon-highlight) 70%, white);\n  filter: blur(0.04rem);\n  opacity: 0.55;\n}\n\n.saturn-decoration__moon--titan .saturn-decoration__glare {\n  opacity: 0.28;\n}\n\n.saturn-decoration__moon--enceladus .saturn-decoration__glare,\n.saturn-decoration__moon--tethys .saturn-decoration__glare {\n  opacity: 0.7;\n}\n\n@keyframes saturn-spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n@keyframes saturn-spin-cancel {\n  from {\n    transform: translate(-50%, -50%) rotate(0deg);\n  }\n  to {\n    transform: translate(-50%, -50%) rotate(-360deg);\n  }\n}\n\n@keyframes saturn-spin-cancel-reverse {\n  from {\n    transform: translate(-50%, -50%) rotate(0deg);\n  }\n  to {\n    transform: translate(-50%, -50%) rotate(360deg);\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .saturn-decoration__orbit,\n  .saturn-decoration__moon-pivot {\n    animation: none;\n  }\n\n  .saturn-decoration__moon-pivot {\n    transform: translate(-50%, -50%);\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/saturn-decoration.css"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/lib/posts.ts",
      "content": "import fs from \"node:fs/promises\"\nimport path from \"node:path\"\n\nimport matter from \"gray-matter\"\nimport { compileMDX } from \"next-mdx-remote/rsc\"\nimport type { ComponentType } from \"react\"\nimport rehypeKatex from \"rehype-katex\"\nimport remarkGfm from \"remark-gfm\"\nimport remarkMath from \"remark-math\"\nimport { z } from \"zod\"\n\nexport type ContentFrontmatter = {\n  title: string\n  description?: string\n  date: string\n  published?: boolean\n}\n\nexport type ContentEntry = {\n  slug: string\n  title: string\n  description?: string\n  date: string\n  body: ComponentType\n}\n\nconst TEMPLATE_CONTENT_FALLBACK = path.join(\n  process.cwd(),\n  \"registry/new-york/templates/portfolio-v1/content\"\n)\n\nasync function resolveContentDir(kind: \"writing\" | \"books\"): Promise<string | null> {\n  const candidates = [\n    path.join(process.cwd(), \"content\", kind),\n    path.join(TEMPLATE_CONTENT_FALLBACK, kind),\n  ]\n\n  for (const dir of candidates) {\n    try {\n      // Build-time content reads are scoped to the known content dir; the\n      // ignore comment stops Turbopack from tracing the whole project into\n      // the server bundle just because the path is computed at runtime.\n      await fs.access(/*turbopackIgnore: true*/ dir)\n      return dir\n    } catch {\n      // try next\n    }\n  }\n  return null\n}\n\nfunction normalizeDate(date: unknown): string {\n  // gray-matter parses unquoted YAML dates (e.g. `date: 2025-09-20`) into Date objects.\n  if (date instanceof Date) {\n    return date.toISOString().slice(0, 10)\n  }\n  return String(date).slice(0, 10)\n}\n\nconst ContentFrontmatterSchema = z.object({\n  title: z.string().min(1, { message: \"title is required\" }),\n  description: z.string().optional(),\n  date: z.union([z.string(), z.date()]),\n  published: z.boolean().optional(),\n})\n\nexport type FrontmatterParseResult =\n  | { ok: true; data: ContentFrontmatter }\n  | { ok: false; error: string }\n\n/**\n * Pure — takes raw frontmatter data (already parsed by gray-matter) and\n * validates it. Kept separate from file I/O so it can be unit tested\n * without touching the filesystem.\n */\nexport function parseFrontmatter(raw: unknown): FrontmatterParseResult {\n  const result = ContentFrontmatterSchema.safeParse(raw)\n  if (!result.success) {\n    return { ok: false, error: result.error.issues[0]?.message ?? \"Invalid frontmatter.\" }\n  }\n  return {\n    ok: true,\n    data: {\n      title: result.data.title,\n      description: result.data.description,\n      date: normalizeDate(result.data.date),\n      published: result.data.published,\n    },\n  }\n}\n\nfunction formatDate(dateStr: string): string {\n  const [year, month, day] = dateStr.split(\"-\").map(Number)\n  const d = new Date(Date.UTC(year, month - 1, day))\n  return d.toLocaleDateString(\"en-US\", {\n    month: \"long\",\n    day: \"numeric\",\n    year: \"numeric\",\n    timeZone: \"UTC\",\n  })\n}\n\nexport function formatContentDate(dateStr: string): string {\n  return formatDate(dateStr)\n}\n\nasync function loadEntries(kind: \"writing\" | \"books\"): Promise<ContentEntry[]> {\n  const directory = await resolveContentDir(kind)\n  if (!directory) return []\n\n  let filenames: string[] = []\n  try {\n    filenames = await fs.readdir(/*turbopackIgnore: true*/ directory)\n  } catch {\n    return []\n  }\n\n  const mdxFiles = filenames.filter((filename) => filename.endsWith(\".mdx\"))\n\n  const entries = await Promise.all(\n    mdxFiles.map(async (filename) => {\n      try {\n        const fullPath = path.join(/*turbopackIgnore: true*/ directory, filename)\n        const source = await fs.readFile(/*turbopackIgnore: true*/ fullPath, \"utf8\")\n        const { data, content } = matter(source)\n\n        const parsed = parseFrontmatter(data)\n        if (!parsed.ok) {\n          console.warn(`[content] skipping ${kind}/${filename}: ${parsed.error}`)\n          return null\n        }\n        const frontmatter = parsed.data\n\n        if (frontmatter.published === false) {\n          return null\n        }\n\n        const compiled = await compileMDX<ContentFrontmatter>({\n          source: content,\n          options: {\n            parseFrontmatter: false,\n            mdxOptions: {\n              remarkPlugins: [remarkGfm, remarkMath],\n              rehypePlugins: [rehypeKatex],\n            },\n          },\n        })\n        const Body = () => compiled.content\n\n        const entry: ContentEntry = {\n          slug: filename.replace(/\\.mdx$/, \"\"),\n          title: frontmatter.title,\n          description: frontmatter.description,\n          date: frontmatter.date,\n          body: Body,\n        }\n        return entry\n      } catch (error) {\n        console.warn(`[content] skipping ${kind}/${filename}: ${error instanceof Error ? error.message : String(error)}`)\n        return null\n      }\n    })\n  )\n\n  return entries\n    .filter((entry): entry is ContentEntry => entry !== null)\n    .sort((a, b) => b.date.localeCompare(a.date))\n}\n\nasync function loadEntry(kind: \"writing\" | \"books\", slug: string): Promise<ContentEntry | null> {\n  const directory = await resolveContentDir(kind)\n  if (!directory) return null\n\n  const filename = `${slug}.mdx`\n  const fullPath = path.join(/*turbopackIgnore: true*/ directory, filename)\n\n  let source: string\n  try {\n    source = await fs.readFile(/*turbopackIgnore: true*/ fullPath, \"utf8\")\n  } catch {\n    return null\n  }\n\n  const { data, content } = matter(source)\n  const parsed = parseFrontmatter(data)\n  if (!parsed.ok) {\n    console.warn(`[content] ${kind}/${filename}: ${parsed.error}`)\n    return null\n  }\n  const frontmatter = parsed.data\n  if (frontmatter.published === false) return null\n\n  try {\n    const compiled = await compileMDX<ContentFrontmatter>({\n      source: content,\n      options: {\n        parseFrontmatter: false,\n        mdxOptions: {\n          remarkPlugins: [remarkGfm, remarkMath],\n          rehypePlugins: [rehypeKatex],\n        },\n      },\n    })\n    const Body = () => compiled.content\n\n    return {\n      slug,\n      title: frontmatter.title,\n      description: frontmatter.description,\n      date: frontmatter.date,\n      body: Body,\n    }\n  } catch (error) {\n    console.warn(`[content] ${kind}/${filename}: ${error instanceof Error ? error.message : String(error)}`)\n    return null\n  }\n}\n\nexport async function getWriting(): Promise<ContentEntry[]> {\n  return loadEntries(\"writing\")\n}\n\nexport async function getWritingEntry(slug: string): Promise<ContentEntry | null> {\n  return loadEntry(\"writing\", slug)\n}\n\nexport async function getBooks(): Promise<ContentEntry[]> {\n  return loadEntries(\"books\")\n}\n\nexport async function getBook(slug: string): Promise<ContentEntry | null> {\n  return loadEntry(\"books\", slug)\n}\n",
      "type": "registry:lib",
      "target": "lib/posts.ts"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/lib/actions.ts",
      "content": "\"use server\"\n\nimport { ContactFormSchema } from \"./schemas\"\n\nexport async function contactFormAction(formData: FormData) {\n  // Honeypot: real users never see or fill this field (hidden + off-screen\n  // in the form). Bots that auto-fill every field will trip it.\n  const honeypot = String(formData.get(\"company\") ?? \"\").trim()\n  if (honeypot !== \"\") {\n    return { success: true as const }\n  }\n\n  const endpoint = process.env.BASIN_ENDPOINT\n\n  if (!endpoint) {\n    return {\n      error: \"Contact form is not configured. Set BASIN_ENDPOINT in .env.local.\",\n    }\n  }\n\n  const parsed = ContactFormSchema.safeParse({\n    name: String(formData.get(\"name\") ?? \"\").trim(),\n    email: String(formData.get(\"email\") ?? \"\").trim(),\n    message: String(formData.get(\"message\") ?? \"\").trim(),\n  })\n\n  if (!parsed.success) {\n    return { error: parsed.error.issues[0]?.message ?? \"Invalid form data.\" }\n  }\n\n  const { name, email, message } = parsed.data\n\n  const body = new URLSearchParams()\n  body.set(\"name\", name)\n  body.set(\"email\", email)\n  body.set(\"message\", message)\n\n  try {\n    const response = await fetch(endpoint, {\n      method: \"POST\",\n      headers: {\n        Accept: \"application/json\",\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body,\n    })\n\n    if (!response.ok) {\n      return { error: \"Failed to send message. Please try again.\" }\n    }\n\n    return { success: true as const }\n  } catch {\n    return { error: \"Failed to send message. Please try again.\" }\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/actions.ts"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/lib/schemas.ts",
      "content": "import { z } from \"zod\"\n\nexport const ContactFormSchema = z.object({\n  name: z.string().min(1, { message: \"Name is required.\" }),\n  email: z\n    .string()\n    .min(1, { message: \"Email is required.\" })\n    .email(\"Invalid email.\"),\n  message: z\n    .string()\n    .min(10, { message: \"Message must be at least 10 characters.\" })\n    .max(500, { message: \"Message must be less than 500 characters.\" }),\n})\n",
      "type": "registry:lib",
      "target": "lib/schemas.ts"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/styles/typeset-article.css",
      "content": ".typeset-article {\n  --typeset-font-body: var(--font-inter);\n  --typeset-font-heading: var(--font-inter);\n  --typeset-font-mono: var(--font-inter);\n  --typeset-size: 15px;\n  --typeset-leading: 1.75;\n  --typeset-flow: 1.25em;\n}\n\n/* KaTeX inherits theme text; display math follows typeset rhythm. */\n.typeset-article .katex {\n  color: inherit;\n}\n\n.typeset-article .katex-display {\n  margin-block: var(--typeset-flow) 0;\n  overflow-x: auto;\n  overflow-y: hidden;\n  padding-block: 0.25em;\n}\n",
      "type": "registry:file",
      "target": "styles/typeset-article.css"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/.env.example",
      "content": "BASIN_ENDPOINT=https://usebasin.com/f/your-basin-form-id",
      "type": "registry:file",
      "target": ".env.example"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/AGENTS.md",
      "content": "# Personalizing this portfolio\n\nThis site ships with filler content for a fictional physicist and science\ncommunicator (\"Jon Doe\"). It's a demo persona, not a template you fill in\nfield-by-field — your job is to fully re-skin the site as the real person\nwho owns this project.\n\n**Trigger:** the user attaches their resume (a PDF) directly to you and\nasks you to personalize the site — e.g. by mentioning `@AGENTS.md`. When\nthat happens, follow the steps below.\n\nThe resume is personal data and has no reason to live in this repo. Work\nfrom the attachment. Do **not** copy it into the project directory at any\npoint — not to read it, not to run the extraction in Step 1, not\n\"just temporarily while I work.\" As a safety net in case someone saves it\nhere by hand, add a line to `.gitignore` before you start (skip if it's\nalready listed):\n\n```\ngrep -qxF 'resume.pdf' .gitignore || echo 'resume.pdf' >> .gitignore\n```\n\n## Step 1 — Read the resume\n\nRead the attached resume. Pull out:\n\n- Full name\n- Real profession / field / title (e.g. \"Full-Stack Developer\", \"Product\n  Designer\", \"Marine Biologist\") — this drives the tone of everything below\n- A narrative bio: 1–2 paragraphs synthesized from their summary, experience,\n  and notable work — written in first person, in a voice that matches their\n  actual field. Do not reuse the physicist framing (\"planetary science\",\n  \"the solar system\") with the name swapped in — the whole voice changes.\n- Contact email, if present\n- Social / portfolio links actually present in the resume (LinkedIn, GitHub,\n  personal site, X, Dribbble, etc.)\n- Notable projects, publications, or focus areas worth referencing in\n  writing/book content (Step 4)\n\n### Recovering the real URL behind a link\n\nReading the PDF only gives you visible text — \"GitHub\" as a clickable\nlabel doesn't tell you what it links to. Before treating any link as\nunresolved, try recovering the actual target from the raw bytes of the\nfile.\n\nThis needs a real filesystem path, and the resume is an attachment, not a\nfile in the repo. Copy it to a scratch location **outside the project** —\nyour OS temp directory — run the search there, and delete it when done:\n\n```\n# adjust the source to wherever the attachment actually is on disk\ncp <attached-resume-path> \"${TMPDIR:-/tmp}/resume.pdf\"\ngrep -a -o \"/URI[^)]*)\" \"${TMPDIR:-/tmp}/resume.pdf\"\nrm \"${TMPDIR:-/tmp}/resume.pdf\"\n```\n\nOn Windows use the temp dir (`\"$TEMP/resume.pdf\"` in Git Bash,\n`$env:TEMP` in PowerShell). Never copy it under the project directory,\neven for a moment. If you can't get a filesystem path to the attachment at\nall, ask the user for the path to the file on their machine and grep it in\nplace — still don't bring it into the repo.\n\nMost resumes (exported from Google Docs, Word, or a resume builder) store\ntheir hyperlink targets as plain-text `/URI (...)` entries even when the\nrest of the PDF is compressed, so this works more often than not. Match\neach URL back to the label near it in the resume text — a `mailto:` is the\nemail, a `github.com/...` URL is GitHub, `linkedin.com/in/...` is LinkedIn,\nand so on. Do this for every link-looking label in the resume, not just\nthe obvious ones — it's what makes Step 3 actually work instead of falling\nback to asking every time.\n\nOnly fall back to asking the user for the real link if this comes up empty\nand the resume text still shows an unresolved label — some PDFs compress\nthe annotation objects too, which a plain-text search can't see through.\n\n## Step 2 — Identity & tone\n\nRewrite, in the new voice from Step 1:\n\n- `app/page.tsx` — the `<h1>` name, and the two bio `<p>` paragraphs. They\n  are not interchangeable — each one is doing a specific job, and copying\n  the pattern matters as much as matching the length:\n  - **Paragraph 1 — Identity: who they are.** Role, domain, and a\n    defining stance or tension in how they work — the original is \"I'm a\n    physicist. I work on planetary science and the structure of the solar\n    system, and I spend as much time explaining the universe as measuring\n    it.\" (role → domain → a duality that says something about *how* they\n    approach the work, not just what it is).\n  - **Paragraph 2 — Purpose: what their public output is about, and for\n    whom.** Not a list of named projects — the original is \"Most of my\n    public work is about why planets are the way they are... I write\n    books and notes for people who have looked up and wanted a clearer\n    picture of what we are seeing.\" It's the *theme* of what they\n    write/build in public, plus who it serves, and it bridges into the\n    Writing/Books sections below it on the page. Named projects belong in\n    those\n    sections (Step 4) and in the \"what I've been writing lately\" list,\n    which already exists — don't front-load them into the bio too.\n    Most resumes have no publishing history at all — a nurse, an\n    accountant, a retail manager almost never have a \"public output\" to\n    theme around, and that's the common case, not an exception. When\n    there's nothing to draw on, frame this paragraph around what they'd\n    plausibly write about *given their actual field and role* — the kind\n    of thing worth noting from work they've actually done — not invented\n    accomplishments. It's honest framing of intent, not a summary of a\n    body of work that doesn't exist yet.\n- `app/layout.tsx` — `metadata.title` and `metadata.description`\n- `app/contact/page.tsx` — the intro paragraph (currently mentions \"books\n  and notes\" — reframe to match what this person actually does)\n- `app/writing/page.tsx` and `app/books/page.tsx` — the one-line intro\n  under each `<h1>` (currently physicist-flavored, e.g. \"Popular-science\n  books from the same public work\")\n- `app/page.tsx` — the \"Find your way around\" list: the two muted\n  descriptions (\"short notes and essays\", \"longer, book-length work\") next\n  to the Writing and Books links. See \"Section labels\" below — the label\n  words themselves may need to change too, not just these descriptions.\n\n**Length budget:** before you rewrite the two bio paragraphs, count the\nwords in the two you're replacing. Match that count within about 20%, and\nkeep it two paragraphs, not three or four. The demo bio is short on\npurpose — two or three sentences each. A longer bio isn't more thorough,\nit's just a worse read. Same rule for the one-line intros: one line stays\none line. Same for the two \"Find your way around\" descriptions: they're\nfour words each — stay in that range, don't turn them into full sentences.\n\n### Section labels (Writing / Books)\n\n\"Books\" barely fits anyone who isn't an author, and it's the common case,\nnot an edge case — most people personalizing this (a full-stack developer\nis the expected default) have no literal books. Don't just reinterpret the\ncontent under the label (Step 4) and leave the word \"Books\" sitting there\nif it reads wrong. Relabel it. This doesn't touch routing — `/books` stays\n`/books` as a URL; only the *visible* text changes, in every place it\nappears:\n\n1. `app/page.tsx` — the nav link text (\"Writing\" / \"Books\" in the \"Find\n   your way around\" list) and its muted description\n2. `app/writing/page.tsx` / `app/books/page.tsx` — the `<h1>` and the\n   one-line intro under it\n\nPick a label that actually fits — \"Projects\", \"Work\", \"Case Studies\" for\nBooks; \"Writing\" itself is a reasonable fit for almost anyone and usually\ndoesn't need to change. Do this automatically as part of the personalization,\nthe same way you rewrite the bio — you don't need to ask first unless the\nright replacement word genuinely isn't obvious. Keep every label to a\nsingle word or short phrase, matching the length of what it replaces —\nthis is a nav item, not a sentence.\n\n## Step 3 — Links\n\nDo not shorten or remove the \"Elsewhere, I'm on Instagram, X, and\nGitHub\" section in `app/page.tsx` — keep all three labels and the sentence\nstructure exactly as they are. This section is a fixed set of redirect\nslots, not a list you edit down to what the resume happens to mention.\n(\"X\" is the platform formerly called Twitter — a resume may still label it\neither way.)\n\nFor each of the three, the `href` rule is the same for every platform,\nevery time — resolved or not:\n\n- Resume has a real link/handle for the platform (most dev resumes have a\n  GitHub) → replace that one `href` with the real URL. Keep\n  `target=\"_blank\" rel=\"noreferrer\"` as-is — it already opens in a new tab.\n- Resume doesn't mention the platform (Instagram usually won't; a resume\n  link labeled \"Twitter\" is the same platform as X — fill the X slot\n  with it, don't treat it as a new one), **or you can't recover a real URL\n  for it** (see the PDF caveat below) → set `href=\"#\"`. Don't leave the\n  original bare-domain placeholder (`https://instagram.com`, etc.) —\n  that's a working link to the wrong page, which is worse than an obvious\n  placeholder. `href=\"#\"` reads unambiguously as \"not filled in yet,\" for\n  a developer and non-developer alike.\n\nA link's visible text alone (\"Portfolio\", \"GitHub\") doesn't tell you the\n`href` — use the extraction command from Step 1 to recover it before\nconcluding it's unresolved. Only after that comes up empty do you treat it\nlike \"not on the resume\": don't guess a URL, don't leave a URL that looks\nreal but isn't. Set it to `href=\"#\"`, and ask the user for the actual link\nso you can fill it in for real.\n\n**Never add a \"Portfolio\" link found on the resume.** This site *is* their\nportfolio — that resume link points at wherever their portfolio currently\nlives, and once they've personalized this template, this is that site.\nLinking out to it would point the person's own site at itself (or at an\nold one they're replacing). Skip it entirely; don't add it to the list, no\nmatter what domain it resolves to.\n\n**Beyond the fixed three, actively check the resume for these and add each\none you find as a new item in the same list** — never remove or replace\nany of the original three to make room for them, only append:\n\n- **LinkedIn** — check for this every time. It's the single most common\n  link on a resume that isn't already one of the three slots. If found, it\n  goes right after X and before GitHub, so the order reads\n  Instagram, X, LinkedIn, and GitHub.\n- Anything else the resume links to (Dribbble, a Mastodon handle, etc.)\n  also goes at the end, in the order you found it. This isn't a closed\n  list — LinkedIn is just the one to never skip checking for.\n\nExact placement for LinkedIn, so it's unambiguous — this is the only line\nin `app/page.tsx` that changes shape:\n\n```jsx\n<p>\n  Elsewhere, I&apos;m on{\" \"}\n  <Link href=\"#\" target=\"_blank\" rel=\"noreferrer\">\n    Instagram\n  </Link>\n  ,{\" \"}\n  <Link href=\"#\" target=\"_blank\" rel=\"noreferrer\">\n    X\n  </Link>\n  ,{\" \"}\n  <Link href=\"https://linkedin.com/in/their-real-handle\" target=\"_blank\" rel=\"noreferrer\">\n    LinkedIn\n  </Link>\n  , and{\" \"}\n  <Link href=\"https://github.com/their-real-handle\" target=\"_blank\" rel=\"noreferrer\">\n    GitHub\n  </Link>\n  .\n</p>\n```\n\nThis example shows a resume with LinkedIn and GitHub but no Instagram or\nX, which is the common case — Instagram and X fall back to\n`#`, LinkedIn gets appended, GitHub gets its real handle. If LinkedIn\nisn't on the resume, don't add that block at all; just apply the `#`-or-real\nrule to the original three.\n\n## Step 4 — Writing & Books content\n\n`content/writing/*.mdx` and `content/books/*.mdx` are demo posts about\nplanetary science. Replace them with new entries that fit the person's real\ntitle/field from Step 1 — e.g. a full-stack developer gets short technical\nnotes under `content/writing/` and longer project write-ups or guides under\n`content/books/` (reinterpret \"Books\" as their long-form work — case\nstudies, whitepapers, guides — if they don't literally have books). If\nthat reinterpretation feels like a stretch once you've drafted it, relabel\nthe section per \"Section labels\" in Step 2 instead of forcing the content\nto justify a word that doesn't fit — do this automatically, don't wait to\nbe asked.\n\n- Keep the same frontmatter schema: `title`, `description`, `date`\n  (`YYYY-MM-DD`), `published` (optional)\n- **Keep the same number of entries** currently in each folder — count the\n  files in `content/writing/` and `content/books/` before you delete\n  anything, and replace with that many, not fewer. If the resume genuinely\n  doesn't support that many distinct topics, say so and ask before\n  under-filling a section.\n- **Length budget, per entry:** before replacing a file, count its words\n  (frontmatter excluded). Target the new entry within about 20% of that —\n  neither a two-line stub nor a doubled-length essay. Do this per file, not\n  as a folder average, since the demo entries aren't all the same length\n  to begin with.\n- Match the existing structure of the current demo entries — prose\n  paragraphs, a `<figure>` image with `<figcaption>` (see below), a pull\n  quote via `>`, a table or two if relevant to the field\n- Images: every `content/writing/*.mdx` entry gets a `<figure>` unless the\n  topic is genuinely non-visual — see \"Sourcing images\" below for how to\n  find a real URL (a required step, not a nice-to-have) and how exact the\n  match has to be. Video is never an option in this template.\n- Titles and topics should read as genuinely written by this person about\n  their real field, not a generic reskin — use the specifics from their\n  resume (technologies, industries, notable work) as material\n\n### Sourcing images\n\nImages are expected. Personalization should not produce a set of text-only\nposts when the demo had a photo on nearly every one. Every\n`content/writing/*.mdx` entry gets one `<figure>` unless the topic is\ngenuinely non-visual (a pure-abstract argument, a math-heavy note).\nBooks / long-form entries: one where it fits, optional otherwise. \"I\ncouldn't find one\" is a per-figure outcome after a real search — never the\nreason the whole set ships bare.\n\nImages only — this template has no video component or pattern anywhere in\nit, so never add a `<video>` tag or embed.\n\nImages are plain hotlinked URLs in the MDX itself — no upload, no local\nfile, no attachment. Use exactly the pattern the demo content already\nuses:\n\n```\n<figure>\n  <img\n    className=\"grayscale dark:brightness-50\"\n    src=\"https://images.unsplash.com/photo-<id>?q=80&w=1200&h=675&fit=crop\"\n    alt=\"...\"\n    width={1200}\n    height={675}\n  />\n  <figcaption>...</figcaption>\n</figure>\n```\n\n**Finding the URL is a required step, not optional.** Unsplash photo IDs\nare not guessable, and a plausible-looking one is almost always a dead\nlink. Never write a `src` from memory. For each figure:\n\n1. Web-search or fetch for a real Unsplash photo matching the topic — the\n   `firecrawl` skill, or `WebSearch` / `WebFetch`, all work.\n2. Take the actual photo ID from the real page URL\n   (`unsplash.com/photos/<slug>-<id>`) and build the `src` with the query\n   string shown above.\n3. Only if that specific search turns up nothing that is an *exact* match\n   (see below) do you skip that one `<figure>` — and say so in your\n   summary: which entries you left imageless and why.\n\nIf you have no web access at all in this environment, stop and tell the\nuser before finishing: either they supply the image URLs, or the\npersonalization pass runs somewhere with web access. Do not ship the\ncontent silently image-free.\n\n**The match has to be exact, not thematic.** `rip-pluto.mdx` is the bar:\nthe post is specifically about Pluto, and the image is an actual photo of\nPluto, not a generic nebula or star field. A post about a specific\nproduct, place, or piece of hardware needs a photo of that thing (or\nsomething concretely evocative of it), not a generic \"coding\" or \"office\"\nstock photo standing in for the topic.\n\nWhat *kind* of photo fits depends on the field, but the answer is rarely\n\"none\":\n\n- **Developer / technical field** — skip the laptop-on-a-desk cliché. When\n  the work has no photographable subject of its own, a clean editorial\n  shot of the real-world domain it serves (a warehouse floor, a hospital\n  ward, a trading desk, transit signage) carries the post. Never pass off\n  a screenshot, chart, or diagram as a photo.\n- **Writer / creative field** — closest to the current demo. An exact,\n  specific photo on essentially every entry.\n- **Other / general personal site** — one exact-match image per entry;\n  favor a precise photo of the real subject over an evocative-but-vague\n  one.\n\n## Step 5 — Unslop pass\n\nBefore you're done, read `UNSLOP.md` and check everything you drafted in\nSteps 2 and 4 against it. AI-generated bios and blog posts have a\nrecognizable smell — stacked em dashes, fortune-cookie pull quotes,\n\"not just X but Y,\" decorative jargon — and this is exactly the content\nmost likely to have it. Rewrite what trips those patterns before finishing.\n\n## Do not touch\n\n- `BASIN_ENDPOINT` / `.env.local` — separate manual setup, not derived from\n  the resume (see `.env.example`)\n- `components/contact-form.tsx`, `components/saturn-decoration.tsx`, **and\n  the `<SaturnDecoration />` usage in `app/page.tsx`.** Leave the\n  decoration on the page exactly as it is, whatever the person's field. It\n  ships on by default; the owner removes it themselves if they don't want\n  it. Don't drop it, don't gate it on whether the field is\n  space-adjacent, don't move it.\n- Theme, CSS, and routing (URLs stay `/writing`, `/books`, `/contact` —\n  only the visible labels change, per \"Section labels\" in Step 2, never\n  the folder names or routes themselves)\n\n## When you're done\n\nThe resume was never copied into the repo, so there's nothing to clean up\nthere. If you made a scratch copy in a temp directory for the Step 1\nextraction, confirm it's deleted. If the user has a `resume.pdf` sitting in\nthe project root anyway (a manual drop, or a previous run), point it out\nand offer to remove it — `.gitignore` now keeps it from being committed\neither way.\n",
      "type": "registry:file",
      "target": "AGENTS.md"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/UNSLOP.md",
      "content": "# Unslop\n\nA pass to run against everything written during personalization — the\n`about` bio, the contact intro, and every `content/writing/*.mdx` /\n`content/books/*.mdx` entry. This applies whenever `AGENTS.md` has you\ndrafting prose for this site, not just once.\n\n## Process\n\n1. Draft the content per `AGENTS.md`.\n2. Before you're done, read it back against every pattern below.\n3. Rewrite what trips a pattern. Preserve the meaning and the person's real\n   voice — don't flatten it, just strip the tells.\n4. Self-audit once more: \"would this specific sentence survive if a human\n   who does this work for real wrote it?\" If not, cut or rewrite it.\n\n## Patterns to detect and fix\n\n### Style\n\n1. **Em dash overuse.** One sentence with two em dashes is the single most\n   common tell in generated bios. Use a period or comma instead.\n   - Bad: \"I build products end to end — frontend, backend, and\n     infrastructure — and I'd rather have something live than perfect.\"\n   - Better: \"I build products end to end: frontend, backend,\n     infrastructure. I'd rather have something live than perfect.\"\n2. **Colon as a mid-sentence connector.** \"The mechanism is simple once you\n   see it:\" or \"The honest version:\" add nothing — they're a wind-up\n   before the actual sentence. Cut the wind-up, state the point.\n3. **Manufactured profundity.** A pull-quote or closing line that sounds\n   like a fortune cookie is a tell, not a flourish.\n   - Bad: \"The best infrastructure is the kind you stop noticing. Nobody\n     should think about the deck. They should think about the talk.\"\n   - Just say what happened: \"I built it because I got tired of tethering\n     a laptop to a projector just to glance at my own notes.\"\n4. **Decorative technical notation.** Don't dress up a plain idea as a\n   formula or diagram to look rigorous — `$$\\text{time to first working\n   page} \\to \\text{one command}$$` for \"install is one command\" is exactly\n   this. Only use math/tables for content that's actually quantitative.\n5. **Rhetorical essay openers.** \"X reads like Y until you realize Z\" and\n   similar setup-then-reveal openings are a generated-essay pattern. Open\n   with the actual fact or event instead.\n6. **Title case headings, curly quotes, decorative emphasis.** Sentence\n   case headings, straight quotes, and no bolding every proper noun.\n\n### Language\n\n7. **AI vocabulary.** Additionally, crucial, delve, enduring, fostering,\n   garner, interplay, intricate, landscape (abstract), pivotal, showcase,\n   tapestry, testament, underscore, vibrant, \"at the intersection of.\"\n   Replace with the plain word or cut.\n8. **Fancy ways to say \"is.\"** \"serves as\", \"stands as\", \"boasts\". Just say\n   \"is\" or \"has.\"\n9. **\"Not just X, but Y.\"** State the point directly.\n10. **Rule of three.** Don't force two real examples into three by padding\n    a filler item. Use the number that's actually true.\n11. **Vague attribution.** \"People say\", \"it's widely known\" — if there's\n    no source, cut the claim or make it a fact you can stand behind.\n\n### Length\n\n17. **Padding past the point.** AI drafts tend to run long — an extra\n    sentence restating what the previous one said, a closing paragraph that\n    summarizes instead of ending, an implication spelled out that the\n    reader already got. `AGENTS.md` gives you a word-count budget for the\n    bio and each entry, measured against what you're replacing — if you're\n    over it, the fix is almost always to cut a sentence, not tighten the\n    wording of all of them.\n18. **One idea per section, not three angles on it.** If two paragraphs are\n    making the same point from different sides, keep the sharper one and\n    delete the other.\n\n### Voice\n\n12. **Say what happened, not how it felt.** \"made the workflow seamless\"\n    names a feeling. \"cut the review step from three tools to one\" names a\n    fact. If a sentence could sit unchanged in anyone else's portfolio,\n    it says nothing about this one — cut it or make it specific.\n13. **Active voice, real actor.** \"the score was improved by streaming the\n    response\" becomes \"streaming the response improved the score.\"\n14. **Cut adverbs propping up a weak verb.** \"runs significantly faster\"\n    becomes the actual number, or a stronger verb.\n15. **Plain word over the fancier synonym.** \"utilize\" → \"use\", \"leverage\"\n    → \"use\", \"facilitate\" → \"help\", \"numerous\" → \"many.\"\n16. **Chatbot artifacts.** No \"I hope this helps,\" no apologizing, no\n    address to a reader who isn't there — this is a bio and a blog post,\n    not a chat reply.\n\n## Sound test\n\nRead the final paragraph out loud. If it sounds like something written\n*about* the person rather than *by* them, it's still slopped.\n",
      "type": "registry:file",
      "target": "UNSLOP.md"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/content/writing/rip-pluto.mdx",
      "content": "---\ntitle: RIP Pluto\ndescription: My take on the 2006 redefinition, the Kuiper Belt, and why my childhood poster of nine planets still stings a little.\ndate: 2026-05-18\npublished: true\n---\n\nPeople 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.\n\n<figure>\n  <img\n    className=\"grayscale dark:brightness-50\"\n    src=\"https://images.unsplash.com/photo-1614314107768-6018061b5b72?q=80&w=1200&h=675&fit=crop\"\n    alt=\"Pluto on a black background, as seen by New Horizons.\"\n    width={1200}\n    height={675}\n  />\n  <figcaption>Still remarkable. Just not alone out there.</figcaption>\n</figure>\n\nThe 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.\n\nPluto passes the first two. It fails the third.\n\nClearing 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.\n\nThat 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.\n\n> Pluto did not get smaller. Our map got larger.\n\nNew 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.\n\nI still miss the poster. I just stopped arguing with the belt.\n",
      "type": "registry:file",
      "target": "content/writing/rip-pluto.mdx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/content/writing/how-big-is-far.mdx",
      "content": "---\ntitle: How big is far\ndescription: How I talk about light-minutes, light-hours, and why the solar system is mostly empty room with a few bright furniture pieces.\ndate: 2026-03-12\npublished: true\n---\n\nAsk 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.\n\nLight 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.\n\n<figure>\n  <img\n    className=\"grayscale dark:brightness-50\"\n    src=\"https://images.unsplash.com/photo-1446776811953-b23d57bd21aa?q=80&w=1200&h=675&fit=crop\"\n    alt=\"Earth from orbit under a thin blue atmosphere.\"\n    width={1200}\n    height={675}\n  />\n  <figcaption>Eight light-minutes from a star. That is “near.”</figcaption>\n</figure>\n\nSaturn 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.\n\nI 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.\n\n> Far is a clock.\n\nOnce 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.\n",
      "type": "registry:file",
      "target": "content/writing/how-big-is-far.mdx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/content/writing/what-the-sky-is-made-of.mdx",
      "content": "---\ntitle: What the sky is made of\ndescription: My note on vacuum, photons, and why empty space is still full of rules.\ndate: 2026-02-04\npublished: true\n---\n\nOn 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.\n\nWhat 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.\n\n<figure>\n  <img\n    className=\"grayscale dark:brightness-50\"\n    src=\"https://images.unsplash.com/photo-1419242902214-272b3f66ee7a?q=80&w=1200&h=675&fit=crop\"\n    alt=\"A dense field of stars against a dark night sky.\"\n    width={1200}\n    height={675}\n  />\n  <figcaption>Most of the picture is travel time.</figcaption>\n</figure>\n\nThere 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.\n\nI 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.\n",
      "type": "registry:file",
      "target": "content/writing/what-the-sky-is-made-of.mdx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/content/writing/the-book-that-started-it.mdx",
      "content": "---\ntitle: The book that started it\ndescription: How one popular-science book turned my habit of looking up into a decision to study physics.\ndate: 2025-11-22\npublished: true\n---\n\nI became a physicist because a book made the night sky feel like a place you could *understand*, not only admire.\n\nI 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.\n\n<figure>\n  <img\n    className=\"grayscale dark:brightness-50\"\n    src=\"https://images.unsplash.com/photo-1481627834876-b7833e8f5570?q=80&w=1200&h=675&fit=crop\"\n    alt=\"An open book on a wooden table in soft light.\"\n    width={1200}\n    height={675}\n  />\n  <figcaption>The book did not replace wonder. It made the next sentence usable.</figcaption>\n</figure>\n\nAfter 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.\n\nI 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.\n\nIf you want a place to start among my notes, begin with [RIP Pluto](/writing/rip-pluto).\n",
      "type": "registry:file",
      "target": "content/writing/the-book-that-started-it.mdx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/content/books/wonders-of-the-outer-system.mdx",
      "content": "---\ntitle: Wonders of the Outer System\ndescription: My tour of the cold worlds beyond the asteroid belt — Saturn’s rings, ice giants, and the crowded dark of the Kuiper Belt.\ndate: 2026-04-01\npublished: true\n---\n\nMost 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.\n\nThe 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.\n\n<figure>\n  <img\n    src=\"https://images.unsplash.com/photo-1614732414444-096e5f1122d5?q=80&w=1200&h=675&fit=crop\"\n    alt=\"Saturn and its rings against a dark background.\"\n    width={1200}\n    height={675}\n  />\n  <figcaption>Rings are orbits you can see.</figcaption>\n</figure>\n\nEach 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.\n\nIf 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.\n",
      "type": "registry:file",
      "target": "content/books/wonders-of-the-outer-system.mdx"
    },
    {
      "path": "registry/new-york/templates/portfolio-v1/content/books/why-anything-happens.mdx",
      "content": "---\ntitle: Why Anything Happens\ndescription: My book on forces, chance, and the small set of rules that turn empty space into stars, planets, and a night sky worth explaining.\ndate: 2025-09-20\npublished: true\n---\n\nPhysics 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.\n\nI 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.\n\nWhen the machinery does show up, it can stay small. Newton’s second law is just:\n\n$$\nF = ma\n$$\n\nAnd a few familiar scales help keep the story honest:\n\n| Quantity | Rough scale | Why it matters |\n| --- | --- | --- |\n| Earth–Sun light time | ~8 minutes | “Near” in solar-system talk |\n| Earth surface $g$ | $9.8\\,\\mathrm{m/s^2}$ | Everyday falling |\n| Solar escape (Earth orbit) | ~42 km/s | Leaving the neighborhood |\n\nThe 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.\n\nRead it as a companion to the outer-system book if you want the *where*; read it alone if you want the *why*.\n",
      "type": "registry:file",
      "target": "content/books/why-anything-happens.mdx"
    }
  ],
  "envVars": {
    "BASIN_ENDPOINT": "https://usebasin.com/f/your-basin-form-id"
  },
  "docs": "New Next.js project only (same flow as https://ui.shadcn.com/create):\n  npx shadcn@latest init --template next -y \"https://uiception.com/r/portfolio-v1.json\"\n\nThis scaffolds Next.js + shadcn, then installs the full site (app routes, content, components).\n\nThen 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.",
  "categories": [
    "template",
    "portfolio"
  ],
  "type": "registry:block"
}