- 01.Phase 1: MDX Trace
- 02.Phase 2: Shiki + Tailwind Prose Conflict
- 03.Phase 3: Blueprint Background Dimming
- 04.Phase 4: Server Component Refactor
- 05.Audit
- 06.Key Insight: Layouts Don't Re-render
- 07.Blueprint Split
- 08.Build Fix: noPropertyAccessFromIndexSignature
- 09.Result
- 10.Technical Learnings
Shiki Conflicts, Blueprint Dimming, and Server Component Refactor
Created `.traces/mdx.md` documenting the full MDX pipeline — remark plugins, viz components (`Mermaid`, `D3Viz`, `MathBlock`), the typography component map, and the escape-curlies system that prevents
Commits: cff1632, 4b0b31d, plus server component refactor
Files touched: src/components/mdx/typography.tsx, src/components/blueprint.tsx, src/components/blueprint-canvas.tsx, src/app/posts/[slug]/page.tsx, src/components/dashboard/projects-grid.tsx, src/lib/projects.ts, .traces/mdx.md
Phase 1: MDX Trace
Created .traces/mdx.md documenting the full MDX pipeline — remark plugins, viz components (Mermaid, D3Viz, MathBlock), the typography component map, and the escape-curlies system that prevents MDX from interpreting { in code blocks.
Phase 2: Shiki + Tailwind Prose Conflict
Investigated whether @tailwindcss/typography prose styles conflict with Shiki themes. Turned out to be a red herring — no @tailwindcss/typography plugin is used. The prose-dark class in the codebase is custom CSS for section/figure counter numbering only.
Actual bug: The Pre component in typography.tsx spreads {...props} AFTER setting its own className. Shiki's rehype plugin generates:
<pre style="background-color: #1f1f1f" class="shiki min-dark">Those attributes arrive as props and overwrite the component's own classes because spread comes last:
// BEFORE (broken) — Shiki's className/style overwrites ours
export function Pre({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
return (
<pre className="overflow-x-auto font-mono text-sm" {...props}>
{children}
</pre>
);
}Fix: Destructure style and className out of props so they never reach the DOM element:
// AFTER (cff1632) — Shiki's style/className discarded
export function Pre({
children,
style: _style,
className: _className,
...props
}: React.HTMLAttributes<HTMLPreElement>) {
return (
<pre className="overflow-x-auto font-mono text-sm" {...props}>
{children}
</pre>
);
}Lesson: Shiki's rehype plugin puts inline style on <pre>. CSS class specificity can't win against inline styles. The only fix is to prevent those attributes from spreading onto the element.
Phase 3: Blueprint Background Dimming
Goal: Make the animated Blueprint background less visually distracting for reading long-form content.
Attempt 1: Content backdrop panel with bg-[#050505]/80 rounded-2xl border border-white/[0.03]. User hated it — "too boxy", breaks the open feel.
Attempt 2: CSS mask-image with radial gradient making center fully transparent:
mask-image: radial-gradient(ellipse 60% 70% at 50% 45%, transparent 0%, black 100%)Failed — page background IS black, so transparent center just shows black. Blueprint disappears entirely in reading zone.
Attempt 3 (shipped): Mask with reduced opacity via rgba instead of full transparency:
mask-image: radial-gradient(
ellipse 60% 70% at 50% 45%,
rgba(0,0,0,0.12) 0%,
rgba(0,0,0,0.2) 40%,
rgba(0,0,0,0.6) 70%,
black 100%
)rgba(0,0,0,0.12) in the mask means the Blueprint renders at 12% opacity in the center — still visible, still alive, but not competing with text.
Also removed a leftover <div className="bg-[#050505]/80 ..."> wrapper in src/app/posts/[slug]/page.tsx from Attempt 1 that was still darkening the blog post content.
Committed as 4b0b31d.
Lesson: CSS mask with rgba(0,0,0,N) where N < 1 dims content to N opacity without removing it. transparent (N=0) hides completely. This is better than opacity because mask-image can be shaped (ellipse, gradient stops).
Phase 4: Server Component Refactor
Goal: Reduce client component surface area. Principle: client boundaries should be "narrow and low in the tree."
Audit
Found 11 'use client' files. Only 3 were refactorable:
| File | Why 'use client' | Refactorable? |
|---|---|---|
table.tsx | No reason (no hooks, no browser APIs) | Yes — just remove directive |
header.tsx | usePathname() for route-aware content | Yes — per-page pattern |
blueprint.tsx | 450 lines, but only canvas needs client | Yes — split static/animated |
navigation.tsx | usePathname() for active link highlight | No — idiomatic pattern |
| Others (7 files) | Genuine interactivity needs | No |
Key Insight: Layouts Don't Re-render
Next.js App Router layouts are static — they don't re-render on client-side navigation. usePathname() inside a client component IS the idiomatic pattern for route-aware UI in layouts. Converting navigation.tsx to a server component would require middleware to inject the current path, and would break client-side nav updates.
However, header.tsx CAN be eliminated by having each page render its own <PageHeader> server component instead of one layout-level client component that maps pathname → content.
Blueprint Split
Separated the 450-line blueprint.tsx into:
blueprint.tsx (server component): Static grids, SVG schematic elements, structural layout, grain texture overlay — everything that doesn't move.
blueprint-canvas.tsx (client component): Only the animated canvas — Packet class, useRef, useEffect, requestAnimationFrame loop. Narrow, leaf-level, minimal JS shipped.
// Layout tree: before
<Layout> ← server
<Blueprint /> ← CLIENT (450 lines, all of it)
<Navigation /> ← CLIENT (usePathname)
<Header /> ← CLIENT (usePathname)
{children} ← server
// Layout tree: after
<Layout> ← server
<Backdrop> ← server (static grids, SVG)
<BlueprintCanvas /> ← CLIENT (only animated canvas, ~80 lines)
</Backdrop>
<Navigation /> ← CLIENT (usePathname — idiomatic, kept)
<PageHeader /> ← server (each page declares its own)
{children} ← server
Build Fix: noPropertyAccessFromIndexSignature
Pre-existing TypeScript errors in src/lib/projects.ts blocked the build. With noPropertyAccessFromIndexSignature enabled, Record<string, unknown> requires bracket notation:
// ERROR: Property 'OR' comes from an index signature, so it must be accessed with ['OR']
where.OR = [...]
where.language = language
// FIX:
where['OR'] = [...]
where['language'] = languageResult
Layout-level client components: 3 → 1 (Navigation) + 1 narrow leaf (BlueprintCanvas). Header eliminated from layout entirely. Table directive removed (was never needed).
Technical Learnings
-
Shiki inline styles: rehype-pretty-code puts
style="background-color: ..."on<pre>. No CSS class can override inline styles. Destructure style/className out of props in your component map. -
CSS mask opacity trick:
mask-imagewithrgba(0,0,0,0.12)dims to 12% opacity. Unlikeopacityproperty, mask-image can be shaped with gradients for spatial control. -
App Router layout statics: Layouts never re-render on navigation.
usePathname()in client components is correct for route-aware UI. Don't fight it. -
Per-page header pattern: Instead of one client Header that switches on pathname, each page renders a server
<PageHeader>with explicit props. Trades one layout client component for zero. -
TypeScript strict bracket notation:
noPropertyAccessFromIndexSignature+Record<string, unknown>= must useobj['key']notobj.key. Catches potential typos at compile time.