What is new here, in five lines
- The metaphor is not an animation, it is the architecture. Every window is a static page with its own
<title>, description and canonical; the desktop only keeps the topmost one in sync with the address bar. - One single Preact island. The rest of the site is build-time HTML. The text of every window ships in that HTML even when the window is closed, so it indexes like any ordinary page.
- Exporting the CV does not generate a new document: it promotes the window already on screen to an A4 sheet with
@media print. There is no second copy of the CV that could go stale. - Windows mount the first time they open. A URL serves its own content and only its own; opening
/settingsdoes not drag along the CV, the gallery and four projects. - The two languages are addresses, not a
localStorageflag./cvand/es/cvare distinct pages with their ownhreflang; switching language navigates rather than repaints.
What it is
A personal portfolio built on a desktop metaphor: menu bar, icons, draggable windows, a dock, light/dark theme, a trash can with jokes in it and a terminal drawer running real CPython. It looks like a toy and underneath it is a plain static site, which was exactly the point: the gimmick only counts if it is not paid for in SEO, weight or accessibility.
Stack: Astro 5 (static output) · Tailwind v4 · one Preact island · Cloudflare Pages.
Size: 48 TS/TSX/Astro files, ~7,900 lines, 35 generated pages, 2 languages.
Interesting architecture decisions
1. One URL per window, and the server serves the window
Every window sits in a route map (routes.ts) and a static page comes out of it. Adding an entry publishes the window; removing it unpublishes it. The island is told which one to open on mount, so /photos arrives with the gallery already open in the HTML, with its own <title> and canonical — which is what a search engine indexes and what shows when the link is shared.
Slugs are in English and there is exactly one per window: the language switcher changes the content, not the address. The old Spanish slugs stay alive as 301s.
2. Windows mount on open, not all at once
Window returns null until it is mounted, and useWindowManager starts with the URL’s window and only that one. Without it, each of the 35 pages would load the whole CV, the gallery, the changelog and five projects in order to show a single window.
It is the kind of decision that also fixes what you cannot do: a button in the contact window calling a bare window.print() would print a blank sheet, because on /contact the CV window does not exist yet. That is why exporting from another window opens the CV first and waits for it to be in the DOM.
3. Printing is exporting
There is no PDF uploaded to public/ to regenerate every time a line of the CV changes, and no separate template laying it out. The print stylesheet hides the whole desktop and promotes .win[data-win="experience"] to an A4 page, whether or not its window happens to be open.
The intended consequence: Cmd+P on any page of the site produces exactly the same document as the button. One markup, zero drift.
4. The weight that travelled hidden in an attribute
The island receives its props serialised into an HTML attribute. Sending it all four projects whole in both languages was 114 KB — 81% of every page — repeated across every page of the site, including /settings and /trash, which show no projects at all.
Now only the metadata for the page’s language travels, plus the body of the one project that page opens. The home page went from 146 KB to 39 KB. The argument that closes it: nobody indexes an attribute, so that weight was never content, it was ballast.
5. Language lives in the URL, not in the browser
While Spanish was picked client-side only, both versions were the same page to a search engine: one address, one HTML, two contents depending on what localStorage said. Now /es/... are real static pages, with Spanish HTML, their own canonical and an hreflang pointing at the English one.
Both dictionaries (copy/es.ts and copy/en.ts) have the exact same shape — the Copy type is derived from the Spanish one — so adding a key in one language forces TypeScript to demand it in the other. A half-finished translation does not compile.
6. What gets translated and what does not, kept apart on purpose
Colours, file names, coordinates and hrefs live in site.ts; text lives in copy/. They are joined by id. If the phone number were written into both dictionaries it would eventually diverge, and nobody would notice until a visitor dialled the old one.
The same rule governs projects: prose lives in content/projects/<language>/<key>.md and everything identical across languages — year, status, stack, links, order — in a single map. A .md with no entry in the map fails the build on purpose, and so does an entry with no .md: it cannot be left half done.
7. Readable for whoever does not paint the HTML
One <h1> per page and a <main> separating the content from the system chrome — the menu bar and dock repeat identically across all 35 pages — structured data generated from the same arrays that paint the screen, and /llms.txt: the site as Markdown, with no desktop around it, for whoever reads the HTML without drawing it. It is an endpoint, so it cannot go stale when a window is added.
What makes it different
- The terminal really runs Python — CPython 3.14 on WebAssembly, in your tab — and only downloads if you type the command. It has its own entry in this same index.
- The CV is exported from the site that shows it, with no attachment left to age.
- A changelog that is part of the product. The desktop’s versions live in a window, dated, and the first one supplies the
dateModifiedin the metadata: text with no verifiable date is text nobody knows is still current. - Light/dark theme with no flash, resolved before the first paint.
In one line
A portfolio shaped like an operating system, where the interesting part is not the metaphor but what the metaphor is not allowed to cost: every window is still an indexable, lightweight, translated static page, and the CV you download is literally the one you are looking at.