This page is the color of the sky

zenith
horizon
nadir
sampled live from the camera —

This page has two jobs that are easy to blur together.

The first job is normal publishing. I write markdown, Eleventy builds a static site, GitHub Actions publishes the build output, and the Raspberry Pi pulls that static output and serves it with nginx.

The second job is live color. That part does not go through GitHub. The Pi is also running SkyBox, which looks at the sky, reduces the current capture into a few color values, and writes a tiny generated stylesheet. The HTML stays static, but the color layer is live.

The rough shape is:

markdown -> GitHub -> Eleventy build -> live branch -> Pi/nginx -> browser

SkyBox camera -> /api/colors -> sky-tap.timer -> /sky.css + /sky.json -> browser

That split is the main design decision. The site remains a static garden, so it is cheap and boring to host. The sky color is a small runtime overlay, so it can update every few seconds or minutes without rebuilding the site.

The static page has a live stylesheet

In the base template, the normal CSS is inlined first. Then the page links to /sky.css.

<style>/* site CSS */</style>
<link rel="stylesheet" href="/sky.css" />

That order matters. The site CSS defines the layout and the fallback theme. The generated sky CSS comes later and overrides the color variables.

On a mirror, preview build, or any host that is not the Pi, /sky.css is just the checked-in fallback file. It says, basically, "sky offline" and uses a stable warm light theme. On the Pi, nginx serves the live generated file instead. The URL is the same either way, which keeps the HTML simple.

The live CSS is not a large stylesheet. It is mostly custom properties:

:root {
  color-scheme: light;
  --bg: oklch(...);
  --fg: oklch(...);
  --muted: oklch(...);
  --rule: oklch(...);
  --code-bg: oklch(...);
  --accent: oklch(...);

  --sky-zenith: oklch(...);
  --sky-horizon: oklch(...);
  --sky-nadir: oklch(...);
  --sky-c1: oklch(...);
  --sky-c2: oklch(...);
  --sky-c3: oklch(...);
  --sky-c4: oklch(...);
  --sky-c5: oklch(...);

  --sky-label: "...";
}

The page background, text, links, borders, code blocks, and palette widgets all resolve through those variables. If the generated CSS is fresh, the page takes on the current sky. If it is missing, stale, or unavailable, the fallback still leaves the page readable.

What SkyBox publishes

SkyBox exposes the current color state as JSON at /api/colors. The site-facing copy of that data is /sky.json.

Conceptually, the payload is:

{
  "zenith": { "l": 0.99, "c": 0.01, "h": 310 },
  "horizon": { "l": 0.86, "c": 0.05, "h": 290 },
  "nadir": { "l": 0.50, "c": 0.02, "h": 270 },
  "palette": [
    { "l": 0.91, "c": 0.04, "h": 292 },
    { "l": 0.60, "c": 0.01, "h": 269 }
  ],
  "cct": 6800
}

Those l, c, and h fields are OKLCh:

I am using OKLCh because it behaves more like vision than RGB does. A small change in OKLCh lightness tends to look like a small change in lightness. A small change in RGB might instead produce a surprising hue shift, a contrast problem, or a color that looks like it changed by much more than the numbers suggest.

That matters here because the sky is not choosing one decorative accent. It is driving the whole reading surface.

The theme is derived, not copied

The page does not simply set the background to "the average sky color." That would fail quickly. A bright blue sky would produce unreadable blue-on-blue text. A sunset would be beautiful for about five minutes and then collapse into low-contrast mud.

Instead, SkyBox measures the sky, and sky-tap turns that measurement into a conservative site theme.

The input values are sky-like:

The output values are interface-like:

The important distinction is that the sky controls the hue and mood, while the theme code controls legibility. Text contrast is deliberately bounded. Borders and muted text are generated from the same palette, but they are not allowed to become invisible. The site can become cooler, warmer, lighter, or darker, but it should still function as a page of text.

There is also hysteresis around light and dark mode. The system does not flip to night mode the instant a single reading falls under a threshold, then flip back on the next noisy reading. It has separate "go dark" and "go light" thresholds, so twilight does not cause the theme to chatter.

Right now the thresholds are intentionally simple: below roughly 0.43 sky lightness it treats the scene as dark, and above roughly 0.47 it returns to light. That gap is small, but enough to make the state sticky when the measurements are hovering around the boundary.

CSS and JSON have different jobs

The page uses both /sky.css and /sky.json, but they are not redundant.

/sky.css is for the document-level theme. It loads with the page, participates in normal CSS cascade behavior, and works even if JavaScript does not run. This is what colors the background, text, links, code blocks, and general UI.

/sky.json is for live widgets. The palette component fetches it on load, then polls again about every 30 seconds while the page is open. That lets the swatches and numbers refresh without a full page reload.

That means there are two kinds of freshness:

Eventually I may make the CSS refresh in-place too, but I like the current separation. The document theme is stable while you are reading. The instrument panel can keep ticking.

What updates what

There are three timers involved.

SkyBox captures the sky on an interval. On the Pi right now, the service is running with a capture interval around 30 seconds. Each successful capture updates the in-memory color state behind /api/colors and the latest camera snapshot behind /api/snapshot.

Systemd runs sky-tap.timer, also around every 30 seconds. That timer calls a small script that reads SkyBox's local API and writes /var/lib/skybox-sky/sky.css and /var/lib/skybox-sky/sky.json atomically.

The browser palette widget polls /sky.json about every 30 seconds. That polling is intentionally boring: fetch JSON, update swatches, update labels.

So the full live path is:

camera capture
  -> SkyBox process memory
  -> http://127.0.0.1:8080/api/colors
  -> sky-tap systemd timer
  -> /var/lib/skybox-sky/sky.css
  -> /var/lib/skybox-sky/sky.json
  -> nginx at joeshoop.com
  -> browser

If GitHub is down, the live sky color can still update. If SkyBox is down, the site can still serve the last generated CSS. If both the live sky layer and generated files are missing, the checked-in fallback CSS still keeps the page readable.

That is the reason for the architecture: the dynamic part is allowed to fail small.

What this does not solve yet

This page is not a calibrated color instrument yet. It is a live theme driven by a camera that is being treated more carefully over time.

The next version should use a real light and color sensor as a reference. The camera is good at spatial information: where the sky is, which areas are cloud, how the palette varies across the frame. A sensor is better at absolute measurement: lux, color temperature, and broad spectral response. The useful version is probably both:

camera image -> shape, clouds, relative palette
color/lux sensor -> absolute brightness and color correction

That would let the camera continue to provide the visual field while the sensor corrects the overall brightness and hue. The page would still feel like the sky, but the numbers would stop depending so heavily on camera exposure, lens tint, and the current approximation of the raw pipeline.

For now, the page is a working loop:

  1. Measure the sky.
  2. Reduce it to a small OKLCh palette.
  3. Convert that palette into conservative CSS variables.
  4. Let a static site inherit those variables at runtime.

That is the core of the system. Everything else is calibration.

#meta #skybox