Personal portfolio of Mikko Numminen — full-stack developer from Finland.
Live: https://mikkonumminen-dev.vercel.app
This is intentionally not a typical web app. It's a visual showcase, with each page built as its own concept and animation.
/— Immersive scroll experience. A WebGL particle field forms the name, then scatters into a starfield behind the content on scroll. GSAP scroll triggers, parallax sections, animated nav cards./projects— Interactive solar system. Each project orbits a central sun. Hover a planet for the elevator pitch, click to zoom in./experience— Parallax mountain landscape. A goat climbs as you scroll. The sky shifts from pre-dawn to bright day across the climb. Timeline markers fade in along the way./contact— Terminal / CRT aesthetic. Real command parser, command history, tab completion, scan lines, blinking cursor, copy-to-clipboard. Tryhelpfor the command list —skillsanddownload --catalogsurface the live cross-repo skill registry.
Page-to-page navigation is client-side (Astro ClientRouter), animated with the built-in view transition; the music bed survives the swap.
Available in English, Finnish, and Swedish — served from /, /fi, and /sv respectively. English is the default locale and is served without a prefix. Translations live under src/i18n/locales/.
A looping music bed plays across every page, dual-decked and crossfaded so the loop join is inaudible. On / and /projects a locale-specific voiceover narration is layered on top of the music. A single floating sound on/off button in BackgroundAudio.astro controls both tracks via a custom bg-audio:state event; on/off preference and music playhead persist across navigation through sessionStorage. Voice clips don't autostart — they kick in after the audio toggle resolves and recycle on a 50-second idle window so an active visitor isn't re-narrated at. Both narration layers respect prefers-reduced-motion: reduce and stay silent; music still plays.
Assets live in public/audio/ and are keyed by locale: voice-landing-{en|fi|sv}.mp3 (home) and voice-projects-{en|fi|sv}.mp3 (galaxy view). Locales without a recording 404 the audio element silently and the page stays usable.
The /contact terminal accepts free-form questions in addition to its built-in
commands. When you type something the command parser doesn't recognise, the
input is routed to a retrieval-augmented-generation pipeline that answers from a
curated corpus of project descriptions, CV, and selected posts — Mikko's own
words, retrieved and grounded, not a general-purpose chat model.
static Astro terminal ──fetch──▶ FastAPI backend ──▶ Postgres + pgvector
└──▶ Ollama (qwen2.5:7b)
Offline indexer ──embeds corpus──▶ Postgres + pgvector
Embeddings (bge-small-en-v1.5) run in-process inside the backend container.
The stack is a separate, optional service that runs on the repo owner's own machine (see ADR 0009 — it reconciles the static-output requirement from ADR 0002 with a dynamic backend). Load-bearing properties:
- The site stays 100% static. ADR 0002 is preserved intact. The only
addition on the frontend is the build-time
PUBLIC_CHAT_API_URLenv var and ordinary client-sidefetchcalls — no SSR, no edge functions, no runtime secrets. - Fully local, zero marginal cost. Postgres + pgvector, in-process
fastembed
bge-small-en-v1.5embeddings, and a local Ollama instance (qwen2.5:7bby default, switchable viaragctl) all run in Docker on Mikko's own machine. No hosted model, no paid API, nothing per query. - Progressive enhancement, never a regression. At page load the terminal
fires one
GET /healthprobe. The endpoint reports liveness of both the database and the LLM, but the terminal unlocks chat only whenchecks.llmistrue— a real 1-token completion confirming the model actually generates. When the backend is absent — the default for every static build and every CI run — the terminal is byte-for-byte identical to its scripted-only mode: no chat affordance, no error, no visual difference.
Because the terminal exposes a real LLM to the public internet, "what if someone tries to break it out of scope" is answered with layered, architectural defenses — not a hopeful system prompt. A user who can't be answered from the corpus, or who tries to redirect the model, hits guardrails that sit outside the prompt:
- Grounded-only generation. The system prompt answers strictly from the retrieved CONTEXT and declines when the answer isn't there.
- Pre-LLM relevance gate. If the best retrieval is too weak
(
WEAK_RETRIEVAL_DISTANCE, default 0.45), the request short-circuits to a fixed out-of-scope reply before the model ever runs. The gate anchors on the closest prose chunk, so off-topic queries that only graze a stray code chunk still get declined. - Deterministic task gates. Two pre-retrieval gates decline on-corpus-sounding task requests the small model would otherwise just perform: generative asks ("write me a poem/story/song/joke…") and translation asks ("translate to ").
- Hard output cap.
LLM_NUM_PREDICT(default 512) bounds generation, so no single answer can dump a whole document regardless of the prompt. - Input cap.
INPUT_MAX_CHARS(default 800; over-length → HTTP 400) plus a Pydantic length backstop and a byte-size cap in middleware. - Prompt-injection hardening. The whole user message is treated as a question, never as instructions; reveal/ignore-the-prompt and role-play requests are declined.
- Concurrency shedding. A bounded semaphore around generation sheds excess load with a short busy reply instead of queueing.
- Acceptance harness.
evals/acceptance.pyruns black-box contract cases (injection no-dump, prompt-reveal blocked, off-topic declines, the caps, and grounded technical answers) so the containment is regression-tested, not assumed.
The indexer covers more than prose: alongside content/**/*.md it now ingests
55 architecture-defining source files curated under content/code/<project>/
(Python, TypeScript/TSX, JS, C#, Astro, SQL, Prisma, + config). Code-aware
chunking splits those by function/class/method boundaries — decorators and
attributes stay with their definition, with a line-window fallback — while prose
stays markdown-block chunked; each chunk carries language + chunk_type
metadata. Retrieval is hybrid: dense pgvector cosine fused with lexical
BM25-style full-text search (websearch_to_tsquery + ts_rank) via reciprocal
rank fusion, with a hard per-project filter that fails open when a named project
has no hits. Hybrid lifted the measured retrieval hit-rate over pure dense, and
the acceptance harness still passes 9/9 — containment held while deep-code
questions now answer from the actual source.
Full pipeline, every config knob (HYBRID_ENABLED, RRF_K, the dense/lexical
weights, PROJECT_FILTER_STRICT, …), and the threat model are in
docs/rag-chat.md; the design rationale is in
ADR 0009 and
ADR 0010. Still on the roadmap, not yet
built: cross-encoder re-ranking, automatic per-project summary generation, and
query expansion.
The backend lives in chat-backend/ with its own README. The
Docker stack (docker-compose.yml) and Makefile bring everything up with
make up && make index, and it's controlled day-to-day via the ragctl REPL
(status/up/down/doctor/model/english). For turning it on end-to-end
— including publishing the backend via a Tailscale Funnel for live visitors —
see LAUNCH.md and the as-built docs/rag-chat.md.
- Astro — static site generator with island architecture
- Three.js — 3D graphics for the home and projects pages
- postprocessing — bloom pass on the home particle field
- GSAP + ScrollTrigger — scroll-driven animation timelines
- Tailwind CSS v4 — utility CSS, no component library
- TypeScript (strict, with
noUncheckedIndexedAccess)
The build output is fully static — no SSR, no edge functions — so it can move from Vercel to any static host (S3 + CloudFront, Cloudflare Pages, etc.) with a config swap.
This stack is intentionally separate from the production stack used in my other projects (Next.js / React / MUI). This repo is the craft side of the brain.
Requires Node 22+ (Astro 7; see .nvmrc).
npm install
npm run dev # http://localhost:4321
npm run build # build to dist/
npm run preview # preview the production build
npm run typecheck # astro check
npm run check:env # verify Node version + deps are ready on a fresh clone
npm run lint # eslint
npm run format # prettier --write across src/
npm run format:check # prettier --check (CI-friendly)
npm run build:og # rasterize OG cards + manifest icons from the source SVGs
npm run sync:skills-registry # copy latest dated SKILL-REGISTRY-*.json → public/data/
npm run validate:registry # check public/data/skills-registry.json against its JSON schema
npm run build:skills-pdf # regenerate public/skills-registry.pdf via local Chrome
npm test # run the Vitest suite (src + scripts unit tests)
npm run test:watch # Vitest in watch mode for TDDbuild:og reads public/og-*.svg and public/favicon.svg and writes the PNGs referenced by <head> meta and public/manifest.webmanifest. Run it whenever any of those source SVGs change.
prebuild runs validate:registry && render:audit-pdfs && build:skills-pdf automatically on every npm run build. validate:registry checks the committed public/data/skills-registry.json against public/data/skills-registry.schema.json (dependency-free schema validation, so a malformed registry fails the build instead of silently breaking the terminal). The two renderers use the local Chrome's --print-to-pdf flag and skip silently in CI environments, where the committed PDFs are the canonical artifacts for hosted builds. The skills-registry JSON the terminal fetches is likewise a canonical committed artifact: apply-measurement-overlay and build-review-stats layer transcript-measured receipts and A/B buckets onto it from local ~/.claude data that can't be read on a build server, so sync:skills-registry is the first step of the manual /skill-localUpdate refresh chain rather than a prebuild step — auto-syncing the raw dated registry would overwrite that enrichment. Rationale in docs/decisions/0005-skill-registry-pdf-surface.md.
src/
layouts/ BaseLayout — shared head, nav, client router
components/ One folder per page (home, projects, experience, contact, nav)
page-content/ Page-level composition (one .astro per page, wrapped by the routed file)
pages/ One file per route (.astro), including /fi and /sv mirrors
lib/
three/ Core Three.js helpers + scene entry points (homeScene, projectsScene)
home/ Home-page DOM layers (data-feed console, commit popups)
projects/ Projects-scene building blocks (planets, hover labels)
timeline/ Experience-page timeline scene helpers
gsap/ GSAP timelines per page
terminal/ Terminal command parser and runtime
blog/ Blog content helpers (entry dates, slug parity)
observability/ Sentry + Core Web Vitals init
utils/ Cross-cutting helpers (e.g. escapeHtml)
debug/ Dev-only diagnostics, stripped from production
data/ Project metadata, timeline entries
i18n/ Locale dictionaries and locale-aware path helpers
styles/ global.css (Tailwind v4 + CSS vars) and per-component CSS
public/ Static assets — favicon, manifest, OG images, robots, icons
data/ Build-synced registry JSON consumed by the terminal `skills` command
scripts/
check-env.mjs Fresh-clone sanity check: Node version + deps (npm run check:env)
build-og.mjs OG image + manifest icon rasterizer
sync-skill-registry.mjs Copy latest dated SKILL-REGISTRY-*.json into public/data/
apply-measurement-overlay.mjs Layer transcript measurements onto the registry
build-review-stats.mjs Per-invocation /review token stats (reads ~/.claude)
bucket-review-invocations.py Bucket /review invocations by size (feeds build-review-stats)
build-skills-pdf.mjs Bespoke HTML-from-JSON renderer; calls scripts/lib/chrome-pdf.mjs
validate-registry.mjs Validate public/data/skills-registry.json against its schema (prebuild gate)
build-pdf.mjs Generic HTML → PDF CLI (used by the md-to-pdf skill)
render-audit-pdfs.mjs Render docs/audits/*.md → committed PDFs (skips in CI)
render-audit-doc.mjs Markdown → audit-styled HTML used by the renderer
build-scoreboard.mjs Build the review scoreboard artifact
draw-tokens.mjs Token-figure chart rasterizer
lib/
chrome-pdf.mjs Local Chrome --print-to-pdf wrapper; size-asserts the output
validate-json-schema.mjs Dependency-free JSON-schema validator (tested); backs validate-registry
escape.mjs Shared HTML escaper + http(s) URL allowlist
cli-args.mjs Tiny argv parser (tested)
node-version.mjs Semver-minimum check backing check-env (tested)
scoreboard-stats.mjs Scoreboard aggregation (tested)
transcript-tokens.mjs ~/.claude transcript token accounting (tested)
Several scripts/ and scripts/lib/ modules have co-located *.test.mjs suites run by npm test.
- Three.js is dynamically imported and only loaded on the pages that need it
- Three.js scenes are skipped entirely on small screens and when
prefers-reduced-motion: reduceis set, with a static fallback - All animations respect
prefers-reduced-motion; the home and projects voiceovers also skip narration on RM (music still plays) - Skip-link, semantic landmarks, ARIA labels, focus-visible rings per theme
- All Three.js resources are explicitly disposed on client-side navigation away from a scene's page
Custom Claude Code skills live in .claude/skills/ — version-controlled, reviewed when added, audited per run. The automation skills spawn N parallel Sonnet sub-agents that return structured reports; an Opus synthesizer applies the agreed-on rules and opens a PR, and the orchestrator never merges — human review is the gate. The RAG skills are knowledge-and-harness specs a session loads instead of re-deriving the architecture or test battery from scratch.
/sync-readmes— audits this site's project data (src/data/projects.ts+ en/fi/svprojectsData) against the canonical READMEs of all 6 sibling repos in parallel. Opens a PR with drift corrections — factual fixes mirrored to all three locales, tech-list additions inprojects.ts.- Token economics per run: ~140K Sonnet input across 6 parallel sub-agents, ~10K kept on the orchestrator's main context (vs ~31K if read inline), ~45s parallel wall-clock, ~$0.80 in API spend.
- Results to date (2 runs): 15 factually wrong copy fixes across three locales (test counts, engine counts, normalization-pass counts), 14 missing tech tags across 5 projects, 4 cross-project link gaps caught.
/skill-registry— walks every sibling repo in the workspace (resolved as the parent of this repo), finds each.claude/skills/*/SKILL.md, and emits a consolidated JSON registry (name, description, redirect flag, token-savings receipt where one exists). One Sonnet sub-agent per repo, in parallel; main thread aggregates and writes.claude/agent-verdicts/SKILL-REGISTRY-{YYYY-MM-DD}.json. The JSON is the source of truth for "what skills the portfolio operates today" — other Claude sessions read it without re-running the scan. The/skill-localUpdatechain syncs the latest dated JSON intopublic/data/and layers measured receipts on top, so the contact-page terminal'sskillsanddownload --catalogcommands serve the enriched committed registry. (It is not auto-synced at build time — that would overwrite the enrichment with the raw scan.)- Token economics per run: ~80K Sonnet input across 3 parallel sub-agents, ~5K main-thread aggregation, ~30s parallel wall-clock.
/md-to-pdf— renders any HTML document to a styled PDF using the developer's locally-installed Chrome via--print-to-pdf. Zero npm install — no puppeteer or Chromium download (~150MB avoided). Page setup (orientation, size, margins) lives in the HTML's@pageCSS, so the caller controls layout fully. The skill assembles the HTML in-context (markdown → HTML conversion + per-document CSS) and invokesscripts/build-pdf.mjs; a content-aware wrapper atscripts/build-skills-pdf.mjspowers thedownload --catalogregistry PDF. Rationale indocs/decisions/0005-skill-registry-pdf-surface.md.- Token economics per use: ~10K Read of the source + ~10-15K HTML composition + ~1K Bash + ~1K verify ≈ ~25K end to end, ~15-20s wall-clock including Chrome render.
/skill-localUpdate— one-command refresh of every local artifact the site renders about portfolio skills: the JSON the/contactterminal fetches at runtime, the measurement-overlaid annual totals, and the downloadable skills-registry PDF. Wraps a pre-flight check plus four sequenced steps:/skill-registry(re-walk the portfolio),npm run sync:skills-registry(copy intopublic/data/— what the terminal reads),node scripts/apply-measurement-overlay.mjs(layer transcript measurements withprior_estimatesnapshotting and library-canonical dedupe),npm run build:skills-pdf(render via local Chrome). Exists because the chain used to be four manual commands that were easy to run out of order — producing a plausible-looking but wrong artifact (stale terminal table OR PDF that doesn't match what the terminal shows). Renamed from/skill-pdfon 2026-05-21 since the PDF is one of three site surfaces this chain updates, not the headline. Spec:.claude/skills/skill-localUpdate/SKILL.md.- Token economics per use: ~80K dominated by step 2's parallel
/skill-registrysub-agents; steps 1, 3, 4 cost negligible model tokens. Wall-clock ~30–60s.
- Token economics per use: ~80K dominated by step 2's parallel
/rag-backend— the architecture map for the RAG chat backend: the FastAPI + Ollama + pgvector stack, the exact/chatpipeline order, every config knob with its default, the containment layers, and the corpus re-index runbook. Exists so a session working on the backend loads the map instead of re-deriving it from source every time. Spec:.claude/skills/rag-backend/SKILL.md./rag-audit— the verify/audit battery for the RAG chat: the canonical containment and retrieval test cases (off-topic queries that must refuse, deep-code queries that must answer), the sync → rebuild → re-index → validate runbook, and adversarial review lenses. The acceptance cases live here once instead of being re-invented per hardening pass. Spec:.claude/skills/rag-audit/SKILL.md./rag-experiment— the eval-gated, single-variable experiment harness for pipeline swaps (model, embedder, chunking, reranker): a TOML config that declares exactly what varies, a runtime lock-assert that refuses apples-to-oranges comparisons, and AI-free measurement discipline — the only tokens a run spends are the generations under test. Spec:.claude/skills/rag-experiment/SKILL.md.
Of the seven, the first four ship with a written verdict or ADR. The three RAG skills are knowledge-and-harness skills — their SKILL bodies are the rationale, with docs/rag-chat.md as the shared as-built reference, and no token receipts are claimed for them. Token figures vary in their grounding — see each linked doc for the specific "editorial vs measured" split:
/sync-readmes→.claude/agent-verdicts/README-SYNC-AGENT.md— measured: per-sub-agent token figures from 2 real runs (Run 1 sum: 141,080), cumulative drift caught, design comparison vs inline-on-Opus./skill-registry→.claude/agent-verdicts/SKILL-REGISTRY-AGENT.md— editorial-grade: design rationale, schema gaps, validation notes; numbers are author-estimated until the in-progress/skill-usagemeasurement tool lands measured values. Backed by dated JSON snapshots committed alongside every refresh./md-to-pdf→docs/decisions/0005-skill-registry-pdf-surface.md— design ADR (covers the broader contact-terminal PDF surface, of which/md-to-pdfis one consumer): why local-Chrome over puppeteer, layout-via-@page, the content-aware wrapper fordownload --catalog./skill-localUpdate→.claude/skills/skill-localUpdate/SKILL.md— author estimate (no separate verdict doc — the SKILL body holds the chain spec, expected-token table, and failure-mode notes). The chain's measurement step (scripts/apply-measurement-overlay.mjs) is what populates every measured receipt elsewhere on the page.
The "tokens saved" framing throughout these docs is mostly context-budget savings — keeping Opus's orchestrator context free for synthesis work without triggering compaction — not direct dollar savings. Dollar savings vs an inline read are modest; the real win is the surface-area map across sibling repos that nobody hand-grep-audits.
The portfolio-wide registry that aggregates skills from every sibling repo lives in .claude/agent-verdicts/SKILL-REGISTRY-LATEST.json and is downloadable from the contact terminal via download --catalog; it's not republished here because this section is about what ships in this repo.
Client-side errors and Core Web Vitals (LCP, CLS, INP, FCP, TTFB) are reported to Sentry from real visitors. Activation is gated on the PUBLIC_SENTRY_DSN env var — forks without it run silent. Do Not Track is honored (init bails early). No session replay, no PII capture beyond Sentry defaults (URL, browser, stack trace). The init lives in src/lib/observability/initObservability.ts and is called once from BaseLayout.astro. Rationale + alternatives in docs/decisions/0001-observability-sentry.md.
Deployed on Vercel with caching and security headers configured in vercel.json:
- Long cache (1 year, immutable) for
/_astro/hashed assets and/fonts/ - Short cache (1 day) for OG images and favicons
X-Frame-Options,X-Content-Type-Options,Referrer-Policy,Strict-Transport-Security,Permissions-Policy, andContent-Security-Policyon every response
Deploys are automatic on every push to master.
The CSP shipped in vercel.json is deliberately baseline rather than strict:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self' data:;
media-src 'self';
connect-src 'self' https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io https://paskamyrsky.tail6ed53b.ts.net;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
worker-src 'none';
manifest-src 'self';
frame-src 'none';
upgrade-insecure-requests
connect-src allows the Sentry ingest hosts — *.ingest.sentry.io plus the regional *.ingest.us.sentry.io / *.ingest.de.sentry.io — for the observability beacon (see the Observability section above). Chat traffic itself now flows same-origin through the /api/rag/* Vercel rewrites (ADR 0012); the Tailscale Funnel origin is kept in connect-src during the transition and can be dropped once that's verified live. The init module no-ops when PUBLIC_SENTRY_DSN is unset, so the Sentry hosts only see traffic on deployments that have the DSN configured.
'unsafe-inline' remains on both script-src and style-src because the site relies on:
- Astro's inline hoists for small island bootstrap code
- A JSON-LD
<script type="application/ld+json">block in the layout - An inline language-detection script in
BaseLayoutthat runs before hydration - Scoped inline styles from Astro component frontmatter
Moving to a nonce-based CSP would require plumbing a per-request nonce through every inline tag, which breaks the "fully static output" constraint (nonces must change per response). HSTS, frame-ancestors 'none', object-src 'none', base-uri 'self', and upgrade-insecure-requests cover the rest of the hardening surface in the meantime.