No description
  • TypeScript 52.9%
  • Astro 25.2%
  • JavaScript 17.9%
  • CSS 3.8%
  • Dockerfile 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tiago Águeda 6910635b41
All checks were successful
ci / build (push) Successful in 26s
FR: use "Génie physique" in the headline, home eyebrow and teaching role
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 16:01:39 +01:00
.forgejo/workflows Error pages, merger banner, content, performance, sketches, tests, CI and OVH deploy 2026-11-13 14:49:57 +01:00
.vscode Initial site: Astro, EN/FR/PT, Lorenz-butterfly hero, animated banners 2026-11-02 12:19:46 +01:00
public Use the Águeda logo for the header, favicon and app icons 2026-11-13 15:37:00 +01:00
scripts Use the Águeda logo for the header, favicon and app icons 2026-11-13 15:37:00 +01:00
src FR: use "Génie physique" in the headline, home eyebrow and teaching role 2026-11-13 16:01:39 +01:00
tests Use the Águeda logo for the header, favicon and app icons 2026-11-13 15:37:00 +01:00
.dockerignore Initial site: Astro, EN/FR/PT, Lorenz-butterfly hero, animated banners 2026-11-02 12:19:46 +01:00
.gitattributes Normalise line endings to LF 2026-11-02 12:20:12 +01:00
.gitignore Error pages, merger banner, content, performance, sketches, tests, CI and OVH deploy 2026-11-13 14:49:57 +01:00
AGENTS.md Initial site: Astro, EN/FR/PT, Lorenz-butterfly hero, animated banners 2026-11-02 12:19:46 +01:00
astro.config.mjs Initial site: Astro, EN/FR/PT, Lorenz-butterfly hero, animated banners 2026-11-02 12:19:46 +01:00
Dockerfile Initial site: Astro, EN/FR/PT, Lorenz-butterfly hero, animated banners 2026-11-02 12:19:46 +01:00
package-lock.json Initial site: Astro, EN/FR/PT, Lorenz-butterfly hero, animated banners 2026-11-02 12:19:46 +01:00
package.json Use the Águeda logo for the header, favicon and app icons 2026-11-13 15:37:00 +01:00
README.md Use the Águeda logo for the header, favicon and app icons 2026-11-13 15:37:00 +01:00
tsconfig.json Initial site: Astro, EN/FR/PT, Lorenz-butterfly hero, animated banners 2026-11-02 12:19:46 +01:00

tiagoagueda.com

Personal website of Tiago Águeda. Trilingual (English, French, Portuguese) static site built with Astro. The home page draws the Lorenz attractor live in WebGL and morphs it into a 3D butterfly with Three.js.

Develop

npm install
npm run dev          # http://localhost:4321/  (redirects to /en/, /fr/ or /pt/)
npm run build        # type-checks, then writes the static site to dist/
npm run preview      # serves dist/ the way Astro would
npm run serve        # serves dist/ with sirv on port 8080 (what the Docker image runs)
npm test             # content checks, plus smoke tests on dist/ (build first)
npm run sketches     # regenerate the static SVG sketches in public/sketches/
npm run check        # build, then test: what CI runs

Node 22 or newer is required.

Where things live

