Building the Observatory

Overview

The home page of this site is not a hero banner with three feature cards. It is an observatory: a quiet, interactive map of the studio’s work, orbiting a central beacon under a field of stars.

That choice was deliberate. Watchlight Studio is a long-term independent practice—not a startup landing page. The hub should feel like a workshop you enter, not a pitch you scroll past. Navigation should reflect how the site is actually organized (sections, docs, devlogs) while reinforcing the brand symbols that matter here: observation, gravity, a steady light in the dark.

This devlog documents how the observatory works, why it was built this way, and what you would need to build something similar on your own stack.

Why an Observatory Instead of a Menu

Most Hugo + Hextra sites ship with a docs sidebar and a home page of cards. That is fine for reference documentation. It is less fitting for a studio whose identity is tied to craft, atmosphere, and a decades-long horizon.

The observatory solves three problems at once:

  1. Orientation — Visitors see the whole studio at a glance: About, Devlogs, Nucleate, Projects & Tools, each as a node on a ring around the center.
  2. Depth without clutter — Categories expand in place. Subsections appear as child nodes only when you ask for them. The default view stays calm.
  3. Brand expression — Slow rotation, amber glow trails, space dust, and occasional comets reinforce “watchlight under stars” without blocking the actual job of getting somewhere useful.

The motion layer is not decoration for its own sake. It signals that someone cared about how the page feels, which is consistent with how the studio talks about games and tools.

What Visitors Experience

At rest (first load)

  • Title and tagline at the top (data/observatory.yaml drives the tagline; the title comes from site config).
  • “What am I working on” button when a section marks itself with what_am_working_on: true in front matter (currently Nucleate).
  • Center node — a WS monogram on an amber beacon; full studio name remains in accessibility labels.
  • Category ring — top-level content sections evenly spaced around the center, linked to their section homes.
  • Idle rotation begins after a short delay (~100ms). The graph coasts up to a slow target speed (~2.8°/s). Labels stay upright; only positions rotate.
  • Trails — tapered amber wake particles behind orbiting nodes, scaling with radius and spin rate.
  • Space dust — a canvas layer behind the SVG: ~54 core particles plus wing stars on wide viewports, rotating slightly slower than the graph (~91% speed) for depth.
  • Comets — rare diagonal streaks (~55–95s apart, one at a time).
  • Hints — desktop: select, hover, scroll to zoom; touch: tap and pinch.

On interaction

  • Hover (fine pointer) pauses idle spin with phased braking—not a snap. The graph holds its current angle. Starfield persists; center beacon pulse softens.
  • While still rotating, hovering a node highlights only that node (others stay undimmed so transparent nodes do not reveal trails through them).
  • After settling, normal focus behavior returns: hover traces connection paths; unrelated nodes dim.
  • Click a category with children → expand: subsection nodes mount from the current orientation (even mid-spin). Child labels fade during continued rotation and restore on hover.
  • Click a category without children → navigate directly.
  • Click a leaf node → navigate to that page.
  • Click center → collapse back to home overview.
  • Scroll / pinch → zoom with inertia; view frames can chase expanded subgraphs.
  • Working-on button → expands and highlights the configured focus node, with an arrow cue on the graph.

Accessibility and restraint

  • prefers-reduced-motion: reduce disables rotation, trails, dust, and comets. The graph remains fully navigable.
  • Touch targets are enlarged on coarse pointers.
  • The viz is role="application" with an aria-label; primary navigation still works without motion.

Architecture: Build Time vs Runtime

The observatory splits cleanly into Hugo at build time and browser at runtime.

LayerResponsibility
Content treeSections and _index.md files define what exists
observatory-graph.htmlWalks site.Sections, emits JSON: center, categories, nodes, edges
observatory.yamlPresentation only: tagline, navbar label, working-on button text
observatory-working-on.htmlResolves what_am_working_on: true to a node id
layouts/index.htmlHome-only layout; loads CSS/JS via Hugo Pipes
observatory.htmlEmbeds graph JSON in data-graph, empty SVG scaffold
observatory.jsParses JSON, mounts SVG nodes/edges, handles motion and input
observatory.cssCharcoal/amber palette, node states, reduced-motion rules

