# Lookout

A Plate Labs website: a one-room stay on a hilltop above a dam, whose page
scrolls through a real day. Seven photographs of the same hill, taken from the
same tripod position between dawn and moonrise, are registered to each other
and crossfaded as you scroll while the camera turns inside them to follow the
sun. Every fact on the page is sample content for a stay that does not exist;
every picture is a real place.

**Lookout is a review sample, not a product page.** Nothing here is measured,
no performance is claimed, and it has not been approved as a Plate deliverable.

## Run it

Serve `site/` over HTTP (the local server at `127.0.0.1:8961` does this) and
open `/labs/lookout/`. ES modules are blocked on `file://`.

## The idea, in one line

The subject is the view, so the page is the view: the day is the scroll, and
each section is one hour of it with one thing to learn and one thing to do.

## The seven beats

| Beat | Time | View (yaw, pitch, fov) | What you see | What you learn | What moves |
| --- | --- | --- | --- | --- | --- |
| **Dawn** | 08:28 | 172, 5, 78 | Pale sky, lake below, the eastern glow | The place, the name, the two actions | The day starts as you scroll |
| **Sunrise** | 08:43 | 181, 0, 60 | Sun on the horizon over the water | Where you are, four facts | The camera turns to meet the sun |
| **Morning** | 12:05 | 242, -14, 74 | Rocks and dry grass, hard light | The room | A floor plan draws itself, labels last |
| **Afternoon** | 18:04 | 302, -5, 72 | Amber grass, the dam to the right | The walks | A walker moves down the elevation profile, minutes counting |
| **Sunset** | 19:09 | 26, 4, 62 | Sun setting over the water | Dinner | The light goes warm and low |
| **Dusk** | 20:06 | 40, 14, 70 | Mauve sky, first town lights | "Then it gets dark." | The camera tilts up |
| **Night** | 21:41 | 179, 11, 68 | The moon over the dam, stars, the town | Price, rules, the date form | The camera turns back east to find the moon |

Times are the real capture times from Poly Haven's metadata (South African
Standard Time). The fixed clock interpolates between them as you scroll.
Yaw 0 faces the sunset frame's centre; yaw 180 faces the sunrise. The day turns
215 degrees clockwise from dawn to sunset and swings back for the moon.

Scrolling is reversible everywhere: the pose, the frame mix and the clock are
pure functions of scroll position in `choreography.js`; nothing integrates over
time.

## Files

| File | Does |
| --- | --- |
| `choreography.js` | The beats: frame, real time, camera pose for wide and portrait, hold length. **The contract to edit.** |
| `index.html` | The page and all its copy. Each section holds its copy panel and a fallback still with real alt text. |
| `style.css` | Type, panels, the drawn plan and profile, portrait layout, reduced motion, no-JS. Tokens at the top. |
| `main.js` | WebGL2/WebGL1 equirect sampler, texture loading, scroll driver, section progress, the form, the QA hook. |
| `assets/qwantani/` | Per frame: a 2048x1024 whole-sphere base, a 16k-scale crop (`-hi`) of the part of the sphere the page looks at on wide screens, an 8k-scale crop (`-mid`) that also covers portrait poses, and `crops.json` with the bounds. `LICENSE.md` has provenance, times and what was changed. |
| `assets/stills/` | One rectilinear still per beat, wide and portrait, for no-WebGL, no-JS and the poster. |
| `assets/tools/grade.py` | The asset pipeline: registration, exposure, tonemap, crop bounds from the choreography, export, stills. Reproducible from the original 16k `.hdr` files, which are CC0 and re-downloadable; the exact commands are in the file header and in `.qa/lookout/src16k-README.md`. They are not kept on disk. |
| `assets/fonts/` | Instrument Serif (OFL), display face, and Inter (OFL), text face. Self-contained: nothing is loaded from outside this folder. |
| `poster.webp` | The sunset beat, 1600x1000. |

## How it was made (the reusable method)

1. **Find the defining asset before writing anything.** The brief was
   "a stay with a view". Poly Haven's Qwantani series is one hill photographed
   thirteen times across a day, CC0. That series *is* the site; no prompt would
   recreate the page without it. Two frames were rejected on inspection
   (the Milky Way frame for haze and a lamp in shot) before any code.
2. **Register, then grade, then compose.** Poly Haven rotates every capture so
   its sun sits at the centre, so the landscape drifts between frames. An edge
   cross-correlation on the horizon band lines them up (`assets/tools/grade.py`).
   Each frame gets its own exposure target so the day reads as one arc rather
   than seven auto-exposed photos. Only then were camera poses chosen, by
   rendering candidate views and looking at them.
3. **One idea per section, tied to the content.** The plan draws itself because
   the section is about the room; the walker moves because the section is
   about walking. Nothing animates for its own sake.
