Umbral Dark Sky Lodge & Observatory AI website prompt
A black-field site for a remote lodge and public observatory. Forty-one photographs orbit the headline, a live moon-phase panel computes tonight's darkness window, and a three.js word lattice pins as you scroll. Includes a red-light mode for night use and seeded star charts drawn on canvas.
Now put it in WordPress
You have a site. Now make it yours.
An AI build hands you a front end and stops there. No CMS, no client login, no real ownership: you cannot hand it over, and you cannot change it without going back and re-prompting. UiChemy turns that build into native, editable WordPress you own, in the builder you already use.
Bolt to WordPressClaude to WordPressCodex to WordPressCursor to WordPressLovable to WordPressv0 to WordPressAI to WordPress
Get Started
More prompts
Showing 6 prompts
Frequently asked questions
What does the Umbral Dark Sky Lodge & Observatory prompt build?
A black-field site for a remote lodge and public observatory. Forty-one photographs orbit the headline, a live moon-phase panel computes tonight's darkness window, and a three.js word lattice pins as you scroll. Includes a red-light mode for night use and seeded star charts drawn on canvas.
Which AI tools does the Umbral Dark Sky Lodge & Observatory prompt work with?
It's written for Claude. Paste the whole prompt into Claude and it builds the complete site for you, then bring the result into WordPress with UiChemy. This prompt also has a WordPress version: with the UiChemy MCP connected, Claude builds the page straight into WordPress.
How do I turn the result into a WordPress site?
After the AI builds your site, UiChemy Composer rebuilds it as native, editable WordPress in Elementor or Gutenberg (Bricks coming soon) — you own it and edit it in your builder. See AI to WordPress.
Is the Umbral Dark Sky Lodge & Observatory prompt free?
Yes — it's free to copy, no account needed.
Can I edit the website after it's built?
Yes. Once it's in WordPress with UiChemy, every section is native and editable — no re-prompting, no lock-in.
Copy prompt
Copy code
Coming soon
Copy SKILL.md
Coming soon
Umbral Dark Sky Lodge & Observatory · Section prompt
# MASTER PROMPT — Umbral Dark-Sky Lodge
### Plain HTML, CSS and JavaScript. No WordPress, no page builder, no framework, no build step.
## ▶ HOW TO USE THIS PROMPT
**Paste this whole document into Claude. Nothing to attach.** It carries the complete source of the site plus the address of every image, so Claude can assemble a folder that owns its own assets and hand it back as a zip.
**INSTRUCTION TO CLAUDE — BUILD IT, DO NOT ASK.** Every decision is already made in this document. Do not stop to ask the user questions, do not offer options, and do not ask for confirmation before starting. Write the files, zip them, hand over the archive, then report what you built.
1. Create a folder named `umbral` and write out every file in the appendix below **exactly as written** — do not reformat, rename, shorten, reorder or "improve" anything.
2. **Download the 26 images and 4 fonts into `assets/`.** The code already points at `assets/…` — nothing in it references a remote host. Run this from inside the folder; it reads the ASSET MANIFEST at the end of this document:
```bash
mkdir -p assets
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__fonts-archivo-latin-ext.woff2' -o 'assets/archivo-latin-ext.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__fonts-archivo-latin.woff2' -o 'assets/archivo-latin.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__fonts-martian-mono-latin-ext.woff2' -o 'assets/martian-mono-latin-ext.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__fonts-martian-mono-latin.woff2' -o 'assets/martian-mono-latin.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-01.jpg' -o 'assets/ring-01.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-02.jpg' -o 'assets/ring-02.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-03.jpg' -o 'assets/ring-03.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-04.jpg' -o 'assets/ring-04.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-05.jpg' -o 'assets/ring-05.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-06.jpg' -o 'assets/ring-06.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-07.jpg' -o 'assets/ring-07.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-08.jpg' -o 'assets/ring-08.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-09.jpg' -o 'assets/ring-09.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-10.jpg' -o 'assets/ring-10.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-11.jpg' -o 'assets/ring-11.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-12.jpg' -o 'assets/ring-12.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-13.jpg' -o 'assets/ring-13.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-14.jpg' -o 'assets/ring-14.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-15.jpg' -o 'assets/ring-15.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-16.jpg' -o 'assets/ring-16.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-17.jpg' -o 'assets/ring-17.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-18.jpg' -o 'assets/ring-18.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-19.jpg' -o 'assets/ring-19.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-20.jpg' -o 'assets/ring-20.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-split-dome.jpg' -o 'assets/split-dome.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-01.jpg' -o 'assets/stage-01.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-02.jpg' -o 'assets/stage-02.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-03.jpg' -o 'assets/stage-03.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-04.jpg' -o 'assets/stage-04.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-05.jpg' -o 'assets/stage-05.jpg'
```
Then check every file arrived and none is a zero-byte or HTML error page:
```bash
ls assets | wc -l # expect 30 files
find assets -size -1k # expect no output
```
If any download fails, retry it — the host is occasionally slow. Do **not** fall back to hotlinking the remote URL: the whole point is a folder that works offline.
3. **Zip that folder and give the user the archive.** Its contents:
```
umbral/
index.html
css/style.css
js/charts.js
js/lattice.js
js/main.js
js/sky.js
assets/
archivo-latin-ext.woff2
archivo-latin.woff2
martian-mono-latin-ext.woff2
martian-mono-latin.woff2
… 26 more images
```
```bash
zip -r umbral.zip umbral -x '*.DS_Store'
```
Then hand over `umbral.zip` as a file the user can download, not as text in the chat.
4. Before handing it over, serve the folder and confirm it runs:
```bash
cd umbral && python3 -m http.server 8000 # then open http://localhost:8000
```
Check the page renders, the images load from `assets/` with the network throttled or offline, and scrolling drives the animations.
The finished folder needs no network for its images — they are in `assets/`.
Do not rebuild the design from the description below. The description is there so a person can understand and edit the code. The appendix is the deliverable.
---
## ▶ WHAT YOU GET
Umbral is a fourteen-room lodge and public observatory at 2,410 m on the Mesa Corvina plateau, 94 km from the nearest streetlight.
**10 code files plus an `assets/` folder holding 26 images and 4 fonts, delivered as `umbral.zip`.**
| File | What it is |
|---|---|
| `index.html` | a page, 13 KB |
| `css/style.css` | the stylesheet, 23 KB |
| `js/charts.js` | the script, 3 KB |
| `js/lattice.js` | the script, 13 KB |
| `js/main.js` | the script, 27 KB |
| `js/sky.js` | the script, 8 KB |
| `assets/` | 26 images and 4 fonts, downloaded at build time |
**Sections (3):** `#stay` · `#observe` · `#night`.
---
## ▶ WHERE THE MEDIA COMES FROM
All 30 files (26 images and 4 fonts) live in `assets/` inside the folder, and every reference in the markup, the CSS and the script is a relative `assets/…` path. Once the folder is assembled it owns its own media: no CDN, no media library, no external image host. Open it on a laptop with the wifi off and it still renders.
They are fetched **once, at build time**, from the WordPress media library on `demo.uichemy.com` — the same `umbral__` files the WordPress build of this page uses. That is only how they travel into the folder; nothing in the delivered code points back at that host. Replacing an image means dropping a new file into `assets/` under the same name.
The manifest at the end of this document lists every source URL against the filename it is saved as, so the download step is reproducible and checkable.
---
## ▶ HOW THE MOTION IS PUT TOGETHER
Nearly every visual effect is scroll-driven: 38 GSAP calls and 7 ScrollTrigger attachments.
- Reveals generally run both ways, so scrolling back up replays them in reverse rather than leaving them finished.
- Pinned sections own their scroll distance in the markup, so the document height is authored rather than grown by script.
- `prefers-reduced-motion` is honoured: every animated block checks it and falls back to the finished state, so the page stays readable with motion off.
- The numbers are inline — durations, eases, stagger, parallax speeds. There is no config layer and no control panel; it is plain GSAP.
---
## ▶ DESIGN TOKENS
Everything is driven from custom properties at the top of the stylesheet:
`--field` `--white` `--red` `--ink` `--ink-2` `--ink-3` `--line` `--panel` `--shade` `--scrim` `--gut` `--nav-h` `--mono` `--sans`
---
## ▶ WHAT THIS PROMPT DELIBERATELY DOES NOT CONTAIN
- No WordPress, no page builder, and none of the wrapper classes or compatibility CSS a builder needs. The WordPress build is a separate prompt.
- No framework, no TypeScript, no bundler, no `package.json`.
- No backend. Any form here validates and replies in place; it posts nowhere.
- No CMS. The content lives in the files.
- No external image host. The images ship inside the zip; nothing in the code calls out to a media library at runtime.
---
## ASSET MANIFEST — download these into `assets/`
Source is the media library on `demo.uichemy.com`. Save each one under the name in the second column; the code already refers to it by that name.
| Download from | Save as |
|---|---|
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__fonts-archivo-latin-ext.woff2` | `assets/archivo-latin-ext.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__fonts-archivo-latin.woff2` | `assets/archivo-latin.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__fonts-martian-mono-latin-ext.woff2` | `assets/martian-mono-latin-ext.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__fonts-martian-mono-latin.woff2` | `assets/martian-mono-latin.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-01.jpg` | `assets/ring-01.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-02.jpg` | `assets/ring-02.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-03.jpg` | `assets/ring-03.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-04.jpg` | `assets/ring-04.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-05.jpg` | `assets/ring-05.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-06.jpg` | `assets/ring-06.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-07.jpg` | `assets/ring-07.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-08.jpg` | `assets/ring-08.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-09.jpg` | `assets/ring-09.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-10.jpg` | `assets/ring-10.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-11.jpg` | `assets/ring-11.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-12.jpg` | `assets/ring-12.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-13.jpg` | `assets/ring-13.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-14.jpg` | `assets/ring-14.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-15.jpg` | `assets/ring-15.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-16.jpg` | `assets/ring-16.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-17.jpg` | `assets/ring-17.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-18.jpg` | `assets/ring-18.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-19.jpg` | `assets/ring-19.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-ring-20.jpg` | `assets/ring-20.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-split-dome.jpg` | `assets/split-dome.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-01.jpg` | `assets/stage-01.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-02.jpg` | `assets/stage-02.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-03.jpg` | `assets/stage-03.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-04.jpg` | `assets/stage-04.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/umbral__images-stage-05.jpg` | `assets/stage-05.jpg` |
---
# APPENDIX · VERBATIM SOURCE
> **The four vendor libraries load from their CDNs** rather than being transcribed here —
> together they are ~700 KB of minified source, which belongs in a `<script src>`, not in a
> prompt. The versions below are the ones this project pinned (GSAP 3.13, Lenis 1.3,
> three.js r158); `index.html` already points at them. Everything else in the appendix is the
> project's own code, verbatim.
The complete source of every file. **Write them out exactly** — do not change a value, a comment, a class name, or the order of a declaration. Recreate this file layout:
```
index.html
css/style.css
js/charts.js
js/lattice.js
js/main.js
js/sky.js
```
## `index.html`
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Umbral Dark-Sky Lodge</title>
<meta name="description" content="Umbral is a fourteen-room lodge and public observatory at 2,410 m on the Mesa Corvina plateau, 94 km from the nearest streetlight.">
<meta name="theme-color" content="#000000">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23000000'/%3E%3Ccircle cx='32' cy='32' r='19' fill='none' stroke='%23FFFFFF' stroke-width='2'/%3E%3Cpath d='M32 13a19 19 0 0 1 0 38a11 19 0 0 0 0-38z' fill='%23FFFFFF'/%3E%3C/svg%3E">
<script>
/* Restore the visitor's light mode before first paint; storage may be blocked. */
(function () {
var d = document.documentElement;
try { if (localStorage.getItem('umbral-light') === 'red') d.setAttribute('data-light', 'red'); } catch (e) {}
if (/[?&]skip-intro\b/.test(location.search)) d.classList.add('skip-intro');
d.classList.add('js');
setTimeout(function () { d.classList.add('intro-done'); }, 3200);
})();
</script>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<!-- ============ NAV ============ -->
<header class="nav" id="top">
<a class="nav__mark" href="#top" aria-label="Umbral, return to the start">UMBRAL</a>
<nav class="nav__links" aria-label="Primary">
<a href="#stay">Stay</a>
<a href="#observe">Observe</a>
<a href="#night">The night</a>
<a href="#getting-here">Getting here</a>
</nav>
<div class="nav__right">
<button class="redlight" type="button" aria-pressed="false" data-redlight>
<span class="redlight__dot" aria-hidden="true"></span><span class="redlight__txt">Red light</span>
</button>
<a class="btn btn--line nav__book" href="#book">Book a night</a>
<button class="nav__menu" type="button" aria-expanded="false" aria-controls="menu" data-menu>Menu</button>
</div>
</header>
<div class="menu" id="menu" hidden>
<nav aria-label="Mobile">
<a href="#stay">Stay</a>
<a href="#observe">Observe</a>
<a href="#night">The night</a>
<a href="#getting-here">Getting here</a>
<a href="#book">Book a night</a>
</nav>
<p class="mono menu__foot">24.62° S · 70.40° W · Lights out 21:00</p>
</div>
<main id="main">
<!-- ============ 1 · HERO · photo ring ============ -->
<section class="hero" id="stay" aria-label="Umbral, a dark-sky lodge">
<div class="ring" aria-hidden="true">
<div class="ring__spin" data-ring></div>
</div>
<div class="hero__center">
<h1 class="hero__title" aria-label="Sleep under four thousand stars">
<span class="line"><span class="w">Sleep</span> <span class="w">under</span></span>
<span class="line"><span class="w">Four</span> <span class="w">thousand</span></span>
<span class="line"><span class="w w--heavy">Stars</span></span>
</h1>
<p class="mono hero__meta" data-rise>Mesa Corvina · 2,410 m · Bortle 1</p>
</div>
<!-- Signature instrument: computed locally from the visitor's clock -->
<aside class="tonight" aria-labelledby="tonight-h" data-rise>
<div class="tonight__head">
<h2 class="mono tonight__h" id="tonight-h">Tonight at Umbral</h2>
<span class="mono tonight__date" data-t="date"> </span>
</div>
<div class="tonight__moon">
<svg class="moon" viewBox="-50 -50 100 100" role="img" aria-labelledby="moon-t">
<title id="moon-t">Moon phase as seen from the plateau</title>
<circle class="moon__dark" r="46"></circle>
<path class="moon__lit" d="" data-t="lit"></path>
<circle class="moon__rim" r="46"></circle>
</svg>
<div class="tonight__phase">
<p class="tonight__name" data-t="phase"> </p>
<p class="mono tonight__sub"><span data-t="illum"> </span> lit · age <span data-t="age"> </span> d</p>
</div>
</div>
<div class="tonight__dark">
<p class="mono tonight__lbl">True dark</p>
<p class="tonight__win"><span data-t="dusk">--:--</span><svg class="arr" viewBox="0 0 18 8" aria-hidden="true"><path d="M0 4h16M13 1l3 3-3 3" fill="none" stroke="currentColor" stroke-width="1"/></svg><span data-t="dawn">--:--</span></p>
<p class="mono tonight__len" data-t="len"> </p>
</div>
<div class="nightbar" aria-hidden="true">
<span class="nightbar__tw" data-t="tw"></span>
<span class="nightbar__dk" data-t="dk"></span>
<span class="nightbar__now" data-t="now"></span>
<span class="mono nightbar__a">18:00</span><span class="mono nightbar__b">00:00</span><span class="mono nightbar__c">07:00</span>
</div>
<div class="tonight__foot">
<p class="mono tonight__verdict" data-t="verdict"> </p>
<button class="redlight redlight--panel" type="button" aria-pressed="false" data-redlight>
<span class="redlight__dot" aria-hidden="true"></span><span class="redlight__txt">Red light</span>
</button>
</div>
</aside>
<p class="mono hero__cue" data-rise>Scroll <span class="dash" aria-hidden="true"></span> eyes adjust in about 20 minutes</p>
</section>
<!-- ============ 2 · MANIFESTO · justified statement ============ -->
<section class="manifesto" aria-label="Why Umbral is dark">
<h2 class="manifesto__text" data-manifesto aria-label="Ninety-four kilometres from the nearest streetlight, Umbral keeps the plateau dark enough for the Milky Way to cast a shadow on your open hand."></h2>
<dl class="readout">
<div class="readout__cell"><dt class="mono">Altitude</dt><dd><span data-count="2410" data-fmt="comma">2,410</span><small class="mono"> m</small></dd></div>
<div class="readout__cell"><dt class="mono">Clear nights</dt><dd><span data-count="311">311</span><small class="mono"> / yr</small></dd></div>
<div class="readout__cell"><dt class="mono">Sky brightness</dt><dd><span data-count="21.9" data-fmt="dec1">21.9</span><small class="mono"> mag/arcsec²</small></dd></div>
<div class="readout__cell"><dt class="mono">Lights out</dt><dd><span data-count="21" data-fmt="clock">21:00</span></dd></div>
</dl>
</section>
<!-- ============ 3 · SKY VOCABULARY · three.js lattice ============ -->
<section class="lattice" id="observe" aria-label="Words for a dark sky">
<div class="lattice__stage" data-lattice>
<canvas class="lattice__gl" aria-hidden="true"></canvas>
<div class="lattice__fallback" aria-hidden="true">
<span>Airglow</span><span>is real</span><span>look north</span>
</div>
<p class="mono lattice__tag">Sky vocabulary</p>
<div class="lattice__cap">
<span class="lattice__bar" aria-hidden="true"><i data-lat-bar></i></span>
<p class="mono" data-lat-cap aria-live="polite">Airglow — the sky’s own faint green light, visible only where nothing else is</p>
</div>
<div class="lattice__veil" aria-hidden="true"></div>
</div>
</section>
<!-- ============ 4 · THE NIGHT · split dome ============ -->
<section class="split" id="night" aria-label="The night, hour by hour">
<div class="split__stage">
<div class="split__frame">
<div class="split__half split__half--l" data-half="l">
<div class="ph"><img src="assets/split-dome.jpg" width="2800" height="1608" alt="The observatory dome at blue hour with its slit open, the plateau falling away behind it" loading="eager" decoding="async"></div>
</div>
<div class="split__half split__half--r" data-half="r">
<div class="ph"><img src="assets/split-dome.jpg" width="2800" height="1608" alt="" aria-hidden="true" loading="eager" decoding="async"></div>
</div>
<h2 class="split__title" data-split-title><span class="thin">The night,</span> <span class="thin">hour by hour</span></h2>
</div>
<div class="split__flow">
<div class="split__label mono" data-split-label>
<div class="stk" data-stk>
<p><span class="split__time">19:40</span> Blue hour on the terrace</p>
<p><span class="split__time">21:10</span> The slit opens</p>
<p><span class="split__time">23:30</span> Saturn, 60 cm aperture</p>
<p><span class="split__time">01:50</span> The core overhead</p>
<p><span class="split__time">04:10</span> Zodiacal light</p>
</div>
</div>
<div class="split__window" data-split-window>
<div class="ph ph--stack">
<img src="assets/stage-01.jpg" width="600" height="800" alt="Guests on the lodge terrace at blue hour, the salt flats pale below" loading="eager" decoding="async" data-st-img>
<img src="assets/stage-02.jpg" width="600" height="800" alt="The dome slit sliding open against a deep blue sky" loading="eager" decoding="async" data-st-img>
<img src="assets/stage-03.jpg" width="600" height="800" alt="Saturn and its rings through a 60 cm telescope" loading="eager" decoding="async" data-st-img>
<img src="assets/stage-04.jpg" width="600" height="800" alt="The galactic core directly overhead above the plateau" loading="eager" decoding="async" data-st-img>
<img src="assets/stage-05.jpg" width="600" height="800" alt="A faint cone of zodiacal light rising in the east before dawn" loading="eager" decoding="async" data-st-img>
<canvas class="split__wave" aria-hidden="true"></canvas>
</div>
</div>
<div class="split__copy" data-split-copy>
<div class="stk" data-stk>
<p>The last light leaves the salt flats first. Dinner is early, and quiet.</p>
<p>Every lamp in the lodge goes out at nine. The dome turns without a sound.</p>
<p>Rings you could draw with a pencil. Most guests stop talking here.</p>
<p>The galaxy is bright enough to throw a faint shadow. Hold still and look for it.</p>
<p>A pale cone of dust lit by a sun that has not risen yet. Then the sky gives up.</p>
</div>
</div>
<div class="split__detail mono" data-split-detail>
<div class="stk" data-stk>
<p>Bring: a jacket, not a torch</p>
<p>Now: let your eyes catch up</p>
<p>Seeing tonight: steady</p>
<p>Hot cocoa on the terrace until two</p>
<p>Breakfast is at ten. Sleep in.</p>
</div>
</div>
<div class="split__prog" data-split-prog aria-hidden="true">
<span class="split__track"><i data-st-fill></i></span>
<span class="split__ticks"><b></b><b></b><b></b><b></b><b></b></span>
</div>
</div>
</div>
</section>
<!-- ============ 5 · BOOK · tiles on a sine wave ============ -->
<section class="cta" aria-labelledby="cta-h">
<div class="cta__head">
<h2 class="cta__h" id="cta-h" data-rise><span class="thin">Keep a night</span> <span class="thin">for yourself</span></h2>
<p class="mono cta__sub" data-rise>The next three new moons</p>
<div class="cta__row" data-rise>
<ul class="cta__moons" data-newmoons aria-label="Next three new moon dates">
<li class="chip mono"> </li><li class="chip mono"> </li><li class="chip mono"> </li>
</ul>
<a class="cta__go" href="#book" aria-label="Book a night">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h13M13 7l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>
</a>
</div>
</div>
<div class="wave" data-wave aria-hidden="true"></div>
</section>
</main>
<!-- ============ FOOTER ============ -->
<footer class="foot" id="getting-here">
<div class="foot__grid">
<div class="foot__col" id="book">
<p class="mono foot__lbl">Book a night</p>
<p class="foot__big">Rooms open 120 nights ahead. Write with your dates and we’ll hold the darkest ones.</p>
<a class="foot__mail" href="mailto:[email protected]">[email protected]</a>
</div>
<div class="foot__col">
<p class="mono foot__lbl">Getting here</p>
<p class="foot__txt">3 h 40 m by road from the coast airport, the last 31 km on graded gravel. We collect guests from the village of Tres Cruces at 16:00.</p>
<p class="mono foot__coord">24.62° S · 70.40° W · 2,410 m</p>
</div>
<div class="foot__col">
<p class="mono foot__lbl">The house rules</p>
<p class="mono foot__rule">Lights out 21:00 — please arrive before</p>
<ul class="foot__links">
<li><a href="#stay">Stay</a></li>
<li><a href="#observe">Observe</a></li>
<li><a href="#night">The night</a></li>
<li><a href="#top">Up to the zenith ↑</a></li>
</ul>
</div>
</div>
<p class="foot__mark" aria-hidden="true">UMBRAL</p>
<div class="foot__base mono">
<span>© 2026 Umbral</span>
<span>Fourteen rooms · one 60 cm telescope · no streetlights</span>
</div>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/ScrollTrigger.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r158/three.min.js" crossorigin="anonymous"></script>
<script src="js/sky.js"></script>
<script src="js/charts.js"></script>
<script src="js/lattice.js"></script>
<script src="js/main.js"></script>
</body>
</html>
```
## `css/style.css`
```css
/* ==========================================================================
UMBRAL · dark-sky lodge & public observatory
Palette: pure black field, pure white ink, rgba of each.
Red light mode swaps every ink token to #FF1A1A for dark adaptation.
========================================================================== */
/* ---------- Fonts (self-hosted, OFL) ---------- */
@font-face {
font-family: "Archivo"; font-style: normal; font-display: swap;
font-weight: 100 900; font-stretch: 62% 125%;
src: url("../assets/archivo-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Archivo"; font-style: normal; font-display: swap;
font-weight: 100 900; font-stretch: 62% 125%;
src: url("../assets/archivo-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Martian Mono"; font-style: normal; font-display: swap;
font-weight: 100 800; font-stretch: 75% 112.5%;
src: url("../assets/martian-mono-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Martian Mono"; font-style: normal; font-display: swap;
font-weight: 100 800; font-stretch: 75% 112.5%;
src: url("../assets/martian-mono-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* ---------- Tokens ---------- */
@property --ink { syntax: "<color>"; inherits: true; initial-value: #FFFFFF; }
@property --ink-2 { syntax: "<color>"; inherits: true; initial-value: rgba(255,255,255,.62); }
@property --ink-3 { syntax: "<color>"; inherits: true; initial-value: rgba(255,255,255,.38); }
@property --line { syntax: "<color>"; inherits: true; initial-value: rgba(255,255,255,.14); }
@property --panel { syntax: "<color>"; inherits: true; initial-value: rgba(255,255,255,.06); }
:root {
color-scheme: dark;
--field: #000000;
--white: #FFFFFF;
--red: #FF1A1A; /* night-vision accent, red light mode only */
--ink: #FFFFFF;
--ink-2: rgba(255,255,255,.62);
--ink-3: rgba(255,255,255,.38);
--line: rgba(255,255,255,.14);
--panel: rgba(255,255,255,.06);
--shade: rgba(0,0,0,.78);
--scrim: rgba(0,0,0,.94);
--gut: 40px;
--nav-h: 64px;
--mono: "Martian Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
--sans: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
transition: --ink .6s ease, --ink-2 .6s ease, --ink-3 .6s ease, --line .6s ease, --panel .6s ease;
}
:root[data-light="red"] {
--ink: #FF1A1A;
--ink-2: rgba(255,26,26,.66);
--ink-3: rgba(255,26,26,.42);
--line: rgba(255,26,26,.26);
--panel: rgba(255,26,26,.07);
}
/* ---------- Base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { overflow-x: clip; background: var(--field); -webkit-text-size-adjust: 100%; }
body {
margin: 0; background: var(--field); color: var(--ink);
font-family: var(--sans); font-weight: 400; font-stretch: 100%; font-size: 16px; line-height: 1.5;
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
overflow-x: clip;
}
img { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
h1, h2, h3, p, ul, dl, dd { margin: 0; padding: 0; }
ul { list-style: none; }
::selection { background: var(--ink); color: var(--field); }
:focus-visible { outline: 1px solid var(--ink); outline-offset: 3px; }
.mono {
font-family: var(--mono); font-stretch: 87.5%; font-weight: 350;
font-size: 10.5px; line-height: 1.55; letter-spacing: .08em; text-transform: uppercase;
}
.thin { font-weight: 120; font-stretch: 62%; letter-spacing: .02em; text-transform: uppercase; }
.skip { position: fixed; left: 12px; top: -60px; z-index: 100; background: var(--ink); color: var(--field); padding: 8px 12px; }
.skip:focus { top: 12px; }
/* Photographs: red light turns them into red monochrome */
.ph { position: relative; overflow: hidden; isolation: isolate; background: var(--field); }
.ph img, .ph canvas { width: 100%; height: 100%; object-fit: cover; transition: filter .6s ease; }
.ph::after {
content: ""; position: absolute; inset: 0; background: var(--red); opacity: 0;
pointer-events: none; transition: opacity .6s ease; z-index: 3;
}
[data-light="red"] .ph img, [data-light="red"] .ph canvas { filter: grayscale(1) brightness(.8); }
[data-light="red"] .ph::after { opacity: 1; mix-blend-mode: multiply; }
/* ---------- Nav ---------- */
.nav {
position: fixed; inset: 0 0 auto 0; height: var(--nav-h); z-index: 40;
display: flex; align-items: center; justify-content: space-between; gap: 24px;
padding: 0 var(--gut);
}
.nav::before, .nav::after { content: ""; position: absolute; left: 0; right: 0; pointer-events: none; opacity: 0; transition: opacity .3s ease; }
.nav::before { top: 0; bottom: 0; background: var(--scrim); z-index: -1; }
.nav::after { bottom: 0; height: 1px; background: var(--line); }
.nav.is-solid::before, .nav.is-solid::after { opacity: 1; }
.nav__mark { font-weight: 850; font-stretch: 125%; font-size: 17px; letter-spacing: .04em; }
.nav__links { position: absolute; left: 50%; transform: translateX(-50%); display: flex; gap: 34px; font-size: 15px; }
.nav__links a, .foot__links a { position: relative; color: var(--ink); }
.nav__links a::after, .foot__links a::after {
content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 1px; background: currentColor;
transform: scaleX(0); transform-origin: right; transition: transform .45s cubic-bezier(.2,.7,.2,1);
}
.nav__links a:hover::after, .foot__links a:hover::after { transform: scaleX(1); transform-origin: left; }
.nav__right { display: flex; align-items: center; gap: 22px; }
.nav__menu { display: none; font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; font-stretch: 87.5%; }
.btn { display: inline-flex; align-items: center; gap: 10px; white-space: nowrap; }
.btn--line {
height: 36px; padding: 0 16px; border: 1px solid var(--ink-3); font-size: 14px;
transition: background-color .3s ease, color .3s ease, border-color .3s ease;
}
.btn--line:hover { background: var(--ink); color: var(--field); border-color: var(--ink); }
.redlight {
display: inline-flex; align-items: center; gap: 8px; height: 28px;
font-family: var(--mono); font-stretch: 87.5%; font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase;
color: var(--ink-2); transition: color .3s ease;
}
.redlight:hover, .redlight[aria-pressed="true"] { color: var(--ink); }
.redlight__dot {
width: 9px; height: 9px; border-radius: 50%; border: 1px solid currentColor; position: relative;
transition: background-color .3s ease;
}
.redlight[aria-pressed="true"] .redlight__dot { background: var(--ink); }
.menu {
position: fixed; inset: 0; z-index: 39; background: var(--field);
padding: calc(var(--nav-h) + 32px) 16px 28px; display: flex; flex-direction: column; justify-content: space-between;
}
.menu[hidden] { display: none; }
.menu nav { display: flex; flex-direction: column; }
.menu nav a {
font-weight: 120; font-stretch: 62%; text-transform: uppercase; letter-spacing: .02em;
font-size: 13vw; line-height: 1.02; border-bottom: 1px solid var(--line); padding: 6px 0;
}
.menu__foot { color: var(--ink-2); }
/* ---------- 1 · Hero ---------- */
.hero {
position: relative; height: 100vh; height: 100svh; min-height: 640px; overflow: hidden; isolation: isolate;
--D: min(83vw, 132vh);
}
.ring {
position: absolute; left: 50%; top: 15.2vh; width: var(--D); height: var(--D);
margin-left: calc(var(--D) / -2);
}
.ring__spin { position: absolute; inset: 0; will-change: transform; }
.arm { position: absolute; left: 50%; top: 50%; width: 0; height: 0; will-change: transform; }
.card {
position: absolute; left: 0; top: 0;
transform: translate(-50%, calc(var(--D) / -2)); /* outer edge on the circle, card hangs inward */
border: 1px solid var(--line); background: var(--field);
}
.card--p { width: calc(var(--D) * .0627); height: calc(var(--D) * .0831); }
.card--l { width: calc(var(--D) * .0916); height: calc(var(--D) * .0675); }
.card--c { width: calc(var(--D) * .0627); height: calc(var(--D) * .0627); }
.card > .ph { position: absolute; inset: 0; }
.hero__center {
position: absolute; left: 0; right: 0; top: 52%; transform: translateY(-50%);
display: flex; flex-direction: column; align-items: center; text-align: center; z-index: 2; pointer-events: none;
}
.hero__title {
font-weight: 120; font-stretch: 62%; letter-spacing: .02em; text-transform: uppercase;
font-size: 6.2vw; line-height: .94;
}
.hero__title .line { display: block; overflow: hidden; padding: .02em .06em .04em; }
.hero__title .w { display: inline-block; will-change: transform; }
.hero__title .w--heavy { font-weight: 850; font-stretch: 125%; letter-spacing: 0; }
.hero__meta { margin-top: 26px; color: var(--ink-2); }
.hero__cue {
position: absolute; left: 50%; bottom: 40px; transform: translateX(-50%); z-index: 2; color: var(--ink-2);
display: flex; align-items: center; gap: 12px; white-space: nowrap;
}
.dash { display: inline-block; width: 28px; height: 1px; background: currentColor; }
/* Tonight at Umbral — the instrument */
.tonight {
position: absolute; right: var(--gut); bottom: 40px; z-index: 3; width: 340px;
background: var(--shade); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid var(--line); padding: 14px 16px 12px;
}
.tonight__head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
.tonight__h { font-weight: 450; color: var(--ink); }
.tonight__date { color: var(--ink-2); }
.tonight__moon { display: flex; align-items: center; gap: 14px; padding: 12px 0 10px; }
.moon { width: 58px; height: 58px; flex: none; overflow: visible; }
.moon__dark { fill: var(--panel); }
.moon__lit { fill: var(--ink); }
.moon__rim { fill: none; stroke: var(--ink-3); stroke-width: 1; vector-effect: non-scaling-stroke; }
.tonight__name { font-weight: 150; font-stretch: 62%; text-transform: uppercase; letter-spacing: .02em; font-size: 27px; line-height: 1; }
.tonight__sub { color: var(--ink-2); margin-top: 6px; }
.tonight__dark { display: grid; grid-template-columns: auto 1fr; align-items: baseline; column-gap: 12px; padding: 10px 0 2px; border-top: 1px solid var(--line); }
.tonight__lbl { color: var(--ink-2); }
.tonight__win {
font-weight: 850; font-stretch: 125%; font-size: 21px; line-height: 1.1; letter-spacing: .01em;
display: flex; align-items: center; gap: 8px; justify-self: end; font-variant-numeric: tabular-nums;
}
.arr { width: 18px; height: 8px; flex: none; color: var(--ink-2); }
.tonight__len { grid-column: 1 / -1; justify-self: end; color: var(--ink-2); margin-top: 2px; }
.nightbar { position: relative; height: 26px; margin: 8px 0 4px; }
.nightbar::before { content: ""; position: absolute; left: 0; right: 0; top: 4px; height: 6px; background: var(--panel); }
.nightbar__tw, .nightbar__dk, .nightbar__now { position: absolute; top: 4px; height: 6px; left: 0; width: 0; }
.nightbar__tw { background: repeating-linear-gradient(90deg, var(--ink-3) 0 1px, transparent 1px 3px); }
.nightbar__dk { background: var(--ink); }
.nightbar__now { top: 0; height: 14px; width: 1px; background: var(--ink); display: none; }
.nightbar__now.is-on { display: block; }
.nightbar__a, .nightbar__b, .nightbar__c { position: absolute; top: 13px; font-size: 9px; color: var(--ink-3); }
.nightbar__a { left: 0; } .nightbar__b { left: 46.15%; transform: translateX(-50%); } .nightbar__c { right: 0; }
.tonight__foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding-top: 8px; border-top: 1px solid var(--line); }
.tonight__verdict { color: var(--ink); font-size: 10px; line-height: 1.4; flex: 1; }
.tonight__foot .redlight { white-space: nowrap; flex: none; }
/* ---------- 2 · Manifesto ---------- */
.manifesto { position: relative; padding: 20vh var(--gut) 18vh; overflow-x: clip; }
.manifesto__text { font-weight: 400; text-transform: uppercase; }
.mline {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 5.3vw; line-height: .92; white-space: nowrap;
}
.mw { display: inline-flex; align-items: baseline; flex: none; }
.mw__t { display: inline-block; white-space: pre; }
.mw--t { font-weight: 120; font-stretch: 62%; letter-spacing: .02em; }
.mw--h { font-weight: 850; font-stretch: 125%; letter-spacing: 0; }
.mdot { display: inline-block; width: .56em; height: .56em; border-radius: 50%; background: var(--ink); margin-right: .22em; transform: translateY(-.04em); flex: none; }
.js .manifesto:not(.is-static) .mw__t { visibility: hidden; }
.js .manifesto .mw__t.is-on { visibility: visible; }
.js .manifesto:not(.is-static) .mdot { visibility: hidden; }
.js .manifesto .mw.is-on .mdot { visibility: visible; }
.readout { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0 20px; margin-top: 16vh; }
.readout__cell { border-top: 1px solid var(--line); padding-top: 14px; display: flex; flex-direction: column; gap: 18px; }
.readout dt { color: var(--ink-2); }
.readout dd { font-weight: 150; font-stretch: 62%; font-size: clamp(38px, 3.6vw, 60px); line-height: 1; letter-spacing: .01em; font-variant-numeric: tabular-nums; }
.readout dd small { font-size: 10.5px; color: var(--ink-2); letter-spacing: .08em; }
/* ---------- 3 · Lattice ---------- */
.lattice { position: relative; height: 100vh; height: 100svh; }
.lattice__stage { position: relative; height: 100%; overflow: hidden; background: var(--field); isolation: isolate; }
.lattice__gl { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.lattice__fallback {
position: absolute; inset: 0; display: none; flex-direction: column; justify-content: center; align-items: center;
font-size: 4.4vw; line-height: 1.1; text-transform: uppercase;
}
.lattice__fallback span:nth-child(1) { font-weight: 850; font-stretch: 125%; transform: translateX(-6vw); }
.lattice__fallback span:nth-child(2) { font-weight: 120; font-stretch: 62%; }
.lattice__fallback span:nth-child(3) { font-weight: 120; font-stretch: 62%; transform: translateX(6vw); }
.no-gl .lattice__fallback { display: flex; }
.lattice__tag { position: absolute; left: var(--gut); top: calc(var(--nav-h) + 28px); color: var(--ink-2); z-index: 2; }
.lattice__cap { position: absolute; left: var(--gut); bottom: 40px; width: min(520px, calc(100% - 2 * var(--gut))); z-index: 2; }
.lattice__cap p { color: var(--ink); min-height: 3.2em; transition: opacity .35s ease; }
.lattice__cap p.is-swap { opacity: 0; }
.lattice__bar { display: block; height: 1px; background: var(--line); margin-bottom: 12px; position: relative; }
.lattice__bar i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--ink); }
.lattice__veil { position: absolute; inset: 0; background: var(--field); opacity: 0; pointer-events: none; z-index: 4; }
/* ---------- 4 · Split dome ---------- */
.split { position: relative; height: 780vh; margin-top: -50vh; }
.split__stage { position: sticky; top: 0; height: 100vh; overflow: hidden; isolation: isolate; }
.split__frame { position: absolute; left: 20px; right: 20px; top: 76px; bottom: 20px; overflow: hidden; }
.split__half { position: absolute; top: 0; bottom: 0; width: 50%; overflow: hidden; will-change: transform; }
.split__half--l { left: 0; }
.split__half--r { right: 0; }
.split__half .ph { position: absolute; top: 0; height: 100%; width: 200%; }
.split__half--l .ph { left: 0; }
.split__half--r .ph { left: -100%; }
.split__title {
position: absolute; left: 28px; bottom: 22px; z-index: 2; font-size: 5.2vw; line-height: .9;
display: flex; flex-direction: column;
}
.split__flow { position: absolute; inset: 0; pointer-events: none; --ww: 10.42vw; --wh: 13.89vw; }
.split__window {
position: absolute; left: 50%; top: 50%; width: var(--ww); height: var(--wh);
transform: translate(-50%, -50%); clip-path: inset(0 0 100% 0); visibility: hidden;
}
.ph--stack { position: absolute; inset: 0; }
.ph--stack img, .ph--stack canvas { position: absolute; inset: 0; }
.ph--stack img { opacity: 0; }
.ph--stack img:first-child { opacity: 1; }
.split__wave { opacity: 0; z-index: 2; }
.split__label {
position: absolute; left: 50%; top: calc(50% - var(--wh) / 2 - 26px); transform: translateX(-50%);
white-space: nowrap; clip-path: inset(0 0 100% 0); visibility: hidden; color: var(--ink);
}
.split__time { color: var(--ink-2); }
.split__copy, .split__detail { position: absolute; top: calc(50% - var(--wh) / 2 + 2px); width: 250px; opacity: 0; }
.split__copy { right: calc(50% + var(--ww) / 2 + 36px); text-align: right; font-size: 15px; line-height: 1.45; color: var(--ink-2); }
.split__detail { left: calc(50% + var(--ww) / 2 + 36px); color: var(--ink); width: 220px; }
.split__prog {
position: absolute; left: 50%; top: calc(50% + var(--wh) / 2 + 22px); width: var(--ww); transform: translateX(-50%); opacity: 0;
}
.split__track { display: block; height: 1px; background: var(--line); position: relative; }
.split__track i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--ink); }
.split__ticks { position: absolute; left: 0; right: 0; top: -3px; height: 7px; }
.split__ticks b { position: absolute; top: 0; width: 1px; height: 7px; background: var(--ink-3); }
.split__ticks b:nth-child(1) { left: 0; } .split__ticks b:nth-child(2) { left: 20%; } .split__ticks b:nth-child(3) { left: 40%; }
.split__ticks b:nth-child(4) { left: 60%; } .split__ticks b:nth-child(5) { left: 80%; }
.stk { display: grid; }
.stk > * { grid-area: 1 / 1; opacity: 0; }
.stk > *:first-child { opacity: 1; }
.split__label .stk { justify-items: center; }
.split__copy .stk { justify-items: end; }
.split__detail .stk { justify-items: start; }
/* ---------- 5 · Book / sine wave ---------- */
.cta { position: relative; display: block; min-height: 760px; min-height: max(100vh, 760px); overflow: hidden; contain: paint; isolation: isolate; z-index: 0; }
.cta__head {
position: relative; z-index: 5; padding: 14vh var(--gut) 0; text-align: center;
display: flex; flex-direction: column; align-items: center;
}
.cta__h { font-size: 4.6vw; line-height: .95; display: flex; gap: .26em; flex-wrap: wrap; justify-content: center; }
.cta__sub { color: var(--ink-2); margin-top: 16px; }
.cta__row { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.cta__moons { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.chip { border: 1px solid var(--line); padding: 7px 12px 6px; background: var(--shade); color: var(--ink); white-space: nowrap; }
.cta__go {
flex: none; width: 48px; height: 48px; border-radius: 50%; border: 1px solid var(--ink);
display: grid; place-items: center; background: var(--field);
transition: background-color .35s ease, color .35s ease, transform .35s cubic-bezier(.2,.7,.2,1);
}
.cta__go svg { width: 22px; height: 22px; }
.cta__go:hover { background: var(--ink); color: var(--field); transform: scale(1.06); }
.wave { position: absolute; inset: 0; z-index: 1; perspective: 1200px; perspective-origin: 50% 60%; overflow: hidden; contain: paint; }
.wave.is-off { visibility: hidden; }
.tile { position: absolute; left: 0; top: 0; width: 9.4vw; height: 11.5vw; will-change: transform; border: 1px solid var(--line); background: var(--field); }
.tile > .ph { position: absolute; inset: 0; }
/* ---------- Footer ---------- */
.foot { position: relative; padding: 16vh var(--gut) 28px; border-top: 1px solid var(--line); }
.foot__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 40px; }
.foot__lbl { color: var(--ink-2); margin-bottom: 18px; }
.foot__big { font-size: 22px; line-height: 1.3; max-width: 420px; }
.foot__mail { display: inline-block; margin-top: 18px; font-size: 16px; border-bottom: 1px solid var(--ink-3); padding-bottom: 2px; transition: border-color .3s ease; }
.foot__mail:hover { border-color: var(--ink); }
.foot__txt { font-size: 15px; color: var(--ink-2); max-width: 340px; }
.foot__coord { margin-top: 16px; }
.foot__rule { color: var(--ink); }
.foot__links { margin-top: 18px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; }
.foot__links a { display: inline-block; }
.foot__mark {
font-weight: 120; font-stretch: 62%; letter-spacing: .02em; text-transform: uppercase;
font-size: 18vw; line-height: .8; margin: 12vh 0 4vh; white-space: nowrap;
}
.foot__base { display: flex; justify-content: space-between; gap: 20px; color: var(--ink-2); border-top: 1px solid var(--line); padding-top: 16px; }
/* ---------- Intro (eyes adjusting) ---------- */
.js:not(.skip-intro) .hero, .js:not(.skip-intro) .nav { opacity: 0; }
.intro-done .hero, .intro-done .nav { opacity: 1 !important; filter: none !important; }
/* ---------- Responsive ---------- */
@media (max-width: 1100px) {
.nav__links { gap: 24px; }
}
@media (min-width: 768px) and (max-width: 1100px) {
.hero__center { top: 44%; }
}
@media (max-width: 990px) {
.nav__links, .nav__book { display: none; }
.nav__menu { display: inline-block; }
.readout { grid-template-columns: repeat(2, 1fr); gap: 36px 20px; }
.split__copy { width: 200px; font-size: 14px; }
.split__detail { width: 170px; }
.foot__grid { grid-template-columns: 1fr 1fr; }
.foot__grid > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 767px) {
:root { --gut: 16px; --nav-h: 56px; }
.nav__right { gap: 16px; }
.hero { height: auto; min-height: 100svh; --D: 150vw; padding: 0 16px 92px; display: flex; flex-direction: column; align-items: center; }
.ring { top: 78px; -webkit-mask-image: linear-gradient(to bottom, var(--field) 0 56%, transparent 72%); mask-image: linear-gradient(to bottom, var(--field) 0 56%, transparent 72%); }
.card--p { width: calc(var(--D) * .075); height: calc(var(--D) * .1); }
.card--l { width: calc(var(--D) * .1); height: calc(var(--D) * .075); }
.card--c { width: calc(var(--D) * .075); height: calc(var(--D) * .075); }
.hero__center { position: relative; top: auto; transform: none; margin-top: calc(78px + 30vw); }
.hero__title { font-size: 11vw; }
.hero__meta { margin-top: 18px; }
.tonight { position: relative; right: auto; bottom: auto; width: 100%; max-width: 420px; margin-top: 30px; }
.hero__cue { bottom: 32px; }
.manifesto { padding: 14vh 16px 12vh; }
.mline { font-size: 8.4vw; }
.readout { margin-top: 10vh; }
.readout dd { font-size: 40px; }
.lattice__cap p { font-size: 10px; }
.split__frame { left: 12px; right: 12px; top: 64px; bottom: 12px; }
.split__title { left: 16px; bottom: 16px; font-size: 11vw; }
.split__flow { --ww: 46vw; --wh: 61vw; }
.split__window { top: 44%; }
.split__label { top: calc(44% - var(--wh) / 2 - 26px); }
.split__copy, .split__detail { top: calc(44% + var(--wh) / 2 + 42px); left: 16px; right: 16px; width: auto; text-align: left; }
.split__copy { font-size: 14px; }
.split__detail { top: calc(44% + var(--wh) / 2 + 108px); }
.split__prog { top: calc(44% + var(--wh) / 2 + 20px); }
.cta__head { padding-top: 14vh; }
.cta__h { font-size: 11vw; flex-direction: column; gap: 0; }
.cta__row { flex-direction: column; gap: 14px; }
.tile { width: 22vw; height: 27vw; }
.foot { padding-top: 12vh; }
.foot__mark { font-size: 29vw; margin: 9vh 0 3vh; }
.foot__grid { grid-template-columns: 1fr; gap: 36px; }
.foot__base { flex-direction: column; gap: 6px; }
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
:root { transition: none; }
.js:not(.skip-intro) .hero, .js:not(.skip-intro) .nav { opacity: 1; }
.js .manifesto .mw__t { visibility: visible; }
.split { margin-top: 0; }
}
```
## `js/charts.js`
```js
/* Star-chart tiles: black cards with a seeded constellation stick figure and a three-letter tag.
The figures are generated, not traced from any real chart, so no two tags look alike. */
(function () {
'use strict';
var TAGS = ['CRU', 'SCO', 'ORI', 'CAR', 'CEN', 'LYR', 'CYG', 'AQL', 'PEG', 'AND', 'TAU', 'GEM', 'LEO', 'VIR', 'SGR', 'CAP', 'AQR', 'PSC', 'ARI', 'CMA', 'PUP'];
var INK = '#FFFFFF', FIELD = '#000000'; // match :root --ink / --field
function seeded(str) {
var h = 2166136261;
for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return function () { h += 0x6D2B79F5; var t = h; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
/* Build a figure once per tag (unit square coords) so every copy of a tag matches. */
var cache = {};
function figure(tag) {
if (cache[tag]) return cache[tag];
var r = seeded('umbral-' + tag), n = 4 + Math.floor(r() * 3), pts = [], i;
var cx = 0.5 + (r() - 0.5) * 0.1, cy = 0.46 + (r() - 0.5) * 0.1;
for (i = 0; i < n; i++) {
var a = (i / n) * Math.PI * 2 + r() * 1.1, d = 0.12 + r() * 0.26;
pts.push({ x: cx + Math.cos(a) * d, y: cy + Math.sin(a) * d * 0.9, m: 0.35 + r() * 0.65 });
}
// 3–5 connecting segments along a path through the points, plus one branch
var order = pts.map(function (p, k) { return k; }).sort(function (a, b) { return pts[a].x - pts[b].x; });
var segs = [], nseg = Math.min(n - 1, 3 + Math.floor(r() * 3));
for (i = 0; i < nseg - 1; i++) segs.push([order[i], order[i + 1]]);
segs.push([order[Math.floor(r() * (n - 1))], order[n - 1]]);
var dust = [];
for (i = 0; i < 26; i++) dust.push({ x: r(), y: r() * 0.82, m: r() });
var ra = Math.floor(r() * 24), rm = Math.floor(r() * 60), dec = Math.floor(r() * 80) - 60;
return (cache[tag] = { pts: pts, segs: segs, dust: dust, coord: (ra < 10 ? '0' : '') + ra + 'h' + (rm < 10 ? '0' : '') + rm + ' ' + (dec < 0 ? '−' : '+') + Math.abs(dec) + '°' });
}
function draw(canvas, tag, cssW, cssH, dprCap) {
var dpr = Math.min(window.devicePixelRatio || 1, dprCap || 2);
var W = Math.max(2, Math.round(cssW * dpr)), H = Math.max(2, Math.round(cssH * dpr));
canvas.width = W; canvas.height = H;
var ctx = canvas.getContext('2d'); if (!ctx) return;
var f = figure(tag), u = Math.min(W, H), ox = (W - u) / 2, oy = (H - u) / 2 - H * 0.02;
ctx.fillStyle = FIELD; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = INK;
f.dust.forEach(function (p) { ctx.globalAlpha = 0.18 + p.m * 0.3; ctx.fillRect(p.x * W, p.y * H, dpr * 0.8, dpr * 0.8); });
ctx.globalAlpha = 0.9; ctx.strokeStyle = INK; ctx.lineWidth = Math.max(1, dpr * 0.75);
ctx.beginPath();
f.segs.forEach(function (s) { var a = f.pts[s[0]], b = f.pts[s[1]]; ctx.moveTo(ox + a.x * u, oy + a.y * u); ctx.lineTo(ox + b.x * u, oy + b.y * u); });
ctx.stroke();
ctx.globalAlpha = 1;
f.pts.forEach(function (p) {
var rad = (0.9 + p.m * 1.5) * dpr;
ctx.fillStyle = FIELD; ctx.beginPath(); ctx.arc(ox + p.x * u, oy + p.y * u, rad + dpr * 1.2, 0, 6.2832); ctx.fill();
ctx.fillStyle = INK; ctx.beginPath(); ctx.arc(ox + p.x * u, oy + p.y * u, rad, 0, 6.2832); ctx.fill();
});
var fs = Math.max(7 * dpr, Math.round(H * 0.085));
ctx.font = '400 ' + fs + 'px "Martian Mono", ui-monospace, monospace';
ctx.textBaseline = 'alphabetic';
ctx.globalAlpha = 1; ctx.fillText(tag, W * 0.09, H - H * 0.08);
if (W > 60 * dpr) {
ctx.globalAlpha = 0.55; ctx.font = '300 ' + Math.round(fs * 0.72) + 'px "Martian Mono", ui-monospace, monospace';
var tw = ctx.measureText(f.coord).width; ctx.fillText(f.coord, W - W * 0.09 - tw, H - H * 0.08);
}
ctx.globalAlpha = 1;
}
window.UmbralCharts = { TAGS: TAGS, draw: draw };
})();
```
## `js/lattice.js`
```js
/* Sky vocabulary: flat word planes inside a 1px cube lattice.
The page drives it with a 0..1 progress value from a pinned, scrubbed scroll.
Phases (fractions of the pin):
0.00–0.10 three opening words, stacked and offset
0.10–0.25 the field populates, nearest planes first; fog dims with depth
0.25–0.40 field rolls 0 → −28°
0.40–0.60 field rolls −28° → −90°; planes begin to turn
0.60–0.80 planes turn edge-on (→ 88°); the lattice carries the image
0.80–1.00 camera moves forward two cells; the page fades the stage to black
Never throws: returns null when WebGL is unavailable. */
(function () {
'use strict';
var FIELD_WORDS = ['AIRGLOW', 'ZODIACAL LIGHT', 'GEGENSCHEIN', 'PERSEIDS', 'SEEING', 'TRANSPARENCY', 'ZENITH',
'NADIR', 'TERMINATOR', 'MAGELLANIC', 'NEBULA', 'DARK ADAPTED', 'TWILIGHT', 'ECLIPTIC'];
var OPENING = [['AIRGLOW', 'h'], ['IS REAL', 't'], ['LOOK NORTH', 't']];
var INK = '#FFFFFF', RED = '#FF1A1A'; // match :root --white / --red
var D2R = Math.PI / 180;
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function seg(p, a, b) { return clamp((p - a) / (b - a), 0, 1); }
function sineInOut(t) { return -(Math.cos(Math.PI * t) - 1) / 2; }
function rng(seed) { return function () { seed |= 0; seed = seed + 0x6D2B79F5 | 0; var t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
function create(stage, opts) {
if (!window.THREE) return null;
var canvas = stage.querySelector('canvas');
try { // probe quietly before three.js tries (it logs on failure)
var probe = document.createElement('canvas');
var gl = probe.getContext('webgl2') || probe.getContext('webgl');
if (!gl) return null;
var lose = gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext();
} catch (e) { return null; }
var renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });
if (!renderer.getContext()) return null;
} catch (e) { return null; }
var mobile = !!opts.mobile;
var dprCap = mobile ? 1.5 : 2;
var COUNT = mobile ? 30 : 60;
var CELL_VW = mobile ? 0.5 : 0.30; // one lattice cell ≈ 30vw at the front plane
var FONT_VW = mobile ? 0.088 : 0.044; // opening words ≈ 4.4vw (mobile 8.8vw)
var LAYERS = mobile ? 4 : 6; // depth of the field in cells
var FOV = 45, tanH = Math.tan(FOV / 2 * D2R);
renderer.setClearColor(0x000000, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, dprCap));
var aniso = Math.min(8, renderer.capabilities.getMaxAnisotropy ? renderer.capabilities.getMaxAnisotropy() : 1);
var scene = new THREE.Scene();
var fog = new THREE.Fog(0x000000, 1, 10); scene.fog = fog;
var camera = new THREE.PerspectiveCamera(FOV, 1, 0.05, 200);
var roll = new THREE.Group(), tilt = new THREE.Group(), words = new THREE.Group();
scene.add(roll); roll.add(tilt);
/* ---- lattice: 1px lines on integer cell boundaries ---- */
var X = 7, Z0 = -7, Z1 = 1, pts = [], i, j, k;
for (j = -X; j <= X; j++) for (k = Z0; k <= Z1; k++) { pts.push(-X, j, k, X, j, k); } // along x
for (i = -X; i <= X; i++) for (k = Z0; k <= Z1; k++) { pts.push(i, -X, k, i, X, k); } // along y
for (i = -X; i <= X; i++) for (j = -X; j <= X; j++) { pts.push(i, j, Z0, i, j, Z1); } // along z
var lg = new THREE.BufferGeometry(); lg.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
var lineMat = new THREE.LineBasicMaterial({ color: INK, transparent: true, opacity: 0, fog: true, depthWrite: false });
var lattice = new THREE.LineSegments(lg, lineMat);
tilt.add(lattice); tilt.add(words);
/* ---- word textures (drawn once fonts are ready) ---- */
var texCache = {};
function texture(text, style) {
var key = style + text; if (texCache[key]) return texCache[key];
var fs = 128, c = document.createElement('canvas'), ctx = c.getContext('2d');
var font = (style === 'h' ? '850 expanded ' : '140 extra-condensed ') + fs + 'px Archivo, sans-serif';
function setFont() {
ctx.font = font;
try { ctx.fontStretch = style === 'h' ? 'expanded' : 'extra-condensed'; } catch (e) {}
try { ctx.letterSpacing = style === 'h' ? '0px' : Math.round(fs * 0.02) + 'px'; } catch (e) {}
}
setFont();
var padX = Math.round(fs * 0.3), w = Math.ceil(ctx.measureText(text).width + padX * 2), h = Math.round(fs * 1.28);
c.width = w; c.height = h; setFont();
ctx.fillStyle = INK; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, w / 2, h / 2 + fs * 0.05);
var tex = new THREE.CanvasTexture(c);
tex.anisotropy = aniso;
if ('colorSpace' in tex && THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace;
return (texCache[key] = { tex: tex, aspect: w / h, hRatio: h / fs });
}
/* ---- word list (seeded, so the field is the same on every visit) ---- */
var r = rng(4102), planes = [], geo = new THREE.PlaneGeometry(1, 1);
var specs = [];
OPENING.forEach(function (o, n) { specs.push({ text: o[0], style: o[1], opening: n }); });
for (i = 0; i < COUNT - OPENING.length; i++) {
specs.push({ text: FIELD_WORDS[(i * 5 + 3) % FIELD_WORDS.length], style: r() < 0.3 ? 'h' : 't', size: (mobile ? 0.75 : 0.6) + r() * 0.4, seed: r() });
}
var W = 1, H = 1, camZ = 3, upp = 0.001, ready = false, progress = 0, red = opts.red ? 1 : 0;
function build() {
specs.forEach(function (s) {
var t = texture(s.text, s.style);
var mat = new THREE.MeshBasicMaterial({ map: t.tex, transparent: true, depthWrite: false, side: THREE.DoubleSide, fog: true, color: INK, opacity: s.opening !== undefined ? 1 : 0 });
var m = new THREE.Mesh(geo, mat);
m.userData = { s: s, t: t };
words.add(m); planes.push(m);
});
ready = true; layout(); apply(); render();
}
function layout() {
W = stage.clientWidth || window.innerWidth; H = stage.clientHeight || window.innerHeight;
var aspect = W / H, visW = 1 / CELL_VW; // cells across the screen at z = 0
camZ = visW / (2 * tanH * aspect);
upp = visW / W;
camera.aspect = aspect; camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, dprCap));
renderer.setSize(W, H, false);
// fog: nearest words full strength, the farthest layer at ~15%
var near = camZ - 0.6, far6 = camZ + LAYERS - 0.8;
fog.near = near; fog.far = (far6 - 0.15 * near) / 0.85;
if (!ready) return;
var fontPx = FONT_VW * W, lineGap = 1.18, boxes = [], open = null;
// opening words: stacked, each line offset 6vw, kept at 4.4vw on screen
planes.forEach(function (m) {
var s = m.userData.s, t = m.userData.t; if (s.opening === undefined) return;
var z = 0.4, k = (camZ - z) / camZ, hU = fontPx * t.hRatio * upp * k, cap = fontPx * upp * k * lineGap;
m.position.set((s.opening - 1) * 0.06 * W * upp * k, (1 - s.opening) * cap, z);
m.scale.set(hU * t.aspect, hU, 1);
var hw = hU * t.aspect / 2, hh = hU / 2;
open = open ? { x0: Math.min(open.x0, m.position.x - hw), x1: Math.max(open.x1, m.position.x + hw), y0: Math.min(open.y0, m.position.y - hh), y1: Math.max(open.y1, m.position.y + hh) }
: { x0: m.position.x - hw, x1: m.position.x + hw, y0: m.position.y - hh, y1: m.position.y + hh };
});
if (open) { var ko = camZ / (camZ - 0.4); open = { x0: open.x0 * ko, x1: open.x1 * ko, y0: open.y0 * ko, y1: open.y1 * ko }; }
// field words: on lattice layers and rows, no overlaps within a layer, the centre kept clear up front
var pr = rng(911), layerW = [], sumW = 0, L;
for (L = 0; L < LAYERS; L++) { layerW[L] = Math.pow(camZ + L, 1.25); sumW += layerW[L]; }
var placed = [];
planes.forEach(function (m) {
var s = m.userData.s, t = m.userData.t; if (s.opening !== undefined) return;
var hU = fontPx * s.size * t.hRatio * upp, wU = hU * t.aspect, best = null;
for (var tries = 0; tries < 40 && !best; tries++) {
var q = pr() * sumW, layer = 0; while (layer < LAYERS - 1 && q > layerW[layer]) { q -= layerW[layer]; layer++; }
var z = -layer, S = (camZ - z) * tanH * Math.max(aspect, 1) * 1.02;
var x = (pr() * 2 - 1) * S, y = Math.round((pr() * 2 - 1) * S / 0.5) * 0.5 + hU * 0.34;
var bx = { z: z, x0: x - wU / 2 - 0.08, x1: x + wU / 2 + 0.08, y0: y - hU / 2 - 0.04, y1: y + hU / 2 + 0.04 };
var hit = placed.some(function (o) { return o.z === z && o.x0 < bx.x1 && o.x1 > bx.x0 && o.y0 < bx.y1 && o.y1 > bx.y0; });
if (!hit && open && layer <= (mobile ? 1 : 2)) {
var g = (mobile ? 0.1 : 0.35) * (1 + layer); // projected margin around the opening words
var sc = camZ / (camZ - z), px0 = bx.x0 * sc, px1 = bx.x1 * sc, py0 = bx.y0 * sc, py1 = bx.y1 * sc;
hit = px0 < open.x1 + g && px1 > open.x0 - g && py0 < open.y1 + g && py1 > open.y0 - g;
}
if (!hit || tries === 39) best = { x: x, y: y, z: z, box: bx };
}
placed.push(best.box);
s.layer = best.z; m.position.set(best.x, best.y, best.z); m.scale.set(wU, hU, 1);
});
// depth rank drives the fade-in order: nearest first
planes.filter(function (m) { return m.userData.s.opening === undefined; })
.sort(function (a, b) { return b.position.z - a.position.z || Math.abs(a.position.x) - Math.abs(b.position.x); })
.forEach(function (m, n, arr) { m.userData.s.rank = n / Math.max(1, arr.length - 1); });
}
var drift = { x: 0, y: 0 }, camLocal = new THREE.Vector3();
function apply() {
var p = progress;
// lattice: faint in from 0.06, strongest while the words are edge-on
lineMat.opacity = (0.12 * sineInOut(seg(p, 0.06, 0.22)) + 0.05 * sineInOut(seg(p, 0.6, 0.78))) * (1 + 0.8 * red); // red reads darker
// roll
var rz = -28 * sineInOut(seg(p, 0.25, 0.40)) - 62 * sineInOut(seg(p, 0.40, 0.60));
roll.rotation.z = rz * D2R;
var tl = sineInOut(seg(p, 0.08, 0.30));
tilt.rotation.set(0.16 * tl, -0.22 * tl, 0);
// dolly two cells in the last fifth
camera.position.set(drift.x, drift.y, camZ - 2 * sineInOut(seg(p, 0.80, 1.0)));
camera.lookAt(drift.x * 0.4, drift.y * 0.4, -3);
// plane turn about each word's baseline (a screen-space yaw once the field is vertical).
// Each plane turns toward its own edge-on angle to the camera, so words thin to lines.
var f = 0.25 * Math.pow(seg(p, 0.40, 0.60), 2) + 0.72 * sineInOut(seg(p, 0.60, 0.80));
scene.updateMatrixWorld();
camLocal.copy(camera.position); tilt.worldToLocal(camLocal);
planes.forEach(function (m) {
var s = m.userData.s;
if (s.opening === undefined) m.material.opacity = sineInOut(seg(p, 0.10 + 0.10 * s.rank, 0.15 + 0.10 * s.rank));
var vy = camLocal.y - m.position.y, vz = camLocal.z - m.position.z;
m.rotation.x = (Math.abs(vy) < 1e-4 ? Math.PI / 2 : Math.atan(vz / vy)) * f; // smallest turn to edge-on
});
}
function render() { try { renderer.render(scene, camera); } catch (e) {} }
function setColor(t) {
var c = new THREE.Color(INK).lerp(new THREE.Color(RED), t);
lineMat.color.copy(c); planes.forEach(function (m) { m.material.color.copy(c); });
}
/* ---- loop: only while on screen ---- */
var visible = false, still = !!opts.still, t0 = performance.now(), dirty = true;
var io = ('IntersectionObserver' in window) ? new IntersectionObserver(function (e) { visible = e[0].isIntersecting; dirty = true; }, { rootMargin: '100px' }) : null;
if (io) io.observe(stage); else visible = true;
function tick() {
if (!visible || !ready) return;
if (!still) {
var t = (performance.now() - t0) / 1000;
drift.x = Math.sin(t * 0.21) * 0.035; drift.y = Math.cos(t * 0.17) * 0.025; dirty = true;
}
if (dirty) { apply(); render(); dirty = false; }
}
if (window.gsap) gsap.ticker.add(tick);
// textures need the web font; draw once it has loaded (or after a timeout)
var fontsDone = false;
function go() { if (fontsDone) return; fontsDone = true; try { build(); setColor(red); render(); } catch (e) { if (opts.onFail) opts.onFail(e); } }
try {
Promise.all([
document.fonts.load('850 expanded 128px Archivo'),
document.fonts.load('140 extra-condensed 128px Archivo')
]).then(go, go);
setTimeout(go, 2500);
} catch (e) { go(); }
layout();
return {
setProgress: function (p) { progress = clamp(p, 0, 1); dirty = true; if (still) tick(); },
setRed: function (on) {
var o = { t: red }, target = on ? 1 : 0;
if (window.gsap && !still) gsap.to(o, { t: target, duration: 0.6, ease: 'power1.inOut', onUpdate: function () { red = o.t; setColor(red); dirty = true; } });
else { red = target; setColor(red); dirty = true; tick(); }
},
resize: function () { layout(); dirty = true; tick(); },
destroy: function () {
if (window.gsap) gsap.ticker.remove(tick);
if (io) io.disconnect();
planes.forEach(function (m) { m.material.dispose(); });
Object.keys(texCache).forEach(function (k) { texCache[k].tex.dispose(); });
geo.dispose(); lg.dispose(); lineMat.dispose();
try { renderer.dispose(); } catch (e) {}
}
};
}
window.UmbralLattice = { create: create };
})();
```
## `js/main.js`
```js
/* UMBRAL · page choreography
Motion values (measured from reference sections, reproduced here):
- Ring deal-in: proxy angle 0 → 360° over 2.4 s, expo.out; each card rides the proxy until it passes its slot
(i × 360/41 = 8.7805°), then parks at opacity 1. Cards still riding fade by 1/5.5 per slot of distance.
Ring spin: rotation "+=360", 60 s, ease none, repeat −1, started with the deal.
- Title words: from y 100%, 1 s, power3.out, stagger each 0.1. Secondary blocks: from opacity 0, y 5rem, 1 s,
power3.out, ScrollTrigger start "top 90%".
- Manifesto scramble: per word a timeline with delay 0.4 × k / n, one tween of 0.019048 s × 21 (repeat 20)
swapping every letter on each repeat; then the real word. ScrollTrigger start "top 80%", once.
- Lattice: pin end "+=400%" (mobile "+=300%"), scrub 1.
- Split dome: 780vh section, progress p = scrolled / (height − 100vh). Halves ±31.25vw over p 0.049 → 0.231,
sine.inOut; centre window clip wipe p 0.139 → 0.229, sine.inOut; stations swap at p 0.33 / 0.45 / 0.57 / 0.69
(crossfade 0.03); window closes p 0.823 → 0.913; halves return p 0.821 → 1.0, sine.inOut.
- Book tiles: one sine period across −8% → 108% of the viewport, amplitude 22vh, centre 64vh, z ±100px under
perspective 1200px, phase travels one period per 20 s plus scroll velocity. */
(function () {
'use strict';
var d = document, html = d.documentElement, win = window;
var $ = function (s, r) { return (r || d).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || d).querySelectorAll(s)); };
var reduce = !!(win.matchMedia && win.matchMedia('(prefers-reduced-motion: reduce)').matches);
var skipIntro = html.classList.contains('skip-intro') || reduce;
var mq = win.matchMedia('(max-width: 767px)');
function mobile() { return mq.matches; }
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function seg(p, a, b) { return clamp((p - a) / (b - a), 0, 1); }
function isRed() { return html.getAttribute('data-light') === 'red'; }
if (!win.gsap || !win.ScrollTrigger) { html.classList.add('intro-done'); return; }
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.config({ ignoreMobileResize: true });
/* ------------------------------------------------------------------ intro */
var nav = $('.nav'), hero = $('.hero');
var t0 = 0; // delay for hero motion
if (!skipIntro) {
t0 = 0.3;
gsap.fromTo([nav, hero], { opacity: 0, filter: 'blur(6px)' }, {
opacity: 1, filter: 'blur(0px)', duration: 1.5, ease: 'power2.out',
onComplete: function () { gsap.set([nav, hero], { clearProps: 'filter,opacity' }); html.classList.add('intro-done'); }
});
} else { html.classList.add('intro-done'); }
/* ------------------------------------------------------------------ smooth scroll */
var lenis = null;
if (!reduce && typeof win.Lenis === 'function') {
lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add(function (t) { lenis.raf(t * 1000); });
gsap.ticker.lagSmoothing(0);
}
function topOf(el) {
var host = el.parentElement && el.parentElement.classList.contains('pin-spacer') ? el.parentElement : el;
return host.getBoundingClientRect().top + (win.pageYOffset || html.scrollTop);
}
d.addEventListener('click', function (e) {
var a = e.target.closest && e.target.closest('a[href^="#"]');
if (!a) return;
var id = a.getAttribute('href'); if (!id || id.length < 2) return;
var el = id === '#top' ? null : d.getElementById(id.slice(1));
if (id !== '#top' && !el) return;
e.preventDefault(); closeMenu();
var y = el ? topOf(el) : 0;
if (lenis) lenis.scrollTo(y, { duration: 1.6 }); else win.scrollTo({ top: y, behavior: reduce ? 'auto' : 'smooth' });
});
/* ------------------------------------------------------------------ nav: solid once the page moves */
var navSolid = null;
function navState() { var on = (win.pageYOffset || html.scrollTop) > 40; if (on !== navSolid) { navSolid = on; nav.classList.toggle('is-solid', on); } }
win.addEventListener('scroll', navState, { passive: true }); navState();
/* ------------------------------------------------------------------ menu */
var menu = $('#menu'), menuBtn = $('[data-menu]');
function closeMenu() { if (!menu || menu.hidden) return; menu.hidden = true; menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.textContent = 'Menu'; if (lenis) lenis.start(); }
if (menuBtn) menuBtn.addEventListener('click', function () {
var open = menu.hidden; menu.hidden = !open; menuBtn.setAttribute('aria-expanded', String(open)); menuBtn.textContent = open ? 'Close' : 'Menu';
if (lenis) { if (open) lenis.stop(); else lenis.start(); }
});
d.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
/* ------------------------------------------------------------------ red light */
var latticeApi = null;
var themeMeta = $('meta[name="theme-color"]');
function applyLight(on, save) {
if (on) html.setAttribute('data-light', 'red'); else html.removeAttribute('data-light');
$$('[data-redlight]').forEach(function (b) { b.setAttribute('aria-pressed', on ? 'true' : 'false'); });
if (save) { try { localStorage.setItem('umbral-light', on ? 'red' : 'white'); } catch (e) {} }
if (latticeApi) latticeApi.setRed(on);
if (themeMeta) themeMeta.setAttribute('content', '#000000');
}
$$('[data-redlight]').forEach(function (b) { b.addEventListener('click', function () { applyLight(!isRed(), true); }); });
applyLight(isRed(), false);
/* ------------------------------------------------------------------ images: retry once, never show alt text */
function guardImg(img) {
img.addEventListener('error', function onErr() {
if (!img.dataset.retried) { img.dataset.retried = '1'; var s = img.getAttribute('src'); img.src = s + (s.indexOf('?') < 0 ? '?r=1' : '&r=1'); }
else { img.style.visibility = 'hidden'; img.removeEventListener('error', onErr); }
});
}
$$('img').forEach(guardImg);
/* ------------------------------------------------------------------ star charts */
var chartsReady = false;
function drawCharts(root) {
if (!chartsReady || !win.UmbralCharts) return;
$$('canvas[data-tag]', root).forEach(function (c) {
var p = c.parentElement, w = p.clientWidth, h = p.clientHeight;
if (w > 1 && h > 1) UmbralCharts.draw(c, c.getAttribute('data-tag'), w, h, 2);
});
}
function whenFonts(cb) {
var done = false, go = function () { if (!done) { done = true; cb(); } };
try { Promise.all([d.fonts.load('400 10px "Martian Mono"'), d.fonts.load('140 extra-condensed 20px Archivo'), d.fonts.load('850 expanded 20px Archivo')]).then(go, go); } catch (e) { go(); }
setTimeout(go, 3000);
}
var RING_ALT = {
1: 'An astronomer at the eyepiece lit by a dim red torch', 2: 'A guest in silhouette beneath the Milky Way core',
3: 'An adobe guest room with its door open to the night', 4: 'A hand holding a torch covered in red film',
5: 'The telescope pier and counterweights inside the dome', 6: 'Two guests wrapped in blankets on the terrace',
7: 'Star trails circling above the observatory dome', 8: 'Breakfast at dawn with long shadows across the table',
9: 'A planisphere lying on a wool blanket', 10: 'A bunk-room window framing the stars of Orion',
11: 'A guide pointing a green laser at the sky', 12: 'The dome interior with the slit open',
13: 'The plateau road at blue hour', 14: 'Steam rising from a cup of tea in torchlight',
15: 'Panorama of the Mesa Corvina plateau at dusk', 16: 'The Milky Way arching over the lodge',
17: 'The rooftop observation deck under stars', 18: 'A cone of zodiacal light above the horizon',
19: 'A camera on a tripod pointed at the sky', 20: 'The moon rising over the ridge'
};
var PHOTO_ORDER = [1, 15, 2, 3, 16, 4, 5, 17, 6, 7, 18, 8, 9, 19, 10, 11, 20, 12, 13, 14];
function pad2(n) { return (n < 10 ? '0' : '') + n; }
function photoHTML(slot) {
var land = slot > 14;
return '<div class="ph"><img src="images/ring-' + pad2(slot) + '.jpg" width="' + (land ? 1000 : 800) + '" height="' + (land ? 750 : 1000) + '" alt="' + RING_ALT[slot] + '" loading="eager" decoding="async"></div>';
}
function chartHTML(tag) { return '<div class="ph"><canvas data-tag="' + tag + '" aria-hidden="true"></canvas></div>'; }
/* ------------------------------------------------------------------ 1 · hero ring */
var ringEl = $('[data-ring]'), arms = [], cards = [];
var deal = { a: 0 }, dealt = false, spinTween = null;
function buildRing() {
var n = mobile() ? 29 : 41, tags = (win.UmbralCharts && UmbralCharts.TAGS) || ['CRU'];
ringEl.innerHTML = ''; arms = []; cards = [];
for (var i = 0; i < n; i++) {
var arm = d.createElement('div'), card = d.createElement('div');
arm.className = 'arm'; arm.style.zIndex = String(n - i);
if (i % 2 === 0) { card.className = 'card card--c'; card.innerHTML = chartHTML(tags[(i / 2) % tags.length]); }
else { var slot = PHOTO_ORDER[((i - 1) / 2) % PHOTO_ORDER.length]; card.className = 'card ' + (slot > 14 ? 'card--l' : 'card--p'); card.innerHTML = photoHTML(slot); }
arm.appendChild(card); ringEl.appendChild(arm); arms.push(arm); cards.push(card);
}
$$('img', ringEl).forEach(guardImg);
drawCharts(ringEl);
}
function place(theta) {
var n = arms.length, sp = 360 / n, fade = 5.5 * sp;
for (var i = 0; i < n; i++) {
var slot = i * sp, ang, op;
if (theta >= slot - 1e-4) { ang = slot; op = 1; } else { ang = theta; op = Math.max(0, 1 - (slot - theta) / fade); }
arms[i].style.transform = 'rotate(' + ang.toFixed(3) + 'deg)';
cards[i].style.opacity = op.toFixed(3);
}
}
buildRing(); place(reduce ? 360 : 0);
function startDeal() {
if (dealt) return; dealt = true;
if (reduce) { place(360); return; }
gsap.to(deal, { a: 360, duration: 2.4, ease: 'expo.out', delay: t0, onUpdate: function () { place(deal.a); } });
spinTween = gsap.to(ringEl, { rotation: '+=360', duration: 60, ease: 'none', repeat: -1, delay: t0, transformOrigin: '50% 50%' });
}
ScrollTrigger.create({ trigger: hero, start: 'top 80%', once: true, onEnter: startDeal });
// the ring keeps turning only while the hero is on screen
if ('IntersectionObserver' in win) new IntersectionObserver(function (e) {
if (!spinTween) return; if (e[0].isIntersecting) spinTween.resume(); else spinTween.pause();
}).observe(hero);
// title words + secondary blocks
if (!reduce) {
gsap.from('.hero__title .w', { y: '100%', duration: 1, ease: 'power3.out', stagger: { each: 0.1 }, delay: t0 + 0.05 });
$$('[data-rise]').forEach(function (el) {
var inHero = hero.contains(el); // hero blocks play with the load, the rest on scroll
gsap.from(el, {
opacity: 0, y: '5rem', duration: 1, ease: 'power3.out', delay: inHero ? t0 + 0.25 : 0,
scrollTrigger: inHero ? null : { trigger: el, start: 'top 90%', toggleActions: 'play none none none' }
});
});
}
/* ------------------------------------------------------------------ signature: tonight at Umbral */
function t(key) { return $('[data-t="' + key + '"]'); }
function fillTonight() {
if (!win.UmbralSky) return;
var S = UmbralSky, now = Date.now(), T = S.tonight(now);
t('date').textContent = T.eveningLabel;
var lit = t('lit'); lit.setAttribute('d', T.litPath(46)); lit.setAttribute('transform', 'scale(-1,1)'); // as seen from 24.6° S
t('phase').textContent = T.phase;
t('illum').textContent = Math.round(T.moon.illum * 100) + '%';
t('age').textContent = T.moon.age.toFixed(1);
t('dusk').textContent = T.duskTxt; t('dawn').textContent = T.dawnTxt; t('len').textContent = T.lenTxt;
t('verdict').textContent = T.verdict;
var start = T.eveningStartUtc, span = 13 * 3600000;
function pct(ms) { return clamp((ms - start) / span, 0, 1) * 100; }
if (T.sunset && T.sunrise) { t('tw').style.left = pct(T.sunset) + '%'; t('tw').style.width = (pct(T.sunrise) - pct(T.sunset)) + '%'; }
if (T.dusk && T.dawn) { t('dk').style.left = pct(T.dusk) + '%'; t('dk').style.width = (pct(T.dawn) - pct(T.dusk)) + '%'; }
var nowEl = t('now');
if (now > start && now < start + span) { nowEl.style.left = pct(now) + '%'; nowEl.classList.add('is-on'); } else nowEl.classList.remove('is-on');
$('.tonight').setAttribute('aria-description', 'Tonight: ' + T.phase + ', ' + Math.round(T.moon.illum * 100) + ' percent lit. True dark from ' + T.duskTxt + ' to ' + T.dawnTxt + ' lodge time.');
// CTA: next three new moons
var chips = $$('[data-newmoons] .chip');
S.nextNewMoons(now, chips.length).forEach(function (ms, i) { chips[i].textContent = S.dayLabel(ms) + ' · ' + S.hhmm(ms); });
}
try { fillTonight(); } catch (e) {}
setInterval(function () { try { fillTonight(); } catch (e) {} }, 60000);
/* ------------------------------------------------------------------ 2 · manifesto */
var MWORDS = [
{ t: 'NINETY-FOUR', dot: 1, h: 'd' }, { t: 'KILOMETRES' }, { t: 'FROM' },
{ t: 'THE' }, { t: 'NEAREST' }, { t: 'STREETLIGHT' }, { t: 'UMBRAL', h: 1 },
{ t: 'KEEPS' }, { t: 'THE' }, { t: 'PLATEAU' }, { t: 'DARK', h: 1 }, { t: 'ENOUGH' },
{ t: 'FOR' }, { t: 'THE' }, { t: 'MILKY WAY', h: 1 }, { t: 'TO' }, { t: 'CAST' },
{ t: 'A' }, { t: 'SHADOW', h: 1 }, { t: 'ON' }, { t: 'YOUR' }, { t: 'OPEN' }, { t: 'HAND' }
];
var BREAKS_D = [3, 7, 12, 17]; // five lines
var BREAKS_M = [2, 5, 7, 10, 14, 16, 20]; // eight lines (fallback; mobile breaks are computed)
var mText = $('[data-manifesto]'), mLines = [], mPlayed = false, mLayout = '';
var GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
function scramble(s) { return s.replace(/[^\s-]/g, function () { return GLYPHS.charAt((Math.random() * GLYPHS.length) | 0); }); }
function heavy(w, key) { return w.h === 1 || (w.h === 'd' && key === 'd'); }
/* Mobile: choose line breaks that keep every line full (≥ 2 words, ≤ 4), minimising leftover space. */
function computeBreaks() {
var probe = d.createElement('span'); probe.className = 'mline'; probe.style.cssText = 'position:absolute;visibility:hidden;left:0;top:0;justify-content:flex-start';
MWORDS.forEach(function (w) {
var el = d.createElement('span'); el.className = 'mw ' + (heavy(w, 'm') ? 'mw--h' : 'mw--t');
if (w.dot) { var dot = d.createElement('i'); dot.className = 'mdot'; el.appendChild(dot); }
var tx = d.createElement('span'); tx.className = 'mw__t'; tx.textContent = w.t; el.appendChild(tx); probe.appendChild(el);
});
mText.appendChild(probe);
var widths = Array.prototype.map.call(probe.children, function (c) { return c.getBoundingClientRect().width; });
var fs = parseFloat(getComputedStyle(probe).fontSize), Wc = mText.getBoundingClientRect().width, gap = fs * 0.28;
mText.removeChild(probe);
var n = widths.length, best = [0], prev = [-1], j, i, k;
for (j = 1; j <= n; j++) {
best[j] = Infinity; prev[j] = -1;
for (i = j - 2; i >= 0 && j - i <= 4; i--) {
var nat = gap * (j - i - 1); for (k = i; k < j; k++) nat += widths[k];
if (nat > Wc) break;
var c = best[i] + Math.pow(Wc - nat, 2);
if (c < best[j]) { best[j] = c; prev[j] = i; }
}
}
if (!isFinite(best[n])) return BREAKS_M;
var cuts = []; for (j = n; j > 0; j = prev[j]) cuts.unshift(j);
cuts.pop(); return cuts;
}
function buildManifesto() {
var key = mobile() ? 'm' : 'd'; if (key === mLayout) return; mLayout = key;
var breaks = key === 'm' ? computeBreaks() : BREAKS_D, cuts = [0].concat(breaks, [MWORDS.length]);
mText.innerHTML = ''; mLines = [];
for (var l = 0; l < cuts.length - 1; l++) {
var line = d.createElement('span'); line.className = 'mline'; line.setAttribute('aria-hidden', 'true');
var words = [];
for (var i = cuts[l]; i < cuts[l + 1]; i++) {
var w = MWORDS[i], el = d.createElement('span'), tx = d.createElement('span');
el.className = 'mw ' + (heavy(w, key) ? 'mw--h' : 'mw--t');
if (w.dot) { var dot = d.createElement('i'); dot.className = 'mdot'; el.appendChild(dot); }
tx.className = 'mw__t' + (mPlayed || reduce ? ' is-on' : ''); tx.textContent = w.t;
if (mPlayed || reduce) el.classList.add('is-on');
el.appendChild(tx); line.appendChild(el); words.push({ el: tx, t: w.t });
}
mText.appendChild(line); mLines.push(words);
}
}
function playManifesto() {
if (mPlayed) return; mPlayed = true;
mLines.forEach(function (words) {
var n = words.length;
words.forEach(function (w, k) {
var el = w.el, final = w.t;
el.style.width = el.getBoundingClientRect().width + 'px';
var tl = gsap.timeline({ delay: 0.4 * ((k + 1) / n) });
tl.call(function () { el.textContent = scramble(final); el.classList.add('is-on'); el.parentNode.classList.add('is-on'); });
tl.to({}, {
duration: 0.019048, repeat: 20,
onRepeat: function () { el.textContent = scramble(final); },
onComplete: function () { el.textContent = final; el.style.width = ''; }
});
});
});
}
buildManifesto();
if (reduce) $('.manifesto').classList.add('is-static');
else ScrollTrigger.create({ trigger: mText, start: 'top 80%', once: true, onEnter: playManifesto });
// readout count-up
function fmt(v, f) {
if (f === 'comma') return Math.round(v).toLocaleString('en-US');
if (f === 'dec1') return v.toFixed(1);
if (f === 'clock') return pad2(Math.round(v)) + ':00';
return String(Math.round(v));
}
$$('[data-count]').forEach(function (el) {
if (reduce) return;
var to = parseFloat(el.getAttribute('data-count')), f = el.getAttribute('data-fmt'), o = { v: 0 };
el.textContent = fmt(0, f);
ScrollTrigger.create({
trigger: el, start: 'top 90%', once: true,
onEnter: function () { gsap.to(o, { v: to, duration: 0.9, ease: 'power2.out', onUpdate: function () { el.textContent = fmt(o.v, f); } }); }
});
});
/* ------------------------------------------------------------------ 3 · lattice */
var latStage = $('[data-lattice]');
var CAPS = [
'Airglow — the sky’s own faint green light, visible only where nothing else is',
'Transparency — how much starlight the air lets through. Up here, nearly all of it',
'Ecliptic — the sun’s road across the year. The planets never stray far from it',
'Zenith — straight up, through the thinnest air you will ever look through',
'Seeing — how still the air is. Still air, sharp stars',
'Dark adapted — what your eyes become after twenty minutes without a screen'
];
var CAP_AT = [0, 0.10, 0.25, 0.40, 0.60, 0.80];
var capEl = $('[data-lat-cap]'), barEl = $('[data-lat-bar]'), veil = $('.lattice__veil'), capIdx = 0, capTimer = 0;
try {
latticeApi = win.UmbralLattice ? UmbralLattice.create(latStage, { mobile: mobile(), red: isRed(), still: reduce, onFail: function () { html.classList.add('no-gl'); } }) : null;
} catch (e) { latticeApi = null; }
if (!latticeApi) html.classList.add('no-gl');
function latUpdate(p) {
if (latticeApi) latticeApi.setProgress(p);
var idx = 0; for (var i = 0; i < CAP_AT.length; i++) if (p >= CAP_AT[i]) idx = i;
if (idx !== capIdx) {
capIdx = idx; capEl.classList.add('is-swap'); clearTimeout(capTimer);
capTimer = setTimeout(function () { capEl.textContent = CAPS[capIdx]; capEl.classList.remove('is-swap'); }, 220);
}
barEl.style.width = (p * 100).toFixed(2) + '%';
var v = seg(p, 0.90, 1); veil.style.opacity = (v * v).toFixed(3);
}
if (!reduce) {
var lat = { p: 0 };
gsap.to(lat, {
p: 1, ease: 'none', onUpdate: function () { latUpdate(lat.p); },
scrollTrigger: { trigger: '.lattice', start: 'top top', end: function () { return '+=' + (mobile() ? 300 : 400) + '%'; }, pin: true, scrub: 1, invalidateOnRefresh: true }
});
} else { latUpdate(0.3); capEl.textContent = CAPS[2]; }
/* ------------------------------------------------------------------ 4 · split dome */
var split = $('.split');
var L = $('[data-half="l"]'), R = $('[data-half="r"]'), wEl = $('[data-split-window]'), lab = $('[data-split-label]');
var sCopy = $('[data-split-copy]'), sDet = $('[data-split-detail]'), sProg = $('[data-split-prog]'), sTitle = $('[data-split-title]');
var stImgs = $$('[data-st-img]'), stks = $$('.split [data-stk]'), fill = $('[data-st-fill]');
var SWAPS = [0.33, 0.45, 0.57, 0.69], XF = 0.03, STARTS = [0.25, 0.33, 0.45, 0.57, 0.69, 0.82];
function travel() { return win.innerWidth * (mobile() ? 0.44 : 0.3125); }
var stl = gsap.timeline({ paused: true, defaults: { ease: 'none' } });
stl.set({}, {}, 1);
stl.to(sTitle, { opacity: 0, duration: 0.035 }, 0.03);
stl.fromTo(L, { x: 0 }, { x: function () { return -travel(); }, duration: 0.182, ease: 'sine.inOut' }, 0.049);
stl.fromTo(R, { x: 0 }, { x: function () { return travel(); }, duration: 0.182, ease: 'sine.inOut' }, 0.049);
stl.set([wEl, lab], { visibility: 'visible' }, 0.139);
stl.fromTo([wEl, lab], { clipPath: 'inset(0% 0% 100% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 0.09, ease: 'sine.inOut' }, 0.139);
stl.fromTo([sCopy, sDet, sProg], { opacity: 0 }, { opacity: 1, duration: 0.05 }, 0.18);
SWAPS.forEach(function (s, k) {
var a = s - XF / 2;
stl.fromTo(stImgs[k + 1], { opacity: 0 }, { opacity: 1, duration: XF, immediateRender: false }, a);
stks.forEach(function (stk) {
var it = stk.children;
stl.fromTo(it[k], { opacity: 1, y: 0 }, { opacity: 0, y: -6, duration: XF / 2, immediateRender: false }, a);
stl.fromTo(it[k + 1], { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: XF / 2, immediateRender: false }, a + XF / 2);
});
});
stl.fromTo([sCopy, sDet, sProg], { opacity: 1 }, { opacity: 0, duration: 0.04, immediateRender: false }, 0.80);
stl.fromTo([wEl, lab], { clipPath: 'inset(0% 0% 0% 0%)' }, { clipPath: 'inset(0% 0% 100% 0%)', duration: 0.09, ease: 'sine.inOut', immediateRender: false }, 0.823);
stl.set([wEl, lab], { visibility: 'hidden' }, 0.9135);
stl.fromTo(L, { x: function () { return -travel(); } }, { x: 0, duration: 0.179, ease: 'sine.inOut', immediateRender: false }, 0.821);
stl.fromTo(R, { x: function () { return travel(); } }, { x: 0, duration: 0.179, ease: 'sine.inOut', immediateRender: false }, 0.821);
stl.fromTo(sTitle, { opacity: 0 }, { opacity: 1, duration: 0.035, immediateRender: false }, 0.955);
// wave distortion drawn on a small canvas while a station swaps
var wc = $('.split__wave'), wctx = wc && wc.getContext('2d'), wDpr = 1;
function sizeWave() { if (!wc) return; var r = wEl.getBoundingClientRect(); wDpr = Math.min(win.devicePixelRatio || 1, 2); wc.width = Math.max(2, Math.round(r.width * wDpr)); wc.height = Math.max(2, Math.round(r.height * wDpr)); }
function drawWaved(img, alpha, amp, ph) {
var cw = wc.width, ch = wc.height, iw = img.naturalWidth, ih = img.naturalHeight; if (!iw || !ih) return;
var s = Math.max(cw / iw, ch / ih), sw = cw / s, sx = (iw - sw) / 2, sy = (ih - ch / s) / 2, strip = Math.max(2, Math.round(3 * wDpr));
wctx.globalAlpha = alpha;
for (var y = 0; y < ch; y += strip) {
var off = amp * Math.sin((y / ch) * Math.PI * 4 + ph * 7);
try { wctx.drawImage(img, sx, sy + y / s, sw, strip / s, off, y, cw, strip); } catch (e) { return; }
}
wctx.globalAlpha = 1;
}
function splitFrame(p) {
var k = 0; while (k < 4 && p >= STARTS[k + 1]) k++;
var f = clamp((p - STARTS[k]) / (STARTS[k + 1] - STARTS[k]), 0, 1);
if (p < STARTS[0]) { k = 0; f = 0; }
fill.style.width = ((k + f) / 5 * 100).toFixed(2) + '%';
if (!wctx || reduce) return;
var act = -1, tt = 0;
for (var i = 0; i < SWAPS.length; i++) if (p > SWAPS[i] - XF / 2 && p < SWAPS[i] + XF / 2) { act = i; tt = (p - (SWAPS[i] - XF / 2)) / XF; }
if (act < 0) { wc.style.opacity = 0; return; }
wc.style.opacity = 1;
wctx.clearRect(0, 0, wc.width, wc.height);
var amp = Math.sin(Math.PI * tt) * 0.07 * wc.width;
drawWaved(stImgs[act], 1, amp, tt);
drawWaved(stImgs[act + 1], tt, amp, tt + 0.5);
}
sizeWave();
ScrollTrigger.create({
trigger: split, start: 'top top', end: 'bottom bottom', scrub: true, animation: stl, invalidateOnRefresh: true,
onUpdate: function (self) { splitFrame(self.progress); }, onRefresh: function (self) { sizeWave(); splitFrame(self.progress); }
});
/* ------------------------------------------------------------------ 5 · book: tiles on a sine wave */
var cta = $('.cta'), waveEl = $('[data-wave]'), tiles = [], tSize = { w: 0, h: 0 };
var phase = 0, lastT = performance.now(), waveOn = false;
function buildWave() {
var n = mobile() ? 16 : 30, tags = (win.UmbralCharts && UmbralCharts.TAGS) || ['CRU'];
waveEl.innerHTML = ''; tiles = [];
for (var i = 0; i < n; i++) {
var el = d.createElement('div'); el.className = 'tile';
el.innerHTML = i % 2 === 1 ? chartHTML(tags[(i * 5 + 3) % tags.length]) : photoHTML(PHOTO_ORDER[(i / 2 + 7) % PHOTO_ORDER.length]);
waveEl.appendChild(el); tiles.push(el);
}
$$('img', waveEl).forEach(guardImg);
measureTiles(); drawCharts(waveEl); layoutTiles();
}
function measureTiles() { if (tiles[0]) { tSize.w = tiles[0].offsetWidth; tSize.h = tiles[0].offsetHeight; } }
function layoutTiles() {
var n = tiles.length; if (!n) return;
var Wd = win.innerWidth, H = cta.clientHeight, m = mobile();
var x0 = m ? -0.2 : -0.08, span = m ? 1.4 : 1.16, A = (m ? 0.12 : 0.22) * win.innerHeight, cy = 0.64 * H; // centre line at 64% of the section
for (var i = 0; i < n; i++) {
var u = i / (n - 1), ang = 2 * Math.PI * (u - phase);
var x = (x0 + span * u) * Wd, y = cy - A * Math.sin(ang), z = 100 * Math.cos(ang), ry = -9 * Math.cos(ang);
tiles[i].style.transform = 'translate3d(' + (x - tSize.w / 2).toFixed(1) + 'px,' + (y - tSize.h / 2).toFixed(1) + 'px,' + z.toFixed(1) + 'px) rotateY(' + ry.toFixed(2) + 'deg)';
tiles[i].style.zIndex = String(200 + Math.round(z));
}
}
function waveTick() {
var now = performance.now(), dt = Math.min(0.05, (now - lastT) / 1000); lastT = now;
if (!waveOn || reduce) return;
var v = lenis ? lenis.velocity : 0;
phase += dt / 20 + clamp(v, -120, 120) * 0.00006;
layoutTiles();
}
buildWave();
if ('IntersectionObserver' in win) new IntersectionObserver(function (e) { waveOn = e[0].isIntersecting; waveEl.classList.toggle('is-off', !waveOn); lastT = performance.now(); }, { rootMargin: '80px' }).observe(cta);
else waveOn = true;
gsap.ticker.add(waveTick);
/* ------------------------------------------------------------------ fonts, resize */
whenFonts(function () {
chartsReady = true; drawCharts(d);
if (mobile() && !mPlayed) { mLayout = ''; buildManifesto(); }
ScrollTrigger.refresh();
});
var lastMobile = mobile(), rT = 0;
win.addEventListener('resize', function () {
clearTimeout(rT);
rT = setTimeout(function () {
var m = mobile();
if (m !== lastMobile) {
lastMobile = m; buildRing(); place(dealt ? (reduce ? 360 : deal.a) : 0); buildWave(); buildManifesto();
} else { drawCharts(d); measureTiles(); layoutTiles(); }
if (latticeApi) latticeApi.resize();
ScrollTrigger.refresh();
}, 180);
});
win.addEventListener('load', function () { ScrollTrigger.refresh(); });
})();
```
## `js/sky.js`
```js
/* Umbral sky calculator: moon phase, illumination, astronomical darkness and new moons.
Everything is computed on the visitor's device from their clock; no network.
Lodge: 24.62° S, 70.40° W, local time UTC−3. */
(function () {
'use strict';
var LAT = -24.62, LON = -70.40, TZ = -3; // hours from UTC
var SYNODIC = 29.530588853; // days
var EPOCH_NEW = Date.UTC(2000, 0, 6, 18, 14); // reference new moon, UTC
var RAD = Math.PI / 180, DAY = 86400000;
function jd(ms) { return ms / DAY + 2440587.5; }
function msFromJd(j) { return (j - 2440587.5) * DAY; }
function norm(a) { a %= 360; return a < 0 ? a + 360 : a; }
function s(a) { return Math.sin(a * RAD); }
function c(a) { return Math.cos(a * RAD); }
/* ---- Sun: declination + equation of time (NOAA low-precision series) ---- */
function sunAt(ms) {
var T = (jd(ms) - 2451545) / 36525;
var L0 = norm(280.46646 + T * (36000.76983 + T * 0.0003032));
var M = 357.52911 + T * (35999.05029 - 0.0001537 * T);
var e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T);
var C = s(M) * (1.914602 - T * (0.004817 + 0.000014 * T)) + s(2 * M) * (0.019993 - 0.000101 * T) + s(3 * M) * 0.000289;
var om = 125.04 - 1934.136 * T;
var lam = L0 + C - 0.00569 - 0.00478 * s(om);
var eps0 = 23 + (26 + (21.448 - T * (46.815 + T * (0.00059 - T * 0.001813))) / 60) / 60;
var eps = eps0 + 0.00256 * c(om);
var decl = Math.asin(s(eps) * s(lam)) / RAD;
var y = Math.pow(Math.tan(eps * RAD / 2), 2);
var eot = 4 / RAD * (y * s(2 * L0) - 2 * e * s(M) + 4 * e * y * s(M) * c(2 * L0) - 0.5 * y * y * s(4 * L0) - 1.25 * e * e * s(2 * M));
return { decl: decl, eot: eot };
}
/* UTC ms at which the sun crosses `alt` degrees on the lodge's civil date (y, m, d), evening or morning. */
function sunCross(y, m, d, alt, evening) {
var t = Date.UTC(y, m, d, 12 - TZ, 0); // local noon as a first guess
for (var i = 0; i < 3; i++) { // refine: declination/EoT at the event time
var sp = sunAt(t);
var noon = Date.UTC(y, m, d) + (720 - 4 * LON - sp.eot) * 60000;
var cosH = (s(alt) - s(LAT) * s(sp.decl)) / (c(LAT) * c(sp.decl));
if (cosH > 1 || cosH < -1) return null; // never reaches that altitude
var H = Math.acos(cosH) / RAD;
t = noon + (evening ? 1 : -1) * 4 * H * 60000;
}
return t;
}
/* ---- Moon: elongation from the sun (principal terms), gives phase + illumination ---- */
function moonAt(ms) {
var d = jd(ms) - 2451545.0;
var Lm = 218.316 + 13.176396 * d, Mm = 134.963 + 13.064993 * d, F = 93.272 + 13.229350 * d;
var D = 297.850 + 12.190749 * d, Ms = 357.529 + 0.98560028 * d;
var lamM = Lm + 6.289 * s(Mm) + 1.274 * s(2 * D - Mm) + 0.658 * s(2 * D) + 0.214 * s(2 * Mm) - 0.186 * s(Ms) - 0.114 * s(2 * F);
var lamS = 280.459 + 0.98564736 * d + 1.915 * s(Ms) + 0.020 * s(2 * Ms);
var elong = norm(lamM - lamS); // 0 new, 180 full
var illum = (1 - c(elong)) / 2;
// age: days since the previous true new moon
var prev = newMoonBefore(ms);
return { elong: elong, illum: illum, waxing: elong < 180, age: (ms - prev) / DAY, frac: elong / 360 };
}
/* ---- True new moon (lunation k counted from the reference epoch), periodic terms ---- */
function trueNewMoon(k) {
var T = k / 1236.85, T2 = T * T, T3 = T2 * T, T4 = T3 * T;
var JDE = 2451550.09766 + 29.530588861 * k + 0.00015437 * T2 - 0.000000150 * T3 + 0.00000000073 * T4;
var E = 1 - 0.002516 * T - 0.0000074 * T2;
var M = 2.5534 + 29.10535670 * k - 0.0000014 * T2 - 0.00000011 * T3;
var Mp = 201.5643 + 385.81693528 * k + 0.0107582 * T2 + 0.00001238 * T3 - 0.000000058 * T4;
var F = 160.7108 + 390.67050284 * k - 0.0016118 * T2 - 0.00000227 * T3 + 0.000000011 * T4;
var Om = 124.7746 - 1.56375588 * k + 0.0020672 * T2 + 0.00000215 * T3;
var corr = -0.40720 * s(Mp) + 0.17241 * E * s(M) + 0.01608 * s(2 * Mp) + 0.01039 * s(2 * F)
+ 0.00739 * E * s(Mp - M) - 0.00514 * E * s(Mp + M) + 0.00208 * E * E * s(2 * M)
- 0.00111 * s(Mp - 2 * F) - 0.00057 * s(Mp + 2 * F) + 0.00056 * E * s(2 * Mp + M)
- 0.00042 * s(3 * Mp) + 0.00042 * E * s(M + 2 * F) + 0.00038 * E * s(M - 2 * F)
- 0.00024 * E * s(2 * Mp - M) - 0.00017 * s(Om) - 0.00007 * s(Mp + 2 * M)
+ 0.00004 * s(2 * Mp - 2 * F) + 0.00004 * s(3 * M) + 0.00003 * s(Mp + M - 2 * F)
+ 0.00003 * s(2 * Mp + 2 * F) - 0.00003 * s(Mp + M + 2 * F) + 0.00003 * s(Mp - M + 2 * F)
- 0.00002 * s(Mp - M - 2 * F) - 0.00002 * s(3 * Mp + M) + 0.00002 * s(4 * Mp);
return msFromJd(JDE + corr) - 69000; // TT to UTC (ΔT ≈ 69 s)
}
function lunationIndex(ms) { return Math.floor((ms - EPOCH_NEW) / (SYNODIC * DAY)); }
function newMoonBefore(ms) {
var k = lunationIndex(ms) + 1, t;
while ((t = trueNewMoon(k)) > ms) k--;
return t;
}
function nextNewMoons(ms, n) {
var k = lunationIndex(ms) - 1, out = [];
while (out.length < n) { var t = trueNewMoon(k++); if (t > ms) out.push(t); }
return out;
}
/* ---- helpers in lodge time ---- */
function local(ms) { return new Date(ms + TZ * 3600000); } // read with getUTC*
function hhmm(ms) {
var d = local(ms), h = d.getUTCHours(), m = d.getUTCMinutes();
if (d.getUTCSeconds() >= 30) { m++; if (m === 60) { m = 0; h = (h + 1) % 24; } }
return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
}
var DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function dayLabel(ms) { var d = local(ms); return DOW[d.getUTCDay()] + ' ' + d.getUTCDate() + ' ' + MON[d.getUTCMonth()]; }
function phaseName(m) {
var k = m.illum;
if (k < 0.02) return 'New moon';
if (k > 0.98) return 'Full moon';
if (Math.abs(k - 0.5) < 0.04) return m.waxing ? 'First quarter' : 'Last quarter';
if (k < 0.5) return m.waxing ? 'Waxing crescent' : 'Waning crescent';
return m.waxing ? 'Waxing gibbous' : 'Waning gibbous';
}
function verdict(m) {
if (m.illum < 0.25) return 'New moon week — galaxy season';
if (m.illum > 0.75) return 'Bright moon — planets & craters night';
return m.waxing ? 'Waxing moon — darkest after moonset' : 'Waning moon — darkest before moonrise';
}
/* SVG path for the lit part of a disc of radius r. frac: 0 new → 0.5 full → 1 new.
Drawn as seen from the northern sky (waxing lit on the right); the caller mirrors it for the lodge. */
function litPath(frac, r) {
var k = Math.cos(2 * Math.PI * frac), rx = Math.abs(k) * r;
if (frac < 0.5) { // right limb lit
return 'M0,' + (-r) + 'A' + r + ',' + r + ' 0 0,1 0,' + r + 'A' + rx.toFixed(2) + ',' + r + ' 0 0,' + (k > 0 ? 0 : 1) + ' 0,' + (-r) + 'Z';
}
return 'M0,' + (-r) + 'A' + r + ',' + r + ' 0 0,0 0,' + r + 'A' + rx.toFixed(2) + ',' + r + ' 0 0,' + (k > 0 ? 1 : 0) + ' 0,' + (-r) + 'Z';
}
/* Tonight: the night that starts on the lodge's current evening (before noon it is still last night). */
function tonight(nowMs) {
var L = local(nowMs);
var y = L.getUTCFullYear(), mo = L.getUTCMonth(), d = L.getUTCDate();
if (L.getUTCHours() < 12) { var p = new Date(Date.UTC(y, mo, d - 1)); y = p.getUTCFullYear(); mo = p.getUTCMonth(); d = p.getUTCDate(); }
var nx = new Date(Date.UTC(y, mo, d + 1));
var dusk = sunCross(y, mo, d, -18, true);
var dawn = sunCross(nx.getUTCFullYear(), nx.getUTCMonth(), nx.getUTCDate(), -18, false);
var sunset = sunCross(y, mo, d, -0.833, true);
var sunrise = sunCross(nx.getUTCFullYear(), nx.getUTCMonth(), nx.getUTCDate(), -0.833, false);
var mid = dusk && dawn ? (dusk + dawn) / 2 : Date.UTC(y, mo, d, 24 - TZ);
var moon = moonAt(mid); // the moon in the middle of tonight's dark
var len = dusk && dawn ? Math.round((dawn - dusk) / 60000) : 0;
return {
eveningLabel: dayLabel(Date.UTC(y, mo, d, 12 - TZ)),
dusk: dusk, dawn: dawn, sunset: sunset, sunrise: sunrise,
duskTxt: dusk ? hhmm(dusk) : '--:--', dawnTxt: dawn ? hhmm(dawn) : '--:--',
lenTxt: Math.floor(len / 60) + ' h ' + (len % 60) + ' m of true dark',
moon: moon, phase: phaseName(moon), verdict: verdict(moon),
litPath: function (r) { return litPath(moon.frac, r); },
eveningStartUtc: Date.UTC(y, mo, d, 18 - TZ) // 18:00 lodge time, for the night bar
};
}
window.UmbralSky = {
tonight: tonight, nextNewMoons: nextNewMoons, dayLabel: dayLabel, hhmm: hhmm,
moonAt: moonAt, litPath: litPath, SYNODIC: SYNODIC, EPOCH_NEW: EPOCH_NEW, TZ: TZ
};
})();
```