COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
BRUBKR

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

2026-01-22 // RAW LEARNING CAPTURE
PROJECTBRUBKR

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:

  1. 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%)
  2. Packet Animation Canvas - Full-viewport canvas with 12 Packet instances moving along grid-aligned paths
  3. 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

RouteComponent TypeComplexity
/ (Dashboard)Server Component (uses Link, no hooks)Medium - hero bio, status cards, featured work, social grid
/projectsServer ComponentMedium - 8 project cards, corner markers, grid placeholders, tags
/stackServer ComponentLow-Medium - StackItem component, 3-column grid, status badges
/artClient 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

  1. 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.

  2. noUncheckedIndexedAccess is strict but worthwhile - Forces explicit handling of array bounds. The ?? fallback pattern is mechanical but prevents real runtime errors when canvas dimensions are 0 during init.

  3. next/font/google + Tailwind v4 @theme inline bridge - The font CSS variables from next/font need explicit mapping in the @theme block to become Tailwind utilities. Without this, font-sans still uses the system stack.

  4. Fixed background + relative content z-layering - Clean separation of decorative canvas from interactive content. The pointer-events-none on Blueprint is critical.

  5. 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 composition
  • src/components/blueprint.tsx - New: Packet animation canvas + SVG schematic
  • src/components/navigation.tsx - New: Next.js Link + usePathname active detection
  • src/app/page.tsx - Dashboard: hero bio, status cards, featured work, social links
  • src/app/projects/page.tsx - 8 project cards with corner markers and tags
  • src/app/stack/page.tsx - 3-column tech inventory with StackItem component
  • src/app/art/page.tsx - Flow field canvas: Noise class, particles, two layout modes
LOG.ENTRY_END
ref:brubkr
RAW