Engineering Case Study

This website

The portfolio you're reading right now. A Hugo static site that ships its own design dashboard, a live matrix-rain background, a four-theme palette engine, and a deeply customized PaperMod base — built and deployed on a 1-core VPS that uses about as much RAM as a single browser tab.

  • Role Sole author · design, build, deploy
  • Stack Hugo · PaperMod · Vanilla JS · CSS variables
  • Status Live · 54 pages
  • Footprint ~10 MB RAM · single static output · no backend
01

The problem

Personal-portfolio templates are a solved problem. PaperMod, Beautiful Hugo, Centilium, and a hundred others all do the same thing: a clean light-on-dark blog with a sidebar, a code font, and zero personality.

Two things bothered me about that:

  • None of them teach anything about the author. Visiting the blog is one click; learning something about the author's taste is zero.
  • None of them let the visitor steer. A theme picker that just toggles dark / light is a checkbox, not a design choice.

So the brief I wrote for myself was: build a portfolio that is a project — a frontend that an interviewer can click around for thirty seconds and walk away thinking "this person cares about the details, not just the page". That meant: visible engineering, a working design system, and a piece of motion that isn't decorative — it has controls.

02

What it is

Four user-facing surfaces, served as a single static site:

  1. A neon-cyber landing page with a faux-terminal hero, an animated profile card, typed-out "command" output, and a hand-drawn grid background. The hero is custom Hugo HTML rendered from layouts/index.html — no JavaScript required.
  2. An inner-site theme for blog, projects, and CV pages that overrides PaperMod's chrome with neon-blue accents, monospace fonts, and a translucent surface treatment.
  3. A floating design dashboard (top-right ⚙) that lets the visitor swap themes, slow the matrix rain, change its opacity, and switch character sets. Preferences persist in localStorage and survive cross-tab reloads.
  4. A live matrix-rain background drawn on a full-screen canvas, theme-aware, with a fractional speed accumulator so the rain can drop below 1 row/frame.

The site is built with Hugo, deployed as a directory of static files, and reads no backend at all in the steady state. The lunch-tracker showcase makes a single read-only cross-origin fetch to fahimimam.sytes.net on its hero card — everything else is offline.

03

Architecture

📝 Content (Markdown) content/blog · content/projects
↓ hugo --minify
🧩 Layouts layouts/index.html + custom _default pages
🎨 PaperMod + custom.css 4 :root[data-theme] palettes · cyberpunk default
↓ CSS + JS bundles
🌧 matrix.js canvas · rAF · theme-aware glyph
theme-dashboard.js floating panel · live settings
🥾 extend_head.html inline no-flash theme boot
↓ public/
📦 Static output 54 pages · 6 static files · single CSS bundle
↓ rsync -avz
🌐 VPS · Nginx TLS · serve /var/www/portfolio

One CSS bundle, four themes

Every theme is a :root[data-theme="…"] block in custom.css. Higher specificity than the page's plain :root rules, so they win without !important. The dashboard just toggles the attribute on <html>.

Settings as events, not pollls

The dashboard writes to localStorage and dispatches lt:settings-change. Matrix.js listens for both that event and the cross-tab storage event, so changing the rain in one tab takes effect in every other open tab instantly.

No-flash theme boot

A synchronous inline script in extend_head.html reads lt.theme and sets data-theme on <html> before the stylesheet loads — so the very first paint is in the active palette.

No build step for the JS

matrix.js and theme-dashboard.js ship as plain ES5-compatible scripts. No bundler, no transpiler, no node_modules. The dashboard injects its own CSS once at boot.

04

Tech stack & why

LayerChoiceWhy
GeneratorHugo ≥ 0.155Single static binary, builds 54 pages in 62 ms. Templating with Go's html/template is fast enough that incremental rebuilds feel like a save.
Base themePaperModHandles the boring 80% — blog pagination, tags, RSS, search-friendly URLs, share buttons. disableThemeToggle = true because we ship our own.
Theme layerCSS variables + :root[data-theme]Four palettes in one stylesheet. No SASS, no PostCSS, no CSS-in-JS. Switch is one attribute write.
Custom layoutslayouts/_default/*.htmlSelected per page via the frontmatter layout: key. lunch-tracker-showcase and this page are full-paper overrides.
Matrix rainVanilla canvas + requestAnimationFramePauses on visibilitychange; nothing flashes when the tab is in the background.
DashboardVanilla JS + injected CSSBuilds the panel DOM, injects a stylesheet once, persists to localStorage. No framework, no virtual DOM.
Settings syncCustom lt:settings-change event + storage eventSame-tab updates fire the custom event; cross-tab updates fire storage. Both handlers do the same thing.
No-FOUCInline synchronous boot scriptRuns before the stylesheet to prevent the first paint from flashing the default palette.
HostingNginx on a 1-core 2 GB VPSStatic files only. The site uses essentially zero RAM — the VPS serves WordPress-grade traffic on this configuration.
Deployrsync -avz public/ vps:/var/www/portfolio/One command. --delete keeps the server mirror clean. No CI, no rollbacks — a bad deploy is one rsync away from being reverted.
05

What it does

Faux-terminal landing hero

The home page renders a custom HTML terminal — three coloured "buttons", a whoami prompt, animated typed text via CSS-only keyframes, and a blinking block cursor. No JS needed; the typing runs entirely on CSS animation-timing-function: steps().

🪪

Animated profile card

A holographic-bordered credential card with corner markers, a moving scanline overlay, a pulsing status indicator, and a CSS-only holographic gradient that shifts every six seconds. Pure CSS, zero JS.

🌧

Live matrix rain

A full-screen <canvas> with cascading binary glyphs. Theme-aware — the glyph colour is read from --neon-blue on every paint, so it tracks whichever theme the dashboard is currently on.

Floating design dashboard

A glassmorphism panel that hands the visitor four themes, controls for the rain speed, opacity, and character set, and a reset button. Keyboard shortcuts: T to toggle, [/] for speed, /= for opacity, R to toggle rain, / to cycle themes.

📝

Blog + RSS + tags

PaperMod's built-in blog machinery, restyled. Homeinfo is disabled so the blog index is the empty-state hero. Tags, RSS, breadcrumbs, code-copy buttons, and word counts all work out of the box.

📁

Project cards + detail pages

A reusable project-card partial renders four cards on the projects index. The lunch-tracker and this page each use a custom layout: to render a full engineering case study on their own page.

📄

In-page CV viewer

/cv/ embeds the PDF as an <iframe> with download and external links. No backend, no file-upload workflow — the PDF is a static asset in static/.

🍱

Live lunch-tracker showcase

/projects/lunch-tracker/ is a 12-section engineering case study with a live hero card that polls /api/lunch/state every 30s. Render is fully static — the live data is fetched client-side.

06

Engineering challenges

Six things that cost me an evening each. All are either bugs that shipped and got caught, or design decisions that got refactored after the first build looked wrong.

FOUC

Theme flash on first paint

Symptom. Pick a theme, refresh — the page paints in the default cyberpunk palette for ~80 ms, then swaps to the saved one. The matrix rain appears in the wrong colour for a single frame.

Cause. The dashboard (which sets data-theme) is a defer'd script, so it runs after the stylesheet. The stylesheet already contains all four palettes, but the saved one isn't applied until the script parses.

Fix. A tiny inline synchronous script in extend_head.html reads localStorage.getItem('lt.theme') and sets documentElement.setAttribute('data-theme', …) before the stylesheet loads. Runs in one statement, no conditionals on the dashboard.

(function () {
  try {
    var t = localStorage.getItem('lt.theme');
    if (t && t !== 'cyberpunk') {
      document.documentElement.setAttribute('data-theme', t);
    }
  } catch (e) { /* localStorage blocked */ }
})();
Theme engine

Four themes that actually look distinct