Important design choice: the graph structure is derived from content, not hand-maintained. Add a subsection folder with an _index.md under Nucleate, and it becomes a node on the next build. Hide it with observatory: false. Tease a future section with observatoryUnrevealed: true (visible but not clickable).

That keeps the hub honest. The map always matches the vault.

File Map

layouts/
  index.html                      # Home page shell
  partials/
    observatory.html              # Viz container + data-graph attribute
    observatory-graph.html        # Section → JSON graph
    observatory-working-on.html   # Front matter → working-on target
    navbar-hub-brand.html         # "The Observatory" nav label

data/
  observatory.yaml                # Tagline, hub navbar, button label

assets/
  css/observatory.css             # Home-only styles
  js/observatory.js               # ~3000 lines: layout, motion, input

content/
  _index.md                       # Minimal front matter; layout is custom
  <section>/_index.md             # weight, linkTitle, observatory flags

Production builds minify and fingerprint CSS/JS in layouts/index.html. The observatory does not load on other pages.

Graph Generation (Hugo)

observatory-graph.html iterates visible top-level sections (observatory defaults to true), assigns each an angle on the ring (starting at 270°, stepped by 360 / count), and walks one level of subsections:

  • Category id → section name (e.g. nucleate, devlog)
  • Node id → path with slashes replaced by hyphens (e.g. nucleate-01-overview)
  • Edgesstudio → category, category → subsection
  • Overview nodes are synthesized in JavaScript when a category has children—a “Section home” link on the spoke between center and category

Front matter knobs:

observatory: false              # omit from graph
observatoryUnrevealed: true    # show but disable click
what_am_working_on: true       # working-on button target (first match wins)
weight: 2                      # ring order and sidebar order
linkTitle: "Display Name"      # node label

The graph JSON is injected once:

data-graph="{{ $graphPayload | jsonify }}"

No client-side fetch, no CMS API. Static site all the way down.

Runtime Structure (JavaScript)

observatory.js is a single IIFE. Constants at the top tune radii, rotation, trails, dust, comets, zoom, and touch—change behavior there rather than hunting magic numbers.

Core pieces:

  1. buildGraph() — Places center and categories in polar coordinates; registers edges from JSON.
  2. Dynamic children — Subsections are not in the initial DOM. syncChildNodes() mounts them when a category expands, positioned with layoutChildPositions() (arc layout on the parent spoke, inbound gap so labels do not sit on the center line).
  3. Rotation state machine — Phases: idle ramp, coast down (hover brake), hold at 0 RPM, resume after delay. Angular velocity eased, not toggled.
  4. Canonical vs display coordinates — Nodes store baseX/baseY in the unrotated frame; displayPosition() applies current angle. Labels counter-rotate so text stays readable.
  5. Trails — Particle pools per node; spawn rate and arc span tied to rotation.velocity.
  6. Dust — Full-width canvas; ResizeObserver keeps layout in sync; wing particle count scales with horizontal margin on ultrawide displays.
  7. Zoom — Spring-damped scroll zoom with focal point tracking; viewFrameActive can reframe expanded subgraphs.
  8. Focus tiersapplyFocus() dims non-path nodes when settled; featured/working-on mode uses path highlighting.

Initialization at the bottom: buildGraph(), mount center + categories, initZoom(), initSpaceDust(), startRotationLoop().

Motion and Interaction Decisions

These were iterated deliberately; they are worth preserving if you fork the pattern.

DecisionRationale
Brake to hold angle, not snap backFeels physical; expanding at current orientation respects user context
Resume spin after 100ms hover exitAvoids flicker when crossing nodes
Child labels fade during spinReduces visual noise; center + category labels stay
Opaque node backing while rotatingPrevents trail particles showing through SVG transparency
Dust at 91% of graph spinParallax depth without seasickness
Comets infrequentReward attention without carnival energy
prefers-reduced-motion hard offNon-negotiable for accessibility
Graph from sections, not manual JSONSingle source of truth with the rest of the site

The observatory should feel like a quiet instrument, not a demo reel.

