How this site is built.
A short tour of the stack, the design decisions, and the AI bits behind the scenes. The whole thing is open-source — fork, copy, learn from it, ship your own.
The stack
Design decisions
Dark by default, no apologies
The site ships dark. The light theme exists for users who prefer it but
the design is built for the dark palette first. prefers-color-scheme
is intentionally ignored on first load — only an explicit click switches.
Tokens all the way down
Every colour, font, and easing curve is a CSS custom property in
:root. The accent picker swaps eight tokens; the font picker
swaps three. Light theme is a single [data-theme="light"]
override on the same tokens — no rule duplication.
One CSS file, one JS file
No bundler, no code-splitting. The CSS is hand-written and lives in
assets/css/main.css. The JS is split into a few focused
files (main.js, theme.js, accent.js,
fonts.js, chatbot.js, terminal.js,
spotlight.js, card-tilt.js,
agent-visualiser.js, ml-demo.js,
project-filter.js) — all loaded with defer,
none required for first paint.
No flash of anything
Critical CSS (tokens + nav + hero shell) is inlined in the head.
The full stylesheet loads non-blocking with media="print"
+ onload swap. A small inline script reads the saved
theme, accent key, and font key from localStorage and
applies them before first paint, so you never see the
default-then-swap flash.
The AI features
Talk to Paul (chatbot)
A retrieval-augmented chatbot. The build script
(scripts/build-knowledge-base.js) chunks
_data/*.yml + blog posts into ~32 semantic chunks, each
optionally embedded with @cf/baai/bge-base-en-v1.5.
On every message: sanitise → rate-limit per IP → check for
prompt-injection patterns → classify intent → retrieve the top-5
relevant chunks (cosine similarity if embeddings are present, BM25-lite
keyword overlap otherwise) → build a focused prompt with only those
chunks → call the LLM (Llama 3.1 8B via Workers AI by default, or
Claude Haiku 4.5 if configured). The "How I found this answer" panel
under each reply exposes the retrieval — same pattern Perplexity uses.
Live Lab (TF.js sentiment)
A real CNN running in your browser. The sentiment_cnn_v1
model from the TensorFlow.js model zoo, lazy-loaded only when the
Live Lab section enters the viewport. Tokenises with the model's
metadata vocab, runs inference on WebGL, reports timing in milliseconds.
Total cold payload ~1.8 MB, cached after first visit. No data leaves
your device.
Agent visualiser
A hand-rolled SVG flow showing how a research agent decomposes a task — Input → Planning → Tool Selection → Execution → Memory → Synthesis → Output. Auto-plays on viewport entry, pauses when scrolled out, supports play/pause/step/reset and 1×/2× speed.
Terminal
A CLI-style portfolio explorer. Type paul --help to start.
Pure frontend — all data comes from _data/*.yml serialised
into a JSON blob at build time. Up/Down history, Tab completion,
Ctrl-L to clear, and a matrix easter egg.
Performance & privacy
- No tracking pixels. No Google Analytics. No advertising.
- YouTube embeds use the lite-embed pattern — thumbnails first, iframe only on click.
- Service worker caches static assets with versioned cache + network-first for HTML, so deploys never serve stale pages.
- The chatbot doesn't log or retain messages.
- See the privacy page for the full picture.
Open source
Source: github.com/mcneillium/mcneillium.github.io.
The chatbot worker code is in workers/portfolio-bot/.
Deployment guide: docs/chatbot-deployment.md.