Path What
src/i18n/ui.ts Every visible interface string, per language. Pages never hard-code text.
src/i18n/utils.ts Locale helpers: getLangFromUrl, useTranslations, localizePath, alternatesFor, date formatting.
src/content/profile.json Name, email, links, location and headline ({ en, fr, pt } fields). publishedAs is the name on papers (Tiago A. Oliveira): the CV states it and highlights it in author lists. Phone number and street address are deliberately absent. Validated and typed by src/data/profile.ts.
src/assets/portrait.(jpg|png|webp|avif) Optional portrait. When present it appears, cropped to a circle and optimised, in the CV header and the About sidebar (src/components/Portrait.astro). Remove the file to hide it.
src/content/cv/experience/<lang>/*.md One file per role and language. Front matter: org, role, location, start, end (YYYY-MM, null = current), tags, translationKey. The body is the description: a paragraph, then bullets.
src/content/cv/*.json education.json, publications.json, skills.json, languages.json: arrays of records with an id and { en, fr, pt } fields where text differs by language. Publications take optional authors, journal, volume, pages, year, doi (bare, e.g. 10.1021/nl500932q) and url (defaults to the DOI link).
src/data/cv.ts Typed, sorted accessors over those collections (getExperience(lang), getPublications(), …). Schemas live in src/content.config.ts.
src/content/blog/<lang>/*.md Notes. Front matter: title, description, date, tags, draft, translationKey.
src/content/projects/<lang>/*.md Project cards. Front matter: title, summary, period, tags, links (a list of label and url: repositories, papers, demos), order, translationKey.
src/content/pages/<lang>/about.md Body of the About page.
src/scripts/lorenz-butterfly.ts The hero animation. Lorenz RK4 integration, lobe-to-wing morph, butterfly mesh, bloom.
src/scripts/banner-fractal.ts About page banner: Julia set in a WebGL2 fragment shader, zooming endlessly into its repelling fixed point.
src/pages/dev/[tool].astro Development-only preview of the banners at /dev/banners/ (not built in production).
src/scripts/banner-wave.ts CV banner: two translucent sheets waving in a band across the frame, fading at the sides, with drifting sparks, after the PS3 cross-media-bar background. The pointer swells the front wave where it hovers.
src/scripts/banner-merger.ts Not on a page yet: two black holes spiral together on a sheet of spacetime rippled by their chirping gravitational waves (quadrupole spiral with retarded time, Newtonian chirp, ringdown), looping seamlessly. Preview at /dev/banners/?only=merger.
src/scripts/banner-lattice.ts Projects banner: zincblende-type lattice of clear, softly tinted glass marbles (refraction, dispersion, a warm backlight, one orbiting light). Bonds are springs: grab a marble with the pointer and pull it; the lattice deforms and relaxes back.
src/scripts/banner-rain.ts Notes banner: falling Greek letters and math symbols on depth planes the camera flies through (planes grow, blur and fade as they pass, then recycle far away); fresh glyphs glow and fade with age; the camera tilts slowly at random; the pointer adds parallax and seeds streams from the top edge at its x position.
src/scripts/theme-colors.ts Shared helpers: read theme tokens, react to theme changes, pause loops off-screen, cap render resolution and the quality governor (see Performance).
src/scripts/dev-perf.ts Development-only frame-time probe: add ?perf to any page.
src/scripts/gpu.ts, src/scripts/banner-loader.ts, src/scripts/sketch.ts Low-resource mode: detect a hardware GPU, mount a banner or its sketch, fetch and inline a sketch. See below.
scripts/make-icons.mjs, public/logo*.svg, icons The Águeda logo: logo.svg (also favicon.svg) and the animated logo-runner.svg used in the header, with the viewBox tightened to the mark. PNG and ICO fallbacks (logo.png, favicon.ico, apple-touch-icon.png, manifest icons) are rendered from the SVG by npm run icons, which reads ../agueda-logo/ by default.
scripts/make-sketches.mjs, public/sketches/*.svg The sketch generator and its output: hand-drawn-looking SVGs of each animation, computed from the same maths.
tests/ content.test.mjs checks the sources (every role and project in every language, unique ids, complete translations, DOIs, no phone number or address); site.test.mjs checks dist/ (same pages per language, no broken internal links or hreflang targets, drafts and dev pages excluded, RSS and sitemap complete, error pages). Node's built-in test runner, no dependencies.
.forgejo/workflows/ci.yml CI on the Forgejo server: install, type-check, build and test on every push to main and every pull request.
src/components/Hero.astro Mounts the hero animation and holds the per-theme colour palettes.
src/components/PageHeader.astro Page title block that lazy-loads one of the banner animations.
src/styles/tokens.css Colour and type tokens for dark (default) and light themes.

Adding a note

Create src/content/blog/en/my-note.md (and fr/, pt/ versions if you want them). Give all translations the same translationKey so the language switcher jumps between them. draft: true keeps a note out of production builds and RSS while still showing it in npm run dev.

Languages and routing

All pages are prefixed: /en/…, /fr/…, /pt/…. The root / is a tiny page that redirects to the remembered language, then the browser language, then English. Each page emits hreflang alternates, and sitemap-index.xml and per-language rss.xml are generated at build time.

Deploy

The build output in dist/ is plain static files and runs on any web server. Point the server's 404 handler at dist/404.html and its 5xx handler at dist/error/500/index.html. Both error pages show the landing page, which then crumbles to the bottom of the screen and leaves only the error message (src/components/ErrorScene.astro).

OVH web hosting (current target)

The site runs on any OVH shared hosting plan with SSH (Pro and above). public/.htaccess ships with the build and configures Apache: HTTPS and www. redirects, the 404 and 500 error pages, compression, long caching for hashed assets and security headers.

One-time setup in the OVH control panel:

  1. Multisite: add www.tiagoagueda.com and tiagoagueda.com, both pointing at one folder, for example tiagoagueda-website. Enable the free SSL certificate for both.
  2. SSH: note the SSH login and host (login@ssh.clusterNNN.hosting.ovh.net) and add your public key to ~/.ssh/authorized_keys on the hosting.

Deploy from your machine (needs ssh and tar, both built into Windows 10+):

npm run build
OVH_SSH=login@ssh.clusterNNN.hosting.ovh.net OVH_DIR=tiagoagueda-website npm run deploy -- --dry-run
OVH_SSH=login@ssh.clusterNNN.hosting.ovh.net OVH_DIR=tiagoagueda-website npm run deploy

Instead of exporting variables each time, put them in a git-ignored .env.deploy file (OVH_SSH, OVH_DIR, OVH_SSH_KEY, OVH_SSH_KNOWN_HOSTS); npm run deploy reads it. Authentication is by SSH key only: the script never asks for a password.

The script streams dist/ as a tarball over SSH into <dir>.incoming, then swaps it in with two renames, so visitors never see a half-uploaded site; the previous version stays in <dir>.previous (rename it back to roll back). It only replaces a folder that is empty, missing, or was deployed by it before; pass --init once if the folder already holds files you mean to replace.

CI deploys automatically on every push to main once these repository secrets are set in Forgejo: OVH_SSH, OVH_DIR, OVH_SSH_KEY (a private key used only for deploys) and OVH_KNOWN_HOSTS (the output of ssh-keyscan ssh.clusterNNN.hosting.ovh.net). Without them the deploy step is skipped.

Docker (Node static server)

docker build -t tiagoagueda-website .
docker run --rm -p 8080:8080 tiagoagueda-website

The image builds the site and serves dist/ with sirv (gzip and brotli, etags). Set PORT to change the port.

nginx or Caddy

Serve dist/ as the document root. For nginx add error_page 404 /404.html; and error_page 500 502 503 504 /error/500/index.html;. For Caddy use file_server with handle_errors rewriting to /404.html or /error/500/index.html by status.

Continuous integration

.forgejo/workflows/ci.yml runs npm ci, npm run build and npm test in a node:22 container on every push to main and every pull request. It runs on the server's default ubuntu-latest runner label. After the tests pass on main, it deploys to OVH (see above).

Design notes

Dark-first palette (#0b0c0f), Inter for text and JetBrains Mono for data, warm orange-to-pink accent. Buttons, the language pill and the theme toggle use a shared "liquid glass" treatment defined in src/styles/global.css (.glass): tinted translucent surface, a faint hairline edge with diffuse light from above, a low soft shadow and a slowly drifting highlight. Every animation respects prefers-reduced-motion (a still frame is drawn instead), pauses when the tab is hidden or the element is off-screen, and recolours itself when the theme changes.

Performance

Measured on an integrated AMD Radeon GPU through Chrome/ANGLE (Direct3D 11), with ?perf: every animation holds 60 fps at 2x HiDPI and at a phone-sized pixel load. What it took:

  • Resolution cap. WebGL scenes render at most 1.5 device pixels per CSS pixel and the rain canvases at 1.25; the content is soft and glowing, so the difference is not visible but the fill cost falls by up to 45 %.
  • Quality governor. If most frames over about 1.5 s run below 40 fps, a scene lowers its render scale one step (to 50 % at most) and stays there. It ignores one-off stalls such as shader compilation. On a software renderer it took the hero from 2 to 6.6 fps.
  • Hero trails as a ring buffer. Each step overwrites the oldest point instead of shifting 82 000 points, and only the new block is uploaded; the fade along each trail is computed in the shader from the point's age. Re-uploading the whole 3.9 MB buffer every frame had been ~90 % of the hero's cost.
  • Shader precompilation. The hero compiles the butterfly's materials in the background at start-up, for both the screen and the bloom buffer; otherwise the first morph froze the page for ~0.7 s.
  • Lighter glass. The lattice and black-hole sheet dropped dispersion and iridescence and use fewer sphere segments; side-by-side renders were indistinguishable and the GPU time fell by about 40 %. Transmission buffers render at half resolution.

Not measured: a real phone GPU (roughly four times slower than the laptop GPU above) and long-session thermal throttling. The governor exists for exactly that case.

Low-resource mode: sketches instead of animations

Machines without a hardware GPU (WebGL emulated on the CPU by SwiftShader, llvmpipe or Microsoft Basic Render, or no WebGL2 at all) get a static SVG sketch in place of each animation: the Lorenz attractor with its fixed points and equations on the home page, the Julia set's escape-time contours on About, the symbol rain on Notes, the zincblende cell on Projects, the waving ribbons on the CV. Detection uses failIfMajorPerformanceCaveat and the WebGL renderer name. The sketches are fetched only when needed, so GPU users download nothing extra; inlined, they draw with the page's colour tokens and follow the theme.

An animation that starts but stays below 40 fps even at its lowest resolution swaps itself for its sketch after a few seconds, logs why in the console, and the rest of the session uses sketches. ?lowpower forces sketches and ?animate forces animations, on any page, to preview either mode.

The SVGs are generated, not drawn by hand: npm run sketches recomputes them (deterministic, seeded) and a test fails if the committed files drift from the generator.

Animation debug (development only)

?perf (or ?perf=8 for an 8 s window) on any page logs one [perf] line to the console after a 4 s warm-up: GPU, canvas megapixels, fps and frame-time percentiles.

/en/?t=12.6 starts the hero timeline at 12.6 s (the butterfly stage). The timeline is attractor 8 s, morph 2.5 s, butterfly 6 s, dissolve 2.5 s.

/dev/banners/ shows the page banners large and unmasked (/dev/banners/?lowpower shows their sketches). Query parameters: only=fractal|lattice|rain|wave|merger (one banner, full height), t=<seconds> (advance the fractal's clock; the zoom reaches its endless loop after about 18 s), mask=1 (apply the page gradient mask), theme=light|dark, still=1 (the reduced-motion frame), mouse=x,y (fake a pointer at those viewport pixels).

How the Julia zoom loops

The camera dives into β, the repelling fixed point of z² + c. Near β the set is invariant under w → λw with λ = 2β, so once the window is deep enough a zoom by |λ| together with a turn by arg λ reproduces the same image; the zoom factor wraps there and the motion never ends. Coordinates are kept relative to β (iterating w ← w(2β + w)), which keeps float precision at depth. c drifts slowly just inside the main cardioid of the Mandelbrot set so the Julia set stays connected and paints cleanly.