Every website has a skyline. Paste any public URL and walk through its real structure, rebuilt as an isometric pixel-art city.
Features • Page → City • Safe Capture • Tech Stack • How To Run • License
Not a screenshot dressed up as a city — the page's own structure, measured and rebuilt. Every part of the page becomes a district: its land is proportional to how much of the page it takes up, and the kind of content decides how the district is built.
- A list becomes a row of shopfronts, an index becomes low stacks, a media showcase becomes a tower of screens, and the hero becomes the landmark. The reading order of the page is the order the land is laid out, from the centre outwards.
- Nothing is decided by hostname. Regions are inferred from tag, position, size, content and class names, so two pages with the same shape get the same kind of city, whoever made them.
- Nothing is invented. Every building can be traced back to the part of the page it came from, and the app shows you which one.
| 🏗️ Live Construction | The page appears in a panel while the city rises; each part of the page lights up as its district is built and the numbers grow with it. Skip (or Esc, Space, Enter) jumps to the end. |
| 🗺️ One Zoomable City | Drag to move, scroll from the whole district down to street level, Q/E or Shift+drag to rotate. There is no separate explore mode: the city you watched being built is the one you walk around. |
| 🏢 From the Page | Click any building for a compact card with the part of the page it came from — a cropped excerpt of the real page, not a description of it. |
| ❓ Why This City? | A drawer with the site, four metrics, and the page's largest parts with what each one became. Point at a part (in the drawer or in the city) and a line ties the two together. |
| 🌤️ Page-Driven Atmosphere | Light, haze and environment are derived from signals in the page itself, so a dense news site and a quiet portfolio don't share the same evening. |
| 🪧 Doto Signs | Shop signs and billboards are lettered in Doto, the same bitmap face as the home screen. |
| 📮 Postcard | Save the current view as an image. H hides the interface for a clean shot. |
| 🔗 Deep Links | /pixel?url=… builds a site directly, and ?at=references (or any other part name) opens the city already focused on that district. |
| 🔍 Explore in More Detail | A second reading of the same page: it is opened in a real, sandboxed browser, and the city follows how the page is arranged on screen — what sits side by side and how large each item is. Standard view goes back. Pages that only build themselves in the browser are read that way from the start ("Read in a browser"). When the browser isn't available, the standard city stays and the app says why. |
| Typed addresses are checked before anything is fetched; an invalid one gets a bold title, one hint line and a highlighted field, cleared as soon as you edit. Blocked sites say why they're blocked. | |
| 📱 Narrow Screens | A small-screen layout: icon-only controls that never wrap, and a home and drawer sized for phones. |
| Construction | The city |
![]() |
![]() |
| Why this city? | |
![]() |
|
| Category | Technologies |
|---|---|
| Framework | Next.js 16 (App Router), React 19 |
| Language | TypeScript 5 |
| Rendering | Three.js, React Three Fiber, drei, postprocessing |
| State | Zustand |
| Styling | Tailwind CSS v4, Doto · Instrument Serif · Instrument Sans · Geist |
| Capture | parse5 (static HTML parsing), ipaddr.js (socket-level SSRF guard), Puppeteer + Chromium in a separate renderer service on Fly.io |
| Testing | tsx check scripts over a corpus of real pages, Puppeteer captures |
| Tooling | ESLint |
Skyline reads the real structure of a public web page and rebuilds it as an isometric pixel-art city. It isn't a visual filter over a screenshot: the server captures the page, reduces it to structure and metrics, and the client turns that structure into territory, buildings, streets and street life.
Each part of the page — hero, navigation, feed, index, references, footer — becomes a district. How much land it gets follows how much of the page it occupies; what kind of district it becomes follows what kind of content it holds. The result is that a news front page, a documentation site and a product landing page produce recognisably different cities, and you can always ask the city why.
Additional features:
- The home is a city too: the landing screen is a whole kit city in morning light, seen from a little lower than the City View, with Apple, Hacker News, Linear and The Guardian as one-click examples.
- Page map: the page redrawn from what the pipeline already knows — reading order, headings, links, real images, colours and type — without inventing content. It is the page you watch light up during construction, and the source of the cropped excerpt on every building card.
- Real images through a signed proxy: images from the page are served through an HMAC-signed asset route, so the proxy can't be used as an open relay.
- Short-lived cache and rate limiting: a page captured in the last 5 minutes is answered from cache, and captures are rate-limited per client.
The pipeline is split between a server that captures and a client that builds:
URL ─▶ /api/capture ─┬─▶ static capture ───────┬─▶ DomSnapshot ─▶ normalize() ─▶ NormalizedDocument
└─▶ renderer (Chromium) ──┘ │ (client)
analyzeSemantics ─▶ computeFingerprint ─▶ planFromPage ─▶ generateKitDistrict ─▶ PixelScene
(regions) (visual identity) (territories) (kit city) (three.js)
- Capture — static first: the server fetches the HTML and reads it. Pages that only build themselves in the browser, and the optional Explore in more detail, go through the renderer service (
renderer/), which opens the page in Chromium and returns it rendered and measured. The app reads that page with the same parser and the same refusals as the static one. - Semantics — page regions (hero, navigation, feed, index, references, footer…) are inferred from tag, position, size, content and class names, never from the hostname.
- Allocation — territories fill 256 lots in reading order, from the centre outwards, with land proportional to each one's weight.
- City — composition, massing, program, façades, streets, street life and atmosphere all come from the kit (
src/lib/pixelcity/kit). Each layer is checked against a corpus of real pages, on its own and switched on and off. - Page map — the page redrawn from the same data the city was built from, so the two always agree.
Fetching arbitrary URLs on a server is the riskiest thing this app does, so the capture is built defensively:
- The client never receives HTML — only structure and metrics.
- SSRF is closed at the socket level — DNS is resolved and validated, the connection goes to the validated IP, and every redirect is validated again (
net-guard.ts,safe-fetch.ts). - Signed image proxy — real images are served only for URLs the server signed itself (
signing.ts). - The browser runs elsewhere — rendered captures run in a separate service (
renderer/, on Fly.io), never on the app's host. Every request to it is HMAC-signed, and it holds none of the app's secrets. - Sandboxed and unprivileged — Chromium keeps its sandbox (seccomp, its own user and network namespaces) and runs as an unprivileged user, one browser per capture, with time, memory, request and size limits.
- One way out — the browser reaches the internet only through an egress proxy that applies the same SSRF guard at the socket: HTTPS on port 443, DNS resolved and checked in the proxy, private and internal addresses refused. WebRTC and WebTransport are removed from every page.
- A hard monthly budget — the renderer counts its own awake time on a volume. After 50 hours in a month it stops, gives up its public addresses and takes itself out of routing, so nothing from outside can wake it (operation and costs).
- No evasion — sites behind a bot challenge (Cloudflare and the like) or a login are refused with an explanation. Skyline doesn't try to get around them.
The hardest part was making the city mean something without cheating. A city that looks good is easy to fake with a hostname lookup; a city that reflects the page needed a semantic layer that works from structure alone, an allocation that keeps land honest to each part's weight, and a building kit expressive enough that a list, an index and a media wall read as different places from the street. Every stage is checked by its own script against a corpus of real, frozen page snapshots (docs/real-pages/snapshots), from news and documentation to shops and the old web.
The second one was changing the look without breaking the meaning. The city is built in layers — the page model and the land first, then streets, street life, atmosphere and the drawing of rooftop plants and billboards — and each upper layer can be switched off. The checks build every corpus page with a layer on and off and require the difference to stay inside that layer: the polish never changes a decision, the atmosphere only touches the sky, sun and ambient light, street life never moves a block, and the art direction never moves the land. Earlier milestones stay in git as tags (foundation-v1, art-direction-v1, visual-polish-v1).
Clone the repository:
git clone https://github.com/maricastroc/skylineInstall the dependencies:
npm installStart the service:
npm run dev⏩ Access http://localhost:3000 (it redirects to
/pixel), paste a URL and press Build. Direct link:http://localhost:3000/pixel?url=https://news.ycombinator.com.
To try Explore in more detail locally, start it with the local renderer. It opens pages in the Chrome installed on your machine, behind the same egress proxy, and only works in development (set
CHROME_PATHif Chrome isn't in the default macOS location):
SKYLINE_LOCAL_RENDERER=1 npm run devThen open
http://localhost:3000/pixel?url=https://books.toscrape.com/&mode=detailed. To run the renderer as its own service, and to deploy it, see RENDERED_CAPTURE_PRODUCTION.md.
Run every check over the real-page corpus (
test:polishpaints the signs in Chrome; setCHROME_PATHif it isn't in the default macOS location):
npm testRun one stage (also
test:warnings,test:hygiene,test:surface,test:openings,test:composition,test:program,test:streets,test:life,test:atmosphere,test:polish,test:css):
npm run test:allocationRun the capture checks, which open a real Chrome (
test:rendered,test:egress,test:renderer):
npm run test:captureGitHub Actions runs
lint,typecheck,npm test,test:capture,renderer:buildandbuildon every push tomainand on every pull request (ci.yml).
Run capture → normalization in the terminal and print a summary:
npm run probe -- https://news.ycombinator.com| Route | What it is |
|---|---|
/pixel |
The product |
/pixel/kit?page=<id> |
The city of a corpus snapshot (docs/real-pages/snapshots), with debug views (debug=provenance, surface, streets) |
/pixel/compare |
Several cities under the same camera |
/maquette |
The 3D maquette from the first version |
| Document | |
|---|---|
| PIXEL_FOUNDATION_V1.md | Semantic and architectural base |
| PIXEL_ART_DIRECTION_V1.md | Streets, street life and atmosphere |
| PIXEL_VISUAL_POLISH_V1.md | Haze and assets |
| PIXEL_PRODUCT_COMMUNICATION.md | Page → city in the interface |
| ARCHITECTURE.md | Capture, security and the original maquette |
| RENDERED_CAPTURE_PRODUCTION.md | The renderer service: design, budget, deploy and operation |
- Pages that build themselves in the browser: the static capture only sees the HTML the server sends. Such pages, and Explore in more detail, need the renderer service, which the app reaches with
SKYLINE_RENDERER_URLandSKYLINE_RENDERER_SECRET. Without those, or while the service is unavailable, the city is built from the HTML alone and says so ("Scaffolding only"). - The renderer has limits: 100 new captures in the browser a day across the whole site, and 50 hours awake a month. Past the daily limit, detailed exploration pauses until midnight UTC. Past the monthly one, it stays off until it is switched back on by hand. The standard city keeps working in both cases (operation).
- Rendered readings can differ by place: the renderer browses from a datacenter in the US, and some sites show different content there.
- Blocked sites: pages behind a bot challenge or a login are refused with an explanation.
- In-memory state: rate limiting, cache and the signing secret live per process. With more than one instance, set
SKYLINE_ASSET_SECRETand use a shared store.
Released under the MIT License. You're free to use, study, fork and build on this code — as long as the original copyright and license notice are kept. Reuse it and learn from it; don't strip the attribution and present it as your own.
© 2026 Mariana Castro
⭐ If you like this project, give it a star on GitHub!