First attempt. Just changed three primary colours. Result: every theme looked like the default cyberpunk with a different accent.

Lesson. A theme is not a hue. It's a palette — background, surface, border, glow, text-primary, text-secondary, and the three accent colours all have to shift together, or the page looks like a colour-swap demo.

Fix. Every theme in custom.css defines the full 17-token set: --neon-blue/orange/purple/green, --cyber-bg/bg-2/bg-3, --cyber-surface/border/glow/glow-strong, --text-primary/secondary, and the PaperMod passthrough tokens (--theme, --entry, --primary, --secondary, …). Solarized is a light theme — neon values become ink. Terminal swaps the whole palette to amber-on-black.

CSS specificity

Themes lost to a less-specific rule

Symptom. Solarized painted correctly on the dashboard panel but reverted to cyberpunk on the rest of the page.

Cause. The plain :root { … } fallback in custom.css had the same specificity as the :root[data-theme="solarized"] block, and the cascaded order meant the fallback won.

Fix. The themed blocks now use :root[data-theme="X"], which is more specific than a bare :root. The fallback :root block only applies when no data-theme attribute is set at all — i.e. on pages that haven't loaded the dashboard yet, and on the initial FOUC frame.

Concurrency

Scripts loading twice on the landing page

Symptom. Right after moving the script tags into extend_head.html, the home page loaded matrix.js and theme-dashboard.js twice. Two rain canvases, two dashboard panels, LocalStorage thrashing between reads.

Cause. layouts/index.html had been carrying its own <script src="/js/matrix.js"> at the bottom for the original drop-in install. The extend_head.html partial was now also including the same scripts, so the home page got both.

Fix. The bottom-of-page script tags were deleted from layouts/index.html. Scripts now load exactly once across every page, verified by grep -c 'js/matrix.js' public/index.html returning 1.

Animation

Rain too fast at speed=1

Symptom. The default speed was 1 row/frame, which felt like a video at 2× speed. The whole canvas flashed past unreadable.

Cause. Most demos use integer speed. At 60 fps, "1 row/frame" is 60 rows/second — too fast for the binary glyphs the user actually wanted to read.

Fix. matrix.js now uses a fractional accumulator:

speedAcc += speed;
var step = Math.floor(speedAcc);
if (step < 1) step = 1;
speedAcc -= step;

The slider goes from 0.2× to 3×. At 0.2× the rain is contemplative; at 3× it's a flicker. The default of 1× feels like a calm downpour.

UX

Hard to discover the dashboard

Symptom. First user test: "Where do I change the theme?" Nobody clicked the ⚙ because they didn't know what it was.

Fix. Three changes layered on top of each other:

  • The header now carries a visible hint strip — T toggle, [/] speed, /= opacity — formatted as <kbd> badges.
  • The toggle button's title attribute says "Open design dashboard (press T)" so hover reveals the shortcut.
  • The keyboard shortcuts actually work — pressing T while the dashboard is closed opens it; pressing it again closes it.
Multi-domain

Every link redirected to the production domain

Symptom. Click any internal link from localhost or fahimimam.sytes.net and the browser bounced the visitor over to fahimimam.pro.bd. The address bar was effectively a redirect button: arrive anywhere, click anywhere, end up on the production host.

Cause. Hugo's baseURL is set to http://fahimimam.pro.bd/ at build time. PaperMod's templates use absURL and absLangURL for nav, logo, menu, breadcrumbs, and favicon links — both prepend the baseURL, so every rendered href is fully qualified against the production host. The build is host-locked.

Why this was bad. Two domains point at the site. Forcing both into one URL collapses them: a visitor who arrived on sytes.net gets silently teleported to pro.bd on the first click, and back-button behavior turns into an infinite redirect loop.

Fix. Hugo's layout override system lets a file in layouts/partials/ shadow the matching partial under themes/PaperMod/. I copied four partials and swapped every internal absURL / absLangURL for relURL / relLangURL, and every internal Permalink for RelPermalink:

layouts/partials/header.html       # logo + main menu
layouts/partials/head.html         # favicon + apple-touch + mask
layouts/partials/footer.html       # copyright link
layouts/partials/breadcrumbs.html  # home + intermediate crumbs

Plus two hand-written pages — the landing hero's CTA buttons and the CV viewer's PDF link — were using hardcoded href="/projects/" etc., which on a static build work correctly with relative URLs but I'd written them with absolute paths. Swapped for relURL / relLangURL for consistency.

What stayed absolute (intentionally). <link rel="canonical">, RSS / JSON feed links, OpenGraph / Twitter card tags, JSON-LD @id and url, and the live lunch-tracker demo URLs all stayed absolute. Search engines dedupe via canonical, feed readers need absolute URLs, and the OpenGraph spec requires them — making those relative would break every external consumer.

Verification. After the rebuild, grep 'href="http' against public/ returned matches only inside index.xml (RSS feeds), the lunch-tracker showcase's https://fahimimam.sytes.net/api/... demo links, and the JSON-LD / OG blocks. No navigation link is absolute anymore.

07

The design dashboard

The dashboard is a single float-mounted <aside> appended to document.body at boot. It owns its own scoped CSS (injected once via a style[data-lt-dashboard] tag), builds the panel DOM from scratch, and never touches PaperMod's styles.

Theme picker

Six theme cards, each with a three-swatch preview (the blue, orange, and purple tokens) and a "CURRENT" pill that fades in on the active card. role="radiogroup" with arrow-key navigation; the picker is fully keyboard-driven.

Rain controls

Enabled checkbox, speed slider (0.2–3×), opacity slider (0–0.5), and a character-set selector with four alphabets: binary (01), ASCII mix, Japanese katakana, and hex digits. The slider thumbs pick up the active theme's neon-blue via accent-color.

Live state

Every change writes to localStorage and dispatches lt:settings-change. Matrix.js listens for that event and re-reads settings at the next frame, so the rain visibly reacts without a re-render.

Cross-tab sync

The storage event fires when another tab writes the same keys. The dashboard and matrix.js both listen for it, so flipping the theme in tab A flips it in tab B without a refresh.

The whole dashboard is ~600 lines of vanilla JS, has no dependencies, and survives a grep for react, vue, or jquery returning zero matches.

08

Matrix rain

static/js/matrix.js is one of the smallest files in the project (~144 lines) and one of the most polished. The surface looks trivial — a canvas, glyphs, animation — but three decisions made it production-ready:

Theme-aware glyph colour

On every frame, the glyph colour is read from getComputedStyle(document.documentElement).getPropertyValue('--neon-blue'). Switch the theme and the rain picks up the new colour on the next frame — no re-init, no canvas reset.

Fractional speed

The speed accumulator lets the rain drop below 1 row/frame without losing position. At 0.2× the rainfall is contemplative; at 1× it matches the default demo; at 3× it's a flicker.

Tab-visibility aware

The visibilitychange listener pauses requestAnimationFrame when the tab is hidden. No battery drain in the background, no waste of GPU time.

Four alphabets

Binary (01), ASCII mix (the default with katakana + ASCII letters + symbols), pure katakana (雨.gif), and hex digits. Switch live from the dashboard.

09

Theming system

The site is single-CSS. No SASS, no PostCSS, no CSS-in-JS, no tailwind.config.js. Just four :root[data-theme="X"] blocks in assets/css/extended/custom.css plus a default :root fallback. The cascading order is the spec:

:root[data-theme="cyberpunk"] { /* 17 tokens */ }
:root[data-theme="synthwave"]  { /* 17 tokens */ }
:root[data-theme="solarized"]  { /* 17 tokens */ }
:root[data-theme="terminal"]   { /* 17 tokens */ }
:root[data-theme="nord"]       { /* 17 tokens */ }
:root[data-theme="monokai"]    { /* 17 tokens */ }
:root                          { /* 17 tokens — fallback */ }

