Skip to content

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

brandonshutter.com

Personal site for Brandon Shutter — Senior Solutions Architect, cloud governance and FinOps. Built on Next.js 16 with a WebGL flow shader, Lenis smooth scroll, and a strict black-and-white design system.

Originally a purchased portfolio template, since rebuilt around a different shape: employment, companies, and open-source are three separate lanes rather than one project grid.

Stack

Framework Next.js 16 (App Router, Turbopack)
Language TypeScript 7 (strict)
Styling Tailwind CSS v4, token-driven
Package manager Bun
Linting oxlint with type-aware rules
Motion motion/react, Lenis, Matter.js
Graphics OGL (raw WebGL)

TypeScript 7 and oxlint are coupled: typescript-eslint does not support the TS 7 API, and oxlint's type-aware mode requires TS 7 because it runs on typescript-go. That pairing is why there is no ESLint here.

Getting started

bun install
bun run dev

Open http://localhost:3000.

React Bits Pro registry

components.json wires two license-authenticated shadcn registries, @reactbits-starter (135 animated components) and @reactbits-pro (marketing blocks, App UI blocks, Agent Kit). Both read REACTBITS_LICENSE_KEY from .env.local at the repo root, which is gitignored and therefore not present in a fresh clone — without it the CLI fails with 401 Unauthorized: License key required. Copy the key from the Polar customer portal, or from another checkout:

echo 'REACTBITS_LICENSE_KEY=<key>' > .env.local
bunx shadcn@latest add @reactbits-starter/<slug>-tw  # component, -tw/-css suffix required
bunx shadcn@latest add @reactbits-pro/<slug>         # any block, no suffix

.claude/skills/react-bits-pro/SKILL.md is the vendored usage guide (catalogs, export conventions, harmonization rules). It is excluded from Prettier so it stays byte-identical to the registry copy.

Scripts

Command Description
bun run dev Start development server
bun run build Build for production
bun run start Start production server
bun run lint Run oxlint, type-aware
bun run lint:fix Fix oxlint errors
bun run format Format with Prettier
bun run format:check Check formatting
bun run typecheck Run tsc --noEmit
bun run sync:resume Copy the built resume PDF/DOCX into public/

Editing content

All copy lives in lib/content/. Nothing user-facing is hardcoded in a component, so adding a role or a project never means touching JSX.

File Holds
site.ts Name, role, location, email, GitHub profiles, portrait paths, hero headline
work.ts Employment history — POSITIONS for the detailed entries, EARLIER for the collapsed list
ventures.ts Companies, and the named client work nested under them
lab.ts Open-source and internal tooling
about.ts Bio paragraphs, certifications, skills, stack chips

Two conventions worth knowing:

  • Bio paragraphs support **bold** via components/ui/rich-text.tsx. It is a deliberate ten-line parser, not a markdown dependency.
  • Only link to destinations verified public. lab.ts has a url field left unset for anything private, and the cards render fine without it. Check before adding one:
    curl -s -o /dev/null -w "%{http_code}\n" https://github.com/OWNER/REPO

Résumé

The PDF and DOCX in public/ are generated from resume.html in the shutter-linkedin-resume repo, which shares this site's structure and typography. After rebuilding it there, run bun run sync:resume rather than editing anything in public/ by hand. Both are linked from the footer.

Portrait

site.portrait takes two frames. Pointing hover at a second shot gives a true WebGL morph; leaving it equal to base gives a displacement ripple on a single photo, which is the current setup.

The hero renders the portrait twice on purpose: once as a server-rendered next/image with priority, so it lands in the initial HTML with a preload hint, and again as the deferred WebGL canvas that fades in over it.

Routes

/           Hero, active ventures, selected tooling
/work       Employment history, expandable earlier roles
/ventures   Companies founded and led, plus client work
/lab        Open-source and internal tooling
/about      Background, certifications, stack, profiles

Generated alongside them: sitemap.xml, robots.txt, manifest.webmanifest, opengraph-image, and llms.txt.

Project structure

