Personal site for Axel Rosso, served by GitHub Pages from main at the repo root.
Static HTML, one stylesheet, one script. No build step, no framework.
index.html page content
assets/style.css design system, dark only
assets/app.js theme, language, scroll system, heatmap
assets/i18n.js the Spanish half of the page, keyed by English
assets/interact.js pointer effects, word splitting, counting stats
assets/mascots/ hornero (dametrabajo) and carpincho (Nuchus)
data/github.json refreshed daily by Actions, committed to the repo
scripts/fetch_github.py the fetcher
.github/workflows/refresh-data.yml
python -m http.server 8000Then open http://localhost:8000. Opening index.html straight from the filesystem
works too, except the fetch of data/github.json is blocked by CORS, so the
heatmap stays empty.
GH_TOKEN=$(gh auth token) python scripts/fetch_github.pyThe contribution total is only interesting because it counts private work. For the
scheduled job to see it, add a repo secret named PROFILE_TOKEN: a fine grained or
classic personal access token with the read:user scope. Without the secret the
workflow falls back to the default Actions token and reports public activity only.
Related: turn on Settings → Public profile → Contributions → Include private contributions on my profile so the graph on github.com/MR-Axel matches this one.
GitHub Pages caches assets for ten minutes, which is enough to make a fresh deploy
look broken while you are iterating. index.html loads the CSS and JS with a ?v=N
query, so bump that number in both <link> and <script> whenever you change
assets/. The HTML itself is never cached that way.
Project cards, the "Also built" list, craft items, skills and copy are plain HTML
in index.html. The only generated part is the heatmap.
Every colour is a token on :root (dark) with an override inside
[data-theme="light"]. Nothing hardcodes a hex outside that block. The accent
splits in two on purpose: --accent is the fill and stays lime in both themes
because it is the brand, --accent-ink is the same idea as text and gets darker
in light mode, because lime text on white cannot be read.
An inline script in <head> sets data-theme and data-lang before the body
parses, so the page never flashes the wrong theme. Both fall back to what the
browser already knows: prefers-color-scheme and navigator.language.
assets/i18n.js is keyed by the English source: the element's innerHTML with
whitespace collapsed. Nothing in index.html needs an id or a data attribute,
and a string that reads the same in both languages (product names, stack chips)
is simply absent from the dictionary and left alone.
When you edit English copy, edit the matching key too, or that element silently stops translating. To find those, switch to Spanish in the browser and run:
const norm = h => h.replace(/\s+/g, ' ').trim();
const used = new Set([...document.querySelectorAll('[data-en]')].map(e => norm(e.getAttribute('data-en'))));
Object.keys(window.ES).filter(k => k[0] !== '_' && !used.has(norm(k)));An empty array means every key found its element.
Five fixed layers, each drifting at its own rate and some of them the other
way: .bg__wash (the colour gradients), .bg__grid (a 64px rule grid, masked
so it fades before it reaches an edge) and three .bg__orb bodies. app.js
writes one custom property per layer on .bg, and the children inherit them.
Every drift is a fraction of progress (0 to 1) rather than raw scrollY, so
the movement is bounded by construction: a layer can never travel far enough to
show an edge, however long the page gets.
The orbs also carry a slow ambient translate animation. It is on translate
and not transform on purpose, because transform is what JS is writing.
assets/interact.js is additive by construction: the page reads, animates and
translates without it. It stamps rich or plain on <html> and only the
first gets pointer effects, because a tilt on a phone is a tap that feels
broken. Everything pointer-driven writes a custom property inside one rAF and
lets CSS paint, so moving the mouse never triggers layout.
It splits headlines into per-word spans, which collides with the translator:
after a split, the element's innerHTML no longer resembles any dictionary key.
The handshake is one attribute. app.js caches the English source in
data-en and always matches against the cache rather than the live DOM, and
drops data-split from anything it rewrites. interact.js skips elements
that still carry the flag. Clear that flag unconditionally and every toggle
nests the spans one level deeper.
The SVG set is inline and hand-written, 24x24 on a 1.6 stroke with round caps, so the whole page draws from one hand. The generator that emitted it lives in the scratchpad, not the repo: the icons are content now, edit them in place.
The hero orbit places each chip with three rotations, not one:
rotate(--a) to put it on the clock face, translateY(-r) to push it to the
radius, then rotate(calc(--a * -1)) to stand the glyph back up. The inner span
counter-spins at the track's own duration for the same reason. Drop either and
the icons tumble.
The reveal is three states, not two: below and unseen, on stage, and is-past
above the top. The observer is never disconnected, so scrolling back up replays
the entrance. translate and scale are used instead of transform, because
they are separate animatable properties: a card can slide into place and lift on
hover at the same time without the two fighting over one property.
Everything stops under prefers-reduced-motion.