Because the themed blocks have higher specificity than the plain :root, they win without !important. The fallback is there for pages that haven't loaded the dashboard yet — i.e. the brief FOUC window before the inline boot script runs.

Six themes today: cyberpunk, synthwave, solarized, terminal, nord, monokai. Each defines the same 17 tokens:

GroupTokens
Brand--neon-blue, --neon-orange, --neon-purple, --neon-green
Surface--cyber-bg, --cyber-bg-2, --cyber-bg-3, --cyber-surface, --cyber-border, --cyber-glow, --cyber-glow-strong
Text--text-primary, --text-secondary
PaperMod passthrough--theme, --entry, --primary, --secondary, --tertiary, --content, --code-block-bg, --code-bg, --border

For an extra defensive layer, the page's body rule is overridden by [data-theme] body, so a theme set on <html> always wins even if the body rule was loaded later.

10

Testing

A static site doesn't have an obvious test surface. The practical QA loop is:

  1. Build determinism. hugo --minify must finish with zero errors and a known page count. A counter-assertion in the deploy script verifies "54 pages, 6 static files" before pushing.
  2. Cross-theme grep. After the build, the generated CSS is grepped for each theme block to confirm it made it into the bundle. Catches the case where a typo in a :root[data-theme="…"] selector silently swallows the palette.
  3. Script loading check. Each published page is grepped for the script tags. A regression where a script loads twice is caught immediately.
  4. JS smoke test. The dashboard and matrix scripts are stub-loaded under a minimal browser mock to catch syntax errors and top-level exceptions at boot.
  5. Cross-theme visual pass. Each route (home, blog, projects, CV, about, lunch-tracker showcase, this page) is opened in each of the four themes and visually checked — no clipped text, no broken contrast, no white-on-white.

The JS smoke test is a one-shot node script that re-uses a stub DOM, runs the script under eval, and exits non-zero on throw. Not a test framework — just a syntactic and runtime floor.

11

Deployment

No CI. No containers. A bad deploy is one rsync and one reverse rsync away from being reverted.

cd /Users/pathaoltd/go/src/github.com/fahimimam/portfolio
rm -rf public/
hugo --minify
# spot-check: 54 pages, 6 static files, 0 errors
rsync -avz --delete public/ vps:/var/www/portfolio/
ssh vps 'nginx -t && sudo systemctl reload nginx'
curl -sS https://fahimimam.pro.bd/ | grep -c 'lt-theme\|cyberpunk\|synthwave'

Nginx