├── app/
│   ├── work/ ventures/ lab/ about/  # Routes
│   ├── llms.txt/route.ts            # Site summary for language models
│   ├── opengraph-image.tsx          # Social card, generated at build
│   ├── manifest.ts                  # Web app manifest
│   ├── robots.ts  sitemap.ts        # Generated from lib/content
│   ├── globals.css                  # Design tokens, site frame
│   └── layout.tsx                   # Providers, nav, backdrop
├── components/
│   ├── about/                       # Certifications, skills, stack, profiles
│   ├── contact/                     # Copy-to-clipboard email, shader card
│   ├── hero/                        # Hero, CTAs, portrait morph (+ lazy wrapper)
│   ├── lab/  ventures/  work/       # Cards for each lane
│   ├── layout/                      # Nav, backdrop, providers, smooth scroll
│   ├── shaders/                     # WebGL flow shader (+ lazy wrapper)
│   └── ui/                          # Shared primitives
├── lib/
│   ├── content/                     # All site copy
│   └── metadata.ts                  # SEO metadata helpers
└── public/                          # Portrait, social icons, resume PDF/DOCX

Performance

Measured, not assumed. Initial JS is 236 KB gzipped across 11 chunks.

  • The WebGL chunk is deferred. OGL is ~61 KB gzipped for purely decorative surfaces, so the shader and portrait canvas load through next/dynamic client wrappers rather than the critical path. Worth 16 KB off the initial bundle, and it takes the canvas off the paint path entirely.
  • The portrait is server-rendered with priority, so it is in the HTML with a preload hint instead of waiting on JavaScript.
  • Matter.js is dynamically imported inside the stack section.
  • Shaders pause offscreen via IntersectionObserver and on tab hide via visibilitychange, cap DPR to bound retina cost, and release their context on unmount with WEBGL_lose_context.
  • The page-backdrop fade is baked into the fragment shader — one opaque draw, no mask layers.
  • console.* is stripped from production builds, except error and warn.
  • Lenis runs on a single rAF loop.

To re-measure after a change:

bun run build && bun run start &
curl -s http://localhost:3000/ | grep -oE '/_next/static/chunks/[^"]+\.js' | sort -u \
  | while read -r u; do gzip -c ".next${u#/_next}" | wc -c; done \
  | awk '{t+=$1} END {printf "initial JS: %.0f KB gzip\n", t/1024}'

SEO and machine readability

  • llms.txt follows the llmstxt.org convention — an H1, a blockquote summary, then annotated links. It is generated from the same content modules the pages render from, because a hand-maintained summary goes stale and a confidently wrong date is worse than none.
  • opengraph-image.tsx generates the social card at build time from lib/content. Metadata previously pointed at an og-image.png that was never in the repo.
  • robots.txt disallows nothing, deliberately. Every route here is meant to be indexed.
  • Icons, the manifest, and the social image all rely on Next's file conventions in app/. Declaring them in metadata.ts as well is how the repo ended up linking to three files that did not exist.

Design system

Colors — --background / --foreground, --muted / --muted-foreground, --border, --ring, --frame. Strict black and white; no accent hues.

Typography — Geist Sans, Geist Mono, and Fraunces for display headlines.

Layout — Headlines cap at two lines. The site frame is fixed top/left/right rails with two corner cutouts, hidden below 850px. No backdrop-blur except the experience collapse fade.

Tuning the shader

components/shaders/shader-flow.tsx backs both the page backdrop and the contact card. All knobs are props: colorLowA / colorHighA (linear RGB stops), flowSpeed, iterations (up to 24), scale, brightness, and fadeCx / fadeCy / fadeRx / fadeRy for the aspect-correct circular fade. The fade reads --background from CSS and picks up theme changes through a MutationObserver on <html>.

Accessibility

Skip-to-content link, visible focus rings, ARIA labels on toggles and social links, one <h1> per page, and WCAG 2.1 AA contrast in both themes. prefers-reduced-motion skips the theme-toggle view transition. The hero's WebGL layer is aria-hidden because the next/image beneath it already carries the alt text.

Deployment

Deploys to Vercel. Set site.url in lib/content/site.ts to the production origin — the sitemap, robots, and OpenGraph tags all build absolute URLs from it.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages