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.
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/lightis 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.
What it is
Four user-facing surfaces, served as a single static site:
- 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. - 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.
- 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
localStorageand survive cross-tab reloads. - 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.
Architecture
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.
Tech stack & why
| Layer | Choice | Why |
|---|---|---|
| Generator | Hugo ≥ 0.155 | Single 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 theme | PaperMod | Handles the boring 80% — blog pagination, tags, RSS, search-friendly URLs, share buttons. disableThemeToggle
= true because we ship our own. |
| Theme layer | CSS variables + :root[data-theme] | Four palettes in one stylesheet. No SASS, no PostCSS, no CSS-in-JS. Switch is one attribute write. |
| Custom layouts | layouts/_default/*.html | Selected per page via the frontmatter layout: key.
lunch-tracker-showcase and this page are full-paper overrides. |
| Matrix rain | Vanilla canvas + requestAnimationFrame | Pauses on visibilitychange; nothing flashes when the tab is in the background. |
| Dashboard | Vanilla JS + injected CSS | Builds the panel DOM, injects a stylesheet once, persists to localStorage. No
framework, no virtual DOM. |
| Settings sync | Custom lt:settings-change event + storage event | Same-tab updates fire the custom event; cross-tab updates fire storage. Both
handlers do the same thing. |
| No-FOUC | Inline synchronous boot script | Runs before the stylesheet to prevent the first paint from flashing the default palette. |
| Hosting | Nginx on a 1-core 2 GB VPS | Static files only. The site uses essentially zero RAM — the VPS serves WordPress-grade traffic on this configuration. |
| Deploy | rsync -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. |
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.
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.
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 */ }
})();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.
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.
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.
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.
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
titleattribute 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.
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 crumbsPlus 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.
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.
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.
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:
| Group | Tokens |
|---|---|
| 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.
Testing
A static site doesn't have an obvious test surface. The practical QA loop is:
- Build determinism.
hugo --minifymust finish with zero errors and a known page count. A counter-assertion in the deploy script verifies "54 pages, 6 static files" before pushing. - 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. - Script loading check. Each published page is grepped for the script tags. A regression where a script loads twice is caught immediately.
- 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.
- 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.
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.
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
Four small pieces of the site itself — the bits that were the fiddliest to get right.
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.
| |
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.
| |
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.
| |
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.
| |