Outline · 7 sections
Most portfolio sites are a hero section, a grid of cards, and a footer. Mine is a window. The idea was simple: if the thing I spend my day inside is an editor, why should the site that describes my work look like something else? So the portfolio is dressed as a code editor — title bar, activity bar, explorer, tabs, an editor pane and a status bar — and every page opens as a file.
This post is about the parts that were actually interesting to build.
The shell
The whole visual frame lives in the root layout, and it is almost entirely
flex with overflow-hidden set deliberately. Only the editor area scrolls:
// app/layout.js
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className="h-screen overflow-hidden bg-editor font-sans text-fg">
<div className="flex h-full flex-col">
<TitleBar />
<div className="flex min-h-0 flex-1">
<SidePanel />
<div className="flex min-w-0 flex-1 flex-col">
<EditorTabs />
<main className="min-h-0 flex-1 overflow-y-auto">{children}</main>
</div>
</div>
<StatusBar />
</div>
</body>
</html>
);
}The min-h-0 on the middle row and on <main> is the detail that makes the
layout behave. Flex items default to min-height: auto, which means a tall
page pushes the status bar off the bottom of the screen instead of scrolling
inside its own pane. Every scroll container in the shell has it.
Navigation is a tab bar
Each route is a tab with a filename, a file-type icon and an accent colour:
home.jsx, about.jsx, projects.jsx, and now blog.mdx. The tab row is the
primary navigation, and the active tab is styled the way an open document is —
a coloured top border and the editor background:
// components/vscode/EditorTabs.jsx
const active = pathname === tab.path;
<Link
href={tab.path}
className={`flex items-center gap-2 border-r border-chrome px-4 py-2 ${
active
? "border-t-2 border-t-sky-400 bg-editor text-fg"
: "border-t-2 border-t-transparent bg-tab-inactive text-fg-muted hover:bg-hover"
}`}
>
<FileIcon className={`h-4 w-4 ${tab.accent}`} />
<span>{tab.name}</span>
</Link>Because the tab bar has a fixed height and the list of tabs is data, adding a page is a two-line change: create the route, add an entry to the tab array.
Every page is an open file
A page does not render directly into <main>. It renders an EditorFile,
which splits its own area into a live preview on top and a line-numbered code
pane underneath, with a divider that can be dragged:
export default function Projects() {
return (
<EditorFile
filename="projects.jsx"
language="JavaScript JSX"
previewLabel="Projects"
preview={<>…the real page content…</>}
>
{/* the "source code" shown in the code pane */}
</EditorFile>
);
}The preview is passed as a prop rather than as children. That is not cosmetic — it is the fix for a bug worth remembering.
The same rule shapes the code pane. Syntax highlighting is a handful of plain functions — no hooks, no state — so they stay server-side and the pages call them directly:
// components/vscode/syntax.js
export const kw = (children) => <span className="text-[#c586c0]">{children}</span>;
export const fn = (children) => <span className="text-[#dcdcaa]">{children}</span>;
export const str = (children) => <span className="text-[#ce9178]">{children}</span>;One set of design tokens
The theme is a Tailwind v4 @theme block mapped onto CSS variables, so the
same names work in class names and in hand-written CSS. Naming them after the
editor they imitate keeps the palette honest:
/* app/globals.css */
:root {
--vscode-editor: #1e1e1e;
--vscode-editor-soft: #252526;
--vscode-border: #3c3c3c;
--vscode-border-strong: #454545;
--vscode-status-bar: #007acc;
--vscode-selection: #264f78;
--vscode-fg: #cccccc;
--vscode-fg-muted: #9d9d9d;
--vscode-fg-dim: #6e6e6e;
}@theme inline {
--color-editor: var(--vscode-editor);
--color-editor-soft: var(--vscode-editor-soft);
--color-chrome: var(--vscode-border);
--color-chrome-strong: var(--vscode-border-strong);
--color-fg: var(--vscode-fg);
--color-fg-muted: var(--vscode-fg-muted);
--color-fg-dim: var(--vscode-fg-dim);
}<body className="h-screen overflow-hidden bg-editor font-sans text-fg">Two rules keep the UI from drifting as pages get added:
- Cards are borders, not shadows. Every panel is a
rounded-mdbox with aborder-chromeorborder-chrome-strongedge on the editor background. Hovering changes the border colour to an accent, nothing more. - Metadata is monospace and small. Dates, counts, languages and labels are
font-mono text-[11px], in the dim foreground colours. That one convention is what makes the interface read as a developer tool rather than a brochure.
Typography and accessibility details
A few small decisions that matter more than they look:
| Concern | Decision |
|---|---|
| Fonts | Geist Sans for content, Geist Mono for anything that looks like data |
| Long code lines | Only code panes and tables scroll horizontally — never the page itself |
| Small screens | Explorer hides below md, tabs scroll, grids collapse to one column |
| Motion | Short transitions on hover; new animations sit behind a reduced-motion check |
| Contrast | Body copy uses the muted foreground; headings use the full foreground |
A theme is not the colours you pick, it is the decisions you refuse to make twice.
Deploying
The site is static, so deployment is the boring part: push to GitHub, import
the repository into Vercel, and every push to main ships. Because there is no
database and no server state, the build is the deployment — and since the blog
reads its posts from MDX files at build time, publishing an article is a commit.
That is also the reason the blog was built this way. Content that lives in the repository can be written in the editor, reviewed like code, and deployed with the same command as everything else.
What is next
- Keep expanding the blog now that the MDX pipeline is in place
- Add more interactive MDX components where an article genuinely benefits
- Tighten the small-screen experience of the split editor pane