4. **Copy on the quiet part of the picture.** The hero sits on the sky, the
   night panel on the dark hill; the camera was tilted to make that room. Where
   the picture is busy the panel carries a blurred paper or ink ground.
5. **Wide and portrait are framed separately.** Portrait uses a vertical field
   of view and its own yaw/pitch per beat, so the horizon and the subject land
   where the copy is not.
6. **Everything degrades on purpose.** Reduced motion cuts between beats with
   no interpolation and a static clock. No WebGL shows the still for each beat.
   No JavaScript reads as a plain document with the plan and profile drawn.
7. **Look at the real result, at every beat, at both sizes**, with a harness
   that poses the page through `window.lookoutPreview` and screenshots it
   (`.qa/lookout/capture.py`), then fix what the screenshots show. The first
   pass found a black canvas, an index over the headline, a panel that never
   reached the bottom and a rotation sign flipped between the Python renderer
   and the shader. Ohad's own screenshot then found the softness that led to
   the per-pose crops: check pixels at 1:1 on a real screen, not only layout.
8. **Keep authoring sources out of the runtime.** The 16k sources are 2.6 GB
   and live outside the site tree; the page folder is 20 MB, of which the
   visitor on a wide screen downloads about 14 MB across the whole day and a
   phone about 5 MB.

## Behaviour

- **Scrolling is never intercepted.** No wheel or touch handlers, no pinning
  library. The page is a tall document; panels are `position: sticky`.
- **Rendering is on demand.** A frame is drawn when the eased beat position
  changes, and stops when it settles or the tab is hidden. The drawing buffer is not preserved (`?preserve=1`
  turns that on for testing); the page redraws on every scroll frame and once
  more when it settles.
- **First paint.** The poster is a 2560-wide still of the dawn view. The
  canvas is revealed only once the dawn base and its sharp crop are on the
  GPU (about half a second on a local server), or after four seconds on a
  slow connection, so the soft base never shows as the first picture.
- **Textures.** A 78 degree view across a 2000px screen needs about 9000
  source pixels per 360 degrees, more than any whole-sphere texture a page can
  afford. So every frame ships as a small whole-sphere base (2048x1024, about
  150 KB) plus crops of only the sphere region the page looks at while that
  frame is on screen, computed from the choreography (`assets/tools/grade.py`):
  an 8k-scale `-mid` crop (1.3 MB at most, 4 MB for all seven) that also
  covers portrait poses and stays on the GPU for the whole visit (about
  25 MB each), so scrolling is always sharp; and on wide screens with a fine
  pointer a 16k-scale `-hi` crop (0.5 to 3.8 MB, 12 MB in all) laid over the
  beat the page has settled on, one at a time. The hi crop ships as four
  pre-cut strip files, decoded off the main thread and uploaded with one
  plain sub-image call per animation frame once the page stops moving; it is
  replaced when the beat changes. The shader samples the sharpest crop that
  covers the view and the base elsewhere. `?quality=hi|mid|base` caps the
  tier; a device whose maximum texture size cannot hold a tier falls to the
  next. Measured with `.qa/lookout/perf.py` under fast wheel scrolling: worst
  frame 48 ms, the same as the page with no crops at all (274 ms in the first
  version, which evicted and re-uploaded crops as the page moved).
  **Changing a camera pose in `choreography.js` can move a view outside its
  crop: rerun `assets/tools/grade.py` after editing poses.**
- **The seam.** The WebGL2 path uses `textureGrad` with derivatives taken from
  whichever of two half-turn-offset parametrisations is continuous, so the
  equirect seam (which runs through the sunrise view) leaves no line.
- **Form.** Client-side only. Empty and one-night requests are refused; a valid
  request confirms and says plainly that nothing was sent.
- `?motion=reduced` forces reduced motion. `?scene=off` forces the no-WebGL
  page for testing.

## QA hook

```js
window.lookoutPreview.setBeat(2.5)     // scroll instantly to a beat position
window.lookoutPreview.getState()       // loaded, pose, beat, time, sizes
```

`.qa/lookout/capture.py` uses this to screenshot every beat and half-beat at
desktop and portrait, plus reduced motion and no-WebGL, and checks real wheel
scrolling both ways, anchors, keyboard order and the form.

## Known limits

- The moon is a blown disc; the moonrise frame has no detail in it at any
  exposure that keeps the stars.
- Nothing is measured: no frame rate, no device testing beyond headless
  Chromium and the in-app browser.

## Licences

- **Qwantani series** (seven HDRIs) — Greg Zaal and Jarod Guest via Poly
  Haven, CC0. See `assets/qwantani/LICENSE.md` for provenance, times and what
  was changed.
- **Instrument Serif** and **Inter** — SIL Open Font License 1.1, both texts in `assets/fonts/LICENSES.md`.
- Everything else in this folder is original work for Plate Labs.
