# Portfolio v4

> Bash-style terminal portfolio for a fullstack developer — Jon Doe filler career. Type about, work, resume, or help. New projects only (shadcn init --template next).

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

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

## Install

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

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

## Dependencies

- npm packages: `geist`, `next-themes`

## Notes from the registry

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

This scaffolds Next.js + shadcn, then installs the terminal portfolio.

Edit name, bio, projects, experience, and education in lib/portfolio.ts. Common commands: about, work, projects, resume, help. Also on disk (via ls): destinations, events. Colors use the uiception cyan palette (see styles/terminal.css) — aligned with app/vercel-docs-theme.css.

## Files

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

```tsx
import type { Metadata, Viewport } from "next"
import { GeistMono } from "geist/font/mono"
import type { ReactNode } from "react"

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

import "./globals.css"

export const metadata: Metadata = {
  title: "Jon Doe",
  description:
    "Fullstack developer — type about, work, resume, or help in the terminal.",
}

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  viewportFit: "cover",
  interactiveWidget: "resizes-content",
}

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html
      lang="en"
      className={`${GeistMono.variable} h-full antialiased`}
      suppressHydrationWarning
    >
      <body className={`${GeistMono.variable} m-0 min-h-full overflow-x-hidden p-0 font-mono antialiased`}>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}
```

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

```tsx
import { Terminal } from "../components/terminal"

export default function HomePage() {
  return <Terminal />
}
```

### `registry/new-york/templates/portfolio-v4/components/terminal.tsx`

