Skip to content
maricastrocPublic

About

Paste a URL and watch any website become an isometric pixel-art city. Each page section becomes a district; content weight decides what gets built.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation


Skyline
Skyline

Every website has a skyline. Paste any public URL and walk through its real structure, rebuilt as an isometric pixel-art city.

Next.js React TypeScript Three.js React Three Fiber Tailwind CSS

CI

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.


🌆 Every page becomes a city

Skyline's home: a whole pixel-art city in morning light, waiting for an address

  • 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.

✨ Features

🏗️ 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.
⚠️ Clear Feedback 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.

🖼️ Screenshots

Construction The city
Construction: the page in a side panel, its parts lighting up as the city rises The finished pixel-art city of a real website
Why this city?
The Why this city drawer: the page's largest parts, how much land each got and what each became

🧰 Tech Stack

Next.js React TypeScript Three.js React Three Fiber Tailwind CSS Zustand

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

📝 Project Description

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.

🧱 From page to city

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.

🔒 Safe capture

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.

🛠️ Engineering challenges

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).


ℹ️ How to run the application?

Clone the repository:

git clone https://github.com/maricastroc/skyline

Install the dependencies:

npm install

Start 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_PATH if Chrome isn't in the default macOS location):

SKYLINE_LOCAL_RENDERER=1 npm run dev

Then 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:polish paints the signs in Chrome; set CHROME_PATH if it isn't in the default macOS location):

npm test

Run 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:allocation

Run the capture checks, which open a real Chrome (test:rendered, test:egress, test:renderer):

npm run test:capture

GitHub Actions runs lint, typecheck, npm test, test:capture, renderer:build and build on every push to main and on every pull request (ci.yml).

Run capture → normalization in the terminal and print a summary:

npm run probe -- https://news.ycombinator.com

🧭 Routes

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

📚 Documentation

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

⚠️ Known limits

  • 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_URL and SKYLINE_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_SECRET and use a shared store.

📄 License

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!

About

Paste a URL and watch any website become an isometric pixel-art city. Each page section becomes a district; content weight decides what gets built.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages