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:
- Orientation — Visitors see the whole studio at a glance: About, Devlogs, Nucleate, Projects & Tools, each as a node on a ring around the center.
- Depth without clutter — Categories expand in place. Subsections appear as child nodes only when you ask for them. The default view stays calm.
- 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.yamldrives the tagline; the title comes from site config). - “What am I working on” button when a section marks itself with
what_am_working_on: truein 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: reducedisables 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.
| Layer | Responsibility |
|---|---|
| Content tree | Sections and _index.md files define what exists |
observatory-graph.html | Walks site.Sections, emits JSON: center, categories, nodes, edges |
observatory.yaml | Presentation only: tagline, navbar label, working-on button text |
observatory-working-on.html | Resolves what_am_working_on: true to a node id |
layouts/index.html | Home-only layout; loads CSS/JS via Hugo Pipes |
observatory.html | Embeds graph JSON in data-graph, empty SVG scaffold |
observatory.js | Parses JSON, mounts SVG nodes/edges, handles motion and input |
observatory.css | Charcoal/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 flagsProduction 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) - Edges →
studio → 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 labelThe 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:
buildGraph()— Places center and categories in polar coordinates; registers edges from JSON.- Dynamic children — Subsections are not in the initial DOM.
syncChildNodes()mounts them when a category expands, positioned withlayoutChildPositions()(arc layout on the parent spoke, inbound gap so labels do not sit on the center line). - Rotation state machine — Phases: idle ramp, coast down (hover brake), hold at 0 RPM, resume after delay. Angular velocity eased, not toggled.
- Canonical vs display coordinates — Nodes store
baseX/baseYin the unrotated frame;displayPosition()applies current angle. Labels counter-rotate so text stays readable. - Trails — Particle pools per node; spawn rate and arc span tied to
rotation.velocity. - Dust — Full-width canvas;
ResizeObserverkeeps layout in sync; wing particle count scales with horizontal margin on ultrawide displays. - Zoom — Spring-damped scroll zoom with focal point tracking;
viewFrameActivecan reframe expanded subgraphs. - Focus tiers —
applyFocus()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.
| Decision | Rationale |
|---|---|
| Brake to hold angle, not snap back | Feels physical; expanding at current orientation respects user context |
| Resume spin after 100ms hover exit | Avoids flicker when crossing nodes |
| Child labels fade during spin | Reduces visual noise; center + category labels stay |
| Opaque node backing while rotating | Prevents trail particles showing through SVG transparency |
| Dust at 91% of graph spin | Parallax depth without seasickness |
| Comets infrequent | Reward attention without carnival energy |
prefers-reduced-motion hard off | Non-negotiable for accessibility |
| Graph from sections, not manual JSON | Single 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 DOM —
createElementNS, viewBox, transforms,preserveAspectRatio - Animation loop discipline —
requestAnimationFrame, 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; setweightfor ring order. - Hide a section —
observatory: falseon that section’s index. - Change tagline or button label —
data/observatory.yaml. - Point “working on” elsewhere — Move
what_am_working_on: trueto the target section’s_index.md. - Tune motion — Constants at top of
assets/js/observatory.js. - Navbar hub name —
hubNavbar.labelindata/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 (
displayLogois 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.