- 01.What We Did
- 02.CSS: shadcn OKLCH to Dark Hex with Variable Preservation
- 03.Font Swap: Geist to Inter/JetBrains via next/font/google
- 04.Blueprint Canvas Architecture
- 05.Packet Class - TypeScript Strict Adaptation
- 06.Noise Class - Strict Mode Perlin/Simplex
- 07.Layout Composition: Fixed Background + Relative Content
- 08.Views Ported
- 09.Build Output
- 10.Key Learnings
- 11.Files Changed
Design System & Views Port: Vite SPA to Next.js 16
Ported the entire design system and all view pages from the Vite SPA into the Next.js 16 app. The template started with shadcn/ui defaults (OKLCH light/dark theme, Geist fonts) and needed a complete v
Commit: 52255a5
Context: Continuing migration of design-engineer-dark-mode-blueprint (Vite + React SPA) into brubkr (Next.js 16 App Router). MDX pipeline was already working from previous session.
What We Did
Ported the entire design system and all view pages from the Vite SPA into the Next.js 16 app. The template started with shadcn/ui defaults (OKLCH light/dark theme, Geist fonts) and needed a complete visual overhaul to match the dark-first blueprint aesthetic.
CSS: shadcn OKLCH to Dark Hex with Variable Preservation
The key insight: keep the CSS variable indirection pattern but swap the values. The shadcn/ui template uses variables like --background, --card, --primary etc., and its components reference these via Tailwind's bg-background, text-foreground utilities. Rather than ripping this out, we mapped the variables to our dark palette:
/* Before (shadcn default - OKLCH with light/dark variants) */
:root {
--background: oklch(1 0 0); /* white */
--card: oklch(1 0 0); /* white */
--primary: oklch(0.145 0 0); /* near-black */
}
.dark {
--background: oklch(0.145 0 0); /* near-black */
}
/* After (dark-first hex, no .dark class needed) */
:root {
--background: #050505;
--foreground: #f5f5f5;
--card: #0a0a0a;
--card-foreground: #f5f5f5;
--secondary: #1a1a1a;
--muted: #1a1a1a;
--muted-foreground: #666666;
--accent: #333333;
--border: rgba(255, 255, 255, 0.1);
--input: rgba(255, 255, 255, 0.15);
--ring: rgba(255, 255, 255, 0.2);
}This means shadcn/ui components (Badge, Button, Card) still render correctly -- they reference bg-card, text-foreground etc., which now resolve to dark values. No component code changes needed.
Added a custom --color-text-muted: #666666 mapped through Tailwind's @theme inline block for the design system's secondary text color (text-text-muted utility).
Also added: custom scrollbar styles, .vertical-text utility, .bg-noise fixed overlay (SVG feTurbulence at 3.5% opacity), .no-scrollbar/.custom-scrollbar utilities, and a scan keyframe for status card animations.
Font Swap: Geist to Inter/JetBrains via next/font/google
// layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
const inter = Inter({
variable: '--font-inter',
subsets: ['latin'],
weight: ['300', '400', '500', '600'],
});
const jetbrainsMono = JetBrains_Mono({
variable: '--font-jetbrains-mono',
subsets: ['latin'],
weight: ['400', '500'],
});
// Applied as className on <body>
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>In globals.css, the @theme inline block maps:
--font-sans: var(--font-inter);
--font-mono: var(--font-jetbrains-mono);This bridges next/font's CSS variable injection with Tailwind v4's font theme. font-sans and font-mono utilities now use Inter and JetBrains Mono respectively.
Blueprint Canvas Architecture
The Blueprint component (src/components/blueprint.tsx) is a 'use client' component with three visual layers:
- CSS Grid Background - Two nested divs with
background-image: linear-gradient()creating 32px primary and 8px secondary grids at very low opacity (2% and 1%) - Packet Animation Canvas - Full-viewport canvas with 12
Packetinstances moving along grid-aligned paths - SVG Schematic - Decorative concentric circles, bezier paths, crosshairs, and corner brackets
Packet Class - TypeScript Strict Adaptation
The original Vite version closed over width/height from the useEffect scope. For TypeScript strict mode, these became instance properties:
class Packet {
width: number; // Instance property, updated on resize
height: number;
x: number;
// ...
constructor(width: number, height: number) {
this.width = width;
this.height = height;
// ... init other properties
this.reset(); // Safe because width/height are set
}
reset() {
// Uses this.width/this.height instead of closure vars
const safeWidth = Math.max(this.width, GRID_SIZE);
const safeHeight = Math.max(this.height, GRID_SIZE);
this.x = Math.floor(Math.random() * (safeWidth / GRID_SIZE)) * GRID_SIZE;
// ...
}
}The noUncheckedIndexedAccess rule required null-coalescing on all array access:
this.id = HEX_CODES[Math.floor(Math.random() * HEX_CODES.length)] ?? '0x00';
// In draw():
const first = this.trail[0];
if (first) ctx.moveTo(first.x, first.y);
for (let i = 1; i < this.trail.length; i++) {
const pt = this.trail[i];
if (pt) ctx.lineTo(pt.x, pt.y);
}The resize handler recreates all packets with fresh dimensions (DPR-aware sizing via ctx.setTransform).
Noise Class - Strict Mode Perlin/Simplex
The Art page's Noise class hit the same noUncheckedIndexedAccess issues more aggressively since it does nested array lookups for gradient permutation:
class Noise {
grad3: number[][];
perm: number[];
gradP: number[];
constructor(seed: number) {
// Build permutation table with seeded PRNG (mulberry32)
for (let i = 0; i < 512; i++) {
this.perm[i] = p[i & 255] ?? 0; // ?? 0 for strict
this.gradP[i] = (this.perm[i] ?? 0) % 12; // nested access
}
}
get(x: number, y: number) {
// Triple-nested: gradP[ii + perm[jj]] -> grad3[gi0]
const gi0 = this.gradP[ii + (this.perm[jj] ?? 0)] ?? 0;
// ...
n0 = t0 * t0 * t0 * t0 * this.dot(this.grad3[gi0] ?? [0, 0, 0], x0, y0);
}
}Every bracket access gets ?? defaultValue. Without this, TypeScript errors on ~15 lines because each array[index] returns T | undefined.
Layout Composition: Fixed Background + Relative Content
// layout.tsx
<body>
<Blueprint /> {/* fixed inset-0 z-0 */}
<div className="relative z-10 min-h-screen">
<div className="max-w-5xl mx-auto px-6 pt-12">
<Navigation />
<main>{children}</main>
</div>
</div>
</body>The Blueprint sits at z-0 with pointer-events-none so it never intercepts clicks. Content overlays at z-10. The noise texture overlay sits at z-50 above everything but also has pointer-events-none.
Navigation uses usePathname() for active route detection with exact match for / and prefix match for other routes.
Views Ported
| Route | Component Type | Complexity |
|---|---|---|
/ (Dashboard) | Server Component (uses Link, no hooks) | Medium - hero bio, status cards, featured work, social grid |
/projects | Server Component | Medium - 8 project cards, corner markers, grid placeholders, tags |
/stack | Server Component | Low-Medium - StackItem component, 3-column grid, status badges |
/art | Client Component ('use client') | High - ~400 lines, Noise class, particle system, ResizeObserver, rAF loop, two layout modes |
The Art page supports two layout modes:
- Workstation: Sidebar panel with full controls (seed, palette, physics sliders) + canvas
- Theater: Full-width canvas with collapsed HUD bar at bottom (compact controls)
Build Output
All 17 pages generated statically on first build attempt:
Route (app) Size First Load JS
+ First Load JS shared by all 103 kB
├ /_not-found 975 B 104 kB
├ / 137 B 103 kB
├ /art 5.81 kB 109 kB
├ /posts 137 B 103 kB
├ /posts/[slug] 316 B 103 kB
├ /projects 137 B 103 kB
├ /stack 137 B 103 kB
+ 10 more blog post routes...
The Art page is the only client-heavy route (5.81 kB first-load JS) due to the canvas/noise/particle system. Everything else ships near-zero client JS since they're server components.
Key Learnings
-
shadcn variable pattern is a good abstraction boundary - You can completely swap the visual theme (OKLCH light to hex dark) without touching any component code. The variables act as a contract between the design system and the component library.
-
noUncheckedIndexedAccessis strict but worthwhile - Forces explicit handling of array bounds. The?? fallbackpattern is mechanical but prevents real runtime errors when canvas dimensions are 0 during init. -
next/font/google + Tailwind v4
@theme inlinebridge - The font CSS variables from next/font need explicit mapping in the@themeblock to become Tailwind utilities. Without this,font-sansstill uses the system stack. -
Fixed background + relative content z-layering - Clean separation of decorative canvas from interactive content. The
pointer-events-noneon Blueprint is critical. -
Class instances in useEffect - Moving closed-over dimensions into class instance properties is cleaner for TypeScript strict mode than fighting with the closure. The resize handler just recreates all instances.
Files Changed
src/app/globals.css- Complete replacement (shadcn OKLCH -> dark hex palette)src/app/layout.tsx- Inter/JetBrains fonts, Blueprint + Navigation compositionsrc/components/blueprint.tsx- New: Packet animation canvas + SVG schematicsrc/components/navigation.tsx- New: Next.js Link + usePathname active detectionsrc/app/page.tsx- Dashboard: hero bio, status cards, featured work, social linkssrc/app/projects/page.tsx- 8 project cards with corner markers and tagssrc/app/stack/page.tsx- 3-column tech inventory with StackItem componentsrc/app/art/page.tsx- Flow field canvas: Noise class, particles, two layout modes