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.
| 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.
bun install
bun run devOpen http://localhost:3000.
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.localbunx 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.
| 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/ |
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**viacomponents/ui/rich-text.tsx. It is a deliberate ten-line parser, not a markdown dependency. - Only link to destinations verified public.
lab.tshas aurlfield 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
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.
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.
/ 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, profilesGenerated alongside them: sitemap.xml, robots.txt, manifest.webmanifest, opengraph-image, and llms.txt.
├── 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/DOCXMeasured, 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/dynamicclient 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
IntersectionObserverand on tab hide viavisibilitychange, cap DPR to bound retina cost, and release their context on unmount withWEBGL_lose_context. - The page-backdrop fade is baked into the fragment shader — one opaque draw, no mask layers.
console.*is stripped from production builds, excepterrorandwarn.- 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}'llms.txtfollows 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.tsxgenerates the social card at build time fromlib/content. Metadata previously pointed at anog-image.pngthat was never in the repo.robots.txtdisallows 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 inmetadata.tsas well is how the repo ended up linking to three files that did not exist.
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.
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>.
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.
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.