```tsx
"use client"

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type FormEvent,
  type KeyboardEvent,
} from "react"

import {
  capScrollback,
  getPromptPrefix,
  getWelcomeLines,
  pushCommand,
  runCommand,
  type TerminalLine,
} from "../lib/commands"
import { TerminalLineView } from "./terminal-line"

import "../styles/terminal.css"

type HistoryEntry = {
  id: number
  kind: "output" | "input"
  lines: TerminalLine[]
}

function estimateCols(width: number): number {
  // 15px Geist Mono ≈ 9px/ch; subtract 32px for the 16px side padding
  const available = Math.max(0, width - 32)
  return Math.max(20, Math.floor(available / 9))
}

export function Terminal() {
  const rootRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const idRef = useRef(0)

  const [entries, setEntries] = useState<HistoryEntry[]>([])
  const [input, setInput] = useState("")
  const [commandHistory, setCommandHistory] = useState<string[]>([])
  const [historyIndex, setHistoryIndex] = useState<number | null>(null)
  const [draft, setDraft] = useState("")
  const [booted, setBooted] = useState(false)

  const nextId = () => {
    idRef.current += 1
    return idRef.current
  }

  const scrollToBottom = useEffectEvent(() => {
    window.scrollTo(0, document.body.scrollHeight)
  })

  const boot = useEffectEvent(() => {
    const width = rootRef.current?.clientWidth ?? window.innerWidth
    const lines = getWelcomeLines(estimateCols(width))
    setEntries([{ id: nextId(), kind: "output", lines }])
    setBooted(true)
  })

  useEffect(() => {
    boot()
  }, [])

  useEffect(() => {
    if (!booted) return
    // Keep the welcome banner (jondoe title) pinned to the top on load and
    // after `clear`; only chase the bottom once there's command output.
    if (entries.length <= 1) return
    scrollToBottom()
  }, [entries, booted])

  useEffect(() => {
    if (!booted) return
    // Preview iframes that steal focus scroll the parent catalog to this
    // section. Only autofocus when this template is the top-level page.
    try {
      if (window.self !== window.top) return
    } catch {
      return
    }
    inputRef.current?.focus({ preventScroll: true })
  }, [booted])

  function focusInput() {
    inputRef.current?.focus({ preventScroll: true })
  }

  function resetScreen() {
    const width = rootRef.current?.clientWidth ?? window.innerWidth
    setEntries([
      {
        id: nextId(),
        kind: "output",
        lines: getWelcomeLines(estimateCols(width)),
      },
    ])
    setInput("")
    setHistoryIndex(null)
    setDraft("")
  }

  function submitCommand(raw: string) {
    const trimmed = raw.trim()
    const prompt = getPromptPrefix()

    if (!trimmed) {
      setEntries((prev) => [
        ...prev,
        {
          id: nextId(),
          kind: "input",
          lines: [
            {
              segments: [
                { type: "text", value: prompt, tone: "default" },
                { type: "text", value: "", tone: "default" },
              ],
            },
          ],
        },
      ])
      setInput("")
      setHistoryIndex(null)
      setDraft("")
      return
    }

    const echo: HistoryEntry = {
      id: nextId(),
      kind: "input",
      lines: [
        {
          segments: [
            { type: "text", value: prompt, tone: "default" },
            { type: "text", value: trimmed, tone: "default" },
          ],
        },
      ],
    }

    setCommandHistory((prev) => pushCommand(prev, trimmed))
    setHistoryIndex(null)
    setDraft("")
    setInput("")

    const result = runCommand(
      trimmed,
      estimateCols(rootRef.current?.clientWidth ?? window.innerWidth)
    )

    if (result.kind === "clear") {
      resetScreen()
      return
    }

    setEntries((prev) =>
      capScrollback([
        ...prev,
        echo,
        ...(result.lines.length > 0
          ? [{ id: nextId(), kind: "output" as const, lines: result.lines }]
          : []),
      ])
    )
  }

  function onSubmit(event: FormEvent) {
    event.preventDefault()
    submitCommand(input)
  }

  function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    if (event.key === "l" && event.ctrlKey) {
      event.preventDefault()
      resetScreen()
      return
    }

    if (event.key === "c" && event.ctrlKey) {
      event.preventDefault()
      setInput("")
      setHistoryIndex(null)
      setDraft("")
      return
    }

    if (event.key === "ArrowUp") {
      event.preventDefault()
      if (commandHistory.length === 0) return
      if (historyIndex === null) {
        setDraft(input)
        const index = commandHistory.length - 1
        setHistoryIndex(index)
        setInput(commandHistory[index] ?? "")
        return
      }
      if (historyIndex > 0) {
        const index = historyIndex - 1
        setHistoryIndex(index)
        setInput(commandHistory[index] ?? "")
      }
      return
    }

    if (event.key === "ArrowDown") {
      event.preventDefault()
      if (historyIndex === null) return
      if (historyIndex < commandHistory.length - 1) {
        const index = historyIndex + 1
        setHistoryIndex(index)
        setInput(commandHistory[index] ?? "")
        return
      }
      setHistoryIndex(null)
      setInput(draft)
    }
  }

  const prompt = getPromptPrefix()

  return (
    <div ref={rootRef} className="pt-root" onClick={focusInput}>
      <div className="pt-output">
        {entries.map((entry) => (
          <div key={entry.id}>
            {entry.lines.map((line, index) => (
              <TerminalLineView key={`${entry.id}-${index}`} line={line} />
            ))}
          </div>
        ))}

        <form
          onSubmit={onSubmit}
          className="pt-line flex items-baseline"
          aria-label="Terminal command"
        >
          <span className="shrink-0">{prompt}</span>
          <div className="relative min-w-0 flex-1">
            <input
              ref={inputRef}
              value={input}
              onChange={(event) => {
                setInput(event.target.value)
                setHistoryIndex(null)
              }}
              onKeyDown={onKeyDown}
              spellCheck={false}
              autoCapitalize="off"
              autoComplete="off"
              autoCorrect="off"
              aria-label="Command"
              className="w-full bg-transparent text-[inherit] caret-transparent outline-none"
            />
            <span
              className="pt-cursor blink pointer-events-none absolute top-0"
              style={{ left: `${input.length}ch` }}
              aria-hidden
            />
          </div>
        </form>
      </div>
    </div>
  )
}
```

### `registry/new-york/templates/portfolio-v4/components/terminal-line.tsx`

```tsx
import { memo } from "react"

import type { LineSegment, TerminalLine } from "../lib/commands"

function segmentClass(tone: LineSegment["tone"]): string {
  switch (tone) {
    case "bold":
      return "pt-bold"
    case "dim":
      return "pt-dim"
    case "cyan":
      return "pt-cyan"
    case "error":
      return "pt-error"
    case "default":
    case undefined:
      return ""
    default: {
      const _exhaustive: never = tone
      return _exhaustive
    }
  }
}

const SegmentView = memo(function SegmentView({
  segment,
}: {
  segment: LineSegment
}) {
  const className = segmentClass(segment.tone)

  if (segment.type === "link") {
    return (
      <a
        href={segment.href}
        target={segment.href.startsWith("mailto:") ? undefined : "_blank"}
        rel={
          segment.href.startsWith("mailto:") ? undefined : "noreferrer noopener"
        }
        className={`pt-link ${className}`.trim()}
      >
        {segment.value}
      </a>
    )
  }

  if (!className) {
    return <>{segment.value}</>
  }

  return <span className={className}>{segment.value}</span>
})

export const TerminalLineView = memo(function TerminalLineView({
  line,
}: {
  line: TerminalLine
}) {
  const empty =
    line.segments.length === 0 ||
    line.segments.every((s) => s.type === "text" && s.value === "")

  if (empty) {
    return <div className="pt-line" aria-hidden />
  }

  return (
    <div className="pt-line">
      {line.segments.map((segment, index) => (
        <SegmentView
          key={`${segment.type}-${index}-${segment.value}`}
          segment={segment}
        />
      ))}
    </div>
  )
})
```

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

```tsx
"use client"

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

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

### `registry/new-york/templates/portfolio-v4/lib/ascii.ts`

```ts
/** FIGlet-style banner — show only when the terminal is wide enough. */
export const ASCII_ART = [
  "     _             ____             ",
  "    | | ___  _ __ |  _ \\  ___   ___ ",
  " _  | |/ _ \\| '_ \\| | | |/ _ \\ / _ \\",
  "| |_| | (_) | | | | |_| | (_) |  __/",
  " \\___/ \\___/|_| |_|____/ \\___/ \\___|",
] as const

/** Skip the banner below this column count. */
export const ASCII_MIN_COLS = 43

export function shouldShowAscii(cols: number): boolean {
  return cols >= ASCII_MIN_COLS
}
```

### `registry/new-york/templates/portfolio-v4/lib/commands.ts`

```ts
import { ASCII_ART, shouldShowAscii } from "./ascii"
import { portfolio } from "./portfolio"

export type LineTone = "default" | "dim" | "bold" | "cyan" | "error"

export type LineSegment =
  | { type: "text"; value: string; tone?: LineTone }
  | { type: "link"; value: string; href: string; tone?: LineTone }

export type TerminalLine = {
  segments: LineSegment[]
}

export type CommandResult =
  | { kind: "output"; lines: TerminalLine[] }
  | { kind: "clear" }

/** Shown on the welcome banner. */
const COMMON_COMMANDS = ["about", "work", "projects", "resume", "help"] as const

/** Discoverable via `ls` (and runnable) — not listed under Commands:. */
const EXTRA_COMMANDS = ["destinations", "events"] as const

const UTILITY_COMMANDS = ["ls", "clear"] as const

const COMMANDS = [
  ...COMMON_COMMANDS,
  ...EXTRA_COMMANDS,
  ...UTILITY_COMMANDS,
] as const

type CommandName = (typeof COMMANDS)[number]

/** Everything `ls` lists — like a home directory of portfolio entries. */
const LS_ENTRIES = [
  "about",
  "destinations",
  "events",
  "help",
  "projects",
  "resume",
  "work",
] as const

function text(value: string, tone: LineTone = "default"): TerminalLine {
  return { segments: [{ type: "text", value, tone }] }
}

function blank(): TerminalLine {
  return { segments: [{ type: "text", value: "", tone: "default" }] }
}

/** Equal gaps between names (navbar-style), wrapping when the row fills. */
function equalGapListing(names: readonly string[], cols: number): TerminalLine[] {
  const gap = "    "
  const gapLen = gap.length
  const lines: TerminalLine[] = []
  let row: string[] = []
  let rowLen = 0

  const flush = () => {
    if (row.length === 0) return
    const segments: LineSegment[] = []
    row.forEach((name, index) => {
      if (index > 0) {
        segments.push({ type: "text", value: gap, tone: "default" })
      }
      segments.push({ type: "text", value: name, tone: "cyan" })
    })
    lines.push({ segments })
    row = []
    rowLen = 0
  }

  for (const name of names) {
    const nextLen = row.length === 0 ? name.length : rowLen + gapLen + name.length
    if (row.length > 0 && nextLen > Math.max(cols, 24)) {
      flush()
    }
    row.push(name)
    rowLen = row.length === 1 ? name.length : rowLen + gapLen + name.length
  }
  flush()

  return lines
}