How to Recreate Something Like This

You do not need Hugo specifically. The pattern generalizes.

1. Model your site as a graph

  • Center = home / org root
  • Ring = primary areas (products, docs, blog, about)
  • Children = lazy-loaded when a ring node expands
  • Edges = hierarchy only (not arbitrary force-directed mess—readability matters)

Generate the graph from your content CMS, filesystem, or build step so you never maintain two maps.

2. Use SVG for structure, canvas for atmosphere

SVG excels at clickable nodes, crisp text, and filters (glow). Canvas excels at hundreds of cheap particles. Layer canvas behind SVG; keep pointer events on SVG only.

3. Separate layout from presentation

Store base positions in an unrotated coordinate system. Apply rotation as a transform pass each frame. Text stays upright via counter-rotation or position-only updates—never spin labels with the graph.

4. Phase your motion

Instant on/off rotation feels cheap. Use velocity integration: ramp up, coast down, hold, resume. Tie visual effects (trail opacity, dust intensity) to angular velocity, not a boolean “isSpinning”.

5. Respect input context

  • (hover: hover) and (pointer: fine) vs (pointer: coarse) — different hit radii, hints, and focus rules
  • Debounce hover resume so brief pointer crossings do not restart spin
  • Expand-on-click while braking should still work—users should not wait for physics to finish

6. Static embed, no runtime graph API

Serialize graph JSON into the page (or a small static .json file). For a studio site, the graph is small and changes at deploy time. Keep it simple.

7. Tune from constants

You will adjust rotation speed and trail length more often than you refactor architecture. Group tunables at the top of one file and document what each does (this repo’s AGENTS.md summarizes the observatory constants for future editors).

Technical Prerequisites

To build and maintain this observatory as implemented:

  • Hugo with custom home layout and partials (or equivalent static generator + templating)
  • Comfortable SVG DOMcreateElementNS, viewBox, transforms, preserveAspectRatio
  • Animation loop disciplinerequestAnimationFrame, delta time, exponential easing
  • Basic polar coordinates — layout on rings and arcs
  • Canvas 2D for particle fields (optional but worth it for depth)
  • CSS custom properties for brand palette (--wl-charcoal, --wl-amber-soft, etc.)
  • Accessibility habits — reduced motion, keyboard-focus styles, meaningful aria on controls

Optional but used here: Hugo Pipes for minify/fingerprint, ResizeObserver for dust layout, matchMedia for pointer/hover capability queries.

Rough size: ~770 lines CSS, ~3000 lines JS, ~200 lines of Hugo templates for the feature—not trivial, but bounded. Most complexity lives in motion polish, not graph algorithms.

Extending the Observatory

Common tasks:

  • New hub node — Add a content section with _index.md; set weight for ring order.
  • Hide a sectionobservatory: false on that section’s index.
  • Change tagline or button labeldata/observatory.yaml.
  • Point “working on” elsewhere — Move what_am_working_on: true to the target section’s _index.md.
  • Tune motion — Constants at top of assets/js/observatory.js.
  • Navbar hub namehubNavbar.label in data/observatory.yaml (shows “The Observatory” on the home link).

Avoid hand-editing node lists in JavaScript. If the graph lies, trust erodes.

Reflections

The observatory was one of the largest personalization investments on this site, and it paid off in identity: you know you are at Watchlight Studio before you read a word. It also encodes a philosophy—show the whole workshop, let people choose their depth, keep the light steady.

Tradeoffs are real. The JS bundle is dedicated to one page. Editors must understand front matter flags. Motion requires testing on touch devices, ultrawide monitors, and reduced-motion settings. That maintenance cost is acceptable for a flagship hub, but it would be hard to justify on every docs page.

If you are considering something similar, ask whether your home page is a map or a billboard. Maps take more work. They also age better.

Next Steps

  • Logo integration when the studio mark is finalized (displayLogo is still off site-wide).
  • Continued tuning of trail/dust balance as content sections grow.
  • Additional technical devlogs for other systems (PixelCAD pipeline, docs layout choices) as they mature.

Written for the Technical devlog series—focused technical notes outside the monthly Nucleate evolution logs.