A single-page personal site with an Industry 4.0 / IIoT aesthetic. Built with vanilla HTML/JS and Tailwind CSS v4, precompiled into a static stylesheet (no toolchain in the repo — one npx command regenerates it). Hosted on Hostinger at samdonche.com, deployed automatically from this repo via Hostinger's Git integration.
The navigation borrows from Ignition's tag browser:
- A fixed left-sidebar tag tree with quality-value badges (
STALE→GOOD→LIVE) that update live as you scroll. - An ⌘K / Ctrl+K command palette for type-ahead navigation over the same tags. Try paths like
experience/mustryor justcontact.
Both are driven by a single SECTIONS registry in assets/js/script.js — add a section there and it appears in the sidebar, the palette, and the active-section observer.
# Clone (or just open the folder)
git clone https://github.com/sdonche/personal-website.git
cd personal-website
# Open locally — any static server works
python3 -m http.server 8080
# then visit http://localhost:8080There is no build step to view or deploy the site — the compiled Tailwind stylesheet (assets/css/tailwind.css) is committed, fonts are self-hosted in assets/fonts/, everything else is hand-rolled. Just open index.html. The site makes zero third-party requests.
A few small stdlib-Python helpers in scripts/ prepare files before you commit. None are needed to serve the site (Hostinger serves the committed files as-is); they just save manual work. Run make before committing.
| command | what it does |
|---|---|
make |
cache-bust the assets (stamp) + run the skills check (check) |
make icons |
regenerate assets/js/skill-meta.js from scripts/skill-icons.jsonl (after editing the Toolbelt tool list); then re-run make |
make check |
assert every skill chip has an icon/description and maps to a diagram node |
scripts/stamp-assets.py stamps a content hash onto each ?v= asset URL in the HTML, so returning visitors always fetch the current file — no more hand-bumping version strings.
The Tailwind stylesheet is generated from assets/css/tailwind.input.css (which also holds the design tokens). Rerun this only when you add or remove Tailwind utility classes in index.html, 404.html or script.js — pure text/content edits don't need it:
npx --yes -p tailwindcss@4 -p @tailwindcss/cli@4 tailwindcss \
-i assets/css/tailwind.input.css \
-o assets/css/tailwind.css --minifyThen run make to re-stamp the cache-busters. (Requires Node; if the npx one-liner can't resolve tailwindcss, run npm install --no-save --no-package-lock tailwindcss@4 @tailwindcss/cli@4 first and use ./node_modules/.bin/tailwindcss — node_modules/ is gitignored.)
.
├── index.html # Single-page site, all sections inlined
├── assets/
│ ├── css/tailwind.input.css # Tailwind source: design tokens + @source globs (not served)
│ ├── css/tailwind.css # Compiled Tailwind output — committed, regenerate via npx (see above)
│ ├── css/styles.css # Custom styles (animations, network nav, timeline, etc.)
│ ├── css/fonts.css # @font-face rules for the self-hosted fonts
│ ├── fonts/ # Inter + JetBrains Mono variable woff2 (latin, latin-ext)
│ ├── js/script.js # Network nav builder, scroll behavior, contact form
│ └── img/ # og-card.jpg (1200×630 social card, generated), portrait.jpg
│ # (About photo + schema.org), og.jpg (source photo), favicons
├── .htaccess # Apache config: HTTPS, custom 404, caching (Hostinger)
├── 404.html # Custom 404 (wired up via .htaccess)
├── robots.txt # Crawler rules + sitemap pointer
├── sitemap.xml # Single-URL sitemap
├── .gitignore # Keeps secrets / OS cruft out of the repo
└── README.md
All content lives inline in index.html (the shipped HTML intentionally carries no editing-guide comments — this table is the map):
| Section | Where to edit |
|---|---|
| Hero | <section id="hero"> — headline, tagline, stat tiles |
| About | <section id="about"> — three paragraphs + sidebar facts |
| Experience | <section id="experience"> — duplicate <li id="role-..."> per role |
| Education | <section id="education"> — duplicate <article class="edu-card"> per degree |
| Skills | <section id="skills"> — each <li class="skill" data-level="N"> (N = 0–100) |
| Contact form | <form id="contact-form" action="..."> — Formspree ID (see below) |
| Footer / brand | Top bar handle, footer line, social links |
The sidebar and command palette automatically pick up sections from the SECTIONS array at the top of assets/js/script.js.
- Add or remove a
<section id="...">inindex.html(or a<li id="role-...">inside the experience timeline). - Add a matching entry to the
SECTIONSarray inassets/js/script.js:- Top-level section:
{ id: "blog", label: "blog" } - Nested role under experience:
{ id: "role-acme", label: "acme", group: "experience", desc: "Senior Engineer" }
- Top-level section:
- Update the static fallback
<ul id="tag-nav-tree">inindex.html(used for SEO + no-JS users).
The form submits to Formspree (form ID maqrwrjd, in the action attribute of <form id="contact-form"> in index.html). Submissions are AJAX-posted by script.js and arrive by email; the email field becomes the reply-to and the _subject field becomes the notification's subject line. A honeypot (_gotcha) filters naive bots.
Form settings live at https://formspree.io → the form's Settings. Recommended there: restrict the allowed domain to samdonche.com so nobody else can post to the endpoint and burn the free-tier quota (50 submissions/month).
If the form ID is ever removed from the action, script.js auto-detects it and falls back to opening the visitor's mail client (mailto:) instead.
The contact email is never written as plaintext in the HTML — that keeps harvester bots (which don't run JS) from picking it up. It's stored base64-encoded in data-email attributes and assembled at runtime by wireEmailLinks() in assets/js/script.js. Two modes:
- Click-to-reveal (
data-email-reveal, the "reveal email address" link): the address stays out of the DOM entirely until the visitor clicks — the first click swaps in the real address + a workingmailto:, a second click opens the mail client. - Immediate (default, e.g. the footer "email" link): the
mailto:is wired on load; the visible label is replaced with the address unlessdata-email-text="false".
To change the address, encode it and update both the data-email attributes in index.html and B64_EMAIL in script.js:
printf '%s' 'you@example.com' | base64Trade-off: visitors with JavaScript disabled won't see the address (the links read "reveal email address" / "email" and the contact form's
mailto:fallback won't fire). The Formspree form remains the primary, JS-light path.
The site is English-only. Beyond the single-page index.html there are standalone subpages that reuse the same CSS/JS and design language:
- publications/ — research output (relocated off the main page, linked from the About sentence + the UZ Gent timeline card).
- case-studies/ — one directory per case study (e.g.
case-studies/factory-data-backbone/); surfaced in the "Selected work" section of the main page. Add a new one by copying an existing case-studyindex.html, then adding a card to the Selected-work section and a<loc>to the sitemap.
(A Dutch /nl/ mirror existed briefly and was removed on 2026-07-18 — it's recoverable from git history if ever wanted.)
Pageviews are counted by GoatCounter (site code samdonche — dashboard at https://samdonche.goatcounter.com). It sets no cookies and stores no personal data, so no consent banner is needed.
The tracker script is self-hosted at assets/js/goatcounter.js (a pinned copy of GoatCounter's count.js, ISC-licensed) so the only external request a visitor makes is the count ping to the GoatCounter endpoint. To refresh the pinned copy occasionally:
curl -s https://gc.zgo.at/count.js -o assets/js/goatcounter.jsThe script ignores localhost, so local development doesn't pollute the stats. The 404 page logs hits under a 404- path prefix, so broken inbound links surface in the dashboard.
The site lives on a Hostinger Business Web Hosting plan, served from public_html/ at the main domain samdonche.com. Because it's fully static, there's no build or runtime — Hostinger just serves the files.
# edit files locally, then:
git add -A
git commit -m "Update content"
git push origin mainHostinger's Git integration (hPanel → Advanced → GIT) is connected to this GitHub repo with auto-deployment on, branch main, root directory public_html. Every push to main is pulled onto the server automatically — no manual step. (You can also click Redeploy in hPanel, or trigger a manual deploy any time.)
- .htaccess — forces HTTPS, wires up the custom
404.html, and sets gzip + cache headers. HTML is cached only briefly so content edits appear quickly. - Free SSL — issued by Hostinger for samdonche.com (hPanel → Security → SSL); HTTPS is enforced via
.htaccess.
Hostinger runs LiteSpeed cache + a CDN edge cache. If an update doesn't show up after a deploy, purge the cache in hPanel (Cache Manager / Purge cache) — or wait for it to expire on its own.
samdonche.com is registered inside the same Hostinger account, so it's set as the plan's main domain in hPanel (Websites → Domains → Main domain) and its DNS points to Hostinger automatically — no external A/CNAME records needed.
All brand colors are defined in two places (kept in sync intentionally):
- Tailwind tokens — in assets/css/tailwind.input.css, inside
@theme { ... }. These power utility classes likebg-brand-400. Rebuild the CSS after changing them (see "Rebuilding the CSS"). - CSS variables — top of assets/css/styles.css, under
:root. These power custom components (network nav, timeline, etc.).
Change --color-brand-400 (currently cyan) to re-skin the whole site. Use a vivid, single-channel accent — the IIoT aesthetic relies on that "active sensor" pop against the dark slate background.
The decorative HUD card in the hero (<aside aria-hidden="true">) is purely visual — delete it if you'd rather have a photo or simpler hero.
| Keys | Action |
|---|---|
⌘K (Mac) · Ctrl+K (others) |
Open / close the command palette |
/ |
Same as ⌘K (when not focused in an input) |
↑ / ↓ |
Move selection inside the palette |
↵ Enter |
Jump to the selected tag |
Esc |
Close the palette (or the mobile sidebar) |
- Skip link; the sidebar is a labelled plain list (deliberately no ARIA
treerole — that would promise arrow-key semantics the nav doesn't implement) witharia-currentmarking the section in view; the palette keepsrole="dialog"+aria-modalwith full keyboard support. - Mobile sidebar opens via a labeled hamburger and traps body scroll while open;
Escand backdrop-click close it. prefers-reduced-motiondisables the LIVE pulse, reveal animations and palette enter animation.- Progressive enhancement: scroll-reveal is hidden only when JS is available (an inline script sets
html.js; the CSS hides.revealexclusively under.js). With JS off, all content renders fully — nothing depends on the observer firing. - Cache-busting: each CSS/JS include carries a
?v=<content-hash>query, stamped automatically bymake(scripts/stamp-assets.py). Editing an asset changes its hash, so returning visitors always get the new version despite the long asset cache in.htaccess— no manual version bumps. - No JS frameworks and no runtime CSS compilation — one small JS file + three static stylesheets (Tailwind is precompiled to ~25 KB minified). Lighthouse should score near-100 out of the box.
- Fonts are self-hosted (assets/css/fonts.css +
assets/fonts/): no visitor data ever reaches Google (GDPR — German courts have ruled Google Fonts embeds unlawful), and no third-party request can block rendering. Variable woff2 files, one per family+subset;unicode-rangemeans the latin-ext files are only downloaded if a page actually uses those characters. The two latin files are preloaded inindex.html(crossoriginis required on font preloads even same-origin). To change fonts or add weights outside Inter 300–800 / JetBrains Mono 400–600, fetch new woff2 files from Google Fonts (curl the CSS URL with a browser User-Agent to get woff2 sources) and updatefonts.css.
Personal site — use as a template if it's useful to you.