function parseInput(raw: string): { name: string; args: string[] } {
  const parts = raw.trim().split(/\s+/).filter(Boolean)
  const head = parts[0] ?? ""
  return {
    name: head.replace(/^\//, "").toLowerCase(),
    args: parts.slice(1),
  }
}

function isCommandName(value: string): value is CommandName {
  return (COMMANDS as readonly string[]).includes(value)
}

export function getWelcomeLines(cols: number): TerminalLine[] {
  const lines: TerminalLine[] = [blank()]

  if (shouldShowAscii(cols)) {
    for (const row of ASCII_ART) {
      lines.push(text(row))
    }
  } else {
    lines.push(text(portfolio.name, "bold"))
    lines.push(text("======="))
  }

  lines.push(blank())
  lines.push(text(portfolio.tagline, "bold"))
  lines.push(blank())

  const commonSegments: LineSegment[] = [
    { type: "text", value: "Commands: ", tone: "dim" },
  ]
  COMMON_COMMANDS.forEach((cmd, index) => {
    commonSegments.push({ type: "text", value: cmd, tone: "cyan" })
    if (index < COMMON_COMMANDS.length - 1) {
      commonSegments.push({ type: "text", value: ", ", tone: "dim" })
    }
  })
  lines.push({ segments: commonSegments })

  lines.push({
    segments: [
      { type: "text", value: "Try: ", tone: "dim" },
      { type: "text", value: "ls", tone: "cyan" },
    ],
  })
  lines.push(blank())

  return lines
}

function aboutLines(): TerminalLine[] {
  const lines: TerminalLine[] = [
    text(`${portfolio.name} — ${portfolio.title}`, "bold"),
    text(portfolio.location, "dim"),
    blank(),
    text(portfolio.about.summary),
    blank(),
  ]

  for (const paragraph of portfolio.about.details) {
    lines.push(text(paragraph))
  }

  lines.push(blank())
  lines.push(text("Hobbies", "bold"))
  for (const hobby of portfolio.about.hobbies) {
    lines.push(text(`  · ${hobby}`, "dim"))
  }

  lines.push(blank())
  lines.push({
    segments: [
      { type: "text", value: "Find me on ", tone: "default" },
      ...portfolio.socials.flatMap((social, index) => {
        const parts: LineSegment[] = [
          { type: "link", value: social.label, href: social.href, tone: "cyan" },
        ]
        if (index < portfolio.socials.length - 1) {
          parts.push({ type: "text", value: ", ", tone: "default" })
        }
        return parts
      }),
      { type: "text", value: ", or ", tone: "default" },
      {
        type: "link",
        value: portfolio.email,
        href: `mailto:${portfolio.email}`,
        tone: "cyan",
      },
      { type: "text", value: ".", tone: "default" },
    ],
  })

  return lines
}

function renderJob(job: (typeof portfolio.experience)[number]): TerminalLine[] {
  return [
    {
      segments: [
        { type: "text", value: job.role, tone: "bold" },
        { type: "text", value: " · ", tone: "dim" },
        { type: "link", value: job.company, href: job.href, tone: "cyan" },
      ],
    },
    text(job.dates, "dim"),
    text(job.description),
  ]
}

function workLines(): TerminalLine[] {
  const lines: TerminalLine[] = [text("Work", "bold"), blank()]

  portfolio.experience.forEach((job, index) => {
    lines.push(...renderJob(job))
    if (index < portfolio.experience.length - 1) {
      lines.push(blank())
    }
  })

  lines.push(blank())
  lines.push(text("Internships", "bold"))
  lines.push(blank())

  portfolio.internships.forEach((job, index) => {
    lines.push(...renderJob(job))
    if (index < portfolio.internships.length - 1) {
      lines.push(blank())
    }
  })

  return lines
}

function projectsLines(): TerminalLine[] {
  const lines: TerminalLine[] = [text("Projects", "bold"), blank()]

  portfolio.projects.forEach((project, index) => {
    const number = String(index + 1).padStart(2, "0")
    lines.push({
      segments: [
        { type: "text", value: `${number}  `, tone: "dim" },
        { type: "link", value: project.name, href: project.href, tone: "cyan" },
      ],
    })
    lines.push(text(`    ${project.description}`, "dim"))
    if (index < portfolio.projects.length - 1) {
      lines.push(blank())
    }
  })

  return lines
}

function resumeLines(): TerminalLine[] {
  const lines: TerminalLine[] = [
    text(`${portfolio.name}`, "bold"),
    text(`${portfolio.title} · ${portfolio.location}`, "dim"),
    {
      segments: [
        {
          type: "link",
          value: portfolio.email,
          href: `mailto:${portfolio.email}`,
          tone: "cyan",
        },
      ],
    },
    blank(),
    text(portfolio.about.summary),
    blank(),
    text("Experience", "bold"),
    blank(),
  ]

  for (const job of portfolio.experience) {
    lines.push({
      segments: [
        { type: "text", value: `${job.role}, `, tone: "default" },
        { type: "link", value: job.company, href: job.href, tone: "cyan" },
      ],
    })
    lines.push(text(`  ${job.dates}`, "dim"))
    lines.push(text(`  ${job.description}`, "dim"))
    lines.push(blank())
  }

  lines.push(text("Internships", "bold"))
  lines.push(blank())

  for (const job of portfolio.internships) {
    lines.push({
      segments: [
        { type: "text", value: `${job.role}, `, tone: "default" },
        { type: "link", value: job.company, href: job.href, tone: "cyan" },
      ],
    })
    lines.push(text(`  ${job.dates}`, "dim"))
    lines.push(text(`  ${job.description}`, "dim"))
    lines.push(blank())
  }

  lines.push(text("Skills", "bold"))
  lines.push(blank())

  for (const group of portfolio.skills) {
    lines.push({
      segments: [
        { type: "text", value: group.label, tone: "cyan" },
        { type: "text", value: ` — ${group.description}`, tone: "dim" },
      ],
    })
    lines.push(text(`  ${group.items.join(", ")}`))
    lines.push(blank())
  }

  lines.push(text("Education", "bold"))
  lines.push(blank())

  for (const school of portfolio.education) {
    lines.push({
      segments: [
        { type: "link", value: school.school, href: school.href, tone: "cyan" },
        { type: "text", value: ` — ${school.detail}`, tone: "default" },
      ],
    })
    lines.push(text(`  ${school.dates}`, "dim"))
    lines.push(blank())
  }

  lines.push(text("Certificates", "bold"))
  lines.push(blank())

  for (const cert of portfolio.certificates) {
    lines.push({
      segments: [
        { type: "link", value: cert.name, href: cert.href, tone: "cyan" },
      ],
    })
    lines.push(text(`  ${cert.issuer} · ${cert.year}`, "dim"))
    lines.push(blank())
  }

  if (lines.at(-1)?.segments.every((s) => s.type === "text" && s.value === "")) {
    lines.pop()
  }

  return lines
}

function destinationsLines(): TerminalLine[] {
  const lines: TerminalLine[] = [text("Destinations", "bold"), blank()]

  portfolio.destinations.forEach((spot, index) => {
    lines.push({
      segments: [
        { type: "text", value: spot.place, tone: "cyan" },
        { type: "text", value: `  ${spot.year}`, tone: "dim" },
      ],
    })
    lines.push(text(`  ${spot.note}`))
    if (index < portfolio.destinations.length - 1) {
      lines.push(blank())
    }
  })

  return lines
}

function eventsLines(): TerminalLine[] {
  const lines: TerminalLine[] = [text("Events", "bold"), blank()]

  portfolio.events.forEach((event, index) => {
    lines.push(text(event.name, "cyan"))
    lines.push(text(`  ${event.where}`, "dim"))
    if (index < portfolio.events.length - 1) {
      lines.push(blank())
    }
  })

  return lines
}

function helpLines(): TerminalLine[] {
  return [
    text("Available commands", "bold"),
    blank(),
    {
      segments: [
        { type: "text", value: "  about         ", tone: "cyan" },
        { type: "text", value: "Who I am, hobbies, links", tone: "dim" },
      ],
    },
    {
      segments: [
        { type: "text", value: "  work          ", tone: "cyan" },
        { type: "text", value: "Jobs — title, company, what I did", tone: "dim" },
      ],
    },
    {
      segments: [
        { type: "text", value: "  projects      ", tone: "cyan" },
        { type: "text", value: "Things I've shipped on the side", tone: "dim" },
      ],
    },
    {
      segments: [
        { type: "text", value: "  resume        ", tone: "cyan" },
        { type: "text", value: "Full résumé — experience, skills, school, certs", tone: "dim" },
      ],
    },
    {
      segments: [
        { type: "text", value: "  ls            ", tone: "cyan" },
        { type: "text", value: "List everything in this portfolio", tone: "dim" },
      ],
    },
    {
      segments: [
        { type: "text", value: "  help          ", tone: "cyan" },
        { type: "text", value: "Show this list", tone: "dim" },
      ],
    },
    {
      segments: [
        { type: "text", value: "  clear         ", tone: "cyan" },
        { type: "text", value: "Clear the screen (or Ctrl+L)", tone: "dim" },
      ],
    },
    blank(),
    text("Also on disk (try ls):", "dim"),
    {
      segments: [
        { type: "text", value: "  destinations  ", tone: "cyan" },
        { type: "text", value: "Places I've been", tone: "dim" },
      ],
    },
    {
      segments: [
        { type: "text", value: "  events        ", tone: "cyan" },
        { type: "text", value: "Conferences & meetups", tone: "dim" },
      ],
    },
  ]
}

export function runCommand(raw: string, cols = 80): CommandResult {
  const { name } = parseInput(raw)

  if (!name) {
    return { kind: "output", lines: [] }
  }

  if (!isCommandName(name)) {
    return {
      kind: "output",
      lines: [
        text(`bash: ${name}: command not found`, "error"),
        {
          segments: [
            { type: "text", value: "Type ", tone: "dim" },
            { type: "text", value: "ls", tone: "cyan" },
            { type: "text", value: " or ", tone: "dim" },
            { type: "text", value: "help", tone: "cyan" },
            { type: "text", value: ".", tone: "dim" },
          ],
        },
      ],
    }
  }

  switch (name) {
    case "clear":
      return { kind: "clear" }
    case "ls":
      return { kind: "output", lines: equalGapListing(LS_ENTRIES, cols) }
    case "about":
      return { kind: "output", lines: aboutLines() }
    case "work":
      return { kind: "output", lines: workLines() }
    case "projects":
      return { kind: "output", lines: projectsLines() }
    case "resume":
      return { kind: "output", lines: resumeLines() }
    case "destinations":
      return { kind: "output", lines: destinationsLines() }
    case "events":
      return { kind: "output", lines: eventsLines() }
    case "help":
      return { kind: "output", lines: helpLines() }
    default: {
      const _exhaustive: never = name
      return _exhaustive
    }
  }
}

export function getPromptPrefix(): string {
  return "$ "
}

/** Max commands kept for ArrowUp/ArrowDown recall. */
export const MAX_COMMAND_HISTORY = 100

/**
 * Max output/input entry groups kept in the rendered scrollback. Older groups
 * fall off the top. Large enough that running every command once still shows
 * full output; bounded so a long session can't grow the DOM without limit.
 */
export const MAX_SCROLLBACK_ENTRIES = 60

/** Append `value`, keeping only the newest `MAX_COMMAND_HISTORY`. */
export function pushCommand(history: string[], value: string): string[] {
  const next = [...history, value]
  return next.length > MAX_COMMAND_HISTORY
    ? next.slice(-MAX_COMMAND_HISTORY)
    : next
}

/** Keep only the newest `MAX_SCROLLBACK_ENTRIES` entry groups. */
export function capScrollback<T>(entries: T[]): T[] {
  return entries.length > MAX_SCROLLBACK_ENTRIES
    ? entries.slice(-MAX_SCROLLBACK_ENTRIES)
    : entries
}
```

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

```ts
export type SocialLink = {
  label: string
  href: string
}

export type Project = {
  name: string
  href: string
  description: string
}

export type ExperienceItem = {
  role: string
  company: string
  href: string
  dates: string
  description: string
}

export type EducationItem = {
  school: string
  href: string
  detail: string
  dates: string
}

export type Certificate = {
  name: string
  issuer: string
  href: string
  year: string
}

export type SkillGroup = {
  label: string
  description: string
  items: string[]
}

export type Destination = {
  place: string
  year: string
  note: string
}

export type GlobalEvent = {
  name: string
  where: string
}

export const portfolio = {
  name: "Jon Doe",
  title: "Fullstack Developer",
  promptUser: "jon@portfolio",
  location: "Portland, Oregon",
  email: "jon@example.com",
  tagline: "Welcome to my portfolio. Type a command to look around.",
  about: {
    summary:
      "Hey — I'm Jon. Fullstack developer from Portland, building for AI infrastructure startups and teams shipping real tools with real users.",
    details: [
      "I keep my stack simple, ship in pieces I can trust, and leave software that's easy to maintain.",
      "Outside of work I'm usually cooking the same five meals on repeat, easing back into running after a bad knee, or finishing one more book for the shelf.",
    ],
    hobbies: [
      "Cooking on repeat",
      "Slow-return running",
      "Reading (and finishing) books",
      "Conferences & build nights",
      "Travel when I can steal the time",
    ],
  },
  socials: [
    { label: "X", href: "https://x.com/" },
    { label: "LinkedIn", href: "https://www.linkedin.com/" },
    { label: "GitHub", href: "https://github.com/" },
  ] satisfies SocialLink[],
  experience: [
    {
      role: "Senior Full Stack Engineer",
      company: "Cortexa Labs",
      href: "https://www.linkedin.com/",
      dates: "2024 — Present",
      description:
        "Cortexa Labs builds AI infrastructure so teams can train, serve, and ship models in production.",
    },
    {
      role: "Full Stack Engineer",
      company: "Latentworks",
      href: "https://www.linkedin.com/",
      dates: "2021 — 2024",
      description:
        "Latentworks makes tools for machine learning teams — from experiment tracking to deployment pipelines.",
    },
    {
      role: "Software Engineer",
      company: "Synapse Foundry",
      href: "https://www.linkedin.com/",
      dates: "2018 — 2021",
      description:
        "Synapse Foundry is an early-stage platform helping startups turn prototypes into real products.",
    },
    {
      role: "Junior Developer",
      company: "NeuralArc",
      href: "https://www.linkedin.com/",
      dates: "2015 — 2018",
      description:
        "NeuralArc develops software for applied AI research and the teams putting models into practice.",
    },
  ] satisfies ExperienceItem[],
  internships: [
    {
      role: "Software Engineering Intern",
      company: "Cascade Systems",
      href: "https://www.linkedin.com/",
      dates: "Summer 2014",
      description:
        "Cascade Systems builds developer tools for teams shipping cloud-native web applications.",
    },
    {
      role: "Software Engineering Intern",
      company: "Northline Soft",
      href: "https://www.linkedin.com/",
      dates: "Summer 2013",
      description:
        "Northline Soft creates internal platforms that help mid-size companies modernize legacy software.",
    },
  ] satisfies ExperienceItem[],
  projects: [
    {
      name: "Panfry",
      href: "https://github.com/",
      description: "A small recipe box for the meals I actually cook on repeat.",
    },
    {
      name: "Longmile",
      href: "https://github.com/",
      description: "Tracks my slow, patient return to running after a bad knee.",
    },
    {
      name: "Spinebox",
      href: "https://github.com/",
      description: "A shelf for the books I have actually finished this year.",
    },
    {
      name: "Coinwell",
      href: "https://github.com/",
      description: "A simple, honest look at where my money actually goes.",
    },
  ] satisfies Project[],
  skills: [
    {
      label: "Frontend",
      description: "User Interface Development",
      items: [
        "Next.js",
        "shadcn/ui",
        "Tailwind CSS",
        "WebGL",
        "Motion",
        "GSAP",
        "magic-ui",
        "Next View Transitions",
        "coss",
        "spline",
        "nuqs",
        "Figma",
      ],
    },
    {
      label: "Backend",
      description: "Server-side Development",
      items: [
        "Vercel",
        "Neon",
        "Drizzle",
        "Prisma",
        "Cloudflare R2",
        "Linux",
        "Vercel Blob",
        "Upstash",
        "ngrok",
        "uploadthing",
        "Supabase",
      ],
    },
    {
      label: "Dev Tools",
      description: "Productivity & Workflow",
      items: [
        "Cursor",
        "v0",
        "Git",
        "GitHub",
        "Vercel",
        "VSCode",
        "Notion",
        "Swift",
        "DaVinci Resolve",
        "Affinity",
      ],
    },
    {
      label: "APIs",
      description: "Integration & Connectivity",
      items: [
        "Better Auth",
        "Arcjet",
        "Vercel AI SDK",
        "DeepSeek",
        "NextAuth",
        "xAI",
        "Resend",
        "Claude",
        "GPT",
        "Clerk",
        "polar.sh",
        "Stripe",
      ],
    },
  ] satisfies SkillGroup[],
  education: [
    {
      school: "Georgia Tech",
      href: "https://omscs.gatech.edu/",
      detail: "M.S. Computer Science (OMSCS)",
      dates: "2017 — 2019",
    },
    {
      school: "University of Washington",
      href: "https://www.cs.washington.edu/",
      detail: "B.S. Computer Science",
      dates: "2010 — 2014",
    },
  ] satisfies EducationItem[],
  certificates: [
    {
      name: "AWS Certified Developer — Associate",
      issuer: "Amazon Web Services",
      href: "https://aws.amazon.com/certification/",
      year: "2023",
    },
    {
      name: "Professional Scrum Master I (PSM I)",
      issuer: "Scrum.org",
      href: "https://www.scrum.org/",
      year: "2021",
    },
    {
      name: "Meta Front-End Developer Certificate",
      issuer: "Meta / Coursera",
      href: "https://www.coursera.org/",
      year: "2020",
    },
  ] satisfies Certificate[],
  destinations: [
    {
      place: "Japan",
      year: "2014",
      note: "Tokyo",
    },
    {
      place: "Italy",
      year: "2019",
      note: "Rome",
    },
    {
      place: "Greece",
      year: "2023",
      note: "Athens",
    },
  ] satisfies Destination[],
  events: [
    {
      name: "Vercel Ship 2024",
      where: "New York · May 23, 2024",
    },
    {
      name: "CES 2025 — NVIDIA keynote",
      where: "Las Vegas · Jan 6, 2025",
    },
    {
      name: "Vercel Ship 2025",
      where: "New York · June 25, 2025",
    },
    {
      name: "Compile 26",
      where: "Fort Mason, San Francisco · Jun 22, 2026",
    },
    {
      name: "Grok Bot Founder Build Night",
      where: "Cursor HQ, San Francisco · Aug 20, 2026",
    },
    {
      name: "Cursor Conversations London",
      where: "Central London · Sep 15, 2026",
    },
    {
      name: "Vercel Ship 2026",
      where: "Palace of Fine Arts, San Francisco · Oct 15, 2026",
    },
  ] satisfies GlobalEvent[],
} as const
```

### `registry/new-york/templates/portfolio-v4/styles/terminal.css`

```css
/* Portfolio v4 terminal — cyan accent from vercel-docs-theme */

.pt-root {
  /* Light */
  --pt-bg: #fff;
  --pt-fg: #171717;
  --pt-cursor: #000;
  --pt-cyan: #0070f3;
  --pt-bright-cyan: #3291ff;
  --pt-bright-black: #6b7280;
  --pt-red: #cd0000;

  box-sizing: border-box;
  position: relative;
  min-height: 100dvh;
  max-width: 100%;
  margin: 0;
  padding-top: env(safe-area-inset-top, 0px);
  padding-right: 16px;
  padding-bottom: calc(50vh + env(safe-area-inset-bottom, 0px));
  padding-left: 16px;
  overflow-x: hidden;
  overflow-wrap: break-word;
  background-color: var(--pt-bg);
  color: var(--pt-fg);
  font-family: var(--font-geist-mono), "Geist Mono", "SF Mono", Menlo, monospace;
  font-size: 15px;
  line-height: 1.2;
  letter-spacing: 0;
  outline: none;
}

.dark .pt-root,
html.dark .pt-root {
  /* Dark */
  --pt-bg: #000;
  --pt-fg: #eeeeee;
  --pt-cursor: #fff;
  --pt-cyan: #47a8ff;
  --pt-bright-cyan: #79c0ff;
  --pt-bright-black: #a0a0a0;
  --pt-red: #ff0000;
}

@media (prefers-color-scheme: dark) {
  :root:not(.light) .pt-root {
    --pt-bg: #000;
    --pt-fg: #eeeeee;
    --pt-cursor: #fff;
    --pt-cyan: #47a8ff;
    --pt-bright-cyan: #79c0ff;
    --pt-bright-black: #a0a0a0;
    --pt-red: #ff0000;
  }
}

.pt-root ::selection {
  background: color-mix(in srgb, var(--pt-cyan) 35%, transparent);
  color: var(--pt-fg);
}

.pt-line {
  white-space: pre-wrap;
  word-break: break-all;
  min-height: 1.2em;
}

.pt-bold {
  font-weight: bold;
}

.pt-dim {
  opacity: 0.6;
}

.pt-cyan {
  color: var(--pt-cyan);
}

.pt-error {
  color: var(--pt-red);
}

.pt-link {
  color: var(--pt-cyan);
  text-decoration: underline;
  cursor: pointer;
}

.pt-link:hover {
  color: var(--pt-bright-cyan);
}

.pt-cursor {
  display: inline-block;
  width: 0.6em;
  height: 1.2em;
  margin-left: 0;
  vertical-align: text-bottom;
  background-color: var(--pt-cursor);
  pointer-events: none;
  opacity: 1;
}

.pt-cursor.blink {
  animation: pt-blink 1s step-end infinite;
}

@keyframes pt-blink {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .pt-cursor.blink {
    animation: none;
    opacity: 1;
  }
}
```