Static-only server block. TLS terminated at Nginx (certbot / Let's Encrypt). Gzip on, expires headers on /assets/, add_header Cache-Control "public, max-age=31536000, immutable" for hashed assets. The page itself is served with Cache-Control: no-cache.

Cross-origin read for the lunch tracker

The lunch-tracker showcase makes a single Origin: https://fahimimam.pro.bd fetch to fahimimam.sytes.net. The lunch-tracker service has that origin on its CORS allow-list, so the fetch is permitted at the browser level.

Footprint

The built site is ~600 KB of HTML/CSS/JS. The single served CSS bundle is the largest asset; everything else is small. Cold-cache first byte is roughly the VPS round-trip, which is under 100 ms from any Bangladesh-origin network.

Rollback

The last published public/ is kept on the laptop. If a deploy is bad, rsync again from the previous build. There's no git-based publish and no staging environment — the cost of being wrong is at most one rebuild.

12

Lessons learned

Static-first still wins for portfolios

No backend, no auth, no database, no S3 bill. The site is a thousand files in a directory and Nginx is happy. For a personal site that gets a few thousand visitors a month, this is the right shape.

Show, don't tell, in the design system

The reason the dashboard exists is that a portfolio that only describes its design is half a portfolio. Handing the visitor the same controls the author uses is the most honest form of showing taste.

Specificity > cascade order

When CSS variables are overridden by :root[data-theme="X"], they win because of specificity, not cascade order. This is load-order independent — the themed blocks can be declared anywhere in the file and still win.

Inline scripts are not a sin

The no-flash theme boot is a synchronous inline script in extend_head.html. The "no inline JS" rule has its place — but the rule against "five lines" of inline script that prevents a visible flash is misplaced.

Custom events beat polling for in-page state

The dashboard writes to localStorage and fires lt:settings-change. Matrix.js listens for that event. There's no shared state object, no observer chain, no polling. The dashboard doesn't even know matrix.js exists.

A portfolio is a project

The site has its own commits, its own bug history, its own FOUC fix, its own testing loop. Documenting it the same way as a backend service is honest — it's the same kind of work, just front-of-screen instead of behind an API.

Source & live site

Code samples

FOUC boot script

A single synchronous IIFE in extend_head.html reads the saved theme and sets data-theme on <html> before the stylesheet loads. The cached palette is the very first paint.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Runs synchronously, BEFORE the stylesheet, so the saved theme
    // is correct on first paint -- no flash of default palette.
    (function () {
    try {
    var t = localStorage.getItem('lt.theme');
    if (t && t !== 'cyberpunk') {
    document.documentElement.setAttribute('data-theme', t);
    }
    } catch (e) { /* localStorage blocked -- default theme */ }
    })();

Matrix rain — cached glyph colour

The first version of matrix.js called getComputedStyle() every frame to read --neon-blue — 60 forced reflows a second. The colour is now cached and refreshed only on lt:theme-change.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// --neon-blue is read ONCE per theme change, not every frame.
    // The previous version called getComputedStyle() at 60 Hz, which
    // forced a layout read on every animation tick.
    function refreshGlyph() {
    try {
    var v = getComputedStyle(document.documentElement)
    .getPropertyValue('--neon-blue').trim();
    if (v) cachedGlyph = v;
    } catch (e) { /* keep previous */ }
    }

Matrix rain — fractional speed accumulator

The dashboard's rain speed slider goes from 0.2× to 3×. A naive round(speed) would stall sub-1× speeds or alias them to integer-only motion. A speedAcc carries the fractional part across frames.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Fractional speed: the slider goes 0.2x - 3x, so we accumulate
    // and step the drops only when the accumulator crosses 1. A naive
    // int(round(speed)) would either stall the rain below 1x or look
    // the same as 1x.
    var speedAcc = 0;

    function frame() {
    rafId = 0;
    if (!running) return;

    ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
    ctx.fillRect(0, 0, width, height);

    ctx.fillStyle = cachedGlyph;
    ctx.font = fontSize + 'px monospace';

    speedAcc += speed;
    var step = Math.floor(speedAcc);
    if (step < 1) step = 1;
    speedAcc -= step;

    for (var i = 0; i < drops.length; i++) {
    ctx.fillText(pickChar(), i * fontSize, drops[i] * fontSize);
    if (drops[i] * fontSize > height && Math.random() > 0.975) {
    drops[i] = 0;
    }
    drops[i] += step;
    }
    rafId = requestAnimationFrame(frame);
    }

Theme palette — Nord

One of six themes. Every block must define all 17 tokens; missing tokens fall through to the bare :root fallback, which usually means an invisible colour and a long debugging session.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/* :root[data-theme="nord"] sets every token. Specificity beats
    the bare :root fallback, so a missing token here would still
    fall through to a near-invisible default. */
    :root[data-theme="nord"] {
    --neon-blue: #88c0d0;
    --neon-orange: #d08770;
    --neon-purple: #b48ead;
    --neon-green: #a3be8c;

    --cyber-bg: #2e3440;
    --cyber-bg-2: #3b4252;
    --cyber-bg-3: #434c5e;
    --cyber-surface: rgba(59, 66, 82, 0.6);
    --cyber-border: rgba(136, 192, 208, 0.18);
    --cyber-glow: 0 0 18px rgba(136, 192, 208, 0.18);
    --cyber-glow-strong: 0 0 28px rgba(136, 192, 208, 0.35);

    --text-primary: #eceff4;
    --text-secondary: #d8dee9;
    }